cloudformation-validate-cel-engine 1.14.0

AWS CloudFormation Validate
Documentation
use super::resources_extra::{fargate_condition_scenarios, merge_reachable_scenario_conditions};
use super::{EvalContext, NativeRuleRegistry};
use diagnostics::{Diagnostic, RelatedResource, ResourceRef};
use std::collections::BTreeSet;
use template_model::consts::{
    FIELD_CREATION_POLICY, FIELD_RESOURCE_TYPE, FIELD_RESOURCES, FIELD_UPDATE_POLICY, KEY_CREATION_POLICY,
    KEY_UPDATE_POLICY,
};
use template_model::fargate::task_size_is_offered;
use template_model::resolver::ResolvedValue;
use template_model::vpc_cidr::overlapping_subnets;
use template_model::{SemanticModel, SourceSpan, render_value};
use validation_engine::make_resource_diagnostic;

pub fn register(reg: &mut NativeRuleRegistry) {
    reg.add(eval_resources);
    reg.add(crate::rules::resources_extra::eval_extra_resources);
}

fn resolve_concrete(m: &SemanticModel, rid: &str, path: &str) -> Option<serde_json::Value> {
    match m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned())? {
        ResolvedValue::Concrete { value: v } => Some(v.into_inner()),
        _ => None,
    }
}

