Skip to main content

stack_deploy/
instance_spec.rs

1use crate::stack::{load_stack, try_load_stack};
2use crate::types::*;
3use std::collections::BTreeSet;
4
5pub(crate) struct RemoteOperation {
6    pub(crate) client_request_token: ClientRequestToken,
7    pub(crate) stack_id: StackId,
8}
9
10#[derive(Clone, Debug, Eq, PartialEq, clap::ValueEnum)]
11pub enum ReviewChangeSet {
12    Interactive,
13    NoReview,
14}
15
16pub type Capabilities = std::collections::BTreeSet<aws_sdk_cloudformation::types::Capability>;
17
18pub struct TemplateUploader<'a> {
19    pub cloudformation: &'a aws_sdk_cloudformation::Client,
20    pub s3: &'a aws_sdk_s3::Client,
21    pub s3_bucket_name_output_key: &'a OutputKey,
22    pub stack_name: &'a StackName,
23}
24
25impl TemplateUploader<'_> {
26    async fn upload(&self, template_rendered: &TemplateRendered) -> TemplateUrl {
27        let hex =
28            hex::encode(<sha2::Sha256 as sha2::Digest>::digest(&template_rendered.body).as_slice());
29
30        let s3_bucket_name = crate::stack::read_stack_output(
31            self.cloudformation,
32            self.stack_name,
33            self.s3_bucket_name_output_key,
34        )
35        .await;
36
37        let s3_object_key = format!("{hex}.{}", template_rendered.format.file_ext());
38
39        log::info!("Uploading template to: {s3_bucket_name}/{s3_object_key}");
40
41        self.s3
42            .put_object()
43            .bucket(&s3_bucket_name)
44            .key(&s3_object_key)
45            .body((&template_rendered.body).into())
46            .send()
47            .await
48            .unwrap();
49
50        format!("https://s3.amazonaws.com/{s3_bucket_name}/{s3_object_key}").into()
51    }
52}
53
54#[derive(Debug)]
55pub struct InstanceSpec {
56    pub capabilities: Capabilities,
57    pub parameter_map: ParameterMap,
58    pub stack_name: StackName,
59    pub tag_map: TagMap,
60    pub template: Template,
61}
62
63impl InstanceSpec {
64    #[must_use]
65    pub fn context<'a>(
66        &'a self,
67        cloudformation: &'a aws_sdk_cloudformation::Client,
68        template_uploader: Option<&'a TemplateUploader<'a>>,
69    ) -> Context<'a> {
70        Context {
71            cloudformation,
72            instance_spec: self,
73            template_uploader,
74        }
75    }
76
77    pub(crate) async fn watch(cloudformation: &aws_sdk_cloudformation::Client, stack_id: StackId) {
78        crate::events::Poll::default(stack_id)
79            .run(cloudformation, |stack_event| {
80                crate::events::print_event(stack_event)
81            })
82            .await;
83    }
84}
85
86pub struct Context<'a> {
87    cloudformation: &'a aws_sdk_cloudformation::Client,
88    instance_spec: &'a InstanceSpec,
89    template_uploader: Option<&'a TemplateUploader<'a>>,
90}
91
92impl Context<'_> {
93    pub async fn create_change_set(
94        &self,
95        change_set_name: &ChangeSetName,
96        user_parameter_map: &ParameterMap,
97    ) {
98        let existing_stack = load_stack(self.cloudformation, &self.instance_spec.stack_name).await;
99
100        let change_set_arn = self
101            .start_create_change_set(&existing_stack, change_set_name, user_parameter_map)
102            .await;
103
104        println!("ChangeSetArn: {}", change_set_arn.as_str());
105    }
106
107    pub async fn delete_change_set(&self, change_set_name: &ChangeSetName) {
108        self.cloudformation
109            .delete_change_set()
110            .change_set_name(change_set_name)
111            .stack_name(self.instance_spec.stack_name.as_str())
112            .send()
113            .await
114            .unwrap();
115    }
116
117    pub async fn describe_change_set(&self, change_set_name: &ChangeSetName) {
118        let output = self
119            .cloudformation
120            .describe_change_set()
121            .change_set_name(change_set_name)
122            .stack_name(self.instance_spec.stack_name.as_str())
123            .send()
124            .await
125            .unwrap();
126
127        crate::change_set::print_change_set_output(&output);
128    }
129
130    pub async fn list_change_sets(&self) {
131        let mut paginator = self
132            .cloudformation
133            .list_change_sets()
134            .stack_name(self.instance_spec.stack_name.as_str())
135            .into_paginator()
136            .send();
137
138        while let Some(result) = paginator.next().await {
139            for summary in result.unwrap().summaries.unwrap_or_default() {
140                eprintln!("{summary:#?}")
141            }
142        }
143    }
144
145    pub async fn delete(&self) {
146        let client_request_token = ClientRequestToken::generate();
147        let stack_id =
148            crate::stack::load_stack_id(self.cloudformation, &self.instance_spec.stack_name).await;
149
150        self.cloudformation
151            .delete_stack()
152            .client_request_token(&client_request_token)
153            .stack_name(self.instance_spec.stack_name.as_str())
154            .send()
155            .await
156            .unwrap();
157
158        Self::wait(
159            self.cloudformation,
160            RemoteOperation {
161                client_request_token,
162                stack_id,
163            },
164        )
165        .await
166    }
167
168    pub async fn create(&self, user_parameter_map: &ParameterMap) {
169        self.wait_for_final(self.start_create(user_parameter_map).await)
170            .await
171    }
172
173    pub async fn sync(
174        &self,
175        review_change_set: &ReviewChangeSet,
176        user_parameter_map: &ParameterMap,
177    ) {
178        match try_load_stack(self.cloudformation, &self.instance_spec.stack_name).await {
179            Some(existing_stack) => {
180                self.update_existing_stack(review_change_set, &existing_stack, user_parameter_map)
181                    .await
182            }
183            None => self.create(user_parameter_map).await,
184        }
185    }
186
187    pub async fn update(
188        &self,
189        review_change_set: &ReviewChangeSet,
190        user_parameter_map: &ParameterMap,
191    ) {
192        let existing_stack = load_stack(self.cloudformation, &self.instance_spec.stack_name).await;
193        self.update_existing_stack(review_change_set, &existing_stack, user_parameter_map)
194            .await
195    }
196
197    async fn update_existing_stack(
198        &self,
199        review_change_set: &ReviewChangeSet,
200        existing_stack: &aws_sdk_cloudformation::types::Stack,
201        user_parameter_map: &ParameterMap,
202    ) {
203        match review_change_set {
204            ReviewChangeSet::Interactive => {
205                self.update_interactive(existing_stack, user_parameter_map)
206                    .await
207            }
208            ReviewChangeSet::NoReview => {
209                self.wait_for_final_update(
210                    self.start_template_update(existing_stack, user_parameter_map)
211                        .await,
212                )
213                .await
214            }
215        }
216    }
217
218    async fn update_interactive(
219        &self,
220        existing_stack: &aws_sdk_cloudformation::types::Stack,
221        user_parameter_map: &ParameterMap,
222    ) {
223        use std::io::BufRead;
224
225        let change_set_name =
226            format!("interactive-{}", chrono::Utc::now().format("%Y%m%d%H%M%S")).into();
227
228        log::info!("Creating change set: {change_set_name}");
229
230        let change_set_arn = self
231            .start_create_change_set(existing_stack, &change_set_name, user_parameter_map)
232            .await;
233
234        log::info!("Created change set: {change_set_arn}");
235
236        self.wait_for_final_change_set_status(&change_set_arn).await;
237
238        let output = self
239            .cloudformation
240            .describe_change_set()
241            .change_set_name(&change_set_arn)
242            .include_property_values(true)
243            .send()
244            .await
245            .unwrap();
246
247        let stack_id: StackId = StackId(output.stack_id.clone().unwrap());
248
249        crate::change_set::print_change_set_output(&output);
250
251        println!("Apply? Type YES to proceed or send SIGERM");
252
253        for line in std::io::stdin().lock().lines() {
254            if line.unwrap() == "YES" {
255                break;
256            } else {
257                println!("Only YES please ;)")
258            }
259        }
260
261        let client_request_token = ClientRequestToken::generate();
262
263        self.cloudformation
264            .execute_change_set()
265            .change_set_name(change_set_arn)
266            .client_request_token(&client_request_token)
267            .send()
268            .await
269            .unwrap();
270
271        self.wait_for_final(RemoteOperation {
272            stack_id,
273            client_request_token,
274        })
275        .await
276    }
277
278    async fn wait_for_final_update(&self, result: Option<RemoteOperation>) {
279        match result {
280            None => log::info!("Stack is already up to date"),
281            Some(remote_operation) => self.wait_for_final(remote_operation).await,
282        }
283    }
284
285    async fn wait_for_final(&self, remote_operation: RemoteOperation) {
286        Self::wait(self.cloudformation, remote_operation).await
287    }
288
289    pub async fn parameter_update(&self, user_parameter_map: &ParameterMap) {
290        let result = self
291            .start_parameter_update(
292                self.cloudformation,
293                &load_stack(self.cloudformation, &self.instance_spec.stack_name).await,
294                user_parameter_map,
295            )
296            .await;
297
298        match result {
299            None => log::info!("Stack is already up to date"),
300            Some(remote_operation) => Self::wait(self.cloudformation, remote_operation).await,
301        }
302    }
303
304    async fn wait(
305        cloudformation: &aws_sdk_cloudformation::Client,
306        remote_operation: RemoteOperation,
307    ) {
308        crate::events::Poll::wait_for_remote_operation(remote_operation)
309            .run(cloudformation, crate::events::print_event)
310            .await
311    }
312
313    async fn wait_for_final_change_set_status(&self, change_set_arn: &ChangeSetArn) {
314        use aws_sdk_cloudformation::types::ChangeSetStatus;
315
316        enum Iteration {
317            Continue,
318            Stop,
319        }
320
321        loop {
322            let output = self
323                .cloudformation
324                .describe_change_set()
325                .change_set_name(change_set_arn)
326                .include_property_values(true)
327                .send()
328                .await
329                .unwrap();
330
331            let status = output.status.unwrap();
332
333            let (log_level, panic_message, iteration) = match status {
334                ChangeSetStatus::CreateComplete | ChangeSetStatus::DeleteComplete => {
335                    (log::Level::Info, None, Iteration::Stop)
336                }
337                ChangeSetStatus::CreateInProgress
338                | ChangeSetStatus::CreatePending
339                | ChangeSetStatus::DeleteInProgress
340                | ChangeSetStatus::DeletePending => (log::Level::Info, None, Iteration::Continue),
341                ChangeSetStatus::DeleteFailed => (
342                    log::Level::Error,
343                    Some("Failed to delete change set"),
344                    Iteration::Stop,
345                ),
346                ChangeSetStatus::Failed => (
347                    log::Level::Error,
348                    Some("Failed to create change set"),
349                    Iteration::Stop,
350                ),
351                unknown => panic!("Unknown change set status: {unknown:#?}"),
352            };
353
354            log::log!(
355                log_level,
356                "{change_set_arn}: {status}, {}",
357                match output.status_reason.as_ref() {
358                    Some(reason) => reason.as_str(),
359                    None => "",
360                }
361            );
362
363            if let Some(panic_message) = panic_message {
364                panic!("{panic_message}");
365            }
366
367            match iteration {
368                Iteration::Continue => {}
369                Iteration::Stop => break,
370            }
371
372            tokio::time::sleep(std::time::Duration::new(1, 0)).await
373        }
374    }
375
376    async fn start_create(&self, user_parameter_map: &ParameterMap) -> RemoteOperation {
377        let client_request_token = ClientRequestToken::generate();
378
379        let request = self
380            .cloudformation
381            .create_stack()
382            .stack_name(self.instance_spec.stack_name.as_str())
383            .set_parameters(Some(
384                self.instance_spec
385                    .parameter_map
386                    .merge(user_parameter_map)
387                    .to_create_parameters(),
388            ))
389            .set_capabilities(Some(self.capabilities()))
390            .set_tags(Some(self.instance_spec.tag_map.to_sdk_tags()))
391            .client_request_token(&client_request_token);
392
393        let stack_id = StackId(
394            self.set_create_template(request)
395                .await
396                .send()
397                .await
398                .unwrap()
399                .stack_id
400                .unwrap(),
401        );
402
403        RemoteOperation {
404            client_request_token,
405            stack_id,
406        }
407    }
408
409    pub async fn start_create_change_set(
410        &self,
411        existing_stack: &aws_sdk_cloudformation::types::Stack,
412        change_set_name: &ChangeSetName,
413        user_parameter_map: &ParameterMap,
414    ) -> ChangeSetArn {
415        let client_request_token = ClientRequestToken::generate();
416
417        let existing_stack_parameter_keys = existing_stack_parameter_keys(existing_stack);
418
419        let request = self
420            .cloudformation
421            .create_change_set()
422            .change_set_name(change_set_name)
423            .stack_name(existing_stack.stack_id.as_ref().unwrap())
424            .set_parameters(Some(
425                self.instance_spec
426                    .parameter_map
427                    .merge(user_parameter_map)
428                    .to_template_update_parameters(
429                        &self.instance_spec.template.parameter_keys(),
430                        &existing_stack_parameter_keys,
431                    ),
432            ))
433            .set_capabilities(Some(self.capabilities()))
434            .set_tags(Some(self.instance_spec.tag_map.to_sdk_tags()))
435            .client_token(&client_request_token);
436
437        let output = self
438            .set_create_change_set_template(request)
439            .await
440            .send()
441            .await
442            .unwrap();
443
444        ChangeSetArn(output.id.unwrap())
445    }
446
447    async fn start_template_update(
448        &self,
449        existing_stack: &aws_sdk_cloudformation::types::Stack,
450        user_parameter_map: &ParameterMap,
451    ) -> Option<RemoteOperation> {
452        let client_request_token = ClientRequestToken::generate();
453        let existing_stack_parameter_keys = existing_stack_parameter_keys(existing_stack);
454
455        let request = self
456            .cloudformation
457            .update_stack()
458            .stack_name(existing_stack.stack_id.as_ref().unwrap())
459            .set_parameters(Some(
460                self.instance_spec
461                    .parameter_map
462                    .merge(user_parameter_map)
463                    .to_template_update_parameters(
464                        &self.instance_spec.template.parameter_keys(),
465                        &existing_stack_parameter_keys,
466                    ),
467            ))
468            .set_capabilities(Some(self.capabilities()))
469            .set_tags(Some(self.instance_spec.tag_map.to_sdk_tags()))
470            .client_request_token(&client_request_token);
471
472        let response = self.set_update_template(request).await.send().await;
473
474        Self::process_update_response(client_request_token, response)
475    }
476
477    async fn start_parameter_update(
478        &self,
479        cloudformation: &aws_sdk_cloudformation::Client,
480        existing_stack: &aws_sdk_cloudformation::types::Stack,
481        user_parameter_map: &ParameterMap,
482    ) -> Option<RemoteOperation> {
483        let client_request_token = ClientRequestToken::generate();
484        let response = cloudformation
485            .update_stack()
486            .stack_name(existing_stack.stack_id.as_ref().unwrap())
487            .set_parameters(Some(
488                user_parameter_map.to_parameter_update_parameters(existing_stack),
489            ))
490            .set_capabilities(Some(self.capabilities()))
491            .set_tags(Some(self.instance_spec.tag_map.to_sdk_tags()))
492            .client_request_token(&client_request_token)
493            .use_previous_template(true)
494            .send()
495            .await;
496
497        Self::process_update_response(client_request_token, response)
498    }
499
500    fn process_update_response(
501        client_request_token: ClientRequestToken,
502        result: Result<
503            aws_sdk_cloudformation::operation::update_stack::UpdateStackOutput,
504            aws_sdk_cloudformation::error::SdkError<
505                aws_sdk_cloudformation::operation::update_stack::UpdateStackError,
506            >,
507        >,
508    ) -> Option<RemoteOperation> {
509        match result {
510            Ok(output) => Some(RemoteOperation {
511                client_request_token,
512                stack_id: StackId(output.stack_id.unwrap()),
513            }),
514            Err(error) => {
515                let service_error = error.into_service_error();
516                let meta = service_error.meta();
517
518                match meta.code() {
519                    // CF API has no more direct signal that an update is a noop.
520                    Some("ValidationError")
521                        if meta.message() == Some("No updates are to be performed.") =>
522                    {
523                        None
524                    }
525                    _ => panic!("unexpected service error: {service_error:#?}"),
526                }
527            }
528        }
529    }
530
531    fn capabilities(&self) -> Vec<aws_sdk_cloudformation::types::Capability> {
532        self.instance_spec.capabilities.iter().cloned().collect()
533    }
534
535    fn template_rendered(&self) -> TemplateRendered {
536        self.instance_spec.template.rendered()
537    }
538
539    async fn set_create_template(
540        &self,
541        request: aws_sdk_cloudformation::operation::create_stack::builders::CreateStackFluentBuilder,
542    ) -> aws_sdk_cloudformation::operation::create_stack::builders::CreateStackFluentBuilder {
543        match self.upload_template().await {
544            Ok(template_url) => request.template_url(template_url),
545            Err(template_body) => request.template_body(template_body),
546        }
547    }
548
549    async fn set_create_change_set_template(
550        &self,
551        request: aws_sdk_cloudformation::operation::create_change_set::builders::CreateChangeSetFluentBuilder,
552    ) -> aws_sdk_cloudformation::operation::create_change_set::builders::CreateChangeSetFluentBuilder
553    {
554        match self.upload_template().await {
555            Ok(template_url) => request.template_url(template_url),
556            Err(template_body) => request.template_body(template_body),
557        }
558    }
559
560    async fn set_update_template(
561        &self,
562        request: aws_sdk_cloudformation::operation::update_stack::builders::UpdateStackFluentBuilder,
563    ) -> aws_sdk_cloudformation::operation::update_stack::builders::UpdateStackFluentBuilder {
564        match self.upload_template().await {
565            Ok(template_url) => request.template_url(template_url),
566            Err(template_body) => request.template_body(template_body),
567        }
568    }
569
570    async fn upload_template(&self) -> Result<TemplateUrl, TemplateBody> {
571        let template_rendered = self.template_rendered();
572
573        if template_rendered.body.needs_upload() {
574            log::debug!("Template is to big for inline, uploading it");
575
576            let template_uploader = self
577                .template_uploader
578                .as_ref()
579                .expect("Template needs upload but template uploader not configured!");
580
581            Ok(template_uploader.upload(&template_rendered).await)
582        } else {
583            Err(template_rendered.body)
584        }
585    }
586}
587
588pub struct Registry(pub Vec<InstanceSpec>);
589
590impl Registry {
591    #[must_use]
592    pub fn find(&self, instance_name: &StackName) -> Option<&InstanceSpec> {
593        self.0
594            .iter()
595            .find(|&instance_spec| instance_spec.stack_name == *instance_name)
596    }
597
598    #[must_use]
599    pub fn templates(&self) -> std::collections::BTreeMap<TemplateName, &Template> {
600        let mut map = std::collections::BTreeMap::new();
601
602        for instance_spec in &self.0 {
603            let template = &instance_spec.template;
604            let name = template.name();
605
606            if let Some(other) = map.insert(name.clone(), template)
607                && other != template
608            {
609                panic!("Template name clash for unequal templates: {name:#?}")
610            }
611        }
612
613        map
614    }
615
616    pub fn golden_tests(&self, path: &std::path::Path) {
617        let mut base: std::path::PathBuf = path.into();
618        base.push("templates");
619        std::fs::create_dir_all(&base).unwrap();
620
621        for (_name, template) in self.templates() {
622            verify_template(&base, template)
623        }
624    }
625}
626
627fn verify_template(base: &std::path::Path, template: &Template) {
628    let new = template.rendered_pretty();
629
630    let mut template_path: std::path::PathBuf = base.into();
631
632    template_path.push(format!(
633        "{}.{}",
634        template.name().as_str(),
635        new.format.file_ext()
636    ));
637
638    if std::env::var("UPDATE_GOLDEN_TESTS").is_ok() {
639        if let Ok(existing) = std::fs::read_to_string(&template_path)
640            && new.body == existing.into()
641        {
642            return;
643        }
644        eprintln!("Updating: {}", template_path.display());
645        std::fs::write(template_path, new.body).unwrap();
646    } else {
647        let expected = std::fs::read_to_string(&template_path)
648            .unwrap_or_else(|error| panic!("Could not open: {}: {error}", template_path.display()));
649
650        assert_eq!(&expected, new.body.as_str())
651    }
652}
653
654fn existing_stack_parameter_keys(
655    existing_stack: &aws_sdk_cloudformation::types::Stack,
656) -> BTreeSet<ParameterKey> {
657    BTreeSet::from_iter(match &existing_stack.parameters {
658        None => vec![],
659        Some(parameters) => parameters
660            .iter()
661            .map(|parameter| ParameterKey(parameter.parameter_key.clone().unwrap()))
662            .collect(),
663    })
664}