diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 85a51cf8..b1720350 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -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, 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 = [