use std::io::Write;
use std::path::PathBuf;
use std::process;
use clap::{Args, Subcommand, ValueEnum};
use crate::config;
use crate::exit_code;
use crate::output::OutputCtx;
const DEFAULT_TOKEN_ENV: &str = "RSIGMA_API_TOKEN";
#[derive(Subcommand, Debug)]
pub(crate) enum IncidentsCommands {
Export(IncidentsExportArgs),
}
pub(crate) fn dispatch_incidents(cmd: IncidentsCommands, ctx: OutputCtx) {
match cmd {
IncidentsCommands::Export(args) => cmd_incidents_export(args, ctx),
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub(crate) enum BundleFormat {
Json,
Markdown,
}
impl BundleFormat {
fn as_query(self) -> &'static str {
match self {
BundleFormat::Json => "json",
BundleFormat::Markdown => "markdown",
}
}
}
#[derive(Args, Debug)]
pub(crate) struct IncidentsExportArgs {
#[arg(value_name = "INCIDENT_ID")]
pub id: String,
#[arg(long)]
pub addr: Option<String>,
#[arg(short, long)]
pub config: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = BundleFormat::Json)]
pub bundle_format: BundleFormat,
#[arg(short, long, value_name = "PATH")]
pub output: Option<PathBuf>,
#[arg(long, value_name = "VAR", default_value = DEFAULT_TOKEN_ENV)]
pub auth_token_env: String,
}
pub(crate) fn cmd_incidents_export(args: IncidentsExportArgs, ctx: OutputCtx) {
ctx.warn_unsupported("engine incidents export", "bundle");
let addr = config::resolve_daemon_addr(args.addr.clone(), args.config.as_deref());
let url = format!(
"{}?format={}",
config::api_url(&addr, &format!("/api/v1/incidents/{}/bundle", args.id)),
args.bundle_format.as_query()
);
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let mut request = agent.get(&url);
if let Some(token) = token_from_env(&args.auth_token_env) {
request = request.header("authorization", format!("Bearer {token}"));
}
let response = match request.call() {
Ok(response) => response,
Err(e) => {
eprintln!("incident export failed: could not reach {url}: {e}");
eprintln!("(is the daemon running?)");
process::exit(exit_code::CONFIG_ERROR);
}
};
let status = response.status().as_u16();
let body = match response.into_body().read_to_string() {
Ok(body) => body,
Err(e) => {
eprintln!("incident export failed: could not read response from {url}: {e}");
process::exit(exit_code::CONFIG_ERROR);
}
};
if !(200..300).contains(&status) {
eprintln!("incident export failed: {url} returned HTTP {status}");
if !body.trim().is_empty() {
eprintln!("{}", describe_error(&body));
}
process::exit(exit_code::CONFIG_ERROR);
}
match &args.output {
None => print!("{body}"),
Some(path) => {
if let Err(e) = write_atomically(path, &body) {
eprintln!(
"incident export failed: could not write {}: {e}",
path.display()
);
process::exit(exit_code::CONFIG_ERROR);
}
}
}
}
fn token_from_env(var: &str) -> Option<String> {
std::env::var(var).ok().filter(|t| !t.trim().is_empty())
}
fn describe_error(body: &str) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return body.trim().to_string();
};
let Some(error) = value.get("error").and_then(|v| v.as_str()) else {
return body.trim().to_string();
};
match value.get("hint").and_then(|v| v.as_str()) {
Some(hint) => format!("{error}\n({hint})"),
None => error.to_string(),
}
}
fn write_atomically(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
let Some(file_name) = path.file_name() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"output path has no file name",
));
};
let mut temporary = path.to_path_buf();
temporary.set_file_name(format!(
".{}.rsigma-{}.tmp",
file_name.to_string_lossy(),
process::id()
));
let write = (|| {
let mut file = std::fs::File::create(&temporary)?;
file.write_all(contents.as_bytes())?;
file.sync_all()
})();
if let Err(e) = write {
let _ = std::fs::remove_file(&temporary);
return Err(e);
}
if let Err(e) = std::fs::rename(&temporary, path) {
let _ = std::fs::remove_file(&temporary);
return Err(e);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_error_body_is_rendered_with_its_hint() {
let body = r#"{"error":"incident grouping disabled","hint":"configure a group stage"}"#;
assert_eq!(
describe_error(body),
"incident grouping disabled\n(configure a group stage)"
);
}
#[test]
fn a_body_that_is_not_the_usual_shape_is_shown_verbatim() {
assert_eq!(describe_error("upstream said no\n"), "upstream said no");
assert_eq!(
describe_error(r#"{"detail":"nope"}"#),
r#"{"detail":"nope"}"#
);
}
#[test]
fn a_blank_token_variable_sends_no_header() {
unsafe { std::env::set_var("RSIGMA_TEST_BLANK_TOKEN", " ") };
assert!(token_from_env("RSIGMA_TEST_BLANK_TOKEN").is_none());
assert!(token_from_env("RSIGMA_TEST_UNSET_TOKEN").is_none());
unsafe { std::env::remove_var("RSIGMA_TEST_BLANK_TOKEN") };
}
#[test]
fn a_failed_write_leaves_the_previous_export_intact() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("bundle.json");
write_atomically(&path, "first").unwrap();
write_atomically(&path, "second").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
let leftovers: Vec<_> = std::fs::read_dir(directory.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(leftovers.len(), 1, "{leftovers:?}");
}
}