feat: CLI modules for all 25+ service clients
One cli.rs per SDK module, gated behind #[cfg(feature = "cli")]: - auth (identity + hydra): identity, session, recovery, schema, courier, health, client, jwk, issuer, token, SSO passthrough - vcs (gitea): repo, issue, pr, branch, org, user, file, notification - chat (matrix): room, message, state, profile, device, user, sync - search (opensearch): doc, query, count, index, cluster, node, ingest pipeline, snapshot - storage (s3): bucket, object - media (livekit): room, participant, egress, token - mon (prometheus, loki, grafana): queries, dashboards, datasources, folders, annotations, alerts, org - vault (openbao): status, init, unseal, kv, policy, auth, secrets - la suite (people, docs, meet, drive, mail, cal, find) All dispatch functions take (cmd, &SunbeamClient, OutputFormat).
This commit is contained in:
930
sunbeam-sdk/src/gitea/cli.rs
Normal file
930
sunbeam-sdk/src/gitea/cli.rs
Normal file
@@ -0,0 +1,930 @@
|
||||
//! Gitea VCS CLI — `vcs repo|issue|pr|branch|org|user|file|notification`.
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::error::{Result, SunbeamError};
|
||||
use crate::gitea::types::*;
|
||||
use crate::gitea::GiteaClient;
|
||||
use crate::output::{render, render_list, read_json_input, OutputFormat};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn split_repo(repo: &str) -> Result<(&str, &str)> {
|
||||
repo.split_once('/')
|
||||
.ok_or_else(|| SunbeamError::Other("--repo must be owner/repo".into()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum VcsCommand {
|
||||
/// Repository operations.
|
||||
Repo {
|
||||
#[command(subcommand)]
|
||||
action: RepoAction,
|
||||
},
|
||||
/// Issue operations.
|
||||
Issue {
|
||||
#[command(subcommand)]
|
||||
action: IssueAction,
|
||||
},
|
||||
/// Pull request operations.
|
||||
Pr {
|
||||
#[command(subcommand)]
|
||||
action: PrAction,
|
||||
},
|
||||
/// Branch operations.
|
||||
Branch {
|
||||
#[command(subcommand)]
|
||||
action: BranchAction,
|
||||
},
|
||||
/// Organization operations.
|
||||
Org {
|
||||
#[command(subcommand)]
|
||||
action: OrgAction,
|
||||
},
|
||||
/// User operations.
|
||||
User {
|
||||
#[command(subcommand)]
|
||||
action: UserAction,
|
||||
},
|
||||
/// File operations.
|
||||
File {
|
||||
#[command(subcommand)]
|
||||
action: FileAction,
|
||||
},
|
||||
/// Notification operations.
|
||||
Notification {
|
||||
#[command(subcommand)]
|
||||
action: NotificationAction,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Repo -------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RepoAction {
|
||||
/// List repositories for an organization.
|
||||
List {
|
||||
/// Organization name.
|
||||
#[arg(long)]
|
||||
org: String,
|
||||
/// Max results.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Search repositories.
|
||||
Search {
|
||||
/// Search query.
|
||||
#[arg(short, long)]
|
||||
query: String,
|
||||
/// Max results.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Get a repository.
|
||||
Get {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
},
|
||||
/// Create a repository.
|
||||
Create {
|
||||
/// Organization (creates org repo). Omit for user repo.
|
||||
#[arg(long)]
|
||||
org: Option<String>,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update a repository.
|
||||
Update {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a repository.
|
||||
Delete {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
},
|
||||
/// Fork a repository.
|
||||
Fork {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Trigger mirror sync.
|
||||
MirrorSync {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
},
|
||||
/// Transfer a repository to another owner.
|
||||
Transfer {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Issue ------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum IssueAction {
|
||||
/// List issues.
|
||||
List {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Filter by state (open, closed, all).
|
||||
#[arg(long, default_value = "open")]
|
||||
state: String,
|
||||
/// Max results.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Get an issue.
|
||||
Get {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Issue number.
|
||||
#[arg(short, long)]
|
||||
id: u64,
|
||||
},
|
||||
/// Create an issue.
|
||||
Create {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update an issue.
|
||||
Update {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Issue number.
|
||||
#[arg(short, long)]
|
||||
id: u64,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Add a comment to an issue.
|
||||
Comment {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Issue number.
|
||||
#[arg(short, long)]
|
||||
id: u64,
|
||||
/// Comment body text.
|
||||
#[arg(long)]
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
// -- PR ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum PrAction {
|
||||
/// List pull requests.
|
||||
List {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Filter by state (open, closed, all).
|
||||
#[arg(long, default_value = "open")]
|
||||
state: String,
|
||||
},
|
||||
/// Get a pull request.
|
||||
Get {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Pull request number.
|
||||
#[arg(short, long)]
|
||||
id: u64,
|
||||
},
|
||||
/// Create a pull request.
|
||||
Create {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Merge a pull request.
|
||||
Merge {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Pull request number.
|
||||
#[arg(short, long)]
|
||||
id: u64,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Branch -----------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum BranchAction {
|
||||
/// List branches.
|
||||
List {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
},
|
||||
/// Create a branch.
|
||||
Create {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a branch.
|
||||
Delete {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// Branch name.
|
||||
#[arg(long)]
|
||||
branch: String,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Org --------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum OrgAction {
|
||||
/// List organizations for a user.
|
||||
List {
|
||||
/// Username.
|
||||
#[arg(long)]
|
||||
user: String,
|
||||
},
|
||||
/// Get an organization.
|
||||
Get {
|
||||
/// Organization name.
|
||||
#[arg(long)]
|
||||
org: String,
|
||||
},
|
||||
/// Create an organization.
|
||||
Create {
|
||||
/// JSON body or "-" for stdin.
|
||||
#[arg(long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- User -------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum UserAction {
|
||||
/// Search users.
|
||||
Search {
|
||||
/// Search query.
|
||||
#[arg(short, long)]
|
||||
query: String,
|
||||
/// Max results.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Get a user by username.
|
||||
Get {
|
||||
/// Username.
|
||||
#[arg(long)]
|
||||
user: String,
|
||||
},
|
||||
/// Get the authenticated user.
|
||||
Me,
|
||||
}
|
||||
|
||||
// -- File -------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum FileAction {
|
||||
/// Get file content.
|
||||
Get {
|
||||
/// Repository (owner/repo).
|
||||
#[arg(short, long)]
|
||||
repo: String,
|
||||
/// File path within the repository.
|
||||
#[arg(long)]
|
||||
path: String,
|
||||
/// Git ref (branch, tag, commit SHA).
|
||||
#[arg(long, name = "ref")]
|
||||
git_ref: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Notification -----------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum NotificationAction {
|
||||
/// List notifications.
|
||||
List,
|
||||
/// Mark all notifications as read.
|
||||
Read,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table row helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn repo_row(r: &Repository) -> Vec<String> {
|
||||
vec![
|
||||
r.full_name.clone(),
|
||||
if r.private { "private" } else { "public" }.into(),
|
||||
r.stars_count.to_string(),
|
||||
r.forks_count.to_string(),
|
||||
r.default_branch.clone(),
|
||||
]
|
||||
}
|
||||
|
||||
fn issue_row(i: &Issue) -> Vec<String> {
|
||||
vec![
|
||||
format!("#{}", i.number),
|
||||
i.title.clone(),
|
||||
i.state.clone(),
|
||||
i.created_at.clone().unwrap_or_default(),
|
||||
]
|
||||
}
|
||||
|
||||
fn pr_row(pr: &PullRequest) -> Vec<String> {
|
||||
vec![
|
||||
format!("#{}", pr.number),
|
||||
pr.title.clone(),
|
||||
pr.state.clone(),
|
||||
if pr.merged { "yes" } else { "no" }.into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn branch_row(b: &Branch) -> Vec<String> {
|
||||
vec![
|
||||
b.name.clone(),
|
||||
if b.protected { "yes" } else { "no" }.into(),
|
||||
b.commit
|
||||
.as_ref()
|
||||
.map(|c| c.id.chars().take(8).collect::<String>())
|
||||
.unwrap_or_default(),
|
||||
]
|
||||
}
|
||||
|
||||
fn org_row(o: &Organization) -> Vec<String> {
|
||||
vec![
|
||||
o.username.clone(),
|
||||
o.full_name.clone(),
|
||||
o.visibility.clone(),
|
||||
]
|
||||
}
|
||||
|
||||
fn user_row(u: &User) -> Vec<String> {
|
||||
vec![
|
||||
u.login.clone(),
|
||||
u.full_name.clone(),
|
||||
u.email.clone(),
|
||||
if u.is_admin { "admin" } else { "" }.into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn notification_row(n: &Notification) -> Vec<String> {
|
||||
let subject_title = n
|
||||
.subject
|
||||
.as_ref()
|
||||
.map(|s| s.title.clone())
|
||||
.unwrap_or_default();
|
||||
let repo_name = n
|
||||
.repository
|
||||
.as_ref()
|
||||
.map(|r| r.full_name.clone())
|
||||
.unwrap_or_default();
|
||||
vec![
|
||||
n.id.to_string(),
|
||||
repo_name,
|
||||
subject_title,
|
||||
if n.unread { "unread" } else { "read" }.into(),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: VcsCommand, client: &GiteaClient, fmt: OutputFormat) -> Result<()> {
|
||||
match cmd {
|
||||
// -- Repo -----------------------------------------------------------
|
||||
VcsCommand::Repo { action } => match action {
|
||||
RepoAction::List { org, limit } => {
|
||||
let repos = client.list_org_repos(&org, limit).await?;
|
||||
render_list(
|
||||
&repos,
|
||||
&["NAME", "VISIBILITY", "STARS", "FORKS", "BRANCH"],
|
||||
repo_row,
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
RepoAction::Search { query, limit } => {
|
||||
let result = client.search_repos(&query, limit).await?;
|
||||
render_list(
|
||||
&result.data,
|
||||
&["NAME", "VISIBILITY", "STARS", "FORKS", "BRANCH"],
|
||||
repo_row,
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
RepoAction::Get { repo } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let r = client.get_repo(owner, name).await?;
|
||||
render(&r, fmt)
|
||||
}
|
||||
RepoAction::Create { org, data } => {
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: CreateRepoBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid repo body: {e}")))?;
|
||||
let r = match org {
|
||||
Some(org) => client.create_org_repo(&org, &body).await?,
|
||||
None => client.create_user_repo(&body).await?,
|
||||
};
|
||||
render(&r, fmt)
|
||||
}
|
||||
RepoAction::Update { repo, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: EditRepoBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid repo body: {e}")))?;
|
||||
let r = client.edit_repo(owner, name, &body).await?;
|
||||
render(&r, fmt)
|
||||
}
|
||||
RepoAction::Delete { repo } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
client.delete_repo(owner, name).await?;
|
||||
crate::output::ok(&format!("Deleted repository {repo}"));
|
||||
Ok(())
|
||||
}
|
||||
RepoAction::Fork { repo, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = data
|
||||
.as_deref()
|
||||
.map(|d| read_json_input(Some(d)))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let body: ForkRepoBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid fork body: {e}")))?;
|
||||
let r = client.fork_repo(owner, name, &body).await?;
|
||||
render(&r, fmt)
|
||||
}
|
||||
RepoAction::MirrorSync { repo } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
client.mirror_sync(owner, name).await?;
|
||||
crate::output::ok(&format!("Mirror sync triggered for {repo}"));
|
||||
Ok(())
|
||||
}
|
||||
RepoAction::Transfer { repo, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: TransferRepoBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid transfer body: {e}")))?;
|
||||
let r = client.transfer_repo(owner, name, &body).await?;
|
||||
render(&r, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
// -- Issue ----------------------------------------------------------
|
||||
VcsCommand::Issue { action } => match action {
|
||||
IssueAction::List { repo, state, limit } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let issues = client.list_issues(owner, name, &state, limit).await?;
|
||||
render_list(
|
||||
&issues,
|
||||
&["#", "TITLE", "STATE", "CREATED"],
|
||||
issue_row,
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
IssueAction::Get { repo, id } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let issue = client.get_issue(owner, name, id).await?;
|
||||
render(&issue, fmt)
|
||||
}
|
||||
IssueAction::Create { repo, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: CreateIssueBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid issue body: {e}")))?;
|
||||
let issue = client.create_issue(owner, name, &body).await?;
|
||||
render(&issue, fmt)
|
||||
}
|
||||
IssueAction::Update { repo, id, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: EditIssueBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid issue body: {e}")))?;
|
||||
let issue = client.edit_issue(owner, name, id, &body).await?;
|
||||
render(&issue, fmt)
|
||||
}
|
||||
IssueAction::Comment { repo, id, body } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let comment = client.create_issue_comment(owner, name, id, &body).await?;
|
||||
render(&comment, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
// -- PR -------------------------------------------------------------
|
||||
VcsCommand::Pr { action } => match action {
|
||||
PrAction::List { repo, state } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let prs = client.list_pulls(owner, name, &state).await?;
|
||||
render_list(
|
||||
&prs,
|
||||
&["#", "TITLE", "STATE", "MERGED"],
|
||||
pr_row,
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
PrAction::Get { repo, id } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let pr = client.get_pull(owner, name, id).await?;
|
||||
render(&pr, fmt)
|
||||
}
|
||||
PrAction::Create { repo, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: CreatePullBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid PR body: {e}")))?;
|
||||
let pr = client.create_pull(owner, name, &body).await?;
|
||||
render(&pr, fmt)
|
||||
}
|
||||
PrAction::Merge { repo, id, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = data
|
||||
.as_deref()
|
||||
.map(|d| read_json_input(Some(d)))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| serde_json::json!({"Do": "merge"}));
|
||||
let body: MergePullBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid merge body: {e}")))?;
|
||||
client.merge_pull(owner, name, id, &body).await?;
|
||||
crate::output::ok(&format!("Merged PR #{id} in {repo}"));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// -- Branch ---------------------------------------------------------
|
||||
VcsCommand::Branch { action } => match action {
|
||||
BranchAction::List { repo } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let branches = client.list_branches(owner, name).await?;
|
||||
render_list(
|
||||
&branches,
|
||||
&["NAME", "PROTECTED", "COMMIT"],
|
||||
branch_row,
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
BranchAction::Create { repo, data } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: CreateBranchBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid branch body: {e}")))?;
|
||||
let branch = client.create_branch(owner, name, &body).await?;
|
||||
render(&branch, fmt)
|
||||
}
|
||||
BranchAction::Delete { repo, branch } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
client.delete_branch(owner, name, &branch).await?;
|
||||
crate::output::ok(&format!("Deleted branch {branch} from {repo}"));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// -- Org ------------------------------------------------------------
|
||||
VcsCommand::Org { action } => match action {
|
||||
OrgAction::List { user } => {
|
||||
let orgs = client.list_user_orgs(&user).await?;
|
||||
render_list(
|
||||
&orgs,
|
||||
&["USERNAME", "FULL NAME", "VISIBILITY"],
|
||||
org_row,
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
OrgAction::Get { org } => {
|
||||
let o = client.get_org(&org).await?;
|
||||
render(&o, fmt)
|
||||
}
|
||||
OrgAction::Create { data } => {
|
||||
let val = read_json_input(data.as_deref())?;
|
||||
let body: CreateOrgBody = serde_json::from_value(val)
|
||||
.map_err(|e| SunbeamError::Other(format!("invalid org body: {e}")))?;
|
||||
let o = client.create_org(&body).await?;
|
||||
render(&o, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
// -- User -----------------------------------------------------------
|
||||
VcsCommand::User { action } => match action {
|
||||
UserAction::Search { query, limit } => {
|
||||
let result = client.search_users(&query, limit).await?;
|
||||
render_list(
|
||||
&result.data,
|
||||
&["LOGIN", "NAME", "EMAIL", "ROLE"],
|
||||
user_row,
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
UserAction::Get { user } => {
|
||||
let u = client.get_user(&user).await?;
|
||||
render(&u, fmt)
|
||||
}
|
||||
UserAction::Me => {
|
||||
let u = client.get_authenticated_user().await?;
|
||||
render(&u, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
// -- File -----------------------------------------------------------
|
||||
VcsCommand::File { action } => match action {
|
||||
FileAction::Get { repo, path, git_ref } => {
|
||||
let (owner, name) = split_repo(&repo)?;
|
||||
let fc = client
|
||||
.get_file_content(owner, name, &path, git_ref.as_deref())
|
||||
.await?;
|
||||
render(&fc, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
// -- Notification ---------------------------------------------------
|
||||
VcsCommand::Notification { action } => match action {
|
||||
NotificationAction::List => {
|
||||
let notes = client.list_notifications().await?;
|
||||
render_list(
|
||||
¬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], "");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
726
sunbeam-sdk/src/identity/cli.rs
Normal file
726
sunbeam-sdk/src/identity/cli.rs
Normal file
@@ -0,0 +1,726 @@
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::client::SunbeamClient;
|
||||
use crate::error::Result;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level AuthCommand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AuthCommand {
|
||||
/// Identity management (Kratos).
|
||||
Identity {
|
||||
#[command(subcommand)]
|
||||
action: IdentityAction,
|
||||
},
|
||||
/// Session management (Kratos).
|
||||
Session {
|
||||
#[command(subcommand)]
|
||||
action: SessionAction,
|
||||
},
|
||||
/// Recovery codes and links (Kratos).
|
||||
Recovery {
|
||||
#[command(subcommand)]
|
||||
action: RecoveryAction,
|
||||
},
|
||||
/// Identity schemas (Kratos).
|
||||
Schema {
|
||||
#[command(subcommand)]
|
||||
action: SchemaAction,
|
||||
},
|
||||
/// Courier messages (Kratos).
|
||||
Courier {
|
||||
#[command(subcommand)]
|
||||
action: CourierAction,
|
||||
},
|
||||
/// Health check (Kratos).
|
||||
Health,
|
||||
/// OAuth2 client management (Hydra).
|
||||
Client {
|
||||
#[command(subcommand)]
|
||||
action: ClientAction,
|
||||
},
|
||||
/// JWK set management (Hydra).
|
||||
Jwk {
|
||||
#[command(subcommand)]
|
||||
action: JwkAction,
|
||||
},
|
||||
/// Trusted JWT issuer management (Hydra).
|
||||
Issuer {
|
||||
#[command(subcommand)]
|
||||
action: IssuerAction,
|
||||
},
|
||||
/// Token introspection and revocation (Hydra).
|
||||
Token {
|
||||
#[command(subcommand)]
|
||||
action: TokenAction,
|
||||
},
|
||||
/// Log in to both SSO and Gitea.
|
||||
Login {
|
||||
#[arg(long)]
|
||||
domain: Option<String>,
|
||||
},
|
||||
/// Log in to SSO only.
|
||||
Sso {
|
||||
#[arg(long)]
|
||||
domain: Option<String>,
|
||||
},
|
||||
/// Log in to Gitea only.
|
||||
Git {
|
||||
#[arg(long)]
|
||||
domain: Option<String>,
|
||||
},
|
||||
/// Log out.
|
||||
Logout,
|
||||
/// Show auth status.
|
||||
Status,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum IdentityAction {
|
||||
/// List identities.
|
||||
List {
|
||||
#[arg(long)]
|
||||
page: Option<u32>,
|
||||
#[arg(long, default_value = "20")]
|
||||
page_size: Option<u32>,
|
||||
},
|
||||
/// Get an identity by ID.
|
||||
Get {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
/// Create a new identity from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update an identity (full replace) from JSON.
|
||||
Update {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete an identity.
|
||||
Delete {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SessionAction {
|
||||
/// List sessions.
|
||||
List {
|
||||
#[arg(long, default_value = "20")]
|
||||
page_size: Option<u32>,
|
||||
#[arg(long)]
|
||||
page_token: Option<String>,
|
||||
#[arg(long)]
|
||||
active: Option<bool>,
|
||||
},
|
||||
/// Get a session by ID.
|
||||
Get {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
/// Extend a session.
|
||||
Extend {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
/// Delete (disable) a session.
|
||||
Delete {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RecoveryAction {
|
||||
/// Create a recovery code for an identity.
|
||||
CreateCode {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
/// Duration string (e.g. "24h", "1h30m").
|
||||
#[arg(long)]
|
||||
expires_in: Option<String>,
|
||||
},
|
||||
/// Create a recovery link for an identity.
|
||||
CreateLink {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
/// Duration string (e.g. "24h", "1h30m").
|
||||
#[arg(long)]
|
||||
expires_in: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SchemaAction {
|
||||
/// List identity schemas.
|
||||
List,
|
||||
/// Get a specific schema by ID.
|
||||
Get {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Courier sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum CourierAction {
|
||||
/// List courier messages.
|
||||
List {
|
||||
#[arg(long, default_value = "20")]
|
||||
page_size: Option<u32>,
|
||||
#[arg(long)]
|
||||
page_token: Option<String>,
|
||||
},
|
||||
/// Get a courier message by ID.
|
||||
Get {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client sub-commands (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ClientAction {
|
||||
/// List OAuth2 clients.
|
||||
List {
|
||||
#[arg(long, default_value = "20")]
|
||||
limit: Option<u32>,
|
||||
#[arg(long)]
|
||||
offset: Option<u32>,
|
||||
},
|
||||
/// Get an OAuth2 client by ID.
|
||||
Get {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
/// Create an OAuth2 client from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update an OAuth2 client (full replace) from JSON.
|
||||
Update {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete an OAuth2 client.
|
||||
Delete {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWK sub-commands (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum JwkAction {
|
||||
/// List keys in a JWK set.
|
||||
List {
|
||||
/// Name of the JWK set.
|
||||
#[arg(short = 'n', long)]
|
||||
set_name: String,
|
||||
},
|
||||
/// Get a specific key from a JWK set.
|
||||
Get {
|
||||
#[arg(short = 'n', long)]
|
||||
set_name: String,
|
||||
#[arg(short, long)]
|
||||
kid: String,
|
||||
},
|
||||
/// Create a new JWK set from JSON.
|
||||
Create {
|
||||
#[arg(short = 'n', long)]
|
||||
set_name: String,
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a JWK set.
|
||||
Delete {
|
||||
#[arg(short = 'n', long)]
|
||||
set_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issuer sub-commands (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum IssuerAction {
|
||||
/// List trusted JWT issuers.
|
||||
List,
|
||||
/// Get a trusted JWT issuer by ID.
|
||||
Get {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
/// Create a trusted JWT issuer from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a trusted JWT issuer.
|
||||
Delete {
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token sub-commands (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum TokenAction {
|
||||
/// Introspect a token.
|
||||
Introspect {
|
||||
/// The token string to introspect.
|
||||
#[arg(short = 't', long)]
|
||||
token: String,
|
||||
},
|
||||
/// Delete all tokens for an OAuth2 client.
|
||||
Delete {
|
||||
/// The client_id whose tokens should be revoked.
|
||||
#[arg(long)]
|
||||
client_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: AuthCommand,
|
||||
client: &SunbeamClient,
|
||||
output: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
// -- Kratos: Identity ---------------------------------------------------
|
||||
AuthCommand::Identity { action } => dispatch_identity(action, client, output).await,
|
||||
// -- Kratos: Session ----------------------------------------------------
|
||||
AuthCommand::Session { action } => dispatch_session(action, client, output).await,
|
||||
// -- Kratos: Recovery ---------------------------------------------------
|
||||
AuthCommand::Recovery { action } => dispatch_recovery(action, client, output).await,
|
||||
// -- Kratos: Schema -----------------------------------------------------
|
||||
AuthCommand::Schema { action } => dispatch_schema(action, client, output).await,
|
||||
// -- Kratos: Courier ----------------------------------------------------
|
||||
AuthCommand::Courier { action } => dispatch_courier(action, client, output).await,
|
||||
// -- Kratos: Health -----------------------------------------------------
|
||||
AuthCommand::Health => {
|
||||
let status = client.kratos().alive().await?;
|
||||
output::render(&status, output)
|
||||
}
|
||||
// -- Hydra: Client ------------------------------------------------------
|
||||
AuthCommand::Client { action } => dispatch_client(action, client, output).await,
|
||||
// -- Hydra: JWK ---------------------------------------------------------
|
||||
AuthCommand::Jwk { action } => dispatch_jwk(action, client, output).await,
|
||||
// -- Hydra: Issuer ------------------------------------------------------
|
||||
AuthCommand::Issuer { action } => dispatch_issuer(action, client, output).await,
|
||||
// -- Hydra: Token -------------------------------------------------------
|
||||
AuthCommand::Token { action } => dispatch_token(action, client, output).await,
|
||||
// -- SSO commands (delegate to existing auth module) --------------------
|
||||
AuthCommand::Login { domain } => {
|
||||
crate::auth::cmd_auth_login_all(domain.as_deref()).await
|
||||
}
|
||||
AuthCommand::Sso { domain } => {
|
||||
crate::auth::cmd_auth_sso_login(domain.as_deref()).await
|
||||
}
|
||||
AuthCommand::Git { domain } => {
|
||||
crate::auth::cmd_auth_git_login(domain.as_deref()).await
|
||||
}
|
||||
AuthCommand::Logout => crate::auth::cmd_auth_logout().await,
|
||||
AuthCommand::Status => crate::auth::cmd_auth_status().await,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_identity(
|
||||
action: IdentityAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let kratos = client.kratos();
|
||||
match action {
|
||||
IdentityAction::List { page, page_size } => {
|
||||
let items = kratos.list_identities(page, page_size).await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["ID", "SCHEMA", "STATE", "CREATED"],
|
||||
|i| {
|
||||
vec![
|
||||
i.id.clone(),
|
||||
i.schema_id.clone(),
|
||||
i.state.clone().unwrap_or_default(),
|
||||
i.created_at.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
IdentityAction::Get { id } => {
|
||||
let item = kratos.get_identity(&id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
IdentityAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let body: crate::identity::types::CreateIdentityBody =
|
||||
serde_json::from_value(json)?;
|
||||
let item = kratos.create_identity(&body).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
IdentityAction::Update { id, data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let body: crate::identity::types::UpdateIdentityBody =
|
||||
serde_json::from_value(json)?;
|
||||
let item = kratos.update_identity(&id, &body).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
IdentityAction::Delete { id } => {
|
||||
kratos.delete_identity(&id).await?;
|
||||
output::ok(&format!("Deleted identity {id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_session(
|
||||
action: SessionAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let kratos = client.kratos();
|
||||
match action {
|
||||
SessionAction::List {
|
||||
page_size,
|
||||
page_token,
|
||||
active,
|
||||
} => {
|
||||
let items = kratos
|
||||
.list_sessions(page_size, page_token.as_deref(), active)
|
||||
.await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["ID", "ACTIVE", "EXPIRES", "AUTHENTICATED"],
|
||||
|s| {
|
||||
vec![
|
||||
s.id.clone(),
|
||||
s.active.map_or("-".into(), |a| a.to_string()),
|
||||
s.expires_at.clone().unwrap_or_default(),
|
||||
s.authenticated_at.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
SessionAction::Get { id } => {
|
||||
let item = kratos.get_session(&id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
SessionAction::Extend { id } => {
|
||||
let item = kratos.extend_session(&id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
SessionAction::Delete { id } => {
|
||||
kratos.disable_session(&id).await?;
|
||||
output::ok(&format!("Disabled session {id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_recovery(
|
||||
action: RecoveryAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let kratos = client.kratos();
|
||||
match action {
|
||||
RecoveryAction::CreateCode { id, expires_in } => {
|
||||
let item = kratos
|
||||
.create_recovery_code(&id, expires_in.as_deref())
|
||||
.await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
RecoveryAction::CreateLink { id, expires_in } => {
|
||||
let item = kratos
|
||||
.create_recovery_link(&id, expires_in.as_deref())
|
||||
.await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_schema(
|
||||
action: SchemaAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let kratos = client.kratos();
|
||||
match action {
|
||||
SchemaAction::List => {
|
||||
let items = kratos.list_schemas().await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["ID"],
|
||||
|s| vec![s.id.clone()],
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
SchemaAction::Get { id } => {
|
||||
let item = kratos.get_schema(&id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Courier dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_courier(
|
||||
action: CourierAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let kratos = client.kratos();
|
||||
match action {
|
||||
CourierAction::List {
|
||||
page_size,
|
||||
page_token,
|
||||
} => {
|
||||
let items = kratos
|
||||
.list_courier_messages(page_size, page_token.as_deref())
|
||||
.await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["ID", "STATUS", "TYPE", "RECIPIENT", "SUBJECT"],
|
||||
|m| {
|
||||
vec![
|
||||
m.id.clone(),
|
||||
m.status.clone(),
|
||||
m.r#type.clone(),
|
||||
m.recipient.clone(),
|
||||
m.subject.clone(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
CourierAction::Get { id } => {
|
||||
let item = kratos.get_courier_message(&id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client dispatch (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_client(
|
||||
action: ClientAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let hydra = client.hydra();
|
||||
match action {
|
||||
ClientAction::List { limit, offset } => {
|
||||
let items = hydra.list_clients(limit, offset).await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["CLIENT_ID", "NAME", "SCOPE"],
|
||||
|c| {
|
||||
vec![
|
||||
c.client_id.clone().unwrap_or_default(),
|
||||
c.client_name.clone().unwrap_or_default(),
|
||||
c.scope.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
ClientAction::Get { id } => {
|
||||
let item = hydra.get_client(&id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
ClientAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let body: crate::auth::hydra::types::OAuth2Client =
|
||||
serde_json::from_value(json)?;
|
||||
let item = hydra.create_client(&body).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
ClientAction::Update { id, data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let body: crate::auth::hydra::types::OAuth2Client =
|
||||
serde_json::from_value(json)?;
|
||||
let item = hydra.update_client(&id, &body).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
ClientAction::Delete { id } => {
|
||||
hydra.delete_client(&id).await?;
|
||||
output::ok(&format!("Deleted OAuth2 client {id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWK dispatch (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_jwk(
|
||||
action: JwkAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let hydra = client.hydra();
|
||||
match action {
|
||||
JwkAction::List { set_name } => {
|
||||
let item = hydra.get_jwk_set(&set_name).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
JwkAction::Get { set_name, kid } => {
|
||||
let item = hydra.get_jwk_key(&set_name, &kid).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
JwkAction::Create { set_name, data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let body: crate::auth::hydra::types::CreateJwkBody =
|
||||
serde_json::from_value(json)?;
|
||||
let item = hydra.create_jwk_set(&set_name, &body).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
JwkAction::Delete { set_name } => {
|
||||
hydra.delete_jwk_set(&set_name).await?;
|
||||
output::ok(&format!("Deleted JWK set {set_name}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issuer dispatch (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_issuer(
|
||||
action: IssuerAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let hydra = client.hydra();
|
||||
match action {
|
||||
IssuerAction::List => {
|
||||
let items = hydra.list_trusted_issuers().await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["ID", "ISSUER", "SUBJECT", "EXPIRES"],
|
||||
|i| {
|
||||
vec![
|
||||
i.id.clone().unwrap_or_default(),
|
||||
i.issuer.clone(),
|
||||
i.subject.clone(),
|
||||
i.expires_at.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
IssuerAction::Get { id } => {
|
||||
let item = hydra.get_trusted_issuer(&id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
IssuerAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let body: crate::auth::hydra::types::TrustedJwtIssuer =
|
||||
serde_json::from_value(json)?;
|
||||
let item = hydra.create_trusted_issuer(&body).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
IssuerAction::Delete { id } => {
|
||||
hydra.delete_trusted_issuer(&id).await?;
|
||||
output::ok(&format!("Deleted trusted issuer {id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token dispatch (Hydra)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_token(
|
||||
action: TokenAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let hydra = client.hydra();
|
||||
match action {
|
||||
TokenAction::Introspect { token } => {
|
||||
let item = hydra.introspect_token(&token).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
TokenAction::Delete { client_id } => {
|
||||
hydra.delete_tokens_for_client(&client_id).await?;
|
||||
output::ok(&format!("Deleted tokens for client {client_id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1147
sunbeam-sdk/src/lasuite/cli.rs
Normal file
1147
sunbeam-sdk/src/lasuite/cli.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
773
sunbeam-sdk/src/matrix/cli.rs
Normal file
773
sunbeam-sdk/src/matrix/cli.rs
Normal file
@@ -0,0 +1,773 @@
|
||||
//! CLI dispatch for Matrix chat commands.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use clap::Subcommand;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Construct a [`MatrixClient`] with a valid access token from the credential
|
||||
/// cache. Fails if the user is not logged in.
|
||||
async fn matrix_with_token(domain: &str) -> Result<super::MatrixClient> {
|
||||
let token = crate::auth::get_token().await?;
|
||||
let mut m = super::MatrixClient::connect(domain);
|
||||
m.set_token(&token);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ChatCommand {
|
||||
/// Room management.
|
||||
Room {
|
||||
#[command(subcommand)]
|
||||
action: RoomAction,
|
||||
},
|
||||
/// Send, list, get and redact messages.
|
||||
Message {
|
||||
#[command(subcommand)]
|
||||
action: MessageAction,
|
||||
},
|
||||
/// Room state events.
|
||||
State {
|
||||
#[command(subcommand)]
|
||||
action: StateAction,
|
||||
},
|
||||
/// User profile management.
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
action: ProfileAction,
|
||||
},
|
||||
/// Device management.
|
||||
Device {
|
||||
#[command(subcommand)]
|
||||
action: DeviceAction,
|
||||
},
|
||||
/// User directory search.
|
||||
User {
|
||||
#[command(subcommand)]
|
||||
action: UserAction,
|
||||
},
|
||||
/// Show authenticated user identity.
|
||||
Whoami,
|
||||
/// Synchronise client state with the server.
|
||||
Sync {
|
||||
/// Pagination token from a previous sync.
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
/// Filter ID or inline JSON filter.
|
||||
#[arg(long)]
|
||||
filter: Option<String>,
|
||||
/// Request full state (all room state events).
|
||||
#[arg(long)]
|
||||
full_state: bool,
|
||||
/// Presence mode (offline, unavailable, online).
|
||||
#[arg(long)]
|
||||
set_presence: Option<String>,
|
||||
/// Long-poll timeout in milliseconds.
|
||||
#[arg(long)]
|
||||
timeout: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Room -------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RoomAction {
|
||||
/// Create a new room.
|
||||
Create {
|
||||
/// JSON body (or - for stdin).
|
||||
#[arg(short = 'd', long = "data")]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// List public rooms.
|
||||
List {
|
||||
/// Maximum number of rooms to return.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
/// Pagination token.
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
},
|
||||
/// Search public rooms.
|
||||
Search {
|
||||
/// Search query.
|
||||
#[arg(short = 'q', long)]
|
||||
query: String,
|
||||
/// Maximum results.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Join a room.
|
||||
Join {
|
||||
/// Room ID or alias (e.g. !abc:example.com or #room:example.com).
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
},
|
||||
/// Leave a room.
|
||||
Leave {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
},
|
||||
/// Invite a user to a room.
|
||||
Invite {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// User ID to invite (e.g. @alice:example.com).
|
||||
#[arg(long)]
|
||||
user_id: String,
|
||||
/// Reason for the invite.
|
||||
#[arg(long)]
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// Kick a user from a room.
|
||||
Kick {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// User ID to kick.
|
||||
#[arg(long)]
|
||||
user_id: String,
|
||||
/// Reason.
|
||||
#[arg(long)]
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// Ban a user from a room.
|
||||
Ban {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// User ID to ban.
|
||||
#[arg(long)]
|
||||
user_id: String,
|
||||
/// Reason.
|
||||
#[arg(long)]
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// Unban a user from a room.
|
||||
Unban {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// User ID to unban.
|
||||
#[arg(long)]
|
||||
user_id: String,
|
||||
/// Reason.
|
||||
#[arg(long)]
|
||||
reason: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Message ----------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum MessageAction {
|
||||
/// Send a message to a room.
|
||||
Send {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// Message text body. If omitted, reads JSON from --data or stdin.
|
||||
#[arg(long)]
|
||||
body: Option<String>,
|
||||
/// Event type (default: m.room.message).
|
||||
#[arg(long, default_value = "m.room.message")]
|
||||
event_type: String,
|
||||
/// Raw JSON body for the event content (or - for stdin).
|
||||
#[arg(short = 'd', long = "data")]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// List messages in a room.
|
||||
List {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// Pagination direction (b = backwards, f = forwards).
|
||||
#[arg(long, default_value = "b")]
|
||||
dir: String,
|
||||
/// Pagination token.
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
/// Maximum messages to return.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
/// Event filter (JSON string).
|
||||
#[arg(long)]
|
||||
filter: Option<String>,
|
||||
},
|
||||
/// Get a single event.
|
||||
Get {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// Event ID.
|
||||
#[arg(long)]
|
||||
event_id: String,
|
||||
},
|
||||
/// Redact an event.
|
||||
Redact {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// Event ID to redact.
|
||||
#[arg(long)]
|
||||
event_id: String,
|
||||
/// Reason for redaction.
|
||||
#[arg(long)]
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// Search messages across rooms.
|
||||
Search {
|
||||
/// Search query.
|
||||
#[arg(short = 'q', long)]
|
||||
query: String,
|
||||
},
|
||||
}
|
||||
|
||||
// -- State ------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum StateAction {
|
||||
/// List all state events in a room.
|
||||
List {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
},
|
||||
/// Get a specific state event.
|
||||
Get {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// Event type (e.g. m.room.name).
|
||||
#[arg(long)]
|
||||
event_type: String,
|
||||
/// State key (default: empty string).
|
||||
#[arg(long, default_value = "")]
|
||||
state_key: String,
|
||||
},
|
||||
/// Set a state event in a room.
|
||||
Set {
|
||||
/// Room ID.
|
||||
#[arg(long)]
|
||||
room_id: String,
|
||||
/// Event type (e.g. m.room.name).
|
||||
#[arg(long)]
|
||||
event_type: String,
|
||||
/// State key (default: empty string).
|
||||
#[arg(long, default_value = "")]
|
||||
state_key: String,
|
||||
/// JSON body (or - for stdin).
|
||||
#[arg(short = 'd', long = "data")]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Profile ----------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ProfileAction {
|
||||
/// Get a user's profile.
|
||||
Get {
|
||||
/// User ID (e.g. @alice:example.com).
|
||||
#[arg(long)]
|
||||
user_id: String,
|
||||
},
|
||||
/// Set the display name.
|
||||
SetName {
|
||||
/// User ID.
|
||||
#[arg(long)]
|
||||
user_id: String,
|
||||
/// New display name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
/// Set the avatar URL.
|
||||
SetAvatar {
|
||||
/// User ID.
|
||||
#[arg(long)]
|
||||
user_id: String,
|
||||
/// Avatar MXC URI.
|
||||
#[arg(long)]
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
// -- Device -----------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum DeviceAction {
|
||||
/// List all devices for the authenticated user.
|
||||
List,
|
||||
/// Get information about a specific device.
|
||||
Get {
|
||||
/// Device ID.
|
||||
#[arg(long)]
|
||||
device_id: String,
|
||||
},
|
||||
/// Delete a device.
|
||||
Delete {
|
||||
/// Device ID.
|
||||
#[arg(long)]
|
||||
device_id: String,
|
||||
/// Interactive auth JSON (or - for stdin).
|
||||
#[arg(short = 'd', long = "data")]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// -- User -------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum UserAction {
|
||||
/// Search the user directory.
|
||||
Search {
|
||||
/// Search query.
|
||||
#[arg(short = 'q', long)]
|
||||
query: String,
|
||||
/// Maximum results.
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Dispatch a parsed [`ChatCommand`] against the Matrix homeserver.
|
||||
pub async fn dispatch(domain: &str, format: OutputFormat, cmd: ChatCommand) -> Result<()> {
|
||||
let m = matrix_with_token(domain).await?;
|
||||
|
||||
match cmd {
|
||||
// -- Whoami ---------------------------------------------------------
|
||||
ChatCommand::Whoami => {
|
||||
let resp = m.whoami().await?;
|
||||
output::render(&resp, format)
|
||||
}
|
||||
|
||||
// -- Sync -----------------------------------------------------------
|
||||
ChatCommand::Sync {
|
||||
since,
|
||||
filter,
|
||||
full_state,
|
||||
set_presence,
|
||||
timeout,
|
||||
} => {
|
||||
let params = super::types::SyncParams {
|
||||
since,
|
||||
filter,
|
||||
full_state: if full_state { Some(true) } else { None },
|
||||
set_presence,
|
||||
timeout,
|
||||
};
|
||||
let resp = m.sync(¶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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
351
sunbeam-sdk/src/media/cli.rs
Normal file
351
sunbeam-sdk/src/media/cli.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::client::SunbeamClient;
|
||||
use crate::error::Result;
|
||||
use crate::media::types::VideoGrants;
|
||||
use crate::media::LiveKitClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level MediaCommand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum MediaCommand {
|
||||
/// Room management.
|
||||
Room {
|
||||
#[command(subcommand)]
|
||||
action: RoomAction,
|
||||
},
|
||||
/// Participant management.
|
||||
Participant {
|
||||
#[command(subcommand)]
|
||||
action: ParticipantAction,
|
||||
},
|
||||
/// Egress management.
|
||||
Egress {
|
||||
#[command(subcommand)]
|
||||
action: EgressAction,
|
||||
},
|
||||
/// Token operations.
|
||||
Token {
|
||||
#[command(subcommand)]
|
||||
action: TokenAction,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RoomAction {
|
||||
/// List all rooms.
|
||||
List,
|
||||
/// Create a room.
|
||||
Create {
|
||||
/// Room name.
|
||||
#[arg(short, long)]
|
||||
name: String,
|
||||
/// Maximum number of participants.
|
||||
#[arg(long)]
|
||||
max_participants: Option<u32>,
|
||||
},
|
||||
/// Delete a room.
|
||||
Delete {
|
||||
/// Room name.
|
||||
#[arg(short, long)]
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Participant sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ParticipantAction {
|
||||
/// List participants in a room.
|
||||
List {
|
||||
/// Room name.
|
||||
#[arg(short, long)]
|
||||
room: String,
|
||||
},
|
||||
/// Get a participant.
|
||||
Get {
|
||||
/// Room name.
|
||||
#[arg(short, long)]
|
||||
room: String,
|
||||
/// Participant identity.
|
||||
#[arg(short, long)]
|
||||
identity: String,
|
||||
},
|
||||
/// Remove a participant from a room.
|
||||
Remove {
|
||||
/// Room name.
|
||||
#[arg(short, long)]
|
||||
room: String,
|
||||
/// Participant identity.
|
||||
#[arg(short, long)]
|
||||
identity: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Egress sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum EgressAction {
|
||||
/// List egress sessions for a room.
|
||||
List {
|
||||
/// Room name.
|
||||
#[arg(short, long)]
|
||||
room: String,
|
||||
},
|
||||
/// Start a room composite egress.
|
||||
StartRoomComposite {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Start a track egress.
|
||||
StartTrack {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Stop an egress session.
|
||||
Stop {
|
||||
/// Egress ID.
|
||||
#[arg(short, long)]
|
||||
egress_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum TokenAction {
|
||||
/// Generate a LiveKit access token.
|
||||
Generate {
|
||||
/// LiveKit API key.
|
||||
#[arg(long)]
|
||||
api_key: String,
|
||||
/// LiveKit API secret.
|
||||
#[arg(long)]
|
||||
api_secret: String,
|
||||
/// Participant identity.
|
||||
#[arg(long)]
|
||||
identity: String,
|
||||
/// Room name to grant access to.
|
||||
#[arg(long)]
|
||||
room: Option<String>,
|
||||
/// Token TTL in seconds.
|
||||
#[arg(long, default_value = "3600")]
|
||||
ttl: u64,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: MediaCommand,
|
||||
client: &SunbeamClient,
|
||||
output: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
MediaCommand::Room { action } => dispatch_room(action, client, output).await,
|
||||
MediaCommand::Participant { action } => {
|
||||
dispatch_participant(action, client, output).await
|
||||
}
|
||||
MediaCommand::Egress { action } => dispatch_egress(action, client, output).await,
|
||||
MediaCommand::Token { action } => dispatch_token(action, output),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_room(
|
||||
action: RoomAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let lk = client.livekit();
|
||||
match action {
|
||||
RoomAction::List => {
|
||||
let resp = lk.list_rooms().await?;
|
||||
output::render_list(
|
||||
&resp.rooms,
|
||||
&["NAME", "SID", "PARTICIPANTS", "MAX", "CREATED"],
|
||||
|r| {
|
||||
vec![
|
||||
r.name.clone(),
|
||||
r.sid.clone(),
|
||||
r.num_participants
|
||||
.map_or("-".into(), |n| n.to_string()),
|
||||
r.max_participants
|
||||
.map_or("-".into(), |n| n.to_string()),
|
||||
r.creation_time
|
||||
.map_or("-".into(), |t| t.to_string()),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
RoomAction::Create {
|
||||
name,
|
||||
max_participants,
|
||||
} => {
|
||||
let mut body = serde_json::json!({ "name": name });
|
||||
if let Some(max) = max_participants {
|
||||
body["max_participants"] = serde_json::json!(max);
|
||||
}
|
||||
let room = lk.create_room(&body).await?;
|
||||
output::render(&room, fmt)
|
||||
}
|
||||
RoomAction::Delete { name } => {
|
||||
lk.delete_room(&serde_json::json!({ "room": name })).await?;
|
||||
output::ok(&format!("Deleted room {name}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Participant dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_participant(
|
||||
action: ParticipantAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let lk = client.livekit();
|
||||
match action {
|
||||
ParticipantAction::List { room } => {
|
||||
let resp = lk
|
||||
.list_participants(&serde_json::json!({ "room": room }))
|
||||
.await?;
|
||||
output::render_list(
|
||||
&resp.participants,
|
||||
&["SID", "IDENTITY", "NAME", "STATE", "JOINED_AT"],
|
||||
|p| {
|
||||
vec![
|
||||
p.sid.clone(),
|
||||
p.identity.clone(),
|
||||
p.name.clone().unwrap_or_default(),
|
||||
p.state.map_or("-".into(), |s| s.to_string()),
|
||||
p.joined_at.map_or("-".into(), |t| t.to_string()),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
ParticipantAction::Get { room, identity } => {
|
||||
let info = lk
|
||||
.get_participant(&serde_json::json!({
|
||||
"room": room,
|
||||
"identity": identity,
|
||||
}))
|
||||
.await?;
|
||||
output::render(&info, fmt)
|
||||
}
|
||||
ParticipantAction::Remove { room, identity } => {
|
||||
lk.remove_participant(&serde_json::json!({
|
||||
"room": room,
|
||||
"identity": identity,
|
||||
}))
|
||||
.await?;
|
||||
output::ok(&format!("Removed participant {identity} from room {room}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Egress dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_egress(
|
||||
action: EgressAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let lk = client.livekit();
|
||||
match action {
|
||||
EgressAction::List { room } => {
|
||||
let resp = lk
|
||||
.list_egress(&serde_json::json!({ "room_name": room }))
|
||||
.await?;
|
||||
output::render_list(
|
||||
&resp.items,
|
||||
&["EGRESS_ID", "ROOM", "STATUS", "STARTED", "ENDED"],
|
||||
|e| {
|
||||
vec![
|
||||
e.egress_id.clone(),
|
||||
e.room_name.clone().unwrap_or_default(),
|
||||
e.status.map_or("-".into(), |s| s.to_string()),
|
||||
e.started_at.map_or("-".into(), |t| t.to_string()),
|
||||
e.ended_at.map_or("-".into(), |t| t.to_string()),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
EgressAction::StartRoomComposite { data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let info = lk.start_room_composite_egress(&body).await?;
|
||||
output::render(&info, fmt)
|
||||
}
|
||||
EgressAction::StartTrack { data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let info = lk.start_track_egress(&body).await?;
|
||||
output::render(&info, fmt)
|
||||
}
|
||||
EgressAction::Stop { egress_id } => {
|
||||
let info = lk
|
||||
.stop_egress(&serde_json::json!({ "egress_id": egress_id }))
|
||||
.await?;
|
||||
output::render(&info, fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn dispatch_token(action: TokenAction, fmt: OutputFormat) -> Result<()> {
|
||||
match action {
|
||||
TokenAction::Generate {
|
||||
api_key,
|
||||
api_secret,
|
||||
identity,
|
||||
room,
|
||||
ttl,
|
||||
} => {
|
||||
let grants = VideoGrants {
|
||||
room_join: Some(true),
|
||||
room,
|
||||
can_publish: Some(true),
|
||||
can_subscribe: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let token = LiveKitClient::generate_access_token(
|
||||
&api_key,
|
||||
&api_secret,
|
||||
&identity,
|
||||
&grants,
|
||||
ttl,
|
||||
)?;
|
||||
output::render(&serde_json::json!({ "token": token }), fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
895
sunbeam-sdk/src/monitoring/cli.rs
Normal file
895
sunbeam-sdk/src/monitoring/cli.rs
Normal file
@@ -0,0 +1,895 @@
|
||||
//! CLI commands for monitoring services: Prometheus, Loki, and Grafana.
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::client::SunbeamClient;
|
||||
use crate::error::Result;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
// ===========================================================================
|
||||
// Top-level MonCommand
|
||||
// ===========================================================================
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum MonCommand {
|
||||
/// Prometheus queries, targets, rules and status.
|
||||
Prometheus {
|
||||
#[command(subcommand)]
|
||||
action: PrometheusAction,
|
||||
},
|
||||
/// Loki log queries, labels, and ingestion.
|
||||
Loki {
|
||||
#[command(subcommand)]
|
||||
action: LokiAction,
|
||||
},
|
||||
/// Grafana dashboards, datasources, folders, annotations, alerts, and org.
|
||||
Grafana {
|
||||
#[command(subcommand)]
|
||||
action: GrafanaAction,
|
||||
},
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Prometheus
|
||||
// ===========================================================================
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum PrometheusAction {
|
||||
/// Execute an instant PromQL query.
|
||||
Query {
|
||||
/// PromQL expression.
|
||||
#[arg(short, long, alias = "expr", short_alias = 'e')]
|
||||
query: String,
|
||||
/// Evaluation timestamp (RFC-3339 or Unix).
|
||||
#[arg(long)]
|
||||
time: Option<String>,
|
||||
},
|
||||
/// Execute a range PromQL query.
|
||||
QueryRange {
|
||||
/// PromQL expression.
|
||||
#[arg(short, long, alias = "expr", short_alias = 'e')]
|
||||
query: String,
|
||||
/// Start timestamp.
|
||||
#[arg(long)]
|
||||
start: String,
|
||||
/// End timestamp.
|
||||
#[arg(long)]
|
||||
end: String,
|
||||
/// Query resolution step (e.g. "15s").
|
||||
#[arg(long)]
|
||||
step: String,
|
||||
},
|
||||
/// Find series by label matchers.
|
||||
Series {
|
||||
/// One or more series selectors (e.g. `up{job="node"}`).
|
||||
#[arg(short = 'm', long = "match", required = true)]
|
||||
match_params: Vec<String>,
|
||||
#[arg(long)]
|
||||
start: Option<String>,
|
||||
#[arg(long)]
|
||||
end: Option<String>,
|
||||
},
|
||||
/// List all label names.
|
||||
Labels {
|
||||
#[arg(long)]
|
||||
start: Option<String>,
|
||||
#[arg(long)]
|
||||
end: Option<String>,
|
||||
},
|
||||
/// List values for a specific label.
|
||||
LabelValues {
|
||||
/// Label name.
|
||||
#[arg(short, long)]
|
||||
label: String,
|
||||
#[arg(long)]
|
||||
start: Option<String>,
|
||||
#[arg(long)]
|
||||
end: Option<String>,
|
||||
},
|
||||
/// Show current target discovery status.
|
||||
Targets,
|
||||
/// List alerting and recording rules.
|
||||
Rules,
|
||||
/// List active alerts.
|
||||
Alerts,
|
||||
/// Show Prometheus runtime information.
|
||||
Status,
|
||||
/// Show per-metric metadata.
|
||||
Metadata {
|
||||
/// Filter by metric name.
|
||||
#[arg(short, long)]
|
||||
metric: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Loki
|
||||
// ===========================================================================
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum LokiAction {
|
||||
/// Execute an instant LogQL query.
|
||||
Query {
|
||||
/// LogQL expression.
|
||||
#[arg(short, long, alias = "expr", short_alias = 'e')]
|
||||
query: String,
|
||||
/// Maximum number of entries to return.
|
||||
#[arg(short, long)]
|
||||
limit: Option<u32>,
|
||||
/// Evaluation timestamp.
|
||||
#[arg(long)]
|
||||
time: Option<String>,
|
||||
},
|
||||
/// Execute a range LogQL query.
|
||||
QueryRange {
|
||||
/// LogQL expression.
|
||||
#[arg(short, long, alias = "expr", short_alias = 'e')]
|
||||
query: String,
|
||||
/// Start timestamp.
|
||||
#[arg(long)]
|
||||
start: String,
|
||||
/// End timestamp.
|
||||
#[arg(long)]
|
||||
end: String,
|
||||
/// Maximum number of entries to return.
|
||||
#[arg(short, long)]
|
||||
limit: Option<u32>,
|
||||
/// Query resolution step.
|
||||
#[arg(long)]
|
||||
step: Option<String>,
|
||||
},
|
||||
/// List all label names.
|
||||
Labels {
|
||||
#[arg(long)]
|
||||
start: Option<String>,
|
||||
#[arg(long)]
|
||||
end: Option<String>,
|
||||
},
|
||||
/// List values for a specific label.
|
||||
LabelValues {
|
||||
/// Label name.
|
||||
#[arg(short, long)]
|
||||
label: String,
|
||||
#[arg(long)]
|
||||
start: Option<String>,
|
||||
#[arg(long)]
|
||||
end: Option<String>,
|
||||
},
|
||||
/// Find series by label matchers.
|
||||
Series {
|
||||
/// One or more series selectors.
|
||||
#[arg(short = 'm', long = "match", required = true)]
|
||||
match_params: Vec<String>,
|
||||
#[arg(long)]
|
||||
start: Option<String>,
|
||||
#[arg(long)]
|
||||
end: Option<String>,
|
||||
},
|
||||
/// Push log entries from JSON.
|
||||
Push {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Show index statistics.
|
||||
IndexStats,
|
||||
/// Detect log patterns.
|
||||
Patterns {
|
||||
/// LogQL expression.
|
||||
#[arg(short, long, alias = "expr", short_alias = 'e')]
|
||||
query: String,
|
||||
#[arg(long)]
|
||||
start: Option<String>,
|
||||
#[arg(long)]
|
||||
end: Option<String>,
|
||||
},
|
||||
/// Check Loki readiness.
|
||||
Ready,
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Grafana
|
||||
// ===========================================================================
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum GrafanaAction {
|
||||
/// Dashboard management.
|
||||
Dashboard {
|
||||
#[command(subcommand)]
|
||||
action: GrafanaDashboardAction,
|
||||
},
|
||||
/// Datasource management.
|
||||
Datasource {
|
||||
#[command(subcommand)]
|
||||
action: GrafanaDatasourceAction,
|
||||
},
|
||||
/// Folder management.
|
||||
Folder {
|
||||
#[command(subcommand)]
|
||||
action: GrafanaFolderAction,
|
||||
},
|
||||
/// Annotation management.
|
||||
Annotation {
|
||||
#[command(subcommand)]
|
||||
action: GrafanaAnnotationAction,
|
||||
},
|
||||
/// Alert rule management.
|
||||
Alert {
|
||||
#[command(subcommand)]
|
||||
action: GrafanaAlertAction,
|
||||
},
|
||||
/// Organization settings.
|
||||
Org {
|
||||
#[command(subcommand)]
|
||||
action: GrafanaOrgAction,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum GrafanaDashboardAction {
|
||||
/// List all dashboards.
|
||||
List,
|
||||
/// Get a dashboard by UID.
|
||||
Get {
|
||||
#[arg(long)]
|
||||
uid: String,
|
||||
},
|
||||
/// Create a dashboard from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update a dashboard from JSON.
|
||||
Update {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a dashboard by UID.
|
||||
Delete {
|
||||
#[arg(long)]
|
||||
uid: String,
|
||||
},
|
||||
/// Search dashboards by query string.
|
||||
Search {
|
||||
#[arg(short, long)]
|
||||
query: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Datasource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum GrafanaDatasourceAction {
|
||||
/// List all datasources.
|
||||
List,
|
||||
/// Get a datasource by numeric ID.
|
||||
Get {
|
||||
#[arg(long)]
|
||||
id: u64,
|
||||
},
|
||||
/// Create a datasource from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update a datasource from JSON.
|
||||
Update {
|
||||
#[arg(long)]
|
||||
id: u64,
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a datasource by numeric ID.
|
||||
Delete {
|
||||
#[arg(long)]
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Folder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum GrafanaFolderAction {
|
||||
/// List all folders.
|
||||
List,
|
||||
/// Get a folder by UID.
|
||||
Get {
|
||||
#[arg(long)]
|
||||
uid: String,
|
||||
},
|
||||
/// Create a folder from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update a folder from JSON.
|
||||
Update {
|
||||
#[arg(long)]
|
||||
uid: String,
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a folder by UID.
|
||||
Delete {
|
||||
#[arg(long)]
|
||||
uid: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Annotation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum GrafanaAnnotationAction {
|
||||
/// List annotations (optional query-string filter).
|
||||
List {
|
||||
/// Raw query-string params (e.g. "from=1234&to=5678").
|
||||
#[arg(long)]
|
||||
params: Option<String>,
|
||||
},
|
||||
/// Create an annotation from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete an annotation by ID.
|
||||
Delete {
|
||||
#[arg(long)]
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Alert
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum GrafanaAlertAction {
|
||||
/// List all provisioned alert rules.
|
||||
List,
|
||||
/// Create a provisioned alert rule from JSON.
|
||||
Create {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Update a provisioned alert rule from JSON.
|
||||
Update {
|
||||
#[arg(long)]
|
||||
uid: String,
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a provisioned alert rule by UID.
|
||||
Delete {
|
||||
#[arg(long)]
|
||||
uid: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Org
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum GrafanaOrgAction {
|
||||
/// Get the current organization.
|
||||
Get,
|
||||
/// Update the current organization from JSON.
|
||||
Update {
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Dispatch
|
||||
// ===========================================================================
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: MonCommand,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
MonCommand::Prometheus { action } => dispatch_prometheus(action, client, fmt).await,
|
||||
MonCommand::Loki { action } => dispatch_loki(action, client, fmt).await,
|
||||
MonCommand::Grafana { action } => dispatch_grafana(action, client, fmt).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Prometheus dispatch
|
||||
// ===========================================================================
|
||||
|
||||
async fn dispatch_prometheus(
|
||||
action: PrometheusAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let prom = client.prometheus();
|
||||
match action {
|
||||
PrometheusAction::Query { query, time } => {
|
||||
let res = prom.query(&query, time.as_deref()).await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
PrometheusAction::QueryRange {
|
||||
query,
|
||||
start,
|
||||
end,
|
||||
step,
|
||||
} => {
|
||||
let res = prom.query_range(&query, &start, &end, &step).await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
PrometheusAction::Series {
|
||||
match_params,
|
||||
start,
|
||||
end,
|
||||
} => {
|
||||
let refs: Vec<&str> = match_params.iter().map(|s| s.as_str()).collect();
|
||||
let res = prom
|
||||
.series(&refs, start.as_deref(), end.as_deref())
|
||||
.await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
PrometheusAction::Labels { start, end } => {
|
||||
let res = prom.labels(start.as_deref(), end.as_deref()).await?;
|
||||
if let Some(labels) = &res.data {
|
||||
output::render_list(
|
||||
labels,
|
||||
&["LABEL"],
|
||||
|l| vec![l.clone()],
|
||||
fmt,
|
||||
)
|
||||
} else {
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
}
|
||||
PrometheusAction::LabelValues { label, start, end } => {
|
||||
let res = prom
|
||||
.label_values(&label, start.as_deref(), end.as_deref())
|
||||
.await?;
|
||||
if let Some(values) = &res.data {
|
||||
output::render_list(
|
||||
values,
|
||||
&["VALUE"],
|
||||
|v| vec![v.clone()],
|
||||
fmt,
|
||||
)
|
||||
} else {
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
}
|
||||
PrometheusAction::Targets => {
|
||||
let res = prom.targets().await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
PrometheusAction::Rules => {
|
||||
let res = prom.rules().await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
PrometheusAction::Alerts => {
|
||||
let res = prom.alerts().await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
PrometheusAction::Status => {
|
||||
let res = prom.runtime_info().await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
PrometheusAction::Metadata { metric } => {
|
||||
let res = prom.metadata(metric.as_deref()).await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Loki dispatch
|
||||
// ===========================================================================
|
||||
|
||||
async fn dispatch_loki(
|
||||
action: LokiAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let loki = client.loki();
|
||||
match action {
|
||||
LokiAction::Query { query, limit, time } => {
|
||||
let res = loki.query(&query, limit, time.as_deref()).await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
LokiAction::QueryRange {
|
||||
query,
|
||||
start,
|
||||
end,
|
||||
limit,
|
||||
step,
|
||||
} => {
|
||||
let res = loki
|
||||
.query_range(&query, &start, &end, limit, step.as_deref())
|
||||
.await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
LokiAction::Labels { start, end } => {
|
||||
let res = loki.labels(start.as_deref(), end.as_deref()).await?;
|
||||
if let Some(labels) = &res.data {
|
||||
output::render_list(
|
||||
labels,
|
||||
&["LABEL"],
|
||||
|l| vec![l.clone()],
|
||||
fmt,
|
||||
)
|
||||
} else {
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
}
|
||||
LokiAction::LabelValues { label, start, end } => {
|
||||
let res = loki
|
||||
.label_values(&label, start.as_deref(), end.as_deref())
|
||||
.await?;
|
||||
if let Some(values) = &res.data {
|
||||
output::render_list(
|
||||
values,
|
||||
&["VALUE"],
|
||||
|v| vec![v.clone()],
|
||||
fmt,
|
||||
)
|
||||
} else {
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
}
|
||||
LokiAction::Series {
|
||||
match_params,
|
||||
start,
|
||||
end,
|
||||
} => {
|
||||
let refs: Vec<&str> = match_params.iter().map(|s| s.as_str()).collect();
|
||||
let res = loki
|
||||
.series(&refs, start.as_deref(), end.as_deref())
|
||||
.await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
LokiAction::Push { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
loki.push(&json).await?;
|
||||
output::ok("Pushed log entries");
|
||||
Ok(())
|
||||
}
|
||||
LokiAction::IndexStats => {
|
||||
let res = loki.index_stats().await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
LokiAction::Patterns { query, start, end } => {
|
||||
let res = loki
|
||||
.detect_patterns(&query, start.as_deref(), end.as_deref())
|
||||
.await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
LokiAction::Ready => {
|
||||
let res = loki.ready().await?;
|
||||
output::render(&res, fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Grafana dispatch
|
||||
// ===========================================================================
|
||||
|
||||
async fn dispatch_grafana(
|
||||
action: GrafanaAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match action {
|
||||
GrafanaAction::Dashboard { action } => {
|
||||
dispatch_grafana_dashboard(action, client, fmt).await
|
||||
}
|
||||
GrafanaAction::Datasource { action } => {
|
||||
dispatch_grafana_datasource(action, client, fmt).await
|
||||
}
|
||||
GrafanaAction::Folder { action } => {
|
||||
dispatch_grafana_folder(action, client, fmt).await
|
||||
}
|
||||
GrafanaAction::Annotation { action } => {
|
||||
dispatch_grafana_annotation(action, client, fmt).await
|
||||
}
|
||||
GrafanaAction::Alert { action } => {
|
||||
dispatch_grafana_alert(action, client, fmt).await
|
||||
}
|
||||
GrafanaAction::Org { action } => {
|
||||
dispatch_grafana_org(action, client, fmt).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Dashboard dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_grafana_dashboard(
|
||||
action: GrafanaDashboardAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let grafana = client.grafana();
|
||||
match action {
|
||||
GrafanaDashboardAction::List => {
|
||||
let items = grafana.list_dashboards().await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["UID", "TITLE", "URL", "TAGS"],
|
||||
|d| {
|
||||
vec![
|
||||
d.uid.clone(),
|
||||
d.title.clone(),
|
||||
d.url.clone().unwrap_or_default(),
|
||||
d.tags.join(", "),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
GrafanaDashboardAction::Get { uid } => {
|
||||
let item = grafana.get_dashboard(&uid).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaDashboardAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.create_dashboard(&json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaDashboardAction::Update { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.update_dashboard(&json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaDashboardAction::Delete { uid } => {
|
||||
grafana.delete_dashboard(&uid).await?;
|
||||
output::ok(&format!("Deleted dashboard {uid}"));
|
||||
Ok(())
|
||||
}
|
||||
GrafanaDashboardAction::Search { query } => {
|
||||
let items = grafana.search_dashboards(&query).await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["UID", "TITLE", "URL", "TAGS"],
|
||||
|d| {
|
||||
vec![
|
||||
d.uid.clone(),
|
||||
d.title.clone(),
|
||||
d.url.clone().unwrap_or_default(),
|
||||
d.tags.join(", "),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Datasource dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_grafana_datasource(
|
||||
action: GrafanaDatasourceAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let grafana = client.grafana();
|
||||
match action {
|
||||
GrafanaDatasourceAction::List => {
|
||||
let items = grafana.list_datasources().await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["ID", "UID", "NAME", "TYPE", "URL"],
|
||||
|d| {
|
||||
vec![
|
||||
d.id.map_or("-".into(), |id| id.to_string()),
|
||||
d.uid.clone().unwrap_or_default(),
|
||||
d.name.clone(),
|
||||
d.kind.clone(),
|
||||
d.url.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
GrafanaDatasourceAction::Get { id } => {
|
||||
let item = grafana.get_datasource(id).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaDatasourceAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.create_datasource(&json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaDatasourceAction::Update { id, data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.update_datasource(id, &json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaDatasourceAction::Delete { id } => {
|
||||
grafana.delete_datasource(id).await?;
|
||||
output::ok(&format!("Deleted datasource {id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Folder dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_grafana_folder(
|
||||
action: GrafanaFolderAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let grafana = client.grafana();
|
||||
match action {
|
||||
GrafanaFolderAction::List => {
|
||||
let items = grafana.list_folders().await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["UID", "TITLE", "URL"],
|
||||
|f| {
|
||||
vec![
|
||||
f.uid.clone(),
|
||||
f.title.clone(),
|
||||
f.url.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
GrafanaFolderAction::Get { uid } => {
|
||||
let item = grafana.get_folder(&uid).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaFolderAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.create_folder(&json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaFolderAction::Update { uid, data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.update_folder(&uid, &json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaFolderAction::Delete { uid } => {
|
||||
grafana.delete_folder(&uid).await?;
|
||||
output::ok(&format!("Deleted folder {uid}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Annotation dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_grafana_annotation(
|
||||
action: GrafanaAnnotationAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let grafana = client.grafana();
|
||||
match action {
|
||||
GrafanaAnnotationAction::List { params } => {
|
||||
let items = grafana.list_annotations(params.as_deref()).await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["ID", "TEXT", "TAGS"],
|
||||
|a| {
|
||||
vec![
|
||||
a.id.map_or("-".into(), |id| id.to_string()),
|
||||
a.text.clone().unwrap_or_default(),
|
||||
a.tags.join(", "),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
GrafanaAnnotationAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.create_annotation(&json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaAnnotationAction::Delete { id } => {
|
||||
grafana.delete_annotation(id).await?;
|
||||
output::ok(&format!("Deleted annotation {id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Alert dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_grafana_alert(
|
||||
action: GrafanaAlertAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let grafana = client.grafana();
|
||||
match action {
|
||||
GrafanaAlertAction::List => {
|
||||
let items = grafana.get_alert_rules().await?;
|
||||
output::render_list(
|
||||
&items,
|
||||
&["UID", "TITLE", "CONDITION", "FOLDER", "GROUP"],
|
||||
|r| {
|
||||
vec![
|
||||
r.uid.clone().unwrap_or_default(),
|
||||
r.title.clone().unwrap_or_default(),
|
||||
r.condition.clone().unwrap_or_default(),
|
||||
r.folder_uid.clone().unwrap_or_default(),
|
||||
r.rule_group.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
GrafanaAlertAction::Create { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.create_alert_rule(&json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaAlertAction::Update { uid, data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let item = grafana.update_alert_rule(&uid, &json).await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaAlertAction::Delete { uid } => {
|
||||
grafana.delete_alert_rule(&uid).await?;
|
||||
output::ok(&format!("Deleted alert rule {uid}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana Org dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_grafana_org(
|
||||
action: GrafanaOrgAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let grafana = client.grafana();
|
||||
match action {
|
||||
GrafanaOrgAction::Get => {
|
||||
let item = grafana.get_current_org().await?;
|
||||
output::render(&item, fmt)
|
||||
}
|
||||
GrafanaOrgAction::Update { data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
grafana.update_org(&json).await?;
|
||||
output::ok("Updated organization");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
337
sunbeam-sdk/src/openbao/cli.rs
Normal file
337
sunbeam-sdk/src/openbao/cli.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
//! CLI command definitions and dispatch for OpenBao (Vault).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Command tree
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum VaultCommand {
|
||||
/// Show seal status.
|
||||
Status,
|
||||
/// Initialize the vault.
|
||||
Init {
|
||||
/// Number of key shares.
|
||||
#[arg(long, default_value = "5")]
|
||||
key_shares: u32,
|
||||
/// Key threshold for unseal.
|
||||
#[arg(long, default_value = "3")]
|
||||
key_threshold: u32,
|
||||
},
|
||||
/// Unseal the vault with a key share.
|
||||
Unseal {
|
||||
/// Unseal key share.
|
||||
#[arg(short, long)]
|
||||
key: String,
|
||||
},
|
||||
/// KV secrets engine operations.
|
||||
Kv {
|
||||
#[command(subcommand)]
|
||||
action: KvAction,
|
||||
},
|
||||
/// Write a policy from HCL.
|
||||
Policy {
|
||||
#[command(subcommand)]
|
||||
action: PolicyAction,
|
||||
},
|
||||
/// Auth method management.
|
||||
Auth {
|
||||
#[command(subcommand)]
|
||||
action: AuthAction,
|
||||
},
|
||||
/// Secrets engine management.
|
||||
Secrets {
|
||||
#[command(subcommand)]
|
||||
action: SecretsAction,
|
||||
},
|
||||
/// Read from an arbitrary API path.
|
||||
Read {
|
||||
/// API path (e.g. "auth/token/lookup-self").
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
},
|
||||
/// Write to an arbitrary API path.
|
||||
Write {
|
||||
/// API path.
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// JSON body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum KvAction {
|
||||
/// Get a secret.
|
||||
Get {
|
||||
/// Secret path.
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// Secrets engine mount point.
|
||||
#[arg(long, default_value = "secret")]
|
||||
mount: String,
|
||||
},
|
||||
/// Put (create/overwrite) a secret.
|
||||
Put {
|
||||
/// Secret path.
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// Secrets engine mount point.
|
||||
#[arg(long, default_value = "secret")]
|
||||
mount: String,
|
||||
/// JSON object or key=value pairs.
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Patch (merge) fields into a secret.
|
||||
Patch {
|
||||
/// Secret path.
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// Secrets engine mount point.
|
||||
#[arg(long, default_value = "secret")]
|
||||
mount: String,
|
||||
/// JSON object or key=value pairs.
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
/// Delete a secret.
|
||||
Delete {
|
||||
/// Secret path.
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// Secrets engine mount point.
|
||||
#[arg(long, default_value = "secret")]
|
||||
mount: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum PolicyAction {
|
||||
/// Write a policy from HCL.
|
||||
Write {
|
||||
/// Policy name.
|
||||
#[arg(short, long)]
|
||||
name: String,
|
||||
/// HCL policy body (or "-" to read from stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AuthAction {
|
||||
/// Enable an auth method.
|
||||
Enable {
|
||||
/// Mount path (e.g. "kubernetes").
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// Auth method type (e.g. "kubernetes").
|
||||
#[arg(short = 't', long = "type")]
|
||||
method_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SecretsAction {
|
||||
/// Enable a secrets engine.
|
||||
Enable {
|
||||
/// Mount path (e.g. "database").
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// Engine type (e.g. "database", "kv").
|
||||
#[arg(short = 't', long = "type")]
|
||||
engine_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Parse a `--data` value as either a JSON object `{"k":"v"}` or as
|
||||
/// `key=value` pairs (one per line or comma-separated), returning a
|
||||
/// `HashMap<String, String>`.
|
||||
fn parse_kv_data(raw: &str) -> Result<HashMap<String, String>> {
|
||||
// Try JSON first
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(raw) {
|
||||
if let Some(obj) = val.as_object() {
|
||||
let map: HashMap<String, String> = obj
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let s = match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
(k.clone(), s)
|
||||
})
|
||||
.collect();
|
||||
return Ok(map);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: key=value pairs separated by newlines or commas
|
||||
let mut map = HashMap::new();
|
||||
for token in raw.split(|c| c == '\n' || c == ',') {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = token.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
} else {
|
||||
return Err(crate::error::SunbeamError::Other(format!(
|
||||
"invalid key=value pair: {token}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Read kv data from `--data` flag or stdin.
|
||||
fn read_kv_input(flag: Option<&str>) -> Result<HashMap<String, String>> {
|
||||
let raw = match flag {
|
||||
Some("-") | None => {
|
||||
let mut buf = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
||||
buf
|
||||
}
|
||||
Some(v) => v.to_string(),
|
||||
};
|
||||
parse_kv_data(&raw)
|
||||
}
|
||||
|
||||
/// Read raw text from `--data` flag or stdin (for policy HCL).
|
||||
fn read_text_input(flag: Option<&str>) -> Result<String> {
|
||||
match flag {
|
||||
Some("-") | None => {
|
||||
let mut buf = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
Some(v) => Ok(v.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Dispatch
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: VaultCommand,
|
||||
bao: &super::BaoClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
// -- Status ---------------------------------------------------------
|
||||
VaultCommand::Status => {
|
||||
let status = bao.seal_status().await?;
|
||||
output::render(&status, fmt)
|
||||
}
|
||||
|
||||
// -- Init -----------------------------------------------------------
|
||||
VaultCommand::Init {
|
||||
key_shares,
|
||||
key_threshold,
|
||||
} => {
|
||||
let resp = bao.init(key_shares, key_threshold).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
// -- Unseal ---------------------------------------------------------
|
||||
VaultCommand::Unseal { key } => {
|
||||
let resp = bao.unseal(&key).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
// -- KV operations --------------------------------------------------
|
||||
VaultCommand::Kv { action } => match action {
|
||||
KvAction::Get { path, mount } => {
|
||||
let data = bao.kv_get(&mount, &path).await?;
|
||||
match data {
|
||||
Some(map) => output::render(&map, fmt),
|
||||
None => {
|
||||
output::ok(&format!("No secret found at {mount}/data/{path}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
KvAction::Put { path, mount, data } => {
|
||||
let map = read_kv_input(data.as_deref())?;
|
||||
bao.kv_put(&mount, &path, &map).await?;
|
||||
output::ok(&format!("Written to {mount}/data/{path}"));
|
||||
Ok(())
|
||||
}
|
||||
KvAction::Patch { path, mount, data } => {
|
||||
let map = read_kv_input(data.as_deref())?;
|
||||
bao.kv_patch(&mount, &path, &map).await?;
|
||||
output::ok(&format!("Patched {mount}/data/{path}"));
|
||||
Ok(())
|
||||
}
|
||||
KvAction::Delete { path, mount } => {
|
||||
bao.kv_delete(&mount, &path).await?;
|
||||
output::ok(&format!("Deleted {mount}/data/{path}"));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// -- Policy ---------------------------------------------------------
|
||||
VaultCommand::Policy { action } => match action {
|
||||
PolicyAction::Write { name, data } => {
|
||||
let hcl = read_text_input(data.as_deref())?;
|
||||
bao.write_policy(&name, &hcl).await?;
|
||||
output::ok(&format!("Written policy {name}"));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// -- Auth -----------------------------------------------------------
|
||||
VaultCommand::Auth { action } => match action {
|
||||
AuthAction::Enable { path, method_type } => {
|
||||
bao.auth_enable(&path, &method_type).await?;
|
||||
output::ok(&format!("Enabled auth method {method_type} at {path}"));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// -- Secrets engine -------------------------------------------------
|
||||
VaultCommand::Secrets { action } => match action {
|
||||
SecretsAction::Enable { path, engine_type } => {
|
||||
bao.enable_secrets_engine(&path, &engine_type).await?;
|
||||
output::ok(&format!("Enabled secrets engine {engine_type} at {path}"));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// -- Read -----------------------------------------------------------
|
||||
VaultCommand::Read { path } => {
|
||||
let data = bao.read(&path).await?;
|
||||
match data {
|
||||
Some(val) => output::render(&val, fmt),
|
||||
None => {
|
||||
output::ok(&format!("No data at {path}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Write ----------------------------------------------------------
|
||||
VaultCommand::Write { path, data } => {
|
||||
let json = output::read_json_input(data.as_deref())?;
|
||||
let resp = bao.write(&path, &json).await?;
|
||||
if resp.is_null() {
|
||||
output::ok(&format!("Written to {path}"));
|
||||
Ok(())
|
||||
} else {
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
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,
|
||||
|
||||
722
sunbeam-sdk/src/search/cli.rs
Normal file
722
sunbeam-sdk/src/search/cli.rs
Normal file
@@ -0,0 +1,722 @@
|
||||
//! CLI commands for OpenSearch.
|
||||
|
||||
use clap::Subcommand;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn os_client(domain: &str) -> Result<super::OpenSearchClient> {
|
||||
let token = crate::auth::get_token().await?;
|
||||
let mut c = super::OpenSearchClient::connect(domain);
|
||||
c.set_token(token);
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level command enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SearchCommand {
|
||||
/// Document operations.
|
||||
Doc {
|
||||
#[command(subcommand)]
|
||||
action: DocAction,
|
||||
},
|
||||
|
||||
/// Search an index.
|
||||
Query {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// Query string (wrapped in query_string query).
|
||||
#[arg(short, long)]
|
||||
query: Option<String>,
|
||||
/// Raw JSON body (overrides --query).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Count documents in an index.
|
||||
Count {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// Raw JSON body for the count query.
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Index management.
|
||||
Index {
|
||||
#[command(subcommand)]
|
||||
action: IndexAction,
|
||||
},
|
||||
|
||||
/// Cluster operations.
|
||||
Cluster {
|
||||
#[command(subcommand)]
|
||||
action: ClusterAction,
|
||||
},
|
||||
|
||||
/// Node operations.
|
||||
Node {
|
||||
#[command(subcommand)]
|
||||
action: NodeAction,
|
||||
},
|
||||
|
||||
/// Ingest pipeline management.
|
||||
Ingest {
|
||||
#[command(subcommand)]
|
||||
action: IngestAction,
|
||||
},
|
||||
|
||||
/// Snapshot management.
|
||||
Snapshot {
|
||||
#[command(subcommand)]
|
||||
action: SnapshotAction,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum DocAction {
|
||||
/// Get a document by ID.
|
||||
Get {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// Document ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
|
||||
/// Index (create/replace) a document.
|
||||
Create {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// Document ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Update a document by ID.
|
||||
Update {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// Document ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a document by ID.
|
||||
Delete {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// Document ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
|
||||
/// Check if a document exists.
|
||||
Exists {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// Document ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum IndexAction {
|
||||
/// List all indices.
|
||||
List,
|
||||
|
||||
/// Create an index.
|
||||
Create {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// JSON body (settings/mappings, or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Get index metadata.
|
||||
Get {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
},
|
||||
|
||||
/// Delete an index.
|
||||
Delete {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
},
|
||||
|
||||
/// Check if an index exists.
|
||||
Exists {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
},
|
||||
|
||||
/// Open a closed index.
|
||||
Open {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
},
|
||||
|
||||
/// Close an index.
|
||||
Close {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
},
|
||||
|
||||
/// Index mapping operations.
|
||||
Mapping {
|
||||
#[command(subcommand)]
|
||||
action: MappingAction,
|
||||
},
|
||||
|
||||
/// Index settings operations.
|
||||
Settings {
|
||||
#[command(subcommand)]
|
||||
action: SettingsAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum MappingAction {
|
||||
/// Get index mapping.
|
||||
Get {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
},
|
||||
|
||||
/// Update index mapping.
|
||||
Update {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SettingsAction {
|
||||
/// Get index settings.
|
||||
Get {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
},
|
||||
|
||||
/// Update index settings.
|
||||
Update {
|
||||
/// Index name.
|
||||
#[arg(short = 'x', long)]
|
||||
index: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cluster
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ClusterAction {
|
||||
/// Cluster health.
|
||||
Health,
|
||||
|
||||
/// Cluster state.
|
||||
State,
|
||||
|
||||
/// Cluster stats.
|
||||
Stats,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum NodeAction {
|
||||
/// List nodes.
|
||||
List,
|
||||
|
||||
/// Node stats.
|
||||
Stats,
|
||||
|
||||
/// Node info.
|
||||
Info,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ingest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum IngestAction {
|
||||
/// Ingest pipeline management.
|
||||
Pipeline {
|
||||
#[command(subcommand)]
|
||||
action: PipelineAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum PipelineAction {
|
||||
/// List all pipelines.
|
||||
List,
|
||||
|
||||
/// Create or update a pipeline.
|
||||
Create {
|
||||
/// Pipeline ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Get a pipeline.
|
||||
Get {
|
||||
/// Pipeline ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
|
||||
/// Delete a pipeline.
|
||||
Delete {
|
||||
/// Pipeline ID.
|
||||
#[arg(short, long)]
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SnapshotAction {
|
||||
/// List snapshots in a repository.
|
||||
List {
|
||||
/// Repository name.
|
||||
#[arg(long)]
|
||||
repo: String,
|
||||
},
|
||||
|
||||
/// Create a snapshot.
|
||||
Create {
|
||||
/// Repository name.
|
||||
#[arg(long)]
|
||||
repo: String,
|
||||
/// Snapshot name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a snapshot.
|
||||
Delete {
|
||||
/// Repository name.
|
||||
#[arg(long)]
|
||||
repo: String,
|
||||
/// Snapshot name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Restore a snapshot.
|
||||
Restore {
|
||||
/// Repository name.
|
||||
#[arg(long)]
|
||||
repo: String,
|
||||
/// Snapshot name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Snapshot repository management.
|
||||
Repo {
|
||||
#[command(subcommand)]
|
||||
action: RepoAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RepoAction {
|
||||
/// Create or update a snapshot repository.
|
||||
Create {
|
||||
/// Repository name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// JSON body (or "-" for stdin).
|
||||
#[arg(short, long)]
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a snapshot repository.
|
||||
Delete {
|
||||
/// Repository name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: SearchCommand,
|
||||
client: &crate::client::SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let c = os_client(client.domain()).await?;
|
||||
|
||||
match cmd {
|
||||
// -----------------------------------------------------------------
|
||||
// Documents
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Doc { action } => match action {
|
||||
DocAction::Get { index, id } => {
|
||||
let resp = c.get_doc(&index, &id).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
DocAction::Create { index, id, data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let resp = c.index_doc(&index, &id, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
DocAction::Update { index, id, data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let resp = c.update_doc(&index, &id, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
DocAction::Delete { index, id } => {
|
||||
let resp = c.delete_doc(&index, &id).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
DocAction::Exists { index, id } => {
|
||||
let exists = c.head_doc(&index, &id).await?;
|
||||
println!("{exists}");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Search query
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Query { index, query, data } => {
|
||||
let body = if let Some(d) = data.as_deref() {
|
||||
output::read_json_input(Some(d))?
|
||||
} else if let Some(ref q) = query {
|
||||
json!({ "query": { "query_string": { "query": q } } })
|
||||
} else {
|
||||
json!({ "query": { "match_all": {} } })
|
||||
};
|
||||
let resp = c.search(&index, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Count
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Count { index, data } => {
|
||||
let body = if let Some(d) = data.as_deref() {
|
||||
output::read_json_input(Some(d))?
|
||||
} else {
|
||||
json!({ "query": { "match_all": {} } })
|
||||
};
|
||||
let resp = c.count(&index, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Index management
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Index { action } => dispatch_index(&c, action, fmt).await,
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Cluster
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Cluster { action } => match action {
|
||||
ClusterAction::Health => {
|
||||
let resp = c.cluster_health().await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
ClusterAction::State => {
|
||||
let resp = c.cluster_state().await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
ClusterAction::Stats => {
|
||||
let resp = c.cluster_stats().await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Nodes
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Node { action } => match action {
|
||||
NodeAction::List => {
|
||||
let rows = c.cat_nodes().await?;
|
||||
output::render_list(
|
||||
&rows,
|
||||
&["NAME", "IP", "HEAP%", "RAM%", "CPU", "ROLE", "MASTER"],
|
||||
|n| {
|
||||
vec![
|
||||
n.name.clone().unwrap_or_default(),
|
||||
n.ip.clone().unwrap_or_default(),
|
||||
n.heap_percent.clone().unwrap_or_default(),
|
||||
n.ram_percent.clone().unwrap_or_default(),
|
||||
n.cpu.clone().unwrap_or_default(),
|
||||
n.node_role.clone().unwrap_or_default(),
|
||||
n.master.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
|
||||
NodeAction::Stats => {
|
||||
let resp = c.nodes_stats().await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
NodeAction::Info => {
|
||||
let resp = c.nodes_info().await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Ingest
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Ingest { action } => match action {
|
||||
IngestAction::Pipeline { action } => match action {
|
||||
PipelineAction::List => {
|
||||
let resp = c.get_all_pipelines().await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
PipelineAction::Create { id, data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let resp = c.create_pipeline(&id, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
PipelineAction::Get { id } => {
|
||||
let resp = c.get_pipeline(&id).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
PipelineAction::Delete { id } => {
|
||||
let resp = c.delete_pipeline(&id).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Snapshots
|
||||
// -----------------------------------------------------------------
|
||||
SearchCommand::Snapshot { action } => dispatch_snapshot(&c, action, fmt).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index sub-dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_index(
|
||||
c: &super::OpenSearchClient,
|
||||
action: IndexAction,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match action {
|
||||
IndexAction::List => {
|
||||
let rows = c.cat_indices().await?;
|
||||
output::render_list(
|
||||
&rows,
|
||||
&["HEALTH", "STATUS", "INDEX", "PRI", "REP", "DOCS", "SIZE"],
|
||||
|i| {
|
||||
vec![
|
||||
i.health.clone().unwrap_or_default(),
|
||||
i.status.clone().unwrap_or_default(),
|
||||
i.index.clone().unwrap_or_default(),
|
||||
i.pri.clone().unwrap_or_default(),
|
||||
i.rep.clone().unwrap_or_default(),
|
||||
i.docs_count.clone().unwrap_or_default(),
|
||||
i.store_size.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
|
||||
IndexAction::Create { index, data } => {
|
||||
let body = data
|
||||
.as_deref()
|
||||
.map(|d| output::read_json_input(Some(d)))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let resp = c.create_index(&index, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
IndexAction::Get { index } => {
|
||||
let resp = c.get_index(&index).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
IndexAction::Delete { index } => {
|
||||
let resp = c.delete_index(&index).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
IndexAction::Exists { index } => {
|
||||
let exists = c.index_exists(&index).await?;
|
||||
println!("{exists}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
IndexAction::Open { index } => {
|
||||
let resp = c.open_index(&index).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
IndexAction::Close { index } => {
|
||||
let resp = c.close_index(&index).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
IndexAction::Mapping { action } => match action {
|
||||
MappingAction::Get { index } => {
|
||||
let resp = c.get_mapping(&index).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
MappingAction::Update { index, data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let resp = c.put_mapping(&index, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
},
|
||||
|
||||
IndexAction::Settings { action } => match action {
|
||||
SettingsAction::Get { index } => {
|
||||
let resp = c.get_settings(&index).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
SettingsAction::Update { index, data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let resp = c.update_settings(&index, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot sub-dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_snapshot(
|
||||
c: &super::OpenSearchClient,
|
||||
action: SnapshotAction,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match action {
|
||||
SnapshotAction::List { repo } => {
|
||||
let resp = c.list_snapshots(&repo).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
SnapshotAction::Create { repo, name, data } => {
|
||||
let body = data
|
||||
.as_deref()
|
||||
.map(|d| output::read_json_input(Some(d)))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let resp = c.create_snapshot(&repo, &name, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
SnapshotAction::Delete { repo, name } => {
|
||||
let resp = c.delete_snapshot(&repo, &name).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
SnapshotAction::Restore { repo, name, data } => {
|
||||
let body = data
|
||||
.as_deref()
|
||||
.map(|d| output::read_json_input(Some(d)))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let resp = c.restore_snapshot(&repo, &name, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
SnapshotAction::Repo { action } => match action {
|
||||
RepoAction::Create { name, data } => {
|
||||
let body = output::read_json_input(data.as_deref())?;
|
||||
let resp = c.create_snapshot_repo(&name, &body).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
|
||||
RepoAction::Delete { name } => {
|
||||
let resp = c.delete_snapshot_repo(&name).await?;
|
||||
output::render(&resp, fmt)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! OpenSearch client.
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
pub mod cli;
|
||||
pub mod types;
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
|
||||
268
sunbeam-sdk/src/storage/cli.rs
Normal file
268
sunbeam-sdk/src/storage/cli.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use clap::Subcommand;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::client::SunbeamClient;
|
||||
use crate::error::Result;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level StorageCommand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum StorageCommand {
|
||||
/// Bucket operations.
|
||||
Bucket {
|
||||
#[command(subcommand)]
|
||||
action: BucketAction,
|
||||
},
|
||||
/// Object operations.
|
||||
Object {
|
||||
#[command(subcommand)]
|
||||
action: ObjectAction,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bucket sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum BucketAction {
|
||||
/// List all buckets.
|
||||
List,
|
||||
/// Create a bucket.
|
||||
Create {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
},
|
||||
/// Delete a bucket.
|
||||
Delete {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
},
|
||||
/// Check if a bucket exists.
|
||||
Exists {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object sub-commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ObjectAction {
|
||||
/// List objects in a bucket.
|
||||
List {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
/// Filter by key prefix.
|
||||
#[arg(long)]
|
||||
prefix: Option<String>,
|
||||
/// Maximum number of keys to return.
|
||||
#[arg(long)]
|
||||
max_keys: Option<u32>,
|
||||
},
|
||||
/// Download an object.
|
||||
Get {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
/// Object key.
|
||||
#[arg(short, long)]
|
||||
key: String,
|
||||
/// Write to file instead of stdout.
|
||||
#[arg(long)]
|
||||
output_file: Option<String>,
|
||||
},
|
||||
/// Upload an object.
|
||||
Put {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
/// Object key.
|
||||
#[arg(short, long)]
|
||||
key: String,
|
||||
/// Content-Type header.
|
||||
#[arg(long, default_value = "application/octet-stream")]
|
||||
content_type: String,
|
||||
/// Path to the file to upload.
|
||||
#[arg(short, long)]
|
||||
file: String,
|
||||
},
|
||||
/// Delete an object.
|
||||
Delete {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
/// Object key.
|
||||
#[arg(short, long)]
|
||||
key: String,
|
||||
},
|
||||
/// Check if an object exists.
|
||||
Exists {
|
||||
/// Bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
/// Object key.
|
||||
#[arg(short, long)]
|
||||
key: String,
|
||||
},
|
||||
/// Copy an object.
|
||||
Copy {
|
||||
/// Destination bucket name.
|
||||
#[arg(short, long)]
|
||||
bucket: String,
|
||||
/// Destination object key.
|
||||
#[arg(short, long)]
|
||||
key: String,
|
||||
/// Source in the form `/source-bucket/source-key`.
|
||||
#[arg(short, long)]
|
||||
source: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: StorageCommand,
|
||||
client: &SunbeamClient,
|
||||
output: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
StorageCommand::Bucket { action } => dispatch_bucket(action, client, output).await,
|
||||
StorageCommand::Object { action } => dispatch_object(action, client, output).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bucket dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_bucket(
|
||||
action: BucketAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let s3 = client.s3();
|
||||
match action {
|
||||
BucketAction::List => {
|
||||
let resp = s3.list_buckets().await?;
|
||||
output::render_list(
|
||||
&resp.buckets,
|
||||
&["NAME", "CREATED"],
|
||||
|b| {
|
||||
vec![
|
||||
b.name.clone(),
|
||||
b.creation_date.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
BucketAction::Create { bucket } => {
|
||||
s3.create_bucket(&bucket).await?;
|
||||
output::ok(&format!("Created bucket {bucket}"));
|
||||
Ok(())
|
||||
}
|
||||
BucketAction::Delete { bucket } => {
|
||||
s3.delete_bucket(&bucket).await?;
|
||||
output::ok(&format!("Deleted bucket {bucket}"));
|
||||
Ok(())
|
||||
}
|
||||
BucketAction::Exists { bucket } => {
|
||||
let exists = s3.head_bucket(&bucket).await?;
|
||||
output::render(&serde_json::json!({ "exists": exists }), fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn dispatch_object(
|
||||
action: ObjectAction,
|
||||
client: &SunbeamClient,
|
||||
fmt: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let s3 = client.s3();
|
||||
match action {
|
||||
ObjectAction::List {
|
||||
bucket,
|
||||
prefix,
|
||||
max_keys,
|
||||
} => {
|
||||
let resp = s3
|
||||
.list_objects_v2(&bucket, prefix.as_deref(), max_keys)
|
||||
.await?;
|
||||
output::render_list(
|
||||
&resp.contents,
|
||||
&["KEY", "SIZE", "LAST_MODIFIED", "ETAG"],
|
||||
|o| {
|
||||
vec![
|
||||
o.key.clone(),
|
||||
o.size.map_or("-".into(), |s| s.to_string()),
|
||||
o.last_modified.clone().unwrap_or_default(),
|
||||
o.etag.clone().unwrap_or_default(),
|
||||
]
|
||||
},
|
||||
fmt,
|
||||
)
|
||||
}
|
||||
ObjectAction::Get {
|
||||
bucket,
|
||||
key,
|
||||
output_file,
|
||||
} => {
|
||||
let data = s3.get_object(&bucket, &key).await?;
|
||||
match output_file {
|
||||
Some(path) => {
|
||||
std::fs::write(&path, &data)?;
|
||||
output::ok(&format!("Written {} bytes to {path}", data.len()));
|
||||
}
|
||||
None => {
|
||||
std::io::stdout().write_all(&data)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ObjectAction::Put {
|
||||
bucket,
|
||||
key,
|
||||
content_type,
|
||||
file,
|
||||
} => {
|
||||
let data = bytes::Bytes::from(std::fs::read(&file)?);
|
||||
s3.put_object(&bucket, &key, &content_type, data).await?;
|
||||
output::ok(&format!("Uploaded {file} to {bucket}/{key}"));
|
||||
Ok(())
|
||||
}
|
||||
ObjectAction::Delete { bucket, key } => {
|
||||
s3.delete_object(&bucket, &key).await?;
|
||||
output::ok(&format!("Deleted {bucket}/{key}"));
|
||||
Ok(())
|
||||
}
|
||||
ObjectAction::Exists { bucket, key } => {
|
||||
let exists = s3.head_object(&bucket, &key).await?;
|
||||
output::render(&serde_json::json!({ "exists": exists }), fmt)
|
||||
}
|
||||
ObjectAction::Copy {
|
||||
bucket,
|
||||
key,
|
||||
source,
|
||||
} => {
|
||||
s3.copy_object(&bucket, &key, &source).await?;
|
||||
output::ok(&format!("Copied {source} to {bucket}/{key}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user