1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use fakecloud_core::delivery::DeliveryBus;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_dynamodb::state::SharedDynamoDbState;
10use fakecloud_eventbridge::state::SharedEventBridgeState;
11use fakecloud_iam::state::SharedIamState;
12use fakecloud_logs::state::SharedLogsState;
13use fakecloud_s3::state::SharedS3State;
14use fakecloud_sns::state::SharedSnsState;
15use fakecloud_sqs::state::SharedSqsState;
16use fakecloud_ssm::state::SharedSsmState;
17
18use crate::resource_provisioner::ResourceProvisioner;
19use crate::state::{SharedCloudFormationState, Stack};
20use crate::template;
21use crate::xml_responses;
22
23pub struct CloudFormationService {
24 state: SharedCloudFormationState,
25 sqs_state: SharedSqsState,
26 sns_state: SharedSnsState,
27 ssm_state: SharedSsmState,
28 iam_state: SharedIamState,
29 s3_state: SharedS3State,
30 eventbridge_state: SharedEventBridgeState,
31 dynamodb_state: SharedDynamoDbState,
32 logs_state: SharedLogsState,
33 delivery: Arc<DeliveryBus>,
34}
35
36impl CloudFormationService {
37 #[allow(clippy::too_many_arguments)]
38 pub fn new(
39 state: SharedCloudFormationState,
40 sqs_state: SharedSqsState,
41 sns_state: SharedSnsState,
42 ssm_state: SharedSsmState,
43 iam_state: SharedIamState,
44 s3_state: SharedS3State,
45 eventbridge_state: SharedEventBridgeState,
46 dynamodb_state: SharedDynamoDbState,
47 logs_state: SharedLogsState,
48 delivery: Arc<DeliveryBus>,
49 ) -> Self {
50 Self {
51 state,
52 sqs_state,
53 sns_state,
54 ssm_state,
55 iam_state,
56 s3_state,
57 eventbridge_state,
58 dynamodb_state,
59 logs_state,
60 delivery,
61 }
62 }
63
64 fn provisioner(&self, stack_id: &str) -> ResourceProvisioner {
65 let cf_state = self.state.read();
66 ResourceProvisioner {
67 sqs_state: self.sqs_state.clone(),
68 sns_state: self.sns_state.clone(),
69 ssm_state: self.ssm_state.clone(),
70 iam_state: self.iam_state.clone(),
71 s3_state: self.s3_state.clone(),
72 eventbridge_state: self.eventbridge_state.clone(),
73 dynamodb_state: self.dynamodb_state.clone(),
74 logs_state: self.logs_state.clone(),
75 delivery: self.delivery.clone(),
76 account_id: cf_state.account_id.clone(),
77 region: cf_state.region.clone(),
78 stack_id: stack_id.to_string(),
79 }
80 }
81
82 fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
83 if let Some(v) = req.query_params.get(key) {
85 return Some(v.clone());
86 }
87 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
89 body_params.get(key).cloned()
90 }
91
92 fn get_all_params(req: &AwsRequest) -> HashMap<String, String> {
93 let mut params = req.query_params.clone();
94 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
95 for (k, v) in body_params {
96 params.entry(k).or_insert(v);
97 }
98 params
99 }
100
101 fn extract_tags(params: &HashMap<String, String>) -> HashMap<String, String> {
102 let mut tags = HashMap::new();
103 for i in 1.. {
104 let key_param = format!("Tags.member.{i}.Key");
105 let value_param = format!("Tags.member.{i}.Value");
106 match (params.get(&key_param), params.get(&value_param)) {
107 (Some(k), Some(v)) => {
108 tags.insert(k.clone(), v.clone());
109 }
110 _ => break,
111 }
112 }
113 tags
114 }
115
116 fn extract_parameters(params: &HashMap<String, String>) -> HashMap<String, String> {
117 let mut result = HashMap::new();
118 for i in 1.. {
119 let key_param = format!("Parameters.member.{i}.ParameterKey");
120 let value_param = format!("Parameters.member.{i}.ParameterValue");
121 match (params.get(&key_param), params.get(&value_param)) {
122 (Some(k), Some(v)) => {
123 result.insert(k.clone(), v.clone());
124 }
125 _ => break,
126 }
127 }
128 result
129 }
130
131 fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
132 let params = Self::get_all_params(req);
133
134 let stack_name = params.get("StackName").ok_or_else(|| {
135 AwsServiceError::aws_error(
136 StatusCode::BAD_REQUEST,
137 "ValidationError",
138 "StackName is required",
139 )
140 })?;
141
142 let template_body = params.get("TemplateBody").ok_or_else(|| {
143 AwsServiceError::aws_error(
144 StatusCode::BAD_REQUEST,
145 "ValidationError",
146 "TemplateBody is required",
147 )
148 })?;
149
150 {
152 let state = self.state.read();
153 if let Some(existing) = state.stacks.get(stack_name.as_str()) {
154 if existing.status != "DELETE_COMPLETE" {
155 return Err(AwsServiceError::aws_error(
156 StatusCode::BAD_REQUEST,
157 "AlreadyExistsException",
158 format!("Stack [{stack_name}] already exists"),
159 ));
160 }
161 }
162 }
163
164 let tags = Self::extract_tags(¶ms);
165 let parameters = Self::extract_parameters(¶ms);
166
167 let parsed = template::parse_template(template_body, ¶meters).map_err(|e| {
169 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
170 })?;
171
172 let stack_id = {
173 let state = self.state.read();
174 format!(
175 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
176 state.region,
177 state.account_id,
178 stack_name,
179 uuid::Uuid::new_v4()
180 )
181 };
182
183 let provisioner = self.provisioner(&stack_id);
184 let mut resources = Vec::new();
185 let mut physical_ids: HashMap<String, String> = HashMap::new();
186
187 let mut pending: Vec<&template::ResourceDefinition> = parsed.resources.iter().collect();
191 let max_passes = pending.len() + 1;
192 for _ in 0..max_passes {
193 if pending.is_empty() {
194 break;
195 }
196 let mut still_pending = Vec::new();
197 let mut made_progress = false;
198
199 for resource_def in pending {
200 let resolved_def = template::resolve_resource_properties(
201 resource_def,
202 template_body,
203 ¶meters,
204 &physical_ids,
205 )
206 .map_err(|e| {
207 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
208 })?;
209
210 match provisioner.create_resource(&resolved_def) {
211 Ok(stack_resource) => {
212 physical_ids.insert(
213 stack_resource.logical_id.clone(),
214 stack_resource.physical_id.clone(),
215 );
216 resources.push(stack_resource);
217 made_progress = true;
218 }
219 Err(_) => {
220 still_pending.push(resource_def);
221 }
222 }
223 }
224
225 pending = still_pending;
226 if !made_progress && !pending.is_empty() {
227 let resource_def = pending[0];
229 let resolved_def = template::resolve_resource_properties(
230 resource_def,
231 template_body,
232 ¶meters,
233 &physical_ids,
234 )
235 .unwrap_or_else(|_| resource_def.clone());
236 let err = provisioner.create_resource(&resolved_def).unwrap_err();
237 for r in &resources {
239 let _ = provisioner.delete_resource(r);
240 }
241 return Err(AwsServiceError::aws_error(
242 StatusCode::BAD_REQUEST,
243 "ValidationError",
244 format!(
245 "Failed to create resource {}: {err}",
246 resource_def.logical_id
247 ),
248 ));
249 }
250 }
251
252 let stack = Stack {
253 name: stack_name.clone(),
254 stack_id: stack_id.clone(),
255 template: template_body.clone(),
256 status: "CREATE_COMPLETE".to_string(),
257 resources,
258 parameters,
259 tags,
260 created_at: Utc::now(),
261 updated_at: None,
262 description: parsed.description,
263 };
264
265 {
266 let mut state = self.state.write();
267 state.stacks.insert(stack_name.clone(), stack);
268 }
269
270 Ok(AwsResponse::xml(
271 StatusCode::OK,
272 xml_responses::create_stack_response(&stack_id, &req.request_id),
273 ))
274 }
275
276 fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
277 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
278 AwsServiceError::aws_error(
279 StatusCode::BAD_REQUEST,
280 "ValidationError",
281 "StackName is required",
282 )
283 })?;
284
285 let mut state = self.state.write();
286
287 let stack = state.stacks.values_mut().find(|s| {
289 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
290 });
291
292 if let Some(stack) = stack {
293 let stack_id = stack.stack_id.clone();
294 let resources: Vec<_> = stack.resources.clone();
295
296 drop(state);
299 let provisioner = self.provisioner(&stack_id);
300
301 for resource in resources.iter().rev() {
303 let _ = provisioner.delete_resource(resource);
304 }
305
306 let mut state = self.state.write();
308 if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
309 stack.status = "DELETE_COMPLETE".to_string();
310 stack.resources.clear();
311 }
312 }
313
314 Ok(AwsResponse::xml(
315 StatusCode::OK,
316 xml_responses::delete_stack_response(&req.request_id),
317 ))
318 }
319
320 fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
321 let stack_name = Self::get_param(req, "StackName");
322
323 let state = self.state.read();
324 let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
325 state
326 .stacks
327 .values()
328 .filter(|s| {
329 (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
330 })
331 .cloned()
332 .collect()
333 } else {
334 state
335 .stacks
336 .values()
337 .filter(|s| s.status != "DELETE_COMPLETE")
338 .cloned()
339 .collect()
340 };
341
342 if let Some(ref name) = stack_name {
343 if stacks.is_empty() {
344 return Err(AwsServiceError::aws_error(
345 StatusCode::BAD_REQUEST,
346 "ValidationError",
347 format!("Stack with id {name} does not exist"),
348 ));
349 }
350 }
351
352 Ok(AwsResponse::xml(
353 StatusCode::OK,
354 xml_responses::describe_stacks_response(&stacks, &req.request_id),
355 ))
356 }
357
358 fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
359 let state = self.state.read();
360 let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
361
362 Ok(AwsResponse::xml(
363 StatusCode::OK,
364 xml_responses::list_stacks_response(&stacks, &req.request_id),
365 ))
366 }
367
368 fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
369 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
370 AwsServiceError::aws_error(
371 StatusCode::BAD_REQUEST,
372 "ValidationError",
373 "StackName is required",
374 )
375 })?;
376
377 let state = self.state.read();
378 let stack = state
379 .stacks
380 .values()
381 .find(|s| {
382 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
383 })
384 .ok_or_else(|| {
385 AwsServiceError::aws_error(
386 StatusCode::BAD_REQUEST,
387 "ValidationError",
388 format!("Stack [{stack_name}] does not exist"),
389 )
390 })?;
391
392 Ok(AwsResponse::xml(
393 StatusCode::OK,
394 xml_responses::list_stack_resources_response(&stack.resources, &req.request_id),
395 ))
396 }
397
398 fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
399 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
400 AwsServiceError::aws_error(
401 StatusCode::BAD_REQUEST,
402 "ValidationError",
403 "StackName is required",
404 )
405 })?;
406
407 let state = self.state.read();
408 let stack = state
409 .stacks
410 .values()
411 .find(|s| {
412 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
413 })
414 .ok_or_else(|| {
415 AwsServiceError::aws_error(
416 StatusCode::BAD_REQUEST,
417 "ValidationError",
418 format!("Stack [{stack_name}] does not exist"),
419 )
420 })?;
421
422 Ok(AwsResponse::xml(
423 StatusCode::OK,
424 xml_responses::describe_stack_resources_response(
425 &stack.resources,
426 &stack.name,
427 &req.request_id,
428 ),
429 ))
430 }
431
432 fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
433 let params = Self::get_all_params(req);
434
435 let stack_name = params.get("StackName").ok_or_else(|| {
436 AwsServiceError::aws_error(
437 StatusCode::BAD_REQUEST,
438 "ValidationError",
439 "StackName is required",
440 )
441 })?;
442
443 let template_body = params.get("TemplateBody").ok_or_else(|| {
444 AwsServiceError::aws_error(
445 StatusCode::BAD_REQUEST,
446 "ValidationError",
447 "TemplateBody is required",
448 )
449 })?;
450
451 let new_parameters = Self::extract_parameters(¶ms);
452 let new_tags = Self::extract_tags(¶ms);
453
454 let parsed = template::parse_template(template_body, &new_parameters).map_err(|e| {
455 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
456 })?;
457
458 let found_stack_id = {
460 let state = self.state.read();
461 state
462 .stacks
463 .values()
464 .find(|s| {
465 (s.name == *stack_name || s.stack_id == *stack_name)
466 && s.status != "DELETE_COMPLETE"
467 })
468 .map(|s| s.stack_id.clone())
469 .unwrap_or_default()
470 };
471
472 let provisioner = self.provisioner(&found_stack_id);
473
474 let mut state = self.state.write();
475 let stack = state
476 .stacks
477 .values_mut()
478 .find(|s| {
479 (s.name == *stack_name || s.stack_id == *stack_name)
480 && s.status != "DELETE_COMPLETE"
481 })
482 .ok_or_else(|| {
483 AwsServiceError::aws_error(
484 StatusCode::BAD_REQUEST,
485 "ValidationError",
486 format!("Stack [{stack_name}] does not exist"),
487 )
488 })?;
489
490 let old_logical_ids: std::collections::HashSet<String> = stack
492 .resources
493 .iter()
494 .map(|r| r.logical_id.clone())
495 .collect();
496 let new_logical_ids: std::collections::HashSet<String> = parsed
497 .resources
498 .iter()
499 .map(|r| r.logical_id.clone())
500 .collect();
501
502 let to_remove: Vec<_> = stack
504 .resources
505 .iter()
506 .filter(|r| !new_logical_ids.contains(&r.logical_id))
507 .cloned()
508 .collect();
509 for resource in &to_remove {
510 let _ = provisioner.delete_resource(resource);
511 }
512 stack
513 .resources
514 .retain(|r| new_logical_ids.contains(&r.logical_id));
515
516 let mut physical_ids: HashMap<String, String> = stack
518 .resources
519 .iter()
520 .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
521 .collect();
522
523 let mut update_failed = false;
525 let mut update_error_msg = String::new();
526 for resource_def in &parsed.resources {
527 if !old_logical_ids.contains(&resource_def.logical_id) {
528 let resolved_def = match template::resolve_resource_properties(
529 resource_def,
530 template_body,
531 &new_parameters,
532 &physical_ids,
533 ) {
534 Ok(d) => d,
535 Err(e) => {
536 update_failed = true;
537 update_error_msg = format!(
538 "Failed to resolve resource {}: {e}",
539 resource_def.logical_id
540 );
541 continue;
542 }
543 };
544 match provisioner.create_resource(&resolved_def) {
545 Ok(stack_resource) => {
546 physical_ids.insert(
547 stack_resource.logical_id.clone(),
548 stack_resource.physical_id.clone(),
549 );
550 stack.resources.push(stack_resource);
551 }
552 Err(e) => {
553 tracing::warn!(
554 "Failed to create resource {} during update: {e}",
555 resource_def.logical_id
556 );
557 update_failed = true;
558 update_error_msg =
559 format!("Failed to create resource {}: {e}", resource_def.logical_id);
560 }
561 }
562 }
563 }
564
565 let stack_id = stack.stack_id.clone();
566 stack.template = template_body.clone();
567 stack.status = if update_failed {
568 "UPDATE_FAILED".to_string()
569 } else {
570 "UPDATE_COMPLETE".to_string()
571 };
572 stack.parameters = new_parameters;
573 if !new_tags.is_empty() {
574 stack.tags = new_tags;
575 }
576 stack.updated_at = Some(Utc::now());
577 stack.description = parsed.description;
578
579 if update_failed {
580 return Err(AwsServiceError::aws_error(
581 StatusCode::BAD_REQUEST,
582 "ValidationError",
583 update_error_msg,
584 ));
585 }
586
587 Ok(AwsResponse::xml(
588 StatusCode::OK,
589 xml_responses::update_stack_response(&stack_id, &req.request_id),
590 ))
591 }
592
593 fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
594 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
595 AwsServiceError::aws_error(
596 StatusCode::BAD_REQUEST,
597 "ValidationError",
598 "StackName is required",
599 )
600 })?;
601
602 let state = self.state.read();
603 let stack = state
604 .stacks
605 .values()
606 .find(|s| {
607 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
608 })
609 .ok_or_else(|| {
610 AwsServiceError::aws_error(
611 StatusCode::BAD_REQUEST,
612 "ValidationError",
613 format!("Stack [{stack_name}] does not exist"),
614 )
615 })?;
616
617 Ok(AwsResponse::xml(
618 StatusCode::OK,
619 xml_responses::get_template_response(&stack.template, &req.request_id),
620 ))
621 }
622}
623
624#[async_trait]
625impl AwsService for CloudFormationService {
626 fn service_name(&self) -> &str {
627 "cloudformation"
628 }
629
630 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
631 match req.action.as_str() {
632 "CreateStack" => self.create_stack(&req),
633 "DeleteStack" => self.delete_stack(&req),
634 "DescribeStacks" => self.describe_stacks(&req),
635 "ListStacks" => self.list_stacks(&req),
636 "ListStackResources" => self.list_stack_resources(&req),
637 "DescribeStackResources" => self.describe_stack_resources(&req),
638 "UpdateStack" => self.update_stack(&req),
639 "GetTemplate" => self.get_template(&req),
640 _ => Err(AwsServiceError::action_not_implemented(
641 "cloudformation",
642 &req.action,
643 )),
644 }
645 }
646
647 fn supported_actions(&self) -> &[&str] {
648 &[
649 "CreateStack",
650 "DeleteStack",
651 "DescribeStacks",
652 "ListStacks",
653 "ListStackResources",
654 "DescribeStackResources",
655 "UpdateStack",
656 "GetTemplate",
657 ]
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::state::CloudFormationState;
665 use http::HeaderMap;
666 use parking_lot::RwLock;
667 use std::sync::Arc;
668
669 fn make_service() -> CloudFormationService {
670 let cf_state = Arc::new(RwLock::new(CloudFormationState::new(
671 "123456789012",
672 "us-east-1",
673 )));
674 CloudFormationService::new(
675 cf_state,
676 Arc::new(RwLock::new(fakecloud_sqs::state::SqsState::new(
677 "123456789012",
678 "us-east-1",
679 "http://localhost:4566",
680 ))),
681 Arc::new(RwLock::new(fakecloud_sns::state::SnsState::new(
682 "123456789012",
683 "us-east-1",
684 "http://localhost:4566",
685 ))),
686 Arc::new(RwLock::new(fakecloud_ssm::state::SsmState::new(
687 "123456789012",
688 "us-east-1",
689 ))),
690 Arc::new(RwLock::new(fakecloud_iam::state::IamState::new(
691 "123456789012",
692 ))),
693 Arc::new(RwLock::new(fakecloud_s3::state::S3State::new(
694 "123456789012",
695 "us-east-1",
696 ))),
697 Arc::new(RwLock::new(
698 fakecloud_eventbridge::state::EventBridgeState::new("123456789012", "us-east-1"),
699 )),
700 Arc::new(RwLock::new(fakecloud_dynamodb::state::DynamoDbState::new(
701 "123456789012",
702 "us-east-1",
703 ))),
704 Arc::new(RwLock::new(fakecloud_logs::state::LogsState::new(
705 "123456789012",
706 "us-east-1",
707 ))),
708 Arc::new(DeliveryBus::new()),
709 )
710 }
711
712 fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
713 AwsRequest {
714 service: "cloudformation".to_string(),
715 action: action.to_string(),
716 region: "us-east-1".to_string(),
717 account_id: "123456789012".to_string(),
718 request_id: "test-request-id".to_string(),
719 headers: HeaderMap::new(),
720 query_params: params,
721 body: bytes::Bytes::new(),
722 path_segments: vec![],
723 raw_path: "/".to_string(),
724 raw_query: String::new(),
725 method: http::Method::POST,
726 is_query_protocol: true,
727 access_key_id: None,
728 }
729 }
730
731 #[test]
732 fn update_stack_sets_failed_status_on_resource_error() {
733 let svc = make_service();
734
735 let mut create_params = HashMap::new();
737 create_params.insert("StackName".to_string(), "test-stack".to_string());
738 create_params.insert(
739 "TemplateBody".to_string(),
740 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
741 );
742 let req = make_request("CreateStack", create_params);
743 let result = svc.create_stack(&req);
744 assert!(result.is_ok());
745
746 let mut update_params = HashMap::new();
748 update_params.insert("StackName".to_string(), "test-stack".to_string());
749 update_params.insert(
750 "TemplateBody".to_string(),
751 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}},"BadSub":{"Type":"AWS::SNS::Subscription","Properties":{"TopicArn":"arn:aws:sns:us-east-1:123456789012:nope","Protocol":"sqs","Endpoint":"arn:aws:sqs:us-east-1:123456789012:q1"}}}}"#.to_string(),
752 );
753 let req = make_request("UpdateStack", update_params);
754 let result = svc.update_stack(&req);
755
756 assert!(result.is_err());
758
759 let state = svc.state.read();
761 let stack = state.stacks.get("test-stack").unwrap();
762 assert_eq!(stack.status, "UPDATE_FAILED");
763 }
764
765 #[test]
766 fn create_stack_resolves_ref_to_physical_id() {
767 let svc = make_service();
768
769 let template = r#"{
771 "Resources": {
772 "MyTopic": {
773 "Type": "AWS::SNS::Topic",
774 "Properties": { "TopicName": "ref-test-topic" }
775 },
776 "MySub": {
777 "Type": "AWS::SNS::Subscription",
778 "Properties": {
779 "TopicArn": { "Ref": "MyTopic" },
780 "Protocol": "sqs",
781 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
782 }
783 }
784 }
785 }"#;
786
787 let mut params = HashMap::new();
788 params.insert("StackName".to_string(), "ref-stack".to_string());
789 params.insert("TemplateBody".to_string(), template.to_string());
790 let req = make_request("CreateStack", params);
791 let result = svc.create_stack(&req);
792 assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
793
794 let state = svc.state.read();
796 let stack = state.stacks.get("ref-stack").unwrap();
797 assert_eq!(stack.resources.len(), 2);
798 assert_eq!(stack.status, "CREATE_COMPLETE");
799
800 let sub = stack
802 .resources
803 .iter()
804 .find(|r| r.logical_id == "MySub")
805 .unwrap();
806 assert!(
807 sub.physical_id.contains("ref-test-topic"),
808 "Subscription physical ID should reference the topic ARN, got: {}",
809 sub.physical_id
810 );
811 }
812}