cloudformation-validate-template-model 1.14.0

AWS CloudFormation Validate
Documentation
use crate::SemanticModel;
use crate::resolved_value_to_json;
use crate::resolver::ResolvedValue;
use serde_json::{Map, Value};
use std::collections::{BTreeSet, HashMap};

const ATTRIBUTE_DEFINITIONS: &str = "AttributeDefinitions";
const ATTRIBUTE_NAME: &str = "AttributeName";
const BILLING_MODE: &str = "BillingMode";
const GLOBAL_SECONDARY_INDEXES: &str = "GlobalSecondaryIndexes";
const KEY_SCHEMA: &str = "KeySchema";
const LOCAL_SECONDARY_INDEXES: &str = "LocalSecondaryIndexes";
const PROVISIONED_THROUGHPUT: &str = "ProvisionedThroughput";

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct DynamoDbAttributeMismatch {
    pub missing: Vec<String>,
    pub unused: Vec<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DynamoDbScenarioAnalysis {
    pub attribute_mismatches: Vec<DynamoDbAttributeMismatch>,
    pub explicit_provisioned_missing_throughput: bool,
    pub default_provisioned_missing_throughput: bool,
}

pub fn analyze_dynamodb_table_scenarios(model: &SemanticModel, resource_id: &str) -> DynamoDbScenarioAnalysis {
    let mut analysis = DynamoDbScenarioAnalysis::default();
    let mut attribute_mismatches = BTreeSet::new();

    let scenarios = model.resolve_properties_scenarios_projected(
        resource_id,
        &[
            ATTRIBUTE_DEFINITIONS,
            BILLING_MODE,
            GLOBAL_SECONDARY_INDEXES,
            KEY_SCHEMA,
            LOCAL_SECONDARY_INDEXES,
            PROVISIONED_THROUGHPUT,
        ],
    );
    for (properties, conditions) in scenarios.iter() {
        if !scenario_is_reachable(model, resource_id, conditions) {
            continue;
        }
        let Some(properties) = properties_object(properties) else {
            continue;
        };

        let throughput_missing = effective_property(&properties, PROVISIONED_THROUGHPUT).is_none();
        match effective_property(&properties, BILLING_MODE) {
            None if throughput_missing => analysis.default_provisioned_missing_throughput = true,
            Some(Value::String(mode)) if mode == "PROVISIONED" && throughput_missing => {
                analysis.explicit_provisioned_missing_throughput = true;
            }
            _ => {}
        }

        let Some(defined) = attribute_definitions(properties.get(ATTRIBUTE_DEFINITIONS)) else {
            continue;
        };
        let mut referenced = BTreeSet::new();
        let table_schema_complete = collect_key_schema(properties.get(KEY_SCHEMA), &mut referenced);
        let global_indexes_complete =
            collect_index_key_schemas(properties.get(GLOBAL_SECONDARY_INDEXES), &mut referenced);
        let local_indexes_complete =
            collect_index_key_schemas(properties.get(LOCAL_SECONDARY_INDEXES), &mut referenced);

        let missing: Vec<String> = referenced.difference(&defined).cloned().collect();
        let unused: Vec<String> = if table_schema_complete && global_indexes_complete && local_indexes_complete {
            defined.difference(&referenced).cloned().collect()
        } else {
            Vec::new()
        };
        if !missing.is_empty() || !unused.is_empty() {
            attribute_mismatches.insert(DynamoDbAttributeMismatch { missing, unused });
        }
    }

    analysis.attribute_mismatches = attribute_mismatches.into_iter().collect();
    analysis
}

fn properties_object(properties: &ResolvedValue) -> Option<Map<String, Value>> {
    match properties {
        ResolvedValue::Map { .. } | ResolvedValue::Concrete { .. } => {
            resolved_value_to_json(properties).as_object().cloned()
        }
        _ => None,
    }
}

fn effective_property<'a>(properties: &'a Map<String, Value>, name: &str) -> Option<&'a Value> {
    properties.get(name).filter(|value| !value.is_null())
}

fn attribute_definitions(value: Option<&Value>) -> Option<BTreeSet<String>> {
    let definitions = value?.as_array()?;
    let mut names = BTreeSet::new();
    for definition in definitions {
        if definition.is_null() {
            continue;
        }
        names.insert(definition.get(ATTRIBUTE_NAME)?.as_str()?.to_string());
    }
    Some(names)
}

fn collect_key_schema(value: Option<&Value>, referenced: &mut BTreeSet<String>) -> bool {
    let Some(keys) = value.and_then(Value::as_array) else {
        return false;
    };
    let mut complete = true;
    for key in keys {
        if key.is_null() {
            continue;
        }
        if let Some(name) = key.get(ATTRIBUTE_NAME).and_then(Value::as_str) {
            referenced.insert(name.to_string());
        } else {
            complete = false;
        }
    }
    complete
}

fn collect_index_key_schemas(value: Option<&Value>, referenced: &mut BTreeSet<String>) -> bool {
    let Some(value) = value else { return true };
    if value.is_null() {
        return true;
    }
    let Some(indexes) = value.as_array() else {
        return false;
    };
    let mut complete = true;
    for index in indexes {
        if index.is_null() {
            continue;
        }
        if !collect_key_schema(index.get(KEY_SCHEMA), referenced) {
            complete = false;
        }
    }
    complete
}

