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