use crate::context::Context;
use elasticctl_core::Resolved;
pub(crate) const MUTATING: [&str; 29] = [
"rules enable",
"rules disable",
"rules delete",
"rules import",
"rules prebuilt install",
"exceptions delete",
"exceptions import",
"data-views import",
"data-views delete",
"data-views default set",
"data-views default unset",
"dashboards import",
"dashboards delete",
"dashboards bundle import",
"fleet agent-policies import",
"fleet agent-policies delete",
"state push",
"config init",
"alerts ack",
"alerts open",
"alerts close",
"alerts tag",
"alerts assign",
"cases create",
"cases close",
"cases open",
"cases delete",
"cases attach",
"cases comment",
];
pub struct Preview {
pub action: String,
pub details: Vec<String>,
}
pub fn banner(resolved: &Resolved) -> String {
format!("({})", resolved.banner())
}
pub fn preview_text(preview: &Preview, resolved: &Resolved, applying: bool) -> String {
let tag = banner(resolved);
let mut out = if applying {
format!("Applying: {} {tag}\n", preview.action)
} else {
format!("[DRY RUN] {} {tag}\n", preview.action)
};
for d in &preview.details {
out.push_str(" ");
out.push_str(d);
out.push('\n');
}
if !applying {
out.push_str("Pass --yes to apply.\n");
}
out
}
pub fn check(ctx: &Context, path: &'static str, preview: &Preview) -> bool {
assert!(
MUTATING.contains(&path),
"guard::check called from \"{path}\", which is not in MUTATING — \
a mutating command must be declared there or it ships mutates:false \
in the command tree"
);
let applying = ctx.global.yes;
eprint!("{}", preview_text(preview, &ctx.resolved, applying));
applying
}
#[cfg(test)]
mod tests {
use super::*;
use elasticctl_core::{Profile, Resolved, Source};
fn resolved(name: &str, host: &str, space: &str) -> Resolved {
Resolved {
name: name.into(),
source: Source::Profile,
profile: Profile {
kibana_url: format!("https://{host}"),
es_url: None,
api_key: Some("essu_secret".into()),
username: None,
password: None,
space: space.into(),
verify: true,
timeout_secs: 30,
},
}
}
#[test]
fn banner_names_profile_host_and_space() {
let b = banner(&resolved("prod", "kibana.corp.internal", "soc"));
assert!(b.contains("prod"), "{b}");
assert!(b.contains("kibana.corp.internal"), "{b}");
assert!(b.contains("soc"), "{b}");
}
#[test]
fn banner_never_leaks_the_credential() {
let b = banner(&resolved("prod", "kibana.corp.internal", "default"));
assert!(
!b.contains("essu_secret"),
"the banner must not print the key: {b}"
);
}
#[test]
fn preview_text_lists_the_action_and_every_detail() {
let p = Preview {
action: "Disable 2 rules".into(),
details: vec![
"a Alpha enabled -> disabled".into(),
"b Beta enabled -> disabled".into(),
],
};
let text = preview_text(&p, &resolved("uat", "uat.example.com", "default"), false);
assert!(text.starts_with("[DRY RUN]"), "{text}");
assert!(text.contains("Disable 2 rules"));
assert!(text.contains("Alpha") && text.contains("Beta"));
assert!(text.contains("Pass --yes to apply."));
}
#[test]
fn apply_text_drops_the_dry_run_marker_and_the_hint() {
let p = Preview {
action: "Disable 2 rules".into(),
details: vec![],
};
let text = preview_text(&p, &resolved("uat", "uat.example.com", "default"), true);
assert!(text.starts_with("Applying:"), "{text}");
assert!(
!text.contains("--yes"),
"the hint is pointless once applying: {text}"
);
}
#[test]
fn both_modes_carry_the_target_banner() {
let p = Preview {
action: "Delete 1 rule".into(),
details: vec![],
};
let r = resolved("prod", "prod.example.com", "default");
for applying in [false, true] {
let text = preview_text(&p, &r, applying);
assert!(
text.contains("prod"),
"target must be named in both modes: {text}"
);
assert!(text.contains("prod.example.com"), "{text}");
}
}
#[test]
#[should_panic(expected = "rules touch")]
fn check_rejects_an_undeclared_mutating_path() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(
&config,
"current = \"test\"\n\n\
[profiles.test]\n\
kibana_url = \"https://kb.example.com\"\n\
space = \"default\"\n\
verify = true\n\
timeout_secs = 30\n",
)
.unwrap();
let global = crate::cli::GlobalArgs {
config: Some(config),
..Default::default()
};
let ctx = crate::context::Context::build(&global).unwrap();
let preview = Preview {
action: "Touch 1 rule".into(),
details: vec![],
};
let _ = check(&ctx, "rules touch", &preview);
}
}