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