use std::collections::HashMap;
use super::residency;
use super::{SecretProvider, SecretRef, server_region};
use crate::error::AppResult;
use crate::metrics::{record_secret_resolve, record_secret_resolve_duration};
use crate::playbook::types::KeychainDef;
use crate::template::TemplateRenderer;
pub async fn resolve_keychain_entry(
kc: &KeychainDef,
workload: &HashMap<String, serde_json::Value>,
provider: &dyn SecretProvider,
) -> AppResult<serde_json::Value> {
resolve_keychain_entry_with_meta(kc, workload, provider)
.await
.map(|(v, _)| v)
}
pub async fn resolve_keychain_entry_with_meta(
kc: &KeychainDef,
workload: &HashMap<String, serde_json::Value>,
provider: &dyn SecretProvider,
) -> AppResult<(serde_json::Value, Option<chrono::DateTime<chrono::Utc>>)> {
let renderer = TemplateRenderer::new();
let region = effective_region(kc);
let provider_id = provider.provider();
residency::to_result(residency::evaluate(kc, ®ion))?;
let started = std::time::Instant::now();
let result = match &kc.map {
Some(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
let mut earliest_expires_at: Option<chrono::DateTime<chrono::Utc>> = None;
for (key, path_template) in map {
let path = match renderer.render(path_template, workload) {
Ok(p) => p,
Err(e) => {
record_secret_resolve(provider_id, ®ion, "template_error");
return Err(e);
}
};
let secret = match provider
.fetch(&SecretRef {
name: path,
region: Some(region.clone()).filter(|r| !r.is_empty()),
..SecretRef::default()
})
.await
{
Ok(s) => s,
Err(e) => {
record_secret_resolve(provider_id, ®ion, "provider_fetch_error");
return Err(e);
}
};
if let Some(exp) = secret.expires_at {
earliest_expires_at =
Some(earliest_expires_at.map(|prev| prev.min(exp)).unwrap_or(exp));
}
out.insert(key.clone(), serde_json::Value::String(secret.value));
}
Ok((serde_json::Value::Object(out), earliest_expires_at))
}
None => {
let secret = match provider
.fetch(&SecretRef {
name: kc.name.clone(),
region: Some(region.clone()).filter(|r| !r.is_empty()),
..SecretRef::default()
})
.await
{
Ok(s) => s,
Err(e) => {
record_secret_resolve(provider_id, ®ion, "provider_fetch_error");
return Err(e);
}
};
Ok((serde_json::Value::String(secret.value), secret.expires_at))
}
};
if result.is_ok() {
record_secret_resolve(provider_id, ®ion, "ok");
}
record_secret_resolve_duration(provider_id, ®ion, started.elapsed().as_secs_f64());
result
}
pub(crate) fn effective_region(kc: &KeychainDef) -> String {
if let Some(r) = kc.region.as_deref().filter(|s| !s.is_empty()) {
return r.to_string();
}
server_region().to_string()
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::error::AppError;
use crate::secrets::SecretValue;
use async_trait::async_trait;
struct FakeProvider {
values: HashMap<String, String>,
seen: Mutex<Vec<SecretRef>>,
}
impl FakeProvider {
fn new(pairs: &[(&str, &str)]) -> Self {
Self {
values: pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
seen: Mutex::new(Vec::new()),
}
}
fn last_region(&self) -> Option<String> {
self.seen
.lock()
.unwrap()
.last()
.and_then(|s| s.region.clone())
}
}
#[async_trait]
impl SecretProvider for FakeProvider {
fn provider(&self) -> &'static str {
"fake"
}
async fn fetch(&self, secret: &SecretRef) -> AppResult<SecretValue> {
self.seen.lock().unwrap().push(secret.clone());
self.values
.get(&secret.name)
.map(|v| SecretValue {
value: v.clone(),
version: None,
expires_at: None,
})
.ok_or_else(|| {
AppError::NotFound(format!("fake secret '{}' not found", secret.name))
})
}
}
fn workload(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
.collect()
}
#[tokio::test]
async fn map_entry_renders_path_template_and_assembles_object() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: openai_token
provider: gcp
map:
api_key: "{{ openai_secret_path }}"
"#,
)
.unwrap();
let wl = workload(&[("openai_secret_path", "projects/p/secrets/openai")]);
let provider = FakeProvider::new(&[("projects/p/secrets/openai", "sk-live-123")]);
let resolved = resolve_keychain_entry(&kc, &wl, &provider).await.unwrap();
assert_eq!(resolved, serde_json::json!({ "api_key": "sk-live-123" }));
}
#[tokio::test]
async fn map_entry_with_multiple_keys_fetches_each() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: combo
provider: gcp
map:
key_a: "projects/p/secrets/a"
key_b: "{{ b_path }}"
"#,
)
.unwrap();
let wl = workload(&[("b_path", "projects/p/secrets/b")]);
let provider = FakeProvider::new(&[
("projects/p/secrets/a", "val-a"),
("projects/p/secrets/b", "val-b"),
]);
let resolved = resolve_keychain_entry(&kc, &wl, &provider).await.unwrap();
assert_eq!(resolved["key_a"], "val-a");
assert_eq!(resolved["key_b"], "val-b");
}
#[tokio::test]
async fn map_less_entry_resolves_single_value_by_name() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: projects/p/secrets/duffel
provider: gcp
"#,
)
.unwrap();
let provider = FakeProvider::new(&[("projects/p/secrets/duffel", "duffel-token")]);
let resolved = resolve_keychain_entry(&kc, &HashMap::new(), &provider)
.await
.unwrap();
assert_eq!(resolved, serde_json::json!("duffel-token"));
}
#[tokio::test]
async fn missing_secret_propagates_error() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: missing
provider: gcp
map:
k: "projects/p/secrets/nope"
"#,
)
.unwrap();
let provider = FakeProvider::new(&[]);
let err = resolve_keychain_entry(&kc, &HashMap::new(), &provider)
.await
.unwrap_err();
assert!(format!("{err:?}").contains("not found"), "got: {err:?}");
}
#[tokio::test]
async fn keychain_region_propagates_into_secret_ref_map_shape() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: eu_secret
provider: aws
region: eu-central-1
map:
api_key: "projects/p/secrets/api"
"#,
)
.unwrap();
let provider = FakeProvider::new(&[("projects/p/secrets/api", "k")]);
let _ = resolve_keychain_entry(&kc, &HashMap::new(), &provider)
.await
.unwrap();
assert_eq!(provider.last_region().as_deref(), Some("eu-central-1"));
}
#[tokio::test]
async fn keychain_region_propagates_into_secret_ref_map_less_shape() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: projects/p/secrets/x
provider: gcp
region: us-east-1
"#,
)
.unwrap();
let provider = FakeProvider::new(&[("projects/p/secrets/x", "v")]);
let _ = resolve_keychain_entry(&kc, &HashMap::new(), &provider)
.await
.unwrap();
assert_eq!(provider.last_region().as_deref(), Some("us-east-1"));
}
#[tokio::test]
async fn missing_region_falls_back_to_none_when_env_unset() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: projects/p/secrets/z
provider: gcp
"#,
)
.unwrap();
let provider = FakeProvider::new(&[("projects/p/secrets/z", "v")]);
let _ = resolve_keychain_entry(&kc, &HashMap::new(), &provider)
.await
.unwrap();
let expected = server_region();
let got = provider.last_region().unwrap_or_default();
assert_eq!(got, expected);
}
#[tokio::test]
async fn effective_region_prefers_keychain_over_env() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: x
provider: gcp
region: ap-south-1
"#,
)
.unwrap();
assert_eq!(effective_region(&kc), "ap-south-1");
}
#[tokio::test]
async fn empty_string_region_treated_as_unset() {
let kc: KeychainDef = serde_yaml::from_str(
r#"
name: x
provider: gcp
region: ""
"#,
)
.unwrap();
assert_eq!(effective_region(&kc), server_region().to_string());
}
}