Skip to main content

alien_aws_clients/aws/
cloudformation.rs

1use crate::aws::aws_request_utils::{AwsRequestBuilderExt, AwsSignConfig};
2use crate::aws::credential_provider::AwsCredentialProvider;
3use alien_client_core::{ErrorData, Result};
4
5use alien_error::ContextError;
6use bon::Builder;
7use form_urlencoded;
8use quick_xml;
9use reqwest::{Client, StatusCode};
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12
13#[cfg(feature = "test-utils")]
14use mockall::automock;
15
16#[cfg_attr(feature = "test-utils", automock)]
17#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
18#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
19pub trait CloudFormationApi: Send + Sync + std::fmt::Debug {
20    async fn create_stack(&self, request: CreateStackRequest) -> Result<CreateStackResponse>;
21    async fn describe_stacks(
22        &self,
23        request: DescribeStacksRequest,
24    ) -> Result<DescribeStacksResponse>;
25    async fn delete_stack(&self, request: DeleteStackRequest) -> Result<DeleteStackResponse>;
26    async fn describe_stack_resources(
27        &self,
28        request: DescribeStackResourcesRequest,
29    ) -> Result<DescribeStackResourcesResponse>;
30    async fn describe_stack_resource(
31        &self,
32        request: DescribeStackResourceRequest,
33    ) -> Result<DescribeStackResourceResponse>;
34    async fn describe_stack_events(
35        &self,
36        request: DescribeStackEventsRequest,
37    ) -> Result<DescribeStackEventsResponse>;
38}
39
40/// AWS CloudFormation client implemented with the new Alien request & error utilities.
41#[derive(Debug, Clone)]
42pub struct CloudFormationClient {
43    client: Client,
44    credentials: AwsCredentialProvider,
45}
46
47impl CloudFormationClient {
48    pub fn new(client: Client, credentials: AwsCredentialProvider) -> Self {
49        Self {
50            client,
51            credentials,
52        }
53    }
54
55    fn sign_config(&self) -> AwsSignConfig {
56        AwsSignConfig {
57            service_name: "cloudformation".into(),
58            region: self.credentials.region().to_string(),
59            credentials: self.credentials.get_credentials(),
60            signing_region: None,
61        }
62    }
63
64    fn get_base_url(&self) -> String {
65        if let Some(override_url) = self
66            .credentials
67            .get_service_endpoint_option("cloudformation")
68        {
69            override_url.to_string()
70        } else {
71            format!(
72                "https://cloudformation.{}.amazonaws.com",
73                self.credentials.region()
74            )
75        }
76    }
77
78    fn build_form_body(action: &str, version: &str, params: Vec<(String, String)>) -> String {
79        let mut all = vec![
80            ("Action".to_string(), action.to_string()),
81            ("Version".to_string(), version.to_string()),
82        ];
83        all.extend(params);
84        all.into_iter()
85            .map(|(k, v)| {
86                format!(
87                    "{}={}",
88                    k,
89                    form_urlencoded::byte_serialize(v.as_bytes()).collect::<String>()
90                )
91            })
92            .collect::<Vec<String>>()
93            .join("&")
94    }
95
96    async fn post_xml<T: DeserializeOwned + Send + 'static>(
97        &self,
98        body: String,
99        operation: &str,
100        resource_name: &str,
101    ) -> Result<T> {
102        self.credentials.ensure_fresh().await?;
103        let base_url = self.get_base_url();
104        let url = format!("{}/", base_url.trim_end_matches('/'));
105        let body_for_error = body.clone();
106        let builder = self
107            .client
108            .post(&url)
109            .host(&format!(
110                "cloudformation.{}.amazonaws.com",
111                self.credentials.region()
112            ))
113            .content_type_form()
114            .body(body);
115
116        let result =
117            crate::aws::aws_request_utils::sign_send_xml(builder, &self.sign_config()).await;
118
119        match result {
120            Ok(v) => Ok(v),
121            Err(e) => {
122                if let Some(ErrorData::HttpResponseError {
123                    http_status,
124                    http_response_text: Some(ref text),
125                    ..
126                }) = &e.error
127                {
128                    let status = StatusCode::from_u16(*http_status)
129                        .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
130                    if let Some(mapped) = Self::map_cfn_error(
131                        status,
132                        text,
133                        operation,
134                        resource_name,
135                        Some(&body_for_error),
136                    ) {
137                        Err(e.context(mapped))
138                    } else {
139                        // Couldn't parse CloudFormation error, use original error
140                        Err(e)
141                    }
142                } else {
143                    Err(e)
144                }
145            }
146        }
147    }
148
149    fn map_cfn_error(
150        status: StatusCode,
151        error_body: &str,
152        operation: &str,
153        resource_name: &str,
154        request_body: Option<&str>,
155    ) -> Option<ErrorData> {
156        // Try to parse CloudFormation error xml: <ErrorResponse><Error><Code>...</Code><Message>...</Message></Error></ErrorResponse>
157        let parsed: std::result::Result<CloudFormationErrorResponse, _> =
158            quick_xml::de::from_str(error_body);
159        let (code, message) = match parsed {
160            Ok(e) => (
161                e.error.code.unwrap_or_else(|| "UnknownErrorCode".into()),
162                e.error.message.unwrap_or_else(|| "Unknown error".into()),
163            ),
164            Err(_) => {
165                // If we can't parse the response, return None to use original error
166                return None;
167            }
168        };
169
170        Some(match code.as_str() {
171            "AccessDenied"
172            | "AccessDeniedException"
173            | "UnauthorizedOperation"
174            | "AuthFailure"
175            | "SignatureDoesNotMatch" => ErrorData::RemoteAccessDenied {
176                resource_type: "CloudFormation Stack".into(),
177                resource_name: resource_name.into(),
178            },
179            "Throttling" | "ThrottlingException" | "RequestLimitExceeded" => {
180                ErrorData::RateLimitExceeded { message }
181            }
182            "ServiceUnavailable" | "InternalFailure" => {
183                ErrorData::RemoteServiceUnavailable { message }
184            }
185            "AlreadyExists" | "AlreadyExistsException" => ErrorData::RemoteResourceConflict {
186                message,
187                resource_type: "CloudFormation Stack".into(),
188                resource_name: resource_name.into(),
189            },
190            "LimitExceeded" | "LimitExceededException" => ErrorData::QuotaExceeded { message },
191            "ValidationError" if message.contains("does not exist") => {
192                ErrorData::RemoteResourceNotFound {
193                    resource_type: "CloudFormation Stack".into(),
194                    resource_name: resource_name.into(),
195                }
196            }
197            _ => match status {
198                StatusCode::CONFLICT => ErrorData::RemoteResourceConflict {
199                    message,
200                    resource_type: "CloudFormation Stack".into(),
201                    resource_name: resource_name.into(),
202                },
203                StatusCode::NOT_FOUND => ErrorData::RemoteResourceNotFound {
204                    resource_type: "CloudFormation Stack".into(),
205                    resource_name: resource_name.into(),
206                },
207                StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => ErrorData::RemoteAccessDenied {
208                    resource_type: "CloudFormation Stack".into(),
209                    resource_name: resource_name.into(),
210                },
211                StatusCode::TOO_MANY_REQUESTS => ErrorData::RateLimitExceeded { message },
212                StatusCode::SERVICE_UNAVAILABLE
213                | StatusCode::BAD_GATEWAY
214                | StatusCode::GATEWAY_TIMEOUT => ErrorData::RemoteServiceUnavailable { message },
215                _ => ErrorData::HttpResponseError {
216                    message: format!("CloudFormation {operation} failed: {message}"),
217                    url: format!("cloudformation.amazonaws.com"),
218                    http_status: status.as_u16(),
219                    http_request_text: request_body.map(|s| s.to_string()),
220                    http_response_text: Some(error_body.into()),
221                },
222            },
223        })
224    }
225}
226
227#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
228#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
229impl CloudFormationApi for CloudFormationClient {
230    async fn create_stack(&self, request: CreateStackRequest) -> Result<CreateStackResponse> {
231        let mut params = vec![
232            ("StackName".to_string(), request.stack_name.clone()),
233            ("TemplateBody".to_string(), request.template_body),
234        ];
235
236        if let Some(description) = request.description {
237            params.push(("Description".to_string(), description));
238        }
239
240        if let Some(timeout) = request.timeout_in_minutes {
241            params.push(("TimeoutInMinutes".to_string(), timeout.to_string()));
242        }
243
244        if let Some(ref capabilities) = request.capabilities {
245            for (i, capability) in capabilities.iter().enumerate() {
246                params.push((format!("Capabilities.member.{}", i + 1), capability.clone()));
247            }
248        }
249
250        let body = Self::build_form_body("CreateStack", "2010-05-15", params);
251        let stack_name = &request.stack_name;
252        self.post_xml(body, "CreateStack", stack_name).await
253    }
254
255    async fn describe_stacks(
256        &self,
257        request: DescribeStacksRequest,
258    ) -> Result<DescribeStacksResponse> {
259        let mut params = Vec::new();
260        if let Some(ref stack_name) = request.stack_name {
261            params.push(("StackName".to_string(), stack_name.clone()));
262        }
263
264        let body = Self::build_form_body("DescribeStacks", "2010-05-15", params);
265        let resource_name = request.stack_name.as_deref().unwrap_or("(unknown)");
266        self.post_xml(body, "DescribeStacks", resource_name).await
267    }
268
269    async fn delete_stack(&self, request: DeleteStackRequest) -> Result<DeleteStackResponse> {
270        let params = vec![("StackName".to_string(), request.stack_name.clone())];
271
272        let body = Self::build_form_body("DeleteStack", "2010-05-15", params);
273        self.post_xml(body, "DeleteStack", &request.stack_name)
274            .await
275    }
276
277    async fn describe_stack_resources(
278        &self,
279        request: DescribeStackResourcesRequest,
280    ) -> Result<DescribeStackResourcesResponse> {
281        let mut params = Vec::new();
282        if let Some(ref stack_name) = request.stack_name {
283            params.push(("StackName".to_string(), stack_name.clone()));
284        }
285        if let Some(ref logical_id) = request.logical_resource_id {
286            params.push(("LogicalResourceId".to_string(), logical_id.clone()));
287        }
288        if let Some(ref physical_id) = request.physical_resource_id {
289            params.push(("PhysicalResourceId".to_string(), physical_id.clone()));
290        }
291
292        let body = Self::build_form_body("DescribeStackResources", "2010-05-15", params);
293        self.post_xml(
294            body,
295            "DescribeStackResources",
296            request.stack_name.as_deref().unwrap_or("(unknown)"),
297        )
298        .await
299    }
300
301    async fn describe_stack_resource(
302        &self,
303        request: DescribeStackResourceRequest,
304    ) -> Result<DescribeStackResourceResponse> {
305        let params = vec![
306            ("StackName".to_string(), request.stack_name.clone()),
307            (
308                "LogicalResourceId".to_string(),
309                request.logical_resource_id.clone(),
310            ),
311        ];
312
313        let body = Self::build_form_body("DescribeStackResource", "2010-05-15", params);
314        self.post_xml(body, "DescribeStackResource", &request.stack_name)
315            .await
316    }
317
318    async fn describe_stack_events(
319        &self,
320        request: DescribeStackEventsRequest,
321    ) -> Result<DescribeStackEventsResponse> {
322        let params = vec![("StackName".to_string(), request.stack_name.clone())];
323
324        let body = Self::build_form_body("DescribeStackEvents", "2010-05-15", params);
325        self.post_xml(body, "DescribeStackEvents", &request.stack_name)
326            .await
327    }
328}
329
330// -------------------------------------------------------------------------
331// Error XML structs
332// -------------------------------------------------------------------------
333
334#[derive(Deserialize, Debug)]
335#[serde(rename_all = "PascalCase")]
336struct CloudFormationErrorResponse {
337    pub error: CloudFormationErrorDetails,
338}
339
340#[derive(Deserialize, Debug)]
341#[serde(rename_all = "PascalCase")]
342struct CloudFormationErrorDetails {
343    pub code: Option<String>,
344    pub message: Option<String>,
345}
346
347// -------------------------------------------------------------------------
348// Request / response payloads
349// -------------------------------------------------------------------------
350
351#[derive(Serialize, Debug, Clone, Builder)]
352#[serde(rename_all = "PascalCase")]
353pub struct CreateStackRequest {
354    pub stack_name: String,
355    pub template_body: String,
356    pub description: Option<String>,
357    pub timeout_in_minutes: Option<i32>,
358    pub capabilities: Option<Vec<String>>,
359}
360
361#[derive(Deserialize, Debug)]
362#[serde(rename_all = "PascalCase")]
363pub struct CreateStackResponse {
364    pub create_stack_result: CreateStackResult,
365}
366
367#[derive(Deserialize, Debug)]
368#[serde(rename_all = "PascalCase")]
369pub struct CreateStackResult {
370    pub stack_id: String,
371}
372
373#[derive(Serialize, Debug, Clone, Builder)]
374#[serde(rename_all = "PascalCase")]
375pub struct DescribeStacksRequest {
376    pub stack_name: Option<String>,
377}
378
379#[derive(Deserialize, Debug)]
380#[serde(rename_all = "PascalCase")]
381pub struct DescribeStacksResponse {
382    pub describe_stacks_result: DescribeStacksResult,
383}
384
385#[derive(Deserialize, Debug)]
386#[serde(rename_all = "PascalCase")]
387pub struct DescribeStacksResult {
388    pub stacks: Stacks,
389}
390
391#[derive(Deserialize, Debug)]
392#[serde(rename_all = "PascalCase")]
393pub struct Stacks {
394    #[serde(rename = "member", default)]
395    pub member: Vec<Stack>,
396}
397
398#[derive(Deserialize, Debug)]
399#[serde(rename_all = "PascalCase")]
400pub struct Stack {
401    pub stack_id: String,
402    pub stack_name: String,
403    pub stack_status: String,
404    pub creation_time: String,
405    pub description: Option<String>,
406    pub capabilities: Option<Capabilities>,
407    pub outputs: Option<Outputs>,
408    pub parameters: Option<Parameters>,
409}
410
411#[derive(Deserialize, Debug)]
412#[serde(rename_all = "PascalCase")]
413pub struct Capabilities {
414    #[serde(rename = "member", default)]
415    pub member: Vec<String>,
416}
417
418#[derive(Deserialize, Debug)]
419#[serde(rename_all = "PascalCase")]
420pub struct Outputs {
421    #[serde(rename = "member", default)]
422    pub member: Vec<Output>,
423}
424
425#[derive(Deserialize, Debug)]
426#[serde(rename_all = "PascalCase")]
427pub struct Output {
428    pub output_key: String,
429    pub output_value: String,
430    pub description: Option<String>,
431}
432
433#[derive(Deserialize, Debug)]
434#[serde(rename_all = "PascalCase")]
435pub struct Parameters {
436    #[serde(rename = "member", default)]
437    pub member: Vec<Parameter>,
438}
439
440#[derive(Deserialize, Debug)]
441#[serde(rename_all = "PascalCase")]
442pub struct Parameter {
443    pub parameter_key: String,
444    pub parameter_value: Option<String>,
445}
446
447#[derive(Serialize, Debug, Clone, Builder)]
448#[serde(rename_all = "PascalCase")]
449pub struct DeleteStackRequest {
450    pub stack_name: String,
451}
452
453#[derive(Deserialize, Debug)]
454#[serde(rename_all = "PascalCase")]
455pub struct DeleteStackResponse {
456    // DeleteStack returns an empty response on success
457}
458
459#[derive(Serialize, Debug, Clone, Builder)]
460#[serde(rename_all = "PascalCase")]
461pub struct DescribeStackResourcesRequest {
462    pub stack_name: Option<String>,
463    pub logical_resource_id: Option<String>,
464    pub physical_resource_id: Option<String>,
465}
466
467#[derive(Deserialize, Debug)]
468#[serde(rename_all = "PascalCase")]
469pub struct DescribeStackResourcesResponse {
470    pub describe_stack_resources_result: DescribeStackResourcesResult,
471}
472
473#[derive(Deserialize, Debug)]
474#[serde(rename_all = "PascalCase")]
475pub struct DescribeStackResourcesResult {
476    pub stack_resources: StackResources,
477}
478
479#[derive(Deserialize, Debug)]
480#[serde(rename_all = "PascalCase")]
481pub struct StackResources {
482    #[serde(rename = "member", default)]
483    pub member: Vec<StackResource>,
484}
485
486#[derive(Deserialize, Debug)]
487#[serde(rename_all = "PascalCase")]
488pub struct StackResource {
489    pub stack_name: Option<String>,
490    pub stack_id: Option<String>,
491    pub logical_resource_id: String,
492    pub physical_resource_id: Option<String>,
493    pub resource_type: String,
494    pub timestamp: String,
495    pub resource_status: String,
496    pub resource_status_reason: Option<String>,
497    pub description: Option<String>,
498    pub drift_information: Option<StackResourceDriftInformation>,
499    pub module_info: Option<ModuleInfo>,
500}
501
502#[derive(Deserialize, Debug)]
503#[serde(rename_all = "PascalCase")]
504pub struct StackResourceDriftInformation {
505    pub stack_resource_drift_status: String,
506    pub last_check_timestamp: Option<String>,
507}
508
509#[derive(Deserialize, Debug)]
510#[serde(rename_all = "PascalCase")]
511pub struct ModuleInfo {
512    pub type_hierarchy: Option<String>,
513    pub logical_id_hierarchy: Option<String>,
514}
515
516#[derive(Serialize, Debug, Clone, Builder)]
517#[serde(rename_all = "PascalCase")]
518pub struct DescribeStackResourceRequest {
519    pub stack_name: String,
520    pub logical_resource_id: String,
521}
522
523#[derive(Deserialize, Debug)]
524#[serde(rename_all = "PascalCase")]
525pub struct DescribeStackResourceResponse {
526    pub describe_stack_resource_result: DescribeStackResourceResult,
527}
528
529#[derive(Deserialize, Debug)]
530#[serde(rename_all = "PascalCase")]
531pub struct DescribeStackResourceResult {
532    pub stack_resource_detail: StackResourceDetail,
533}
534
535#[derive(Deserialize, Debug)]
536#[serde(rename_all = "PascalCase")]
537pub struct StackResourceDetail {
538    pub stack_name: Option<String>,
539    pub stack_id: Option<String>,
540    pub logical_resource_id: String,
541    pub physical_resource_id: Option<String>,
542    pub resource_type: String,
543    pub last_updated_timestamp: String,
544    pub resource_status: String,
545    pub resource_status_reason: Option<String>,
546    pub description: Option<String>,
547    pub metadata: Option<String>,
548    pub drift_information: Option<StackResourceDriftInformation>,
549    pub module_info: Option<ModuleInfo>,
550}
551
552#[derive(Serialize, Debug, Clone, Builder)]
553#[serde(rename_all = "PascalCase")]
554pub struct DescribeStackEventsRequest {
555    pub stack_name: String,
556}
557
558#[derive(Deserialize, Debug)]
559#[serde(rename_all = "PascalCase")]
560pub struct DescribeStackEventsResponse {
561    pub describe_stack_events_result: DescribeStackEventsResult,
562}
563
564#[derive(Deserialize, Debug)]
565#[serde(rename_all = "PascalCase")]
566pub struct DescribeStackEventsResult {
567    pub stack_events: StackEvents,
568}
569
570#[derive(Deserialize, Debug)]
571#[serde(rename_all = "PascalCase")]
572pub struct StackEvents {
573    #[serde(rename = "member", default)]
574    pub member: Vec<StackEvent>,
575}
576
577#[derive(Deserialize, Debug)]
578#[serde(rename_all = "PascalCase")]
579pub struct StackEvent {
580    pub stack_id: String,
581    pub event_id: String,
582    pub stack_name: String,
583    pub logical_resource_id: Option<String>,
584    pub physical_resource_id: Option<String>,
585    pub resource_type: Option<String>,
586    pub timestamp: String,
587    pub resource_status: Option<String>,
588    pub resource_status_reason: Option<String>,
589    pub resource_properties: Option<String>,
590    pub client_request_token: Option<String>,
591}