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