fn eval_resources(ctx: &EvalContext) -> Vec<Diagnostic> {
    let mut out = Vec::new();
    let m = ctx.model;

    for name in m.resources_of_type("AWS::ECS::TaskDefinition") {
        let fargate_scenarios = fargate_condition_scenarios(m, name);
        if fargate_scenarios.is_empty() {
            continue;
        }
        let cpu_scenarios = m
            .resolve_scenarios_json(name, "Properties.Cpu")
            .into_iter()
            .filter(|(_, conditions)| !m.scenario_uses_unresolved_parameter(name, "Properties.Cpu", conditions))
            .collect::<Vec<_>>();
        let memory_scenarios = m
            .resolve_scenarios_json(name, "Properties.Memory")
            .into_iter()
            .filter(|(_, conditions)| !m.scenario_uses_unresolved_parameter(name, "Properties.Memory", conditions))
            .collect::<Vec<_>>();
        let mut invalid_sizes = BTreeSet::new();

        for fargate_conditions in &fargate_scenarios {
            for (cpu, cpu_conditions) in &cpu_scenarios {
                let Some(fargate_cpu_conditions) =
                    merge_reachable_scenario_conditions(m, name, fargate_conditions, cpu_conditions)
                else {
                    continue;
                };
                for (memory, memory_conditions) in &memory_scenarios {
                    if merge_reachable_scenario_conditions(m, name, &fargate_cpu_conditions, memory_conditions)
                        .is_some()
                        && matches!(task_size_is_offered(cpu, memory), Some(false))
                    {
                        invalid_sizes.insert((render_value(cpu), render_value(memory)));
                    }
                }
            }
        }

        for (cpu, memory) in invalid_sizes {
            out.push(make_resource_diagnostic(
                "E3047",
                &format!("Cpu {cpu} is not compatible with Memory {memory} for Fargate"),
                m,
                name,
                "Properties.Cpu",
                Some("Use a valid Fargate CPU/memory combination (e.g., Cpu: 256 with Memory: 512, 1024, or 2048)"),
            ));
        }
    }

    for finding in overlapping_subnets(m) {
        let mut diag =
            make_resource_diagnostic("E3060", &finding.message, m, &finding.subnet_id, "Properties.CidrBlock", None);
        let span = m.resource_span(&finding.earlier_subnet_id, "");
        diag.related_resources.get_or_insert_with(Vec::new).push(RelatedResource {
            resource: Some(ResourceRef {
                id: Some(finding.earlier_subnet_id.clone()),
                resource_type: m.resources.get(finding.earlier_subnet_id.as_str()).map(|r| r.resource_type.clone()),
            }),
            location: Some(SourceSpan {
                start_line: span.start_line,
                start_column: span.start_column,
                end_line: span.end_line,
                end_column: span.end_column,
            }),
            message: finding.earlier_subnet_message,
        });
        out.push(diag);
    }

    for (name, res) in &m.resources {
        if res.resource_type.ends_with("::MODULE") && res.properties.contains_key("Tags") {
            out.push(make_resource_diagnostic(
                "E5001",
                &format!("Tags is not permitted within Module resource '{}'", name),
                m,
                name,
                "Properties.Tags",
                None,
            ));
        }
    }

    if let Some(resources) = ctx.input.get(FIELD_RESOURCES).and_then(|r| r.as_object()) {
        for (name, res) in resources {
            let rtype = res.get(FIELD_RESOURCE_TYPE).and_then(|t| t.as_str()).unwrap_or("");
            if rtype.ends_with("::MODULE") {
                if res.get(FIELD_CREATION_POLICY).is_some_and(|v| !v.is_null()) {
                    out.push(make_resource_diagnostic(
                        "E5001",
                        &format!("CreationPolicy is not permitted within Module resource '{}'", name),
                        m,
                        name,
                        KEY_CREATION_POLICY,
                        None,
                    ));
                }
                if res.get(FIELD_UPDATE_POLICY).is_some_and(|v| !v.is_null()) {
                    out.push(make_resource_diagnostic(
                        "E5001",
                        &format!("UpdatePolicy is not permitted within Module resource '{}'", name),
                        m,
                        name,
                        KEY_UPDATE_POLICY,
                        None,
                    ));
                }
            }
        }
    }

    for name in m.resources_of_type("AWS::Lambda::Function") {
        if is_zip_deployment(m, name) {
            // Report a single diagnostic listing every missing property, anchored
            // at the Code property (CloudFormation rejects the resource once, not
            // once per property). Collect them and emit one finding rather than
            // one per property.
            let missing: Vec<&str> = ctx
                .cached_data
                .rule_tables
                .lambda_zip_required_properties
                .iter()
                .map(|s| s.as_str())
                .filter(|prop| {
                    !m.resources.get(name.as_str()).map(|r| r.properties.contains_key(*prop)).unwrap_or(false)
                })
                .collect();
            if !missing.is_empty() {
                let formatted = missing.iter().map(|p| format!("'{}'", p)).collect::<Vec<_>>().join(", ");
                out.push(make_resource_diagnostic(
                    "W2533",
                    &format!(
                        "Properties [{}] missing for zip file deployment at Resources/{}/Properties",
                        formatted, name
                    ),
                    m,
                    name,
                    "Properties.Code",
                    Some("Add the missing properties for zip file deployment"),
                ));
            }
        }
    }

    for name in m.resources_of_type("AWS::SQS::Queue") {
        if let Some(serde_json::Value::Bool(true)) = resolve_concrete(m, name, "Properties.FifoQueue")
            && let Some(serde_json::Value::String(qname)) = resolve_concrete(m, name, "Properties.QueueName")
            && !qname.ends_with(".fifo")
        {
            out.push(make_resource_diagnostic(
                "E2504",
                &format!("FIFO queue name '{}' must end with '.fifo'", qname),
                m,
                name,
                "Properties.QueueName",
                None,
            ));
        }
    }

    out
}

fn is_zip_deployment(m: &SemanticModel, name: &str) -> bool {
    if let Some(serde_json::Value::String(pt)) = resolve_concrete(m, name, "Properties.PackageType") {
        return pt == "Zip";
    }
    if !m.resources.get(name).map(|r| r.properties.contains_key("PackageType")).unwrap_or(false)
        && let Some(serde_json::Value::Object(code)) = resolve_concrete(m, name, "Properties.Code")
    {
        return code.contains_key("ZipFile") || code.contains_key("S3Key");
    }
    false
}