rusty_cdk_core/stack/
dto.rs

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