Phase 3a of the install path consolidation. Two coupled changes:
1. install.rs handle_package_install: gate the legacy "container exists →
adopt + return" probe on !orchestrator_managed. Apps the orchestrator
knows about (bitcoin-knots, bitcoin-core, lnd, electrumx, fedimint,
filebrowser, btcpay-server stack apps, mempool stack apps, plus the
companion UIs that just moved to Quadlet) skip the legacy probe and
fall straight into the orchestrator branch.
The legacy adopt block was returning success on a bare `podman start`
exit-0 — even when the process inside the container crashed seconds
later. That's the .228 "running but unreachable" failure mode. The
orchestrator's ensure_running honors the manifest's health check and
pre-start hooks (e.g. re-renders bitcoin-ui's nginx.conf if the RPC
password rotated), so this is a behavioral upgrade, not just a
refactor.
2. ProdContainerOrchestrator::install: make idempotent. Previously it
blindly called install_fresh which would fail on `podman create` if
the container name already existed. Now it delegates to ensure_running:
- Container Running + healthy → no-op (refresh hooks, restart if
config rewritten)
- Container Stopped/Exited → start (with hook refresh)
- Container missing → install_fresh
- Container in wedged state (Created/Paused/Unknown) → force-recreate
Without this, change #1 would regress every "container already exists"
case for the 18 orchestrator-managed app IDs. With it, install becomes
the single source of truth for "make app X be in the desired state."
Tests: 654 passed across the workspace (614 unit + 37 orchestration + 3
rpc), 0 failures. The 20 prod_orchestrator tests cover the install /
ensure_running / reconcile paths the new install delegates through.
Net delta: install.rs grows by ~30 lines (gating wrapper + comments),
prod_orchestrator.rs grows by ~30 lines (idempotent install body). Both
are temporary — the larger deletions (~1700 lines) come once every app
has been verified through the orchestrator path in subsequent phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
6.0 KiB
Rust
186 lines
6.0 KiB
Rust
//! Cached Bitcoin node status for browser UIs.
|
|
//!
|
|
//! The bitcoin-ui should not poll Bitcoin RPC directly for display state.
|
|
//! During container restarts, reindexing, and IBD, direct browser RPC polling
|
|
//! turns short RPC gaps into visible UI failures. This module owns the RPC
|
|
//! polling loop, caches the last successful snapshot, and serves stale-but-known
|
|
//! state while the node is reconnecting.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::Serialize;
|
|
use std::sync::OnceLock;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
use tokio::sync::RwLock;
|
|
use tracing::{debug, warn};
|
|
|
|
const CACHE_REFRESH_SECS: u64 = 5;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct BitcoinNodeStatus {
|
|
pub ok: bool,
|
|
pub stale: bool,
|
|
pub updated_at_ms: u64,
|
|
pub error: Option<String>,
|
|
pub blockchain_info: Option<serde_json::Value>,
|
|
pub network_info: Option<serde_json::Value>,
|
|
pub index_info: Option<serde_json::Value>,
|
|
pub zmq_notifications: Option<serde_json::Value>,
|
|
}
|
|
|
|
impl Default for BitcoinNodeStatus {
|
|
fn default() -> Self {
|
|
Self {
|
|
ok: false,
|
|
stale: false,
|
|
updated_at_ms: 0,
|
|
error: Some("Connecting to Bitcoin node...".to_string()),
|
|
blockchain_info: None,
|
|
network_info: None,
|
|
index_info: None,
|
|
zmq_notifications: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
static STATUS_CACHE: OnceLock<RwLock<BitcoinNodeStatus>> = OnceLock::new();
|
|
|
|
fn cache() -> &'static RwLock<BitcoinNodeStatus> {
|
|
STATUS_CACHE.get_or_init(|| RwLock::new(BitcoinNodeStatus::default()))
|
|
}
|
|
|
|
fn now_ms() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64
|
|
}
|
|
|
|
fn transient_error(err_msg: &str) -> bool {
|
|
let lower = err_msg.to_lowercase();
|
|
lower.contains("connect")
|
|
|| lower.contains("reset")
|
|
|| lower.contains("refused")
|
|
|| lower.contains("timed out")
|
|
|| lower.contains("timeout")
|
|
|| lower.contains("broken pipe")
|
|
|| lower.contains("eof")
|
|
|| lower.contains("500 internal server error")
|
|
}
|
|
|
|
pub fn spawn_status_cache() {
|
|
tokio::spawn(async {
|
|
loop {
|
|
let fresh = fetch_bitcoin_status().await;
|
|
let mut cached = cache().write().await;
|
|
match fresh {
|
|
Ok(mut status) => {
|
|
status.ok = true;
|
|
status.stale = false;
|
|
status.error = None;
|
|
*cached = status;
|
|
}
|
|
Err(e) => {
|
|
let err_msg = e.to_string();
|
|
if transient_error(&err_msg) {
|
|
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
|
|
} else {
|
|
warn!("Bitcoin status: RPC failure: {}", err_msg);
|
|
}
|
|
|
|
if cached.blockchain_info.is_some() {
|
|
cached.ok = false;
|
|
cached.stale = true;
|
|
cached.error = Some(format!(
|
|
"Bitcoin node is reconnecting; showing last known state: {}",
|
|
err_msg
|
|
));
|
|
} else {
|
|
*cached = BitcoinNodeStatus {
|
|
ok: false,
|
|
stale: false,
|
|
updated_at_ms: now_ms(),
|
|
error: Some(format!("Connecting to Bitcoin node: {}", err_msg)),
|
|
..BitcoinNodeStatus::default()
|
|
};
|
|
}
|
|
}
|
|
}
|
|
drop(cached);
|
|
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
|
cache().read().await.clone()
|
|
}
|
|
|
|
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(8))
|
|
.build()
|
|
.context("build Bitcoin status HTTP client")?;
|
|
|
|
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
|
|
.await
|
|
.context("getblockchaininfo")?;
|
|
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
|
|
.await
|
|
.context("getnetworkinfo")
|
|
.ok();
|
|
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
|
|
.await
|
|
.context("getindexinfo")
|
|
.ok();
|
|
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
|
.await
|
|
.context("getzmqnotifications")
|
|
.ok();
|
|
|
|
Ok(BitcoinNodeStatus {
|
|
ok: true,
|
|
stale: false,
|
|
updated_at_ms: now_ms(),
|
|
error: None,
|
|
blockchain_info: Some(blockchain_info),
|
|
network_info,
|
|
index_info,
|
|
zmq_notifications,
|
|
})
|
|
}
|
|
|
|
async fn bitcoin_rpc_call(
|
|
client: &reqwest::Client,
|
|
method: &str,
|
|
params: serde_json::Value,
|
|
) -> Result<serde_json::Value> {
|
|
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
|
let body = serde_json::json!({
|
|
"jsonrpc": "1.0",
|
|
"id": "bitcoin-status",
|
|
"method": method,
|
|
"params": params,
|
|
});
|
|
|
|
let resp = client
|
|
.post(crate::constants::BITCOIN_RPC_URL)
|
|
.basic_auth(rpc_user, Some(rpc_pass))
|
|
.header("Content-Type", "application/json")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.context("Bitcoin RPC request failed")?;
|
|
|
|
let status = resp.status();
|
|
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC JSON")?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("Bitcoin RPC returned {}: {}", status, json);
|
|
}
|
|
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
|
|
anyhow::bail!("Bitcoin RPC {} error: {}", method, error);
|
|
}
|
|
json.get("result")
|
|
.cloned()
|
|
.context("missing Bitcoin RPC result")
|
|
}
|