use boatramp_core::secret_store::SecretMeta;
use clap::Subcommand;
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error("reading secret value: {0}")]
Read(#[source] std::io::Error),
#[error("server returned HTTP {status}: {body}")]
Server { status: u16, body: String },
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct SecretsArgs {
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[command(subcommand)]
command: SecretsCommand,
}
#[derive(Debug, clap::Args)]
struct ValueSource {
#[arg(long, group = "value_source")]
stdin: bool,
#[arg(long, group = "value_source", value_name = "PATH")]
file: Option<std::path::PathBuf>,
#[arg(long, group = "value_source", value_name = "VALUE")]
value: Option<String>,
}
#[derive(Debug, Subcommand)]
enum SecretsCommand {
Set {
name: String,
#[command(flatten)]
source: ValueSource,
},
Rotate {
name: String,
#[command(flatten)]
source: ValueSource,
},
Ls,
Rm {
name: String,
},
}
pub async fn run(args: SecretsArgs, config: &ProjectConfig) -> Result<()> {
let server = client::resolve_server(args.server, config)?;
let http = client::http_client(client::token(config).as_deref());
let seg = client::project_seg(&client::resolve_project(config), "secrets");
match args.command {
SecretsCommand::Set { name, source } | SecretsCommand::Rotate { name, source } => {
let value = read_value(source)?;
let resp = http
.post(format!("{server}/api/{seg}"))
.json(&SetRequest {
name: &name,
value: &value,
})
.send()
.await?;
let meta: SecretMeta = parse_json(resp).await?;
println!("set {} (revision {})", meta.name, meta.revision);
}
SecretsCommand::Ls => {
let resp = http.get(format!("{server}/api/{seg}")).send().await?;
let secrets: Vec<SecretMeta> = parse_json(resp).await?;
if secrets.is_empty() {
println!("no secrets");
return Ok(());
}
println!("{:<32} {:>8} UPDATED", "NAME", "REVISION");
for s in secrets {
println!("{:<32} {:>8} {}", s.name, s.revision, s.updated_at);
}
}
SecretsCommand::Rm { name } => {
let resp = http
.delete(format!("{server}/api/{seg}/{name}"))
.send()
.await?;
check_no_content(resp).await?;
println!("removed {name}");
}
}
Ok(())
}
#[derive(serde::Serialize)]
struct SetRequest<'a> {
name: &'a str,
value: &'a str,
}
fn read_value(source: ValueSource) -> Result<String> {
use std::io::Read as _;
if source.stdin {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(Error::Read)?;
Ok(buf.trim_end_matches('\n').to_string())
} else if let Some(path) = source.file {
let bytes = std::fs::read(&path).map_err(Error::Read)?;
String::from_utf8(bytes)
.map_err(|e| Error::Read(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
.map(|s| s.trim_end_matches('\n').to_string())
} else if let Some(value) = source.value {
Ok(value)
} else {
Err(Error::Read(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"a value is required: pass one of --stdin, --file <path>, or --value <VALUE>",
)))
}
}
async fn parse_json<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(Error::Server {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).trim().to_string(),
});
}
serde_json::from_slice(&bytes).map_err(|e| Error::Server {
status: status.as_u16(),
body: format!("could not parse response: {e}"),
})
}
async fn check_no_content(resp: reqwest::Response) -> Result<()> {
let status = resp.status();
if status.is_success() {
return Ok(());
}
let bytes = resp.bytes().await?;
Err(Error::Server {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).trim().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[arg(long, global = true, env = "BOATRAMP_PROJECT")]
project: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Secrets(SecretsArgs),
}
fn parse(argv: &[&str]) -> std::result::Result<Cli, clap::Error> {
Cli::try_parse_from(std::iter::once("boatramp").chain(argv.iter().copied()))
}
#[test]
fn set_accepts_exactly_one_value_source() {
assert!(parse(&["secrets", "set", "api-key", "--value", "s3cr3t"]).is_ok());
assert!(parse(&["secrets", "set", "api-key", "--stdin"]).is_ok());
assert!(parse(&["secrets", "set", "api-key", "--file", "/tmp/k"]).is_ok());
assert!(parse(&["secrets", "set", "api-key", "--stdin", "--value", "x"]).is_err());
assert!(parse(&["secrets", "set", "api-key", "--file", "/tmp/k", "--value", "x"]).is_err());
}
#[test]
fn rotate_mirrors_set_and_ls_rm_parse() {
assert!(parse(&["secrets", "rotate", "api-key", "--stdin"]).is_ok());
assert!(parse(&["secrets", "ls"]).is_ok());
assert!(parse(&["secrets", "rm", "api-key"]).is_ok());
}
#[test]
fn the_global_project_flag_reaches_the_secrets_subcommand() {
let cli = parse(&["secrets", "--project", "acme", "ls"]).expect("parses");
assert_eq!(cli.project.as_deref(), Some("acme"));
let cli = parse(&["secrets", "ls"]).expect("parses");
assert_eq!(cli.project, None);
}
}