use anyhow::{Context, Result};
use std::collections::BTreeMap;
use crate::config::{AwsSecretsManagerRemoteConfig, Config};
use crate::store::StorePayload;
use crate::targets::{CommandOpts, CommandRunner};
use super::SyncRemote;
pub struct AwsSecretsManagerRemote<'a> {
config: &'a Config,
remote_config: AwsSecretsManagerRemoteConfig,
runner: &'a dyn CommandRunner,
}
impl<'a> AwsSecretsManagerRemote<'a> {
pub fn new(
config: &'a Config,
remote_config: AwsSecretsManagerRemoteConfig,
runner: &'a dyn CommandRunner,
) -> Self {
Self {
config,
remote_config,
runner,
}
}
fn secret_name(&self, env: &str) -> String {
self.remote_config
.secret_name
.replace("{project}", &self.config.project)
.replace("{environment}", env)
}
fn base_args(&self) -> Vec<String> {
crate::targets::aws_base_args(
self.remote_config.region.as_deref(),
self.remote_config.profile.as_deref(),
)
}
}
impl SyncRemote for AwsSecretsManagerRemote<'_> {
fn name(&self) -> &'static str {
"aws_secrets_manager"
}
fn preflight(&self) -> Result<()> {
crate::targets::check_command(self.runner, "aws").map_err(|_| {
anyhow::anyhow!(
"AWS CLI (aws) is not installed or not in PATH. Install it from: https://aws.amazon.com/cli/"
)
})?;
let base = self.base_args();
let mut args: Vec<&str> = vec!["sts", "get-caller-identity"];
args.extend(base.iter().map(String::as_str));
let output = self
.runner
.run("aws", &args, CommandOpts::default())
.context("failed to run aws sts get-caller-identity")?;
if !output.success {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("AWS authentication failed: {stderr}");
}
Ok(())
}
fn push(&self, payload: &StorePayload, _config: &Config, env: &str) -> Result<()> {
let env_payload = payload.for_env(env);
if env_payload.secrets.is_empty() {
return Ok(());
}
let json =
serde_json::to_string(&env_payload).context("failed to serialize store payload")?;
let secret_name = self.secret_name(env);
let base = self.base_args();
let mut args: Vec<&str> = vec![
"secretsmanager",
"put-secret-value",
"--secret-id",
&secret_name,
"--secret-string",
"file:///dev/stdin",
];
args.extend(base.iter().map(String::as_str));
let output = self
.runner
.run(
"aws",
&args,
CommandOpts {
stdin: Some(json.as_bytes().to_vec()),
..Default::default()
},
)
.context("failed to run aws secretsmanager put-secret-value")?;
if !output.success {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("ResourceNotFoundException") {
let mut create_args: Vec<&str> = vec![
"secretsmanager",
"create-secret",
"--name",
&secret_name,
"--secret-string",
"file:///dev/stdin",
];
create_args.extend(base.iter().map(String::as_str));
let create_output = self
.runner
.run(
"aws",
&create_args,
CommandOpts {
stdin: Some(json.as_bytes().to_vec()),
..Default::default()
},
)
.context("failed to run aws secretsmanager create-secret")?;
if !create_output.success {
let stderr = String::from_utf8_lossy(&create_output.stderr);
anyhow::bail!("aws secretsmanager create-secret failed: {stderr}");
}
} else {
anyhow::bail!("aws secretsmanager put-secret-value failed: {stderr}");
}
}
Ok(())
}
fn pull(&self, _config: &Config, env: &str) -> Result<Option<(BTreeMap<String, String>, u64)>> {
let secret_name = self.secret_name(env);
let base = self.base_args();
let mut args: Vec<&str> = vec![
"secretsmanager",
"get-secret-value",
"--secret-id",
&secret_name,
"--output",
"json",
];
args.extend(base.iter().map(String::as_str));
let output = self
.runner
.run("aws", &args, CommandOpts::default())
.context("failed to run aws secretsmanager get-secret-value")?;
if !output.success {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("ResourceNotFoundException") {
return Ok(None);
}
anyhow::bail!("aws secretsmanager get-secret-value failed: {stderr}");
}
let response: serde_json::Value =
serde_json::from_slice(&output.stdout).context("failed to parse AWS response")?;
let secret_string = response["SecretString"]
.as_str()
.context("AWS response missing SecretString field")?;
let remote_payload: StorePayload =
serde_json::from_str(secret_string).context("failed to parse SecretString payload")?;
Ok(Some((
StorePayload::bare_to_composite(&remote_payload.secrets, env),
remote_payload.version,
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::targets::{CommandOpts, CommandOutput};
use crate::test_support::{ErrorCommandRunner, MockCommandRunner};
use serde_json::json;
#[test]
fn secret_name_substitution() {
struct DummyRunner;
impl CommandRunner for DummyRunner {
fn run(&self, _: &str, _: &[&str], _: CommandOpts) -> Result<CommandOutput> {
Ok(CommandOutput {
success: true,
stdout: Vec::new(),
stderr: Vec::new(),
})
}
}
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: "{project}/{environment}"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = DummyRunner;
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
assert_eq!(remote.secret_name("dev"), "myapp/dev");
assert_eq!(remote.secret_name("prod"), "myapp/prod");
}
#[test]
fn base_args_with_region_and_profile() {
struct DummyRunner;
impl CommandRunner for DummyRunner {
fn run(&self, _: &str, _: &[&str], _: CommandOpts) -> Result<CommandOutput> {
Ok(CommandOutput {
success: true,
stdout: Vec::new(),
stderr: Vec::new(),
})
}
}
let dir = tempfile::tempdir().unwrap();
let yaml = r"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: test
region: us-west-2
profile: staging
";
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = DummyRunner;
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let args = remote.base_args();
assert_eq!(args, vec!["--region", "us-west-2", "--profile", "staging"]);
}
#[test]
fn base_args_empty_when_no_options() {
struct DummyRunner;
impl CommandRunner for DummyRunner {
fn run(&self, _: &str, _: &[&str], _: CommandOpts) -> Result<CommandOutput> {
Ok(CommandOutput {
success: true,
stdout: Vec::new(),
stderr: Vec::new(),
})
}
}
let dir = tempfile::tempdir().unwrap();
let yaml = r"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: test
";
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = DummyRunner;
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
assert!(remote.base_args().is_empty());
}
#[test]
fn preflight_success() {
let dir = tempfile::tempdir().unwrap();
let yaml = r"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: test
";
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = MockCommandRunner::from_outputs(vec![
CommandOutput {
success: true,
stdout: b"{}".to_vec(),
stderr: Vec::new(),
},
CommandOutput {
success: true,
stdout: b"{}".to_vec(),
stderr: Vec::new(),
},
]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
assert!(remote.preflight().is_ok());
let calls = runner.calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].args, vec!["--version"]);
assert_eq!(calls[1].args, vec!["sts", "get-caller-identity"]);
}
#[test]
fn preflight_missing_aws_cli() {
let dir = tempfile::tempdir().unwrap();
let yaml = r"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: test
";
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = ErrorCommandRunner::missing_command();
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let err = remote.preflight().unwrap_err();
assert!(err.to_string().contains("AWS CLI (aws) is not installed"));
}
#[test]
fn preflight_auth_failure() {
let dir = tempfile::tempdir().unwrap();
let yaml = r"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: test
";
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = MockCommandRunner::from_outputs(vec![
CommandOutput {
success: true,
stdout: b"aws-cli/2.0.0".to_vec(),
stderr: Vec::new(),
},
CommandOutput {
success: false,
stdout: Vec::new(),
stderr: b"Unable to locate credentials".to_vec(),
},
]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let err = remote.preflight().unwrap_err();
assert!(err.to_string().contains("AWS authentication failed"));
assert!(err.to_string().contains("Unable to locate credentials"));
}
#[test]
fn push_creates_secret_on_not_found() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: "{project}/{environment}"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = MockCommandRunner::from_outputs(vec![
CommandOutput {
success: false,
stdout: Vec::new(),
stderr:
b"ResourceNotFoundException: Secrets Manager can't find the specified secret."
.to_vec(),
},
CommandOutput {
success: true,
stdout: b"{}".to_vec(),
stderr: Vec::new(),
},
]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let mut secrets = BTreeMap::new();
secrets.insert("API_KEY:dev".to_string(), "sk_test".to_string());
let payload = StorePayload {
secrets,
version: 3,
..Default::default()
};
remote.push(&payload, &config, "dev").unwrap();
let calls = runner.calls();
assert_eq!(calls.len(), 2);
assert!(calls[0].args.contains(&"put-secret-value".to_string()));
assert!(calls[0].args.contains(&"myapp/dev".to_string()));
assert!(calls[1].args.contains(&"create-secret".to_string()));
assert!(calls[1].args.contains(&"myapp/dev".to_string()));
}
#[test]
fn push_updates_existing_secret() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: "{project}/{environment}"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: b"{}".to_vec(),
stderr: Vec::new(),
}]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let mut secrets = BTreeMap::new();
secrets.insert("DB_URL:dev".to_string(), "postgres://localhost".to_string());
let payload = StorePayload {
secrets,
version: 5,
..Default::default()
};
remote.push(&payload, &config, "dev").unwrap();
let calls = runner.calls();
assert_eq!(calls.len(), 1);
assert!(calls[0].args.contains(&"put-secret-value".to_string()));
}
#[test]
fn push_skips_empty_env() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: myapp
environments: [dev, prod]
remotes:
aws_secrets_manager:
secret_name: "{project}/{environment}"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = MockCommandRunner::from_outputs(vec![]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let mut secrets = BTreeMap::new();
secrets.insert("KEY:prod".to_string(), "val".to_string());
let payload = StorePayload {
secrets,
version: 1,
..Default::default()
};
remote.push(&payload, &config, "dev").unwrap();
assert!(runner.calls().is_empty());
}
#[test]
fn pull_success() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: "{project}/{environment}"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let remote_payload = StorePayload {
secrets: {
let mut m = BTreeMap::new();
m.insert("API_KEY".to_string(), "sk_live".to_string());
m.insert("DB_URL".to_string(), "postgres://prod".to_string());
m
},
version: 7,
..Default::default()
};
let secret_string = serde_json::to_string(&remote_payload).unwrap();
let aws_response = json!({
"SecretString": secret_string,
"Name": "myapp/dev",
});
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: serde_json::to_vec(&aws_response).unwrap(),
stderr: Vec::new(),
}]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let (secrets, version) = remote.pull(&config, "dev").unwrap().unwrap();
assert_eq!(version, 7);
assert_eq!(secrets.get("API_KEY:dev").unwrap(), "sk_live");
assert_eq!(secrets.get("DB_URL:dev").unwrap(), "postgres://prod");
}
#[test]
fn pull_not_found_returns_none() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: "{project}/{environment}"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: false,
stdout: Vec::new(),
stderr: b"ResourceNotFoundException: Secrets Manager can't find the specified secret."
.to_vec(),
}]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
assert!(remote.pull(&config, "dev").unwrap().is_none());
}
#[test]
fn push_uses_env_version() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: myapp
environments: [dev]
remotes:
aws_secrets_manager:
secret_name: "{project}/{environment}"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let remote_config: AwsSecretsManagerRemoteConfig =
config.remote_config("aws_secrets_manager").unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: b"{}".to_vec(),
stderr: Vec::new(),
}]);
let remote = AwsSecretsManagerRemote::new(&config, remote_config, &runner);
let mut env_versions = BTreeMap::new();
env_versions.insert("dev".to_string(), 10);
let mut secrets = BTreeMap::new();
secrets.insert("KEY:dev".to_string(), "val".to_string());
let payload = StorePayload {
secrets,
version: 5,
env_versions,
..Default::default()
};
remote.push(&payload, &config, "dev").unwrap();
let calls = runner.calls();
assert_eq!(calls.len(), 1);
let pushed: StorePayload =
serde_json::from_slice(calls[0].stdin.as_ref().unwrap()).unwrap();
assert_eq!(pushed.version, 10);
assert!(pushed.secrets.contains_key("KEY"));
assert!(!pushed.secrets.contains_key("KEY:dev"));
}
}