fn scenario_is_reachable(model: &SemanticModel, resource_id: &str, conditions: &HashMap<String, bool>) -> bool {
    let mut assumptions: Vec<(String, bool)> = conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
    if let Some(resource_condition) = model.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
    {
        match conditions.get(resource_condition) {
            Some(false) => return false,
            Some(true) => {}
            None => assumptions.push((resource_condition.clone(), true)),
        }
    }
    model.conditions.is_satisfiable(&assumptions)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn analyze(template: &str, resource_id: &str) -> DynamoDbScenarioAnalysis {
        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
        analyze_dynamodb_table_scenarios(&model, resource_id)
    }

    #[test]
    fn conditional_index_absence_exposes_unused_definitions() {
        let analysis = analyze(
            r#"
Conditions:
  HasIndex: !Equals [!Ref AWS::Region, us-east-1]
Resources:
  Table:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - {AttributeName: pk, AttributeType: S}
        - {AttributeName: index_pk, AttributeType: S}
      KeySchema: [{AttributeName: pk, KeyType: HASH}]
      GlobalSecondaryIndexes: !If
        - HasIndex
        - - IndexName: by-index
            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
            Projection: {ProjectionType: ALL}
        - !Ref AWS::NoValue
"#,
            "Table",
        );
        assert_eq!(
            analysis.attribute_mismatches,
            [DynamoDbAttributeMismatch { missing: vec![], unused: vec!["index_pk".to_string()] }]
        );
    }

    #[test]
    fn correlated_index_and_definitions_are_valid_in_every_world() {
        let analysis = analyze(
            r#"
Conditions:
  HasIndex: !Equals [!Ref AWS::Region, us-east-1]
Resources:
  Table:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions: !If
        - HasIndex
        - - {AttributeName: pk, AttributeType: S}
          - {AttributeName: index_pk, AttributeType: S}
        - - {AttributeName: pk, AttributeType: S}
      KeySchema: [{AttributeName: pk, KeyType: HASH}]
      GlobalSecondaryIndexes: !If
        - HasIndex
        - - IndexName: by-index
            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
            Projection: {ProjectionType: ALL}
        - !Ref AWS::NoValue
"#,
            "Table",
        );
        assert!(analysis.attribute_mismatches.is_empty());
    }

    #[test]
    fn throughput_requirement_follows_whole_properties_branches() {
        let template = r#"
Conditions:
  OnDemand: !Equals [!Ref AWS::Region, us-east-1]
Resources:
  Valid:
    Type: AWS::DynamoDB::Table
    Properties: !If
      - OnDemand
      - BillingMode: PAY_PER_REQUEST
      - ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1}
  Invalid:
    Type: AWS::DynamoDB::Table
    Properties: !If
      - OnDemand
      - BillingMode: PAY_PER_REQUEST
      - {}
"#;
        let valid = analyze(template, "Valid");
        assert!(!valid.explicit_provisioned_missing_throughput);
        assert!(!valid.default_provisioned_missing_throughput);
        let invalid = analyze(template, "Invalid");
        assert!(!invalid.explicit_provisioned_missing_throughput);
        assert!(invalid.default_provisioned_missing_throughput);
    }

    #[test]
    fn unknown_index_content_preserves_known_missing_checks_but_not_unused_checks() {
        let analysis = analyze(
            r#"
Parameters:
  Indexes:
    Type: String
Resources:
  Table:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - {AttributeName: pk, AttributeType: S}
        - {AttributeName: maybe_used, AttributeType: S}
      KeySchema:
        - {AttributeName: pk, KeyType: HASH}
        - {AttributeName: missing, KeyType: RANGE}
      GlobalSecondaryIndexes: !Ref Indexes
"#,
            "Table",
        );
        assert_eq!(
            analysis.attribute_mismatches,
            [DynamoDbAttributeMismatch { missing: vec!["missing".to_string()], unused: vec![] }]
        );
    }

    #[test]
    fn resource_condition_excludes_incompatible_index_and_throughput_worlds() {
        let template = r#"
Conditions:
  IsPresent: !Equals [!Ref AWS::Region, us-east-1]
Resources:
  Table:
    Type: AWS::DynamoDB::Table
    Condition: IsPresent
    Properties: !If
      - IsPresent
      - BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - {AttributeName: pk, AttributeType: S}
          - {AttributeName: index_pk, AttributeType: S}
        KeySchema: [{AttributeName: pk, KeyType: HASH}]
        GlobalSecondaryIndexes:
          - IndexName: by-index
            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
            Projection: {ProjectionType: ALL}
      - AttributeDefinitions:
          - {AttributeName: pk, AttributeType: S}
          - {AttributeName: index_pk, AttributeType: S}
        KeySchema: [{AttributeName: pk, KeyType: HASH}]
"#;
        let analysis = analyze(template, "Table");
        assert!(analysis.attribute_mismatches.is_empty());
        assert!(!analysis.explicit_provisioned_missing_throughput);
        assert!(!analysis.default_provisioned_missing_throughput);
    }

    #[test]
    fn repeated_analysis_reuses_whole_properties_scenarios() {
        let model = SemanticModel::from_bytes(
            br#"{
                "Conditions": {"OnDemand": {"Fn::Equals": [{"Ref": "AWS::Region"}, "us-east-1"]}},
                "Resources": {"Table": {"Type": "AWS::DynamoDB::Table", "Properties": {"Fn::If": [
                    "OnDemand",
                    {"BillingMode": "PAY_PER_REQUEST"},
                    {"ProvisionedThroughput": {"ReadCapacityUnits": 1, "WriteCapacityUnits": 1}}
                ]}}}
            }"#,
        )
        .unwrap();
        let first = analyze_dynamodb_table_scenarios(&model, "Table");
        let combinations_after_first = model.scenario_combinations_used();
        assert!(combinations_after_first > 0);
        let second = analyze_dynamodb_table_scenarios(&model, "Table");
        assert_eq!(second, first);
        assert_eq!(model.scenario_combinations_used(), combinations_after_first);
    }
}