From f86780528084674d400252597ffee1f7537c3fc3 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 21 Mar 2026 22:18:58 +0000 Subject: [PATCH] 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). --- sunbeam-sdk/src/gitea/cli.rs | 930 +++++++++++++++++++++++ sunbeam-sdk/src/gitea/mod.rs | 3 + sunbeam-sdk/src/identity/cli.rs | 726 ++++++++++++++++++ sunbeam-sdk/src/identity/mod.rs | 3 + sunbeam-sdk/src/lasuite/cli.rs | 1147 +++++++++++++++++++++++++++++ sunbeam-sdk/src/lasuite/mod.rs | 3 + sunbeam-sdk/src/matrix/cli.rs | 773 +++++++++++++++++++ sunbeam-sdk/src/matrix/mod.rs | 3 + sunbeam-sdk/src/media/cli.rs | 351 +++++++++ sunbeam-sdk/src/media/mod.rs | 3 + sunbeam-sdk/src/monitoring/cli.rs | 895 ++++++++++++++++++++++ sunbeam-sdk/src/monitoring/mod.rs | 2 + sunbeam-sdk/src/openbao/cli.rs | 337 +++++++++ sunbeam-sdk/src/openbao/mod.rs | 9 +- sunbeam-sdk/src/search/cli.rs | 722 ++++++++++++++++++ sunbeam-sdk/src/search/mod.rs | 2 + sunbeam-sdk/src/storage/cli.rs | 268 +++++++ sunbeam-sdk/src/storage/mod.rs | 3 + 18 files changed, 6177 insertions(+), 3 deletions(-) create mode 100644 sunbeam-sdk/src/gitea/cli.rs create mode 100644 sunbeam-sdk/src/identity/cli.rs create mode 100644 sunbeam-sdk/src/lasuite/cli.rs create mode 100644 sunbeam-sdk/src/matrix/cli.rs create mode 100644 sunbeam-sdk/src/media/cli.rs create mode 100644 sunbeam-sdk/src/monitoring/cli.rs create mode 100644 sunbeam-sdk/src/openbao/cli.rs create mode 100644 sunbeam-sdk/src/search/cli.rs create mode 100644 sunbeam-sdk/src/storage/cli.rs diff --git a/sunbeam-sdk/src/gitea/cli.rs b/sunbeam-sdk/src/gitea/cli.rs new file mode 100644 index 0000000..75dd967 --- /dev/null +++ b/sunbeam-sdk/src/gitea/cli.rs @@ -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, + }, + /// Search repositories. + Search { + /// Search query. + #[arg(short, long)] + query: String, + /// Max results. + #[arg(long)] + limit: Option, + }, + /// 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, + /// JSON body or "-" for stdin. + #[arg(long)] + data: Option, + }, + /// Update a repository. + Update { + /// Repository (owner/repo). + #[arg(short, long)] + repo: String, + /// JSON body or "-" for stdin. + #[arg(long)] + data: Option, + }, + /// 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, + }, + /// 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, + }, +} + +// -- 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, + }, + /// 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, + }, + /// 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, + }, + /// 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, + }, + /// 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, + }, +} + +// -- 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, + }, + /// 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, + }, +} + +// -- User ------------------------------------------------------------------- + +#[derive(Subcommand, Debug)] +pub enum UserAction { + /// Search users. + Search { + /// Search query. + #[arg(short, long)] + query: String, + /// Max results. + #[arg(long)] + limit: Option, + }, + /// 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, + }, +} + +// -- 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 { + 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 { + vec![ + format!("#{}", i.number), + i.title.clone(), + i.state.clone(), + i.created_at.clone().unwrap_or_default(), + ] +} + +fn pr_row(pr: &PullRequest) -> Vec { + vec![ + format!("#{}", pr.number), + pr.title.clone(), + pr.state.clone(), + if pr.merged { "yes" } else { "no" }.into(), + ] +} + +fn branch_row(b: &Branch) -> Vec { + vec![ + b.name.clone(), + if b.protected { "yes" } else { "no" }.into(), + b.commit + .as_ref() + .map(|c| c.id.chars().take(8).collect::()) + .unwrap_or_default(), + ] +} + +fn org_row(o: &Organization) -> Vec { + vec![ + o.username.clone(), + o.full_name.clone(), + o.visibility.clone(), + ] +} + +fn user_row(u: &User) -> Vec { + vec![ + u.login.clone(), + u.full_name.clone(), + u.email.clone(), + if u.is_admin { "admin" } else { "" }.into(), + ] +} + +fn notification_row(n: &Notification) -> Vec { + 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( + ¬es, + &["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], ""); + } +} diff --git a/sunbeam-sdk/src/gitea/mod.rs b/sunbeam-sdk/src/gitea/mod.rs index db2b85d..3ce0a7f 100644 --- a/sunbeam-sdk/src/gitea/mod.rs +++ b/sunbeam-sdk/src/gitea/mod.rs @@ -2,6 +2,9 @@ pub mod types; +#[cfg(feature = "cli")] +pub mod cli; + use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::error::Result; use k8s_openapi::api::core::v1::Pod; diff --git a/sunbeam-sdk/src/identity/cli.rs b/sunbeam-sdk/src/identity/cli.rs new file mode 100644 index 0000000..5b8f84b --- /dev/null +++ b/sunbeam-sdk/src/identity/cli.rs @@ -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, + }, + /// Log in to SSO only. + Sso { + #[arg(long)] + domain: Option, + }, + /// Log in to Gitea only. + Git { + #[arg(long)] + domain: Option, + }, + /// Log out. + Logout, + /// Show auth status. + Status, +} + +// --------------------------------------------------------------------------- +// Identity sub-commands +// --------------------------------------------------------------------------- + +#[derive(Subcommand, Debug)] +pub enum IdentityAction { + /// List identities. + List { + #[arg(long)] + page: Option, + #[arg(long, default_value = "20")] + page_size: Option, + }, + /// 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, + }, + /// 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, + }, + /// 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, + #[arg(long)] + page_token: Option, + #[arg(long)] + active: Option, + }, + /// 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, + }, + /// Create a recovery link for an identity. + CreateLink { + #[arg(short, long)] + id: String, + /// Duration string (e.g. "24h", "1h30m"). + #[arg(long)] + expires_in: Option, + }, +} + +// --------------------------------------------------------------------------- +// 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, + #[arg(long)] + page_token: Option, + }, + /// 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, + #[arg(long)] + offset: Option, + }, + /// 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, + }, + /// 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, + }, + /// 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, + }, + /// 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, + }, + /// 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(()) + } + } +} diff --git a/sunbeam-sdk/src/identity/mod.rs b/sunbeam-sdk/src/identity/mod.rs index 434c5f2..3e7f1bf 100644 --- a/sunbeam-sdk/src/identity/mod.rs +++ b/sunbeam-sdk/src/identity/mod.rs @@ -2,6 +2,9 @@ pub mod types; +#[cfg(feature = "cli")] +pub mod cli; + use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::error::Result; use reqwest::Method; diff --git a/sunbeam-sdk/src/lasuite/cli.rs b/sunbeam-sdk/src/lasuite/cli.rs new file mode 100644 index 0000000..3d3edaa --- /dev/null +++ b/sunbeam-sdk/src/lasuite/cli.rs @@ -0,0 +1,1147 @@ +//! CLI command definitions and dispatch for all 7 La Suite services. + +use clap::Subcommand; + +use crate::client::SunbeamClient; +use crate::error::Result; +use crate::output::{self, OutputFormat}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Helper: build an authenticated La Suite client +// ═══════════════════════════════════════════════════════════════════════════ + +async fn people_client(domain: &str) -> Result { + let token = crate::auth::get_token().await?; + Ok(super::PeopleClient::connect(domain).with_token(&token)) +} + +async fn docs_client(domain: &str) -> Result { + let token = crate::auth::get_token().await?; + Ok(super::DocsClient::connect(domain).with_token(&token)) +} + +async fn meet_client(domain: &str) -> Result { + let token = crate::auth::get_token().await?; + Ok(super::MeetClient::connect(domain).with_token(&token)) +} + +async fn drive_client(domain: &str) -> Result { + let token = crate::auth::get_token().await?; + Ok(super::DriveClient::connect(domain).with_token(&token)) +} + +async fn messages_client(domain: &str) -> Result { + let token = crate::auth::get_token().await?; + Ok(super::MessagesClient::connect(domain).with_token(&token)) +} + +async fn calendars_client(domain: &str) -> Result { + let token = crate::auth::get_token().await?; + Ok(super::CalendarsClient::connect(domain).with_token(&token)) +} + +async fn find_client(domain: &str) -> Result { + let token = crate::auth::get_token().await?; + Ok(super::FindClient::connect(domain).with_token(&token)) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// People +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Subcommand, Debug)] +pub enum PeopleCommand { + /// Contact management. + Contact { + #[command(subcommand)] + action: ContactAction, + }, + /// Team management. + Team { + #[command(subcommand)] + action: TeamAction, + }, + /// Service provider listing. + ServiceProvider { + #[command(subcommand)] + action: ServiceProviderAction, + }, + /// Mail domain listing. + MailDomain { + #[command(subcommand)] + action: MailDomainAction, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ContactAction { + /// List contacts. + List { + #[arg(long)] + page: Option, + }, + /// Get a contact by ID. + Get { + #[arg(short, long)] + id: String, + }, + /// Create a contact from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Update a contact from JSON. + Update { + #[arg(short, long)] + id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Delete a contact. + Delete { + #[arg(short, long)] + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum TeamAction { + /// List teams. + List { + #[arg(long)] + page: Option, + }, + /// Get a team by ID. + Get { + #[arg(short, long)] + id: String, + }, + /// Create a team from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ServiceProviderAction { + /// List service providers. + List, +} + +#[derive(Subcommand, Debug)] +pub enum MailDomainAction { + /// List mail domains. + List, +} + +pub async fn dispatch_people( + cmd: PeopleCommand, + client: &SunbeamClient, + fmt: OutputFormat, +) -> Result<()> { + let people = people_client(client.domain()).await?; + match cmd { + PeopleCommand::Contact { action } => match action { + ContactAction::List { page } => { + let page_data = people.list_contacts(page).await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "EMAIL", "ORGANIZATION"], + |c| { + vec![ + c.id.clone(), + format!( + "{} {}", + c.first_name.as_deref().unwrap_or(""), + c.last_name.as_deref().unwrap_or("") + ) + .trim() + .to_string(), + c.email.clone().unwrap_or_default(), + c.organization.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + ContactAction::Get { id } => { + let item = people.get_contact(&id).await?; + output::render(&item, fmt) + } + ContactAction::Create { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = people.create_contact(&json).await?; + output::render(&item, fmt) + } + ContactAction::Update { id, data } => { + let json = output::read_json_input(data.as_deref())?; + let item = people.update_contact(&id, &json).await?; + output::render(&item, fmt) + } + ContactAction::Delete { id } => { + people.delete_contact(&id).await?; + output::ok(&format!("Deleted contact {id}")); + Ok(()) + } + }, + PeopleCommand::Team { action } => match action { + TeamAction::List { page } => { + let page_data = people.list_teams(page).await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "DESCRIPTION"], + |t| { + vec![ + t.id.clone(), + t.name.clone().unwrap_or_default(), + t.description.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + TeamAction::Get { id } => { + let item = people.get_team(&id).await?; + output::render(&item, fmt) + } + TeamAction::Create { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = people.create_team(&json).await?; + output::render(&item, fmt) + } + }, + PeopleCommand::ServiceProvider { action } => match action { + ServiceProviderAction::List => { + let page_data = people.list_service_providers().await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "BASE_URL"], + |sp| { + vec![ + sp.id.clone(), + sp.name.clone().unwrap_or_default(), + sp.base_url.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + }, + PeopleCommand::MailDomain { action } => match action { + MailDomainAction::List => { + let page_data = people.list_mail_domains().await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "STATUS"], + |md| { + vec![ + md.id.clone(), + md.name.clone().unwrap_or_default(), + md.status.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + }, + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Docs +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Subcommand, Debug)] +pub enum DocsCommand { + /// Document management. + Document { + #[command(subcommand)] + action: DocumentAction, + }, + /// Template management. + Template { + #[command(subcommand)] + action: TemplateAction, + }, + /// Version history. + Version { + #[command(subcommand)] + action: VersionAction, + }, + /// Invite a user to a document. + Invite { + /// Document ID. + #[arg(short, long)] + id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum DocumentAction { + /// List documents. + List { + #[arg(long)] + page: Option, + }, + /// Get a document by ID. + Get { + #[arg(short, long)] + id: String, + }, + /// Create a document from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Update a document from JSON. + Update { + #[arg(short, long)] + id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Delete a document. + Delete { + #[arg(short, long)] + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum TemplateAction { + /// List templates. + List { + #[arg(long)] + page: Option, + }, + /// Create a template from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum VersionAction { + /// List versions of a document. + List { + /// Document ID. + #[arg(short, long)] + id: String, + }, +} + +pub async fn dispatch_docs( + cmd: DocsCommand, + client: &SunbeamClient, + fmt: OutputFormat, +) -> Result<()> { + let docs = docs_client(client.domain()).await?; + match cmd { + DocsCommand::Document { action } => match action { + DocumentAction::List { page } => { + let page_data = docs.list_documents(page).await?; + output::render_list( + &page_data.results, + &["ID", "TITLE", "PUBLIC", "UPDATED"], + |d| { + vec![ + d.id.clone(), + d.title.clone().unwrap_or_default(), + d.is_public.map_or("-".into(), |p| p.to_string()), + d.updated_at.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + DocumentAction::Get { id } => { + let item = docs.get_document(&id).await?; + output::render(&item, fmt) + } + DocumentAction::Create { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = docs.create_document(&json).await?; + output::render(&item, fmt) + } + DocumentAction::Update { id, data } => { + let json = output::read_json_input(data.as_deref())?; + let item = docs.update_document(&id, &json).await?; + output::render(&item, fmt) + } + DocumentAction::Delete { id } => { + docs.delete_document(&id).await?; + output::ok(&format!("Deleted document {id}")); + Ok(()) + } + }, + DocsCommand::Template { action } => match action { + TemplateAction::List { page } => { + let page_data = docs.list_templates(page).await?; + output::render_list( + &page_data.results, + &["ID", "TITLE", "PUBLIC"], + |t| { + vec![ + t.id.clone(), + t.title.clone().unwrap_or_default(), + t.is_public.map_or("-".into(), |p| p.to_string()), + ] + }, + fmt, + ) + } + TemplateAction::Create { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = docs.create_template(&json).await?; + output::render(&item, fmt) + } + }, + DocsCommand::Version { action } => match action { + VersionAction::List { id } => { + let page_data = docs.list_versions(&id).await?; + output::render_list( + &page_data.results, + &["ID", "VERSION", "CREATED"], + |v| { + vec![ + v.id.clone(), + v.version_number.map_or("-".into(), |n| n.to_string()), + v.created_at.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + }, + DocsCommand::Invite { id, data } => { + let json = output::read_json_input(data.as_deref())?; + let item = docs.invite_user(&id, &json).await?; + output::render(&item, fmt) + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Meet +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Subcommand, Debug)] +pub enum MeetCommand { + /// Room management. + Room { + #[command(subcommand)] + action: RoomAction, + }, + /// Recording management. + Recording { + #[command(subcommand)] + action: RecordingAction, + }, +} + +#[derive(Subcommand, Debug)] +pub enum RoomAction { + /// List rooms. + List { + #[arg(long)] + page: Option, + }, + /// Get a room by ID. + Get { + #[arg(short, long)] + id: String, + }, + /// Create a room from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Update a room from JSON. + Update { + #[arg(short, long)] + id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Delete a room. + Delete { + #[arg(short, long)] + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum RecordingAction { + /// List recordings for a room. + List { + /// Room ID. + #[arg(short, long)] + id: String, + }, +} + +pub async fn dispatch_meet( + cmd: MeetCommand, + client: &SunbeamClient, + fmt: OutputFormat, +) -> Result<()> { + let meet = meet_client(client.domain()).await?; + match cmd { + MeetCommand::Room { action } => match action { + RoomAction::List { page } => { + let page_data = meet.list_rooms(page).await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "SLUG", "PUBLIC"], + |r| { + vec![ + r.id.clone(), + r.name.clone().unwrap_or_default(), + r.slug.clone().unwrap_or_default(), + r.is_public.map_or("-".into(), |p| p.to_string()), + ] + }, + fmt, + ) + } + RoomAction::Get { id } => { + let item = meet.get_room(&id).await?; + output::render(&item, fmt) + } + RoomAction::Create { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = meet.create_room(&json).await?; + output::render(&item, fmt) + } + RoomAction::Update { id, data } => { + let json = output::read_json_input(data.as_deref())?; + let item = meet.update_room(&id, &json).await?; + output::render(&item, fmt) + } + RoomAction::Delete { id } => { + meet.delete_room(&id).await?; + output::ok(&format!("Deleted room {id}")); + Ok(()) + } + }, + MeetCommand::Recording { action } => match action { + RecordingAction::List { id } => { + let page_data = meet.list_recordings(&id).await?; + output::render_list( + &page_data.results, + &["ID", "FILENAME", "DURATION", "CREATED"], + |r| { + vec![ + r.id.clone(), + r.filename.clone().unwrap_or_default(), + r.duration.map_or("-".into(), |d| format!("{d:.1}s")), + r.created_at.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + }, + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Drive +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Subcommand, Debug)] +pub enum DriveCommand { + /// File management. + File { + #[command(subcommand)] + action: FileAction, + }, + /// Folder management. + Folder { + #[command(subcommand)] + action: FolderAction, + }, + /// Share a file with a user. + Share { + /// File ID. + #[arg(short, long)] + id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Permission management. + Permission { + #[command(subcommand)] + action: PermissionAction, + }, +} + +#[derive(Subcommand, Debug)] +pub enum FileAction { + /// List files. + List { + #[arg(long)] + page: Option, + }, + /// Get a file by ID. + Get { + #[arg(short, long)] + id: String, + }, + /// Upload a file (JSON metadata). + Upload { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Delete a file. + Delete { + #[arg(short, long)] + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum FolderAction { + /// List folders. + List { + #[arg(long)] + page: Option, + }, + /// Create a folder from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum PermissionAction { + /// List permissions for a file. + List { + /// File ID. + #[arg(short, long)] + id: String, + }, +} + +pub async fn dispatch_drive( + cmd: DriveCommand, + client: &SunbeamClient, + fmt: OutputFormat, +) -> Result<()> { + let drive = drive_client(client.domain()).await?; + match cmd { + DriveCommand::File { action } => match action { + FileAction::List { page } => { + let page_data = drive.list_files(page).await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "SIZE", "MIME_TYPE"], + |f| { + vec![ + f.id.clone(), + f.name.clone().unwrap_or_default(), + f.size.map_or("-".into(), |s| s.to_string()), + f.mime_type.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + FileAction::Get { id } => { + let item = drive.get_file(&id).await?; + output::render(&item, fmt) + } + FileAction::Upload { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = drive.upload_file(&json).await?; + output::render(&item, fmt) + } + FileAction::Delete { id } => { + drive.delete_file(&id).await?; + output::ok(&format!("Deleted file {id}")); + Ok(()) + } + }, + DriveCommand::Folder { action } => match action { + FolderAction::List { page } => { + let page_data = drive.list_folders(page).await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "PARENT_ID"], + |f| { + vec![ + f.id.clone(), + f.name.clone().unwrap_or_default(), + f.parent_id.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + FolderAction::Create { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = drive.create_folder(&json).await?; + output::render(&item, fmt) + } + }, + DriveCommand::Share { id, data } => { + let json = output::read_json_input(data.as_deref())?; + let item = drive.share_file(&id, &json).await?; + output::render(&item, fmt) + } + DriveCommand::Permission { action } => match action { + PermissionAction::List { id } => { + let page_data = drive.get_permissions(&id).await?; + output::render_list( + &page_data.results, + &["ID", "USER_ID", "ROLE", "READ", "WRITE"], + |p| { + vec![ + p.id.clone(), + p.user_id.clone().unwrap_or_default(), + p.role.clone().unwrap_or_default(), + p.can_read.map_or("-".into(), |v| v.to_string()), + p.can_write.map_or("-".into(), |v| v.to_string()), + ] + }, + fmt, + ) + } + }, + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Mail (Messages) +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Subcommand, Debug)] +pub enum MailCommand { + /// Mailbox management. + Mailbox { + #[command(subcommand)] + action: MailboxAction, + }, + /// Message management. + Message { + #[command(subcommand)] + action: MessageAction, + }, + /// Folder listing. + Folder { + #[command(subcommand)] + action: MailFolderAction, + }, + /// Contact listing. + Contact { + #[command(subcommand)] + action: MailContactAction, + }, +} + +#[derive(Subcommand, Debug)] +pub enum MailboxAction { + /// List mailboxes. + List, + /// Get a mailbox by ID. + Get { + #[arg(short, long)] + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum MessageAction { + /// List messages in a mailbox folder. + List { + /// Mailbox ID. + #[arg(short, long)] + id: String, + /// Folder name (e.g. "inbox"). + #[arg(short, long, default_value = "inbox")] + folder: String, + }, + /// Get a message. + Get { + /// Mailbox ID. + #[arg(short, long)] + id: String, + /// Message ID. + #[arg(short, long)] + message_id: String, + }, + /// Send a message from a mailbox. + Send { + /// Mailbox ID. + #[arg(short, long)] + id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum MailFolderAction { + /// List folders in a mailbox. + List { + /// Mailbox ID. + #[arg(short, long)] + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum MailContactAction { + /// List contacts in a mailbox. + List { + /// Mailbox ID. + #[arg(short, long)] + id: String, + }, +} + +pub async fn dispatch_mail( + cmd: MailCommand, + client: &SunbeamClient, + fmt: OutputFormat, +) -> Result<()> { + let mail = messages_client(client.domain()).await?; + match cmd { + MailCommand::Mailbox { action } => match action { + MailboxAction::List => { + let page_data = mail.list_mailboxes().await?; + output::render_list( + &page_data.results, + &["ID", "EMAIL", "DISPLAY_NAME"], + |m| { + vec![ + m.id.clone(), + m.email.clone().unwrap_or_default(), + m.display_name.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + MailboxAction::Get { id } => { + let item = mail.get_mailbox(&id).await?; + output::render(&item, fmt) + } + }, + MailCommand::Message { action } => match action { + MessageAction::List { id, folder } => { + let page_data = mail.list_messages(&id, &folder).await?; + output::render_list( + &page_data.results, + &["ID", "SUBJECT", "FROM", "READ", "CREATED"], + |m| { + vec![ + m.id.clone(), + m.subject.clone().unwrap_or_default(), + m.from_address.clone().unwrap_or_default(), + m.is_read.map_or("-".into(), |r| r.to_string()), + m.created_at.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + MessageAction::Get { id, message_id } => { + let item = mail.get_message(&id, &message_id).await?; + output::render(&item, fmt) + } + MessageAction::Send { id, data } => { + let json = output::read_json_input(data.as_deref())?; + let item = mail.send_message(&id, &json).await?; + output::render(&item, fmt) + } + }, + MailCommand::Folder { action } => match action { + MailFolderAction::List { id } => { + let page_data = mail.list_folders(&id).await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "MESSAGES", "UNREAD"], + |f| { + vec![ + f.id.clone(), + f.name.clone().unwrap_or_default(), + f.message_count.map_or("-".into(), |c| c.to_string()), + f.unread_count.map_or("-".into(), |c| c.to_string()), + ] + }, + fmt, + ) + } + }, + MailCommand::Contact { action } => match action { + MailContactAction::List { id } => { + let page_data = mail.list_contacts(&id).await?; + output::render_list( + &page_data.results, + &["ID", "EMAIL", "DISPLAY_NAME"], + |c| { + vec![ + c.id.clone(), + c.email.clone().unwrap_or_default(), + c.display_name.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + }, + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Calendars +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Subcommand, Debug)] +pub enum CalCommand { + /// Calendar management. + Calendar { + #[command(subcommand)] + action: CalendarAction, + }, + /// Event management. + Event { + #[command(subcommand)] + action: EventAction, + }, + /// RSVP to an event. + Rsvp { + /// Calendar ID. + #[arg(short = 'c', long)] + calendar_id: String, + /// Event ID. + #[arg(short = 'e', long)] + event_id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum CalendarAction { + /// List calendars. + List, + /// Get a calendar by ID. + Get { + #[arg(short, long)] + id: String, + }, + /// Create a calendar from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum EventAction { + /// List events in a calendar. + List { + /// Calendar ID. + #[arg(short = 'c', long)] + calendar_id: String, + }, + /// Get an event. + Get { + /// Calendar ID. + #[arg(short = 'c', long)] + calendar_id: String, + /// Event ID. + #[arg(short = 'e', long)] + event_id: String, + }, + /// Create an event from JSON. + Create { + /// Calendar ID. + #[arg(short = 'c', long)] + calendar_id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Update an event from JSON. + Update { + /// Calendar ID. + #[arg(short = 'c', long)] + calendar_id: String, + /// Event ID. + #[arg(short = 'e', long)] + event_id: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Delete an event. + Delete { + /// Calendar ID. + #[arg(short = 'c', long)] + calendar_id: String, + /// Event ID. + #[arg(short = 'e', long)] + event_id: String, + }, +} + +pub async fn dispatch_cal( + cmd: CalCommand, + client: &SunbeamClient, + fmt: OutputFormat, +) -> Result<()> { + let cal = calendars_client(client.domain()).await?; + match cmd { + CalCommand::Calendar { action } => match action { + CalendarAction::List => { + let page_data = cal.list_calendars().await?; + output::render_list( + &page_data.results, + &["ID", "NAME", "COLOR", "DEFAULT"], + |c| { + vec![ + c.id.clone(), + c.name.clone().unwrap_or_default(), + c.color.clone().unwrap_or_default(), + c.is_default.map_or("-".into(), |d| d.to_string()), + ] + }, + fmt, + ) + } + CalendarAction::Get { id } => { + let item = cal.get_calendar(&id).await?; + output::render(&item, fmt) + } + CalendarAction::Create { data } => { + let json = output::read_json_input(data.as_deref())?; + let item = cal.create_calendar(&json).await?; + output::render(&item, fmt) + } + }, + CalCommand::Event { action } => match action { + EventAction::List { calendar_id } => { + let page_data = cal.list_events(&calendar_id).await?; + output::render_list( + &page_data.results, + &["ID", "TITLE", "START", "END", "ALL_DAY"], + |e| { + vec![ + e.id.clone(), + e.title.clone().unwrap_or_default(), + e.start.clone().unwrap_or_default(), + e.end.clone().unwrap_or_default(), + e.all_day.map_or("-".into(), |a| a.to_string()), + ] + }, + fmt, + ) + } + EventAction::Get { + calendar_id, + event_id, + } => { + let item = cal.get_event(&calendar_id, &event_id).await?; + output::render(&item, fmt) + } + EventAction::Create { calendar_id, data } => { + let json = output::read_json_input(data.as_deref())?; + let item = cal.create_event(&calendar_id, &json).await?; + output::render(&item, fmt) + } + EventAction::Update { + calendar_id, + event_id, + data, + } => { + let json = output::read_json_input(data.as_deref())?; + let item = cal.update_event(&calendar_id, &event_id, &json).await?; + output::render(&item, fmt) + } + EventAction::Delete { + calendar_id, + event_id, + } => { + cal.delete_event(&calendar_id, &event_id).await?; + output::ok(&format!("Deleted event {event_id}")); + Ok(()) + } + }, + CalCommand::Rsvp { + calendar_id, + event_id, + data, + } => { + let json = output::read_json_input(data.as_deref())?; + cal.rsvp(&calendar_id, &event_id, &json).await?; + output::ok(&format!("RSVP sent for event {event_id}")); + Ok(()) + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Find +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Subcommand, Debug)] +pub enum FindCommand { + /// Search across La Suite services. + Search { + /// Search query. + #[arg(short, long)] + query: String, + #[arg(long)] + page: Option, + }, +} + +pub async fn dispatch_find( + cmd: FindCommand, + client: &SunbeamClient, + fmt: OutputFormat, +) -> Result<()> { + let find = find_client(client.domain()).await?; + match cmd { + FindCommand::Search { query, page } => { + let page_data = find.search(&query, page).await?; + output::render_list( + &page_data.results, + &["ID", "TITLE", "SOURCE", "SCORE", "URL"], + |r| { + vec![ + r.id.clone(), + r.title.clone().unwrap_or_default(), + r.source.clone().unwrap_or_default(), + r.score.map_or("-".into(), |s| format!("{s:.2}")), + r.url.clone().unwrap_or_default(), + ] + }, + fmt, + ) + } + } +} diff --git a/sunbeam-sdk/src/lasuite/mod.rs b/sunbeam-sdk/src/lasuite/mod.rs index ccdecc1..cf8ffb1 100644 --- a/sunbeam-sdk/src/lasuite/mod.rs +++ b/sunbeam-sdk/src/lasuite/mod.rs @@ -9,6 +9,9 @@ pub mod calendars; pub mod find; pub mod types; +#[cfg(feature = "cli")] +pub mod cli; + pub use people::PeopleClient; pub use docs::DocsClient; pub use meet::MeetClient; diff --git a/sunbeam-sdk/src/matrix/cli.rs b/sunbeam-sdk/src/matrix/cli.rs new file mode 100644 index 0000000..572848d --- /dev/null +++ b/sunbeam-sdk/src/matrix/cli.rs @@ -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 { + 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, + /// Filter ID or inline JSON filter. + #[arg(long)] + filter: Option, + /// Request full state (all room state events). + #[arg(long)] + full_state: bool, + /// Presence mode (offline, unavailable, online). + #[arg(long)] + set_presence: Option, + /// Long-poll timeout in milliseconds. + #[arg(long)] + timeout: Option, + }, +} + +// -- Room ------------------------------------------------------------------- + +#[derive(Subcommand, Debug)] +pub enum RoomAction { + /// Create a new room. + Create { + /// JSON body (or - for stdin). + #[arg(short = 'd', long = "data")] + data: Option, + }, + /// List public rooms. + List { + /// Maximum number of rooms to return. + #[arg(long)] + limit: Option, + /// Pagination token. + #[arg(long)] + since: Option, + }, + /// Search public rooms. + Search { + /// Search query. + #[arg(short = 'q', long)] + query: String, + /// Maximum results. + #[arg(long)] + limit: Option, + }, + /// 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, + }, + /// 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, + }, + /// 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, + }, + /// 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, + }, +} + +// -- 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, + /// 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, + }, + /// 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, + /// Maximum messages to return. + #[arg(long)] + limit: Option, + /// Event filter (JSON string). + #[arg(long)] + filter: Option, + }, + /// 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, + }, + /// 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, + }, +} + +// -- 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, + }, +} + +// -- 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, + }, +} + +// --------------------------------------------------------------------------- +// 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(¶ms).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, ¶ms).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, + ) + } + } +} diff --git a/sunbeam-sdk/src/matrix/mod.rs b/sunbeam-sdk/src/matrix/mod.rs index 1ffc929..e6dfd3b 100644 --- a/sunbeam-sdk/src/matrix/mod.rs +++ b/sunbeam-sdk/src/matrix/mod.rs @@ -2,6 +2,9 @@ pub mod types; +#[cfg(feature = "cli")] +pub mod cli; + use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::error::Result; use reqwest::Method; diff --git a/sunbeam-sdk/src/media/cli.rs b/sunbeam-sdk/src/media/cli.rs new file mode 100644 index 0000000..0755dae --- /dev/null +++ b/sunbeam-sdk/src/media/cli.rs @@ -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, + }, + /// 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, + }, + /// Start a track egress. + StartTrack { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// 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, + /// 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) + } + } +} diff --git a/sunbeam-sdk/src/media/mod.rs b/sunbeam-sdk/src/media/mod.rs index c7c9c8d..933a646 100644 --- a/sunbeam-sdk/src/media/mod.rs +++ b/sunbeam-sdk/src/media/mod.rs @@ -2,6 +2,9 @@ pub mod types; +#[cfg(feature = "cli")] +pub mod cli; + use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::error::{Result, SunbeamError}; use base64::Engine; diff --git a/sunbeam-sdk/src/monitoring/cli.rs b/sunbeam-sdk/src/monitoring/cli.rs new file mode 100644 index 0000000..9f8cd96 --- /dev/null +++ b/sunbeam-sdk/src/monitoring/cli.rs @@ -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, + }, + /// 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, + #[arg(long)] + start: Option, + #[arg(long)] + end: Option, + }, + /// List all label names. + Labels { + #[arg(long)] + start: Option, + #[arg(long)] + end: Option, + }, + /// List values for a specific label. + LabelValues { + /// Label name. + #[arg(short, long)] + label: String, + #[arg(long)] + start: Option, + #[arg(long)] + end: Option, + }, + /// 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, + }, +} + +// =========================================================================== +// 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, + /// Evaluation timestamp. + #[arg(long)] + time: Option, + }, + /// 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, + /// Query resolution step. + #[arg(long)] + step: Option, + }, + /// List all label names. + Labels { + #[arg(long)] + start: Option, + #[arg(long)] + end: Option, + }, + /// List values for a specific label. + LabelValues { + /// Label name. + #[arg(short, long)] + label: String, + #[arg(long)] + start: Option, + #[arg(long)] + end: Option, + }, + /// Find series by label matchers. + Series { + /// One or more series selectors. + #[arg(short = 'm', long = "match", required = true)] + match_params: Vec, + #[arg(long)] + start: Option, + #[arg(long)] + end: Option, + }, + /// Push log entries from JSON. + Push { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// Show index statistics. + IndexStats, + /// Detect log patterns. + Patterns { + /// LogQL expression. + #[arg(short, long, alias = "expr", short_alias = 'e')] + query: String, + #[arg(long)] + start: Option, + #[arg(long)] + end: Option, + }, + /// 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, + }, + /// Update a dashboard from JSON. + Update { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// 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, + }, + /// Update a datasource from JSON. + Update { + #[arg(long)] + id: u64, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// 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, + }, + /// Update a folder from JSON. + Update { + #[arg(long)] + uid: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// 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, + }, + /// Create an annotation from JSON. + Create { + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// 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, + }, + /// Update a provisioned alert rule from JSON. + Update { + #[arg(long)] + uid: String, + /// JSON body (or "-" to read from stdin). + #[arg(short, long)] + data: Option, + }, + /// 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, + }, +} + +// =========================================================================== +// 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(()) + } + } +} diff --git a/sunbeam-sdk/src/monitoring/mod.rs b/sunbeam-sdk/src/monitoring/mod.rs index 13cd461..1cc5a65 100644 --- a/sunbeam-sdk/src/monitoring/mod.rs +++ b/sunbeam-sdk/src/monitoring/mod.rs @@ -1,5 +1,7 @@ //! Monitoring service clients: Prometheus, Loki, and Grafana. +#[cfg(feature = "cli")] +pub mod cli; pub mod grafana; pub mod loki; pub mod prometheus; diff --git a/sunbeam-sdk/src/openbao/cli.rs b/sunbeam-sdk/src/openbao/cli.rs new file mode 100644 index 0000000..beb4986 --- /dev/null +++ b/sunbeam-sdk/src/openbao/cli.rs @@ -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, + }, +} + +#[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, + }, + /// 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, + }, + /// 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, + }, +} + +#[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`. +fn parse_kv_data(raw: &str) -> Result> { + // Try JSON first + if let Ok(val) = serde_json::from_str::(raw) { + if let Some(obj) = val.as_object() { + let map: HashMap = 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> { + 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 { + 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) + } + } + } +} diff --git a/sunbeam-sdk/src/openbao/mod.rs b/sunbeam-sdk/src/openbao/mod.rs index cdea8d9..26973b6 100644 --- a/sunbeam-sdk/src/openbao/mod.rs +++ b/sunbeam-sdk/src/openbao/mod.rs @@ -3,6 +3,9 @@ //! 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. +#[cfg(feature = "cli")] +pub mod cli; + use crate::error::{Result, ResultExt}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -17,13 +20,13 @@ pub struct BaoClient { // ── API response types ────────────────────────────────────────────────────── -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct InitResponse { pub unseal_keys_b64: Vec, pub root_token: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SealStatusResponse { #[serde(default)] pub initialized: bool, @@ -37,7 +40,7 @@ pub struct SealStatusResponse { pub n: u32, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct UnsealResponse { #[serde(default)] pub sealed: bool, diff --git a/sunbeam-sdk/src/search/cli.rs b/sunbeam-sdk/src/search/cli.rs new file mode 100644 index 0000000..d7f53d4 --- /dev/null +++ b/sunbeam-sdk/src/search/cli.rs @@ -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 { + 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, + /// Raw JSON body (overrides --query). + #[arg(short, long)] + data: Option, + }, + + /// 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, + }, + + /// 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, + }, + + /// 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, + }, + + /// 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, + }, + + /// 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, + }, +} + +#[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, + }, +} + +// --------------------------------------------------------------------------- +// 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, + }, + + /// 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, + }, + + /// 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, + }, + + /// 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, + }, + + /// 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) + } + }, + } +} diff --git a/sunbeam-sdk/src/search/mod.rs b/sunbeam-sdk/src/search/mod.rs index 406bd92..26793ea 100644 --- a/sunbeam-sdk/src/search/mod.rs +++ b/sunbeam-sdk/src/search/mod.rs @@ -1,5 +1,7 @@ //! OpenSearch client. +#[cfg(feature = "cli")] +pub mod cli; pub mod types; use crate::client::{AuthMethod, HttpTransport, ServiceClient}; diff --git a/sunbeam-sdk/src/storage/cli.rs b/sunbeam-sdk/src/storage/cli.rs new file mode 100644 index 0000000..2a1ea54 --- /dev/null +++ b/sunbeam-sdk/src/storage/cli.rs @@ -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, + /// Maximum number of keys to return. + #[arg(long)] + max_keys: Option, + }, + /// 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, + }, + /// 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(()) + } + } +} diff --git a/sunbeam-sdk/src/storage/mod.rs b/sunbeam-sdk/src/storage/mod.rs index 32a7c17..75f5412 100644 --- a/sunbeam-sdk/src/storage/mod.rs +++ b/sunbeam-sdk/src/storage/mod.rs @@ -2,6 +2,9 @@ pub mod types; +#[cfg(feature = "cli")] +pub mod cli; + use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::error::{Result, ResultExt, SunbeamError}; use bytes::Bytes;