feat(manifest): optional secret_env entries
A secret_env entry with optional: true is skipped when its secret file is missing, unreadable, or empty, instead of failing the whole resolution and taking the app down. Needed for per-node integrations like btcpay's internal-LND connection string: nodes without LND must still run btcpay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
140b703838
commit
bb0875f7f2
@ -279,6 +279,12 @@ pub struct DerivedEnv {
|
|||||||
pub struct SecretEnv {
|
pub struct SecretEnv {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub secret_file: String,
|
pub secret_file: String,
|
||||||
|
/// When true, a missing/unreadable/empty secret skips this entry instead
|
||||||
|
/// of failing the whole resolution. For integrations that exist on some
|
||||||
|
/// nodes only (btcpay's internal-LND connection string: nodes without
|
||||||
|
/// LND must still run btcpay, just without the internal node).
|
||||||
|
#[serde(default)]
|
||||||
|
pub optional: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fully resolved secret env entry, produced at apply time. `value` lives
|
/// A fully resolved secret env entry, produced at apply time. `value` lives
|
||||||
@ -1353,10 +1359,18 @@ impl ContainerConfig {
|
|||||||
) -> Result<Vec<(String, String)>, ManifestError> {
|
) -> Result<Vec<(String, String)>, ManifestError> {
|
||||||
let mut out = Vec::with_capacity(self.secret_env.len());
|
let mut out = Vec::with_capacity(self.secret_env.len());
|
||||||
for e in &self.secret_env {
|
for e in &self.secret_env {
|
||||||
let v = provider.read(&e.secret_file)?;
|
let v = match provider.read(&e.secret_file) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) if e.optional => continue,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
// An empty secret produces e.g. `-rpcpassword=` and crashes
|
// An empty secret produces e.g. `-rpcpassword=` and crashes
|
||||||
// the container on auth before logs are useful. Fail loud.
|
// the container on auth before logs are useful. Fail loud —
|
||||||
|
// unless the entry is optional, where empty means "not set".
|
||||||
if v.trim().is_empty() {
|
if v.trim().is_empty() {
|
||||||
|
if e.optional {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return Err(ManifestError::Invalid(format!(
|
return Err(ManifestError::Invalid(format!(
|
||||||
"secret_env {} resolved to empty value (file: {})",
|
"secret_env {} resolved to empty value (file: {})",
|
||||||
e.key, e.secret_file
|
e.key, e.secret_file
|
||||||
@ -2034,10 +2048,12 @@ app:
|
|||||||
SecretEnv {
|
SecretEnv {
|
||||||
key: "FM_BITCOIND_PASSWORD".to_string(),
|
key: "FM_BITCOIND_PASSWORD".to_string(),
|
||||||
secret_file: "bitcoin-rpc-password".to_string(),
|
secret_file: "bitcoin-rpc-password".to_string(),
|
||||||
|
optional: false,
|
||||||
},
|
},
|
||||||
SecretEnv {
|
SecretEnv {
|
||||||
key: "FM_GATEWAY_PASSWORD".to_string(),
|
key: "FM_GATEWAY_PASSWORD".to_string(),
|
||||||
secret_file: "fedimint-gateway-password".to_string(),
|
secret_file: "fedimint-gateway-password".to_string(),
|
||||||
|
optional: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
generated_secrets: vec![],
|
generated_secrets: vec![],
|
||||||
@ -2079,6 +2095,7 @@ app:
|
|||||||
secret_env: vec![SecretEnv {
|
secret_env: vec![SecretEnv {
|
||||||
key: "BITCOIN_RPC_PASS".to_string(),
|
key: "BITCOIN_RPC_PASS".to_string(),
|
||||||
secret_file: "bitcoin-rpc-password".to_string(),
|
secret_file: "bitcoin-rpc-password".to_string(),
|
||||||
|
optional: false,
|
||||||
}],
|
}],
|
||||||
generated_secrets: vec![],
|
generated_secrets: vec![],
|
||||||
generated_certs: vec![],
|
generated_certs: vec![],
|
||||||
@ -2099,6 +2116,51 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_secret_env_skips_missing_or_empty_optional_entries() {
|
||||||
|
let c = ContainerConfig {
|
||||||
|
image: Some("x:latest".to_string()),
|
||||||
|
image_signature: None,
|
||||||
|
pull_policy: "if-not-present".to_string(),
|
||||||
|
build: None,
|
||||||
|
network: None,
|
||||||
|
network_aliases: vec![],
|
||||||
|
custom_args: vec![],
|
||||||
|
entrypoint: None,
|
||||||
|
derived_env: vec![],
|
||||||
|
secret_env: vec![
|
||||||
|
SecretEnv {
|
||||||
|
key: "REQUIRED".to_string(),
|
||||||
|
secret_file: "present".to_string(),
|
||||||
|
optional: false,
|
||||||
|
},
|
||||||
|
SecretEnv {
|
||||||
|
key: "OPT_MISSING".to_string(),
|
||||||
|
secret_file: "does-not-exist".to_string(),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
SecretEnv {
|
||||||
|
key: "OPT_EMPTY".to_string(),
|
||||||
|
secret_file: "empty".to_string(),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generated_secrets: vec![],
|
||||||
|
generated_certs: vec![],
|
||||||
|
data_uid: None,
|
||||||
|
secret_env_refs: vec![],
|
||||||
|
secret_env_hash: None,
|
||||||
|
};
|
||||||
|
let p = MapSecretsProvider {
|
||||||
|
data: HashMap::from([
|
||||||
|
("present".to_string(), "value".to_string()),
|
||||||
|
("empty".to_string(), " \n".to_string()),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
let out = c.resolve_secret_env(&p).unwrap();
|
||||||
|
assert_eq!(out, vec!["REQUIRED=value".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unsafe_manifest_values_are_rejected() {
|
fn unsafe_manifest_values_are_rejected() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user