use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warn,
Error,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warn => "warn",
Severity::Error => "error",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"info" => Some(Severity::Info),
"warn" | "warning" => Some(Severity::Warn),
"error" | "err" => Some(Severity::Error),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
pub rule_id: String,
pub severity: Severity,
pub env_name: Option<String>,
pub title: String,
pub detail: String,
pub suggestion: Option<String>,
pub fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct LintContext<'a> {
pub env: &'a crate::aws::Environment,
pub options: &'a [(String, String, String)],
pub events: &'a [crate::aws::Event],
pub cost_usd_per_month: Option<f64>,
pub latest_stack_version: Option<&'a str>,
}
pub trait Rule: Send + Sync {
fn id(&self) -> &'static str;
fn severity(&self) -> Severity;
fn applies(&self, ctx: &LintContext) -> Option<Issue>;
}
pub fn run_rules(rules: &[Box<dyn Rule>], ctx: &LintContext) -> Vec<Issue> {
let mut out: Vec<Issue> = rules.iter().filter_map(|r| r.applies(ctx)).collect();
out.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| a.rule_id.cmp(&b.rule_id))
});
out
}
pub fn render_issues_json(issues: &[Issue]) -> String {
let mut out = String::from("{\"issues\":[");
for (i, issue) in issues.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('{');
push_kv(&mut out, "rule_id", &issue.rule_id);
out.push(',');
push_kv(&mut out, "severity", issue.severity.as_str());
out.push(',');
if let Some(env) = &issue.env_name {
push_kv(&mut out, "env", env);
out.push(',');
}
push_kv(&mut out, "title", &issue.title);
out.push(',');
push_kv(&mut out, "detail", &issue.detail);
if let Some(s) = &issue.suggestion {
out.push(',');
push_kv(&mut out, "suggestion", s);
}
if !issue.fields.is_empty() {
out.push_str(",\"fields\":{");
for (j, (k, v)) in issue.fields.iter().enumerate() {
if j > 0 {
out.push(',');
}
push_kv(&mut out, k, v);
}
out.push('}');
}
out.push('}');
}
out.push_str("]}");
out
}
fn push_kv(out: &mut String, k: &str, v: &str) {
out.push('"');
out.push_str(&json_escape(k));
out.push_str("\":\"");
out.push_str(&json_escape(v));
out.push('"');
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
use std::fmt::Write;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out
}
pub(crate) fn option_value<'a>(
options: &'a [(String, String, String)],
namespace: &str,
name: &str,
) -> &'a str {
options
.iter()
.find(|(n, k, _)| n == namespace && k == name)
.map(|(_, _, v)| v.as_str())
.unwrap_or("")
}
fn parse_i32(s: &str) -> Option<i32> {
s.trim().parse().ok()
}
pub struct AllAtOnceMultiInstance;
impl Rule for AllAtOnceMultiInstance {
fn id(&self) -> &'static str {
"EBL001"
}
fn severity(&self) -> Severity {
Severity::Warn
}
fn applies(&self, ctx: &LintContext) -> Option<Issue> {
let policy = option_value(
ctx.options,
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
);
let max_size = parse_i32(option_value(ctx.options, "aws:autoscaling:asg", "MaxSize"))?;
if policy.eq_ignore_ascii_case("AllAtOnce") && max_size > 1 {
let mut fields = BTreeMap::new();
fields.insert("policy".into(), policy.to_string());
fields.insert("max_size".into(), max_size.to_string());
return Some(Issue {
rule_id: self.id().into(),
severity: self.severity(),
env_name: Some(ctx.env.name.clone()),
title: format!(
"AllAtOnce on {max_size}-instance env: 100% capacity loss during deploys"
),
detail: format!(
"Deployment policy is {policy} with MaxSize={max_size}. Every instance \
will restart simultaneously when a deploy fires, so the env is fully \
unavailable for the duration of the rollout."
),
suggestion: Some(
":deployment-policy Rolling (or RollingWithAdditionalBatch for zero downtime)"
.into(),
),
fields,
});
}
None
}
}
pub struct WebTierNoHealthCheckUrl;
impl Rule for WebTierNoHealthCheckUrl {
fn id(&self) -> &'static str {
"EBL002"
}
fn severity(&self) -> Severity {
Severity::Warn
}
fn applies(&self, ctx: &LintContext) -> Option<Issue> {
if !ctx.env.tier.eq_ignore_ascii_case("Web") {
return None;
}
let url = option_value(
ctx.options,
"aws:elasticbeanstalk:application",
"Application Healthcheck URL",
);
if url.is_empty() || url == "/" {
let mut fields = BTreeMap::new();
fields.insert("tier".into(), ctx.env.tier.clone());
fields.insert("current_url".into(), url.to_string());
return Some(Issue {
rule_id: self.id().into(),
severity: self.severity(),
env_name: Some(ctx.env.name.clone()),
title: "Web-tier env probes `/` for health — consider an explicit /health endpoint"
.into(),
detail:
"EB defaults to probing the env root for health checks. A deploy that breaks \
the homepage still looks healthy to the ALB, so auto-rollback won't fire. \
An explicit `/health` (or similar) endpoint that exercises real dependencies \
is the standard safety net."
.into(),
suggestion: Some(":health-check-url /health".into()),
fields,
});
}
None
}
}
pub struct EnvRedForExtendedPeriod;
impl Rule for EnvRedForExtendedPeriod {
fn id(&self) -> &'static str {
"EBL003"
}
fn severity(&self) -> Severity {
Severity::Warn
}
fn applies(&self, ctx: &LintContext) -> Option<Issue> {
let health = ctx.env.health.to_ascii_lowercase();
if !matches!(health.as_str(), "red" | "severe" | "degraded") {
return None;
}
let updated = ctx.env.updated?;
let hours_since = (chrono::Utc::now() - updated).num_hours();
if hours_since < 4 {
return None;
}
let mut fields = BTreeMap::new();
fields.insert("health".into(), ctx.env.health.clone());
fields.insert("hours_red".into(), hours_since.to_string());
Some(Issue {
rule_id: self.id().into(),
severity: self.severity(),
env_name: Some(ctx.env.name.clone()),
title: format!("Env has been {} for {}h", ctx.env.health, hours_since),
detail: format!(
"Health has been {} since {} — that's {}h. Long-running unhealthy envs \
typically mean either an abandoned stack or a missed page. Worth \
acknowledging via :why and either remediating or terminating.",
ctx.env.health,
updated.to_rfc3339(),
hours_since
),
suggestion: Some(":why (drill into events + alarms + instances)".into()),
fields,
})
}
}
pub struct BatchSizeExceedsMaxSize;
impl Rule for BatchSizeExceedsMaxSize {
fn id(&self) -> &'static str {
"EBL004"
}
fn severity(&self) -> Severity {
Severity::Warn
}
fn applies(&self, ctx: &LintContext) -> Option<Issue> {
let batch_size = parse_i32(option_value(
ctx.options,
"aws:elasticbeanstalk:command",
"BatchSize",
))?;
let batch_type = option_value(ctx.options, "aws:elasticbeanstalk:command", "BatchSizeType");
if !batch_type.eq_ignore_ascii_case("Fixed") {
return None;
}
let max_size = parse_i32(option_value(ctx.options, "aws:autoscaling:asg", "MaxSize"))?;
if batch_size > max_size {
let mut fields = BTreeMap::new();
fields.insert("batch_size".into(), batch_size.to_string());
fields.insert("max_size".into(), max_size.to_string());
return Some(Issue {
rule_id: self.id().into(),
severity: self.severity(),
env_name: Some(ctx.env.name.clone()),
title: format!("BatchSize ({batch_size}) > MaxSize ({max_size})"),
detail: format!(
"Rolling deployment is configured with BatchSize={batch_size} (Fixed) \
but ASG MaxSize={max_size}. EB will clamp the effective batch to \
MaxSize, but the configured intent is broken — either the policy or \
the scaling profile is wrong."
),
suggestion: Some(format!(
":set-option aws:elasticbeanstalk:command BatchSize {max_size} (clamp to MaxSize)"
)),
fields,
});
}
None
}
}
pub struct SingleInstanceEnv;
impl Rule for SingleInstanceEnv {
fn id(&self) -> &'static str {
"EBL005"
}
fn severity(&self) -> Severity {
Severity::Info
}
fn applies(&self, ctx: &LintContext) -> Option<Issue> {
let min_size = parse_i32(option_value(ctx.options, "aws:autoscaling:asg", "MinSize"))?;
let max_size = parse_i32(option_value(ctx.options, "aws:autoscaling:asg", "MaxSize"))?;
if min_size == 1 && max_size == 1 {
let mut fields = BTreeMap::new();
fields.insert("min_size".into(), "1".into());
fields.insert("max_size".into(), "1".into());
return Some(Issue {
rule_id: self.id().into(),
severity: self.severity(),
env_name: Some(ctx.env.name.clone()),
title: "Single-instance env — no redundancy".into(),
detail:
"MinSize=MaxSize=1 means any instance failure is a full outage. Acceptable for \
dev/staging; risky for production. Consider scaling to ≥ 2 instances if this \
is a production workload."
.into(),
suggestion: Some(":capacity (set Min ≥ 2 for redundancy)".into()),
fields,
});
}
None
}
}
pub struct CooldownBelowRecommended;
impl Rule for CooldownBelowRecommended {
fn id(&self) -> &'static str {
"EBL006"
}
fn severity(&self) -> Severity {
Severity::Info
}
fn applies(&self, ctx: &LintContext) -> Option<Issue> {
let cooldown = parse_i32(option_value(ctx.options, "aws:autoscaling:asg", "Cooldown"))?;
if cooldown < 60 {
let mut fields = BTreeMap::new();
fields.insert("cooldown_secs".into(), cooldown.to_string());
fields.insert("recommended_min".into(), "60".into());
return Some(Issue {
rule_id: self.id().into(),
severity: self.severity(),
env_name: Some(ctx.env.name.clone()),
title: format!(
"Autoscaling Cooldown={cooldown}s is below the 60s recommended floor"
),
detail: format!(
"Cooldown={cooldown}s means the ASG can launch / terminate instances in rapid \
succession before a new instance has stabilised under load — typical symptom \
is autoscaling thrashing during spikes. EB documents 60s as the floor."
),
suggestion: Some(":set-option aws:autoscaling:asg Cooldown 360".into()),
fields,
});
}
None
}
}
pub fn default_rules(disabled: &[String]) -> Vec<Box<dyn Rule>> {
let candidates: Vec<Box<dyn Rule>> = vec![
Box::new(AllAtOnceMultiInstance),
Box::new(WebTierNoHealthCheckUrl),
Box::new(EnvRedForExtendedPeriod),
Box::new(BatchSizeExceedsMaxSize),
Box::new(SingleInstanceEnv),
Box::new(CooldownBelowRecommended),
];
candidates
.into_iter()
.filter(|r| !disabled.iter().any(|d| d == r.id()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aws::Environment;
fn mk_env(name: &str, tier: &str, health: &str) -> Environment {
Environment {
name: name.into(),
application: "shop".into(),
status: "Ready".into(),
health: health.into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: tier.into(),
cname: format!("{name}.example.com"),
version_label: "build-1".into(),
arn: Some(format!("arn:aws:eb:us-east-1:0:env/{name}")),
updated: None,
id: None,
region: None,
}
}
fn mk_opt(ns: &str, name: &str, value: &str) -> (String, String, String) {
(ns.into(), name.into(), value.into())
}
fn ctx<'a>(env: &'a Environment, options: &'a [(String, String, String)]) -> LintContext<'a> {
LintContext {
env,
options,
events: &[],
cost_usd_per_month: None,
latest_stack_version: None,
}
}
#[test]
fn severity_parses_common_forms() {
assert_eq!(Severity::parse("info"), Some(Severity::Info));
assert_eq!(Severity::parse("INFO"), Some(Severity::Info));
assert_eq!(Severity::parse("warn"), Some(Severity::Warn));
assert_eq!(Severity::parse("warning"), Some(Severity::Warn));
assert_eq!(Severity::parse("Error"), Some(Severity::Error));
assert_eq!(Severity::parse("err"), Some(Severity::Error));
assert_eq!(Severity::parse("nope"), None);
}
#[test]
fn ebl001_fires_on_all_at_once_multi_instance() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![
mk_opt(
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
"AllAtOnce",
),
mk_opt("aws:autoscaling:asg", "MaxSize", "4"),
];
let issue = AllAtOnceMultiInstance.applies(&ctx(&env, &opts));
let issue = issue.expect("EBL001 should fire");
assert_eq!(issue.rule_id, "EBL001");
assert_eq!(issue.severity, Severity::Warn);
assert!(issue.title.contains("4-instance"));
assert!(issue.suggestion.as_ref().unwrap().contains("Rolling"));
}
#[test]
fn ebl001_skips_when_max_size_1() {
let env = mk_env("dev", "Web", "Green");
let opts = vec![
mk_opt(
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
"AllAtOnce",
),
mk_opt("aws:autoscaling:asg", "MaxSize", "1"),
];
assert!(AllAtOnceMultiInstance.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl001_skips_when_policy_is_rolling() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![
mk_opt(
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
"Rolling",
),
mk_opt("aws:autoscaling:asg", "MaxSize", "4"),
];
assert!(AllAtOnceMultiInstance.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl002_fires_on_web_tier_with_empty_health_check_url() {
let env = mk_env("prod", "Web", "Green");
let opts: Vec<(String, String, String)> = vec![];
let issue = WebTierNoHealthCheckUrl.applies(&ctx(&env, &opts));
let issue = issue.expect("EBL002 should fire");
assert_eq!(issue.rule_id, "EBL002");
}
#[test]
fn ebl002_fires_on_web_tier_with_root_health_check_url() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![mk_opt(
"aws:elasticbeanstalk:application",
"Application Healthcheck URL",
"/",
)];
assert!(WebTierNoHealthCheckUrl.applies(&ctx(&env, &opts)).is_some());
}
#[test]
fn ebl002_skips_on_worker_tier() {
let env = mk_env("worker", "Worker", "Green");
let opts: Vec<(String, String, String)> = vec![];
assert!(WebTierNoHealthCheckUrl.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl002_skips_with_explicit_health_path() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![mk_opt(
"aws:elasticbeanstalk:application",
"Application Healthcheck URL",
"/health",
)];
assert!(WebTierNoHealthCheckUrl.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl003_fires_when_env_red_for_over_4h() {
let mut env = mk_env("prod", "Web", "Red");
env.updated = Some(chrono::Utc::now() - chrono::Duration::hours(5));
let opts: Vec<(String, String, String)> = vec![];
let issue = EnvRedForExtendedPeriod
.applies(&ctx(&env, &opts))
.expect("EBL003 should fire");
assert!(issue.title.contains("Red"));
}
#[test]
fn ebl003_skips_when_recently_red() {
let mut env = mk_env("prod", "Web", "Red");
env.updated = Some(chrono::Utc::now() - chrono::Duration::minutes(30));
let opts: Vec<(String, String, String)> = vec![];
assert!(EnvRedForExtendedPeriod.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl003_skips_when_health_unknown() {
let env = mk_env("prod", "Web", "Red");
let opts: Vec<(String, String, String)> = vec![];
assert!(EnvRedForExtendedPeriod.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl004_fires_when_fixed_batch_exceeds_max_size() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![
mk_opt("aws:elasticbeanstalk:command", "BatchSize", "8"),
mk_opt("aws:elasticbeanstalk:command", "BatchSizeType", "Fixed"),
mk_opt("aws:autoscaling:asg", "MaxSize", "4"),
];
let issue = BatchSizeExceedsMaxSize
.applies(&ctx(&env, &opts))
.expect("EBL004 should fire");
assert!(issue.title.contains("8") && issue.title.contains("4"));
}
#[test]
fn ebl004_skips_percentage_batches() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![
mk_opt("aws:elasticbeanstalk:command", "BatchSize", "50"),
mk_opt(
"aws:elasticbeanstalk:command",
"BatchSizeType",
"Percentage",
),
mk_opt("aws:autoscaling:asg", "MaxSize", "4"),
];
assert!(BatchSizeExceedsMaxSize.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl005_fires_on_single_instance_env() {
let env = mk_env("dev", "Web", "Green");
let opts = vec![
mk_opt("aws:autoscaling:asg", "MinSize", "1"),
mk_opt("aws:autoscaling:asg", "MaxSize", "1"),
];
assert!(SingleInstanceEnv.applies(&ctx(&env, &opts)).is_some());
}
#[test]
fn ebl005_skips_when_max_size_above_1() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![
mk_opt("aws:autoscaling:asg", "MinSize", "1"),
mk_opt("aws:autoscaling:asg", "MaxSize", "4"),
];
assert!(SingleInstanceEnv.applies(&ctx(&env, &opts)).is_none());
}
#[test]
fn ebl006_fires_when_cooldown_below_60s() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![mk_opt("aws:autoscaling:asg", "Cooldown", "30")];
assert!(CooldownBelowRecommended
.applies(&ctx(&env, &opts))
.is_some());
}
#[test]
fn ebl006_skips_at_or_above_60s() {
let env = mk_env("prod", "Web", "Green");
let opts = vec![mk_opt("aws:autoscaling:asg", "Cooldown", "60")];
assert!(CooldownBelowRecommended
.applies(&ctx(&env, &opts))
.is_none());
}
#[test]
fn default_rules_filters_disabled() {
let all = default_rules(&[]);
let n_all = all.len();
let filtered = default_rules(&["EBL001".to_string(), "EBL003".to_string()]);
assert_eq!(filtered.len(), n_all - 2);
assert!(!filtered.iter().any(|r| r.id() == "EBL001"));
assert!(!filtered.iter().any(|r| r.id() == "EBL003"));
}
#[test]
fn run_rules_sorts_severity_desc_then_id_asc() {
let mut env = mk_env("prod", "Web", "Red");
env.updated = Some(chrono::Utc::now() - chrono::Duration::hours(5));
let opts = vec![
mk_opt(
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
"AllAtOnce",
),
mk_opt(
"aws:elasticbeanstalk:application",
"Application Healthcheck URL",
"/health",
),
mk_opt("aws:autoscaling:asg", "MinSize", "1"),
mk_opt("aws:autoscaling:asg", "MaxSize", "1"),
];
let rules = default_rules(&[]);
let issues = run_rules(&rules, &ctx(&env, &opts));
let ids: Vec<&str> = issues.iter().map(|i| i.rule_id.as_str()).collect();
let pos_003 = ids.iter().position(|&i| i == "EBL003");
let pos_005 = ids.iter().position(|&i| i == "EBL005");
if let (Some(p3), Some(p5)) = (pos_003, pos_005) {
assert!(p3 < p5, "Warn must sort before Info");
}
}
#[test]
fn render_issues_json_is_well_formed_and_consumable() {
let issue = Issue {
rule_id: "EBL001".into(),
severity: Severity::Warn,
env_name: Some("prod".into()),
title: "AllAtOnce on 4-instance env".into(),
detail: "Long detail with \"quotes\" and a\nnewline".into(),
suggestion: Some(":deployment-policy Rolling".into()),
fields: {
let mut m = BTreeMap::new();
m.insert("policy".into(), "AllAtOnce".into());
m.insert("max_size".into(), "4".into());
m
},
};
let json = render_issues_json(&[issue]);
let _: serde_yml::Value =
serde_yml::from_str(&json).expect("rendered output must be valid JSON");
assert!(json.contains("\\\"quotes\\\""));
assert!(json.contains("\\n"));
let empty = render_issues_json(&[]);
let _: serde_yml::Value = serde_yml::from_str(&empty).unwrap();
assert_eq!(empty, "{\"issues\":[]}");
}
}