src/cli.rs
Rama main · réplica de lectura del repositorio original.
//! Client subcommands (`raahi apply`, `raahi dump`) that talk to a running
//! Raahi's admin API. Config files are parsed here, not on the server, so
//! `${VAR}` references are filled from this machine's environment and secrets
//! can stay out of the file.
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, anyhow, bail};
use clap::{Args, Subcommand};
use raahi_core::{ConfigFormat, parse_config};
use serde_json::Value;
#[derive(Subcommand, Debug)]
pub enum Command {
/// Apply a config file (YAML, HUML or JSON) to a running Raahi.
Apply {
/// Config file, or `-` for stdin.
#[arg(short, long)]
file: PathBuf,
/// File format: yaml, huml or json (default: from the file extension).
#[arg(long)]
format: Option<String>,
/// Print what would change without changing anything.
#[arg(long)]
dry_run: bool,
#[command(flatten)]
remote: Remote,
},
/// Print the running config as a config file.
Dump {
/// Output format: yaml, huml or json.
#[arg(long, default_value = "yaml")]
format: String,
#[command(flatten)]
remote: Remote,
},
}
#[derive(Args, Debug)]
pub struct Remote {
/// Admin API base URL.
#[arg(long, env = "RAAHI_URL", default_value = "http://127.0.0.1:9080")]
url: String,
/// Admin token, sent as a bearer token.
#[arg(long, env = "RAAHI_TOKEN", hide_env_values = true)]
token: Option<String>,
}
impl Remote {
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}/api/v1{path}", self.url.trim_end_matches('/'));
let req = reqwest::Client::new().request(method, url);
match &self.token {
Some(token) => req.bearer_auth(token),
None => req,
}
}
}
pub fn run(command: Command) -> anyhow::Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async {
match command {
Command::Apply {
file,
format,
dry_run,
remote,
} => apply(&file, format.as_deref(), dry_run, &remote).await,
Command::Dump { format, remote } => dump(&format, &remote).await,
}
})
}
async fn apply(
file: &Path,
format: Option<&str>,
dry_run: bool,
remote: &Remote,
) -> anyhow::Result<()> {
let stdin = file == Path::new("-");
let format = match format {
Some(name) => {
ConfigFormat::from_name(name).ok_or_else(|| anyhow!("unknown format '{name}'"))?
}
None if stdin => ConfigFormat::Yaml,
None => file
.extension()
.and_then(|e| e.to_str())
.and_then(ConfigFormat::from_name)
.ok_or_else(|| anyhow!("can't tell the format of {}; pass --format", file.display()))?,
};
let text = if stdin {
let mut text = String::new();
std::io::stdin().read_to_string(&mut text)?;
text
} else {
std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?
};
let doc = parse_config(&text, format).map_err(|e| anyhow!("{}: {e}", file.display()))?;
let mut body = serde_json::to_value(&doc)?;
expand_env(&mut body, &|name| std::env::var(name).ok())?;
let path = if dry_run {
"/config/apply?dry_run=true"
} else {
"/config/apply"
};
let resp = remote
.request(reqwest::Method::POST, path)
.json(&body)
.send()
.await
.with_context(|| format!("connect to {}", remote.url))?;
let report: Value = ok(resp).await?.json().await?;
print_report(&report);
Ok(())
}
async fn dump(format: &str, remote: &Remote) -> anyhow::Result<()> {
let resp = remote
.request(reqwest::Method::GET, "/config/current")
.query(&[("format", format)])
.send()
.await
.with_context(|| format!("connect to {}", remote.url))?;
print!("{}", ok(resp).await?.text().await?);
Ok(())
}
/// Turn an error response into its `{"error": ...}` message.
async fn ok(resp: reqwest::Response) -> anyhow::Result<reqwest::Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
let message = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| v["error"].as_str().map(str::to_string))
.unwrap_or(body);
bail!("{status}: {message}")
}
fn print_report(report: &Value) {
let changes = report["changes"].as_array().cloned().unwrap_or_default();
for c in &changes {
let sign = match c["action"].as_str() {
Some("create") => '+',
Some("delete") => '-',
_ => '~',
};
let kind = c["kind"].as_str().unwrap_or("").replace('_', " ");
println!("{sign} {kind} {}", c["name"].as_str().unwrap_or(""));
}
let n = changes.len();
let plural = if n == 1 { "" } else { "s" };
match (n, report["dry_run"].as_bool().unwrap_or(false)) {
(0, _) => println!("No changes."),
(_, true) => println!("Dry run: {n} change{plural} planned, nothing applied."),
(_, false) => println!("Applied {n} change{plural}."),
}
if let Some(note) = report["note"].as_str() {
println!("Note: {note}");
}
}
/// Replace `${NAME}` in every string value with the environment variable's
/// value; `$${` writes a literal `${`. Unset variables are an error, and
/// substituted values are not expanded again.
fn expand_env(value: &mut Value, lookup: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<()> {
match value {
Value::String(s) => *s = expand_str(s, lookup)?,
Value::Array(items) => {
for item in items {
expand_env(item, lookup)?;
}
}
Value::Object(map) => {
for item in map.values_mut() {
expand_env(item, lookup)?;
}
}
_ => {}
}
Ok(())
}
fn expand_str(s: &str, lookup: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<String> {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('$') {
out.push_str(&rest[..i]);
rest = &rest[i..];
if let Some(r) = rest.strip_prefix("$${") {
out.push_str("${");
rest = r;
} else if let Some(r) = rest.strip_prefix("${") {
let end = r
.find('}')
.ok_or_else(|| anyhow!("unterminated ${{ in config"))?;
let name = &r[..end];
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
bail!("invalid variable reference ${{{name}}}");
}
let value =
lookup(name).ok_or_else(|| anyhow!("environment variable {name} is not set"))?;
out.push_str(&value);
rest = &r[end + 1..];
} else {
out.push('$');
rest = &rest[1..];
}
}
out.push_str(rest);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn lookup(name: &str) -> Option<String> {
match name {
"KEY" => Some("s3cret".into()),
"TRICKY" => Some("${KEY}".into()),
_ => None,
}
}
#[test]
fn expands_references_in_nested_strings() {
let mut v = json!({"a": ["x-${KEY}-y", {"b": "${KEY}"}], "n": 3, "c": "$5 and $${KEY}"});
expand_env(&mut v, &lookup).unwrap();
assert_eq!(
v,
json!({"a": ["x-s3cret-y", {"b": "s3cret"}], "n": 3, "c": "$5 and ${KEY}"})
);
}
#[test]
fn substituted_values_are_not_reexpanded() {
assert_eq!(expand_str("${TRICKY}", &lookup).unwrap(), "${KEY}");
}
#[test]
fn unset_and_malformed_references_fail() {
let err = expand_str("${MISSING}", &lookup).unwrap_err().to_string();
assert!(err.contains("MISSING"), "{err}");
assert!(expand_str("${KEY", &lookup).is_err());
assert!(expand_str("${A-B}", &lookup).is_err());
}
}