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