pub mod doctor;
pub mod profiles;
pub mod scan;
pub mod tenant;
pub mod tenant_import;
pub mod tenant_write;
pub mod topics;
use crate::cli::{Cli, ProfilesCommand, RuntimeCommand, TenantCommand, TopCommand, TopicsCommand};
use crate::debug::{DebugLog, DebugPolicy};
use crate::error::{
self, CliError, InterruptContext, Operation, Phase, Plane, SdkContext, SecretSet,
};
use crate::output::{self, Format, Output};
use crate::pagination::{self, GetParams, ListParams};
use crate::tenant::{self as registry, Environment, TenantContext};
use std::time::Duration;
pub struct Invocation {
pub output: Output,
pub session: scm::Session,
pub env: Environment,
pub tenant_flag: Option<String>,
pub secrets: SecretSet,
pub debug: Option<DebugLog>,
pub terminals: crate::input::Terminals,
pub committed: bool,
}
impl Invocation {
pub fn new(output: Output, tenant_flag: Option<String>) -> Self {
Self {
output,
session: scm::Session::new(),
env: Environment::capture(),
tenant_flag,
secrets: SecretSet::default(),
debug: None,
terminals: crate::input::Terminals::detect(),
committed: false,
}
}
pub fn open_debug_log(&mut self) -> Result<(), CliError> {
let dir =
crate::debug::directory(self.env.xdg_state_home.as_deref(), self.env.home.as_deref())?;
let log = DebugLog::open(
&dir,
self.secrets.clone(),
crate::render::style::stderr_color(),
)?;
self.output.status(&format!(
"Debug: API log → {}",
output::terminal_safe(&log.path().display().to_string())
));
self.debug = Some(log);
Ok(())
}
pub fn load_tenant(&mut self) -> Result<TenantContext, CliError> {
let context = registry::load_selected(&self.env, self.tenant_flag.as_deref())?;
self.secrets.register_config(&context.config);
Ok(context)
}
pub async fn connect_management(
&mut self,
format: Option<Format>,
options: Option<&scm::RequestOptions>,
) -> Result<(scm::airs::management::ManagementClient, Format), CliError> {
let context = self.load_tenant()?;
let format = output::resolve_tenant_default(format, context.config.default_output());
crate::tenant::config::require_management(&context.config, Some(context.name()))?;
let clients = crate::clients::ManagementClients::build(
&context.config,
&self.session,
&self.secrets,
self.debug.as_ref(),
)?;
let budget = if options.is_some() {
crate::clients::WALK_BUDGET
} else {
crate::clients::TOKEN_BUDGET
};
clients.authenticate(options, budget).await?;
Ok((clients.management, format))
}
pub fn management_error(&self, error: &scm::Error, budget: Duration) -> CliError {
error::from_sdk(
error,
&SdkContext {
plane: Plane::Management,
phase: Phase::Api,
operation: Operation::Read,
timeout: budget,
secrets: &self.secrets,
},
)
}
}
pub fn walk_options() -> Result<scm::RequestOptions, CliError> {
scm::RequestOptions::with_timeout(crate::clients::WALK_BUDGET).map_err(|_| {
CliError::operational("AISEC_SDK_ERROR:The request options could not be configured")
})
}
pub enum Plan {
GroupHelp(&'static [&'static str]),
TenantList(Format),
TenantPath(Option<String>),
TenantRead(Option<String>, Format),
TenantGet {
name: String,
key: &'static str,
format: Format,
},
Doctor(Format),
Scan(scan::ScanInput, Format),
ProfilesList(ListParams, Option<Format>),
ProfilesGet(GetParams, Option<Format>),
TopicsList(ListParams, Option<Format>),
TopicsGet(GetParams, Option<Format>),
TenantWrite(tenant_write::WritePlan),
TenantImport(tenant_import::ImportPlan),
}
impl Plan {
pub fn interrupt_context(&self) -> InterruptContext {
match self {
Self::Scan(..) => InterruptContext::ScanOutcomeUnknown,
Self::TenantWrite(_) | Self::TenantImport(_) => {
InterruptContext::NoConfigurationChanged
}
Self::GroupHelp(_)
| Self::TenantList(_)
| Self::TenantPath(_)
| Self::TenantRead(..)
| Self::TenantGet { .. }
| Self::Doctor(_)
| Self::ProfilesList(..)
| Self::ProfilesGet(..)
| Self::TopicsList(..)
| Self::TopicsGet(..) => InterruptContext::NoDataWritten,
}
}
pub fn debug_policy(&self) -> DebugPolicy {
match self {
Self::Doctor(_)
| Self::Scan(..)
| Self::ProfilesList(..)
| Self::ProfilesGet(..)
| Self::TopicsList(..)
| Self::TopicsGet(..) => DebugPolicy::Log,
Self::GroupHelp(_)
| Self::TenantList(_)
| Self::TenantPath(_)
| Self::TenantRead(..)
| Self::TenantGet { .. }
| Self::TenantWrite(_)
| Self::TenantImport(_) => DebugPolicy::None,
}
}
}
pub fn plan(cli: &Cli) -> Result<Plan, CliError> {
let explicit = cli.global.output.as_deref();
if let Some(TopCommand::Tenant(crate::cli::TenantGroup {
command: Some(TenantCommand::Import(args)),
})) = &cli.command
{
let format = output::resolve(explicit)?;
if cli.global.tenant.is_some() {
return Err(CliError::usage(
"--tenant cannot be used with 'tenant import'; name the tenant as an argument",
));
}
return Ok(Plan::TenantImport(tenant_import::plan(args, format)?));
}
if let Some(TopCommand::Tenant(crate::cli::TenantGroup {
command: Some(command),
})) = &cli.command
&& let Some(subcommand) = tenant_write::write_command_name(command)
{
output::validate_explicit(explicit)?;
if cli.global.tenant.is_some() {
return Err(CliError::usage(format!(
"--tenant cannot be used with 'tenant {subcommand}'; name the tenant as an argument"
)));
}
return Ok(Plan::TenantWrite(tenant_write::plan(command)?));
}
registry::validate_tenant_flag(cli.global.tenant.as_deref())?;
let Some(command) = &cli.command else {
return Ok(Plan::GroupHelp(&[]));
};
Ok(match command {
TopCommand::Tenant(group) => match &group.command {
None => Plan::GroupHelp(&["tenant"]),
Some(
TenantCommand::Create(_)
| TenantCommand::Set(_)
| TenantCommand::Unset { .. }
| TenantCommand::Switch { .. }
| TenantCommand::Delete { .. }
| TenantCommand::Import(_),
) => unreachable!("write commands are planned above"),
Some(TenantCommand::List) => Plan::TenantList(output::resolve(explicit)?),
Some(TenantCommand::Path { name }) => {
output::validate_explicit(explicit)?;
Plan::TenantPath(name.clone())
}
Some(TenantCommand::Read { name }) => {
Plan::TenantRead(name.clone(), output::resolve(explicit)?)
}
Some(TenantCommand::Get { name, key }) => {
let key = crate::tenant::config::schema_key(key).ok_or_else(|| {
CliError::usage(format!(
"Unknown configuration key. Supported keys: {}",
crate::tenant::config::KEYS.join(", ")
))
})?;
Plan::TenantGet {
name: name.clone(),
key,
format: output::resolve(explicit)?,
}
}
},
TopCommand::Doctor => Plan::Doctor(output::resolve(explicit)?),
TopCommand::Runtime(group) => match &group.command {
None => Plan::GroupHelp(&["runtime"]),
Some(RuntimeCommand::Scan(args)) => {
let format = output::resolve(explicit)?;
Plan::Scan(scan::validate_input(args)?, format)
}
Some(RuntimeCommand::Profiles(profiles)) => match &profiles.command {
None => Plan::GroupHelp(&["runtime", "profiles"]),
Some(ProfilesCommand::List(args)) => {
let format = output::validate_explicit(explicit)?;
Plan::ProfilesList(
pagination::parse_list(&args.paging, args.all_versions)?,
format,
)
}
Some(ProfilesCommand::Get(args)) => {
let format = output::validate_explicit(explicit)?;
Plan::ProfilesGet(pagination::parse_get(&args.lookup)?, format)
}
},
Some(RuntimeCommand::Topics(topics)) => match &topics.command {
None => Plan::GroupHelp(&["runtime", "topics"]),
Some(TopicsCommand::List(args)) => {
let format = output::validate_explicit(explicit)?;
Plan::TopicsList(
pagination::parse_list(&args.paging, args.all_versions)?,
format,
)
}
Some(TopicsCommand::Get(args)) => {
let format = output::validate_explicit(explicit)?;
Plan::TopicsGet(pagination::parse_get(&args.lookup)?, format)
}
},
},
})
}
pub async fn dispatch(invocation: &mut Invocation, plan: Plan) -> Result<(), CliError> {
match plan {
Plan::GroupHelp(_) => Ok(()),
Plan::TenantList(format) => tenant::list(invocation, format),
Plan::TenantPath(name) => tenant::path(invocation, name.as_deref()),
Plan::TenantRead(name, format) => tenant::read(invocation, name.as_deref(), format),
Plan::TenantGet { name, key, format } => tenant::get(invocation, &name, key, format),
Plan::Doctor(format) => doctor::run(invocation, format).await,
Plan::Scan(input, format) => scan::run(invocation, &input, format).await,
Plan::ProfilesList(params, format) => profiles::list(invocation, ¶ms, format).await,
Plan::ProfilesGet(params, format) => profiles::get(invocation, ¶ms, format).await,
Plan::TopicsList(params, format) => topics::list(invocation, ¶ms, format).await,
Plan::TopicsGet(params, format) => topics::get(invocation, ¶ms, format).await,
Plan::TenantWrite(write) => tenant_write::run(invocation, write),
Plan::TenantImport(import) => tenant_import::run(invocation, import),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::FromArgMatches;
fn plan_of(args: &[&str]) -> Result<Plan, CliError> {
let matches = crate::cli::command().try_get_matches_from(args).unwrap();
plan(&Cli::from_arg_matches(&matches).unwrap())
}
fn usage(args: &[&str]) -> String {
match plan_of(args) {
Err(CliError::Usage(message)) => message,
Err(other) => panic!("expected usage error, got {other:?}"),
Ok(_) => panic!("expected usage error"),
}
}
#[test]
fn usage_errors_need_no_tenant() {
assert_eq!(
usage(&[
"airs-cli-rs",
"runtime",
"profiles",
"list",
"--output",
"xml"
]),
"Invalid output format 'xml'. Expected: pretty, table, json"
);
assert_eq!(
usage(&["airs-cli-rs", "doctor", "--output", "yaml"]),
"Invalid output format 'yaml'. Expected: pretty, table, json"
);
assert_eq!(
usage(&["airs-cli-rs", "tenant", "path", "--output", "csv"]),
"Invalid output format 'csv'. Expected: pretty, table, json"
);
assert_eq!(
usage(&["airs-cli-rs", "runtime", "topics", "list", "--limit", "0"]),
"--limit must be a positive integer"
);
assert_eq!(
usage(&[
"airs-cli-rs",
"runtime",
"profiles",
"get",
"x",
"--revision",
"abc"
]),
"--revision must be a non-negative integer"
);
assert!(
usage(&["airs-cli-rs", "tenant", "get", "dev", "nope"]).starts_with(
"Unknown configuration key. Supported keys: airsApiKey, airsApiToken,"
)
);
assert!(
usage(&["airs-cli-rs", "--tenant", "-x", "doctor"]).starts_with("Tenant name must be")
);
assert_eq!(
usage(&["airs-cli-rs", "runtime", "scan", "--profile", "p", ""]),
"AISEC_USER_REQUEST_PAYLOAD_ERROR:At least one of prompt, response, codePrompt, codeResponse, or toolEvent must be provided"
);
}
#[test]
fn unknown_key_wins_over_output_and_path_ignores_a_valid_output() {
assert!(
usage(&[
"airs-cli-rs",
"tenant",
"get",
"dev",
"nope",
"--output",
"xml"
])
.starts_with("Unknown configuration key")
);
assert!(matches!(
plan_of(&["airs-cli-rs", "tenant", "path", "--output", "table"]),
Ok(Plan::TenantPath(None))
));
}
#[test]
fn write_commands_validate_output_then_refuse_tenant() {
assert_eq!(
usage(&[
"airs-cli-rs",
"--tenant",
"-x",
"tenant",
"switch",
"dev",
"--output",
"xml"
]),
"Invalid output format 'xml'. Expected: pretty, table, json"
);
for (sub, args) in [
("create", &["create", "dev"][..]),
("set", &["set", "dev", "defaultOutput", "json"]),
("unset", &["unset", "dev", "defaultOutput"]),
("switch", &["switch", "dev"]),
("delete", &["rm", "dev"]),
] {
let mut full = vec!["airs-cli-rs", "--tenant", "-x", "tenant"];
full.extend_from_slice(args);
assert_eq!(
usage(&full),
format!(
"--tenant cannot be used with 'tenant {sub}'; name the tenant as an argument"
)
);
}
assert_eq!(
usage(&["airs-cli-rs", "--tenant", "dev", "tenant", "create", "_bad"]),
"--tenant cannot be used with 'tenant create'; name the tenant as an argument",
"before the name check"
);
assert_eq!(
usage(&[
"airs-cli-rs",
"tenant",
"create",
"_bad",
"--output",
"table"
]),
"Tenant name must be 1–64 letters, digits, hyphens or underscores and start with a letter/digit"
);
assert!(matches!(
plan_of(&["airs-cli-rs", "tenant", "switch", "dev", "--output", "json"]),
Ok(Plan::TenantWrite(tenant_write::WritePlan::Switch { .. }))
));
assert_eq!(
plan_of(&["airs-cli-rs", "tenant", "switch", "dev"])
.ok()
.unwrap()
.interrupt_context(),
InterruptContext::NoConfigurationChanged
);
}
#[test]
fn groups_without_subcommands_render_help() {
for (args, path) in [
(&["airs-cli-rs"][..], &[][..]),
(&["airs-cli-rs", "--quiet"], &[]),
(&["airs-cli-rs", "runtime"], &["runtime"]),
(
&["airs-cli-rs", "runtime", "topics"],
&["runtime", "topics"],
),
(&["airs-cli-rs", "tenant"], &["tenant"]),
] {
match plan_of(args) {
Ok(Plan::GroupHelp(found)) => assert_eq!(found, path),
_ => panic!("expected group help for {args:?}"),
}
}
}
#[test]
fn debug_policy_logs_only_commands_that_send_requests() {
let policy = |args: &[&str]| plan_of(args).ok().unwrap().debug_policy();
for args in [
&["airs-cli-rs", "doctor"][..],
&["airs-cli-rs", "runtime", "scan", "--profile", "p", "hi"],
&["airs-cli-rs", "runtime", "profiles", "list"],
&["airs-cli-rs", "runtime", "profiles", "get", "x"],
&["airs-cli-rs", "runtime", "topics", "ls"],
&["airs-cli-rs", "runtime", "topics", "get", "x"],
] {
assert_eq!(policy(args), DebugPolicy::Log, "{args:?}");
}
for args in [
&["airs-cli-rs", "tenant", "list"][..],
&["airs-cli-rs", "tenant", "switch", "dev"],
&["airs-cli-rs", "tenant", "delete", "dev"],
&["airs-cli-rs", "tenant", "create", "dev"],
&["airs-cli-rs", "tenant", "path"],
&["airs-cli-rs", "tenant", "read"],
&["airs-cli-rs", "tenant", "get", "dev", "mgmtTsgId"],
&["airs-cli-rs", "runtime"],
] {
assert_eq!(policy(args), DebugPolicy::None, "{args:?}");
}
}
#[test]
fn valid_plans() {
assert!(matches!(
plan_of(&[
"airs-cli-rs",
"runtime",
"profiles",
"ls",
"--output",
"json",
"--all"
]),
Ok(Plan::ProfilesList(
ListParams { all: true, .. },
Some(Format::Json)
))
));
assert!(matches!(
plan_of(&["airs-cli-rs", "tenant", "get", "dev", "mgmtTsgId"]),
Ok(Plan::TenantGet {
key: "mgmtTsgId",
format: Format::Pretty,
..
})
));
assert!(matches!(
plan_of(&["airs-cli-rs", "--output", "json", "doctor"]),
Ok(Plan::Doctor(Format::Json))
));
assert!(matches!(
plan_of(&["airs-cli-rs", "tenant", "list"]),
Ok(Plan::TenantList(Format::Pretty))
));
assert!(matches!(
plan_of(&["airs-cli-rs", "runtime", "topics", "get", "x"]),
Ok(Plan::TopicsGet(_, None))
));
assert!(matches!(
plan_of(&[
"airs-cli-rs",
"runtime",
"scan",
"--profile",
"p",
"hi",
"--output",
"table"
]),
Ok(Plan::Scan(_, Format::Table))
));
}
}