fix: wallet receive reliability, bitcoin install self-heal, ElectrumX app tile

Fixes three Bitcoin/wallet failures observed across the fleet on v1.7.90-alpha
(all nodes were already on the latest build — these were live bugs, not stale
builds), plus the missing ElectrumX tile, and adds automated coverage so each
can't regress silently.

Receive address (".116 receive fails", ".228 false 'wallet is locked'"):
- LND publishes its REST API on a host port that can drift from the manifest
  (a container created when the mapping was 8080 kept publishing 8080 after the
  manifest moved to 18080). The in-process client connects to the manifest port,
  gets connection-refused, and wallet init fails forever while the container
  looks "Up". Add published-port drift detection to the reconciler
  (container_ports_drifted / host_port_bindings_drifted) that recreates a
  drifted backend even for restart-sensitive apps — a drifted container is
  already broken, so leaving it "untouched" only perpetuates the failure.
- Receive errors now carry a stable [CODE] token (REST_UNREACHABLE, WALLET_LOCKED,
  WALLET_UNINITIALIZED, SYNCING) and always start with "Bitcoin address" so they
  survive the RPC error sanitizer instead of collapsing to the generic
  "Operation failed". The UI maps the code instead of guessing wallet state from
  substrings — so an unreachable REST endpoint is no longer mislabelled "locked".

Bitcoin install (".198 bitcoin gone / reinstall just stops"):
- bitcoin-knots requires the secret bitcoin-rpc-txrelay-rpcauth, which was only
  generated by the tx-relay flow. Nodes that never used tx-relay lacked it, so
  secret resolution hard-failed and the whole Bitcoin stack cascaded. Generate
  it idempotently before bitcoin starts (ensure_app_secrets, reusing
  ensure_txrelay_credentials), and name the missing secret in the error so a
  genuine gap is actionable instead of a bare "IO error".

ElectrumX app tile missing on every node with it installed:
- The catalog generator dropped electrumx because the manifest had no
  interfaces.main block, so the tile had no launch URL and was hidden. Declare
  the companion UI port (50002) in the manifest, regenerate the catalog, and let
  an app with a known launch URL stay launchable while its backend is still
  "starting" (ElectrumX indexes for 10m+).

Test harness:
- New lifecycle bats suites: bitcoin-receive, port-drift, secret-completeness
  (validated live; port-drift catches the real .116 drift).
- Rust unit tests for drift detection, the receive reason-code classifier, and
  the named-missing-secret error; vitest for the UI code mapping.
- create-release.sh now runs tests/release/run.sh and aborts the release on
  failure — previously it ran no tests at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-14 03:12:56 -04:00
