Skip to main content

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::events::Schedule;
9use crate::iam::Role;
10use crate::lambda::{EventSourceMapping, Function, Permission};
11use crate::s3::{Bucket, BucketPolicy};
12use crate::secretsmanager::Secret;
13use crate::shared::Id;
14use crate::sns::{Subscription, Topic, TopicPolicy};
15use crate::sqs::{Queue, QueuePolicy};
16use rand::Rng;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::fmt::{Display, Formatter};
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, Deserialize)]
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        let still_existing_after_proposed_changes: Vec<_> = existing_ids_with_resource_ids
264            .into_iter()
265            .filter(|(existing_id, _)| self.metadata.contains_key(existing_id))
266            .map(|existing| {
267                let current_stack_resource_id = self.metadata.get(&existing.0).expect("existence to be checked by filter").to_string();
268                (existing.0, existing.1, current_stack_resource_id)
269            })
270            .collect();
271
272        still_existing_after_proposed_changes.into_iter().for_each(|(existing_id, existing_resource_id, current_stack_resource_id)| {
273            let removed = self
274                .resources
275                .remove(&current_stack_resource_id)
276                .expect("resource to exist in stack resources");
277            self.resources.insert(existing_resource_id.clone(), removed);
278            self.metadata.insert(existing_id, existing_resource_id.clone());
279            self.resource_ids_to_replace
280                .push((current_stack_resource_id.to_string(), existing_resource_id));
281        });
282    }
283
284    fn get_metadata(existing_stack: &str) -> Result<StackOnlyMetadata, String> {
285        serde_json::from_str(existing_stack).map_err(|e| format!("Could not retrieve resource info from existing stack: {}", e))
286    }
287}
288
289#[derive(Debug, Serialize, Deserialize)]
290#[serde(untagged)]
291pub enum Resource {
292    ApiGatewayV2Api(ApiGatewayV2Api),
293    ApiGatewayV2Integration(ApiGatewayV2Integration),
294    ApiGatewayV2Route(ApiGatewayV2Route),
295    ApiGatewayV2Stage(ApiGatewayV2Stage),
296    AppSyncApi(AppSyncApi),
297    Application(Application),
298    Bucket(Bucket),
299    BucketNotification(BucketNotification),
300    BucketPolicy(BucketPolicy),
301    CachePolicy(CachePolicy),
302    ChannelNamespace(ChannelNamespace),
303    ConfigurationProfile(ConfigurationProfile),
304    DeploymentStrategy(DeploymentStrategy),
305    Distribution(Distribution),
306    Environment(Environment),
307    EventSourceMapping(EventSourceMapping),
308    Function(Function),
309    LogGroup(LogGroup),
310    OriginAccessControl(OriginAccessControl),
311    Permission(Permission),
312    Queue(Queue),
313    QueuePolicy(QueuePolicy),
314    Role(Role),
315    Secret(Secret),
316    Schedule(Schedule),
317    Subscription(Subscription),
318    Table(Table),
319    Topic(Topic),
320    TopicPolicy(TopicPolicy),
321}
322
323impl Resource {
324    pub fn get_id(&self) -> Id {
325        let id = match self {
326            Resource::ApiGatewayV2Api(r) => r.get_id(),
327            Resource::ApiGatewayV2Integration(r) => r.get_id(),
328            Resource::ApiGatewayV2Route(r) => r.get_id(),
329            Resource::ApiGatewayV2Stage(r) => r.get_id(),
330            Resource::AppSyncApi(r) => r.get_id(),
331            Resource::Application(r) => r.get_id(),
332            Resource::Bucket(r) => r.get_id(),
333            Resource::BucketNotification(r) => r.get_id(),
334            Resource::BucketPolicy(r) => r.get_id(),
335            Resource::CachePolicy(r) => r.get_id(),
336            Resource::ChannelNamespace(r) => r.get_id(),
337            Resource::ConfigurationProfile(r) => r.get_id(),
338            Resource::DeploymentStrategy(r) => r.get_id(),
339            Resource::Distribution(r) => r.get_id(),
340            Resource::Environment(r) => r.get_id(),
341            Resource::EventSourceMapping(r) => r.get_id(),
342            Resource::Function(r) => r.get_id(),
343            Resource::LogGroup(r) => r.get_id(),
344            Resource::OriginAccessControl(r) => r.get_id(),
345            Resource::Permission(r) => r.get_id(),
346            Resource::Queue(r) => r.get_id(),
347            Resource::QueuePolicy(r) => r.get_id(),
348            Resource::Role(r) => r.get_id(),
349            Resource::Schedule(r) => r.get_id(),
350            Resource::Secret(r) => r.get_id(),
351            Resource::Subscription(r) => r.get_id(),
352            Resource::Table(r) => r.get_id(),
353            Resource::Topic(r) => r.get_id(),
354            Resource::TopicPolicy(r) => r.get_id(),
355        };
356        id.clone()
357    }
358
359    pub fn get_resource_id(&self) -> &str {
360        match self {
361            Resource::ApiGatewayV2Api(r) => r.get_resource_id(),
362            Resource::ApiGatewayV2Integration(r) => r.get_resource_id(),
363            Resource::ApiGatewayV2Route(r) => r.get_resource_id(),
364            Resource::ApiGatewayV2Stage(r) => r.get_resource_id(),
365            Resource::AppSyncApi(r) => r.get_resource_id(),
366            Resource::Application(r) => r.get_resource_id(),
367            Resource::Bucket(r) => r.get_resource_id(),
368            Resource::BucketNotification(r) => r.get_resource_id(),
369            Resource::BucketPolicy(r) => r.get_resource_id(),
370            Resource::CachePolicy(r) => r.get_resource_id(),
371            Resource::ChannelNamespace(r) => r.get_resource_id(),
372            Resource::ConfigurationProfile(r) => r.get_resource_id(),
373            Resource::DeploymentStrategy(r) => r.get_resource_id(),
374            Resource::Distribution(r) => r.get_resource_id(),
375            Resource::Environment(r) => r.get_resource_id(),
376            Resource::EventSourceMapping(r) => r.get_resource_id(),
377            Resource::Function(r) => r.get_resource_id(),
378            Resource::LogGroup(r) => r.get_resource_id(),
379            Resource::OriginAccessControl(r) => r.get_resource_id(),
380            Resource::Permission(r) => r.get_resource_id(),
381            Resource::Queue(r) => r.get_resource_id(),
382            Resource::QueuePolicy(r) => r.get_resource_id(),
383            Resource::Role(r) => r.get_resource_id(),
384            Resource::Schedule(r) => r.get_resource_id(),
385            Resource::Secret(r) => r.get_resource_id(),
386            Resource::Subscription(r) => r.get_resource_id(),
387            Resource::Table(t) => t.get_resource_id(),
388            Resource::Topic(r) => r.get_resource_id(),
389            Resource::TopicPolicy(r) => r.get_resource_id(),
390        }
391    }
392
393    pub(crate) fn generate_id(resource_name: &str) -> String {
394        let mut rng = rand::rng();
395        let random_suffix: u32 = rng.random();
396        format!("{resource_name}{random_suffix}")
397    }
398}
399
400macro_rules! from_resource {
401    ($name:ident) => {
402        impl From<$name> for Resource {
403            fn from(value: $name) -> Self {
404                Resource::$name(value)
405            }
406        }
407    };
408}
409
410from_resource!(ApiGatewayV2Api);
411from_resource!(ApiGatewayV2Integration);
412from_resource!(ApiGatewayV2Route);
413from_resource!(ApiGatewayV2Stage);
414from_resource!(AppSyncApi);
415from_resource!(Application);
416from_resource!(Bucket);
417from_resource!(BucketNotification);
418from_resource!(BucketPolicy);
419from_resource!(CachePolicy);
420from_resource!(ChannelNamespace);
421from_resource!(ConfigurationProfile);
422from_resource!(DeploymentStrategy);
423from_resource!(Distribution);
424from_resource!(Environment);
425from_resource!(EventSourceMapping);
426from_resource!(Function);
427from_resource!(LogGroup);
428from_resource!(OriginAccessControl);
429from_resource!(Permission);
430from_resource!(Queue);
431from_resource!(QueuePolicy);
432from_resource!(Role);
433from_resource!(Secret);
434from_resource!(Schedule);
435from_resource!(Subscription);
436from_resource!(Table);
437from_resource!(Topic);
438from_resource!(TopicPolicy);
439
440#[cfg(test)]
441mod tests {
442    use crate::sns::TopicBuilder;
443    use crate::sqs::QueueBuilder;
444    use crate::stack::StackBuilder;
445    use std::collections::HashMap;
446
447    #[test]
448    fn should_do_nothing_for_empty_stack_and_empty_existing_ids() {
449        let mut stack_builder = StackBuilder::new().build().unwrap();
450        let existing_ids = HashMap::new();
451
452        stack_builder.update_resource_ids_for_existing_stack(existing_ids);
453
454        assert_eq!(stack_builder.resources.len(), 0);
455        assert_eq!(stack_builder.metadata.len(), 0);
456        assert_eq!(stack_builder.resource_ids_to_replace.len(), 0);
457    }
458
459    #[test]
460    fn should_do_nothing_for_empty_stack() {
461        let mut stack_builder = StackBuilder::new().build().unwrap();
462        let mut existing_ids = HashMap::new();
463        existing_ids.insert("fun".to_string(), "abc123".to_string());
464
465        stack_builder.update_resource_ids_for_existing_stack(existing_ids);
466
467        assert_eq!(stack_builder.resources.len(), 0);
468        assert_eq!(stack_builder.metadata.len(), 0);
469        assert_eq!(stack_builder.resource_ids_to_replace.len(), 0);
470    }
471
472    #[test]
473    fn should_replace_topic_resource_id_with_the_existing_id() {
474        let mut stack_builder = StackBuilder::new();
475        TopicBuilder::new("topic").build(&mut stack_builder);
476        let mut existing_ids = HashMap::new();
477        existing_ids.insert("topic".to_string(), "abc123".to_string());
478        let mut stack = stack_builder.build().unwrap();
479
480        stack.update_resource_ids_for_existing_stack(existing_ids);
481
482        assert_eq!(stack.resources.len(), 1);
483        assert_eq!(stack.resource_ids_to_replace.len(), 1);
484        assert_eq!(stack.metadata.len(), 1);
485        assert_eq!(stack.metadata.get("topic").unwrap(), &"abc123".to_string());
486    }
487
488    #[test]
489    fn should_replace_topic_resource_id_with_the_existing_id_keeping_new_queue_id() {
490        let mut stack_builder = StackBuilder::new();
491        TopicBuilder::new("topic").build(&mut stack_builder);
492        QueueBuilder::new("queue").standard_queue().build(&mut stack_builder);
493        let mut existing_ids = HashMap::new();
494        existing_ids.insert("topic".to_string(), "abc123".to_string());
495        let mut stack = stack_builder.build().unwrap();
496
497        stack.update_resource_ids_for_existing_stack(existing_ids);
498
499        assert_eq!(stack.resources.len(), 2);
500        assert_eq!(stack.resource_ids_to_replace.len(), 1);
501        assert_eq!(stack.metadata.len(), 2);
502        assert_eq!(stack.metadata.get("topic").unwrap(), &"abc123".to_string());
503    }
504
505    #[test]
506    fn should_produce_diff() {
507        let mut stack_builder = StackBuilder::new();
508        TopicBuilder::new("topic").build(&mut stack_builder);
509        QueueBuilder::new("queue").standard_queue().build(&mut stack_builder);
510        let stack = stack_builder.build().unwrap();
511
512        let diff = stack
513            .get_diff(r#"{"Metadata": { "queue": "Queue123", "bucket": "Bucket234" } }"#)
514            .expect("diff to work");
515
516        assert_eq!(
517            diff.new_ids,
518            vec![("topic".to_string(), stack.metadata.get("topic").unwrap().to_string())]
519        );
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}