cargo_lambda_metadata/cargo/
deploy.rs

1use cargo_lambda_remote::{
2    RemoteConfig,
3    aws_sdk_lambda::types::{Environment, TracingConfig},
4};
5use clap::{ArgAction, Args, ValueHint};
6use serde::{Deserialize, Serialize, ser::SerializeStruct};
7use std::{collections::HashMap, fmt::Debug, path::PathBuf};
8use strum_macros::{Display, EnumString};
9
10use crate::{
11    env::EnvOptions,
12    error::MetadataError,
13    lambda::{Memory, MemoryValueParser, Timeout, Tracing},
14};
15
16use crate::cargo::deserialize_vec_or_map;
17
18const DEFAULT_MANIFEST_PATH: &str = "Cargo.toml";
19const DEFAULT_COMPATIBLE_RUNTIMES: &str = "provided.al2,provided.al2023";
20const DEFAULT_RUNTIME: &str = "provided.al2023";
21
22#[derive(Args, Clone, Debug, Default, Deserialize)]
23#[command(
24    name = "deploy",
25    after_help = "Full command documentation: https://www.cargo-lambda.info/commands/deploy.html"
26)]
27pub struct Deploy {
28    #[command(flatten)]
29    #[serde(flatten)]
30    pub remote_config: RemoteConfig,
31
32    #[command(flatten)]
33    #[serde(flatten)]
34    pub function_config: FunctionDeployConfig,
35
36    /// Directory where the lambda binaries are located
37    #[arg(short, long, value_hint = ValueHint::DirPath)]
38    #[serde(default)]
39    pub lambda_dir: Option<PathBuf>,
40
41    /// Path to Cargo.toml
42    #[arg(long, value_name = "PATH", default_value = DEFAULT_MANIFEST_PATH)]
43    #[serde(default)]
44    pub manifest_path: Option<PathBuf>,
45
46    /// Name of the binary to deploy if it doesn't match the name that you want to deploy it with
47    #[arg(long, conflicts_with = "binary_path")]
48    #[serde(default)]
49    pub binary_name: Option<String>,
50
51    /// Local path of the binary to deploy if it doesn't match the target path generated by cargo-lambda-build
52    #[arg(long, conflicts_with = "binary_name")]
53    #[serde(default)]
54    pub binary_path: Option<PathBuf>,
55
56    /// S3 bucket to upload the code to
57    #[arg(long)]
58    #[serde(default)]
59    pub s3_bucket: Option<String>,
60
61    /// Name with prefix where the code will be uploaded to in S3
62    #[arg(long)]
63    #[serde(default)]
64    pub s3_key: Option<String>,
65
66    /// Whether the code that you're deploying is a Lambda Extension
67    #[arg(long)]
68    #[serde(default)]
69    pub extension: bool,
70
71    /// Whether an extension is internal or external
72    #[arg(long, requires = "extension")]
73    #[serde(default)]
74    pub internal: bool,
75
76    /// Comma separated list with compatible runtimes for the Lambda Extension (--compatible_runtimes=provided.al2,nodejs16.x)
77    /// List of allowed runtimes can be found in the AWS documentation: https://docs.aws.amazon.com/lambda/latest/dg/API_CreateFunction.html#SSS-CreateFunction-request-Runtime
78    #[arg(
79        long,
80        value_delimiter = ',',
81        default_value = DEFAULT_COMPATIBLE_RUNTIMES,
82        requires = "extension"
83    )]
84    #[serde(default)]
85    compatible_runtimes: Option<Vec<String>>,
86
87    /// Format to render the output (text, or json)
88    #[arg(short, long)]
89    #[serde(default)]
90    output_format: Option<OutputFormat>,
91
92    /// Comma separated list of tags to apply to the function or extension (--tag organization=aws,team=lambda).
93    /// It can be used multiple times to add more tags. (--tag organization=aws --tag team=lambda)
94    #[arg(long, value_delimiter = ',', action = ArgAction::Append, visible_alias = "tags")]
95    #[serde(default, alias = "tags", deserialize_with = "deserialize_vec_or_map")]
96    pub tag: Option<Vec<String>>,
97
98    /// Option to add one or more files and directories to include in the zip file to upload.
99    #[arg(short, long)]
100    #[serde(default)]
101    pub include: Option<Vec<String>>,
102
103    /// Perform all the operations to locate and package the binary to deploy, but don't do the final deploy.
104    #[arg(long, alias = "dry-run")]
105    #[serde(default)]
106    pub dry: bool,
107
108    /// Name of the function or extension to deploy
109    #[arg(value_name = "NAME")]
110    #[serde(default)]
111    pub name: Option<String>,
112
113    #[arg(skip)]
114    #[serde(skip)]
115    pub base_env: HashMap<String, String>,
116}
117
118impl Deploy {
119    pub fn manifest_path(&self) -> PathBuf {
120        self.manifest_path
121            .clone()
122            .unwrap_or_else(default_manifest_path)
123    }
124
125    pub fn output_format(&self) -> OutputFormat {
126        self.output_format.clone().unwrap_or_default()
127    }
128
129    pub fn compatible_runtimes(&self) -> Vec<String> {
130        self.compatible_runtimes
131            .clone()
132            .unwrap_or_else(default_compatible_runtimes)
133    }
134
135    pub fn tracing_config(&self) -> Option<TracingConfig> {
136        let tracing = self.function_config.tracing.clone()?;
137
138        Some(
139            TracingConfig::builder()
140                .mode(tracing.as_str().into())
141                .build(),
142        )
143    }
144
145    pub fn lambda_tags(&self) -> Option<HashMap<String, String>> {
146        match &self.tag {
147            None => None,
148            Some(tags) if tags.is_empty() => None,
149            Some(tags) => Some(extract_tags(tags)),
150        }
151    }
152
153    pub fn s3_tags(&self) -> Option<String> {
154        match &self.tag {
155            None => None,
156            Some(tags) if tags.is_empty() => None,
157            Some(tags) => Some(tags.join("&")),
158        }
159    }
160
161    pub fn lambda_environment(&self) -> Result<Option<Environment>, MetadataError> {
162        let builder = Environment::builder();
163
164        let env = match &self.function_config.env_options {
165            None => self.base_env.clone(),
166            Some(env_options) => env_options.lambda_environment(&self.base_env)?,
167        };
168
169        if env.is_empty() {
170            return Ok(None);
171        }
172
173        Ok(Some(builder.set_variables(Some(env)).build()))
174    }
175
176    pub fn publish_code_without_description(&self) -> bool {
177        self.function_config.description.is_none()
178    }
179}
180
181impl Serialize for Deploy {
182    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
183    where
184        S: serde::Serializer,
185    {
186        use serde::ser::SerializeStruct;
187
188        let len = self.manifest_path.is_some() as usize
189            + self.lambda_dir.is_some() as usize
190            + self.binary_path.is_some() as usize
191            + self.binary_name.is_some() as usize
192            + self.s3_bucket.is_some() as usize
193            + self.s3_key.is_some() as usize
194            + self.extension as usize
195            + self.internal as usize
196            + self.compatible_runtimes.is_some() as usize
197            + self.output_format.is_some() as usize
198            + self.tag.is_some() as usize
199            + self.include.is_some() as usize
200            + self.dry as usize
201            + self.name.is_some() as usize
202            + self.remote_config.count_fields()
203            + self.function_config.count_fields();
204
205        let mut state = serializer.serialize_struct("Deploy", len)?;
206
207        if let Some(ref path) = self.manifest_path {
208            state.serialize_field("manifest_path", path)?;
209        }
210        if let Some(ref dir) = self.lambda_dir {
211            state.serialize_field("lambda_dir", dir)?;
212        }
213        if let Some(ref path) = self.binary_path {
214            state.serialize_field("binary_path", path)?;
215        }
216        if let Some(ref name) = self.binary_name {
217            state.serialize_field("binary_name", name)?;
218        }
219        if let Some(ref bucket) = self.s3_bucket {
220            state.serialize_field("s3_bucket", bucket)?;
221        }
222        if let Some(ref key) = self.s3_key {
223            state.serialize_field("s3_key", key)?;
224        }
225        if self.extension {
226            state.serialize_field("extension", &self.extension)?;
227        }
228        if self.internal {
229            state.serialize_field("internal", &self.internal)?;
230        }
231        if let Some(ref runtimes) = self.compatible_runtimes {
232            state.serialize_field("compatible_runtimes", runtimes)?;
233        }
234        if let Some(ref format) = self.output_format {
235            state.serialize_field("output_format", format)?;
236        }
237        if let Some(ref tag) = self.tag {
238            state.serialize_field("tag", tag)?;
239        }
240        if let Some(ref include) = self.include {
241            state.serialize_field("include", include)?;
242        }
243        if self.dry {
244            state.serialize_field("dry", &self.dry)?;
245        }
246        if let Some(ref name) = self.name {
247            state.serialize_field("name", name)?;
248        }
249
250        self.remote_config.serialize_fields::<S>(&mut state)?;
251        self.function_config.serialize_fields::<S>(&mut state)?;
252
253        state.end()
254    }
255}
256
257fn default_manifest_path() -> PathBuf {
258    PathBuf::from(DEFAULT_MANIFEST_PATH)
259}
260
261fn default_compatible_runtimes() -> Vec<String> {
262    DEFAULT_COMPATIBLE_RUNTIMES
263        .split(',')
264        .map(String::from)
265        .collect()
266}
267
268#[derive(Clone, Debug, Default, Deserialize, Display, EnumString, Serialize)]
269#[strum(ascii_case_insensitive)]
270#[serde(rename_all = "lowercase")]
271pub enum OutputFormat {
272    #[default]
273    Text,
274    Json,
275}
276
277#[derive(Args, Clone, Debug, Default, Deserialize, Serialize)]
278pub struct FunctionDeployConfig {
279    /// Enable function URL for this function
280    #[arg(long)]
281    #[serde(default)]
282    pub enable_function_url: bool,
283
284    /// Disable function URL for this function
285    #[arg(long)]
286    #[serde(default)]
287    pub disable_function_url: bool,
288
289    /// Memory allocated for the function. Value must be between 128 and 10240.
290    #[arg(long, alias = "memory-size", value_parser = MemoryValueParser)]
291    #[serde(default)]
292    pub memory: Option<Memory>,
293
294    /// How long the function can be running for, in seconds
295    #[arg(long)]
296    #[serde(default)]
297    pub timeout: Option<Timeout>,
298
299    #[command(flatten)]
300    #[serde(flatten)]
301    pub env_options: Option<EnvOptions>,
302
303    /// Tracing mode with X-Ray
304    #[arg(long)]
305    #[serde(default)]
306    pub tracing: Option<Tracing>,
307
308    /// IAM Role associated with the function
309    #[arg(long, visible_alias = "iam-role")]
310    #[serde(default, alias = "iam_role")]
311    pub role: Option<String>,
312
313    /// Lambda Layer ARN to associate the deployed function with.
314    /// Can be used multiple times to add more layers.
315    /// `--layer arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer1 --layer arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer2`.
316    /// It can also be used with comma separated list of layer ARNs.
317    /// `--layer arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer1,arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer2`.
318    #[arg(long, value_delimiter = ',', action = ArgAction::Append, visible_alias = "layer-arn")]
319    #[serde(default, alias = "layers")]
320    pub layer: Option<Vec<String>>,
321
322    #[command(flatten)]
323    #[serde(flatten)]
324    pub vpc: Option<VpcConfig>,
325
326    /// Choose a different Lambda runtime to deploy with.
327    /// The only other option that might work is `provided.al2`.
328    #[arg(long, default_value = DEFAULT_RUNTIME)]
329    #[serde(default)]
330    pub runtime: Option<String>,
331
332    /// A description for the new function version.
333    #[arg(long)]
334    #[serde(default)]
335    pub description: Option<String>,
336
337    /// Retention policy for the function's log group.
338    /// The value is the number of days to keep the logs.
339    #[arg(long)]
340    #[serde(default)]
341    pub log_retention: Option<i32>,
342}
343
344fn default_runtime() -> String {
345    DEFAULT_RUNTIME.to_string()
346}
347
348impl FunctionDeployConfig {
349    pub fn runtime(&self) -> String {
350        self.runtime.clone().unwrap_or_else(default_runtime)
351    }
352
353    pub fn should_update(&self) -> bool {
354        let Ok(val) = serde_json::to_value(self) else {
355            return false;
356        };
357        let Ok(default) = serde_json::to_value(FunctionDeployConfig::default()) else {
358            return false;
359        };
360        val != default
361    }
362
363    fn count_fields(&self) -> usize {
364        self.disable_function_url as usize
365            + self.enable_function_url as usize
366            + self.layer.as_ref().is_some_and(|l| !l.is_empty()) as usize
367            + self.tracing.is_some() as usize
368            + self.role.is_some() as usize
369            + self.memory.is_some() as usize
370            + self.timeout.is_some() as usize
371            + self.runtime.is_some() as usize
372            + self.description.is_some() as usize
373            + self.log_retention.is_some() as usize
374            + self.vpc.as_ref().map_or(0, |vpc| vpc.count_fields())
375            + self
376                .env_options
377                .as_ref()
378                .map_or(0, |env| env.count_fields())
379    }
380
381    fn serialize_fields<S>(
382        &self,
383        state: &mut <S as serde::Serializer>::SerializeStruct,
384    ) -> Result<(), S::Error>
385    where
386        S: serde::Serializer,
387    {
388        if self.disable_function_url {
389            state.serialize_field("disable_function_url", &true)?;
390        }
391
392        if self.enable_function_url {
393            state.serialize_field("enable_function_url", &true)?;
394        }
395
396        if let Some(memory) = &self.memory {
397            state.serialize_field("memory", &memory)?;
398        }
399
400        if let Some(timeout) = &self.timeout {
401            state.serialize_field("timeout", &timeout)?;
402        }
403
404        if let Some(runtime) = &self.runtime {
405            state.serialize_field("runtime", &runtime)?;
406        }
407
408        if let Some(tracing) = &self.tracing {
409            state.serialize_field("tracing", &tracing)?;
410        }
411
412        if let Some(role) = &self.role {
413            state.serialize_field("role", &role)?;
414        }
415
416        if let Some(layer) = &self.layer {
417            if !layer.is_empty() {
418                state.serialize_field("layer", &layer)?;
419            }
420        }
421
422        if let Some(description) = &self.description {
423            state.serialize_field("description", &description)?;
424        }
425
426        if let Some(log_retention) = &self.log_retention {
427            state.serialize_field("log_retention", &log_retention)?;
428        }
429
430        if let Some(vpc) = &self.vpc {
431            vpc.serialize_fields::<S>(state)?;
432        }
433
434        if let Some(env_options) = &self.env_options {
435            env_options.serialize_fields::<S>(state)?;
436        }
437
438        Ok(())
439    }
440}
441
442#[derive(Args, Clone, Debug, Default, Deserialize, Serialize)]
443pub struct VpcConfig {
444    /// Subnet IDs to associate the deployed function with a VPC
445    #[arg(long, value_delimiter = ',')]
446    #[serde(default)]
447    pub subnet_ids: Option<Vec<String>>,
448
449    /// Security Group IDs to associate the deployed function
450    #[arg(long, value_delimiter = ',')]
451    #[serde(default)]
452    pub security_group_ids: Option<Vec<String>>,
453
454    /// Allow outbound IPv6 traffic on VPC functions that are connected to dual-stack subnets
455    #[arg(long)]
456    #[serde(default, skip_serializing_if = "is_false")]
457    pub ipv6_allowed_for_dual_stack: bool,
458}
459
460fn is_false(b: &bool) -> bool {
461    !b
462}
463
464impl VpcConfig {
465    fn count_fields(&self) -> usize {
466        self.subnet_ids.is_some() as usize
467            + self.security_group_ids.is_some() as usize
468            + self.ipv6_allowed_for_dual_stack as usize
469    }
470
471    fn serialize_fields<S>(
472        &self,
473        state: &mut <S as serde::Serializer>::SerializeStruct,
474    ) -> Result<(), S::Error>
475    where
476        S: serde::Serializer,
477    {
478        if let Some(subnet_ids) = &self.subnet_ids {
479            state.serialize_field("subnet_ids", &subnet_ids)?;
480        }
481        if let Some(security_group_ids) = &self.security_group_ids {
482            state.serialize_field("security_group_ids", &security_group_ids)?;
483        }
484        state.serialize_field(
485            "ipv6_allowed_for_dual_stack",
486            &self.ipv6_allowed_for_dual_stack,
487        )?;
488        Ok(())
489    }
490
491    pub fn should_update(&self) -> bool {
492        let Ok(val) = serde_json::to_value(self) else {
493            return false;
494        };
495        let Ok(default) = serde_json::to_value(VpcConfig::default()) else {
496            return false;
497        };
498        val != default
499    }
500}
501
502fn extract_tags(tags: &Vec<String>) -> HashMap<String, String> {
503    let mut map = HashMap::new();
504
505    for var in tags {
506        let mut split = var.splitn(2, '=');
507        if let (Some(k), Some(v)) = (split.next(), split.next()) {
508            map.insert(k.to_string(), v.to_string());
509        }
510    }
511
512    map
513}
514
515#[cfg(test)]
516mod tests {
517    use crate::{
518        cargo::load_metadata,
519        config::{ConfigOptions, load_config_without_cli_flags},
520        lambda::Timeout,
521        tests::fixture_metadata,
522    };
523
524    use super::*;
525
526    #[test]
527    fn test_extract_tags() {
528        let tags = vec!["organization=aws".to_string(), "team=lambda".to_string()];
529        let map = extract_tags(&tags);
530        assert_eq!(map.get("organization"), Some(&"aws".to_string()));
531        assert_eq!(map.get("team"), Some(&"lambda".to_string()));
532    }
533
534    #[test]
535    fn test_lambda_environment() {
536        let deploy = Deploy::default();
537        let env = deploy.lambda_environment().unwrap();
538        assert_eq!(env, None);
539
540        let deploy = Deploy {
541            base_env: HashMap::from([("FOO".to_string(), "BAR".to_string())]),
542            ..Default::default()
543        };
544        let env = deploy.lambda_environment().unwrap().unwrap();
545        assert_eq!(env.variables().unwrap().len(), 1);
546        assert_eq!(
547            env.variables().unwrap().get("FOO"),
548            Some(&"BAR".to_string())
549        );
550
551        let deploy = Deploy {
552            function_config: FunctionDeployConfig {
553                env_options: Some(EnvOptions {
554                    env_var: Some(vec!["FOO=BAR".to_string()]),
555                    ..Default::default()
556                }),
557                ..Default::default()
558            },
559            ..Default::default()
560        };
561        let env = deploy.lambda_environment().unwrap().unwrap();
562        assert_eq!(env.variables().unwrap().len(), 1);
563        assert_eq!(
564            env.variables().unwrap().get("FOO"),
565            Some(&"BAR".to_string())
566        );
567
568        let deploy = Deploy {
569            function_config: FunctionDeployConfig {
570                env_options: Some(EnvOptions {
571                    env_var: Some(vec!["FOO=BAR".to_string()]),
572                    ..Default::default()
573                }),
574                ..Default::default()
575            },
576            base_env: HashMap::from([("BAZ".to_string(), "QUX".to_string())]),
577            ..Default::default()
578        };
579        let env = deploy.lambda_environment().unwrap().unwrap();
580        assert_eq!(env.variables().unwrap().len(), 2);
581        assert_eq!(
582            env.variables().unwrap().get("BAZ"),
583            Some(&"QUX".to_string())
584        );
585        assert_eq!(
586            env.variables().unwrap().get("FOO"),
587            Some(&"BAR".to_string())
588        );
589
590        let temp_file = tempfile::NamedTempFile::new().unwrap();
591        let path = temp_file.path();
592        std::fs::write(path, "FOO=BAR\nBAZ=QUX").unwrap();
593
594        let deploy = Deploy {
595            function_config: FunctionDeployConfig {
596                env_options: Some(EnvOptions {
597                    env_file: Some(path.to_path_buf()),
598                    ..Default::default()
599                }),
600                ..Default::default()
601            },
602            base_env: HashMap::from([("QUUX".to_string(), "QUUX".to_string())]),
603            ..Default::default()
604        };
605        let env = deploy.lambda_environment().unwrap().unwrap();
606        assert_eq!(env.variables().unwrap().len(), 3);
607        assert_eq!(
608            env.variables().unwrap().get("BAZ"),
609            Some(&"QUX".to_string())
610        );
611        assert_eq!(
612            env.variables().unwrap().get("FOO"),
613            Some(&"BAR".to_string())
614        );
615        assert_eq!(
616            env.variables().unwrap().get("QUUX"),
617            Some(&"QUUX".to_string())
618        );
619    }
620
621    #[test]
622    fn test_load_config_from_workspace() {
623        let options = ConfigOptions {
624            name: Some("crate-3".to_string()),
625            admerge: true,
626            ..Default::default()
627        };
628
629        let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
630        let config = load_config_without_cli_flags(&metadata, &options).unwrap();
631        assert_eq!(
632            config.deploy.function_config.timeout,
633            Some(Timeout::new(120))
634        );
635        assert_eq!(config.deploy.function_config.memory, Some(10240.into()));
636
637        let tags = config.deploy.lambda_tags().unwrap();
638        assert_eq!(tags.len(), 2);
639        assert_eq!(tags.get("organization"), Some(&"aws".to_string()));
640        assert_eq!(tags.get("team"), Some(&"lambda".to_string()));
641
642        assert_eq!(
643            config.deploy.include,
644            Some(vec!["src/bin/main.rs".to_string()])
645        );
646
647        assert_eq!(
648            config.deploy.function_config.env_options.unwrap().env_var,
649            Some(vec!["APP_ENV=production".to_string()])
650        );
651
652        assert_eq!(config.deploy.function_config.log_retention, Some(14));
653    }
654}