fix(install): generate bitcoin RPC password before orchestrator install

Bitcoin containers were exiting in ms after start because the orchestrator
install path skipped the credential-materialisation step the legacy path
did. resolve_secret_env then failed to read
/var/lib/archipelago/secrets/bitcoin-rpc-password, the container started
with no password, and bitcoind crashed before logs were useful.

Two changes:

1. install.rs — call bitcoin_rpc_credentials() for bitcoin/bitcoin-core/
   bitcoin-knots before any install branch runs. The function generates +
   persists on first call (OnceCell-cached), so this is idempotent.

2. manifest.rs::resolve_secret_env — return ManifestError::Invalid when a
   resolved secret trims to empty, instead of silently producing
   `KEY=` env vars that crash auth.

Adds a unit test for the empty-secret rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-05-01 14:39:56 -04:00
parent f9e34fd0c6
commit 27ff1d5b52
2 changed files with 45 additions and 0 deletions

View File

@ -115,6 +115,13 @@ impl RpcHandler {
check_bitcoin_implementation_conflict(package_id).await?;
let repaired_bitcoin_conf =
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
// runs. The orchestrator path resolves secret_env from
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
// time; if the file is missing, bitcoind exits within ms.
// bitcoin_rpc_credentials() generates + persists on first
// call (OnceCell-cached), so this is idempotent.
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
ensure_bitcoin_rpc_bindings().await?
} else {
false

View File

@ -619,6 +619,14 @@ impl ContainerConfig {
let mut out = Vec::with_capacity(self.secret_env.len());
for e in &self.secret_env {
let v = provider.read(&e.secret_file)?;
// An empty secret produces e.g. `-rpcpassword=` and crashes
// the container on auth before logs are useful. Fail loud.
if v.trim().is_empty() {
return Err(ManifestError::Invalid(format!(
"secret_env {} resolved to empty value (file: {})",
e.key, e.secret_file
)));
}
out.push(format!("{}={}", e.key, v));
}
Ok(out)
@ -1051,6 +1059,36 @@ app:
assert_eq!(out[1], "FM_GATEWAY_PASSWORD=supersecret2");
}
#[test]
fn resolve_secret_env_rejects_empty_value() {
let c = ContainerConfig {
image: Some("x:latest".to_string()),
image_signature: None,
pull_policy: "if-not-present".to_string(),
build: None,
network: None,
custom_args: vec![],
entrypoint: None,
derived_env: vec![],
secret_env: vec![SecretEnv {
key: "BITCOIN_RPC_PASS".to_string(),
secret_file: "bitcoin-rpc-password".to_string(),
}],
data_uid: None,
};
let p = MapSecretsProvider {
data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]),
};
let err = c.resolve_secret_env(&p).unwrap_err();
match err {
ManifestError::Invalid(msg) => assert!(
msg.contains("BITCOIN_RPC_PASS") && msg.contains("bitcoin-rpc-password"),
"msg should name the env key + file: {msg}"
),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn parse_every_real_manifest() {
let app_manifests = list_repo_manifests();