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