fakecloud_cloudformation/
template.rs1use serde_json::Value;
2use std::collections::HashMap;
3
4#[derive(Debug, Clone)]
6pub struct ParsedTemplate {
7 pub description: Option<String>,
8 pub resources: Vec<ResourceDefinition>,
9}
10
11#[derive(Debug, Clone)]
13pub struct ResourceDefinition {
14 pub logical_id: String,
15 pub resource_type: String,
16 pub properties: Value,
17}
18
19const 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
31pub 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
39pub 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 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
95pub 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
128fn 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 if let Some(param_val) = parameters.get(ref_name) {
141 return Value::String(param_val.clone());
142 }
143 if let Some(physical_id) = resource_physical_ids.get(ref_name) {
145 return Value::String(physical_id.clone());
146 }
147 if PSEUDO_REFS.contains(&ref_name) {
149 return Value::String(ref_name.to_string());
150 }
151 if _resources.contains_key(ref_name) {
155 return Value::String(ref_name.to_string());
156 }
157 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 for (k, v) in resource_physical_ids {
194 result = result.replace(&format!("${{{k}}}"), v);
195 }
196 return Value::String(result);
197 }
198 }
199 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, ¶ms).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 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 #[test]
416 fn parse_template_invalid_json_errors() {
417 let params = HashMap::new();
418 let result = parse_template("{not-json}", ¶ms);
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"}"#, ¶ms);
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": []}"#, ¶ms);
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":{}}}}"#, ¶ms);
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 ¶ms,
449 )
450 .unwrap();
451 assert_eq!(parsed.description.as_deref(), Some("My template"));
452 assert_eq!(parsed.resources.len(), 1);
453 }
454}