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