Repository: cargo-lambda/cargo-lambda (language: python)

Issue:
Add ability to `deploy` with configuration tags 
The `Cargo.toml` manifest metadata / cli option flags don't allow to specify [configuration tags](https://docs.aws.amazon.com/lambda/latest/dg/configuration-tags.html)


Discussion / hints:
good point, do you want to open a pull request?
i can try, depends on how big of a change it would be. it's something that somewhat blocks us from adopting the command (the `serverless.yml` configuration has more options)
it should not be very complicated, you can follow the implementation for other options that are specified in this struct:

https://github.com/cargo-lambda/cargo-lambda/blob/main/crates/cargo-lambda-deploy/src/functions.rs#LL44C4-L44C4

and in this other one: https://github.com/cargo-lambda/cargo-lambda/blob/main/crates/cargo-lambda-metadata/src/cargo.rs#L230
It looks like the [`UpdateFunctionConfigurationInput`](https://docs.rs/aws-sdk-lambda/0.17.0/aws_sdk_lambda/input/struct.UpdateFunctionConfigurationInput.html) is missing a `tags` property (but is available in [`CreateFunctionInput`](https://docs.rs/aws-sdk-lambda/0.17.0/aws_sdk_lambda/input/struct.CreateFunctionInput.html#method.tags))
oh no 😞 that's unfortunate
it looks like we need to call [tag_resource](https://docs.rs/aws-sdk-lambda/0.17.0/aws_sdk_lambda/client/struct.Client.html#method.tag_resource) to adds tags to an existent function:

https://docs.aws.amazon.com/lambda/latest/dg/configuration-tags.html#configuration-tags-cli

Fix patch:
diff --git a/crates/cargo-lambda-cli/src/main.rs b/crates/cargo-lambda-cli/src/main.rs
index ef5aadbc..bd510a60 100644
--- a/crates/cargo-lambda-cli/src/main.rs
+++ b/crates/cargo-lambda-cli/src/main.rs
@@ -39,7 +39,7 @@ enum LambdaSubcommand {
     Build(Box<Build>),
     /// `cargo lambda deploy` uploads functions and extensions to AWS Lambda.
     /// You can use the same command to create new functions as well as update existent functions code.
-    Deploy(Deploy),
+    Deploy(Box<Deploy>),
     /// `cargo lambda init` creates Rust Lambda packages in an existent directory.
     /// Files present in that directory will be preserved as they were before running this command.
     Init(Init),
diff --git a/crates/cargo-lambda-deploy/src/extensions.rs b/crates/cargo-lambda-deploy/src/extensions.rs
index 6f514770..f10f05e7 100644
--- a/crates/cargo-lambda-deploy/src/extensions.rs
+++ b/crates/cargo-lambda-deploy/src/extensions.rs
@@ -1,6 +1,11 @@
+use std::path::PathBuf;
+
+use crate::extract_tags;
+
 use super::DeployResult;
 use aws_sdk_s3::{types::ByteStream, Client as S3Client};
 use cargo_lambda_interactive::progress::Progress;
+use cargo_lambda_metadata::cargo::function_deploy_metadata;
 use cargo_lambda_remote::{
     aws_sdk_config::SdkConfig,
     aws_sdk_lambda::{
@@ -23,13 +28,16 @@ impl std::fmt::Display for DeployOutput {
     }
 }
 
+#[allow(clippy::too_many_arguments)]
 pub(crate) async fn deploy(
     name: &str,
+    manifest_path: &PathBuf,
     sdk_config: &SdkConfig,
     binary_data: Vec<u8>,
     architecture: Architecture,
     compatible_runtimes: Vec<Runtime>,
     s3_bucket: &Option<String>,
+    tags: &Option<Vec<String>>,
     progress: &Progress,
 ) -> Result<DeployResult> {
     let lambda_client = LambdaClient::new(sdk_config);
@@ -41,12 +49,25 @@ pub(crate) async fn deploy(
         Some(bucket) => {
             progress.set_message("uploading binary to S3");
 
+            let mut deploy_metadata =
+                function_deploy_metadata(manifest_path, name)?.unwrap_or_default();
+
+            if let Some(tags) = tags {
+                deploy_metadata.append_tags(extract_tags(tags));
+            }
+
             let s3_client = S3Client::new(sdk_config);
-            s3_client
+            let mut operation = s3_client
                 .put_object()
                 .bucket(bucket)
                 .key(name)
-                .body(ByteStream::from(binary_data))
+                .body(ByteStream::from(binary_data));
+
+            if tags.is_some() {
+                operation = operation.set_tagging(deploy_metadata.s3_tags());
+            }
+
+            operation
                 .send()
                 .await
                 .into_diagnostic()
diff --git a/crates/cargo-lambda-deploy/src/functions.rs b/crates/cargo-lambda-deploy/src/functions.rs
index 800e1cee..bce2b8d4 100644
--- a/crates/cargo-lambda-deploy/src/functions.rs
+++ b/crates/cargo-lambda-deploy/src/functions.rs
@@ -1,5 +1,5 @@
 use super::DeployResult;
-use crate::roles;
+use crate::{extract_tags, roles};
 use aws_sdk_s3::{types::ByteStream, Client as S3Client};
 use cargo_lambda_interactive::progress::Progress;
 use cargo_lambda_metadata::{
@@ -46,13 +46,21 @@ pub struct FunctionConfig {
     /// Memory allocated for the function
     #[arg(long, alias = "memory-size")]
     pub memory: Option<Memory>,
+
     /// How long the function can be running for, in seconds
     #[arg(long)]
     pub timeout: Option<Timeout>,
-    /// Option to add one or many environment variables, allows multiple repetitions
-    /// Use VAR_KEY=VAR_VALUE as format
+
+    /// Option to add one or many environment variables, allows multiple repetitions (--env-var KEY=VALUE --env-var OTHER=NEW-VALUE)
+    /// This option overrides any values set with the --env-vars flag.
     #[arg(long)]
     pub env_var: Option<Vec<String>>,
+
+    /// Command separated list of environment variables (--env-vars KEY=VALUE,OTHER=NEW-VALUE)
+    /// This option overrides any values set with the --env-var flag.
+    #[arg(long)]
+    pub env_vars: Option<Vec<String>>,
+
     /// Read environment variables from a file
     #[arg(long, value_hint = ValueHint::FilePath)]
     pub env_file: Option<PathBuf>,
@@ -109,6 +117,7 @@ pub(crate) async fn deploy(
     remote_config: &RemoteConfig,
     sdk_config: &SdkConfig,
     s3_bucket: &Option<String>,
+    tags: &Option<Vec<String>>,
     binary_data: Vec<u8>,
     architecture: Architecture,
     progress: &Progress,
@@ -124,6 +133,7 @@ pub(crate) async fn deploy(
         remote_config,
         sdk_config,
         s3_bucket,
+        tags,
         binary_data,
         architecture,
         progress,
@@ -166,6 +176,7 @@ async fn upsert_function(
     remote_config: &RemoteConfig,
     sdk_config: &SdkConfig,
     s3_bucket: &Option<String>,
+    tags: &Option<Vec<String>>,
     binary_data: Vec<u8>,
     architecture: Architecture,
     progress: &Progress,
@@ -173,7 +184,7 @@ async fn upsert_function(
     let current_function = client.get_function().function_name(name).send().await;
 
     let (environment, deploy_metadata) =
-        load_deploy_environment(manifest_path, binary_name, function_config)?;
+        load_deploy_environment(manifest_path, binary_name, function_config, tags)?;
 
     let action = match current_function {
         Ok(fun) => FunctionAction::Update(Box::new(fun)),
@@ -194,6 +205,9 @@ async fn upsert_function(
         .mode(tracing.to_string().as_str().into())
         .build();
 
+    let lambda_tags = deploy_metadata.as_ref().and_then(|m| m.tags.clone());
+    let s3_tags = deploy_metadata.as_ref().and_then(|m| m.s3_tags());
+
     let (arn, version) = match action {
         FunctionAction::Create => {
             let deploy_metadata = deploy_metadata.unwrap_or_default();
@@ -219,6 +233,7 @@ async fn upsert_function(
                         .bucket(bucket)
                         .key(name)
                         .body(ByteStream::from(binary_data))
+                        .set_tagging(s3_tags)
                         .send()
                         .await
                         .into_diagnostic()
@@ -249,6 +264,7 @@ async fn upsert_function(
                     .tracing_config(tracing_config.clone())
                     .environment(environment.clone())
                     .set_layers(deploy_metadata.layers.clone())
+                    .set_tags(lambda_tags.clone())
                     .send()
                     .await;
 
@@ -287,6 +303,8 @@ async fn upsert_function(
                 .configuration
                 .ok_or_else(|| miette::miette!("missing function configuration"))?;
 
+            let function_arn = conf.function_arn.as_ref().expect("missing function arn");
+
             let mut wait_for_readiness = false;
             if conf.state.is_none() || conf.state == Some(State::Pending) {
                 wait_for_readiness = true;
@@ -353,6 +371,19 @@ async fn upsert_function(
                 progress.set_message("deploying function");
             }
 
+            if let Some(tags) = lambda_tags {
+                if !tags.is_empty() {
+                    client
+                        .tag_resource()
+                        .resource(function_arn)
+                        .set_tags(Some(tags))
+                        .send()
+                        .await
+                        .into_diagnostic()
+                        .wrap_err("failed to tag function")?;
+                }
+            }
+
             let mut builder = client.update_function_code().function_name(name);
 
             match &s3_bucket {
@@ -363,12 +394,18 @@ async fn upsert_function(
                 }
                 Some(bucket) => {
                     tracing::debug!(bucket = bucket, "uploading zip to S3");
+
                     let client = S3Client::new(sdk_config);
-                    client
+                    let mut operation = client
                         .put_object()
                         .bucket(bucket)
                         .key(name)
-                        .body(ByteStream::from(binary_data))
+                        .body(ByteStream::from(binary_data));
+
+                    if s3_tags.is_some() {
+                        operation = operation.set_tagging(s3_tags);
+                    }
+                    operation
                         .send()
                         .await
                         .into_diagnostic()
@@ -399,12 +436,13 @@ fn load_deploy_environment(
     manifest_path: &PathBuf,
     binary_name: &str,
     function_config: &FunctionDeployConfig,
+    tags: &Option<Vec<String>>,
 ) -> Result<(Environment, Option<DeployConfig>)> {
     let deploy_metadata = function_deploy_metadata(manifest_path, binary_name)?;
 
     let (environment, deploy_metadata) = match &deploy_metadata {
         Some(base) if function_config.config.is_some() => {
-            merge_configuration(base, function_config)?
+            merge_configuration(base, function_config, tags)?
         }
         Some(base) => {
             let env = function_environment(base.env.clone(), &base.env_file, &None)?;
@@ -420,9 +458,14 @@ fn load_deploy_environment(
 fn merge_configuration(
     base: &DeployConfig,
     function_config: &FunctionDeployConfig,
+    tags: &Option<Vec<String>>,
 ) -> Result<(Environment, Option<DeployConfig>)> {
     let mut deploy_metadata = base.clone();
 
+    if let Some(tags) = tags {
+        deploy_metadata.append_tags(extract_tags(tags));
+    }
+
     if let Some(tracing) = &function_config.tracing {
         if &deploy_metadata.tracing != tracing {
             deploy_metadata.tracing = tracing.clone();
@@ -453,7 +496,12 @@ fn merge_configuration(
             if config.env_file.is_some() {
                 deploy_metadata.env_file = config.env_file.clone();
             }
-            &config.env_var
+
+            if config.env_vars.is_some() {
+                &config.env_vars
+            } else {
+                &config.env_var
+            }
         }
     };
 
@@ -758,16 +806,24 @@ mod tests {
             &fixture("single-binary-package"),
             "basic-lambda",
             &Default::default(),
+            &None,
         )
         .unwrap();
 
+        let config = config.unwrap();
+
         let vars = env.variables().unwrap();
         assert_eq!("VAL1".to_string(), vars["VAR1"]);
-        assert_eq!(Some(Memory::Mb512), config.unwrap().memory);
+        assert_eq!(Some(Memory::Mb512), config.memory);
+
+        let mut tags = HashMap::new();
+        tags.insert("organization".to_string(), "aws".to_string());
+        tags.insert("team".to_string(), "lambda".to_string());
+        assert_eq!(Some(tags), config.tags);
     }
 
     #[test]
-    fn test_load_deploy_environment_overriding_with_flags() {
+    fn test_load_deploy_environment_appending_flags() {
         let flags = FunctionDeployConfig {
             config: Some(FunctionConfig {
                 memory: Some(Memory::Mb1024),
@@ -776,12 +832,27 @@ mod tests {
             ..Default::default()
         };
 
-        let (env, config) =
-            load_deploy_environment(&fixture("single-binary-package"), "basic-lambda", &flags)
-                .unwrap();
+        let tags = vec!["team=s3".to_string(), "subteam=storage".to_string()];
+
+        let (env, config) = load_deploy_environment(
+            &fixture("single-binary-package"),
+            "basic-lambda",
+            &flags,
+            &Some(tags),
+        )
+        .unwrap();
+
+        let config = config.unwrap();
 
         let vars = env.variables().unwrap();
         assert_eq!("VAL1".to_string(), vars["VAR1"]);
-        assert_eq!(Some(Memory::Mb1024), config.unwrap().memory);
+        assert_eq!(Some(Memory::Mb1024), config.memory);
+
+        let mut tags = HashMap::new();
+        tags.insert("organization".to_string(), "aws".to_string());
+        tags.insert("team".to_string(), "s3".to_string());
+        tags.insert("subteam".to_string(), "storage".to_string());
+
+        assert_eq!(Some(tags), config.tags);
     }
 }
diff --git a/crates/cargo-lambda-deploy/src/lib.rs b/crates/cargo-lambda-deploy/src/lib.rs
index 6493d48d..f54f7a04 100644
--- a/crates/cargo-lambda-deploy/src/lib.rs
+++ b/crates/cargo-lambda-deploy/src/lib.rs
@@ -10,7 +10,7 @@ use clap::{Args, ValueHint};
 use miette::{IntoDiagnostic, Result, WrapErr};
 use serde::Serialize;
 use serde_json::ser::to_string_pretty;
-use std::{fs::read, path::PathBuf, time::Duration};
+use std::{collections::HashMap, fs::read, path::PathBuf, time::Duration};
 use strum_macros::{Display, EnumString};
 
 mod extensions;
@@ -72,19 +72,29 @@ pub struct Deploy {
     #[arg(long)]
     pub s3_bucket: Option<String>,
 
-    /// Whether the code that you're building is a Lambda Extension
+    /// Whether the code that you're deploying is a Lambda Extension
     #[arg(long)]
     extension: bool,
 
     /// Comma separated list with compatible runtimes for the Lambda Extension (--compatible_runtimes=provided.al2,nodejs16.x)
     /// 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
     #[arg(long, value_delimiter = ',', default_value = "provided.al2")]
-    pub compatible_runtimes: Vec<String>,
+    compatible_runtimes: Vec<String>,
 
     /// Format to render the output (text, or json)
-    #[arg(long, default_value_t = OutputFormat::Text)]
+    #[arg(short, long, default_value_t = OutputFormat::Text)]
     output_format: OutputFormat,
 
+    /// Option to add one or many tags, allows multiple repetitions (--tag organization=aws --tag team=lambda)
+    /// This option overrides any values set with the --tags flag.
+    #[arg(long)]
+    tag: Option<Vec<String>>,
+
+    /// Comma separated list of tags to apply to the function or extension (--tags organization=aws,team=lambda)
+    /// This option overrides any values set with the --tag flag.
+    #[arg(long, value_delimiter = ',')]
+    tags: Option<Vec<String>>,
+
     /// Name of the function or extension to deploy
     #[arg(value_name = "NAME")]
     name: Option<String>,
@@ -125,14 +135,21 @@ impl Deploy {
             .into_diagnostic()
             .wrap_err("failed to read binary archive")?;
 
+        let mut tags = self.tags.clone();
+        if tags.is_none() {
+            tags = self.tag.clone();
+        }
+
         let result = if self.extension {
             extensions::deploy(
                 &name,
+                &self.manifest_path,
                 &sdk_config,
                 binary_data,
                 architecture,
                 compatible_runtimes,
                 &self.s3_bucket,
+                &tags,
                 &progress,
             )
             .await
@@ -146,6 +163,7 @@ impl Deploy {
                 &self.remote_config,
                 &sdk_config,
                 &self.s3_bucket,
+                &tags,
                 binary_data,
                 architecture,
                 &progress,
@@ -209,3 +227,16 @@ impl Deploy {
         Ok(arc)
     }
 }
+
+pub(crate) fn extract_tags(tags: &Vec<String>) -> HashMap<String, String> {
+    let mut map = HashMap::new();
+
+    for var in tags {
+        let mut split = var.splitn(2, '=');
+        if let (Some(k), Some(v)) = (split.next(), split.next()) {
+            map.insert(k.to_string(), v.to_string());
+        }
+    }
+
+    map
+}
diff --git a/crates/cargo-lambda-metadata/src/cargo.rs b/crates/cargo-lambda-metadata/src/cargo.rs
index 2b41b1c0..4feb5fc7 100644
--- a/crates/cargo-lambda-metadata/src/cargo.rs
+++ b/crates/cargo-lambda-metadata/src/cargo.rs
@@ -92,6 +92,35 @@ pub struct DeployConfig {
     pub iam_role: Option<String>,
     #[serde(default)]
     pub layers: Option<Vec<String>>,
+    #[serde(default)]
+    pub tags: Option<HashMap<String, String>>,
+}
+
+impl DeployConfig {
+    pub fn append_tags(&mut self, tags: HashMap<String, String>) {
+        match &self.tags {
+            None => self.tags = Some(tags),
+            Some(base) => {
+                let mut new_tags = base.clone();
+                new_tags.extend(tags);
+                self.tags = Some(new_tags);
+            }
+        }
+    }
+
+    pub fn s3_tags(&self) -> Option<String> {
+        match &self.tags {
+            None => None,
+            Some(tags) if tags.is_empty() => None,
+            Some(tags) => {
+                let mut vec = Vec::new();
+                for (k, v) in tags {
+                    vec.push(format!("{k}={v}"));
+                }
+                Some(vec.join(","))
+            }
+        }
+    }
 }
 
 /// Extract all the binary target names from a Cargo.toml file
@@ -441,6 +470,15 @@ mod tests {
             Some("arn:aws:lambda:us-east-1:xxxxxxxx:iam:role1".to_string()),
             env.iam_role
         );
+
+        let mut tags = HashMap::new();
+        tags.insert("organization".to_string(), "aws".to_string());
+        tags.insert("team".to_string(), "lambda".to_string());
+
+        assert_eq!(Some(tags), env.tags);
+        let s3_tags = env.s3_tags().unwrap();
+        assert!(s3_tags.contains("organization=aws"), "{s3_tags}");
+        assert!(s3_tags.contains("team=lambda"), "{s3_tags}");
     }
 
     #[test]
diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md
index 2a24249f..887a48b8 100644
--- a/docs/commands/deploy.md
+++ b/docs/commands/deploy.md
@@ -42,9 +42,15 @@ You can use the flag `--disable-function-url` if you want to disable the functio
 
 ## Environment variables
 
-You can add environment variables to a function during deployment with the flags `--env-var` and `--env-file`.
+You can use the flag `--env-vars` to add environment variables to a function. This flag supports a comma separated list of values:
 
-The flag `--env-var` allows you to pass several variables in the command like with the format `KEY=VALUE`:
+```
+cargo lambda deploy \
+  --env-vars FOO=BAR,BAZ=QUX \
+  http-lambda
+```
+
+The flag `--env-var` allows you to pass several variables in the command like with the format `KEY=VALUE`. This flag overrides the previous one, and cannot be combined.
 
 ```
 cargo lambda deploy \
@@ -58,6 +64,25 @@ The flag `--env-file` will read the variables from a file and add them to the fu
 cargo lambda deploy --env-file .env http-lambda
 ```
 
+## Resource tagging
+
+You can use the flag `--tags` to add resource tags to a function or layer. This flag supports a comma separated list of values. If the function is deployed via S3, the tags are also applied to the S3 object:
+
+```
+cargo lambda deploy \
+  --tags organization=aws,team=lambda \
+  http-lambda
+```
+
+You can also use the flag `--tag` to add one or multiple tags separated by flags. This flag overrides the previous one, and cannot be combined.
+
+```
+cargo lambda deploy \
+  --tag organization=aws \
+  --tag team=lambda \
+  http-lambda
+```
+
 ## Extensions
 
 cargo-lambda can deploy Lambda Extensions built in Rust by adding the `--extension` flag to the `deploy` command. This command requires you to build the extension first with the same `--extension` flag in the `build` command:
@@ -82,6 +107,7 @@ env = { "VAR1" = "VAL1" }      # Additional environment variables
 layers = [                     # List of layers to deploy with your function
     "layer-full-arn"
 ]
+tags = { "team" = "lambda" }   # List of AWS resource tags for this function
 ```
 
 ## Other options


Test patch:
diff --git a/tests/fixtures/single-binary-package/Cargo.toml b/tests/fixtures/single-binary-package/Cargo.toml
index cc4613bd..bb5f00fc 100644
--- a/tests/fixtures/single-binary-package/Cargo.toml
+++ b/tests/fixtures/single-binary-package/Cargo.toml
@@ -26,6 +26,7 @@ layers = [
 tracing = "active"
 role = "arn:aws:lambda:us-east-1:xxxxxxxx:iam:role1"
 env = { "VAR1" = "VAL1" }
+tags = { "organization" = "aws", "team" = "lambda" }
 
 [package.metadata.lambda.build.compiler]
 type = "cargo"