use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand, value_parser};
use ironflow_sdk::IronflowClient;
use ironflow_sdk::client::ApiResponse;
use ironflow_sdk::types::{
KeyVersionsResponse, RotateSecretsRequest, SetSecretRequest, UpdateSecretRequest,
};
use serde::Serialize;
use std::slice;
use uuid::Uuid;
use crate::confirm::{confirm, resolve_secret_value};
use crate::output;
#[derive(Debug, Args)]
pub struct SecretArgs {
#[command(subcommand)]
pub command: SecretCommands,
}
#[derive(Debug, Subcommand)]
pub enum SecretCommands {
List,
Set {
key: String,
value: Option<String>,
},
Update {
key: String,
value: Option<String>,
},
Delete {
key: String,
#[arg(long)]
yes: bool,
},
Rotate(RotateArgs),
KeyStatus,
}
#[derive(Debug, Args)]
pub struct RotateArgs {
#[arg(long, value_parser = value_parser!(i32).range(1..))]
pub to_version: Option<i32>,
#[arg(long, default_value_t = 100, value_parser = value_parser!(i32).range(1..=1000))]
pub batch_size: i32,
}
#[derive(Debug, Serialize)]
struct RotationSummary {
to_version: i32,
rotated: u64,
failed: u64,
batches: u64,
}
pub async fn execute(client: &IronflowClient, args: &SecretArgs, json_mode: bool) -> Result<()> {
match &args.command {
SecretCommands::List => {
let response = client.list_secrets().await?;
output::print_output(json_mode, &response, || {
output::secrets_table(&response.data)
})?;
}
SecretCommands::Set { key, value } => {
let value = resolve_secret_value(value.as_deref(), "secret value")?;
let request: SetSecretRequest = SetSecretRequest::builder()
.key(key.clone())
.value(value)
.try_into()
.context("failed to build SetSecretRequest")?;
let response = client.create_secret(&request).await?;
output::print_output(json_mode, &response, || {
output::secrets_table(slice::from_ref(&response.data))
})?;
}
SecretCommands::Update { key, value } => {
let value = resolve_secret_value(value.as_deref(), "secret value")?;
let request: UpdateSecretRequest = UpdateSecretRequest::builder()
.value(value)
.try_into()
.context("failed to build UpdateSecretRequest")?;
let response = client.update_secret(key, &request).await?;
output::print_output(json_mode, &response, || {
output::secrets_table(slice::from_ref(&response.data))
})?;
}
SecretCommands::Delete { key, yes } => {
confirm(&format!("Delete secret '{key}'?"), *yes)?;
client.delete_secret(key).await?;
output::report_deletion(json_mode, "secret", key)?;
}
SecretCommands::Rotate(rotate_args) => rotate(client, rotate_args, json_mode).await?,
SecretCommands::KeyStatus => key_status(client, json_mode).await?,
}
Ok(())
}
async fn rotate(client: &IronflowClient, args: &RotateArgs, json_mode: bool) -> Result<()> {
let mut cursor: Option<Uuid> = None;
let mut to_version = args.to_version;
let mut rotated = 0u64;
let mut failed = 0u64;
let mut batches = 0u64;
loop {
let request = RotateSecretsRequest {
to_version,
batch_size: Some(args.batch_size),
after_id: cursor,
};
let response = client.rotate_secrets(&request).await?;
let batch = response.data;
batches += 1;
rotated += batch.rotated as u64;
failed += batch.failed as u64;
to_version = Some(batch.to_version);
if !json_mode {
let done = rotated + failed;
println!(
"rotating to version {}: {done} done, {} remaining",
batch.to_version, batch.remaining
);
}
let Some(last_id) = batch.last_id else { break };
if batch.remaining == 0 {
break;
}
cursor = Some(last_id);
}
let summary = RotationSummary {
to_version: to_version.unwrap_or_default(),
rotated,
failed,
batches,
};
if json_mode {
output::print_json(&summary)?;
} else {
println!(
"done: {rotated} secret(s) rotated to version {} in {batches} batch(es)",
summary.to_version
);
}
if failed > 0 {
bail!(
"{failed} secret(s) could not be decrypted and were left on their previous key version; check the server logs for the affected keys"
);
}
Ok(())
}
async fn key_status(client: &IronflowClient, json_mode: bool) -> Result<()> {
let response = client.secret_key_versions().await?;
output::print_output(json_mode, &response, || {
output::key_versions_table(&response.data)
})?;
warn_on_missing(&response);
Ok(())
}
fn warn_on_missing(response: &ApiResponse<KeyVersionsResponse>) {
if response.data.missing.is_empty() {
return;
}
let missing = response
.data
.missing
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"warning: key version(s) {missing} are used by stored secrets but not configured; those secrets cannot be read and the server will refuse to restart"
);
}