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 #[arg(short, long, value_hint = ValueHint::DirPath)]
43 #[serde(default)]
44 pub lambda_dir: Option<PathBuf>,
45
46 #[arg(long, value_name = "PATH", default_value = DEFAULT_MANIFEST_PATH)]
48 #[serde(default)]
49 pub manifest_path: Option<PathBuf>,
50
51 #[arg(long, conflicts_with = "binary_path")]
53 #[serde(default)]
54 pub binary_name: Option<String>,
55
56 #[arg(long, conflicts_with = "binary_name")]
58 #[serde(default)]
59 pub binary_path: Option<PathBuf>,
60
61 #[arg(long)]
63 #[serde(default)]
64 pub s3_bucket: Option<String>,
65
66 #[arg(long)]
68 #[serde(default)]
69 pub s3_key: Option<String>,
70
71 #[arg(long)]
73 #[serde(default)]
74 pub extension: bool,
75
76 #[arg(long, requires = "extension")]
78 #[serde(default)]
79 pub internal: bool,
80
81 #[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 #[arg(short, long)]
94 #[serde(default)]
95 output_format: Option<OutputFormat>,
96
97 #[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 #[arg(short, long)]
105 #[serde(default)]
106 pub include: Option<Vec<String>>,
107
108 #[arg(long, alias = "dry-run")]
110 #[serde(default)]
111 pub dry: bool,
112
113 #[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 #[arg(long)]
305 #[serde(default)]
306 pub enable_function_url: bool,
307
308 #[arg(long)]
310 #[serde(default)]
311 pub disable_function_url: bool,
312
313 #[arg(long, alias = "memory-size", value_parser = MemoryValueParser)]
315 #[serde(default)]
316 pub memory: Option<Memory>,
317
318 #[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 #[arg(long)]
329 #[serde(default)]
330 pub tracing: Option<Tracing>,
331
332 #[arg(long, visible_alias = "iam-role")]
334 #[serde(default, alias = "iam_role")]
335 pub role: Option<String>,
336
337 #[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 #[arg(long, default_value = DEFAULT_RUNTIME)]
353 #[serde(default)]
354 pub runtime: Option<String>,
355
356 #[arg(long)]
358 #[serde(default)]
359 pub description: Option<String>,
360
361 #[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 #[arg(long, value_delimiter = ',')]
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub subnet_ids: Option<Vec<String>>,
488
489 #[arg(long, value_delimiter = ',')]
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub security_group_ids: Option<Vec<String>>,
493
494 #[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}