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;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use k8s_openapi::api::core::v1::Pod;
|
use k8s_openapi::api::core::v1::Pod;
|
||||||
|
|||||||
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;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
|||||||
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 find;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
pub use people::PeopleClient;
|
pub use people::PeopleClient;
|
||||||
pub use docs::DocsClient;
|
pub use docs::DocsClient;
|
||||||
pub use meet::MeetClient;
|
pub use meet::MeetClient;
|
||||||
|
|||||||
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;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
|||||||
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;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||||
use crate::error::{Result, SunbeamError};
|
use crate::error::{Result, SunbeamError};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|||||||
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.
|
//! Monitoring service clients: Prometheus, Loki, and Grafana.
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
pub mod grafana;
|
pub mod grafana;
|
||||||
pub mod loki;
|
pub mod loki;
|
||||||
pub mod prometheus;
|
pub mod prometheus;
|
||||||
|
|||||||
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
|
//! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the
|
||||||
//! Python version with direct HTTP API calls via port-forward to openbao:8200.
|
//! Python version with direct HTTP API calls via port-forward to openbao:8200.
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
use crate::error::{Result, ResultExt};
|
use crate::error::{Result, ResultExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -17,13 +20,13 @@ pub struct BaoClient {
|
|||||||
|
|
||||||
// ── API response types ──────────────────────────────────────────────────────
|
// ── API response types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct InitResponse {
|
pub struct InitResponse {
|
||||||
pub unseal_keys_b64: Vec<String>,
|
pub unseal_keys_b64: Vec<String>,
|
||||||
pub root_token: String,
|
pub root_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct SealStatusResponse {
|
pub struct SealStatusResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
@@ -37,7 +40,7 @@ pub struct SealStatusResponse {
|
|||||||
pub n: u32,
|
pub n: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct UnsealResponse {
|
pub struct UnsealResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sealed: bool,
|
pub sealed: bool,
|
||||||
|
|||||||
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.
|
//! OpenSearch client.
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||||
|
|||||||
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;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||||
use crate::error::{Result, ResultExt, SunbeamError};
|
use crate::error::{Result, ResultExt, SunbeamError};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
|||||||
Reference in New Issue
Block a user