cargo_lambda_metadata/cargo/
deploy.rs

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