co-authored by Claude Opus 4.8
parent bb808df89a
commit 0ed892a412
15 changed files with 733 additions and 22 deletions
@@ -297,6 +297,53 @@ async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64)
Ok(())
}
/// Pure published-port drift check. `port_bindings_json` is the JSON that
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
/// manifest container-port is positively published to a *different* host port
/// than the manifest now asks for. Absence of a binding is deliberately NOT
/// treated as drift here — that case is handled by the host-port repair/restart
/// path and by host-networked apps that publish nothing — so we never trigger a
/// destructive recreate on a false positive.
fn host_port_bindings_drifted(
port_bindings_json: &str,
manifest_ports: &[archipelago_container::manifest::PortMapping],
) -> bool {
let parsed: serde_json::Value = match serde_json::from_str(port_bindings_json) {
Ok(v) => v,
Err(_) => return false,
};
let Some(map) = parsed.as_object() else {
return false;
};
for port in manifest_ports {
let proto = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
let key = format!("{}/{}", port.container, proto);
let Some(bindings) = map.get(&key).and_then(|b| b.as_array()) else {
// Container-port not currently published — not our case.
continue;
};
if bindings.is_empty() {
continue;
}
let expected = port.host.to_string();
let matches_expected = bindings.iter().any(|b| {
b.get("HostPort")
.and_then(|h| h.as_str())
.map(|h| h == expected)
.unwrap_or(false)
});
if !matches_expected {
return true;
}
}
false
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if podman_socket_accepts_connections(socket_path).await {
@@ -734,8 +781,19 @@ struct FileSecretsProvider {
impl SecretsProvider for FileSecretsProvider {
fn read(&self, name: &str) -> std::result::Result<String, ManifestError> {
let path = self.root.join(name);
let data = std::fs::read_to_string(&path).map_err(ManifestError::Io)?;
Ok(data.trim().to_string())
match std::fs::read_to_string(&path) {
Ok(data) => Ok(data.trim().to_string()),
// Name the missing secret explicitly so the failure is actionable
// instead of a bare "IO error: No such file or directory" that hides
// which secret (and which app) is blocked.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(ManifestError::Invalid(format!(
"required secret '{name}' is missing (expected at {}); the app cannot start until it is generated",
path.display()
)))
}
Err(e) => Err(ManifestError::Io(e)),
}
}
}
@@ -1054,6 +1112,7 @@ impl ProdContainerOrchestrator {
let lock = self.app_lock(&app_id).await;
let _guard = lock.lock().await;
self.ensure_app_secrets(&app_id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let name = compute_container_name(&lm.manifest);
@@ -1094,6 +1153,22 @@ impl ProdContainerOrchestrator {
self.run_post_start_hooks(&app_id).await?;
return Ok(ReconcileAction::Started);
}
// Published-port drift means the container exists but maps
// its ports to the wrong host ports (e.g. lnd REST stuck on
// host 8080 while the manifest/clients expect 18080, as seen
// on .116). The container is already non-functional, so
// recreate it even for restart-sensitive apps during boot —
// leaving it "untouched" would perpetuate the breakage.
if self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
if self.container_env_drifted(&name, &resolved_manifest).await {
if mode == ReconcileMode::ExistingOnly
&& is_restart_sensitive_app(&app_id)
@@ -1154,8 +1229,12 @@ impl ProdContainerOrchestrator {
// reading it. A Rewritten outcome is fine here — we're
// about to start from a stopped state anyway.
self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await {
tracing::info!(app_id = %app_id, container = %name, "stopped container env drift detected — recreating");
if self.container_env_drifted(&name, &resolved_manifest).await
|| self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -1297,6 +1376,7 @@ impl ProdContainerOrchestrator {
/// Build-or-pull, create, start. Assumes the per-app mutex is already held.
async fn install_fresh(&self, lm: &LoadedManifest) -> Result<()> {
self.ensure_app_secrets(&lm.manifest.app.id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
@@ -2300,6 +2380,25 @@ impl ProdContainerOrchestrator {
Self::detect_disk_gb()
}
/// Ensure app-specific secrets exist *before* env resolution. The Bitcoin
/// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required
/// `secret_env`; it is normally created by the tx-relay flow, so nodes that
/// never used tx-relay lack it and `resolve_secret_env` hard-fails — taking
/// bitcoind (and everything that depends on it) down. Generating it here,
/// idempotently, lets reconcile/install self-heal that state (the .198 case)
/// instead of cascading. Must be called before every `resolve_dynamic_env`.
async fn ensure_app_secrets(&self, app_id: &str) -> Result<()> {
if cfg!(test) {
return Ok(());
}
if matches!(app_id, "bitcoin-knots" | "bitcoin-core" | "bitcoin") {
crate::api::rpc::bitcoin_relay::ensure_txrelay_credentials(&self.data_dir)
.await
.context("ensuring bitcoin tx-relay credentials")?;
}
Ok(())
}
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts();
let mut env = manifest.app.environment.clone();
@@ -2443,6 +2542,42 @@ impl ProdContainerOrchestrator {
false
}
/// True when a *running* container publishes a manifest container-port to a
/// different host port than the manifest now asks for (published-port
/// drift). This catches the class of failure seen on .116, where `lnd` was
/// created mapping host 8080 -> container 8080 but the current manifest maps
/// host 18080 -> container 8080, so every in-process REST client (which
/// connects to the manifest port) gets connection-refused forever while the
/// container looks "Up". `container_env_drifted` never inspects ports, and
/// `wait_for_manifest_host_ports` only restarts the stale container (which
/// republishes the wrong mapping), so without this check the drift is
/// self-perpetuating.
async fn container_ports_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if cfg!(test) {
return false;
}
if manifest.app.ports.is_empty() {
return false;
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{json .HostConfig.PortBindings}}",
])
.output()
.await;
let Ok(output) = inspect else {
return false;
};
if !output.status.success() {
return false;
}
let bindings = String::from_utf8_lossy(&output.stdout);
host_port_bindings_drifted(&bindings, &manifest.app.ports)
}
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
return Ok(());
@@ -2752,6 +2887,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lm = self.loaded(app_id).await?;
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
self.ensure_app_secrets(app_id).await?;
let name = compute_container_name(&lm.manifest);
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
@@ -2913,6 +3049,61 @@ mod tests {
use async_trait::async_trait;
use std::sync::Mutex as StdMutex;
fn port(host: u16, container: u16) -> archipelago_container::manifest::PortMapping {
archipelago_container::manifest::PortMapping {
host,
container,
protocol: "tcp".to_string(),
}
}
#[test]
fn port_drift_detected_when_host_port_differs() {
// The .116 case: container publishes container-port 8080 on host 8080,
// but the manifest now asks for host 18080.
let bindings = r#"{"8080/tcp":[{"HostIp":"","HostPort":"8080"}]}"#;
assert!(host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_host_port_matches() {
let bindings = r#"{"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}"#;
assert!(!host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_binding_absent() {
// Absence is handled elsewhere (host-port repair / host-networked apps);
// never treat it as drift to avoid a destructive recreate on a false
// positive.
assert!(!host_port_bindings_drifted("{}", &[port(18080, 8080)]));
assert!(!host_port_bindings_drifted("null", &[port(18080, 8080)]));
}
#[test]
fn no_drift_on_unparseable_bindings() {
assert!(!host_port_bindings_drifted(
"not json",
&[port(18080, 8080)]
));
}
#[test]
fn missing_secret_error_names_the_secret() {
use archipelago_container::manifest::SecretsProvider;
let provider = FileSecretsProvider {
root: PathBuf::from("/nonexistent-secrets-dir-xyz"),
};
let err = provider
.read("bitcoin-rpc-txrelay-rpcauth")
.expect_err("missing secret must error");
let msg = err.to_string();
assert!(
msg.contains("bitcoin-rpc-txrelay-rpcauth"),
"error should name the missing secret, got: {msg}"
);
}
/// Instrumented in-memory runtime. Every call is recorded so tests can assert
/// the exact sequence of side effects.
#[derive(Default)]