use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::io::Write;
use crate::config::{AzureKeyVaultRemoteConfig, Config};
use crate::store::StorePayload;
use crate::targets::{CommandOpts, CommandRunner};
use super::SyncRemote;
pub struct AzureKeyVaultRemote<'a> {
config: &'a Config,
remote_config: AzureKeyVaultRemoteConfig,
runner: &'a dyn CommandRunner,
}
impl<'a> AzureKeyVaultRemote<'a> {
pub fn new(
config: &'a Config,
remote_config: AzureKeyVaultRemoteConfig,
runner: &'a dyn CommandRunner,
) -> Self {
Self {
config,
remote_config,
runner,
}
}
fn secret_name(&self, env: &str) -> String {
let raw = self
.remote_config
.secret_name
.replace("{project}", &self.config.project)
.replace("{environment}", env);
raw.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect()
}
}
impl SyncRemote for AzureKeyVaultRemote<'_> {
fn name(&self) -> &'static str {
"azure"
}
fn preflight(&self) -> Result<()> {
crate::targets::check_command(self.runner, "az").map_err(|_| {
anyhow::anyhow!(
"Azure CLI (az) is not installed or not in PATH. Install it from: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli"
)
})?;
let output = self
.runner
.run("az", &["account", "show"], CommandOpts::default())
.context("failed to run az account show")?;
if !output.success {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Azure CLI not authenticated: {stderr}");
}
Ok(())
}
fn push(&self, payload: &StorePayload, _config: &Config, env: &str) -> Result<()> {
let Some((env_secrets, version)) = payload.env_secrets(env) else {
return Ok(());
};
let mut json_map: BTreeMap<String, String> = env_secrets;
json_map.insert(super::ESK_VERSION_KEY.to_string(), version.to_string());
let json = serde_json::to_string(&json_map).context("failed to serialize secrets")?;
let mut tmpfile =
tempfile::NamedTempFile::new().context("failed to create temp file for azure push")?;
tmpfile
.write_all(json.as_bytes())
.context("failed to write temp file")?;
let tmppath = tmpfile.path().to_string_lossy().to_string();
let secret_name = self.secret_name(env);
let vault_name = &self.remote_config.vault_name;
let output = self
.runner
.run(
"az",
&[
"keyvault",
"secret",
"set",
"--vault-name",
vault_name,
"--name",
&secret_name,
"--file",
&tmppath,
],
CommandOpts::default(),
)
.context("failed to run az keyvault secret set")?;
if !output.success {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("az keyvault secret set failed: {stderr}");
}
Ok(())
}
fn pull(&self, _config: &Config, env: &str) -> Result<Option<(BTreeMap<String, String>, u64)>> {
let secret_name = self.secret_name(env);
let vault_name = &self.remote_config.vault_name;
let output = self
.runner
.run(
"az",
&[
"keyvault",
"secret",
"show",
"--vault-name",
vault_name,
"--name",
&secret_name,
"--output",
"json",
],
CommandOpts::default(),
)
.context("failed to run az keyvault secret show")?;
if !output.success {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("SecretNotFound") {
return Ok(None);
}
anyhow::bail!("az keyvault secret show failed: {stderr}");
}
let outer: serde_json::Value =
serde_json::from_slice(&output.stdout).context("failed to parse az output JSON")?;
let value_str = outer["value"]
.as_str()
.context("az output missing 'value' field")?;
let json_map: BTreeMap<String, String> =
serde_json::from_str(value_str).context("failed to parse secret value JSON")?;
Ok(Some(super::parse_pulled_secrets(json_map, env)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::targets::CommandOutput;
use crate::test_support::{ConfigFixture, ErrorCommandRunner, MockCommandRunner};
fn azure_yaml() -> &'static str {
r#"
project: myapp
environments: [dev, prod]
remotes:
azure:
vault_name: my-vault
secret_name: "{project}-{environment}"
"#
}
fn make_payload(secrets: &[(&str, &str)], version: u64) -> StorePayload {
let mut map = BTreeMap::new();
for (k, v) in secrets {
map.insert((*k).to_string(), (*v).to_string());
}
StorePayload {
secrets: map,
version,
..Default::default()
}
}
#[test]
fn secret_name_substitution() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = MockCommandRunner::from_outputs(vec![]);
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
assert_eq!(remote.secret_name("dev"), "myapp-dev");
assert_eq!(remote.secret_name("prod"), "myapp-prod");
}
#[test]
fn secret_name_sanitizes_underscores() {
let fixture = ConfigFixture::new(
r#"
project: my_app
environments: [dev]
remotes:
azure:
vault_name: v
secret_name: "{project}_{environment}"
"#,
)
.expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = MockCommandRunner::from_outputs(vec![]);
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
assert_eq!(remote.secret_name("dev"), "my-app-dev");
}
#[test]
fn preflight_success() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = MockCommandRunner::from_outputs(vec![
CommandOutput {
success: true,
stdout: b"2.50.0".to_vec(),
stderr: Vec::new(),
},
CommandOutput {
success: true,
stdout: b"{}".to_vec(),
stderr: Vec::new(),
},
]);
let remote = AzureKeyVaultRemote::new(fixture.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!(calls[1].args.contains(&"account".to_string()));
}
#[test]
fn preflight_missing_az() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = ErrorCommandRunner::missing_command();
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
let err = remote.preflight().unwrap_err();
assert!(err.to_string().contains("Azure CLI (az)"));
assert!(err.to_string().contains("not installed"));
}
#[test]
fn preflight_auth_failure() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = MockCommandRunner::from_outputs(vec![
CommandOutput {
success: true,
stdout: b"2.50.0".to_vec(),
stderr: Vec::new(),
},
CommandOutput {
success: false,
stdout: Vec::new(),
stderr: b"Please run 'az login' to setup account".to_vec(),
},
]);
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
let err = remote.preflight().unwrap_err();
assert!(err.to_string().contains("not authenticated"));
}
#[test]
fn push_success() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: Vec::new(),
stderr: Vec::new(),
}]);
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
let payload = make_payload(&[("API_KEY:dev", "sk_test")], 3);
remote.push(&payload, fixture.config(), "dev").unwrap();
let calls = runner.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].program, "az");
assert!(calls[0].args.contains(&"keyvault".to_string()));
assert!(calls[0].args.contains(&"set".to_string()));
assert!(calls[0].args.contains(&"my-vault".to_string()));
assert!(calls[0].args.contains(&"myapp-dev".to_string()));
assert!(calls[0].args.contains(&"--file".to_string()));
assert!(!calls[0].args.contains(&"--value".to_string()));
assert!(!calls[0].args.iter().any(|a| a.contains("sk_test")));
}
#[test]
fn push_skips_empty_env() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = MockCommandRunner::from_outputs(vec![]);
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
let payload = make_payload(&[("KEY:prod", "val")], 1);
remote.push(&payload, fixture.config(), "dev").unwrap();
let calls = runner.calls();
assert!(calls.is_empty());
}
#[test]
fn pull_success() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let inner = serde_json::json!({
"API_KEY": "sk_test",
"DB_URL": "postgres://localhost",
crate::remotes::ESK_VERSION_KEY: "5"
});
let outer = serde_json::json!({
"value": inner.to_string()
});
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: serde_json::to_vec(&outer).unwrap(),
stderr: Vec::new(),
}]);
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
let (secrets, version) = remote.pull(fixture.config(), "dev").unwrap().unwrap();
assert_eq!(version, 5);
assert_eq!(secrets.get("API_KEY:dev").unwrap(), "sk_test");
assert_eq!(secrets.get("DB_URL:dev").unwrap(), "postgres://localhost");
assert!(!secrets.contains_key("_esk_version:dev"));
}
#[test]
fn pull_not_found_returns_none() {
let fixture = ConfigFixture::new(azure_yaml()).expect("fixture");
let remote_config: AzureKeyVaultRemoteConfig =
fixture.config().remote_config("azure").unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: false,
stdout: Vec::new(),
stderr: b"SecretNotFound: secret not found".to_vec(),
}]);
let remote = AzureKeyVaultRemote::new(fixture.config(), remote_config, &runner);
assert!(remote.pull(fixture.config(), "dev").unwrap().is_none());
}
}