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