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