use color_eyre::eyre::Result;
use crate::lint::inputs::{
build_lint_context, fetch_env_lint_inputs, fetch_stale_platform_issues, run_rules_for_env,
EnvLintInputs,
};
use crate::{audit, aws, config, lint, project};
fn print_baseline_diff_json(new: &[&lint::Issue], cleared: &[&lint::BaselineIssue]) {
let mut out = String::from("{\"new\":[");
for (i, issue) in new.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"rule_id\":{},\"env\":{},\"title\":{}}}",
crate::util::json_string(&issue.rule_id),
crate::util::json_string(issue.env_name.as_deref().unwrap_or("")),
crate::util::json_string(&issue.title),
));
}
out.push_str("],\"cleared\":[");
for (i, b) in cleared.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"rule_id\":{},\"env\":{},\"title\":{}}}",
crate::util::json_string(&b.rule_id),
crate::util::json_string(b.env_name.as_deref().unwrap_or("")),
crate::util::json_string(&b.title),
));
}
out.push_str("]}");
println!("{out}");
}
#[derive(Debug, PartialEq, Eq)]
struct LintArgs {
env_name: Option<String>,
regions: Vec<Option<String>>,
json: bool,
quiet: bool,
severity_filter: Option<lint::Severity>,
rule_filter: Vec<String>,
fix: bool,
dry_run: bool,
yes: bool,
watch: bool,
interval_secs: u64,
baseline_write: Option<String>,
baseline_against: Option<String>,
probe_live: bool,
webhook: Option<String>,
}
fn fix_may_dispatch(yes: bool, pending: usize) -> bool {
yes && pending > 0
}
fn should_run_account_pass(env_scoped: bool, disabled: &[String]) -> bool {
!env_scoped && !disabled.iter().any(|d| d == "EBL015")
}
fn webhook_summary(issues: &[lint::Issue]) -> String {
if issues.is_empty() {
return "lint: ✓ clean (previous issues cleared)".to_string();
}
let mut parts: Vec<String> = issues
.iter()
.take(5)
.map(|i| {
format!(
"{} {} {}: {}",
i.severity.as_str(),
i.rule_id,
i.env_name.as_deref().unwrap_or("-"),
i.title
)
})
.collect();
if issues.len() > 5 {
parts.push(format!("…and {} more", issues.len() - 5));
}
format!("lint: {} issue(s) — {}", issues.len(), parts.join("; "))
}
fn parse_lint_args(args: &[String]) -> Result<LintArgs, String> {
let mut env_name: Option<String> = None;
let mut regions_csv: Option<String> = None;
let mut json = false;
let mut quiet = false;
let mut severity_filter: Option<lint::Severity> = None;
let mut rule_filter: Vec<String> = Vec::new();
let mut fix = false;
let mut dry_run = false;
let mut yes = false;
let mut watch = false;
let mut interval_str: Option<String> = None;
let mut baseline_write: Option<String> = None;
let mut baseline_against: Option<String> = None;
let mut probe_live = false;
let mut webhook: Option<String> = None;
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--env" => {
env_name = Some(crate::cli::take_value(
&mut iter,
"ebman lint",
"--env",
"an env name",
)?)
}
"--regions" => {
regions_csv = Some(crate::cli::take_value(
&mut iter,
"ebman lint",
"--regions",
"a region list",
)?)
}
"--json" => json = true,
"--quiet" => quiet = true,
"--fix" => fix = true,
"--dry-run" => dry_run = true,
"--yes" => yes = true,
"--watch" => watch = true,
"--interval" => {
interval_str = Some(crate::cli::take_value(
&mut iter,
"ebman lint",
"--interval",
"a duration",
)?)
}
"--probe-live" => probe_live = true,
"--webhook" => {
let Some(u) = iter.next() else {
return Err("ebman lint: --webhook expects a URL".into());
};
if u.starts_with("--") {
return Err(format!(
"ebman lint: --webhook expects a URL, got flag '{u}'"
));
}
webhook = Some(u.clone());
}
"--baseline" => {
let Some(p) = iter.next() else {
return Err("ebman lint: --baseline expects a file path".into());
};
if p.starts_with("--") {
return Err(format!(
"ebman lint: --baseline expects a file path, got flag '{p}'"
));
}
baseline_write = Some(p.clone());
}
"--against-baseline" => {
let Some(p) = iter.next() else {
return Err("ebman lint: --against-baseline expects a file path".into());
};
if p.starts_with("--") {
return Err(format!(
"ebman lint: --against-baseline expects a file path, got flag '{p}'"
));
}
baseline_against = Some(p.clone());
}
"--severity" => {
let Some(v) = iter.next() else {
return Err(
"ebman lint: --severity expects a value (info / warn / error)".into(),
);
};
let Some(sev) = lint::Severity::parse(v) else {
return Err(format!(
"ebman lint: unknown severity '{v}' (info / warn / error)"
));
};
severity_filter = Some(sev);
}
"--rules" => {
let v = crate::cli::take_value(
&mut iter,
"ebman lint",
"--rules",
"a comma-separated rule id list",
)?;
rule_filter = crate::util::split_csv(&v);
if rule_filter.is_empty() {
return Err(format!("ebman lint: --rules got '{v}' — no rule ids in it"));
}
}
other => {
return Err(format!("ebman lint: unknown flag '{other}'"));
}
}
}
if watch && fix {
return Err("ebman lint: --watch and --fix are mutually exclusive (use one)".into());
}
if webhook.is_some() && !watch {
return Err(
"ebman lint: --webhook only makes sense with --watch (one-shot runs print their findings)"
.into(),
);
}
if baseline_write.is_some() && baseline_against.is_some() {
return Err(
"ebman lint: --baseline (write) and --against-baseline (compare) are mutually exclusive"
.into(),
);
}
if (baseline_write.is_some() || baseline_against.is_some()) && (fix || watch) {
return Err(
"ebman lint: --baseline / --against-baseline are incompatible with --fix / --watch"
.into(),
);
}
if fix && !yes && !dry_run {
return Err(
"ebman lint --fix: requires --yes to dispatch writes (or --dry-run to preview)".into(),
);
}
if fix && yes && dry_run {
return Err("ebman lint --fix: --yes and --dry-run are mutually exclusive".into());
}
let interval_secs: u64 = match interval_str.as_deref() {
None => 60,
Some(s) => {
if let Ok(n) = s.parse::<u64>() {
if n == 0 {
return Err("ebman lint: --interval must be > 0".into());
}
n
} else if let Some(ms) = aws::parse_window_ms(s) {
((ms / 1000) as u64).max(1)
} else {
return Err(
"ebman lint: --interval expects seconds (`30`) or a duration (`5m`/`1h`)"
.into(),
);
}
}
};
let regions: Vec<Option<String>> = match regions_csv {
Some(csv) => {
let parsed: Vec<String> = crate::util::split_csv(&csv);
if parsed.is_empty() {
return Err("ebman lint: --regions list is empty".into());
}
parsed.into_iter().map(Some).collect()
}
None => vec![None],
};
Ok(LintArgs {
env_name,
regions,
json,
quiet,
severity_filter,
rule_filter,
fix,
dry_run,
yes,
watch,
interval_secs,
baseline_write,
baseline_against,
probe_live,
webhook,
})
}
#[derive(Debug, Default)]
pub(crate) struct CycleReport {
pub issues: Vec<lint::Issue>,
pub degrade_reasons: Vec<String>,
pub fix_dispatch_failed: bool,
pub usage_error: Option<String>,
}
impl CycleReport {
pub(crate) fn degraded(&self) -> bool {
!self.degrade_reasons.is_empty()
}
pub(crate) fn degrade(&mut self, reason: String) {
eprintln!("warning: {reason}");
self.degrade_reasons.push(reason);
}
}
pub(crate) fn should_post_webhook(
previous: Option<&std::collections::BTreeSet<String>>,
current: &std::collections::BTreeSet<String>,
) -> bool {
let first_cycle_clean = previous.is_none() && current.is_empty();
!first_cycle_clean && previous != Some(current)
}
pub(crate) fn filter_issues(
issues: &mut Vec<lint::Issue>,
severity_filter: Option<lint::Severity>,
rule_filter: &[String],
) {
if let Some(min) = severity_filter {
issues.retain(|i| i.severity >= min);
}
if !rule_filter.is_empty() {
issues.retain(|i| rule_filter.contains(&i.rule_id));
}
}
pub(crate) fn lint_exit_code(
fix: bool,
fix_dispatch_failed: bool,
degraded: bool,
clean: bool,
) -> i32 {
if fix {
if fix_dispatch_failed || degraded {
1
} else {
0
}
} else if !clean {
3
} else if degraded {
1
} else {
0
}
}
pub(crate) struct BaselineDrift<'a> {
pub new_issues: Vec<&'a lint::Issue>,
pub cleared: Vec<&'a lint::BaselineIssue>,
pub baseline_count: usize,
}
pub(crate) fn baseline_drift<'a>(
all_issues: &'a [lint::Issue],
baseline_issues: &'a [lint::BaselineIssue],
) -> BaselineDrift<'a> {
let baseline_set: std::collections::HashSet<&str> = baseline_issues
.iter()
.map(|b| b.identity.as_str())
.collect();
let current_identities: Vec<String> = all_issues.iter().map(lint::issue_identity).collect();
let current_set: std::collections::HashSet<&str> =
current_identities.iter().map(String::as_str).collect();
let new_issues: Vec<&lint::Issue> = all_issues
.iter()
.zip(current_identities.iter())
.filter(|(_, id)| !baseline_set.contains(id.as_str()))
.map(|(i, _)| i)
.collect();
let cleared: Vec<&lint::BaselineIssue> = baseline_issues
.iter()
.filter(|b| !current_set.contains(b.identity.as_str()))
.collect();
BaselineDrift {
new_issues,
cleared,
baseline_count: baseline_set.len(),
}
}
pub(crate) fn watch_sleep(
interval_secs: u64,
cycle_elapsed: chrono::Duration,
) -> std::time::Duration {
std::time::Duration::from_secs(interval_secs)
.saturating_sub(cycle_elapsed.to_std().unwrap_or_default())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_cycle<F, Fut>(
regions: &[Option<String>],
env_name: &Option<String>,
disabled: &[String],
probe_live: bool,
fix: bool,
yes: bool,
quiet: bool,
json: bool,
severity_filter: Option<lint::Severity>,
rule_filter: &[String],
safety_cfg: &config::Config,
fix_disabled: &[String],
active_profile_for_safety: &Option<String>,
client_for: F,
now: chrono::DateTime<chrono::Utc>,
) -> CycleReport
where
F: Fn(Option<String>) -> Fut,
Fut: std::future::Future<Output = color_eyre::eyre::Result<aws::AwsClient>>,
{
let mut report = CycleReport::default();
let rules = lint::default_rules(disabled);
let multi_region = regions.len() > 1;
let mut env_found = false;
let mut every_region_answered = true;
for region_opt in regions {
let aws = match client_for(region_opt.clone()).await {
Ok(c) => c,
Err(e) => {
let region_label = region_opt.as_deref().unwrap_or("default");
report.degrade(format!(
"skipping region '{region_label}' — AwsClient::with: {e}"
));
every_region_answered = false;
continue;
}
};
let envs = match aws.list_environments().await {
Ok(envs) => envs,
Err(e) => {
let region_label = aws.context.region.as_str();
report.degrade(format!(
"skipping region '{region_label}' — list_environments: {e}"
));
every_region_answered = false;
continue;
}
};
let platforms =
lint::inputs::Platforms::from_listing(aws.list_solution_stacks().await, |e| {
e.to_string()
});
let targets: Vec<&aws::Environment> = match env_name.as_deref() {
Some(name) => match envs.iter().find(|e| e.name == name) {
Some(env) => {
env_found = true;
vec![env]
}
None => {
if multi_region && !quiet {
let region_label = aws.context.region.as_str();
eprintln!(
"warning: env '{name}' not in region '{region_label}' — skipping"
);
} else if !multi_region {
report.usage_error =
Some(format!("env '{name}' not found in current context"));
return report;
}
continue;
}
},
None => envs.iter().collect(),
};
let platforms = platforms.report_once(
targets
.iter()
.any(|e| lint::inputs::ebl008_could_fire(disabled, e)),
|why| {
let region_label = aws.context.region.as_str();
report.degrade(format!("EBL008 skipped — region '{region_label}': {why}"));
},
);
for env in targets {
let inputs = match fetch_env_lint_inputs(
&aws,
env,
&platforms,
probe_live,
disabled,
&safety_cfg.required_tags,
)
.await
{
Ok(inputs) => inputs,
Err(e) => {
report.degrade(format!(
"skipping {} — fetch_env_option_settings: {e}",
env.name
));
continue;
}
};
for w in &inputs.coverage_warnings {
report.degrade(w.clone());
}
let mut issues = run_rules_for_env(&rules, env, &inputs, &safety_cfg.required_tags);
filter_issues(&mut issues, severity_filter, rule_filter);
if let Some(region) = region_opt {
for issue in &mut issues {
issue.fields.insert("region".into(), region.clone());
}
}
if fix
&& !issues.is_empty()
&& apply_fixes_for_env(
&aws,
env,
&inputs,
&issues,
&rules,
yes,
quiet,
json,
fix_disabled,
safety_cfg,
active_profile_for_safety,
)
.await
{
report.fix_dispatch_failed = true;
}
report.issues.extend(issues);
}
if should_run_account_pass(env_name.is_some(), disabled) {
match fetch_stale_platform_issues(&aws, now).await {
Ok((mut issues, warnings)) => {
for w in warnings {
report.degrade(w);
}
filter_issues(&mut issues, severity_filter, rule_filter);
if let Some(region) = region_opt {
for issue in &mut issues {
issue.fields.insert("region".into(), region.clone());
}
}
report.issues.extend(issues);
}
Err(e) => {
report.degrade(format!("EBL015 skipped — ListPlatformVersions: {e}"));
}
}
}
}
if let Some(msg) = env_not_found_anywhere(
env_name.as_deref(),
regions.len(),
env_found,
every_region_answered,
) {
report.usage_error = Some(msg);
}
report
}
pub(crate) fn env_not_found_anywhere(
env_name: Option<&str>,
region_count: usize,
found: bool,
every_region_answered: bool,
) -> Option<String> {
let name = env_name?;
(region_count > 1 && !found && every_region_answered)
.then(|| format!("env '{name}' not found in any of the {region_count} regions checked"))
}
pub async fn run(args: &[String]) -> Result<()> {
let LintArgs {
env_name,
regions,
json,
quiet,
severity_filter,
rule_filter,
fix,
dry_run: _,
yes,
watch,
interval_secs,
baseline_write,
baseline_against,
probe_live,
webhook,
} = match parse_lint_args(args) {
Ok(parsed) => parsed,
Err(msg) => {
eprintln!("{msg}");
std::process::exit(2);
}
};
let mut disabled: Vec<String> = config::load_lint_disables();
disabled.extend(project::load_lint_disables_from_cwd());
let mut fix_disabled: Vec<String> = config::load_lint_fix_disables();
fix_disabled.extend(project::load_lint_fix_disables_from_cwd());
let safety_cfg = config::load();
let active_profile_for_safety = std::env::var("AWS_PROFILE").ok();
let multi_region = regions.len() > 1;
if fix && yes {
crate::cli::refuse_if_frozen(
"ebman lint --fix",
crate::verb::Verb::SetOption.audit_label(),
)
.await;
}
if webhook.is_some() {
audit::webhook_errors_to_stderr();
}
let mut last_cycle_clean;
let mut last_cycle_degraded;
let mut last_fix_failed = false;
let mut last_webhook_identities: Option<std::collections::BTreeSet<String>> = None;
let ctrl_c = tokio::signal::ctrl_c();
tokio::pin!(ctrl_c);
loop {
let cycle_started = chrono::Utc::now();
if watch && !quiet && !json {
println!("--- {} ---", cycle_started.to_rfc3339());
}
let report = run_cycle(
®ions,
&env_name,
&disabled,
probe_live,
fix,
yes,
quiet,
json,
severity_filter,
&rule_filter,
&safety_cfg,
&fix_disabled,
&active_profile_for_safety,
|region| async move { aws::AwsClient::with(None, region).await },
cycle_started,
)
.await;
if let Some(msg) = report.usage_error.as_deref() {
eprintln!("ebman lint: {msg}");
crate::cli::exit_after_drain(2).await;
}
if let Some(url) = webhook.as_deref() {
if report.degraded() {
if !quiet {
eprintln!("warning: cycle degraded (fetch failures) — webhook suppressed");
}
} else {
let identities: std::collections::BTreeSet<String> =
report.issues.iter().map(lint::issue_identity).collect();
if should_post_webhook(last_webhook_identities.as_ref(), &identities) {
let detail = webhook_summary(&report.issues);
audit::fire_webhook(
url,
None,
active_profile_for_safety.as_deref(),
if multi_region {
"multi"
} else {
regions[0].as_deref().unwrap_or("default")
},
&detail,
&cycle_started.to_rfc3339(),
);
}
last_webhook_identities = Some(identities);
}
}
let baseline_mode = baseline_write.is_some() || baseline_against.is_some();
if !quiet && !baseline_mode {
if json {
println!(
"{}",
lint::render_report_json(&report.issues, &report.degrade_reasons)
);
} else if report.issues.is_empty() {
println!("✓ No issues found");
} else {
for issue in &report.issues {
let sev = issue.severity.as_str();
let env_str = issue.env_name.as_deref().unwrap_or("-");
if multi_region {
let region = issue
.fields
.get("region")
.map(String::as_str)
.unwrap_or("-");
println!(
"{region}\t{sev}\t{}\t{env_str}\t{}",
issue.rule_id, issue.title
);
} else {
println!("{sev}\t{}\t{env_str}\t{}", issue.rule_id, issue.title);
}
if let Some(s) = &issue.suggestion {
println!("\t→ {s}");
}
}
}
use std::io::Write;
let _ = std::io::stdout().flush();
}
if let Some(path) = baseline_write.as_deref() {
if report.degraded() {
eprintln!(
"ebman lint --baseline: refusing to snapshot a degraded run \
(fetch failures above) — fix access and re-run"
);
std::process::exit(1);
}
let body = lint::render_issues_json(&report.issues);
if let Err(e) = std::fs::write(path, &body) {
eprintln!("ebman lint --baseline: write {path}: {e}");
std::process::exit(1);
}
if !quiet {
eprintln!(
"ebman lint --baseline: wrote {} issue(s) to {path}",
report.issues.len()
);
}
last_cycle_clean = true; } else if let Some(path) = baseline_against.as_deref() {
let baseline_text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!("ebman lint --against-baseline: read {path}: {e}");
std::process::exit(1);
}
};
let baseline_issues = match lint::parse_baseline(&baseline_text) {
Ok(v) => v,
Err(e) => {
eprintln!("ebman lint --against-baseline: {e}");
std::process::exit(1);
}
};
let BaselineDrift {
new_issues,
cleared,
baseline_count,
} = baseline_drift(&report.issues, &baseline_issues);
if !quiet {
if json {
print_baseline_diff_json(&new_issues, &cleared);
} else {
if new_issues.is_empty() && cleared.is_empty() {
println!("✓ No drift vs baseline ({} issues stable)", baseline_count);
}
for issue in &new_issues {
let sev = issue.severity.as_str();
let env_str = issue.env_name.as_deref().unwrap_or("-");
println!(
"+ NEW\t{sev}\t{}\t{env_str}\t{}",
issue.rule_id, issue.title
);
}
for b in &cleared {
let env_str = b.env_name.as_deref().unwrap_or("-");
println!("✓ CLEARED\t{}\t{env_str}\t{}", b.rule_id, b.title);
}
}
use std::io::Write;
let _ = std::io::stdout().flush();
}
last_cycle_clean = new_issues.is_empty();
} else {
last_cycle_clean = report.issues.is_empty();
}
last_cycle_degraded = report.degraded();
last_fix_failed |= report.fix_dispatch_failed;
if !watch {
break;
}
tokio::select! {
_ = &mut ctrl_c => {
if !quiet && !json {
eprintln!("(watch interrupted)");
}
break;
}
_ = tokio::time::sleep(
watch_sleep(interval_secs, chrono::Utc::now() - cycle_started),
) => {}
}
}
audit::drain_webhooks(std::time::Duration::from_secs(12)).await;
let code = lint_exit_code(fix, last_fix_failed, last_cycle_degraded, last_cycle_clean);
if code != 0 {
std::process::exit(code);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn bare_lint_has_sane_defaults() {
let p = parse_lint_args(&argv(&["lint"])).unwrap();
assert_eq!(p.regions, vec![None]);
assert_eq!(p.interval_secs, 60);
assert!(!p.json && !p.quiet && !p.fix && !p.watch);
assert!(p.severity_filter.is_none() && p.rule_filter.is_empty());
assert!(p.baseline_write.is_none() && p.baseline_against.is_none());
}
#[test]
fn collects_filters_and_flags() {
let p = parse_lint_args(&argv(&[
"lint",
"--env",
"prod",
"--json",
"--quiet",
"--severity",
"warn",
"--rules",
"EBL001, EBL004 ,EBL019",
]))
.unwrap();
assert_eq!(p.env_name.as_deref(), Some("prod"));
assert!(p.json && p.quiet);
assert_eq!(p.severity_filter, Some(lint::Severity::Warn));
assert_eq!(p.rule_filter, vec!["EBL001", "EBL004", "EBL019"]);
}
#[test]
fn unknown_flag_and_severity_are_usage_errors() {
assert!(parse_lint_args(&argv(&["lint", "--bogus"]))
.unwrap_err()
.contains("unknown flag"));
assert!(parse_lint_args(&argv(&["lint", "--severity", "loud"]))
.unwrap_err()
.contains("unknown severity"));
}
#[test]
fn baseline_flag_requires_a_path_not_another_flag() {
let err = parse_lint_args(&argv(&["lint", "--baseline", "--json"])).unwrap_err();
assert!(err.contains("--baseline expects a file path"), "got: {err}");
let err2 = parse_lint_args(&argv(&["lint", "--baseline"])).unwrap_err();
assert!(
err2.contains("--baseline expects a file path"),
"got: {err2}"
);
}
#[test]
fn interval_accepts_bare_seconds_and_durations_rejects_zero_and_garbage() {
assert_eq!(
parse_lint_args(&argv(&["lint", "--interval", "30"]))
.unwrap()
.interval_secs,
30
);
assert_eq!(
parse_lint_args(&argv(&["lint", "--interval", "5m"]))
.unwrap()
.interval_secs,
300
);
assert!(parse_lint_args(&argv(&["lint", "--interval", "0"]))
.unwrap_err()
.contains("must be > 0"));
assert!(parse_lint_args(&argv(&["lint", "--interval", "soon"]))
.unwrap_err()
.contains("expects seconds"));
assert_eq!(
parse_lint_args(&argv(&["lint", "--interval", "60s"]))
.unwrap()
.interval_secs,
60
);
}
#[test]
fn value_flags_reject_missing_or_flag_values() {
assert!(parse_lint_args(&argv(&["lint", "--fix", "--yes", "--env"]))
.unwrap_err()
.contains("--env expects"));
assert!(parse_lint_args(&argv(&["lint", "--env", "--json"]))
.unwrap_err()
.contains("got flag"));
assert!(parse_lint_args(&argv(&["lint", "--rules", "--json"]))
.unwrap_err()
.contains("got flag"));
assert!(parse_lint_args(&argv(&["lint", "--rules", " , "]))
.unwrap_err()
.contains("no rule ids"));
assert!(parse_lint_args(&argv(&["lint", "--regions"]))
.unwrap_err()
.contains("--regions expects"));
assert!(parse_lint_args(&argv(&["lint", "--interval", "--watch"]))
.unwrap_err()
.contains("got flag"));
}
#[test]
fn fix_requires_yes_or_dry_run() {
assert!(parse_lint_args(&argv(&["lint", "--fix"]))
.unwrap_err()
.contains("requires --yes"));
assert!(
parse_lint_args(&argv(&["lint", "--fix", "--yes"]))
.unwrap()
.fix
);
assert!(
parse_lint_args(&argv(&["lint", "--fix", "--dry-run"]))
.unwrap()
.dry_run
);
assert!(
parse_lint_args(&argv(&["lint", "--fix", "--yes", "--dry-run"]))
.unwrap_err()
.contains("mutually exclusive")
);
}
#[test]
fn mutually_exclusive_mode_combinations_are_rejected() {
assert!(
parse_lint_args(&argv(&["lint", "--watch", "--fix", "--yes"]))
.unwrap_err()
.contains("--watch and --fix")
);
assert!(parse_lint_args(&argv(&[
"lint",
"--baseline",
"b.json",
"--against-baseline",
"a.json"
]))
.unwrap_err()
.contains("mutually exclusive"));
assert!(
parse_lint_args(&argv(&["lint", "--baseline", "b.json", "--fix", "--yes"]))
.unwrap_err()
.contains("incompatible with --fix")
);
}
#[test]
fn empty_regions_csv_is_usage_error() {
let err = parse_lint_args(&argv(&["lint", "--regions", " , "])).unwrap_err();
assert!(err.contains("--regions list is empty"), "got: {err}");
}
#[test]
fn probe_live_flag_parses() {
let p = parse_lint_args(&argv(&["lint", "--probe-live"])).unwrap();
assert!(p.probe_live);
let p = parse_lint_args(&argv(&["lint"])).unwrap();
assert!(!p.probe_live);
}
#[test]
fn webhook_requires_watch_and_a_real_url() {
let p = parse_lint_args(&argv(&[
"lint",
"--watch",
"--webhook",
"https://hooks.example/x",
]))
.unwrap();
assert_eq!(p.webhook.as_deref(), Some("https://hooks.example/x"));
let err =
parse_lint_args(&argv(&["lint", "--webhook", "https://hooks.example/x"])).unwrap_err();
assert!(err.contains("--watch"), "got: {err}");
let err = parse_lint_args(&argv(&["lint", "--watch", "--webhook", "--json"])).unwrap_err();
assert!(err.contains("expects a URL"), "got: {err}");
let err = parse_lint_args(&argv(&["lint", "--watch", "--webhook"])).unwrap_err();
assert!(err.contains("expects a URL"), "got: {err}");
}
#[test]
fn run_rules_for_env_wires_inputs_through_the_context() {
let env = aws::Environment {
name: "prod".into(),
application: "shop".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Node.js 20".into(),
solution_stack: String::new(),
tier: "Web".into(),
cname: "prod.example.com".into(),
version_label: "b1".into(),
arn: None,
updated: Some(chrono::Utc::now()),
id: None,
region: None,
};
let rules = lint::default_rules(&[]);
let bare = EnvLintInputs::bare(vec![]);
let issues = run_rules_for_env(&rules, &env, &bare, &[]);
assert!(issues.iter().any(|i| i.rule_id == "EBL017"));
let probed = EnvLintInputs {
probe_failure: Some("HTTP 503".into()),
waf_missing: Some(true),
..EnvLintInputs::bare(vec![])
};
let issues = run_rules_for_env(&rules, &env, &probed, &[]);
assert!(issues.iter().any(|i| i.rule_id == "EBL016"));
assert!(issues.iter().any(|i| i.rule_id == "EBL018"));
}
#[test]
fn webhook_summary_caps_and_handles_all_clear() {
assert!(webhook_summary(&[]).contains("clean"));
let mk = |n: usize| lint::Issue {
rule_id: format!("EBL00{n}"),
severity: lint::Severity::Warn,
env_name: Some(format!("env-{n}")),
title: format!("issue {n}"),
detail: String::new(),
suggestion: None,
fields: Default::default(),
};
let issues: Vec<lint::Issue> = (1..=7).map(mk).collect();
let s = webhook_summary(&issues);
assert!(s.starts_with("lint: 7 issue(s)"), "got: {s}");
assert!(s.contains("EBL001 env-1: issue 1"), "got: {s}");
assert!(s.contains("…and 2 more"), "got: {s}");
assert!(!s.contains("issue 6"), "cap at 5, got: {s}");
}
}
#[cfg(test)]
mod probe_outcome_tests {
use crate::lint::inputs::ProbeOutcome;
#[test]
fn a_failed_probe_is_not_a_clean_result() {
let denied = ProbeOutcome::Unknown("SimulatePrincipalPolicy failed: AccessDenied".into());
assert_eq!(denied.verdict(), None, "the rule still skips");
let warn = denied
.coverage_warning("EBL020", "api-prod")
.expect("an unrunnable check must say so");
assert!(
warn.contains("EBL020") && warn.contains("api-prod"),
"{warn}"
);
assert!(
warn.contains("NOT a clean result"),
"the wording has to leave no room for reading it as a pass: {warn}"
);
}
#[test]
fn not_applicable_stays_silent() {
let na = ProbeOutcome::NotApplicable;
assert_eq!(na.verdict(), None);
assert_eq!(na.coverage_warning("EBL020", "api-prod"), None);
}
#[test]
fn a_checked_probe_reports_its_verdict_and_warns_about_nothing() {
assert_eq!(ProbeOutcome::Checked(true).verdict(), Some(true));
assert_eq!(ProbeOutcome::Checked(false).verdict(), Some(false));
assert_eq!(
ProbeOutcome::Checked(false).coverage_warning("EBL018", "api-prod"),
None
);
}
#[test]
fn the_three_outcomes_are_distinguishable() {
assert_ne!(ProbeOutcome::NotApplicable, ProbeOutcome::Checked(false));
assert_ne!(
ProbeOutcome::NotApplicable,
ProbeOutcome::Unknown("x".into())
);
assert_ne!(
ProbeOutcome::Checked(false),
ProbeOutcome::Unknown("x".into())
);
}
}
#[cfg(test)]
mod disabled_rule_probes {
use crate::lint::inputs::ProbeOutcome;
#[test]
fn a_disabled_rule_yields_no_coverage_warning() {
let skipped = ProbeOutcome::NotApplicable;
assert_eq!(skipped.coverage_warning("EBL018", "api-prod"), None);
assert_eq!(skipped.verdict(), None);
let failed = ProbeOutcome::Unknown("GetWebACLForResource: AccessDenied".into());
assert!(failed.coverage_warning("EBL018", "api-prod").is_some());
}
}
#[cfg(test)]
mod disabled_rule_wiring {
use crate::lint::inputs::ProbeOutcome;
fn env() -> crate::aws::Environment {
crate::aws::Environment {
name: "api-prod".into(),
application: "poly".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Java 17".into(),
solution_stack: "64bit Amazon Linux 2023 running Corretto 17".into(),
tier: "Web".into(),
cname: String::new(),
version_label: "build-1".into(),
arn: None,
updated: None,
id: None,
region: None,
}
}
#[tokio::test]
async fn a_disabled_rules_probe_does_not_run() {
let aws = crate::aws::AwsClient::stub();
let options = vec![
(
"aws:elasticbeanstalk:xray".to_string(),
"XRayEnabled".to_string(),
"true".to_string(),
),
(
"aws:autoscaling:launchconfiguration".to_string(),
"IamInstanceProfile".to_string(),
"eb-ec2-role".to_string(),
),
(
"aws:elasticbeanstalk:environment".to_string(),
"LoadBalancerType".to_string(),
"application".to_string(),
),
];
assert_eq!(
crate::lint::inputs::probe_xray_trace_denied(&aws, &options, &["EBL020".to_string()])
.await,
ProbeOutcome::NotApplicable
);
assert_eq!(
crate::lint::inputs::probe_waf_missing(&aws, &env(), &options, &["EBL018".to_string()])
.await,
ProbeOutcome::NotApplicable
);
let enabled = crate::lint::inputs::probe_xray_trace_denied(&aws, &options, &[]).await;
assert!(
matches!(enabled, ProbeOutcome::Unknown(_)),
"an enabled probe that cannot run must say so, got {enabled:?}"
);
}
}
#[cfg(test)]
mod lost_coverage {
use crate::lint::inputs::{ebl010_could_fire, ebl012_could_fire};
fn env(status: &str, health: &str) -> crate::aws::Environment {
crate::aws::Environment {
name: "api-prod".into(),
application: "poly".into(),
status: status.into(),
health: health.into(),
platform: "Java 17".into(),
solution_stack: "64bit Amazon Linux 2023 running Corretto 17".into(),
tier: "Web".into(),
cname: String::new(),
version_label: "build-1".into(),
arn: None,
updated: None,
id: None,
region: None,
}
}
fn system_type(v: &str) -> Vec<(String, String, String)> {
vec![(
"aws:elasticbeanstalk:healthreporting:system".into(),
"SystemType".into(),
v.into(),
)]
}
#[test]
fn ebl010_is_lost_only_when_enabled_with_required_tags() {
let tags = vec!["owner".to_string()];
assert!(ebl010_could_fire(&[], &tags));
assert!(!ebl010_could_fire(&["EBL010".into()], &tags), "disabled");
assert!(
!ebl010_could_fire(&[], &[]),
"no required tags: nothing to check"
);
}
#[test]
fn ebl012_is_lost_on_a_ready_green_enhanced_env() {
let enhanced = system_type("enhanced");
assert!(ebl012_could_fire(&[], &env("Ready", "Green"), &enhanced));
assert!(
ebl012_could_fire(&[], &env("Ready", "Ok"), &enhanced),
"Ok is Green"
);
assert!(ebl012_could_fire(&[], &env("Ready", "Green"), &[]));
}
#[test]
fn ebl012_is_not_lost_on_basic_health() {
for v in ["basic", "Basic", "BASIC"] {
assert!(
!ebl012_could_fire(&[], &env("Ready", "Green"), &system_type(v)),
"{v}"
);
}
}
#[test]
fn ebl012_is_not_lost_when_the_rule_could_not_apply() {
let enhanced = system_type("enhanced");
assert!(!ebl012_could_fire(
&[],
&env("Updating", "Green"),
&enhanced
));
assert!(!ebl012_could_fire(&[], &env("Ready", "Red"), &enhanced));
assert!(!ebl012_could_fire(
&["EBL012".into()],
&env("Ready", "Green"),
&enhanced
));
}
}
#[cfg(test)]
mod env_not_found {
use super::env_not_found_anywhere as f;
#[test]
fn a_name_found_nowhere_across_answering_regions_is_an_error() {
let msg = f(Some("typo-env"), 3, false, true).expect("usage error");
assert!(
msg.contains("typo-env") && msg.contains("3 regions"),
"{msg}"
);
}
#[test]
fn found_anywhere_is_not_an_error() {
assert!(f(Some("real"), 3, true, true).is_none());
}
#[test]
fn a_region_that_did_not_answer_withholds_the_verdict() {
assert!(f(Some("maybe"), 3, false, false).is_none());
}
#[test]
fn a_single_region_run_is_left_to_the_loop() {
assert!(f(Some("typo"), 1, false, true).is_none());
}
#[test]
fn no_env_asked_for_is_never_an_error() {
assert!(f(None, 3, false, true).is_none());
}
}
#[cfg(test)]
mod degrade_guard {
#[test]
fn every_degrade_goes_through_the_helper() {
let prod = crate::app::tests::scan::production_source("cli/lint.rs");
let prod = prod.as_str();
let lines: Vec<&str> = prod.lines().collect();
for (i, line) in lines.iter().enumerate() {
let stripped = crate::app::tests::scan::strip_line_comment(line);
if !stripped.contains("eprintln!(") {
continue;
}
let mut stmt = String::new();
for l in lines.iter().skip(i).take(6) {
stmt.push_str(crate::app::tests::scan::strip_line_comment(l));
if crate::app::tests::scan::strip_line_comment(l)
.trim_end()
.ends_with(';')
{
break;
}
}
if stmt.contains("warning:") && stmt.contains("skipped") {
panic!(
"line {}: a skipped fetch is printed directly rather than passed \
to `degrade`, so the cycle still reports clean and --baseline \
will snapshot it: {}",
i + 1,
stmt.trim()
);
}
}
let decl = prod
.split("pub(crate) struct CycleReport {")
.nth(1)
.and_then(|r| r.split('}').next())
.expect("CycleReport is declared here");
let decl: String = decl
.lines()
.map(crate::app::tests::scan::strip_line_comment)
.collect::<Vec<_>>()
.join("\n");
assert!(
!decl.contains("degraded"),
"the degraded state must be DERIVED from `degrade_reasons`, not stored \
beside them — a stored flag can disagree with the reasons, which is how \
a run exited non-zero with an empty log: {decl}"
);
let derived = prod
.split("fn degraded(&self) -> bool {")
.nth(1)
.expect("`CycleReport::degraded` must exist");
assert!(
derived[..derived.find("\n }").unwrap_or(derived.len())]
.contains("degrade_reasons.is_empty()"),
"`degraded()` must read the reasons, or it is a stored flag wearing a \
method's clothes"
);
let helper = prod
.split("fn degrade(&mut self, reason: String) {")
.nth(1)
.expect("`CycleReport::degrade` must exist");
let body = &helper[..helper.find("\n }").unwrap_or(helper.len())];
assert!(body.contains("eprintln!"), "degrade must print the reason");
assert!(
body.contains("push(reason)"),
"degrade must keep the reason for --json"
);
}
}
#[cfg(test)]
mod run_decision_tests {
use super::{baseline_drift, filter_issues, lint_exit_code, watch_sleep};
use crate::lint;
fn drift_issue(rule: &str, env: Option<&str>) -> lint::Issue {
lint::Issue {
rule_id: rule.into(),
severity: lint::Severity::Warn,
env_name: env.map(str::to_string),
title: format!("{rule} fired"),
detail: String::new(),
suggestion: None,
fields: Default::default(),
}
}
fn as_baseline(issue: &lint::Issue) -> lint::BaselineIssue {
lint::BaselineIssue {
identity: lint::issue_identity(issue),
rule_id: issue.rule_id.clone(),
env_name: issue.env_name.clone(),
title: issue.title.clone(),
}
}
#[test]
fn drift_splits_issues_into_new_and_cleared_and_ignores_the_stable_ones() {
let stable = drift_issue("EBL001", Some("api-prod"));
let appeared = drift_issue("EBL002", Some("api-prod"));
let gone = drift_issue("EBL003", Some("api-prod"));
let current = vec![stable.clone(), appeared.clone()];
let baseline = vec![as_baseline(&stable), as_baseline(&gone)];
let d = baseline_drift(¤t, &baseline);
assert_eq!(
d.new_issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>(),
vec!["EBL002"],
"only the issue absent from the baseline is new"
);
assert_eq!(
d.cleared.iter().map(|b| &b.rule_id).collect::<Vec<_>>(),
vec!["EBL003"],
"only the baseline issue that stopped reproducing is cleared"
);
}
#[test]
fn drift_is_by_identity_so_the_same_rule_on_another_env_is_a_new_issue() {
let prod = drift_issue("EBL001", Some("api-prod"));
let staging = drift_issue("EBL001", Some("api-staging"));
let baseline = [as_baseline(&prod)];
let d = baseline_drift(std::slice::from_ref(&staging), &baseline);
assert_eq!(d.new_issues.len(), 1, "same rule, different env, is new");
assert_eq!(d.new_issues[0].env_name.as_deref(), Some("api-staging"));
assert_eq!(d.cleared.len(), 1, "and prod's issue reads as cleared");
}
#[test]
fn drift_on_an_unchanged_fleet_is_empty_both_ways() {
let a = drift_issue("EBL001", Some("api-prod"));
let b = drift_issue("EBL002", None);
let baseline = vec![as_baseline(&a), as_baseline(&b)];
let current = [a.clone(), b.clone()];
let d = baseline_drift(¤t, &baseline);
assert!(d.new_issues.is_empty() && d.cleared.is_empty());
assert_eq!(d.baseline_count, 2, "both baseline issues counted stable");
}
#[test]
fn drift_against_an_empty_baseline_makes_every_issue_new() {
let issues = vec![
drift_issue("EBL001", Some("a")),
drift_issue("EBL002", Some("b")),
];
let d = baseline_drift(&issues, &[]);
assert_eq!(d.new_issues.len(), 2);
assert!(d.cleared.is_empty());
assert_eq!(d.baseline_count, 0);
}
#[test]
fn drift_on_a_now_clean_fleet_clears_the_whole_baseline() {
let was = drift_issue("EBL001", Some("api-prod"));
let baseline = [as_baseline(&was)];
let d = baseline_drift(&[], &baseline);
assert!(d.new_issues.is_empty(), "nothing fires, so nothing is new");
assert_eq!(d.cleared.len(), 1);
}
#[test]
fn baseline_count_deduplicates_repeated_identities() {
let a = drift_issue("EBL001", Some("api-prod"));
let baseline = vec![as_baseline(&a), as_baseline(&a)];
let d = baseline_drift(std::slice::from_ref(&a), &baseline);
assert_eq!(d.baseline_count, 1);
}
#[test]
fn watch_sleep_subtracts_the_cycle_so_the_interval_is_start_to_start() {
let s = watch_sleep(60, chrono::Duration::seconds(20));
assert_eq!(
s,
std::time::Duration::from_secs(40),
"a 20s cycle in a 60s interval sleeps 40s, not 60s"
);
}
#[test]
fn watch_sleep_floors_at_zero_when_a_cycle_overruns_its_interval() {
let s = watch_sleep(30, chrono::Duration::seconds(45));
assert_eq!(s, std::time::Duration::ZERO);
}
#[test]
fn watch_sleep_treats_a_backwards_clock_as_no_time_passed() {
let s = watch_sleep(60, chrono::Duration::seconds(-5));
assert_eq!(s, std::time::Duration::from_secs(60));
}
fn issue(rule: &str, sev: lint::Severity) -> lint::Issue {
lint::Issue {
rule_id: rule.into(),
severity: sev,
env_name: Some("api-prod".into()),
title: format!("{rule} fired"),
detail: String::new(),
suggestion: None,
fields: Default::default(),
}
}
fn ids(issues: &[lint::Issue]) -> Vec<&str> {
issues.iter().map(|i| i.rule_id.as_str()).collect()
}
#[test]
fn min_severity_keeps_that_level_and_above() {
let all = || {
vec![
issue("EBL001", lint::Severity::Info),
issue("EBL002", lint::Severity::Warn),
issue("EBL003", lint::Severity::Error),
]
};
let mut v = all();
filter_issues(&mut v, None, &[]);
assert_eq!(ids(&v), ["EBL001", "EBL002", "EBL003"]);
let mut v = all();
filter_issues(&mut v, Some(lint::Severity::Warn), &[]);
assert_eq!(ids(&v), ["EBL002", "EBL003"], "warn keeps warn and error");
let mut v = all();
filter_issues(&mut v, Some(lint::Severity::Error), &[]);
assert_eq!(ids(&v), ["EBL003"]);
let mut v = all();
filter_issues(&mut v, Some(lint::Severity::Info), &[]);
assert_eq!(ids(&v), ["EBL001", "EBL002", "EBL003"], "info keeps all");
}
#[test]
fn an_empty_rule_filter_is_no_filter_at_all() {
let all = || {
vec![
issue("EBL001", lint::Severity::Warn),
issue("EBL002", lint::Severity::Warn),
]
};
let mut v = all();
filter_issues(&mut v, None, &[]);
assert_eq!(ids(&v), ["EBL001", "EBL002"], "no --rule means no filter");
let mut v = all();
filter_issues(&mut v, None, &["EBL002".to_string()]);
assert_eq!(ids(&v), ["EBL002"]);
let mut v = all();
filter_issues(&mut v, None, &["EBL999".to_string()]);
assert!(v.is_empty(), "an unmatched rule filter reports nothing");
}
#[test]
fn the_two_filters_compose() {
let mut v = vec![
issue("EBL001", lint::Severity::Info),
issue("EBL002", lint::Severity::Error),
issue("EBL003", lint::Severity::Error),
];
filter_issues(
&mut v,
Some(lint::Severity::Warn),
&["EBL001".to_string(), "EBL002".to_string()],
);
assert_eq!(
ids(&v),
["EBL002"],
"an issue has to survive BOTH filters, not either"
);
}
#[test]
fn the_exit_code_matrix_holds() {
for (fix, failed, degraded, clean, want, why) in [
(false, false, false, true, 0, "clean run passes"),
(false, false, false, false, 3, "issues found is exit 3"),
(
false,
false,
true,
true,
1,
"clean but degraded must NOT pass green — a region skipped on \
expired credentials looks identical to a passing check",
),
(
false,
false,
true,
false,
3,
"issues found beats degraded: exit 3 is the actionable one",
),
(
true,
false,
false,
false,
0,
"--fix that dispatched cleanly passes",
),
(
true,
true,
false,
false,
1,
"--fix with a failed dispatch is exit 1",
),
(
true,
false,
true,
false,
1,
"--fix on a degraded run is exit 1",
),
(
true,
false,
false,
true,
0,
"--fix reports on the dispatch, not on cleanliness",
),
] {
assert_eq!(
lint_exit_code(fix, failed, degraded, clean),
want,
"fix={fix} failed={failed} degraded={degraded} clean={clean}: {why}"
);
}
}
}
#[cfg(test)]
mod webhook_gate_tests {
use super::{fix_may_dispatch, should_post_webhook, should_run_account_pass};
use std::collections::BTreeSet;
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn the_watch_webhook_posts_only_on_a_change() {
assert!(should_post_webhook(None, &set(&["EBL001:api-prod"])));
assert!(
!should_post_webhook(None, &set(&[])),
"a first cycle that is already clean has nothing to report"
);
let seen = set(&["EBL001:api-prod"]);
assert!(
!should_post_webhook(Some(&seen), &seen),
"an unchanged finding set must not re-post every interval"
);
assert!(
should_post_webhook(Some(&seen), &set(&["EBL001:api-prod", "EBL002:worker"])),
"a new finding is a change"
);
assert!(
should_post_webhook(Some(&seen), &set(&[])),
"going clean after findings IS worth an all-clear"
);
assert!(
should_post_webhook(Some(&set(&[])), &seen),
"and findings after a known-clean cycle"
);
assert!(should_post_webhook(Some(&seen), &set(&["EBL009:other"])));
}
#[test]
fn fix_dispatches_only_with_yes_and_something_to_do() {
assert!(
!fix_may_dispatch(false, 3),
"a preview must never dispatch, however much it planned"
);
assert!(
!fix_may_dispatch(true, 0),
"nothing planned means no call — an empty write is still a \
round trip against someone's account"
);
assert!(fix_may_dispatch(true, 1), "confirmed, with work to do");
}
#[test]
fn the_account_pass_respects_scope_and_disables() {
let none: Vec<String> = vec![];
let off = vec!["EBL015".to_string()];
assert!(
should_run_account_pass(false, &none),
"fleet-wide run with the rule enabled"
);
assert!(
!should_run_account_pass(true, &none),
"a run scoped to one env must not report account-wide findings"
);
assert!(
!should_run_account_pass(false, &off),
"a disabled rule must stay disabled here too"
);
assert!(!should_run_account_pass(true, &off), "and both together");
assert!(
should_run_account_pass(false, &["EBL001".to_string()]),
"disabling a sibling rule must not disable EBL015"
);
}
#[test]
fn the_extracted_gates_are_wired_into_run() {
let prod = crate::app::tests::scan::production_source("cli/lint.rs");
assert!(
prod.contains("fix_may_dispatch(yes, to_set.len())"),
"the option-write dispatch must go through the tested gate, \
or `--fix` can write without `--yes` again"
);
assert!(
prod.contains("should_run_account_pass(env_name.is_some(),"),
"the EBL015 account pass must go through the tested gate"
);
assert!(
prod.contains("pub async fn run"),
"the production slice is not finding `run`"
);
assert!(
!prod.contains("if !to_set.is_empty() && yes {"),
"the inline gate came back alongside the helper"
);
}
}
#[allow(clippy::too_many_arguments)]
async fn apply_fixes_for_env(
aws: &aws::AwsClient,
env: &aws::Environment,
inputs: &EnvLintInputs,
issues: &[lint::Issue],
rules: &[Box<dyn lint::Rule>],
yes: bool,
quiet: bool,
json: bool,
fix_disabled: &[String],
safety_cfg: &config::Config,
active_profile_for_safety: &Option<String>,
) -> bool {
let mut dispatch_failed = false;
let refusal = if yes {
crate::cli::write_refusal(
safety_cfg,
&env.name,
active_profile_for_safety,
None,
Some(aws.context.region.as_str()),
crate::verb::Verb::SetOption.audit_label(),
)
} else {
crate::cli::write_refusal_unaudited(safety_cfg, &env.name, active_profile_for_safety, None)
.map(|(_, message, _)| message)
};
if let Some(reason) = refusal {
if !quiet {
eprintln!("ebman lint --fix: {reason}");
}
return yes;
}
let region_label = aws.context.region.clone();
let ctx = build_lint_context(env, inputs, &safety_cfg.required_tags);
let mut to_set: Vec<(String, String, String)> = Vec::new();
let mut planned: Vec<(String, lint::FixAction)> = Vec::new();
let mut planned_set_indices: Vec<usize> = Vec::new();
for issue in issues {
if fix_disabled.contains(&issue.rule_id) {
if !quiet && !json {
println!("skip {} ({}): in lint.fix_disable", issue.rule_id, env.name);
}
continue;
}
let Some(rule) = rules.iter().find(|r| r.id() == issue.rule_id) else {
continue;
};
let Some(action) = rule.fix(&ctx) else {
if !quiet && !json {
println!(
"no-fix {} ({}): rule has no auto-remediation",
issue.rule_id, env.name
);
}
continue;
};
if let lint::FixAction::SetOption {
namespace,
name,
value,
..
} = &action
{
planned_set_indices.push(planned.len());
to_set.push((namespace.clone(), name.clone(), value.clone()));
}
planned.push((issue.rule_id.clone(), action));
}
if !quiet && !json {
for (rule_id, action) in &planned {
match action {
lint::FixAction::SetOption { description, .. } => {
println!("fix {rule_id} ({}): {description}", env.name);
}
lint::FixAction::Manual { instructions } => {
println!(
"fix {rule_id} ({}) MANUAL — operator action required:\n {instructions}",
env.name
);
}
}
}
}
if fix_may_dispatch(yes, to_set.len()) {
match aws
.update_env_option_settings(&env.name, &to_set, &[])
.await
{
Ok(()) => {
for &idx in &planned_set_indices {
let (rule_id, action) = &planned[idx];
if let lint::FixAction::SetOption {
namespace,
name,
value,
..
} = action
{
audit::append_lint_fix(
®ion_label,
&env.name,
rule_id,
namespace,
name,
value,
None,
);
}
}
if !quiet && !json {
println!(
"ok ({}): applied {} fix(es)",
env.name,
planned_set_indices.len()
);
}
}
Err(e) => {
eprintln!(
"ebman lint --fix: dispatch failed for {} in {region_label}: {e}",
env.name
);
let err_str = e.to_string();
for &idx in &planned_set_indices {
let (rule_id, action) = &planned[idx];
if let lint::FixAction::SetOption {
namespace,
name,
value,
..
} = action
{
audit::append_lint_fix(
®ion_label,
&env.name,
rule_id,
namespace,
name,
value,
Some(&err_str),
);
}
}
dispatch_failed = true;
}
}
}
dispatch_failed
}
#[cfg(test)]
mod cycle_wiring {
use super::*;
#[derive(Default, Clone, Copy)]
struct MockFaults {
update_rejected: bool,
platform_date_rejected: bool,
tags_rejected: bool,
health_rejected: bool,
stacks_rejected: bool,
listing_rejected: bool,
}
const FAULTED_BRANCH: &str = "Node.js 20 running on 64bit Amazon Linux 2023";
fn client_with_failing_update(envs: Vec<String>) -> aws::AwsClient {
mock_client_inner(
envs,
MockFaults {
update_rejected: true,
..MockFaults::default()
},
)
}
fn client_with_failing_platform_date(envs: Vec<String>) -> aws::AwsClient {
mock_client_inner(
envs,
MockFaults {
platform_date_rejected: true,
..MockFaults::default()
},
)
}
fn mock_client(envs: Vec<String>) -> aws::AwsClient {
mock_client_inner(envs, MockFaults::default())
}
fn mock_client_inner(envs: Vec<String>, faults: MockFaults) -> aws::AwsClient {
let failing_update = faults.update_rejected;
use aws_sdk_elasticbeanstalk::operation::describe_environments::DescribeEnvironmentsOutput;
use aws_sdk_elasticbeanstalk::types::EnvironmentDescription;
let listing_rejected = faults.listing_rejected;
let listing =
aws_smithy_mocks::mock!(aws_sdk_elasticbeanstalk::Client::describe_environments)
.match_requests(move |_| !listing_rejected)
.then_output(move || {
let mut b = DescribeEnvironmentsOutput::builder();
for e in &envs {
b = b.environments(
EnvironmentDescription::builder()
.environment_name(e)
.environment_arn(format!(
"arn:aws:elasticbeanstalk:us-west-1:123456789012:environment/poly/{e}"
))
.application_name("poly")
.solution_stack_name(
"64bit Amazon Linux 2023 v4.1.0 running Corretto 17",
)
.status("Ready".into())
.health("Green".into())
.build(),
);
}
b.build()
});
let stacks = if faults.stacks_rejected {
aws_smithy_mocks::mock!(
aws_sdk_elasticbeanstalk::Client::list_available_solution_stacks
)
.then_error(|| {
aws_sdk_elasticbeanstalk::operation::list_available_solution_stacks::ListAvailableSolutionStacksError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AccessDeniedException")
.message("not authorized")
.build(),
)
})
} else {
aws_smithy_mocks::mock!(
aws_sdk_elasticbeanstalk::Client::list_available_solution_stacks
)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::list_available_solution_stacks::ListAvailableSolutionStacksOutput::builder().build()
})
};
let cfgsettings = aws_smithy_mocks::mock!(
aws_sdk_elasticbeanstalk::Client::describe_configuration_settings
)
.then_output(move || {
use aws_sdk_elasticbeanstalk::types::{
ConfigurationOptionSetting, ConfigurationSettingsDescription,
};
let mut out = aws_sdk_elasticbeanstalk::operation::describe_configuration_settings::DescribeConfigurationSettingsOutput::builder();
if failing_update {
let opt = |ns: &str, name: &str, value: &str| {
ConfigurationOptionSetting::builder()
.namespace(ns)
.option_name(name)
.value(value)
.build()
};
out = out.configuration_settings(
ConfigurationSettingsDescription::builder()
.option_settings(opt(
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
"AllAtOnce",
))
.option_settings(opt("aws:autoscaling:asg", "MaxSize", "4"))
.build(),
);
}
out.build()
});
let denied = |what: &str| {
aws_smithy_types::error::ErrorMetadata::builder()
.code("AccessDeniedException")
.message(format!("User is not authorized to perform {what}"))
.build()
};
let tags = if faults.tags_rejected {
aws_smithy_mocks::mock!(aws_sdk_elasticbeanstalk::Client::list_tags_for_resource)
.then_error(move || {
aws_sdk_elasticbeanstalk::operation::list_tags_for_resource::ListTagsForResourceError::generic(
denied("elasticbeanstalk:ListTagsForResource"),
)
})
} else {
aws_smithy_mocks::mock!(aws_sdk_elasticbeanstalk::Client::list_tags_for_resource)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::list_tags_for_resource::ListTagsForResourceOutput::builder().build()
})
};
let health = if faults.health_rejected {
aws_smithy_mocks::mock!(aws_sdk_elasticbeanstalk::Client::describe_environment_health)
.then_error(move || {
aws_sdk_elasticbeanstalk::operation::describe_environment_health::DescribeEnvironmentHealthError::generic(
denied("elasticbeanstalk:DescribeEnvironmentHealth"),
)
})
} else {
aws_smithy_mocks::mock!(aws_sdk_elasticbeanstalk::Client::describe_environment_health)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_environment_health::DescribeEnvironmentHealthOutput::builder().build()
})
};
let resources = aws_smithy_mocks::mock!(
aws_sdk_elasticbeanstalk::Client::describe_environment_resources
)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput::builder().build()
});
let platform_date_rejected = faults.platform_date_rejected;
let platforms = aws_smithy_mocks::mock!(
aws_sdk_elasticbeanstalk::Client::list_platform_versions
)
.then_output(move || {
use aws_sdk_elasticbeanstalk::types::PlatformSummary;
let mut b = aws_sdk_elasticbeanstalk::operation::list_platform_versions::ListPlatformVersionsOutput::builder();
if platform_date_rejected {
b = b.platform_summary_list(
PlatformSummary::builder()
.platform_arn("arn:aws:elasticbeanstalk:us-west-1:123456789012:platform/custom-node/1.0.0")
.platform_branch_name(FAULTED_BRANCH)
.platform_version("1.0.0")
.build(),
);
}
b.build()
});
let platform_date = aws_smithy_mocks::mock!(
aws_sdk_elasticbeanstalk::Client::describe_platform_version
)
.then_error(|| {
aws_sdk_elasticbeanstalk::operation::describe_platform_version::DescribePlatformVersionError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AccessDeniedException")
.message(
"User is not authorized to perform \
elasticbeanstalk:DescribePlatformVersion",
)
.build(),
)
});
let update = aws_smithy_mocks::mock!(
aws_sdk_elasticbeanstalk::Client::update_environment
)
.then_error(|| {
aws_sdk_elasticbeanstalk::operation::update_environment::UpdateEnvironmentError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AccessDeniedException")
.message("User is not authorized to perform elasticbeanstalk:UpdateEnvironment")
.build(),
)
});
let listing_denied =
aws_smithy_mocks::mock!(aws_sdk_elasticbeanstalk::Client::describe_environments)
.then_error(|| {
aws_sdk_elasticbeanstalk::operation::describe_environments::DescribeEnvironmentsError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AccessDeniedException")
.message("not authorized")
.build(),
)
});
let mut rules: Vec<&aws_smithy_mocks::Rule> = vec![
&listing,
&stacks,
&cfgsettings,
&tags,
&health,
&resources,
&platforms,
];
if failing_update {
rules.push(&update);
}
if faults.platform_date_rejected {
rules.push(&platform_date);
}
if faults.listing_rejected {
rules.push(&listing_denied);
}
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
rules
);
let cfg = aws_config::SdkConfig::builder()
.region(aws_config::Region::new("us-west-1"))
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
aws::AwsClient::for_tests(
eb,
aws_sdk_sqs::Client::new(&cfg),
aws_sdk_cloudwatch::Client::new(&cfg),
aws_sdk_cloudwatchlogs::Client::new(&cfg),
aws_sdk_s3::Client::new(&cfg),
aws_sdk_ec2::Client::new(&cfg),
)
}
#[derive(Default)]
struct CycleOpts {
env_name: Option<String>,
fix: bool,
yes: bool,
safety_cfg: config::Config,
fix_disabled: Vec<String>,
disabled: Vec<String>,
}
fn test_clock() -> chrono::DateTime<chrono::Utc> {
"2026-09-22T12:00:00Z".parse().expect("a valid instant")
}
async fn run_with<F, Fut>(regions: Vec<Option<String>>, client_for: F) -> CycleReport
where
F: Fn(Option<String>) -> Fut,
Fut: std::future::Future<Output = color_eyre::eyre::Result<aws::AwsClient>>,
{
run_with_opts(regions, CycleOpts::default(), client_for).await
}
async fn run_with_opts<F, Fut>(
regions: Vec<Option<String>>,
opts: CycleOpts,
client_for: F,
) -> CycleReport
where
F: Fn(Option<String>) -> Fut,
Fut: std::future::Future<Output = color_eyre::eyre::Result<aws::AwsClient>>,
{
run_cycle(
®ions,
&opts.env_name,
&opts.disabled,
false,
opts.fix,
opts.yes,
true,
false,
None,
&[],
&opts.safety_cfg,
&opts.fix_disabled,
&None,
client_for,
test_clock(),
)
.await
}
#[tokio::test]
async fn a_rejected_fix_dispatch_fails_the_cycle() {
let report = run_with_opts(
vec![None],
CycleOpts {
fix: true,
yes: true,
..CycleOpts::default()
},
|_| async { Ok(client_with_failing_update(vec!["poly-prod-web".into()])) },
)
.await;
assert!(
report.fix_dispatch_failed,
"a rejected UpdateEnvironment must fail the cycle, or `lint --fix` \
exits 0 having changed nothing ({} issues seen)",
report.issues.len()
);
assert!(
!report.degraded(),
"the fleet WAS seen — a rejected write is not incomplete coverage: {:?}",
report.degrade_reasons
);
}
fn with_required_tag() -> config::Config {
config::Config {
required_tags: vec!["owner".into()],
..config::Config::default()
}
}
#[tokio::test]
async fn a_rejected_tag_fetch_degrades_the_cycle() {
let report = run_with_opts(
vec![None],
CycleOpts {
safety_cfg: with_required_tag(),
..CycleOpts::default()
},
|_| async {
Ok(mock_client_inner(
vec!["poly-prod-web".into()],
MockFaults {
tags_rejected: true,
..MockFaults::default()
},
))
},
)
.await;
assert!(
report
.degrade_reasons
.iter()
.any(|r| r.contains("EBL010") && r.contains("ListTagsForResource")),
"{:?}",
report.degrade_reasons
);
}
#[tokio::test]
async fn a_rejected_health_fetch_degrades_the_cycle() {
let report = run_with(vec![None], |_| async {
Ok(mock_client_inner(
vec!["poly-prod-web".into()],
MockFaults {
health_rejected: true,
..MockFaults::default()
},
))
})
.await;
assert!(
report
.degrade_reasons
.iter()
.any(|r| r.contains("EBL012") && r.contains("DescribeEnvironmentHealth")),
"{:?}",
report.degrade_reasons
);
}
fn stacks_rejected() -> aws::AwsClient {
mock_client_inner(
vec!["poly-prod-web".into(), "poly-prod-api".into()],
MockFaults {
stacks_rejected: true,
..MockFaults::default()
},
)
}
#[tokio::test]
async fn a_failed_stack_listing_degrades_the_cycle() {
let report = run_with(vec![None], |_| async { Ok(stacks_rejected()) }).await;
let ebl008: Vec<_> = report
.degrade_reasons
.iter()
.filter(|r| r.contains("EBL008"))
.collect();
assert_eq!(ebl008.len(), 1, "{:?}", report.degrade_reasons);
assert!(
ebl008[0].contains("ListAvailableSolutionStacks") && ebl008[0].contains("region '"),
"{}",
ebl008[0]
);
}
#[tokio::test]
async fn a_failed_stack_listing_does_not_degrade_when_ebl008_is_disabled() {
let report = run_with_opts(
vec![None],
CycleOpts {
disabled: vec!["EBL008".into()],
..CycleOpts::default()
},
|_| async { Ok(stacks_rejected()) },
)
.await;
assert!(!report.degraded(), "{:?}", report.degrade_reasons);
}
#[tokio::test]
async fn a_partly_failed_platform_pass_degrades_the_cycle() {
let report = run_with(vec![None], |_| async {
Ok(client_with_failing_platform_date(vec![
"poly-prod-web".into()
]))
})
.await;
assert!(
report.degraded(),
"a branch whose DescribePlatformVersion failed is EBL015 coverage that \
did not happen — a clean exit here lets `--baseline` snapshot it as good"
);
assert!(
report.degrade_reasons.iter().any(|r| {
r.contains("EBL015 skipped for")
&& r.contains(FAULTED_BRANCH)
&& r.contains("DescribePlatformVersion")
}),
"the reason must NAME the branch and the call that failed, or the \
operator cannot tell which coverage is missing: {:?}",
report.degrade_reasons
);
}
#[tokio::test]
async fn a_fix_is_audited_under_the_region_it_ran_in() {
let env = "lint-fix-region-probe-env";
let path = crate::util::cache_dir().join("audit.log");
let before = std::fs::read_to_string(&path).unwrap_or_default();
run_with_opts(
vec![None],
CycleOpts {
fix: true,
yes: true,
..CycleOpts::default()
},
|_| async { Ok(client_with_failing_update(vec![env.into()])) },
)
.await;
let after = std::fs::read_to_string(&path).unwrap_or_default();
let lines: Vec<&str> = after
.strip_prefix(&before)
.expect("the audit log is append-only")
.lines()
.filter(|l| l.contains(env))
.collect();
assert!(!lines.is_empty(), "the fix attempt is audited");
let region = client_with_failing_update(vec![]).context.region.clone();
for l in &lines {
assert!(l.contains(&format!("region={region}")), "{l}");
assert!(!l.contains("region=default"), "{l}");
}
}
#[tokio::test]
async fn a_preview_never_fails_the_cycle() {
let report = run_with_opts(
vec![None],
CycleOpts {
fix: true,
yes: false,
..CycleOpts::default()
},
|_| async { Ok(client_with_failing_update(vec!["poly-prod-web".into()])) },
)
.await;
assert!(
!report.fix_dispatch_failed,
"a preview dispatched nothing and must not exit 1"
);
}
fn read_only_cfg(env: &str) -> config::Config {
let mut cfg = config::Config::default();
cfg.safety_envs.insert(env.to_string(), true);
cfg
}
#[tokio::test]
async fn a_refused_fix_fails_the_cycle_only_on_a_real_run() {
let env = "poly-prod-web";
for (yes, expect_failed) in [(true, true), (false, false)] {
let report = run_with_opts(
vec![None],
CycleOpts {
fix: true,
yes,
safety_cfg: read_only_cfg(env),
..CycleOpts::default()
},
|_| async { Ok(client_with_failing_update(vec![env.to_string()])) },
)
.await;
assert_eq!(
report.fix_dispatch_failed,
expect_failed,
"a refusal with --yes={yes} must {} the cycle",
if expect_failed { "fail" } else { "not fail" }
);
}
}
#[tokio::test]
async fn an_unknown_env_is_a_usage_error_not_an_exit() {
let report = run_with_opts(
vec![None],
CycleOpts {
env_name: Some("no-such-env".into()),
..CycleOpts::default()
},
|_| async { Ok(mock_client(vec!["real-env".into()])) },
)
.await;
let msg = report
.usage_error
.as_deref()
.expect("an unknown env must be reported as a usage error");
assert!(msg.contains("no-such-env"), "{msg}");
assert!(
!report.degraded(),
"a typo is not a degraded cycle — degraded means the fleet was not \
seen, and it was: {:?}",
report.degrade_reasons
);
assert!(report.issues.is_empty(), "{:?}", report.issues);
}
#[tokio::test]
async fn an_env_in_another_region_is_not_a_usage_error() {
let report = run_with_opts(
vec![Some("eu-west-1".into()), Some("eu-west-2".into())],
CycleOpts {
env_name: Some("target-env".into()),
..CycleOpts::default()
},
|region| async move {
Ok(match region.as_deref() {
Some("eu-west-2") => mock_client(vec!["target-env".into()]),
_ => mock_client(vec!["other-env".into()]),
})
},
)
.await;
assert!(report.usage_error.is_none(), "{:?}", report.usage_error);
}
#[tokio::test]
async fn an_env_in_no_region_is_a_usage_error() {
let report = run_with_opts(
vec![Some("eu-west-1".into()), Some("eu-west-2".into())],
CycleOpts {
env_name: Some("no-such-env".into()),
..CycleOpts::default()
},
|_| async { Ok(mock_client(vec!["real-env".into()])) },
)
.await;
let msg = report.usage_error.expect("a typo is a usage error");
assert!(
msg.contains("no-such-env") && msg.contains("2 regions"),
"{msg}"
);
}
#[tokio::test]
async fn an_env_missing_where_a_region_would_not_list_is_not_a_usage_error() {
let report = run_with_opts(
vec![Some("eu-west-1".into()), Some("eu-west-2".into())],
CycleOpts {
env_name: Some("maybe-env".into()),
..CycleOpts::default()
},
|region| async move {
Ok(match region.as_deref() {
Some("eu-west-2") => mock_client_inner(
vec![],
MockFaults {
listing_rejected: true,
..MockFaults::default()
},
),
_ => mock_client(vec!["other-env".into()]),
})
},
)
.await;
assert!(report.usage_error.is_none(), "{:?}", report.usage_error);
assert!(
report.degraded(),
"the region that would not list degrades the run"
);
}
#[tokio::test]
async fn an_env_missing_where_a_region_did_not_answer_is_not_a_usage_error() {
let report = run_with_opts(
vec![Some("eu-west-1".into()), Some("eu-west-2".into())],
CycleOpts {
env_name: Some("maybe-env".into()),
..CycleOpts::default()
},
|region| async move {
match region.as_deref() {
Some("eu-west-2") => Err(color_eyre::eyre::eyre!("no credentials")),
_ => Ok(mock_client(vec!["other-env".into()])),
}
},
)
.await;
assert!(report.usage_error.is_none(), "{:?}", report.usage_error);
assert!(report.degraded(), "the unanswered region degrades the run");
}
#[tokio::test]
async fn a_region_that_will_not_connect_degrades_the_cycle() {
let report = run_with(vec![Some("eu-west-2".into())], |_| async {
Err(color_eyre::eyre::eyre!("no credentials"))
})
.await;
assert!(
report.degraded(),
"a region that never answered means the issue set is not a full picture"
);
assert_eq!(
report.degrade_reasons.len(),
1,
"{:?}",
report.degrade_reasons
);
let reason = &report.degrade_reasons[0];
assert!(
reason.contains("eu-west-2"),
"the reason must name WHICH region: {reason}"
);
assert!(
reason.contains("no credentials"),
"and carry the cause, or an operator cannot act on it: {reason}"
);
assert!(report.issues.is_empty());
assert!(!report.fix_dispatch_failed, "nothing was dispatched");
}
#[tokio::test]
async fn a_partial_outage_reports_both_what_worked_and_what_did_not() {
let report = run_with(
vec![Some("eu-west-2".into()), Some("us-east-1".into())],
|region| async move {
if region.as_deref() == Some("eu-west-2") {
Err(color_eyre::eyre::eyre!("no credentials"))
} else {
Ok(mock_client(vec!["poly-prod".to_string()]))
}
},
)
.await;
assert!(
report.degraded(),
"a cycle that skipped a region is incomplete even though the other \
region answered — this is the distinction the whole type exists for"
);
assert_eq!(
report.degrade_reasons.len(),
1,
"{:?}",
report.degrade_reasons
);
assert!(report.degrade_reasons[0].contains("eu-west-2"));
assert!(
!report.degrade_reasons[0].contains("us-east-1"),
"the region that worked must not appear as a failure"
);
}
#[tokio::test]
async fn a_cycle_where_every_region_answers_is_not_degraded() {
let report = run_with(vec![Some("us-east-1".into())], |_| async {
Ok(mock_client(vec!["poly-prod".to_string()]))
})
.await;
assert!(
!report.degraded(),
"every region answered: {:?}",
report.degrade_reasons
);
}
}