feat: CLI modules for all 25+ service clients

One cli.rs per SDK module, gated behind #[cfg(feature = "cli")]:
- auth (identity + hydra): identity, session, recovery, schema,
  courier, health, client, jwk, issuer, token, SSO passthrough
- vcs (gitea): repo, issue, pr, branch, org, user, file, notification
- chat (matrix): room, message, state, profile, device, user, sync
- search (opensearch): doc, query, count, index, cluster, node,
  ingest pipeline, snapshot
- storage (s3): bucket, object
- media (livekit): room, participant, egress, token
- mon (prometheus, loki, grafana): queries, dashboards, datasources,
  folders, annotations, alerts, org
- vault (openbao): status, init, unseal, kv, policy, auth, secrets
- la suite (people, docs, meet, drive, mail, cal, find)

All dispatch functions take (cmd, &SunbeamClient, OutputFormat).
This commit is contained in:
2026-03-21 22:18:58 +00:00
parent 3d7a2d5d34
commit f867805280
18 changed files with 6177 additions and 3 deletions

View File

@@ -0,0 +1,930 @@
//! Gitea VCS CLI — `vcs repo|issue|pr|branch|org|user|file|notification`.
use clap::Subcommand;
use crate::error::{Result, SunbeamError};
use crate::gitea::types::*;
use crate::gitea::GiteaClient;
use crate::output::{render, render_list, read_json_input, OutputFormat};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn split_repo(repo: &str) -> Result<(&str, &str)> {
repo.split_once('/')
.ok_or_else(|| SunbeamError::Other("--repo must be owner/repo".into()))
}
// ---------------------------------------------------------------------------
// Command tree
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum VcsCommand {
/// Repository operations.
Repo {
#[command(subcommand)]
action: RepoAction,
},
/// Issue operations.
Issue {
#[command(subcommand)]
action: IssueAction,
},
/// Pull request operations.
Pr {
#[command(subcommand)]
action: PrAction,
},
/// Branch operations.
Branch {
#[command(subcommand)]
action: BranchAction,
},
/// Organization operations.
Org {
#[command(subcommand)]
action: OrgAction,
},
/// User operations.
User {
#[command(subcommand)]
action: UserAction,
},
/// File operations.
File {
#[command(subcommand)]
action: FileAction,
},
/// Notification operations.
Notification {
#[command(subcommand)]
action: NotificationAction,
},
}
// -- Repo -------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum RepoAction {
/// List repositories for an organization.
List {
/// Organization name.
#[arg(long)]
org: String,
/// Max results.
#[arg(long)]
limit: Option<u32>,
},
/// Search repositories.
Search {
/// Search query.
#[arg(short, long)]
query: String,
/// Max results.
#[arg(long)]
limit: Option<u32>,
},
/// Get a repository.
Get {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
},
/// Create a repository.
Create {
/// Organization (creates org repo). Omit for user repo.
#[arg(long)]
org: Option<String>,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
/// Update a repository.
Update {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
/// Delete a repository.
Delete {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
},
/// Fork a repository.
Fork {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
/// Trigger mirror sync.
MirrorSync {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
},
/// Transfer a repository to another owner.
Transfer {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
}
// -- Issue ------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum IssueAction {
/// List issues.
List {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Filter by state (open, closed, all).
#[arg(long, default_value = "open")]
state: String,
/// Max results.
#[arg(long)]
limit: Option<u32>,
},
/// Get an issue.
Get {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Issue number.
#[arg(short, long)]
id: u64,
},
/// Create an issue.
Create {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
/// Update an issue.
Update {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Issue number.
#[arg(short, long)]
id: u64,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
/// Add a comment to an issue.
Comment {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Issue number.
#[arg(short, long)]
id: u64,
/// Comment body text.
#[arg(long)]
body: String,
},
}
// -- PR ---------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum PrAction {
/// List pull requests.
List {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Filter by state (open, closed, all).
#[arg(long, default_value = "open")]
state: String,
},
/// Get a pull request.
Get {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Pull request number.
#[arg(short, long)]
id: u64,
},
/// Create a pull request.
Create {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
/// Merge a pull request.
Merge {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Pull request number.
#[arg(short, long)]
id: u64,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
}
// -- Branch -----------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum BranchAction {
/// List branches.
List {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
},
/// Create a branch.
Create {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
/// Delete a branch.
Delete {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// Branch name.
#[arg(long)]
branch: String,
},
}
// -- Org --------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum OrgAction {
/// List organizations for a user.
List {
/// Username.
#[arg(long)]
user: String,
},
/// Get an organization.
Get {
/// Organization name.
#[arg(long)]
org: String,
},
/// Create an organization.
Create {
/// JSON body or "-" for stdin.
#[arg(long)]
data: Option<String>,
},
}
// -- User -------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum UserAction {
/// Search users.
Search {
/// Search query.
#[arg(short, long)]
query: String,
/// Max results.
#[arg(long)]
limit: Option<u32>,
},
/// Get a user by username.
Get {
/// Username.
#[arg(long)]
user: String,
},
/// Get the authenticated user.
Me,
}
// -- File -------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum FileAction {
/// Get file content.
Get {
/// Repository (owner/repo).
#[arg(short, long)]
repo: String,
/// File path within the repository.
#[arg(long)]
path: String,
/// Git ref (branch, tag, commit SHA).
#[arg(long, name = "ref")]
git_ref: Option<String>,
},
}
// -- Notification -----------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum NotificationAction {
/// List notifications.
List,
/// Mark all notifications as read.
Read,
}
// ---------------------------------------------------------------------------
// Table row helpers
// ---------------------------------------------------------------------------
fn repo_row(r: &Repository) -> Vec<String> {
vec![
r.full_name.clone(),
if r.private { "private" } else { "public" }.into(),
r.stars_count.to_string(),
r.forks_count.to_string(),
r.default_branch.clone(),
]
}
fn issue_row(i: &Issue) -> Vec<String> {
vec![
format!("#{}", i.number),
i.title.clone(),
i.state.clone(),
i.created_at.clone().unwrap_or_default(),
]
}
fn pr_row(pr: &PullRequest) -> Vec<String> {
vec![
format!("#{}", pr.number),
pr.title.clone(),
pr.state.clone(),
if pr.merged { "yes" } else { "no" }.into(),
]
}
fn branch_row(b: &Branch) -> Vec<String> {
vec![
b.name.clone(),
if b.protected { "yes" } else { "no" }.into(),
b.commit
.as_ref()
.map(|c| c.id.chars().take(8).collect::<String>())
.unwrap_or_default(),
]
}
fn org_row(o: &Organization) -> Vec<String> {
vec![
o.username.clone(),
o.full_name.clone(),
o.visibility.clone(),
]
}
fn user_row(u: &User) -> Vec<String> {
vec![
u.login.clone(),
u.full_name.clone(),
u.email.clone(),
if u.is_admin { "admin" } else { "" }.into(),
]
}
fn notification_row(n: &Notification) -> Vec<String> {
let subject_title = n
.subject
.as_ref()
.map(|s| s.title.clone())
.unwrap_or_default();
let repo_name = n
.repository
.as_ref()
.map(|r| r.full_name.clone())
.unwrap_or_default();
vec![
n.id.to_string(),
repo_name,
subject_title,
if n.unread { "unread" } else { "read" }.into(),
]
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: VcsCommand, client: &GiteaClient, fmt: OutputFormat) -> Result<()> {
match cmd {
// -- Repo -----------------------------------------------------------
VcsCommand::Repo { action } => match action {
RepoAction::List { org, limit } => {
let repos = client.list_org_repos(&org, limit).await?;
render_list(
&repos,
&["NAME", "VISIBILITY", "STARS", "FORKS", "BRANCH"],
repo_row,
fmt,
)
}
RepoAction::Search { query, limit } => {
let result = client.search_repos(&query, limit).await?;
render_list(
&result.data,
&["NAME", "VISIBILITY", "STARS", "FORKS", "BRANCH"],
repo_row,
fmt,
)
}
RepoAction::Get { repo } => {
let (owner, name) = split_repo(&repo)?;
let r = client.get_repo(owner, name).await?;
render(&r, fmt)
}
RepoAction::Create { org, data } => {
let val = read_json_input(data.as_deref())?;
let body: CreateRepoBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid repo body: {e}")))?;
let r = match org {
Some(org) => client.create_org_repo(&org, &body).await?,
None => client.create_user_repo(&body).await?,
};
render(&r, fmt)
}
RepoAction::Update { repo, data } => {
let (owner, name) = split_repo(&repo)?;
let val = read_json_input(data.as_deref())?;
let body: EditRepoBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid repo body: {e}")))?;
let r = client.edit_repo(owner, name, &body).await?;
render(&r, fmt)
}
RepoAction::Delete { repo } => {
let (owner, name) = split_repo(&repo)?;
client.delete_repo(owner, name).await?;
crate::output::ok(&format!("Deleted repository {repo}"));
Ok(())
}
RepoAction::Fork { repo, data } => {
let (owner, name) = split_repo(&repo)?;
let val = data
.as_deref()
.map(|d| read_json_input(Some(d)))
.transpose()?
.unwrap_or_else(|| serde_json::json!({}));
let body: ForkRepoBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid fork body: {e}")))?;
let r = client.fork_repo(owner, name, &body).await?;
render(&r, fmt)
}
RepoAction::MirrorSync { repo } => {
let (owner, name) = split_repo(&repo)?;
client.mirror_sync(owner, name).await?;
crate::output::ok(&format!("Mirror sync triggered for {repo}"));
Ok(())
}
RepoAction::Transfer { repo, data } => {
let (owner, name) = split_repo(&repo)?;
let val = read_json_input(data.as_deref())?;
let body: TransferRepoBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid transfer body: {e}")))?;
let r = client.transfer_repo(owner, name, &body).await?;
render(&r, fmt)
}
},
// -- Issue ----------------------------------------------------------
VcsCommand::Issue { action } => match action {
IssueAction::List { repo, state, limit } => {
let (owner, name) = split_repo(&repo)?;
let issues = client.list_issues(owner, name, &state, limit).await?;
render_list(
&issues,
&["#", "TITLE", "STATE", "CREATED"],
issue_row,
fmt,
)
}
IssueAction::Get { repo, id } => {
let (owner, name) = split_repo(&repo)?;
let issue = client.get_issue(owner, name, id).await?;
render(&issue, fmt)
}
IssueAction::Create { repo, data } => {
let (owner, name) = split_repo(&repo)?;
let val = read_json_input(data.as_deref())?;
let body: CreateIssueBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid issue body: {e}")))?;
let issue = client.create_issue(owner, name, &body).await?;
render(&issue, fmt)
}
IssueAction::Update { repo, id, data } => {
let (owner, name) = split_repo(&repo)?;
let val = read_json_input(data.as_deref())?;
let body: EditIssueBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid issue body: {e}")))?;
let issue = client.edit_issue(owner, name, id, &body).await?;
render(&issue, fmt)
}
IssueAction::Comment { repo, id, body } => {
let (owner, name) = split_repo(&repo)?;
let comment = client.create_issue_comment(owner, name, id, &body).await?;
render(&comment, fmt)
}
},
// -- PR -------------------------------------------------------------
VcsCommand::Pr { action } => match action {
PrAction::List { repo, state } => {
let (owner, name) = split_repo(&repo)?;
let prs = client.list_pulls(owner, name, &state).await?;
render_list(
&prs,
&["#", "TITLE", "STATE", "MERGED"],
pr_row,
fmt,
)
}
PrAction::Get { repo, id } => {
let (owner, name) = split_repo(&repo)?;
let pr = client.get_pull(owner, name, id).await?;
render(&pr, fmt)
}
PrAction::Create { repo, data } => {
let (owner, name) = split_repo(&repo)?;
let val = read_json_input(data.as_deref())?;
let body: CreatePullBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid PR body: {e}")))?;
let pr = client.create_pull(owner, name, &body).await?;
render(&pr, fmt)
}
PrAction::Merge { repo, id, data } => {
let (owner, name) = split_repo(&repo)?;
let val = data
.as_deref()
.map(|d| read_json_input(Some(d)))
.transpose()?
.unwrap_or_else(|| serde_json::json!({"Do": "merge"}));
let body: MergePullBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid merge body: {e}")))?;
client.merge_pull(owner, name, id, &body).await?;
crate::output::ok(&format!("Merged PR #{id} in {repo}"));
Ok(())
}
},
// -- Branch ---------------------------------------------------------
VcsCommand::Branch { action } => match action {
BranchAction::List { repo } => {
let (owner, name) = split_repo(&repo)?;
let branches = client.list_branches(owner, name).await?;
render_list(
&branches,
&["NAME", "PROTECTED", "COMMIT"],
branch_row,
fmt,
)
}
BranchAction::Create { repo, data } => {
let (owner, name) = split_repo(&repo)?;
let val = read_json_input(data.as_deref())?;
let body: CreateBranchBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid branch body: {e}")))?;
let branch = client.create_branch(owner, name, &body).await?;
render(&branch, fmt)
}
BranchAction::Delete { repo, branch } => {
let (owner, name) = split_repo(&repo)?;
client.delete_branch(owner, name, &branch).await?;
crate::output::ok(&format!("Deleted branch {branch} from {repo}"));
Ok(())
}
},
// -- Org ------------------------------------------------------------
VcsCommand::Org { action } => match action {
OrgAction::List { user } => {
let orgs = client.list_user_orgs(&user).await?;
render_list(
&orgs,
&["USERNAME", "FULL NAME", "VISIBILITY"],
org_row,
fmt,
)
}
OrgAction::Get { org } => {
let o = client.get_org(&org).await?;
render(&o, fmt)
}
OrgAction::Create { data } => {
let val = read_json_input(data.as_deref())?;
let body: CreateOrgBody = serde_json::from_value(val)
.map_err(|e| SunbeamError::Other(format!("invalid org body: {e}")))?;
let o = client.create_org(&body).await?;
render(&o, fmt)
}
},
// -- User -----------------------------------------------------------
VcsCommand::User { action } => match action {
UserAction::Search { query, limit } => {
let result = client.search_users(&query, limit).await?;
render_list(
&result.data,
&["LOGIN", "NAME", "EMAIL", "ROLE"],
user_row,
fmt,
)
}
UserAction::Get { user } => {
let u = client.get_user(&user).await?;
render(&u, fmt)
}
UserAction::Me => {
let u = client.get_authenticated_user().await?;
render(&u, fmt)
}
},
// -- File -----------------------------------------------------------
VcsCommand::File { action } => match action {
FileAction::Get { repo, path, git_ref } => {
let (owner, name) = split_repo(&repo)?;
let fc = client
.get_file_content(owner, name, &path, git_ref.as_deref())
.await?;
render(&fc, fmt)
}
},
// -- Notification ---------------------------------------------------
VcsCommand::Notification { action } => match action {
NotificationAction::List => {
let notes = client.list_notifications().await?;
render_list(
&notes,
&["ID", "REPO", "SUBJECT", "STATUS"],
notification_row,
fmt,
)
}
NotificationAction::Read => {
client.mark_notifications_read().await?;
crate::output::ok("All notifications marked as read");
Ok(())
}
},
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_repo_valid() {
let (owner, repo) = split_repo("studio/cli").unwrap();
assert_eq!(owner, "studio");
assert_eq!(repo, "cli");
}
#[test]
fn test_split_repo_invalid() {
assert!(split_repo("noslash").is_err());
}
#[test]
fn test_split_repo_nested() {
// Only splits on first slash
let (owner, repo) = split_repo("org/repo/extra").unwrap();
assert_eq!(owner, "org");
assert_eq!(repo, "repo/extra");
}
#[test]
fn test_repo_row() {
let r = Repository {
full_name: "studio/cli".into(),
private: true,
stars_count: 5,
forks_count: 2,
default_branch: "main".into(),
..Default::default()
};
let row = repo_row(&r);
assert_eq!(row[0], "studio/cli");
assert_eq!(row[1], "private");
assert_eq!(row[2], "5");
assert_eq!(row[3], "2");
assert_eq!(row[4], "main");
}
#[test]
fn test_repo_row_public() {
let r = Repository {
full_name: "studio/web".into(),
private: false,
..Default::default()
};
assert_eq!(repo_row(&r)[1], "public");
}
#[test]
fn test_issue_row() {
let i = Issue {
number: 42,
title: "Fix bug".into(),
state: "open".into(),
created_at: Some("2026-01-15".into()),
body: None,
assignees: None,
labels: None,
updated_at: None,
html_url: None,
repository: None,
milestone: None,
comments: None,
};
let row = issue_row(&i);
assert_eq!(row[0], "#42");
assert_eq!(row[1], "Fix bug");
assert_eq!(row[2], "open");
assert_eq!(row[3], "2026-01-15");
}
#[test]
fn test_pr_row_merged() {
let pr = PullRequest {
number: 7,
title: "Add feature".into(),
state: "closed".into(),
merged: true,
body: None,
head: None,
base: None,
mergeable: None,
html_url: None,
assignees: None,
labels: None,
created_at: None,
updated_at: None,
};
let row = pr_row(&pr);
assert_eq!(row[0], "#7");
assert_eq!(row[3], "yes");
}
#[test]
fn test_pr_row_not_merged() {
let pr = PullRequest {
number: 3,
title: "WIP".into(),
state: "open".into(),
merged: false,
body: None,
head: None,
base: None,
mergeable: None,
html_url: None,
assignees: None,
labels: None,
created_at: None,
updated_at: None,
};
assert_eq!(pr_row(&pr)[3], "no");
}
#[test]
fn test_branch_row() {
let b = Branch {
name: "main".into(),
protected: true,
commit: Some(BranchCommit {
id: "abcdef1234567890".into(),
message: "init".into(),
}),
};
let row = branch_row(&b);
assert_eq!(row[0], "main");
assert_eq!(row[1], "yes");
assert_eq!(row[2], "abcdef12");
}
#[test]
fn test_branch_row_no_commit() {
let b = Branch {
name: "feature".into(),
protected: false,
commit: None,
};
let row = branch_row(&b);
assert_eq!(row[1], "no");
assert_eq!(row[2], "");
}
#[test]
fn test_org_row() {
let o = Organization {
id: 1,
username: "studio".into(),
full_name: "Studio Org".into(),
visibility: "public".into(),
description: String::new(),
avatar_url: String::new(),
};
let row = org_row(&o);
assert_eq!(row[0], "studio");
assert_eq!(row[1], "Studio Org");
assert_eq!(row[2], "public");
}
#[test]
fn test_user_row_admin() {
let u = User {
id: 1,
login: "alice".into(),
full_name: "Alice".into(),
email: "alice@example.com".into(),
is_admin: true,
avatar_url: String::new(),
};
let row = user_row(&u);
assert_eq!(row[0], "alice");
assert_eq!(row[3], "admin");
}
#[test]
fn test_user_row_regular() {
let u = User {
login: "bob".into(),
is_admin: false,
..Default::default()
};
assert_eq!(user_row(&u)[3], "");
}
#[test]
fn test_notification_row() {
let n = Notification {
id: 99,
subject: Some(NotificationSubject {
title: "New issue".into(),
url: String::new(),
r#type: "Issue".into(),
state: None,
}),
repository: Some(Repository {
full_name: "studio/cli".into(),
..Default::default()
}),
unread: true,
updated_at: None,
};
let row = notification_row(&n);
assert_eq!(row[0], "99");
assert_eq!(row[1], "studio/cli");
assert_eq!(row[2], "New issue");
assert_eq!(row[3], "unread");
}
#[test]
fn test_notification_row_read() {
let n = Notification {
id: 1,
subject: None,
repository: None,
unread: false,
updated_at: None,
};
let row = notification_row(&n);
assert_eq!(row[3], "read");
// Missing subject/repo should produce empty strings
assert_eq!(row[1], "");
assert_eq!(row[2], "");
}
}

View File

@@ -2,6 +2,9 @@
pub mod types; pub mod types;
#[cfg(feature = "cli")]
pub mod cli;
use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::client::{AuthMethod, HttpTransport, ServiceClient};
use crate::error::Result; use crate::error::Result;
use k8s_openapi::api::core::v1::Pod; use k8s_openapi::api::core::v1::Pod;

View File

@@ -0,0 +1,726 @@
use clap::Subcommand;
use crate::client::SunbeamClient;
use crate::error::Result;
use crate::output::{self, OutputFormat};
// ---------------------------------------------------------------------------
// Top-level AuthCommand
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum AuthCommand {
/// Identity management (Kratos).
Identity {
#[command(subcommand)]
action: IdentityAction,
},
/// Session management (Kratos).
Session {
#[command(subcommand)]
action: SessionAction,
},
/// Recovery codes and links (Kratos).
Recovery {
#[command(subcommand)]
action: RecoveryAction,
},
/// Identity schemas (Kratos).
Schema {
#[command(subcommand)]
action: SchemaAction,
},
/// Courier messages (Kratos).
Courier {
#[command(subcommand)]
action: CourierAction,
},
/// Health check (Kratos).
Health,
/// OAuth2 client management (Hydra).
Client {
#[command(subcommand)]
action: ClientAction,
},
/// JWK set management (Hydra).
Jwk {
#[command(subcommand)]
action: JwkAction,
},
/// Trusted JWT issuer management (Hydra).
Issuer {
#[command(subcommand)]
action: IssuerAction,
},
/// Token introspection and revocation (Hydra).
Token {
#[command(subcommand)]
action: TokenAction,
},
/// Log in to both SSO and Gitea.
Login {
#[arg(long)]
domain: Option<String>,
},
/// Log in to SSO only.
Sso {
#[arg(long)]
domain: Option<String>,
},
/// Log in to Gitea only.
Git {
#[arg(long)]
domain: Option<String>,
},
/// Log out.
Logout,
/// Show auth status.
Status,
}
// ---------------------------------------------------------------------------
// Identity sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum IdentityAction {
/// List identities.
List {
#[arg(long)]
page: Option<u32>,
#[arg(long, default_value = "20")]
page_size: Option<u32>,
},
/// Get an identity by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a new identity from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update an identity (full replace) from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete an identity.
Delete {
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// Session sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum SessionAction {
/// List sessions.
List {
#[arg(long, default_value = "20")]
page_size: Option<u32>,
#[arg(long)]
page_token: Option<String>,
#[arg(long)]
active: Option<bool>,
},
/// Get a session by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Extend a session.
Extend {
#[arg(short, long)]
id: String,
},
/// Delete (disable) a session.
Delete {
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// Recovery sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum RecoveryAction {
/// Create a recovery code for an identity.
CreateCode {
#[arg(short, long)]
id: String,
/// Duration string (e.g. "24h", "1h30m").
#[arg(long)]
expires_in: Option<String>,
},
/// Create a recovery link for an identity.
CreateLink {
#[arg(short, long)]
id: String,
/// Duration string (e.g. "24h", "1h30m").
#[arg(long)]
expires_in: Option<String>,
},
}
// ---------------------------------------------------------------------------
// Schema sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum SchemaAction {
/// List identity schemas.
List,
/// Get a specific schema by ID.
Get {
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// Courier sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum CourierAction {
/// List courier messages.
List {
#[arg(long, default_value = "20")]
page_size: Option<u32>,
#[arg(long)]
page_token: Option<String>,
},
/// Get a courier message by ID.
Get {
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// Client sub-commands (Hydra)
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum ClientAction {
/// List OAuth2 clients.
List {
#[arg(long, default_value = "20")]
limit: Option<u32>,
#[arg(long)]
offset: Option<u32>,
},
/// Get an OAuth2 client by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create an OAuth2 client from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update an OAuth2 client (full replace) from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete an OAuth2 client.
Delete {
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// JWK sub-commands (Hydra)
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum JwkAction {
/// List keys in a JWK set.
List {
/// Name of the JWK set.
#[arg(short = 'n', long)]
set_name: String,
},
/// Get a specific key from a JWK set.
Get {
#[arg(short = 'n', long)]
set_name: String,
#[arg(short, long)]
kid: String,
},
/// Create a new JWK set from JSON.
Create {
#[arg(short = 'n', long)]
set_name: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a JWK set.
Delete {
#[arg(short = 'n', long)]
set_name: String,
},
}
// ---------------------------------------------------------------------------
// Issuer sub-commands (Hydra)
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum IssuerAction {
/// List trusted JWT issuers.
List,
/// Get a trusted JWT issuer by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a trusted JWT issuer from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a trusted JWT issuer.
Delete {
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// Token sub-commands (Hydra)
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum TokenAction {
/// Introspect a token.
Introspect {
/// The token string to introspect.
#[arg(short = 't', long)]
token: String,
},
/// Delete all tokens for an OAuth2 client.
Delete {
/// The client_id whose tokens should be revoked.
#[arg(long)]
client_id: String,
},
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: AuthCommand,
client: &SunbeamClient,
output: OutputFormat,
) -> Result<()> {
match cmd {
// -- Kratos: Identity ---------------------------------------------------
AuthCommand::Identity { action } => dispatch_identity(action, client, output).await,
// -- Kratos: Session ----------------------------------------------------
AuthCommand::Session { action } => dispatch_session(action, client, output).await,
// -- Kratos: Recovery ---------------------------------------------------
AuthCommand::Recovery { action } => dispatch_recovery(action, client, output).await,
// -- Kratos: Schema -----------------------------------------------------
AuthCommand::Schema { action } => dispatch_schema(action, client, output).await,
// -- Kratos: Courier ----------------------------------------------------
AuthCommand::Courier { action } => dispatch_courier(action, client, output).await,
// -- Kratos: Health -----------------------------------------------------
AuthCommand::Health => {
let status = client.kratos().alive().await?;
output::render(&status, output)
}
// -- Hydra: Client ------------------------------------------------------
AuthCommand::Client { action } => dispatch_client(action, client, output).await,
// -- Hydra: JWK ---------------------------------------------------------
AuthCommand::Jwk { action } => dispatch_jwk(action, client, output).await,
// -- Hydra: Issuer ------------------------------------------------------
AuthCommand::Issuer { action } => dispatch_issuer(action, client, output).await,
// -- Hydra: Token -------------------------------------------------------
AuthCommand::Token { action } => dispatch_token(action, client, output).await,
// -- SSO commands (delegate to existing auth module) --------------------
AuthCommand::Login { domain } => {
crate::auth::cmd_auth_login_all(domain.as_deref()).await
}
AuthCommand::Sso { domain } => {
crate::auth::cmd_auth_sso_login(domain.as_deref()).await
}
AuthCommand::Git { domain } => {
crate::auth::cmd_auth_git_login(domain.as_deref()).await
}
AuthCommand::Logout => crate::auth::cmd_auth_logout().await,
AuthCommand::Status => crate::auth::cmd_auth_status().await,
}
}
// ---------------------------------------------------------------------------
// Identity dispatch
// ---------------------------------------------------------------------------
async fn dispatch_identity(
action: IdentityAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let kratos = client.kratos();
match action {
IdentityAction::List { page, page_size } => {
let items = kratos.list_identities(page, page_size).await?;
output::render_list(
&items,
&["ID", "SCHEMA", "STATE", "CREATED"],
|i| {
vec![
i.id.clone(),
i.schema_id.clone(),
i.state.clone().unwrap_or_default(),
i.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
IdentityAction::Get { id } => {
let item = kratos.get_identity(&id).await?;
output::render(&item, fmt)
}
IdentityAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let body: crate::identity::types::CreateIdentityBody =
serde_json::from_value(json)?;
let item = kratos.create_identity(&body).await?;
output::render(&item, fmt)
}
IdentityAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let body: crate::identity::types::UpdateIdentityBody =
serde_json::from_value(json)?;
let item = kratos.update_identity(&id, &body).await?;
output::render(&item, fmt)
}
IdentityAction::Delete { id } => {
kratos.delete_identity(&id).await?;
output::ok(&format!("Deleted identity {id}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Session dispatch
// ---------------------------------------------------------------------------
async fn dispatch_session(
action: SessionAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let kratos = client.kratos();
match action {
SessionAction::List {
page_size,
page_token,
active,
} => {
let items = kratos
.list_sessions(page_size, page_token.as_deref(), active)
.await?;
output::render_list(
&items,
&["ID", "ACTIVE", "EXPIRES", "AUTHENTICATED"],
|s| {
vec![
s.id.clone(),
s.active.map_or("-".into(), |a| a.to_string()),
s.expires_at.clone().unwrap_or_default(),
s.authenticated_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
SessionAction::Get { id } => {
let item = kratos.get_session(&id).await?;
output::render(&item, fmt)
}
SessionAction::Extend { id } => {
let item = kratos.extend_session(&id).await?;
output::render(&item, fmt)
}
SessionAction::Delete { id } => {
kratos.disable_session(&id).await?;
output::ok(&format!("Disabled session {id}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Recovery dispatch
// ---------------------------------------------------------------------------
async fn dispatch_recovery(
action: RecoveryAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let kratos = client.kratos();
match action {
RecoveryAction::CreateCode { id, expires_in } => {
let item = kratos
.create_recovery_code(&id, expires_in.as_deref())
.await?;
output::render(&item, fmt)
}
RecoveryAction::CreateLink { id, expires_in } => {
let item = kratos
.create_recovery_link(&id, expires_in.as_deref())
.await?;
output::render(&item, fmt)
}
}
}
// ---------------------------------------------------------------------------
// Schema dispatch
// ---------------------------------------------------------------------------
async fn dispatch_schema(
action: SchemaAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let kratos = client.kratos();
match action {
SchemaAction::List => {
let items = kratos.list_schemas().await?;
output::render_list(
&items,
&["ID"],
|s| vec![s.id.clone()],
fmt,
)
}
SchemaAction::Get { id } => {
let item = kratos.get_schema(&id).await?;
output::render(&item, fmt)
}
}
}
// ---------------------------------------------------------------------------
// Courier dispatch
// ---------------------------------------------------------------------------
async fn dispatch_courier(
action: CourierAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let kratos = client.kratos();
match action {
CourierAction::List {
page_size,
page_token,
} => {
let items = kratos
.list_courier_messages(page_size, page_token.as_deref())
.await?;
output::render_list(
&items,
&["ID", "STATUS", "TYPE", "RECIPIENT", "SUBJECT"],
|m| {
vec![
m.id.clone(),
m.status.clone(),
m.r#type.clone(),
m.recipient.clone(),
m.subject.clone(),
]
},
fmt,
)
}
CourierAction::Get { id } => {
let item = kratos.get_courier_message(&id).await?;
output::render(&item, fmt)
}
}
}
// ---------------------------------------------------------------------------
// Client dispatch (Hydra)
// ---------------------------------------------------------------------------
async fn dispatch_client(
action: ClientAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let hydra = client.hydra();
match action {
ClientAction::List { limit, offset } => {
let items = hydra.list_clients(limit, offset).await?;
output::render_list(
&items,
&["CLIENT_ID", "NAME", "SCOPE"],
|c| {
vec![
c.client_id.clone().unwrap_or_default(),
c.client_name.clone().unwrap_or_default(),
c.scope.clone().unwrap_or_default(),
]
},
fmt,
)
}
ClientAction::Get { id } => {
let item = hydra.get_client(&id).await?;
output::render(&item, fmt)
}
ClientAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let body: crate::auth::hydra::types::OAuth2Client =
serde_json::from_value(json)?;
let item = hydra.create_client(&body).await?;
output::render(&item, fmt)
}
ClientAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let body: crate::auth::hydra::types::OAuth2Client =
serde_json::from_value(json)?;
let item = hydra.update_client(&id, &body).await?;
output::render(&item, fmt)
}
ClientAction::Delete { id } => {
hydra.delete_client(&id).await?;
output::ok(&format!("Deleted OAuth2 client {id}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// JWK dispatch (Hydra)
// ---------------------------------------------------------------------------
async fn dispatch_jwk(
action: JwkAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let hydra = client.hydra();
match action {
JwkAction::List { set_name } => {
let item = hydra.get_jwk_set(&set_name).await?;
output::render(&item, fmt)
}
JwkAction::Get { set_name, kid } => {
let item = hydra.get_jwk_key(&set_name, &kid).await?;
output::render(&item, fmt)
}
JwkAction::Create { set_name, data } => {
let json = output::read_json_input(data.as_deref())?;
let body: crate::auth::hydra::types::CreateJwkBody =
serde_json::from_value(json)?;
let item = hydra.create_jwk_set(&set_name, &body).await?;
output::render(&item, fmt)
}
JwkAction::Delete { set_name } => {
hydra.delete_jwk_set(&set_name).await?;
output::ok(&format!("Deleted JWK set {set_name}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Issuer dispatch (Hydra)
// ---------------------------------------------------------------------------
async fn dispatch_issuer(
action: IssuerAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let hydra = client.hydra();
match action {
IssuerAction::List => {
let items = hydra.list_trusted_issuers().await?;
output::render_list(
&items,
&["ID", "ISSUER", "SUBJECT", "EXPIRES"],
|i| {
vec![
i.id.clone().unwrap_or_default(),
i.issuer.clone(),
i.subject.clone(),
i.expires_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
IssuerAction::Get { id } => {
let item = hydra.get_trusted_issuer(&id).await?;
output::render(&item, fmt)
}
IssuerAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let body: crate::auth::hydra::types::TrustedJwtIssuer =
serde_json::from_value(json)?;
let item = hydra.create_trusted_issuer(&body).await?;
output::render(&item, fmt)
}
IssuerAction::Delete { id } => {
hydra.delete_trusted_issuer(&id).await?;
output::ok(&format!("Deleted trusted issuer {id}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Token dispatch (Hydra)
// ---------------------------------------------------------------------------
async fn dispatch_token(
action: TokenAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let hydra = client.hydra();
match action {
TokenAction::Introspect { token } => {
let item = hydra.introspect_token(&token).await?;
output::render(&item, fmt)
}
TokenAction::Delete { client_id } => {
hydra.delete_tokens_for_client(&client_id).await?;
output::ok(&format!("Deleted tokens for client {client_id}"));
Ok(())
}
}
}

View File

@@ -2,6 +2,9 @@
pub mod types; pub mod types;
#[cfg(feature = "cli")]
pub mod cli;
use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::client::{AuthMethod, HttpTransport, ServiceClient};
use crate::error::Result; use crate::error::Result;
use reqwest::Method; use reqwest::Method;

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@ pub mod calendars;
pub mod find; pub mod find;
pub mod types; pub mod types;
#[cfg(feature = "cli")]
pub mod cli;
pub use people::PeopleClient; pub use people::PeopleClient;
pub use docs::DocsClient; pub use docs::DocsClient;
pub use meet::MeetClient; pub use meet::MeetClient;

View File

@@ -0,0 +1,773 @@
//! CLI dispatch for Matrix chat commands.
use crate::error::Result;
use crate::output::{self, OutputFormat};
use clap::Subcommand;
// ---------------------------------------------------------------------------
// Auth helper
// ---------------------------------------------------------------------------
/// Construct a [`MatrixClient`] with a valid access token from the credential
/// cache. Fails if the user is not logged in.
async fn matrix_with_token(domain: &str) -> Result<super::MatrixClient> {
let token = crate::auth::get_token().await?;
let mut m = super::MatrixClient::connect(domain);
m.set_token(&token);
Ok(m)
}
// ---------------------------------------------------------------------------
// Command tree
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum ChatCommand {
/// Room management.
Room {
#[command(subcommand)]
action: RoomAction,
},
/// Send, list, get and redact messages.
Message {
#[command(subcommand)]
action: MessageAction,
},
/// Room state events.
State {
#[command(subcommand)]
action: StateAction,
},
/// User profile management.
Profile {
#[command(subcommand)]
action: ProfileAction,
},
/// Device management.
Device {
#[command(subcommand)]
action: DeviceAction,
},
/// User directory search.
User {
#[command(subcommand)]
action: UserAction,
},
/// Show authenticated user identity.
Whoami,
/// Synchronise client state with the server.
Sync {
/// Pagination token from a previous sync.
#[arg(long)]
since: Option<String>,
/// Filter ID or inline JSON filter.
#[arg(long)]
filter: Option<String>,
/// Request full state (all room state events).
#[arg(long)]
full_state: bool,
/// Presence mode (offline, unavailable, online).
#[arg(long)]
set_presence: Option<String>,
/// Long-poll timeout in milliseconds.
#[arg(long)]
timeout: Option<u64>,
},
}
// -- Room -------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum RoomAction {
/// Create a new room.
Create {
/// JSON body (or - for stdin).
#[arg(short = 'd', long = "data")]
data: Option<String>,
},
/// List public rooms.
List {
/// Maximum number of rooms to return.
#[arg(long)]
limit: Option<u32>,
/// Pagination token.
#[arg(long)]
since: Option<String>,
},
/// Search public rooms.
Search {
/// Search query.
#[arg(short = 'q', long)]
query: String,
/// Maximum results.
#[arg(long)]
limit: Option<u32>,
},
/// Join a room.
Join {
/// Room ID or alias (e.g. !abc:example.com or #room:example.com).
#[arg(long)]
room_id: String,
},
/// Leave a room.
Leave {
/// Room ID.
#[arg(long)]
room_id: String,
},
/// Invite a user to a room.
Invite {
/// Room ID.
#[arg(long)]
room_id: String,
/// User ID to invite (e.g. @alice:example.com).
#[arg(long)]
user_id: String,
/// Reason for the invite.
#[arg(long)]
reason: Option<String>,
},
/// Kick a user from a room.
Kick {
/// Room ID.
#[arg(long)]
room_id: String,
/// User ID to kick.
#[arg(long)]
user_id: String,
/// Reason.
#[arg(long)]
reason: Option<String>,
},
/// Ban a user from a room.
Ban {
/// Room ID.
#[arg(long)]
room_id: String,
/// User ID to ban.
#[arg(long)]
user_id: String,
/// Reason.
#[arg(long)]
reason: Option<String>,
},
/// Unban a user from a room.
Unban {
/// Room ID.
#[arg(long)]
room_id: String,
/// User ID to unban.
#[arg(long)]
user_id: String,
/// Reason.
#[arg(long)]
reason: Option<String>,
},
}
// -- Message ----------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum MessageAction {
/// Send a message to a room.
Send {
/// Room ID.
#[arg(long)]
room_id: String,
/// Message text body. If omitted, reads JSON from --data or stdin.
#[arg(long)]
body: Option<String>,
/// Event type (default: m.room.message).
#[arg(long, default_value = "m.room.message")]
event_type: String,
/// Raw JSON body for the event content (or - for stdin).
#[arg(short = 'd', long = "data")]
data: Option<String>,
},
/// List messages in a room.
List {
/// Room ID.
#[arg(long)]
room_id: String,
/// Pagination direction (b = backwards, f = forwards).
#[arg(long, default_value = "b")]
dir: String,
/// Pagination token.
#[arg(long)]
from: Option<String>,
/// Maximum messages to return.
#[arg(long)]
limit: Option<u32>,
/// Event filter (JSON string).
#[arg(long)]
filter: Option<String>,
},
/// Get a single event.
Get {
/// Room ID.
#[arg(long)]
room_id: String,
/// Event ID.
#[arg(long)]
event_id: String,
},
/// Redact an event.
Redact {
/// Room ID.
#[arg(long)]
room_id: String,
/// Event ID to redact.
#[arg(long)]
event_id: String,
/// Reason for redaction.
#[arg(long)]
reason: Option<String>,
},
/// Search messages across rooms.
Search {
/// Search query.
#[arg(short = 'q', long)]
query: String,
},
}
// -- State ------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum StateAction {
/// List all state events in a room.
List {
/// Room ID.
#[arg(long)]
room_id: String,
},
/// Get a specific state event.
Get {
/// Room ID.
#[arg(long)]
room_id: String,
/// Event type (e.g. m.room.name).
#[arg(long)]
event_type: String,
/// State key (default: empty string).
#[arg(long, default_value = "")]
state_key: String,
},
/// Set a state event in a room.
Set {
/// Room ID.
#[arg(long)]
room_id: String,
/// Event type (e.g. m.room.name).
#[arg(long)]
event_type: String,
/// State key (default: empty string).
#[arg(long, default_value = "")]
state_key: String,
/// JSON body (or - for stdin).
#[arg(short = 'd', long = "data")]
data: Option<String>,
},
}
// -- Profile ----------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum ProfileAction {
/// Get a user's profile.
Get {
/// User ID (e.g. @alice:example.com).
#[arg(long)]
user_id: String,
},
/// Set the display name.
SetName {
/// User ID.
#[arg(long)]
user_id: String,
/// New display name.
#[arg(long)]
name: String,
},
/// Set the avatar URL.
SetAvatar {
/// User ID.
#[arg(long)]
user_id: String,
/// Avatar MXC URI.
#[arg(long)]
url: String,
},
}
// -- Device -----------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum DeviceAction {
/// List all devices for the authenticated user.
List,
/// Get information about a specific device.
Get {
/// Device ID.
#[arg(long)]
device_id: String,
},
/// Delete a device.
Delete {
/// Device ID.
#[arg(long)]
device_id: String,
/// Interactive auth JSON (or - for stdin).
#[arg(short = 'd', long = "data")]
data: Option<String>,
},
}
// -- User -------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum UserAction {
/// Search the user directory.
Search {
/// Search query.
#[arg(short = 'q', long)]
query: String,
/// Maximum results.
#[arg(long)]
limit: Option<u32>,
},
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
/// Dispatch a parsed [`ChatCommand`] against the Matrix homeserver.
pub async fn dispatch(domain: &str, format: OutputFormat, cmd: ChatCommand) -> Result<()> {
let m = matrix_with_token(domain).await?;
match cmd {
// -- Whoami ---------------------------------------------------------
ChatCommand::Whoami => {
let resp = m.whoami().await?;
output::render(&resp, format)
}
// -- Sync -----------------------------------------------------------
ChatCommand::Sync {
since,
filter,
full_state,
set_presence,
timeout,
} => {
let params = super::types::SyncParams {
since,
filter,
full_state: if full_state { Some(true) } else { None },
set_presence,
timeout,
};
let resp = m.sync(&params).await?;
output::render(&resp, format)
}
// -- Room -----------------------------------------------------------
ChatCommand::Room { action } => dispatch_room(&m, format, action).await,
// -- Message --------------------------------------------------------
ChatCommand::Message { action } => dispatch_message(&m, format, action).await,
// -- State ----------------------------------------------------------
ChatCommand::State { action } => dispatch_state(&m, format, action).await,
// -- Profile --------------------------------------------------------
ChatCommand::Profile { action } => dispatch_profile(&m, format, action).await,
// -- Device ---------------------------------------------------------
ChatCommand::Device { action } => dispatch_device(&m, format, action).await,
// -- User -----------------------------------------------------------
ChatCommand::User { action } => dispatch_user(&m, format, action).await,
}
}
// ---------------------------------------------------------------------------
// Room
// ---------------------------------------------------------------------------
async fn dispatch_room(
m: &super::MatrixClient,
format: OutputFormat,
action: RoomAction,
) -> Result<()> {
match action {
RoomAction::Create { data } => {
let body: super::types::CreateRoomRequest =
serde_json::from_value(output::read_json_input(data.as_deref())?)?;
let resp = m.create_room(&body).await?;
output::render(&resp, format)
}
RoomAction::List { limit, since } => {
let resp = m.list_public_rooms(limit, since.as_deref()).await?;
output::render_list(
&resp.chunk,
&["ROOM_ID", "NAME", "MEMBERS", "TOPIC"],
|r| {
vec![
r.room_id.clone(),
r.name.clone().unwrap_or_default(),
r.num_joined_members.to_string(),
r.topic.clone().unwrap_or_default(),
]
},
format,
)
}
RoomAction::Search { query, limit } => {
let body = super::types::SearchPublicRoomsRequest {
limit,
since: None,
filter: Some(serde_json::json!({ "generic_search_term": query })),
include_all_networks: None,
third_party_instance_id: None,
};
let resp = m.search_public_rooms(&body).await?;
output::render_list(
&resp.chunk,
&["ROOM_ID", "NAME", "MEMBERS", "TOPIC"],
|r| {
vec![
r.room_id.clone(),
r.name.clone().unwrap_or_default(),
r.num_joined_members.to_string(),
r.topic.clone().unwrap_or_default(),
]
},
format,
)
}
RoomAction::Join { room_id } => {
m.join_room_by_id(&room_id).await?;
output::ok(&format!("Joined {room_id}"));
Ok(())
}
RoomAction::Leave { room_id } => {
m.leave_room(&room_id).await?;
output::ok(&format!("Left {room_id}"));
Ok(())
}
RoomAction::Invite {
room_id,
user_id,
reason,
} => {
let body = super::types::InviteRequest { user_id: user_id.clone(), reason };
m.invite(&room_id, &body).await?;
output::ok(&format!("Invited {user_id} to {room_id}"));
Ok(())
}
RoomAction::Kick {
room_id,
user_id,
reason,
} => {
let body = super::types::KickRequest { user_id: user_id.clone(), reason };
m.kick(&room_id, &body).await?;
output::ok(&format!("Kicked {user_id} from {room_id}"));
Ok(())
}
RoomAction::Ban {
room_id,
user_id,
reason,
} => {
let body = super::types::BanRequest { user_id: user_id.clone(), reason };
m.ban(&room_id, &body).await?;
output::ok(&format!("Banned {user_id} from {room_id}"));
Ok(())
}
RoomAction::Unban {
room_id,
user_id,
reason,
} => {
let body = super::types::UnbanRequest { user_id: user_id.clone(), reason };
m.unban(&room_id, &body).await?;
output::ok(&format!("Unbanned {user_id} from {room_id}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Message
// ---------------------------------------------------------------------------
async fn dispatch_message(
m: &super::MatrixClient,
format: OutputFormat,
action: MessageAction,
) -> Result<()> {
match action {
MessageAction::Send {
room_id,
body,
event_type,
data,
} => {
let content: serde_json::Value = if let Some(text) = body {
// Convenience: wrap plain text into m.room.message content.
serde_json::json!({
"msgtype": "m.text",
"body": text,
})
} else {
output::read_json_input(data.as_deref())?
};
let txn_id = format!(
"cli-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let resp = m.send_event(&room_id, &event_type, &txn_id, &content).await?;
output::render(&resp, format)
}
MessageAction::List {
room_id,
dir,
from,
limit,
filter,
} => {
let params = super::types::MessagesParams {
dir,
from,
to: None,
limit,
filter,
};
let resp = m.get_messages(&room_id, &params).await?;
output::render_list(
&resp.chunk,
&["EVENT_ID", "SENDER", "TYPE", "BODY"],
|ev| {
vec![
ev.event_id.clone().unwrap_or_default(),
ev.sender.clone().unwrap_or_default(),
ev.event_type.clone(),
ev.content
.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
]
},
format,
)
}
MessageAction::Get { room_id, event_id } => {
let ev = m.get_event(&room_id, &event_id).await?;
output::render(&ev, format)
}
MessageAction::Redact {
room_id,
event_id,
reason,
} => {
let txn_id = format!(
"cli-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let body = super::types::RedactRequest { reason };
let resp = m.redact(&room_id, &event_id, &txn_id, &body).await?;
output::render(&resp, format)
}
MessageAction::Search { query } => {
let body = super::types::SearchRequest {
search_categories: serde_json::json!({
"room_events": {
"search_term": query,
}
}),
};
let resp = m.search_messages(&body).await?;
output::render(&resp, format)
}
}
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
async fn dispatch_state(
m: &super::MatrixClient,
format: OutputFormat,
action: StateAction,
) -> Result<()> {
match action {
StateAction::List { room_id } => {
let events = m.get_all_state(&room_id).await?;
output::render_list(
&events,
&["TYPE", "STATE_KEY", "SENDER"],
|ev| {
vec![
ev.event_type.clone(),
ev.state_key.clone(),
ev.sender.clone().unwrap_or_default(),
]
},
format,
)
}
StateAction::Get {
room_id,
event_type,
state_key,
} => {
let val = m.get_state_event(&room_id, &event_type, &state_key).await?;
output::render(&val, format)
}
StateAction::Set {
room_id,
event_type,
state_key,
data,
} => {
let body = output::read_json_input(data.as_deref())?;
let resp = m
.set_state_event(&room_id, &event_type, &state_key, &body)
.await?;
output::render(&resp, format)
}
}
}
// ---------------------------------------------------------------------------
// Profile
// ---------------------------------------------------------------------------
async fn dispatch_profile(
m: &super::MatrixClient,
format: OutputFormat,
action: ProfileAction,
) -> Result<()> {
match action {
ProfileAction::Get { user_id } => {
let profile = m.get_profile(&user_id).await?;
output::render(&profile, format)
}
ProfileAction::SetName { user_id, name } => {
let body = super::types::SetDisplaynameRequest { displayname: name };
m.set_displayname(&user_id, &body).await?;
output::ok("Display name updated.");
Ok(())
}
ProfileAction::SetAvatar { user_id, url } => {
let body = super::types::SetAvatarUrlRequest { avatar_url: url };
m.set_avatar_url(&user_id, &body).await?;
output::ok("Avatar URL updated.");
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Device
// ---------------------------------------------------------------------------
async fn dispatch_device(
m: &super::MatrixClient,
format: OutputFormat,
action: DeviceAction,
) -> Result<()> {
match action {
DeviceAction::List => {
let resp = m.list_devices().await?;
output::render_list(
&resp.devices,
&["DEVICE_ID", "DISPLAY_NAME", "LAST_SEEN_IP"],
|d| {
vec![
d.device_id.clone(),
d.display_name.clone().unwrap_or_default(),
d.last_seen_ip.clone().unwrap_or_default(),
]
},
format,
)
}
DeviceAction::Get { device_id } => {
let device = m.get_device(&device_id).await?;
output::render(&device, format)
}
DeviceAction::Delete { device_id, data } => {
let body: super::types::DeleteDeviceRequest = if data.is_some() {
serde_json::from_value(output::read_json_input(data.as_deref())?)?
} else {
super::types::DeleteDeviceRequest { auth: None }
};
m.delete_device(&device_id, &body).await?;
output::ok(&format!("Device {device_id} deleted."));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// User
// ---------------------------------------------------------------------------
async fn dispatch_user(
m: &super::MatrixClient,
format: OutputFormat,
action: UserAction,
) -> Result<()> {
match action {
UserAction::Search { query, limit } => {
let body = super::types::UserSearchRequest {
search_term: query,
limit,
};
let resp = m.search_users(&body).await?;
output::render_list(
&resp.results,
&["USER_ID", "DISPLAY_NAME"],
|u| {
vec![
u.user_id.clone(),
u.display_name.clone().unwrap_or_default(),
]
},
format,
)
}
}
}

View File

@@ -2,6 +2,9 @@
pub mod types; pub mod types;
#[cfg(feature = "cli")]
pub mod cli;
use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::client::{AuthMethod, HttpTransport, ServiceClient};
use crate::error::Result; use crate::error::Result;
use reqwest::Method; use reqwest::Method;

View File

@@ -0,0 +1,351 @@
use clap::Subcommand;
use crate::client::SunbeamClient;
use crate::error::Result;
use crate::media::types::VideoGrants;
use crate::media::LiveKitClient;
use crate::output::{self, OutputFormat};
// ---------------------------------------------------------------------------
// Top-level MediaCommand
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum MediaCommand {
/// Room management.
Room {
#[command(subcommand)]
action: RoomAction,
},
/// Participant management.
Participant {
#[command(subcommand)]
action: ParticipantAction,
},
/// Egress management.
Egress {
#[command(subcommand)]
action: EgressAction,
},
/// Token operations.
Token {
#[command(subcommand)]
action: TokenAction,
},
}
// ---------------------------------------------------------------------------
// Room sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum RoomAction {
/// List all rooms.
List,
/// Create a room.
Create {
/// Room name.
#[arg(short, long)]
name: String,
/// Maximum number of participants.
#[arg(long)]
max_participants: Option<u32>,
},
/// Delete a room.
Delete {
/// Room name.
#[arg(short, long)]
name: String,
},
}
// ---------------------------------------------------------------------------
// Participant sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum ParticipantAction {
/// List participants in a room.
List {
/// Room name.
#[arg(short, long)]
room: String,
},
/// Get a participant.
Get {
/// Room name.
#[arg(short, long)]
room: String,
/// Participant identity.
#[arg(short, long)]
identity: String,
},
/// Remove a participant from a room.
Remove {
/// Room name.
#[arg(short, long)]
room: String,
/// Participant identity.
#[arg(short, long)]
identity: String,
},
}
// ---------------------------------------------------------------------------
// Egress sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum EgressAction {
/// List egress sessions for a room.
List {
/// Room name.
#[arg(short, long)]
room: String,
},
/// Start a room composite egress.
StartRoomComposite {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Start a track egress.
StartTrack {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Stop an egress session.
Stop {
/// Egress ID.
#[arg(short, long)]
egress_id: String,
},
}
// ---------------------------------------------------------------------------
// Token sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum TokenAction {
/// Generate a LiveKit access token.
Generate {
/// LiveKit API key.
#[arg(long)]
api_key: String,
/// LiveKit API secret.
#[arg(long)]
api_secret: String,
/// Participant identity.
#[arg(long)]
identity: String,
/// Room name to grant access to.
#[arg(long)]
room: Option<String>,
/// Token TTL in seconds.
#[arg(long, default_value = "3600")]
ttl: u64,
},
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: MediaCommand,
client: &SunbeamClient,
output: OutputFormat,
) -> Result<()> {
match cmd {
MediaCommand::Room { action } => dispatch_room(action, client, output).await,
MediaCommand::Participant { action } => {
dispatch_participant(action, client, output).await
}
MediaCommand::Egress { action } => dispatch_egress(action, client, output).await,
MediaCommand::Token { action } => dispatch_token(action, output),
}
}
// ---------------------------------------------------------------------------
// Room dispatch
// ---------------------------------------------------------------------------
async fn dispatch_room(
action: RoomAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let lk = client.livekit();
match action {
RoomAction::List => {
let resp = lk.list_rooms().await?;
output::render_list(
&resp.rooms,
&["NAME", "SID", "PARTICIPANTS", "MAX", "CREATED"],
|r| {
vec![
r.name.clone(),
r.sid.clone(),
r.num_participants
.map_or("-".into(), |n| n.to_string()),
r.max_participants
.map_or("-".into(), |n| n.to_string()),
r.creation_time
.map_or("-".into(), |t| t.to_string()),
]
},
fmt,
)
}
RoomAction::Create {
name,
max_participants,
} => {
let mut body = serde_json::json!({ "name": name });
if let Some(max) = max_participants {
body["max_participants"] = serde_json::json!(max);
}
let room = lk.create_room(&body).await?;
output::render(&room, fmt)
}
RoomAction::Delete { name } => {
lk.delete_room(&serde_json::json!({ "room": name })).await?;
output::ok(&format!("Deleted room {name}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Participant dispatch
// ---------------------------------------------------------------------------
async fn dispatch_participant(
action: ParticipantAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let lk = client.livekit();
match action {
ParticipantAction::List { room } => {
let resp = lk
.list_participants(&serde_json::json!({ "room": room }))
.await?;
output::render_list(
&resp.participants,
&["SID", "IDENTITY", "NAME", "STATE", "JOINED_AT"],
|p| {
vec![
p.sid.clone(),
p.identity.clone(),
p.name.clone().unwrap_or_default(),
p.state.map_or("-".into(), |s| s.to_string()),
p.joined_at.map_or("-".into(), |t| t.to_string()),
]
},
fmt,
)
}
ParticipantAction::Get { room, identity } => {
let info = lk
.get_participant(&serde_json::json!({
"room": room,
"identity": identity,
}))
.await?;
output::render(&info, fmt)
}
ParticipantAction::Remove { room, identity } => {
lk.remove_participant(&serde_json::json!({
"room": room,
"identity": identity,
}))
.await?;
output::ok(&format!("Removed participant {identity} from room {room}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Egress dispatch
// ---------------------------------------------------------------------------
async fn dispatch_egress(
action: EgressAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let lk = client.livekit();
match action {
EgressAction::List { room } => {
let resp = lk
.list_egress(&serde_json::json!({ "room_name": room }))
.await?;
output::render_list(
&resp.items,
&["EGRESS_ID", "ROOM", "STATUS", "STARTED", "ENDED"],
|e| {
vec![
e.egress_id.clone(),
e.room_name.clone().unwrap_or_default(),
e.status.map_or("-".into(), |s| s.to_string()),
e.started_at.map_or("-".into(), |t| t.to_string()),
e.ended_at.map_or("-".into(), |t| t.to_string()),
]
},
fmt,
)
}
EgressAction::StartRoomComposite { data } => {
let body = output::read_json_input(data.as_deref())?;
let info = lk.start_room_composite_egress(&body).await?;
output::render(&info, fmt)
}
EgressAction::StartTrack { data } => {
let body = output::read_json_input(data.as_deref())?;
let info = lk.start_track_egress(&body).await?;
output::render(&info, fmt)
}
EgressAction::Stop { egress_id } => {
let info = lk
.stop_egress(&serde_json::json!({ "egress_id": egress_id }))
.await?;
output::render(&info, fmt)
}
}
}
// ---------------------------------------------------------------------------
// Token dispatch
// ---------------------------------------------------------------------------
fn dispatch_token(action: TokenAction, fmt: OutputFormat) -> Result<()> {
match action {
TokenAction::Generate {
api_key,
api_secret,
identity,
room,
ttl,
} => {
let grants = VideoGrants {
room_join: Some(true),
room,
can_publish: Some(true),
can_subscribe: Some(true),
..Default::default()
};
let token = LiveKitClient::generate_access_token(
&api_key,
&api_secret,
&identity,
&grants,
ttl,
)?;
output::render(&serde_json::json!({ "token": token }), fmt)
}
}
}

View File

@@ -2,6 +2,9 @@
pub mod types; pub mod types;
#[cfg(feature = "cli")]
pub mod cli;
use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::client::{AuthMethod, HttpTransport, ServiceClient};
use crate::error::{Result, SunbeamError}; use crate::error::{Result, SunbeamError};
use base64::Engine; use base64::Engine;

View File

@@ -0,0 +1,895 @@
//! CLI commands for monitoring services: Prometheus, Loki, and Grafana.
use clap::Subcommand;
use crate::client::SunbeamClient;
use crate::error::Result;
use crate::output::{self, OutputFormat};
// ===========================================================================
// Top-level MonCommand
// ===========================================================================
#[derive(Subcommand, Debug)]
pub enum MonCommand {
/// Prometheus queries, targets, rules and status.
Prometheus {
#[command(subcommand)]
action: PrometheusAction,
},
/// Loki log queries, labels, and ingestion.
Loki {
#[command(subcommand)]
action: LokiAction,
},
/// Grafana dashboards, datasources, folders, annotations, alerts, and org.
Grafana {
#[command(subcommand)]
action: GrafanaAction,
},
}
// ===========================================================================
// Prometheus
// ===========================================================================
#[derive(Subcommand, Debug)]
pub enum PrometheusAction {
/// Execute an instant PromQL query.
Query {
/// PromQL expression.
#[arg(short, long, alias = "expr", short_alias = 'e')]
query: String,
/// Evaluation timestamp (RFC-3339 or Unix).
#[arg(long)]
time: Option<String>,
},
/// Execute a range PromQL query.
QueryRange {
/// PromQL expression.
#[arg(short, long, alias = "expr", short_alias = 'e')]
query: String,
/// Start timestamp.
#[arg(long)]
start: String,
/// End timestamp.
#[arg(long)]
end: String,
/// Query resolution step (e.g. "15s").
#[arg(long)]
step: String,
},
/// Find series by label matchers.
Series {
/// One or more series selectors (e.g. `up{job="node"}`).
#[arg(short = 'm', long = "match", required = true)]
match_params: Vec<String>,
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
},
/// List all label names.
Labels {
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
},
/// List values for a specific label.
LabelValues {
/// Label name.
#[arg(short, long)]
label: String,
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
},
/// Show current target discovery status.
Targets,
/// List alerting and recording rules.
Rules,
/// List active alerts.
Alerts,
/// Show Prometheus runtime information.
Status,
/// Show per-metric metadata.
Metadata {
/// Filter by metric name.
#[arg(short, long)]
metric: Option<String>,
},
}
// ===========================================================================
// Loki
// ===========================================================================
#[derive(Subcommand, Debug)]
pub enum LokiAction {
/// Execute an instant LogQL query.
Query {
/// LogQL expression.
#[arg(short, long, alias = "expr", short_alias = 'e')]
query: String,
/// Maximum number of entries to return.
#[arg(short, long)]
limit: Option<u32>,
/// Evaluation timestamp.
#[arg(long)]
time: Option<String>,
},
/// Execute a range LogQL query.
QueryRange {
/// LogQL expression.
#[arg(short, long, alias = "expr", short_alias = 'e')]
query: String,
/// Start timestamp.
#[arg(long)]
start: String,
/// End timestamp.
#[arg(long)]
end: String,
/// Maximum number of entries to return.
#[arg(short, long)]
limit: Option<u32>,
/// Query resolution step.
#[arg(long)]
step: Option<String>,
},
/// List all label names.
Labels {
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
},
/// List values for a specific label.
LabelValues {
/// Label name.
#[arg(short, long)]
label: String,
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
},
/// Find series by label matchers.
Series {
/// One or more series selectors.
#[arg(short = 'm', long = "match", required = true)]
match_params: Vec<String>,
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
},
/// Push log entries from JSON.
Push {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Show index statistics.
IndexStats,
/// Detect log patterns.
Patterns {
/// LogQL expression.
#[arg(short, long, alias = "expr", short_alias = 'e')]
query: String,
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
},
/// Check Loki readiness.
Ready,
}
// ===========================================================================
// Grafana
// ===========================================================================
#[derive(Subcommand, Debug)]
pub enum GrafanaAction {
/// Dashboard management.
Dashboard {
#[command(subcommand)]
action: GrafanaDashboardAction,
},
/// Datasource management.
Datasource {
#[command(subcommand)]
action: GrafanaDatasourceAction,
},
/// Folder management.
Folder {
#[command(subcommand)]
action: GrafanaFolderAction,
},
/// Annotation management.
Annotation {
#[command(subcommand)]
action: GrafanaAnnotationAction,
},
/// Alert rule management.
Alert {
#[command(subcommand)]
action: GrafanaAlertAction,
},
/// Organization settings.
Org {
#[command(subcommand)]
action: GrafanaOrgAction,
},
}
// ---------------------------------------------------------------------------
// Grafana Dashboard
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum GrafanaDashboardAction {
/// List all dashboards.
List,
/// Get a dashboard by UID.
Get {
#[arg(long)]
uid: String,
},
/// Create a dashboard from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a dashboard from JSON.
Update {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a dashboard by UID.
Delete {
#[arg(long)]
uid: String,
},
/// Search dashboards by query string.
Search {
#[arg(short, long)]
query: String,
},
}
// ---------------------------------------------------------------------------
// Grafana Datasource
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum GrafanaDatasourceAction {
/// List all datasources.
List,
/// Get a datasource by numeric ID.
Get {
#[arg(long)]
id: u64,
},
/// Create a datasource from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a datasource from JSON.
Update {
#[arg(long)]
id: u64,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a datasource by numeric ID.
Delete {
#[arg(long)]
id: u64,
},
}
// ---------------------------------------------------------------------------
// Grafana Folder
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum GrafanaFolderAction {
/// List all folders.
List,
/// Get a folder by UID.
Get {
#[arg(long)]
uid: String,
},
/// Create a folder from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a folder from JSON.
Update {
#[arg(long)]
uid: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a folder by UID.
Delete {
#[arg(long)]
uid: String,
},
}
// ---------------------------------------------------------------------------
// Grafana Annotation
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum GrafanaAnnotationAction {
/// List annotations (optional query-string filter).
List {
/// Raw query-string params (e.g. "from=1234&to=5678").
#[arg(long)]
params: Option<String>,
},
/// Create an annotation from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete an annotation by ID.
Delete {
#[arg(long)]
id: u64,
},
}
// ---------------------------------------------------------------------------
// Grafana Alert
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum GrafanaAlertAction {
/// List all provisioned alert rules.
List,
/// Create a provisioned alert rule from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a provisioned alert rule from JSON.
Update {
#[arg(long)]
uid: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a provisioned alert rule by UID.
Delete {
#[arg(long)]
uid: String,
},
}
// ---------------------------------------------------------------------------
// Grafana Org
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum GrafanaOrgAction {
/// Get the current organization.
Get,
/// Update the current organization from JSON.
Update {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
// ===========================================================================
// Dispatch
// ===========================================================================
pub async fn dispatch(
cmd: MonCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
match cmd {
MonCommand::Prometheus { action } => dispatch_prometheus(action, client, fmt).await,
MonCommand::Loki { action } => dispatch_loki(action, client, fmt).await,
MonCommand::Grafana { action } => dispatch_grafana(action, client, fmt).await,
}
}
// ===========================================================================
// Prometheus dispatch
// ===========================================================================
async fn dispatch_prometheus(
action: PrometheusAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let prom = client.prometheus();
match action {
PrometheusAction::Query { query, time } => {
let res = prom.query(&query, time.as_deref()).await?;
output::render(&res, fmt)
}
PrometheusAction::QueryRange {
query,
start,
end,
step,
} => {
let res = prom.query_range(&query, &start, &end, &step).await?;
output::render(&res, fmt)
}
PrometheusAction::Series {
match_params,
start,
end,
} => {
let refs: Vec<&str> = match_params.iter().map(|s| s.as_str()).collect();
let res = prom
.series(&refs, start.as_deref(), end.as_deref())
.await?;
output::render(&res, fmt)
}
PrometheusAction::Labels { start, end } => {
let res = prom.labels(start.as_deref(), end.as_deref()).await?;
if let Some(labels) = &res.data {
output::render_list(
labels,
&["LABEL"],
|l| vec![l.clone()],
fmt,
)
} else {
output::render(&res, fmt)
}
}
PrometheusAction::LabelValues { label, start, end } => {
let res = prom
.label_values(&label, start.as_deref(), end.as_deref())
.await?;
if let Some(values) = &res.data {
output::render_list(
values,
&["VALUE"],
|v| vec![v.clone()],
fmt,
)
} else {
output::render(&res, fmt)
}
}
PrometheusAction::Targets => {
let res = prom.targets().await?;
output::render(&res, fmt)
}
PrometheusAction::Rules => {
let res = prom.rules().await?;
output::render(&res, fmt)
}
PrometheusAction::Alerts => {
let res = prom.alerts().await?;
output::render(&res, fmt)
}
PrometheusAction::Status => {
let res = prom.runtime_info().await?;
output::render(&res, fmt)
}
PrometheusAction::Metadata { metric } => {
let res = prom.metadata(metric.as_deref()).await?;
output::render(&res, fmt)
}
}
}
// ===========================================================================
// Loki dispatch
// ===========================================================================
async fn dispatch_loki(
action: LokiAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let loki = client.loki();
match action {
LokiAction::Query { query, limit, time } => {
let res = loki.query(&query, limit, time.as_deref()).await?;
output::render(&res, fmt)
}
LokiAction::QueryRange {
query,
start,
end,
limit,
step,
} => {
let res = loki
.query_range(&query, &start, &end, limit, step.as_deref())
.await?;
output::render(&res, fmt)
}
LokiAction::Labels { start, end } => {
let res = loki.labels(start.as_deref(), end.as_deref()).await?;
if let Some(labels) = &res.data {
output::render_list(
labels,
&["LABEL"],
|l| vec![l.clone()],
fmt,
)
} else {
output::render(&res, fmt)
}
}
LokiAction::LabelValues { label, start, end } => {
let res = loki
.label_values(&label, start.as_deref(), end.as_deref())
.await?;
if let Some(values) = &res.data {
output::render_list(
values,
&["VALUE"],
|v| vec![v.clone()],
fmt,
)
} else {
output::render(&res, fmt)
}
}
LokiAction::Series {
match_params,
start,
end,
} => {
let refs: Vec<&str> = match_params.iter().map(|s| s.as_str()).collect();
let res = loki
.series(&refs, start.as_deref(), end.as_deref())
.await?;
output::render(&res, fmt)
}
LokiAction::Push { data } => {
let json = output::read_json_input(data.as_deref())?;
loki.push(&json).await?;
output::ok("Pushed log entries");
Ok(())
}
LokiAction::IndexStats => {
let res = loki.index_stats().await?;
output::render(&res, fmt)
}
LokiAction::Patterns { query, start, end } => {
let res = loki
.detect_patterns(&query, start.as_deref(), end.as_deref())
.await?;
output::render(&res, fmt)
}
LokiAction::Ready => {
let res = loki.ready().await?;
output::render(&res, fmt)
}
}
}
// ===========================================================================
// Grafana dispatch
// ===========================================================================
async fn dispatch_grafana(
action: GrafanaAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
match action {
GrafanaAction::Dashboard { action } => {
dispatch_grafana_dashboard(action, client, fmt).await
}
GrafanaAction::Datasource { action } => {
dispatch_grafana_datasource(action, client, fmt).await
}
GrafanaAction::Folder { action } => {
dispatch_grafana_folder(action, client, fmt).await
}
GrafanaAction::Annotation { action } => {
dispatch_grafana_annotation(action, client, fmt).await
}
GrafanaAction::Alert { action } => {
dispatch_grafana_alert(action, client, fmt).await
}
GrafanaAction::Org { action } => {
dispatch_grafana_org(action, client, fmt).await
}
}
}
// ---------------------------------------------------------------------------
// Grafana Dashboard dispatch
// ---------------------------------------------------------------------------
async fn dispatch_grafana_dashboard(
action: GrafanaDashboardAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let grafana = client.grafana();
match action {
GrafanaDashboardAction::List => {
let items = grafana.list_dashboards().await?;
output::render_list(
&items,
&["UID", "TITLE", "URL", "TAGS"],
|d| {
vec![
d.uid.clone(),
d.title.clone(),
d.url.clone().unwrap_or_default(),
d.tags.join(", "),
]
},
fmt,
)
}
GrafanaDashboardAction::Get { uid } => {
let item = grafana.get_dashboard(&uid).await?;
output::render(&item, fmt)
}
GrafanaDashboardAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.create_dashboard(&json).await?;
output::render(&item, fmt)
}
GrafanaDashboardAction::Update { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.update_dashboard(&json).await?;
output::render(&item, fmt)
}
GrafanaDashboardAction::Delete { uid } => {
grafana.delete_dashboard(&uid).await?;
output::ok(&format!("Deleted dashboard {uid}"));
Ok(())
}
GrafanaDashboardAction::Search { query } => {
let items = grafana.search_dashboards(&query).await?;
output::render_list(
&items,
&["UID", "TITLE", "URL", "TAGS"],
|d| {
vec![
d.uid.clone(),
d.title.clone(),
d.url.clone().unwrap_or_default(),
d.tags.join(", "),
]
},
fmt,
)
}
}
}
// ---------------------------------------------------------------------------
// Grafana Datasource dispatch
// ---------------------------------------------------------------------------
async fn dispatch_grafana_datasource(
action: GrafanaDatasourceAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let grafana = client.grafana();
match action {
GrafanaDatasourceAction::List => {
let items = grafana.list_datasources().await?;
output::render_list(
&items,
&["ID", "UID", "NAME", "TYPE", "URL"],
|d| {
vec![
d.id.map_or("-".into(), |id| id.to_string()),
d.uid.clone().unwrap_or_default(),
d.name.clone(),
d.kind.clone(),
d.url.clone().unwrap_or_default(),
]
},
fmt,
)
}
GrafanaDatasourceAction::Get { id } => {
let item = grafana.get_datasource(id).await?;
output::render(&item, fmt)
}
GrafanaDatasourceAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.create_datasource(&json).await?;
output::render(&item, fmt)
}
GrafanaDatasourceAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.update_datasource(id, &json).await?;
output::render(&item, fmt)
}
GrafanaDatasourceAction::Delete { id } => {
grafana.delete_datasource(id).await?;
output::ok(&format!("Deleted datasource {id}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Grafana Folder dispatch
// ---------------------------------------------------------------------------
async fn dispatch_grafana_folder(
action: GrafanaFolderAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let grafana = client.grafana();
match action {
GrafanaFolderAction::List => {
let items = grafana.list_folders().await?;
output::render_list(
&items,
&["UID", "TITLE", "URL"],
|f| {
vec![
f.uid.clone(),
f.title.clone(),
f.url.clone().unwrap_or_default(),
]
},
fmt,
)
}
GrafanaFolderAction::Get { uid } => {
let item = grafana.get_folder(&uid).await?;
output::render(&item, fmt)
}
GrafanaFolderAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.create_folder(&json).await?;
output::render(&item, fmt)
}
GrafanaFolderAction::Update { uid, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.update_folder(&uid, &json).await?;
output::render(&item, fmt)
}
GrafanaFolderAction::Delete { uid } => {
grafana.delete_folder(&uid).await?;
output::ok(&format!("Deleted folder {uid}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Grafana Annotation dispatch
// ---------------------------------------------------------------------------
async fn dispatch_grafana_annotation(
action: GrafanaAnnotationAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let grafana = client.grafana();
match action {
GrafanaAnnotationAction::List { params } => {
let items = grafana.list_annotations(params.as_deref()).await?;
output::render_list(
&items,
&["ID", "TEXT", "TAGS"],
|a| {
vec![
a.id.map_or("-".into(), |id| id.to_string()),
a.text.clone().unwrap_or_default(),
a.tags.join(", "),
]
},
fmt,
)
}
GrafanaAnnotationAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.create_annotation(&json).await?;
output::render(&item, fmt)
}
GrafanaAnnotationAction::Delete { id } => {
grafana.delete_annotation(id).await?;
output::ok(&format!("Deleted annotation {id}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Grafana Alert dispatch
// ---------------------------------------------------------------------------
async fn dispatch_grafana_alert(
action: GrafanaAlertAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let grafana = client.grafana();
match action {
GrafanaAlertAction::List => {
let items = grafana.get_alert_rules().await?;
output::render_list(
&items,
&["UID", "TITLE", "CONDITION", "FOLDER", "GROUP"],
|r| {
vec![
r.uid.clone().unwrap_or_default(),
r.title.clone().unwrap_or_default(),
r.condition.clone().unwrap_or_default(),
r.folder_uid.clone().unwrap_or_default(),
r.rule_group.clone().unwrap_or_default(),
]
},
fmt,
)
}
GrafanaAlertAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.create_alert_rule(&json).await?;
output::render(&item, fmt)
}
GrafanaAlertAction::Update { uid, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = grafana.update_alert_rule(&uid, &json).await?;
output::render(&item, fmt)
}
GrafanaAlertAction::Delete { uid } => {
grafana.delete_alert_rule(&uid).await?;
output::ok(&format!("Deleted alert rule {uid}"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// Grafana Org dispatch
// ---------------------------------------------------------------------------
async fn dispatch_grafana_org(
action: GrafanaOrgAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let grafana = client.grafana();
match action {
GrafanaOrgAction::Get => {
let item = grafana.get_current_org().await?;
output::render(&item, fmt)
}
GrafanaOrgAction::Update { data } => {
let json = output::read_json_input(data.as_deref())?;
grafana.update_org(&json).await?;
output::ok("Updated organization");
Ok(())
}
}
}

View File

@@ -1,5 +1,7 @@
//! Monitoring service clients: Prometheus, Loki, and Grafana. //! Monitoring service clients: Prometheus, Loki, and Grafana.
#[cfg(feature = "cli")]
pub mod cli;
pub mod grafana; pub mod grafana;
pub mod loki; pub mod loki;
pub mod prometheus; pub mod prometheus;

View File

@@ -0,0 +1,337 @@
//! CLI command definitions and dispatch for OpenBao (Vault).
use std::collections::HashMap;
use clap::Subcommand;
use crate::error::Result;
use crate::output::{self, OutputFormat};
// ═══════════════════════════════════════════════════════════════════════════
// Command tree
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum VaultCommand {
/// Show seal status.
Status,
/// Initialize the vault.
Init {
/// Number of key shares.
#[arg(long, default_value = "5")]
key_shares: u32,
/// Key threshold for unseal.
#[arg(long, default_value = "3")]
key_threshold: u32,
},
/// Unseal the vault with a key share.
Unseal {
/// Unseal key share.
#[arg(short, long)]
key: String,
},
/// KV secrets engine operations.
Kv {
#[command(subcommand)]
action: KvAction,
},
/// Write a policy from HCL.
Policy {
#[command(subcommand)]
action: PolicyAction,
},
/// Auth method management.
Auth {
#[command(subcommand)]
action: AuthAction,
},
/// Secrets engine management.
Secrets {
#[command(subcommand)]
action: SecretsAction,
},
/// Read from an arbitrary API path.
Read {
/// API path (e.g. "auth/token/lookup-self").
#[arg(short, long)]
path: String,
},
/// Write to an arbitrary API path.
Write {
/// API path.
#[arg(short, long)]
path: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum KvAction {
/// Get a secret.
Get {
/// Secret path.
#[arg(short, long)]
path: String,
/// Secrets engine mount point.
#[arg(long, default_value = "secret")]
mount: String,
},
/// Put (create/overwrite) a secret.
Put {
/// Secret path.
#[arg(short, long)]
path: String,
/// Secrets engine mount point.
#[arg(long, default_value = "secret")]
mount: String,
/// JSON object or key=value pairs.
#[arg(short, long)]
data: Option<String>,
},
/// Patch (merge) fields into a secret.
Patch {
/// Secret path.
#[arg(short, long)]
path: String,
/// Secrets engine mount point.
#[arg(long, default_value = "secret")]
mount: String,
/// JSON object or key=value pairs.
#[arg(short, long)]
data: Option<String>,
},
/// Delete a secret.
Delete {
/// Secret path.
#[arg(short, long)]
path: String,
/// Secrets engine mount point.
#[arg(long, default_value = "secret")]
mount: String,
},
}
#[derive(Subcommand, Debug)]
pub enum PolicyAction {
/// Write a policy from HCL.
Write {
/// Policy name.
#[arg(short, long)]
name: String,
/// HCL policy body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum AuthAction {
/// Enable an auth method.
Enable {
/// Mount path (e.g. "kubernetes").
#[arg(short, long)]
path: String,
/// Auth method type (e.g. "kubernetes").
#[arg(short = 't', long = "type")]
method_type: String,
},
}
#[derive(Subcommand, Debug)]
pub enum SecretsAction {
/// Enable a secrets engine.
Enable {
/// Mount path (e.g. "database").
#[arg(short, long)]
path: String,
/// Engine type (e.g. "database", "kv").
#[arg(short = 't', long = "type")]
engine_type: String,
},
}
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
/// Parse a `--data` value as either a JSON object `{"k":"v"}` or as
/// `key=value` pairs (one per line or comma-separated), returning a
/// `HashMap<String, String>`.
fn parse_kv_data(raw: &str) -> Result<HashMap<String, String>> {
// Try JSON first
if let Ok(val) = serde_json::from_str::<serde_json::Value>(raw) {
if let Some(obj) = val.as_object() {
let map: HashMap<String, String> = obj
.iter()
.map(|(k, v)| {
let s = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
(k.clone(), s)
})
.collect();
return Ok(map);
}
}
// Fallback: key=value pairs separated by newlines or commas
let mut map = HashMap::new();
for token in raw.split(|c| c == '\n' || c == ',') {
let token = token.trim();
if token.is_empty() {
continue;
}
if let Some((k, v)) = token.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string());
} else {
return Err(crate::error::SunbeamError::Other(format!(
"invalid key=value pair: {token}"
)));
}
}
Ok(map)
}
/// Read kv data from `--data` flag or stdin.
fn read_kv_input(flag: Option<&str>) -> Result<HashMap<String, String>> {
let raw = match flag {
Some("-") | None => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
}
Some(v) => v.to_string(),
};
parse_kv_data(&raw)
}
/// Read raw text from `--data` flag or stdin (for policy HCL).
fn read_text_input(flag: Option<&str>) -> Result<String> {
match flag {
Some("-") | None => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
Ok(buf)
}
Some(v) => Ok(v.to_string()),
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Dispatch
// ═══════════════════════════════════════════════════════════════════════════
pub async fn dispatch(
cmd: VaultCommand,
bao: &super::BaoClient,
fmt: OutputFormat,
) -> Result<()> {
match cmd {
// -- Status ---------------------------------------------------------
VaultCommand::Status => {
let status = bao.seal_status().await?;
output::render(&status, fmt)
}
// -- Init -----------------------------------------------------------
VaultCommand::Init {
key_shares,
key_threshold,
} => {
let resp = bao.init(key_shares, key_threshold).await?;
output::render(&resp, fmt)
}
// -- Unseal ---------------------------------------------------------
VaultCommand::Unseal { key } => {
let resp = bao.unseal(&key).await?;
output::render(&resp, fmt)
}
// -- KV operations --------------------------------------------------
VaultCommand::Kv { action } => match action {
KvAction::Get { path, mount } => {
let data = bao.kv_get(&mount, &path).await?;
match data {
Some(map) => output::render(&map, fmt),
None => {
output::ok(&format!("No secret found at {mount}/data/{path}"));
Ok(())
}
}
}
KvAction::Put { path, mount, data } => {
let map = read_kv_input(data.as_deref())?;
bao.kv_put(&mount, &path, &map).await?;
output::ok(&format!("Written to {mount}/data/{path}"));
Ok(())
}
KvAction::Patch { path, mount, data } => {
let map = read_kv_input(data.as_deref())?;
bao.kv_patch(&mount, &path, &map).await?;
output::ok(&format!("Patched {mount}/data/{path}"));
Ok(())
}
KvAction::Delete { path, mount } => {
bao.kv_delete(&mount, &path).await?;
output::ok(&format!("Deleted {mount}/data/{path}"));
Ok(())
}
},
// -- Policy ---------------------------------------------------------
VaultCommand::Policy { action } => match action {
PolicyAction::Write { name, data } => {
let hcl = read_text_input(data.as_deref())?;
bao.write_policy(&name, &hcl).await?;
output::ok(&format!("Written policy {name}"));
Ok(())
}
},
// -- Auth -----------------------------------------------------------
VaultCommand::Auth { action } => match action {
AuthAction::Enable { path, method_type } => {
bao.auth_enable(&path, &method_type).await?;
output::ok(&format!("Enabled auth method {method_type} at {path}"));
Ok(())
}
},
// -- Secrets engine -------------------------------------------------
VaultCommand::Secrets { action } => match action {
SecretsAction::Enable { path, engine_type } => {
bao.enable_secrets_engine(&path, &engine_type).await?;
output::ok(&format!("Enabled secrets engine {engine_type} at {path}"));
Ok(())
}
},
// -- Read -----------------------------------------------------------
VaultCommand::Read { path } => {
let data = bao.read(&path).await?;
match data {
Some(val) => output::render(&val, fmt),
None => {
output::ok(&format!("No data at {path}"));
Ok(())
}
}
}
// -- Write ----------------------------------------------------------
VaultCommand::Write { path, data } => {
let json = output::read_json_input(data.as_deref())?;
let resp = bao.write(&path, &json).await?;
if resp.is_null() {
output::ok(&format!("Written to {path}"));
Ok(())
} else {
output::render(&resp, fmt)
}
}
}
}

View File

@@ -3,6 +3,9 @@
//! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the //! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the
//! Python version with direct HTTP API calls via port-forward to openbao:8200. //! Python version with direct HTTP API calls via port-forward to openbao:8200.
#[cfg(feature = "cli")]
pub mod cli;
use crate::error::{Result, ResultExt}; use crate::error::{Result, ResultExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -17,13 +20,13 @@ pub struct BaoClient {
// ── API response types ────────────────────────────────────────────────────── // ── API response types ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct InitResponse { pub struct InitResponse {
pub unseal_keys_b64: Vec<String>, pub unseal_keys_b64: Vec<String>,
pub root_token: String, pub root_token: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct SealStatusResponse { pub struct SealStatusResponse {
#[serde(default)] #[serde(default)]
pub initialized: bool, pub initialized: bool,
@@ -37,7 +40,7 @@ pub struct SealStatusResponse {
pub n: u32, pub n: u32,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct UnsealResponse { pub struct UnsealResponse {
#[serde(default)] #[serde(default)]
pub sealed: bool, pub sealed: bool,

View File

@@ -0,0 +1,722 @@
//! CLI commands for OpenSearch.
use clap::Subcommand;
use serde_json::json;
use crate::error::Result;
use crate::output::{self, OutputFormat};
// ---------------------------------------------------------------------------
// Client helper
// ---------------------------------------------------------------------------
async fn os_client(domain: &str) -> Result<super::OpenSearchClient> {
let token = crate::auth::get_token().await?;
let mut c = super::OpenSearchClient::connect(domain);
c.set_token(token);
Ok(c)
}
// ---------------------------------------------------------------------------
// Top-level command enum
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum SearchCommand {
/// Document operations.
Doc {
#[command(subcommand)]
action: DocAction,
},
/// Search an index.
Query {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// Query string (wrapped in query_string query).
#[arg(short, long)]
query: Option<String>,
/// Raw JSON body (overrides --query).
#[arg(short, long)]
data: Option<String>,
},
/// Count documents in an index.
Count {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// Raw JSON body for the count query.
#[arg(short, long)]
data: Option<String>,
},
/// Index management.
Index {
#[command(subcommand)]
action: IndexAction,
},
/// Cluster operations.
Cluster {
#[command(subcommand)]
action: ClusterAction,
},
/// Node operations.
Node {
#[command(subcommand)]
action: NodeAction,
},
/// Ingest pipeline management.
Ingest {
#[command(subcommand)]
action: IngestAction,
},
/// Snapshot management.
Snapshot {
#[command(subcommand)]
action: SnapshotAction,
},
}
// ---------------------------------------------------------------------------
// Document
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum DocAction {
/// Get a document by ID.
Get {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// Document ID.
#[arg(short, long)]
id: String,
},
/// Index (create/replace) a document.
Create {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// Document ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a document by ID.
Update {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// Document ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a document by ID.
Delete {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// Document ID.
#[arg(short, long)]
id: String,
},
/// Check if a document exists.
Exists {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// Document ID.
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// Index
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum IndexAction {
/// List all indices.
List,
/// Create an index.
Create {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// JSON body (settings/mappings, or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Get index metadata.
Get {
/// Index name.
#[arg(short = 'x', long)]
index: String,
},
/// Delete an index.
Delete {
/// Index name.
#[arg(short = 'x', long)]
index: String,
},
/// Check if an index exists.
Exists {
/// Index name.
#[arg(short = 'x', long)]
index: String,
},
/// Open a closed index.
Open {
/// Index name.
#[arg(short = 'x', long)]
index: String,
},
/// Close an index.
Close {
/// Index name.
#[arg(short = 'x', long)]
index: String,
},
/// Index mapping operations.
Mapping {
#[command(subcommand)]
action: MappingAction,
},
/// Index settings operations.
Settings {
#[command(subcommand)]
action: SettingsAction,
},
}
#[derive(Subcommand, Debug)]
pub enum MappingAction {
/// Get index mapping.
Get {
/// Index name.
#[arg(short = 'x', long)]
index: String,
},
/// Update index mapping.
Update {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum SettingsAction {
/// Get index settings.
Get {
/// Index name.
#[arg(short = 'x', long)]
index: String,
},
/// Update index settings.
Update {
/// Index name.
#[arg(short = 'x', long)]
index: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
}
// ---------------------------------------------------------------------------
// Cluster
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum ClusterAction {
/// Cluster health.
Health,
/// Cluster state.
State,
/// Cluster stats.
Stats,
}
// ---------------------------------------------------------------------------
// Node
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum NodeAction {
/// List nodes.
List,
/// Node stats.
Stats,
/// Node info.
Info,
}
// ---------------------------------------------------------------------------
// Ingest
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum IngestAction {
/// Ingest pipeline management.
Pipeline {
#[command(subcommand)]
action: PipelineAction,
},
}
#[derive(Subcommand, Debug)]
pub enum PipelineAction {
/// List all pipelines.
List,
/// Create or update a pipeline.
Create {
/// Pipeline ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Get a pipeline.
Get {
/// Pipeline ID.
#[arg(short, long)]
id: String,
},
/// Delete a pipeline.
Delete {
/// Pipeline ID.
#[arg(short, long)]
id: String,
},
}
// ---------------------------------------------------------------------------
// Snapshot
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum SnapshotAction {
/// List snapshots in a repository.
List {
/// Repository name.
#[arg(long)]
repo: String,
},
/// Create a snapshot.
Create {
/// Repository name.
#[arg(long)]
repo: String,
/// Snapshot name.
#[arg(long)]
name: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a snapshot.
Delete {
/// Repository name.
#[arg(long)]
repo: String,
/// Snapshot name.
#[arg(long)]
name: String,
},
/// Restore a snapshot.
Restore {
/// Repository name.
#[arg(long)]
repo: String,
/// Snapshot name.
#[arg(long)]
name: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Snapshot repository management.
Repo {
#[command(subcommand)]
action: RepoAction,
},
}
#[derive(Subcommand, Debug)]
pub enum RepoAction {
/// Create or update a snapshot repository.
Create {
/// Repository name.
#[arg(long)]
name: String,
/// JSON body (or "-" for stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a snapshot repository.
Delete {
/// Repository name.
#[arg(long)]
name: String,
},
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: SearchCommand,
client: &crate::client::SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let c = os_client(client.domain()).await?;
match cmd {
// -----------------------------------------------------------------
// Documents
// -----------------------------------------------------------------
SearchCommand::Doc { action } => match action {
DocAction::Get { index, id } => {
let resp = c.get_doc(&index, &id).await?;
output::render(&resp, fmt)
}
DocAction::Create { index, id, data } => {
let body = output::read_json_input(data.as_deref())?;
let resp = c.index_doc(&index, &id, &body).await?;
output::render(&resp, fmt)
}
DocAction::Update { index, id, data } => {
let body = output::read_json_input(data.as_deref())?;
let resp = c.update_doc(&index, &id, &body).await?;
output::render(&resp, fmt)
}
DocAction::Delete { index, id } => {
let resp = c.delete_doc(&index, &id).await?;
output::render(&resp, fmt)
}
DocAction::Exists { index, id } => {
let exists = c.head_doc(&index, &id).await?;
println!("{exists}");
Ok(())
}
},
// -----------------------------------------------------------------
// Search query
// -----------------------------------------------------------------
SearchCommand::Query { index, query, data } => {
let body = if let Some(d) = data.as_deref() {
output::read_json_input(Some(d))?
} else if let Some(ref q) = query {
json!({ "query": { "query_string": { "query": q } } })
} else {
json!({ "query": { "match_all": {} } })
};
let resp = c.search(&index, &body).await?;
output::render(&resp, fmt)
}
// -----------------------------------------------------------------
// Count
// -----------------------------------------------------------------
SearchCommand::Count { index, data } => {
let body = if let Some(d) = data.as_deref() {
output::read_json_input(Some(d))?
} else {
json!({ "query": { "match_all": {} } })
};
let resp = c.count(&index, &body).await?;
output::render(&resp, fmt)
}
// -----------------------------------------------------------------
// Index management
// -----------------------------------------------------------------
SearchCommand::Index { action } => dispatch_index(&c, action, fmt).await,
// -----------------------------------------------------------------
// Cluster
// -----------------------------------------------------------------
SearchCommand::Cluster { action } => match action {
ClusterAction::Health => {
let resp = c.cluster_health().await?;
output::render(&resp, fmt)
}
ClusterAction::State => {
let resp = c.cluster_state().await?;
output::render(&resp, fmt)
}
ClusterAction::Stats => {
let resp = c.cluster_stats().await?;
output::render(&resp, fmt)
}
},
// -----------------------------------------------------------------
// Nodes
// -----------------------------------------------------------------
SearchCommand::Node { action } => match action {
NodeAction::List => {
let rows = c.cat_nodes().await?;
output::render_list(
&rows,
&["NAME", "IP", "HEAP%", "RAM%", "CPU", "ROLE", "MASTER"],
|n| {
vec![
n.name.clone().unwrap_or_default(),
n.ip.clone().unwrap_or_default(),
n.heap_percent.clone().unwrap_or_default(),
n.ram_percent.clone().unwrap_or_default(),
n.cpu.clone().unwrap_or_default(),
n.node_role.clone().unwrap_or_default(),
n.master.clone().unwrap_or_default(),
]
},
fmt,
)
}
NodeAction::Stats => {
let resp = c.nodes_stats().await?;
output::render(&resp, fmt)
}
NodeAction::Info => {
let resp = c.nodes_info().await?;
output::render(&resp, fmt)
}
},
// -----------------------------------------------------------------
// Ingest
// -----------------------------------------------------------------
SearchCommand::Ingest { action } => match action {
IngestAction::Pipeline { action } => match action {
PipelineAction::List => {
let resp = c.get_all_pipelines().await?;
output::render(&resp, fmt)
}
PipelineAction::Create { id, data } => {
let body = output::read_json_input(data.as_deref())?;
let resp = c.create_pipeline(&id, &body).await?;
output::render(&resp, fmt)
}
PipelineAction::Get { id } => {
let resp = c.get_pipeline(&id).await?;
output::render(&resp, fmt)
}
PipelineAction::Delete { id } => {
let resp = c.delete_pipeline(&id).await?;
output::render(&resp, fmt)
}
},
},
// -----------------------------------------------------------------
// Snapshots
// -----------------------------------------------------------------
SearchCommand::Snapshot { action } => dispatch_snapshot(&c, action, fmt).await,
}
}
// ---------------------------------------------------------------------------
// Index sub-dispatch
// ---------------------------------------------------------------------------
async fn dispatch_index(
c: &super::OpenSearchClient,
action: IndexAction,
fmt: OutputFormat,
) -> Result<()> {
match action {
IndexAction::List => {
let rows = c.cat_indices().await?;
output::render_list(
&rows,
&["HEALTH", "STATUS", "INDEX", "PRI", "REP", "DOCS", "SIZE"],
|i| {
vec![
i.health.clone().unwrap_or_default(),
i.status.clone().unwrap_or_default(),
i.index.clone().unwrap_or_default(),
i.pri.clone().unwrap_or_default(),
i.rep.clone().unwrap_or_default(),
i.docs_count.clone().unwrap_or_default(),
i.store_size.clone().unwrap_or_default(),
]
},
fmt,
)
}
IndexAction::Create { index, data } => {
let body = data
.as_deref()
.map(|d| output::read_json_input(Some(d)))
.transpose()?
.unwrap_or_else(|| json!({}));
let resp = c.create_index(&index, &body).await?;
output::render(&resp, fmt)
}
IndexAction::Get { index } => {
let resp = c.get_index(&index).await?;
output::render(&resp, fmt)
}
IndexAction::Delete { index } => {
let resp = c.delete_index(&index).await?;
output::render(&resp, fmt)
}
IndexAction::Exists { index } => {
let exists = c.index_exists(&index).await?;
println!("{exists}");
Ok(())
}
IndexAction::Open { index } => {
let resp = c.open_index(&index).await?;
output::render(&resp, fmt)
}
IndexAction::Close { index } => {
let resp = c.close_index(&index).await?;
output::render(&resp, fmt)
}
IndexAction::Mapping { action } => match action {
MappingAction::Get { index } => {
let resp = c.get_mapping(&index).await?;
output::render(&resp, fmt)
}
MappingAction::Update { index, data } => {
let body = output::read_json_input(data.as_deref())?;
let resp = c.put_mapping(&index, &body).await?;
output::render(&resp, fmt)
}
},
IndexAction::Settings { action } => match action {
SettingsAction::Get { index } => {
let resp = c.get_settings(&index).await?;
output::render(&resp, fmt)
}
SettingsAction::Update { index, data } => {
let body = output::read_json_input(data.as_deref())?;
let resp = c.update_settings(&index, &body).await?;
output::render(&resp, fmt)
}
},
}
}
// ---------------------------------------------------------------------------
// Snapshot sub-dispatch
// ---------------------------------------------------------------------------
async fn dispatch_snapshot(
c: &super::OpenSearchClient,
action: SnapshotAction,
fmt: OutputFormat,
) -> Result<()> {
match action {
SnapshotAction::List { repo } => {
let resp = c.list_snapshots(&repo).await?;
output::render(&resp, fmt)
}
SnapshotAction::Create { repo, name, data } => {
let body = data
.as_deref()
.map(|d| output::read_json_input(Some(d)))
.transpose()?
.unwrap_or_else(|| json!({}));
let resp = c.create_snapshot(&repo, &name, &body).await?;
output::render(&resp, fmt)
}
SnapshotAction::Delete { repo, name } => {
let resp = c.delete_snapshot(&repo, &name).await?;
output::render(&resp, fmt)
}
SnapshotAction::Restore { repo, name, data } => {
let body = data
.as_deref()
.map(|d| output::read_json_input(Some(d)))
.transpose()?
.unwrap_or_else(|| json!({}));
let resp = c.restore_snapshot(&repo, &name, &body).await?;
output::render(&resp, fmt)
}
SnapshotAction::Repo { action } => match action {
RepoAction::Create { name, data } => {
let body = output::read_json_input(data.as_deref())?;
let resp = c.create_snapshot_repo(&name, &body).await?;
output::render(&resp, fmt)
}
RepoAction::Delete { name } => {
let resp = c.delete_snapshot_repo(&name).await?;
output::render(&resp, fmt)
}
},
}
}

View File

@@ -1,5 +1,7 @@
//! OpenSearch client. //! OpenSearch client.
#[cfg(feature = "cli")]
pub mod cli;
pub mod types; pub mod types;
use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::client::{AuthMethod, HttpTransport, ServiceClient};

View File

@@ -0,0 +1,268 @@
use clap::Subcommand;
use std::io::Write;
use crate::client::SunbeamClient;
use crate::error::Result;
use crate::output::{self, OutputFormat};
// ---------------------------------------------------------------------------
// Top-level StorageCommand
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum StorageCommand {
/// Bucket operations.
Bucket {
#[command(subcommand)]
action: BucketAction,
},
/// Object operations.
Object {
#[command(subcommand)]
action: ObjectAction,
},
}
// ---------------------------------------------------------------------------
// Bucket sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum BucketAction {
/// List all buckets.
List,
/// Create a bucket.
Create {
/// Bucket name.
#[arg(short, long)]
bucket: String,
},
/// Delete a bucket.
Delete {
/// Bucket name.
#[arg(short, long)]
bucket: String,
},
/// Check if a bucket exists.
Exists {
/// Bucket name.
#[arg(short, long)]
bucket: String,
},
}
// ---------------------------------------------------------------------------
// Object sub-commands
// ---------------------------------------------------------------------------
#[derive(Subcommand, Debug)]
pub enum ObjectAction {
/// List objects in a bucket.
List {
/// Bucket name.
#[arg(short, long)]
bucket: String,
/// Filter by key prefix.
#[arg(long)]
prefix: Option<String>,
/// Maximum number of keys to return.
#[arg(long)]
max_keys: Option<u32>,
},
/// Download an object.
Get {
/// Bucket name.
#[arg(short, long)]
bucket: String,
/// Object key.
#[arg(short, long)]
key: String,
/// Write to file instead of stdout.
#[arg(long)]
output_file: Option<String>,
},
/// Upload an object.
Put {
/// Bucket name.
#[arg(short, long)]
bucket: String,
/// Object key.
#[arg(short, long)]
key: String,
/// Content-Type header.
#[arg(long, default_value = "application/octet-stream")]
content_type: String,
/// Path to the file to upload.
#[arg(short, long)]
file: String,
},
/// Delete an object.
Delete {
/// Bucket name.
#[arg(short, long)]
bucket: String,
/// Object key.
#[arg(short, long)]
key: String,
},
/// Check if an object exists.
Exists {
/// Bucket name.
#[arg(short, long)]
bucket: String,
/// Object key.
#[arg(short, long)]
key: String,
},
/// Copy an object.
Copy {
/// Destination bucket name.
#[arg(short, long)]
bucket: String,
/// Destination object key.
#[arg(short, long)]
key: String,
/// Source in the form `/source-bucket/source-key`.
#[arg(short, long)]
source: String,
},
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: StorageCommand,
client: &SunbeamClient,
output: OutputFormat,
) -> Result<()> {
match cmd {
StorageCommand::Bucket { action } => dispatch_bucket(action, client, output).await,
StorageCommand::Object { action } => dispatch_object(action, client, output).await,
}
}
// ---------------------------------------------------------------------------
// Bucket dispatch
// ---------------------------------------------------------------------------
async fn dispatch_bucket(
action: BucketAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let s3 = client.s3();
match action {
BucketAction::List => {
let resp = s3.list_buckets().await?;
output::render_list(
&resp.buckets,
&["NAME", "CREATED"],
|b| {
vec![
b.name.clone(),
b.creation_date.clone().unwrap_or_default(),
]
},
fmt,
)
}
BucketAction::Create { bucket } => {
s3.create_bucket(&bucket).await?;
output::ok(&format!("Created bucket {bucket}"));
Ok(())
}
BucketAction::Delete { bucket } => {
s3.delete_bucket(&bucket).await?;
output::ok(&format!("Deleted bucket {bucket}"));
Ok(())
}
BucketAction::Exists { bucket } => {
let exists = s3.head_bucket(&bucket).await?;
output::render(&serde_json::json!({ "exists": exists }), fmt)
}
}
}
// ---------------------------------------------------------------------------
// Object dispatch
// ---------------------------------------------------------------------------
async fn dispatch_object(
action: ObjectAction,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let s3 = client.s3();
match action {
ObjectAction::List {
bucket,
prefix,
max_keys,
} => {
let resp = s3
.list_objects_v2(&bucket, prefix.as_deref(), max_keys)
.await?;
output::render_list(
&resp.contents,
&["KEY", "SIZE", "LAST_MODIFIED", "ETAG"],
|o| {
vec![
o.key.clone(),
o.size.map_or("-".into(), |s| s.to_string()),
o.last_modified.clone().unwrap_or_default(),
o.etag.clone().unwrap_or_default(),
]
},
fmt,
)
}
ObjectAction::Get {
bucket,
key,
output_file,
} => {
let data = s3.get_object(&bucket, &key).await?;
match output_file {
Some(path) => {
std::fs::write(&path, &data)?;
output::ok(&format!("Written {} bytes to {path}", data.len()));
}
None => {
std::io::stdout().write_all(&data)?;
}
}
Ok(())
}
ObjectAction::Put {
bucket,
key,
content_type,
file,
} => {
let data = bytes::Bytes::from(std::fs::read(&file)?);
s3.put_object(&bucket, &key, &content_type, data).await?;
output::ok(&format!("Uploaded {file} to {bucket}/{key}"));
Ok(())
}
ObjectAction::Delete { bucket, key } => {
s3.delete_object(&bucket, &key).await?;
output::ok(&format!("Deleted {bucket}/{key}"));
Ok(())
}
ObjectAction::Exists { bucket, key } => {
let exists = s3.head_object(&bucket, &key).await?;
output::render(&serde_json::json!({ "exists": exists }), fmt)
}
ObjectAction::Copy {
bucket,
key,
source,
} => {
s3.copy_object(&bucket, &key, &source).await?;
output::ok(&format!("Copied {source} to {bucket}/{key}"));
Ok(())
}
}
}

View File

@@ -2,6 +2,9 @@
pub mod types; pub mod types;
#[cfg(feature = "cli")]
pub mod cli;
use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::client::{AuthMethod, HttpTransport, ServiceClient};
use crate::error::{Result, ResultExt, SunbeamError}; use crate::error::{Result, ResultExt, SunbeamError};
use bytes::Bytes; use bytes::Bytes;