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:
Dorian 2026-07-10 17:31:18 +01:00
parent 140b703838
commit bb0875f7f2

View File

@ -279,6 +279,12 @@ pub struct DerivedEnv {
pub struct SecretEnv {
pub key: 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
@ -1353,10 +1359,18 @@ impl ContainerConfig {
) -> Result<Vec<(String, String)>, ManifestError> {
let mut out = Vec::with_capacity(self.secret_env.len());
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
// 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 e.optional {
continue;
}
return Err(ManifestError::Invalid(format!(
"secret_env {} resolved to empty value (file: {})",
e.key, e.secret_file
@ -2034,10 +2048,12 @@ app:
SecretEnv {
key: "FM_BITCOIND_PASSWORD".to_string(),
secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
},
SecretEnv {
key: "FM_GATEWAY_PASSWORD".to_string(),
secret_file: "fedimint-gateway-password".to_string(),
optional: false,
},
],
generated_secrets: vec![],
@ -2079,6 +2095,7 @@ app:
secret_env: vec![SecretEnv {
key: "BITCOIN_RPC_PASS".to_string(),
secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
}],
generated_secrets: 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]
fn unsafe_manifest_values_are_rejected() {
let cases = [