Skip to main content

stack_deploy/lambda/
deploy.rs

1use crate::instance_spec::{InstanceSpec, ReviewChangeSet, TemplateUploader};
2use crate::types::{ParameterKey, ParameterMap, ParameterValue};
3use sha2::Digest;
4
5pub struct BinaryName(pub String);
6pub struct BuildTarget(pub String);
7pub struct S3ObjectKey(pub String);
8
9impl From<&S3ObjectKey> for String {
10    fn from(value: &S3ObjectKey) -> String {
11        value.0.clone()
12    }
13}
14
15#[derive(Clone)]
16pub struct S3BucketName(pub String);
17
18impl From<&S3BucketName> for String {
19    fn from(value: &S3BucketName) -> String {
20        value.0.clone()
21    }
22}
23
24pub enum BuildType {
25    Debug,
26    Release,
27}
28
29impl BuildType {
30    fn path(&self) -> &str {
31        match self {
32            Self::Debug => "debug",
33            Self::Release => "release",
34        }
35    }
36
37    fn args(&self) -> &[&str] {
38        match self {
39            Self::Debug => &[],
40            Self::Release => &["--release"],
41        }
42    }
43}
44
45pub enum S3BucketSource {
46    Static(S3BucketName),
47    StackOutput {
48        stack_name: crate::types::StackName,
49        output_key: crate::types::OutputKey,
50    },
51}
52
53pub struct ZipFile {
54    body: aws_sdk_s3::primitives::ByteStream,
55    s3_object_key: S3ObjectKey,
56}
57
58impl ZipFile {
59    pub async fn upload(
60        self,
61        s3: &aws_sdk_s3::client::Client,
62        s3_bucket_name: &S3BucketName,
63    ) -> ParameterValue {
64        if !self.object_exists(s3, s3_bucket_name).await {
65            s3.put_object()
66                .bucket(s3_bucket_name)
67                .key(&self.s3_object_key)
68                .body(self.body)
69                .send()
70                .await
71                .unwrap();
72        }
73
74        ParameterValue(self.s3_object_key.0.clone())
75    }
76
77    async fn object_exists(
78        &self,
79        s3: &aws_sdk_s3::client::Client,
80        s3_bucket_name: &S3BucketName,
81    ) -> bool {
82        let result = s3
83            .head_object()
84            .bucket(s3_bucket_name)
85            .key(&self.s3_object_key.0)
86            .send()
87            .await;
88
89        match result {
90            Err(error) => match error.into_service_error() {
91                aws_sdk_s3::operation::head_object::HeadObjectError::NotFound { .. } => false,
92                other => panic!("Unexpected head object error: {other:#?}"),
93            },
94            Ok(_) => true,
95        }
96    }
97}
98
99pub struct Target {
100    pub binary_name: BinaryName,
101    pub build_target: BuildTarget,
102    pub build_type: BuildType,
103    pub extra_files: std::collections::BTreeMap<std::path::PathBuf, std::path::PathBuf>,
104}
105
106impl Target {
107    /// Path to target
108    ///
109    /// ### Examples
110    ///
111    /// ```
112    /// # use crate::stack_deploy::lambda::deploy::*;
113    ///
114    /// assert_eq!(
115    ///     std::path::PathBuf::from("./target/example-target/debug/example-binary"),
116    ///     Target {
117    ///         binary_name: BinaryName(String::from("example-binary")),
118    ///         build_target: BuildTarget(String::from("example-target")),
119    ///         build_type: BuildType::Debug,
120    ///         extra_files: std::collections::BTreeMap::new(),
121    ///     }
122    ///     .path()
123    /// )
124    /// ```
125    #[must_use]
126    pub fn path(&self) -> std::path::PathBuf {
127        std::path::Path::new("./target")
128            .join(&self.build_target.0)
129            .join(self.build_type.path())
130            .join(&self.binary_name.0)
131    }
132
133    /// Build the lambda target via cargo
134    pub async fn build(&self) {
135        log::info!("Building lambda target");
136        cmd_proc::Command::new("cargo")
137            .arguments(["build", "--target", &self.build_target.0])
138            .arguments(self.build_type.args())
139            .status()
140            .await
141            .unwrap_or_else(|error| panic!("Failed to build lambda target: {error}"));
142    }
143
144    /// Build the lambda target and generate zip file
145    pub async fn build_zip(&self) -> ZipFile {
146        self.build().await;
147        self.generate_zip()
148    }
149
150    /// Generate the zip file from target read from ./target
151    fn generate_zip(&self) -> ZipFile {
152        let path = self.path();
153        log::info!("Reading binary from: {}", path.display());
154
155        let binary = std::fs::read(path).unwrap();
156
157        log::info!("Compressing binary into zip");
158
159        let mut cursor: std::io::Cursor<Vec<u8>> = std::io::Cursor::new(vec![]);
160        let mut zip = zip::write::ZipWriter::new(&mut cursor);
161        zip.start_file::<&str, ()>("bootstrap", Self::zip_file_options(0o555))
162            .unwrap();
163        std::io::Write::write_all(&mut zip, binary.as_ref()).unwrap();
164
165        self.write_extra_files(&mut zip);
166
167        zip.finish().unwrap();
168
169        log::info!("Computing zip hash");
170        let body = cursor.into_inner();
171        let hash = hex::encode(sha2::Sha256::digest(&body).as_slice());
172        log::info!("Content hash: {hash}");
173
174        ZipFile {
175            body: aws_sdk_s3::primitives::ByteStream::from(body),
176            s3_object_key: S3ObjectKey(format!("{hash}.zip")),
177        }
178    }
179
180    fn write_extra_files<W: std::io::Write + std::io::Seek>(
181        &self,
182        zip: &mut zip::write::ZipWriter<W>,
183    ) {
184        for (host, target) in &self.extra_files {
185            zip.start_file_from_path(target, Self::zip_file_options(0o444))
186                .unwrap();
187
188            std::io::copy(&mut std::fs::File::open(host).unwrap(), zip).unwrap();
189        }
190    }
191
192    fn zip_file_options(unix_permissions: u32) -> zip::write::FileOptions<'static, ()> {
193        zip::write::FileOptions::default()
194            .unix_permissions(unix_permissions)
195            // We want byte wise deterministic zip files
196            // The default is to encode the current time which
197            // would not be stable.
198            .last_modified_time(zip::DateTime::default())
199    }
200
201    pub async fn deploy_parameter_update(
202        s3: &aws_sdk_s3::client::Client,
203        cloudformation: &aws_sdk_cloudformation::Client,
204        s3_bucket_name: &S3BucketName,
205        instance_spec: &InstanceSpec,
206        parameter_key: &ParameterKey,
207        template_uploader: &Option<TemplateUploader<'_>>,
208        zip_file: ZipFile,
209    ) {
210        let parameter_value = zip_file.upload(s3, s3_bucket_name).await;
211
212        instance_spec
213            .context(cloudformation, template_uploader.as_ref())
214            .parameter_update(&ParameterMap(std::collections::BTreeMap::from([(
215                parameter_key.clone(),
216                parameter_value,
217            )])))
218            .await;
219    }
220
221    #[allow(clippy::too_many_arguments)]
222    pub async fn deploy_template_update(
223        s3: &aws_sdk_s3::client::Client,
224        cloudformation: &aws_sdk_cloudformation::Client,
225        s3_bucket_name: &S3BucketName,
226        instance_spec: &InstanceSpec,
227        review_change_set: &ReviewChangeSet,
228        parameter_key: &ParameterKey,
229        template_uploader: &Option<TemplateUploader<'_>>,
230        zip_file: ZipFile,
231    ) {
232        let parameter_value = zip_file.upload(s3, s3_bucket_name).await;
233
234        instance_spec
235            .context(cloudformation, template_uploader.as_ref())
236            .update(
237                review_change_set,
238                &ParameterMap(std::collections::BTreeMap::from([(
239                    parameter_key.clone(),
240                    parameter_value,
241                )])),
242            )
243            .await;
244    }
245}
246
247pub mod cli {
248    use crate::instance_spec::{Registry, ReviewChangeSet, TemplateUploader};
249    use crate::lambda::deploy::S3BucketSource;
250    use crate::types::{ParameterKey, ParameterMap, ParameterValue, StackName};
251
252    #[derive(Clone, Debug, Eq, PartialEq, clap::Parser)]
253    pub struct App {
254        #[clap(subcommand)]
255        command: Command,
256    }
257
258    impl App {
259        pub async fn run(&self, config: &'_ Config<'_>) {
260            self.command.run(config).await
261        }
262    }
263
264    pub struct Config<'a> {
265        pub cloudformation: &'a aws_sdk_cloudformation::client::Client,
266        pub parameter_key: ParameterKey,
267        pub registry: Registry,
268        pub s3: &'a aws_sdk_s3::client::Client,
269        pub s3_bucket_source: S3BucketSource,
270        pub target: crate::lambda::deploy::Target,
271        pub template_uploader: Option<&'a TemplateUploader<'a>>,
272    }
273
274    impl Config<'_> {
275        pub(crate) async fn build(&self) {
276            self.target.build().await
277        }
278
279        pub(crate) async fn upload(&self) -> ParameterValue {
280            let s3_bucket_name = self.load_s3_bucket_name().await;
281
282            let parameter_value = self
283                .target
284                .build_zip()
285                .await
286                .upload(self.s3, &s3_bucket_name)
287                .await;
288
289            log::info!("Lambda object key: {}", parameter_value.0);
290
291            parameter_value
292        }
293
294        pub(crate) async fn deploy_template(
295            &self,
296            stack_name: &StackName,
297            review_change_set: &ReviewChangeSet,
298        ) {
299            let instance_spec = self
300                .registry
301                .find(stack_name)
302                .expect("instance spec not registered");
303
304            let parameter_value = self.upload().await;
305
306            instance_spec
307                .context(self.cloudformation, self.template_uploader)
308                .sync(
309                    review_change_set,
310                    &ParameterMap(std::collections::BTreeMap::from([(
311                        self.parameter_key.clone(),
312                        parameter_value,
313                    )])),
314                )
315                .await
316        }
317
318        pub(crate) async fn deploy_parameter(&self, stack_name: &StackName) {
319            let instance_spec = self
320                .registry
321                .find(stack_name)
322                .expect("instance spec not registered");
323
324            let parameter_value = self.upload().await;
325
326            instance_spec
327                .context(self.cloudformation, self.template_uploader)
328                .parameter_update(&ParameterMap(std::collections::BTreeMap::from([(
329                    self.parameter_key.clone(),
330                    parameter_value,
331                )])))
332                .await
333        }
334
335        async fn load_s3_bucket_name(&self) -> crate::lambda::deploy::S3BucketName {
336            match &self.s3_bucket_source {
337                S3BucketSource::StackOutput {
338                    stack_name,
339                    output_key,
340                } => crate::lambda::deploy::S3BucketName(
341                    crate::stack::read_stack_output(self.cloudformation, stack_name, output_key)
342                        .await,
343                ),
344                S3BucketSource::Static(s3_bucket_name) => s3_bucket_name.clone(),
345            }
346        }
347    }
348
349    #[derive(Clone, Debug, Eq, PartialEq, clap::Subcommand)]
350    pub enum Command {
351        /// Deploy lambda function with template update
352        DeployTemplate {
353            /// Instance spec name to deploy to
354            #[arg(long = "stack-name")]
355            name: StackName,
356            #[arg(long, default_value = "interactive")]
357            review_change_set: ReviewChangeSet,
358        },
359        /// Deploy lambda function with parameter update
360        DeployParameter {
361            /// Instance spec name to deploy to
362            #[arg(long = "stack-name")]
363            name: StackName,
364        },
365        /// Build lambda function
366        Build,
367        /// Upload lambda function
368        Upload,
369    }
370
371    impl Command {
372        pub async fn run(&self, config: &'_ Config<'_>) {
373            match self {
374                Self::Build => config.build().await,
375                Self::Upload => {
376                    config.upload().await;
377                }
378                Self::DeployTemplate {
379                    name,
380                    review_change_set,
381                } => config.deploy_template(name, review_change_set).await,
382                Self::DeployParameter { name } => config.deploy_parameter(name).await,
383            }
384        }
385    }
386}