Skip to main content

rusty_cdk_core/stack/
dto.rs

1use crate::apigateway::{ApiGatewayV2Api, ApiGatewayV2Integration, ApiGatewayV2Route, ApiGatewayV2Stage};
2use crate::appconfig::{Application, ConfigurationProfile, DeploymentStrategy, Environment};
3use crate::appsync::{AppSyncApi, ChannelNamespace};
4use crate::cloudfront::{CachePolicy, Distribution, OriginAccessControl};
5use crate::cloudwatch::LogGroup;
6use crate::custom_resource::BucketNotification;
7use crate::dynamodb::Table;
8use crate::events::Schedule;
9use crate::iam::Role;
10use crate::lambda::{EventSourceMapping, Function, Permission};
11use crate::s3::{Bucket, BucketPolicy};
12use crate::secretsmanager::Secret;
13use crate::shared::{DeletionPolicy, Id};
14use crate::sns::{Subscription, Topic, TopicPolicy};
15use crate::sqs::{Queue, QueuePolicy};
16use rand::Rng;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20use std::fmt::{Display, Formatter};
21
22#[derive(Debug)]
23pub enum Cleanable<'a> {
24    Bucket(&'a str),
25    Topic(&'a str),
26}
27
28#[derive(Debug)]
29pub struct StackDiff {
30    pub unchanged_ids: Vec<(String, String)>, // though these might be modified
31    pub ids_to_be_removed: Vec<(String, String)>,
32    pub new_ids: Vec<(String, String)>,
33}
34
35#[derive(Debug, Clone)]
36pub struct Asset {
37    pub s3_bucket: String,
38    pub s3_key: String,
39    pub path: String,
40}
41
42impl Display for Asset {
43    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
44        f.write_fmt(format_args!(
45            "Asset at path {} for bucket {} and key {}",
46            self.path, self.s3_bucket, self.s3_key
47        ))
48    }
49}
50
51/// Represents a CloudFormation stack containing AWS resources and their configurations.
52///
53/// A `Stack` is the core abstraction for defining and managing AWS infrastructure.
54/// It contains a collection of AWS resources (such as Lambda functions, S3 buckets, DynamoDB tables, etc.)
55/// that are deployed together as a single unit in AWS CloudFormation.
56///
57/// # Usage
58///
59/// Stacks are created using the [`StackBuilder`](crate::stack::StackBuilder), which provides a fluent interface for adding resources.
60/// Once built, a stack can be:
61/// - Synthesized into a CloudFormation template JSON using [`synth()`](Stack::synth)
62/// - Deployed to AWS using the deployment utilities (`deploy`)
63///
64/// # Example
65///
66/// ```
67/// use rusty_cdk_core::stack::StackBuilder;
68/// use rusty_cdk_core::sqs::QueueBuilder;
69///
70/// let mut stack_builder = StackBuilder::new();
71///
72/// // Add resources to the stack
73/// QueueBuilder::new("my-queue")
74///     .standard_queue()
75///     .build(&mut stack_builder);
76///
77/// // Build the stack
78/// let stack = stack_builder.build().unwrap();
79///
80/// // Synthesize to CloudFormation template
81/// let template_json = stack.synth().unwrap();
82/// ```
83///
84/// # Serialization
85///
86/// The stack is serialized to CloudFormation-compatible JSON format, with:
87/// - `Resources`: The AWS resources map
88/// - `Metadata`: Additional metadata for resource management
89/// - Tags are *not* serialized directly
90#[derive(Debug, Serialize, Deserialize)]
91pub struct Stack {
92    #[serde(skip)]
93    pub(crate) resource_ids_to_replace: Vec<(String, String)>,
94    #[serde(skip)]
95    pub(crate) tags: Vec<(String, String)>,
96    #[serde(rename = "Resources")]
97    pub(crate) resources: HashMap<String, Resource>,
98    #[serde(rename = "Metadata")]
99    pub(crate) metadata: HashMap<String, String>,
100    #[serde(rename = "Outputs", skip_serializing_if = "Option::is_none")]
101    pub(crate) outputs: Option<HashMap<String, Output>>
102}
103
104#[derive(Debug, Serialize, Deserialize)]
105pub struct Output {
106    #[serde(rename = "Value")]
107    pub(crate) value: Value,
108}
109
110#[derive(Debug, Deserialize)]
111struct StackOnlyMetadata {
112    #[serde(rename = "Metadata")]
113    pub(crate) metadata: HashMap<String, String>,
114}
115
116impl Stack {
117    pub fn get_tags(&self) -> Vec<(String, String)> {
118        self.tags.clone()
119    }
120
121    pub fn get_assets(&self) -> Vec<Asset> {
122        self.resources
123            .values()
124            .flat_map(|r| match r {
125                Resource::Function(l) => l.asset.clone(), // see if we can avoid the clone
126                _ => None,
127            })
128            .collect()
129    }
130
131    /// Synthesizes the stack into a new CloudFormation template JSON string.
132    ///
133    /// This method converts the stack and all its resources into a JSON-formatted
134    /// CloudFormation template that can be deployed to AWS using the AWS CLI, SDKs,
135    /// or the AWS Console.
136    ///
137    /// This method always created a 'fresh' template, and its ids might not match those of an earlier synthesis.
138    /// Use `synth_for_existing` if you want to keep the existing resource ids.
139    /// The deployment methods of this library (`deploy` and `deploy_with_result`) automatically check the resource ids when updating a stack.
140    ///
141    /// # Returns
142    ///
143    /// * `Ok(String)` - A JSON-formatted CloudFormation template string
144    /// * `Err(String)` - An error message if serialization fails
145    ///
146    /// # Example
147    ///
148    /// ```
149    /// use rusty_cdk_core::stack::StackBuilder;
150    /// use rusty_cdk_core::sqs::QueueBuilder;
151    ///
152    /// let mut stack_builder = StackBuilder::new();
153    ///
154    /// // Add resources to the stack
155    /// QueueBuilder::new("my-queue")
156    ///     .standard_queue()
157    ///     .build(&mut stack_builder);
158    ///
159    /// // Build the stack
160    /// let stack = stack_builder.build().unwrap();
161    ///
162    /// // Synthesize to a 'fresh' CloudFormation template
163    /// let template_json = stack.synth().unwrap();
164    /// ```
165    ///
166    /// # Usage with AWS Tools
167    ///
168    /// The synthesized template can be used with:
169    /// - AWS CLI: `aws cloudformation create-stack --template-body file://template.json`
170    /// - AWS SDKs: Pass the template string to the CloudFormation client
171    /// - AWS Console: Upload the template file directly
172    pub fn synth(&self) -> Result<String, String> {
173        let mut naive_synth = serde_json::to_string(self).map_err(|e| format!("Could not serialize stack: {e:#?}"))?;
174        // nicer way to do this? for example, a method on each DTO to look for possible arns/refs (`Value`) and replace them if needed. referenced ids should help a bit
175        self.resource_ids_to_replace.iter().for_each(|(current, new)| {
176            naive_synth = naive_synth.replace(current, new);
177        });
178
179        Ok(naive_synth)
180    }
181
182    // TODO proper error instead of string (also for normal synth above)
183    /// Synthesizes the stack into a CloudFormation template JSON string.
184    ///
185    /// This method converts the stack and all its resources into a JSON-formatted
186    /// CloudFormation template that can be deployed to AWS using the AWS CLI, SDKs,
187    /// or the AWS Console.
188    ///
189    /// It makes sure the resource ids match those of an existing stack.
190    /// *This will only work if the existing stack was also created with this library.*
191    ///
192    /// # Parameters
193    ///
194    /// * `existing_stack` - The existing stack, as a CloudFormation template JSON string
195    ///
196    /// # Returns
197    ///
198    /// * `Ok(String)` - A JSON-formatted CloudFormation template string
199    /// * `Err(String)` - An error message if serialization fails
200    ///
201    /// # Example
202    ///
203    /// ```
204    /// use rusty_cdk_core::stack::StackBuilder;
205    /// use rusty_cdk_core::sqs::QueueBuilder;
206    ///
207    /// let mut stack_builder = StackBuilder::new();
208    ///
209    /// // Add resources to the stack
210    /// QueueBuilder::new("my-queue")
211    ///     .standard_queue()
212    ///     .build(&mut stack_builder);
213    ///
214    /// // Build the stack
215    /// let mut stack = stack_builder.build().unwrap();
216    ///
217    /// // Retrieve the existing stack template
218    /// let existing_stack_template = r#"{"Resources": { "LogGroup198814893": { "Type": "AWS::Logs::LogGroup", "Properties": { "RetentionInDays": 731 } } }, "Metadata": { "myFunLogGroup": "LogGroup198814893" } }"#;
219    ///
220    /// // Synthesize to a CloudFormation template respecting the existing ids
221    /// let template_json = stack.synth_for_existing(existing_stack_template).unwrap();
222    /// ```
223    ///
224    /// # Usage with AWS Tools
225    ///
226    /// The synthesized template can be used with:
227    /// - AWS CLI: `aws cloudformation create-stack --template-body file://template.json`
228    /// - AWS SDKs: Pass the template string to the CloudFormation client
229    /// - AWS Console: Upload the template file directly
230    pub fn synth_for_existing(&mut self, existing_stack: &str) -> Result<String, String> {
231        let meta = Self::get_metadata(existing_stack)?;
232        self.update_resource_ids_for_existing_stack(meta.metadata);
233        self.synth()
234    }
235
236    pub fn get_cleanable_resources(&'_ self) -> Vec<Cleanable<'_>> {
237        self.resources.iter().flat_map(|(k, r)| {
238            println!("Found {k:?}");
239            match r {
240                Resource::Bucket(b) => {
241                    if let Some(pol) = &b.update_delete_policy_dto.deletion_policy {
242                        let pol: DeletionPolicy = pol.into();
243                        match pol {
244                            DeletionPolicy::Retain => None,
245                            _ => Some(Cleanable::Bucket(k))
246                        }
247                    } else {
248                        Some(Cleanable::Bucket(k))
249                    }
250                }
251                Resource::Topic(t) => {
252                    // note: no update/delete policy yet, so only check archive property
253                    if t.properties.archive_policy.is_some() {
254                        Some(Cleanable::Topic(k))
255                    } else {
256                        None
257                    }
258                }
259                _ => None
260            }
261        }).collect()
262    }
263
264    pub fn get_diff(&self, existing_stack: &str) -> Result<StackDiff, String> {
265        let meta = Self::get_metadata(existing_stack)?;
266        let existing_meta = meta.metadata;
267        let existing_ids: Vec<_> = existing_meta.keys().cloned().collect();
268
269        let new_meta = &self.metadata;
270        let new_ids: Vec<_> = new_meta.keys().cloned().collect();
271
272        let (in_existing, not_in_existing): (Vec<_>, Vec<_>) = new_ids.into_iter().partition(|v| existing_ids.contains(v));
273        let removed: Vec<_> = existing_ids.into_iter().filter(|v| !in_existing.contains(v)).collect();
274
275        let in_existing = in_existing
276            .into_iter()
277            .map(|v| {
278                let resource_id = existing_meta.get(&v).expect("resource id to be present").to_string();
279                (v, resource_id)
280            })
281            .collect();
282        let removed = removed
283            .into_iter()
284            .map(|v| {
285                let resource_id = existing_meta.get(&v).expect("resource id to be present").to_string();
286                (v, resource_id)
287            })
288            .collect();
289        let not_in_existing = not_in_existing
290            .into_iter()
291            .map(|v| {
292                let resource_id = new_meta.get(&v).expect("resource id to be present").to_string();
293                (v, resource_id)
294            })
295            .collect();
296
297        Ok(StackDiff {
298            unchanged_ids: in_existing,
299            ids_to_be_removed: removed,
300            new_ids: not_in_existing,
301        })
302    }
303
304    fn update_resource_ids_for_existing_stack(&mut self, existing_ids_with_resource_ids: HashMap<String, String>) {
305        let still_existing_after_proposed_changes: Vec<_> = existing_ids_with_resource_ids
306            .into_iter()
307            .filter(|(existing_id, _)| self.metadata.contains_key(existing_id))
308            .map(|existing| {
309                let current_stack_resource_id = self
310                    .metadata
311                    .get(&existing.0)
312                    .expect("existence to be checked by filter")
313                    .to_string();
314                (existing.0, existing.1, current_stack_resource_id)
315            })
316            .collect();
317
318        still_existing_after_proposed_changes
319            .into_iter()
320            .for_each(|(existing_id, existing_resource_id, current_stack_resource_id)| {
321                let removed = self
322                    .resources
323                    .remove(&current_stack_resource_id)
324                    .expect("resource to exist in stack resources");
325                self.resources.insert(existing_resource_id.clone(), removed);
326                self.metadata.insert(existing_id, existing_resource_id.clone());
327                self.resource_ids_to_replace
328                    .push((current_stack_resource_id.to_string(), existing_resource_id));
329            });
330    }
331
332    fn get_metadata(existing_stack: &str) -> Result<StackOnlyMetadata, String> {
333        serde_json::from_str(existing_stack).map_err(|e| format!("Could not retrieve resource info from existing stack: {}", e))
334    }
335}
336
337#[derive(Debug, Serialize, Deserialize)]
338#[serde(untagged)]
339pub enum Resource {
340    ApiGatewayV2Api(ApiGatewayV2Api),
341    ApiGatewayV2Integration(ApiGatewayV2Integration),
342    ApiGatewayV2Route(ApiGatewayV2Route),
343    ApiGatewayV2Stage(ApiGatewayV2Stage),
344    AppSyncApi(AppSyncApi),
345    Application(Application),
346    Bucket(Bucket),
347    BucketNotification(BucketNotification),
348    BucketPolicy(BucketPolicy),
349    CachePolicy(CachePolicy),
350    ChannelNamespace(ChannelNamespace),
351    ConfigurationProfile(ConfigurationProfile),
352    DeploymentStrategy(DeploymentStrategy),
353    Distribution(Distribution),
354    Environment(Environment),
355    EventSourceMapping(EventSourceMapping),
356    Function(Function),
357    LogGroup(LogGroup),
358    OriginAccessControl(OriginAccessControl),
359    Permission(Permission),
360    Queue(Queue),
361    QueuePolicy(QueuePolicy),
362    Role(Role),
363    Secret(Secret),
364    Schedule(Schedule),
365    Subscription(Subscription),
366    Table(Table),
367    Topic(Topic),
368    TopicPolicy(TopicPolicy),
369}
370
371impl Resource {
372    pub fn get_id(&self) -> Id {
373        let id = match self {
374            Resource::ApiGatewayV2Api(r) => r.get_id(),
375            Resource::ApiGatewayV2Integration(r) => r.get_id(),
376            Resource::ApiGatewayV2Route(r) => r.get_id(),
377            Resource::ApiGatewayV2Stage(r) => r.get_id(),
378            Resource::AppSyncApi(r) => r.get_id(),
379            Resource::Application(r) => r.get_id(),
380            Resource::Bucket(r) => r.get_id(),
381            Resource::BucketNotification(r) => r.get_id(),
382            Resource::BucketPolicy(r) => r.get_id(),
383            Resource::CachePolicy(r) => r.get_id(),
384            Resource::ChannelNamespace(r) => r.get_id(),
385            Resource::ConfigurationProfile(r) => r.get_id(),
386            Resource::DeploymentStrategy(r) => r.get_id(),
387            Resource::Distribution(r) => r.get_id(),
388            Resource::Environment(r) => r.get_id(),
389            Resource::EventSourceMapping(r) => r.get_id(),
390            Resource::Function(r) => r.get_id(),
391            Resource::LogGroup(r) => r.get_id(),
392            Resource::OriginAccessControl(r) => r.get_id(),
393            Resource::Permission(r) => r.get_id(),
394            Resource::Queue(r) => r.get_id(),
395            Resource::QueuePolicy(r) => r.get_id(),
396            Resource::Role(r) => r.get_id(),
397            Resource::Schedule(r) => r.get_id(),
398            Resource::Secret(r) => r.get_id(),
399            Resource::Subscription(r) => r.get_id(),
400            Resource::Table(r) => r.get_id(),
401            Resource::Topic(r) => r.get_id(),
402            Resource::TopicPolicy(r) => r.get_id(),
403        };
404        id.clone()
405    }
406
407    pub fn get_resource_id(&self) -> &str {
408        match self {
409            Resource::ApiGatewayV2Api(r) => r.get_resource_id(),
410            Resource::ApiGatewayV2Integration(r) => r.get_resource_id(),
411            Resource::ApiGatewayV2Route(r) => r.get_resource_id(),
412            Resource::ApiGatewayV2Stage(r) => r.get_resource_id(),
413            Resource::AppSyncApi(r) => r.get_resource_id(),
414            Resource::Application(r) => r.get_resource_id(),
415            Resource::Bucket(r) => r.get_resource_id(),
416            Resource::BucketNotification(r) => r.get_resource_id(),
417            Resource::BucketPolicy(r) => r.get_resource_id(),
418            Resource::CachePolicy(r) => r.get_resource_id(),
419            Resource::ChannelNamespace(r) => r.get_resource_id(),
420            Resource::ConfigurationProfile(r) => r.get_resource_id(),
421            Resource::DeploymentStrategy(r) => r.get_resource_id(),
422            Resource::Distribution(r) => r.get_resource_id(),
423            Resource::Environment(r) => r.get_resource_id(),
424            Resource::EventSourceMapping(r) => r.get_resource_id(),
425            Resource::Function(r) => r.get_resource_id(),
426            Resource::LogGroup(r) => r.get_resource_id(),
427            Resource::OriginAccessControl(r) => r.get_resource_id(),
428            Resource::Permission(r) => r.get_resource_id(),
429            Resource::Queue(r) => r.get_resource_id(),
430            Resource::QueuePolicy(r) => r.get_resource_id(),
431            Resource::Role(r) => r.get_resource_id(),
432            Resource::Schedule(r) => r.get_resource_id(),
433            Resource::Secret(r) => r.get_resource_id(),
434            Resource::Subscription(r) => r.get_resource_id(),
435            Resource::Table(t) => t.get_resource_id(),
436            Resource::Topic(r) => r.get_resource_id(),
437            Resource::TopicPolicy(r) => r.get_resource_id(),
438        }
439    }
440
441    pub(crate) fn generate_id(resource_name: &str) -> String {
442        let mut rng = rand::rng();
443        let random_suffix: u32 = rng.random();
444        format!("{resource_name}{random_suffix}")
445    }
446}
447
448macro_rules! from_resource {
449    ($name:ident) => {
450        impl From<$name> for Resource {
451            fn from(value: $name) -> Self {
452                Resource::$name(value)
453            }
454        }
455    };
456}
457
458from_resource!(ApiGatewayV2Api);
459from_resource!(ApiGatewayV2Integration);
460from_resource!(ApiGatewayV2Route);
461from_resource!(ApiGatewayV2Stage);
462from_resource!(AppSyncApi);
463from_resource!(Application);
464from_resource!(Bucket);
465from_resource!(BucketNotification);
466from_resource!(BucketPolicy);
467from_resource!(CachePolicy);
468from_resource!(ChannelNamespace);
469from_resource!(ConfigurationProfile);
470from_resource!(DeploymentStrategy);
471from_resource!(Distribution);
472from_resource!(Environment);
473from_resource!(EventSourceMapping);
474from_resource!(Function);
475from_resource!(LogGroup);
476from_resource!(OriginAccessControl);
477from_resource!(Permission);
478from_resource!(Queue);
479from_resource!(QueuePolicy);
480from_resource!(Role);
481from_resource!(Secret);
482from_resource!(Schedule);
483from_resource!(Subscription);
484from_resource!(Table);
485from_resource!(Topic);
486from_resource!(TopicPolicy);
487
488#[cfg(test)]
489mod tests {
490    use crate::sns::TopicBuilder;
491    use crate::sqs::QueueBuilder;
492    use crate::stack::StackBuilder;
493    use std::collections::HashMap;
494
495    #[test]
496    fn should_do_nothing_for_empty_stack_and_empty_existing_ids() {
497        let mut stack_builder = StackBuilder::new().build().unwrap();
498        let existing_ids = HashMap::new();
499
500        stack_builder.update_resource_ids_for_existing_stack(existing_ids);
501
502        assert_eq!(stack_builder.resources.len(), 0);
503        assert_eq!(stack_builder.metadata.len(), 0);
504        assert_eq!(stack_builder.resource_ids_to_replace.len(), 0);
505    }
506
507    #[test]
508    fn should_do_nothing_for_empty_stack() {
509        let mut stack_builder = StackBuilder::new().build().unwrap();
510        let mut existing_ids = HashMap::new();
511        existing_ids.insert("fun".to_string(), "abc123".to_string());
512
513        stack_builder.update_resource_ids_for_existing_stack(existing_ids);
514
515        assert_eq!(stack_builder.resources.len(), 0);
516        assert_eq!(stack_builder.metadata.len(), 0);
517        assert_eq!(stack_builder.resource_ids_to_replace.len(), 0);
518    }
519
520    #[test]
521    fn should_replace_topic_resource_id_with_the_existing_id() {
522        let mut stack_builder = StackBuilder::new();
523        TopicBuilder::new("topic").build(&mut stack_builder);
524        let mut existing_ids = HashMap::new();
525        existing_ids.insert("topic".to_string(), "abc123".to_string());
526        let mut stack = stack_builder.build().unwrap();
527
528        stack.update_resource_ids_for_existing_stack(existing_ids);
529
530        assert_eq!(stack.resources.len(), 1);
531        assert_eq!(stack.resource_ids_to_replace.len(), 1);
532        assert_eq!(stack.metadata.len(), 1);
533        assert_eq!(stack.metadata.get("topic").unwrap(), &"abc123".to_string());
534    }
535
536    #[test]
537    fn should_replace_topic_resource_id_with_the_existing_id_keeping_new_queue_id() {
538        let mut stack_builder = StackBuilder::new();
539        TopicBuilder::new("topic").build(&mut stack_builder);
540        QueueBuilder::new("queue").standard_queue().build(&mut stack_builder);
541        let mut existing_ids = HashMap::new();
542        existing_ids.insert("topic".to_string(), "abc123".to_string());
543        let mut stack = stack_builder.build().unwrap();
544
545        stack.update_resource_ids_for_existing_stack(existing_ids);
546
547        assert_eq!(stack.resources.len(), 2);
548        assert_eq!(stack.resource_ids_to_replace.len(), 1);
549        assert_eq!(stack.metadata.len(), 2);
550        assert_eq!(stack.metadata.get("topic").unwrap(), &"abc123".to_string());
551    }
552
553    #[test]
554    fn should_produce_diff() {
555        let mut stack_builder = StackBuilder::new();
556        TopicBuilder::new("topic").build(&mut stack_builder);
557        QueueBuilder::new("queue").standard_queue().build(&mut stack_builder);
558        let stack = stack_builder.build().unwrap();
559
560        let diff = stack
561            .get_diff(r#"{"Metadata": { "queue": "Queue123", "bucket": "Bucket234" } }"#)
562            .expect("diff to work");
563
564        assert_eq!(
565            diff.new_ids,
566            vec![("topic".to_string(), stack.metadata.get("topic").unwrap().to_string())]
567        );
568        assert_eq!(diff.ids_to_be_removed, vec![("bucket".to_string(), "Bucket234".to_string())]);
569        assert_eq!(diff.unchanged_ids, vec![("queue".to_string(), "Queue123".to_string())]);
570    }
571}