Skip to main content

template_model/
dynamodb.rs

1use crate::SemanticModel;
2use crate::resolved_value_to_json;
3use crate::resolver::ResolvedValue;
4use serde_json::{Map, Value};
5use std::collections::{BTreeSet, HashMap};
6
7const ATTRIBUTE_DEFINITIONS: &str = "AttributeDefinitions";
8const ATTRIBUTE_NAME: &str = "AttributeName";
9const BILLING_MODE: &str = "BillingMode";
10const GLOBAL_SECONDARY_INDEXES: &str = "GlobalSecondaryIndexes";
11const KEY_SCHEMA: &str = "KeySchema";
12const LOCAL_SECONDARY_INDEXES: &str = "LocalSecondaryIndexes";
13const PROVISIONED_THROUGHPUT: &str = "ProvisionedThroughput";
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub struct DynamoDbAttributeMismatch {
17    pub missing: Vec<String>,
18    pub unused: Vec<String>,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct DynamoDbScenarioAnalysis {
23    pub attribute_mismatches: Vec<DynamoDbAttributeMismatch>,
24    pub explicit_provisioned_missing_throughput: bool,
25    pub default_provisioned_missing_throughput: bool,
26}
27
28pub fn analyze_dynamodb_table_scenarios(model: &SemanticModel, resource_id: &str) -> DynamoDbScenarioAnalysis {
29    let mut analysis = DynamoDbScenarioAnalysis::default();
30    let mut attribute_mismatches = BTreeSet::new();
31
32    for (properties, conditions) in model.resolve_properties_scenarios(resource_id) {
33        if !scenario_is_reachable(model, resource_id, &conditions) {
34            continue;
35        }
36        let Some(properties) = properties_object(&properties) else {
37            continue;
38        };
39
40        let throughput_missing = effective_property(&properties, PROVISIONED_THROUGHPUT).is_none();
41        match effective_property(&properties, BILLING_MODE) {
42            None if throughput_missing => analysis.default_provisioned_missing_throughput = true,
43            Some(Value::String(mode)) if mode == "PROVISIONED" && throughput_missing => {
44                analysis.explicit_provisioned_missing_throughput = true;
45            }
46            _ => {}
47        }
48
49        let Some(defined) = attribute_definitions(properties.get(ATTRIBUTE_DEFINITIONS)) else {
50            continue;
51        };
52        let mut referenced = BTreeSet::new();
53        let table_schema_complete = collect_key_schema(properties.get(KEY_SCHEMA), &mut referenced);
54        let global_indexes_complete =
55            collect_index_key_schemas(properties.get(GLOBAL_SECONDARY_INDEXES), &mut referenced);
56        let local_indexes_complete =
57            collect_index_key_schemas(properties.get(LOCAL_SECONDARY_INDEXES), &mut referenced);
58
59        let missing: Vec<String> = referenced.difference(&defined).cloned().collect();
60        let unused: Vec<String> = if table_schema_complete && global_indexes_complete && local_indexes_complete {
61            defined.difference(&referenced).cloned().collect()
62        } else {
63            Vec::new()
64        };
65        if !missing.is_empty() || !unused.is_empty() {
66            attribute_mismatches.insert(DynamoDbAttributeMismatch { missing, unused });
67        }
68    }
69
70    analysis.attribute_mismatches = attribute_mismatches.into_iter().collect();
71    analysis
72}
73
74fn properties_object(properties: &ResolvedValue) -> Option<Map<String, Value>> {
75    match properties {
76        ResolvedValue::Map { .. } | ResolvedValue::Concrete { .. } => {
77            resolved_value_to_json(properties).as_object().cloned()
78        }
79        _ => None,
80    }
81}
82
83fn effective_property<'a>(properties: &'a Map<String, Value>, name: &str) -> Option<&'a Value> {
84    properties.get(name).filter(|value| !value.is_null())
85}
86
87fn attribute_definitions(value: Option<&Value>) -> Option<BTreeSet<String>> {
88    let definitions = value?.as_array()?;
89    let mut names = BTreeSet::new();
90    for definition in definitions {
91        if definition.is_null() {
92            continue;
93        }
94        names.insert(definition.get(ATTRIBUTE_NAME)?.as_str()?.to_string());
95    }
96    Some(names)
97}
98
99fn collect_key_schema(value: Option<&Value>, referenced: &mut BTreeSet<String>) -> bool {
100    let Some(keys) = value.and_then(Value::as_array) else {
101        return false;
102    };
103    let mut complete = true;
104    for key in keys {
105        if key.is_null() {
106            continue;
107        }
108        if let Some(name) = key.get(ATTRIBUTE_NAME).and_then(Value::as_str) {
109            referenced.insert(name.to_string());
110        } else {
111            complete = false;
112        }
113    }
114    complete
115}
116
117fn collect_index_key_schemas(value: Option<&Value>, referenced: &mut BTreeSet<String>) -> bool {
118    let Some(value) = value else { return true };
119    if value.is_null() {
120        return true;
121    }
122    let Some(indexes) = value.as_array() else {
123        return false;
124    };
125    let mut complete = true;
126    for index in indexes {
127        if index.is_null() {
128            continue;
129        }
130        if !collect_key_schema(index.get(KEY_SCHEMA), referenced) {
131            complete = false;
132        }
133    }
134    complete
135}
136
137fn scenario_is_reachable(model: &SemanticModel, resource_id: &str, conditions: &HashMap<String, bool>) -> bool {
138    let mut assumptions: Vec<(String, bool)> = conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
139    if let Some(resource_condition) = model.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
140    {
141        match conditions.get(resource_condition) {
142            Some(false) => return false,
143            Some(true) => {}
144            None => assumptions.push((resource_condition.clone(), true)),
145        }
146    }
147    model.conditions.is_satisfiable(&assumptions)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn analyze(template: &str, resource_id: &str) -> DynamoDbScenarioAnalysis {
155        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
156        analyze_dynamodb_table_scenarios(&model, resource_id)
157    }
158
159    #[test]
160    fn conditional_index_absence_exposes_unused_definitions() {
161        let analysis = analyze(
162            r#"
163Conditions:
164  HasIndex: !Equals [!Ref AWS::Region, us-east-1]
165Resources:
166  Table:
167    Type: AWS::DynamoDB::Table
168    Properties:
169      BillingMode: PAY_PER_REQUEST
170      AttributeDefinitions:
171        - {AttributeName: pk, AttributeType: S}
172        - {AttributeName: index_pk, AttributeType: S}
173      KeySchema: [{AttributeName: pk, KeyType: HASH}]
174      GlobalSecondaryIndexes: !If
175        - HasIndex
176        - - IndexName: by-index
177            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
178            Projection: {ProjectionType: ALL}
179        - !Ref AWS::NoValue
180"#,
181            "Table",
182        );
183        assert_eq!(
184            analysis.attribute_mismatches,
185            [DynamoDbAttributeMismatch { missing: vec![], unused: vec!["index_pk".to_string()] }]
186        );
187    }
188
189    #[test]
190    fn correlated_index_and_definitions_are_valid_in_every_world() {
191        let analysis = analyze(
192            r#"
193Conditions:
194  HasIndex: !Equals [!Ref AWS::Region, us-east-1]
195Resources:
196  Table:
197    Type: AWS::DynamoDB::Table
198    Properties:
199      BillingMode: PAY_PER_REQUEST
200      AttributeDefinitions: !If
201        - HasIndex
202        - - {AttributeName: pk, AttributeType: S}
203          - {AttributeName: index_pk, AttributeType: S}
204        - - {AttributeName: pk, AttributeType: S}
205      KeySchema: [{AttributeName: pk, KeyType: HASH}]
206      GlobalSecondaryIndexes: !If
207        - HasIndex
208        - - IndexName: by-index
209            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
210            Projection: {ProjectionType: ALL}
211        - !Ref AWS::NoValue
212"#,
213            "Table",
214        );
215        assert!(analysis.attribute_mismatches.is_empty());
216    }
217
218    #[test]
219    fn throughput_requirement_follows_whole_properties_branches() {
220        let template = r#"
221Conditions:
222  OnDemand: !Equals [!Ref AWS::Region, us-east-1]
223Resources:
224  Valid:
225    Type: AWS::DynamoDB::Table
226    Properties: !If
227      - OnDemand
228      - BillingMode: PAY_PER_REQUEST
229      - ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1}
230  Invalid:
231    Type: AWS::DynamoDB::Table
232    Properties: !If
233      - OnDemand
234      - BillingMode: PAY_PER_REQUEST
235      - {}
236"#;
237        let valid = analyze(template, "Valid");
238        assert!(!valid.explicit_provisioned_missing_throughput);
239        assert!(!valid.default_provisioned_missing_throughput);
240        let invalid = analyze(template, "Invalid");
241        assert!(!invalid.explicit_provisioned_missing_throughput);
242        assert!(invalid.default_provisioned_missing_throughput);
243    }
244
245    #[test]
246    fn unknown_index_content_preserves_known_missing_checks_but_not_unused_checks() {
247        let analysis = analyze(
248            r#"
249Parameters:
250  Indexes:
251    Type: String
252Resources:
253  Table:
254    Type: AWS::DynamoDB::Table
255    Properties:
256      BillingMode: PAY_PER_REQUEST
257      AttributeDefinitions:
258        - {AttributeName: pk, AttributeType: S}
259        - {AttributeName: maybe_used, AttributeType: S}
260      KeySchema:
261        - {AttributeName: pk, KeyType: HASH}
262        - {AttributeName: missing, KeyType: RANGE}
263      GlobalSecondaryIndexes: !Ref Indexes
264"#,
265            "Table",
266        );
267        assert_eq!(
268            analysis.attribute_mismatches,
269            [DynamoDbAttributeMismatch { missing: vec!["missing".to_string()], unused: vec![] }]
270        );
271    }
272
273    #[test]
274    fn resource_condition_excludes_incompatible_index_and_throughput_worlds() {
275        let template = r#"
276Conditions:
277  IsPresent: !Equals [!Ref AWS::Region, us-east-1]
278Resources:
279  Table:
280    Type: AWS::DynamoDB::Table
281    Condition: IsPresent
282    Properties: !If
283      - IsPresent
284      - BillingMode: PAY_PER_REQUEST
285        AttributeDefinitions:
286          - {AttributeName: pk, AttributeType: S}
287          - {AttributeName: index_pk, AttributeType: S}
288        KeySchema: [{AttributeName: pk, KeyType: HASH}]
289        GlobalSecondaryIndexes:
290          - IndexName: by-index
291            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
292            Projection: {ProjectionType: ALL}
293      - AttributeDefinitions:
294          - {AttributeName: pk, AttributeType: S}
295          - {AttributeName: index_pk, AttributeType: S}
296        KeySchema: [{AttributeName: pk, KeyType: HASH}]
297"#;
298        let analysis = analyze(template, "Table");
299        assert!(analysis.attribute_mismatches.is_empty());
300        assert!(!analysis.explicit_provisioned_missing_throughput);
301        assert!(!analysis.default_provisioned_missing_throughput);
302    }
303
304    #[test]
305    fn repeated_analysis_reuses_whole_properties_scenarios() {
306        let model = SemanticModel::from_bytes(
307            br#"{
308                "Conditions": {"OnDemand": {"Fn::Equals": [{"Ref": "AWS::Region"}, "us-east-1"]}},
309                "Resources": {"Table": {"Type": "AWS::DynamoDB::Table", "Properties": {"Fn::If": [
310                    "OnDemand",
311                    {"BillingMode": "PAY_PER_REQUEST"},
312                    {"ProvisionedThroughput": {"ReadCapacityUnits": 1, "WriteCapacityUnits": 1}}
313                ]}}}
314            }"#,
315        )
316        .unwrap();
317        let first = analyze_dynamodb_table_scenarios(&model, "Table");
318        let combinations_after_first = model.scenario_combinations_used();
319        assert!(combinations_after_first > 0);
320        let second = analyze_dynamodb_table_scenarios(&model, "Table");
321        assert_eq!(second, first);
322        assert_eq!(model.scenario_combinations_used(), combinations_after_first);
323    }
324}