Skip to main content

fakecloud_cloudformation/
template.rs

1use serde_json::Value;
2use std::collections::HashMap;
3
4/// A parsed CloudFormation template.
5#[derive(Debug, Clone)]
6pub struct ParsedTemplate {
7    pub description: Option<String>,
8    pub resources: Vec<ResourceDefinition>,
9}
10
11/// A single resource from the template.
12#[derive(Debug, Clone)]
13pub struct ResourceDefinition {
14    pub logical_id: String,
15    pub resource_type: String,
16    pub properties: Value,
17}
18
19/// Known pseudo-references that should be passed through as-is.
20const PSEUDO_REFS: &[&str] = &[
21    "AWS::AccountId",
22    "AWS::NotificationARNs",
23    "AWS::NoValue",
24    "AWS::Partition",
25    "AWS::Region",
26    "AWS::StackId",
27    "AWS::StackName",
28    "AWS::URLSuffix",
29];
30
31/// Parse a CloudFormation template from a string (JSON or YAML).
32pub fn parse_template(
33    template_body: &str,
34    parameters: &HashMap<String, String>,
35) -> Result<ParsedTemplate, String> {
36    parse_template_with_physical_ids(template_body, parameters, &HashMap::new())
37}
38
39/// Parse a CloudFormation template, resolving Refs using known physical resource IDs.
40pub fn parse_template_with_physical_ids(
41    template_body: &str,
42    parameters: &HashMap<String, String>,
43    resource_physical_ids: &HashMap<String, String>,
44) -> Result<ParsedTemplate, String> {
45    let value: Value = if template_body.trim_start().starts_with('{') {
46        serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
47    } else {
48        serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
49    };
50
51    let description = value
52        .get("Description")
53        .and_then(|v| v.as_str())
54        .map(|s| s.to_string());
55
56    let resources_obj = value
57        .get("Resources")
58        .and_then(|v| v.as_object())
59        .ok_or("Template must contain a Resources section")?;
60
61    let mut resources = Vec::new();
62    for (logical_id, resource) in resources_obj {
63        let resource_type = resource
64            .get("Type")
65            .and_then(|v| v.as_str())
66            .ok_or(format!("Resource {logical_id} must have a Type property"))?
67            .to_string();
68
69        let properties = resource
70            .get("Properties")
71            .cloned()
72            .unwrap_or(Value::Object(serde_json::Map::new()));
73
74        // Resolve Ref and parameter substitutions in properties
75        let resolved = resolve_refs(
76            &properties,
77            parameters,
78            resources_obj,
79            resource_physical_ids,
80        );
81
82        resources.push(ResourceDefinition {
83            logical_id: logical_id.clone(),
84            resource_type,
85            properties: resolved,
86        });
87    }
88
89    Ok(ParsedTemplate {
90        description,
91        resources,
92    })
93}
94
95/// Re-resolve a single resource definition's properties with updated physical IDs.
96pub fn resolve_resource_properties(
97    resource: &ResourceDefinition,
98    template_body: &str,
99    parameters: &HashMap<String, String>,
100    resource_physical_ids: &HashMap<String, String>,
101) -> Result<ResourceDefinition, String> {
102    let value: Value = if template_body.trim_start().starts_with('{') {
103        serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
104    } else {
105        serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
106    };
107
108    let resources_obj = value
109        .get("Resources")
110        .and_then(|v| v.as_object())
111        .ok_or("Template must contain a Resources section")?;
112
113    let raw_props = resources_obj
114        .get(&resource.logical_id)
115        .and_then(|r| r.get("Properties"))
116        .cloned()
117        .unwrap_or(Value::Object(serde_json::Map::new()));
118
119    let resolved = resolve_refs(&raw_props, parameters, resources_obj, resource_physical_ids);
120
121    Ok(ResourceDefinition {
122        logical_id: resource.logical_id.clone(),
123        resource_type: resource.resource_type.clone(),
124        properties: resolved,
125    })
126}
127
128/// Resolve { "Ref": "param_name" } and { "Fn::GetAtt": [...] } in property values.
129fn resolve_refs(
130    value: &Value,
131    parameters: &HashMap<String, String>,
132    _resources: &serde_json::Map<String, Value>,
133    resource_physical_ids: &HashMap<String, String>,
134) -> Value {
135    match value {
136        Value::Object(map) => {
137            if let Some(ref_val) = map.get("Ref") {
138                if let Some(ref_name) = ref_val.as_str() {
139                    // 1. Check explicit parameters first
140                    if let Some(param_val) = parameters.get(ref_name) {
141                        return Value::String(param_val.clone());
142                    }
143                    // 2. Check already-provisioned resource physical IDs
144                    if let Some(physical_id) = resource_physical_ids.get(ref_name) {
145                        return Value::String(physical_id.clone());
146                    }
147                    // 3. Allow pseudo-references to pass through as the ref name
148                    if PSEUDO_REFS.contains(&ref_name) {
149                        return Value::String(ref_name.to_string());
150                    }
151                    // 4. If it's a known logical resource in the template but not yet
152                    //    provisioned, return the logical ID (will be resolved later
153                    //    during incremental provisioning)
154                    if _resources.contains_key(ref_name) {
155                        return Value::String(ref_name.to_string());
156                    }
157                    // 5. Unknown ref — return as-is (could be a default parameter)
158                    return Value::String(ref_name.to_string());
159                }
160            }
161            if let Some(join_val) = map.get("Fn::Join") {
162                if let Some(arr) = join_val.as_array() {
163                    if arr.len() == 2 {
164                        let delimiter = arr[0].as_str().unwrap_or("");
165                        if let Some(parts) = arr[1].as_array() {
166                            let resolved_parts: Vec<String> = parts
167                                .iter()
168                                .map(|p| {
169                                    let resolved = resolve_refs(
170                                        p,
171                                        parameters,
172                                        _resources,
173                                        resource_physical_ids,
174                                    );
175                                    match resolved {
176                                        Value::String(s) => s,
177                                        other => other.to_string(),
178                                    }
179                                })
180                                .collect();
181                            return Value::String(resolved_parts.join(delimiter));
182                        }
183                    }
184                }
185            }
186            if let Some(sub_val) = map.get("Fn::Sub") {
187                if let Some(s) = sub_val.as_str() {
188                    let mut result = s.to_string();
189                    for (k, v) in parameters {
190                        result = result.replace(&format!("${{{k}}}"), v);
191                    }
192                    // Also substitute resource physical IDs in Fn::Sub
193                    for (k, v) in resource_physical_ids {
194                        result = result.replace(&format!("${{{k}}}"), v);
195                    }
196                    return Value::String(result);
197                }
198            }
199            // Recurse into object
200            let mut new_map = serde_json::Map::new();
201            for (k, v) in map {
202                new_map.insert(
203                    k.clone(),
204                    resolve_refs(v, parameters, _resources, resource_physical_ids),
205                );
206            }
207            Value::Object(new_map)
208        }
209        Value::Array(arr) => Value::Array(
210            arr.iter()
211                .map(|v| resolve_refs(v, parameters, _resources, resource_physical_ids))
212                .collect(),
213        ),
214        other => other.clone(),
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn parse_json_template() {
224        let template = r#"{
225            "Resources": {
226                "MyQueue": {
227                    "Type": "AWS::SQS::Queue",
228                    "Properties": {
229                        "QueueName": "test-queue"
230                    }
231                }
232            }
233        }"#;
234
235        let parsed = parse_template(template, &HashMap::new()).unwrap();
236        assert_eq!(parsed.resources.len(), 1);
237        assert_eq!(parsed.resources[0].logical_id, "MyQueue");
238        assert_eq!(parsed.resources[0].resource_type, "AWS::SQS::Queue");
239    }
240
241    #[test]
242    fn parse_yaml_template() {
243        let template = r#"
244Resources:
245  MyTopic:
246    Type: AWS::SNS::Topic
247    Properties:
248      TopicName: test-topic
249"#;
250
251        let parsed = parse_template(template, &HashMap::new()).unwrap();
252        assert_eq!(parsed.resources.len(), 1);
253        assert_eq!(parsed.resources[0].logical_id, "MyTopic");
254        assert_eq!(parsed.resources[0].resource_type, "AWS::SNS::Topic");
255    }
256
257    #[test]
258    fn resolve_ref_parameters() {
259        let template = r#"{
260            "Resources": {
261                "MyQueue": {
262                    "Type": "AWS::SQS::Queue",
263                    "Properties": {
264                        "QueueName": { "Ref": "QueueNameParam" }
265                    }
266                }
267            }
268        }"#;
269
270        let mut params = HashMap::new();
271        params.insert("QueueNameParam".to_string(), "resolved-queue".to_string());
272        let parsed = parse_template(template, &params).unwrap();
273        assert_eq!(
274            parsed.resources[0].properties["QueueName"],
275            Value::String("resolved-queue".to_string())
276        );
277    }
278
279    #[test]
280    fn ref_resolves_physical_id_over_logical_id() {
281        let template = r#"{
282            "Resources": {
283                "MyTopic": {
284                    "Type": "AWS::SNS::Topic",
285                    "Properties": {
286                        "TopicName": "my-topic"
287                    }
288                },
289                "MySub": {
290                    "Type": "AWS::SNS::Subscription",
291                    "Properties": {
292                        "TopicArn": { "Ref": "MyTopic" },
293                        "Protocol": "sqs",
294                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
295                    }
296                }
297            }
298        }"#;
299
300        let mut physical_ids = HashMap::new();
301        physical_ids.insert(
302            "MyTopic".to_string(),
303            "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
304        );
305
306        let parsed =
307            parse_template_with_physical_ids(template, &HashMap::new(), &physical_ids).unwrap();
308        let sub = parsed
309            .resources
310            .iter()
311            .find(|r| r.logical_id == "MySub")
312            .unwrap();
313        assert_eq!(
314            sub.properties["TopicArn"],
315            Value::String("arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
316        );
317    }
318
319    #[test]
320    fn ref_without_physical_id_returns_logical_id_for_known_resource() {
321        let template = r#"{
322            "Resources": {
323                "MyTopic": {
324                    "Type": "AWS::SNS::Topic",
325                    "Properties": {
326                        "TopicName": "my-topic"
327                    }
328                },
329                "MySub": {
330                    "Type": "AWS::SNS::Subscription",
331                    "Properties": {
332                        "TopicArn": { "Ref": "MyTopic" },
333                        "Protocol": "sqs",
334                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
335                    }
336                }
337            }
338        }"#;
339
340        // No physical IDs yet — logical ID returned for known resources
341        let parsed = parse_template(template, &HashMap::new()).unwrap();
342        let sub = parsed
343            .resources
344            .iter()
345            .find(|r| r.logical_id == "MySub")
346            .unwrap();
347        assert_eq!(
348            sub.properties["TopicArn"],
349            Value::String("MyTopic".to_string())
350        );
351    }
352
353    #[test]
354    fn pseudo_ref_passes_through() {
355        let template = r#"{
356            "Resources": {
357                "MyQueue": {
358                    "Type": "AWS::SQS::Queue",
359                    "Properties": {
360                        "QueueName": { "Ref": "AWS::StackName" }
361                    }
362                }
363            }
364        }"#;
365
366        let parsed = parse_template(template, &HashMap::new()).unwrap();
367        assert_eq!(
368            parsed.resources[0].properties["QueueName"],
369            Value::String("AWS::StackName".to_string())
370        );
371    }
372
373    #[test]
374    fn fn_sub_resolves_physical_ids() {
375        let template = r#"{
376            "Resources": {
377                "MyTopic": {
378                    "Type": "AWS::SNS::Topic",
379                    "Properties": {
380                        "TopicName": "my-topic"
381                    }
382                },
383                "MyParam": {
384                    "Type": "AWS::SSM::Parameter",
385                    "Properties": {
386                        "Name": "/app/topic",
387                        "Type": "String",
388                        "Value": { "Fn::Sub": "Topic is ${MyTopic}" }
389                    }
390                }
391            }
392        }"#;
393
394        let mut physical_ids = HashMap::new();
395        physical_ids.insert(
396            "MyTopic".to_string(),
397            "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
398        );
399
400        let parsed =
401            parse_template_with_physical_ids(template, &HashMap::new(), &physical_ids).unwrap();
402        let param = parsed
403            .resources
404            .iter()
405            .find(|r| r.logical_id == "MyParam")
406            .unwrap();
407        assert_eq!(
408            param.properties["Value"],
409            Value::String("Topic is arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
410        );
411    }
412
413    // ── error paths ──
414
415    #[test]
416    fn parse_template_invalid_json_errors() {
417        let params = HashMap::new();
418        let result = parse_template("{not-json}", &params);
419        assert!(result.is_err());
420    }
421
422    #[test]
423    fn parse_template_missing_resources_errors() {
424        let params = HashMap::new();
425        let result = parse_template(r#"{"Description":"no resources"}"#, &params);
426        assert!(result.is_err());
427    }
428
429    #[test]
430    fn parse_template_resources_not_object_errors() {
431        let params = HashMap::new();
432        let result = parse_template(r#"{"Resources": []}"#, &params);
433        assert!(result.is_err());
434    }
435
436    #[test]
437    fn parse_template_missing_type_errors() {
438        let params = HashMap::new();
439        let result = parse_template(r#"{"Resources":{"R":{"Properties":{}}}}"#, &params);
440        assert!(result.is_err());
441    }
442
443    #[test]
444    fn parse_template_with_description() {
445        let params = HashMap::new();
446        let parsed = parse_template(
447            r#"{"Description":"My template","Resources":{"R":{"Type":"AWS::SQS::Queue"}}}"#,
448            &params,
449        )
450        .unwrap();
451        assert_eq!(parsed.description.as_deref(), Some("My template"));
452        assert_eq!(parsed.resources.len(), 1);
453    }
454}