feat(bitcoin): multi-version support for Core & Knots (install/switch/pin/auto-update)

Lets a node runner choose which Bitcoin Core / Knots version to install
(latest pre-selected), then switch, pin, or opt into auto-update from the
app's interface — all manifest/catalog-driven, rootless, signed-registry,
zero-data-loss. Motivated by upcoming BIP-110 signalling: runners need a
real choice of software version.

Backend:
- version_config.rs: per-app pin + auto-update persistence (atomic, merge-
  preserving), downgrade detection, auto-update enumeration (+ unit tests).
- app_catalog.rs: CatalogVersion / versions[] schema, catalog_versions(),
  catalog_image_for_version() (same-repo guard); a pin suppresses the update
  badge.
- prod_orchestrator.rs: pinned version wins over the catalog default on every
  install/recreate.
- install.rs: install-time `version` param persisted (default = unpinned).
- set_config.rs: package.versions (read) + package.set-config (write) RPCs;
  downgrade is gated behind explicit confirm (warn + confirm + allow).
- update.rs/main.rs: hourly per-app auto-update tick via the orchestrator
  (opt-in, pin-respecting); fix handle_package_update to be non-fatal for
  orchestrator-managed apps lacking a catalog primary image (bitcoin-core).

UI:
- MarketplaceAppDetails.vue: install-time version selector (shown when an app
  offers >=2 versions).
- appDetails/AppSidebar.vue: "Version & Updates" card (switch / pin / auto-
  update toggle / downgrade warning), per app.
- rpc-client.ts + en.json: RPC methods, types, strings.

Phase 0 image pipeline:
- scripts/build-bitcoin-image.sh: download official tarball + SHA256SUMS(.asc),
  verify SHA-256 + pinned-maintainer OpenPGP signature (fail-closed), build a
  minimal rootless image, smoke-test, tag + push.
- apps/bitcoin-core/Dockerfile rewritten (drops stale community base);
  apps/bitcoin-knots/Dockerfile added.
- generate-app-catalog.sh: emit curated versions[]; published + catalog now
  offers Core 25.2/26.2/27.2/28.4/29.3/30.2/31.0 + Knots 29.3.knots20260508.

docs/bitcoin-multi-version-design.md: live progress tracker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-28 18:46:17 -04:00
co-authored by Claude Opus 4.8
parent d7c6f8c348
commit 6aa74c7386
21 changed files with 1454 additions and 30 deletions
@@ -57,6 +57,8 @@ impl RpcHandler {
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
"package.update" => self.clone().spawn_package_update(params).await,
"package.check-updates" => self.handle_package_check_updates(params).await,
"package.versions" => self.handle_package_versions(params).await,
"package.set-config" => self.clone().handle_package_set_config(params).await,
"package.credentials" => self.handle_package_credentials(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
@@ -243,6 +243,17 @@ impl RpcHandler {
}
}
// Multi-version support: honor an install-time version selection for the
// orchestrator-managed Bitcoin apps. Selecting the catalog default (or
// omitting `version`) leaves the app unpinned (tracks latest); selecting
// an older version pins it so install_fresh resolves that image and the
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
persist_install_version_selection(package_id, version).await;
}
}
// Phase: Preparing — emit BEFORE the stack dispatch so multi-container
// stacks also flip state to Installing immediately. Without this, the
// backend's package state for stack apps stayed empty until the first
@@ -2427,6 +2438,36 @@ exit 2
}
}
/// Persist an install-time version selection for a multi-version app. Selecting
/// the catalog default (or a version equal to it) un-pins so the app tracks
/// latest; selecting any other version pins it. Best-effort: a write failure
/// just means the app installs at the catalog default.
async fn persist_install_version_selection(app_id: &str, version: &str) {
use crate::container::version_config::{read, write, AppVersionConfig};
let is_default = crate::container::app_catalog::catalog_default_version(app_id)
.map(|d| d == version)
.unwrap_or(false);
let existing = read(app_id);
let cfg = AppVersionConfig {
pinned_version: if is_default {
None
} else {
Some(version.to_string())
},
auto_update: existing.auto_update,
};
if let Err(e) = write(app_id, &cfg) {
tracing::warn!(app_id, version, error = %e, "failed to persist install-time version selection");
} else {
tracing::info!(
app_id,
version,
pinned = !is_default,
"persisted install-time version selection"
);
}
}
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && uses_orchestrator_install_flow(package_id)
}
@@ -5,6 +5,7 @@ mod install;
mod lifecycle;
mod progress;
mod runtime;
mod set_config;
mod stacks;
mod update;
mod validation;
@@ -0,0 +1,268 @@
//! Multi-version support — version listing + in-app version switch / pin /
//! auto-update toggle (`docs/bitcoin-multi-version-design.md` §3 Phase 3).
//!
//! Two RPCs:
//! - `package.versions` — read the selectable versions for an app plus the
//! runner's current pin / auto-update preference and (best-effort) the
//! version actually running. Drives the install modal + "Version & Updates"
//! card.
//! - `package.set-config` — persist a version pin (or un-pin to track latest)
//! and/or the auto-update toggle, then recreate the app at the chosen image
//! when the version actually changed. A DOWNGRADE (older release over a
//! newer chainstate — the highest-risk operation, design §4) is refused
//! unless the caller passes `confirm: true`, so the UI can warn first.
use super::config::get_containers_for_app;
use super::install::install_log;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::container::{app_catalog, version_config};
use anyhow::Result;
use std::sync::Arc;
use tracing::{info, warn};
/// Apps that participate in multi-version selection today. Kept narrow on
/// purpose: version switching recreates the container, which is only safe for
/// the single-container, orchestrator-managed Bitcoin backends whose data and
/// downgrade semantics we understand. Any app the catalog gives a `versions[]`
/// list also qualifies (third-party registry apps inherit the capability).
fn supports_versions(app_id: &str) -> bool {
matches!(app_id, "bitcoin-core" | "bitcoin-knots")
|| !app_catalog::catalog_versions(app_id).is_empty()
}
/// Extract the tag from a full image reference, leaving a `registry:port/repo`
/// host-port colon intact (only a colon AFTER the last `/` is a tag).
fn image_tag(image: &str) -> Option<String> {
let after_slash = image.rsplit_once('/').map(|(_, r)| r).unwrap_or(image);
after_slash
.rsplit_once(':')
.map(|(_, tag)| tag.to_string())
.filter(|t| !t.is_empty())
}
/// Best-effort: the version tag of the backend container actually running for
/// `app_id`, by inspecting its image. `None` when not installed or unreadable.
async fn installed_version(app_id: &str) -> Option<String> {
let containers = get_containers_for_app(app_id).await.ok()?;
// Prefer the backend container (exact id / `archy-<id>`) over UI companions.
let name = containers
.iter()
.find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}"))
.or_else(|| containers.first())?;
let out = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.ImageName}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let image = String::from_utf8_lossy(&out.stdout).trim().to_string();
image_tag(&image)
}
impl RpcHandler {
/// `package.versions` — what a runner can install / switch to for this app,
/// plus their current preference and the running version.
pub(in crate::api::rpc) async fn handle_package_versions(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(app_id)?;
let versions = app_catalog::catalog_versions(app_id);
let default = app_catalog::catalog_default_version(app_id);
let cfg = version_config::read(app_id);
let installed = installed_version(app_id).await;
Ok(serde_json::json!({
"id": app_id,
"supportsVersions": supports_versions(app_id),
"default": default,
"installedVersion": installed,
"pinnedVersion": cfg.pinned_version,
"autoUpdate": cfg.auto_update,
"versions": versions.iter().map(|v| serde_json::json!({
"version": v.version,
"default": v.default,
"deprecated": v.deprecated,
"eol": v.eol,
})).collect::<Vec<_>>(),
}))
}
/// `package.set-config` — persist version pin + auto-update preference and
/// recreate on an actual version change. Downgrades require `confirm:true`.
pub(in crate::api::rpc) async fn handle_package_set_config(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
validate_app_id(&app_id)?;
if !supports_versions(&app_id) {
return Err(anyhow::anyhow!(
"{} has no selectable versions in the catalog",
app_id
));
}
let confirm = params
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let existing = version_config::read(&app_id);
let default = app_catalog::catalog_default_version(&app_id);
// ---- Resolve the requested pin (if a version was supplied) ----------
// Absent `version` => leave the pin unchanged (an auto-update-only edit).
// `version == default` => un-pin (track latest). Any other version must
// exist in the catalog and resolve to a same-repo image, else reject.
let version_param = params
.get("version")
.and_then(|v| v.as_str())
.map(str::to_string);
let mut new_pin = existing.pinned_version.clone();
let mut version_changed = false;
if let Some(req) = version_param.as_deref() {
let resolved_pin = if default.as_deref() == Some(req) {
None // selecting the default un-pins
} else {
// Validate the version is real + same-repo before pinning.
if !app_catalog::catalog_versions(&app_id)
.iter()
.any(|v| v.version == req)
{
return Err(anyhow::anyhow!(
"version {} is not offered for {}",
req,
app_id
));
}
Some(req.to_string())
};
version_changed = resolved_pin != existing.pinned_version;
new_pin = resolved_pin;
}
let new_auto_update = params
.get("autoUpdate")
.and_then(|v| v.as_bool())
.unwrap_or(existing.auto_update);
// ---- Downgrade gate (design §4: warn + confirm + allow) -------------
// "Current" = what wrote the on-disk chainstate: the running version if
// we can read it, else the existing pin, else the catalog default.
if version_changed {
let target = version_param.as_deref().unwrap_or_default();
let current = installed_version(&app_id)
.await
.or_else(|| existing.pinned_version.clone())
.or_else(|| default.clone());
if let Some(current) = current {
if version_config::is_downgrade(&current, target) && !confirm {
warn!(
"set-config {}: refusing un-confirmed downgrade {} -> {}",
app_id, current, target
);
return Ok(serde_json::json!({
"status": "confirm_required",
"kind": "downgrade",
"id": app_id,
"currentVersion": current,
"targetVersion": target,
"warning": format!(
"Switching {app_id} from {current} down to {target} is a \
downgrade. Bitcoin may refuse to start on a chainstate \
written by the newer version without a full reindex, and \
a pruned node can lose block data. Re-confirm to proceed."
),
}));
}
}
}
// ---- Persist preference --------------------------------------------
version_config::write(
&app_id,
&version_config::AppVersionConfig {
pinned_version: new_pin.clone(),
auto_update: new_auto_update,
},
)?;
install_log(&format!(
"SET-CONFIG {}: pinned={:?} autoUpdate={} (version_changed={})",
app_id, new_pin, new_auto_update, version_changed
))
.await;
info!(
app_id = %app_id,
pinned = ?new_pin,
auto_update = new_auto_update,
version_changed,
"package.set-config applied"
);
// ---- Recreate when the version actually changed + app is installed --
// The orchestrator's install/recreate path reads the pin we just wrote
// (prod_orchestrator image resolution), so reusing the update machinery
// pulls + recreates at the chosen image. An auto-update-only edit, or a
// change to a not-installed app, just persists the preference.
let mut recreating = false;
if version_changed {
let installed = get_containers_for_app(&app_id)
.await
.map(|c| !c.is_empty())
.unwrap_or(false);
if installed {
recreating = true;
// Fire the existing async update flow; it flips state to
// Updating and recreates honoring the new pin. The UI polls.
self.clone()
.spawn_package_update(Some(serde_json::json!({ "id": app_id })))
.await?;
}
}
Ok(serde_json::json!({
"status": "ok",
"id": app_id,
"pinnedVersion": new_pin,
"autoUpdate": new_auto_update,
"versionChanged": version_changed,
"recreating": recreating,
}))
}
}
#[cfg(test)]
mod tests {
use super::image_tag;
#[test]
fn image_tag_keeps_registry_port_colon() {
assert_eq!(
image_tag("146.59.87.168:3000/lfg2025/bitcoin:28.4").as_deref(),
Some("28.4")
);
assert_eq!(
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508")
.as_deref(),
Some("29.3.knots20260508")
);
// No tag => None (don't mistake the registry port for a tag).
assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None);
assert_eq!(image_tag("docker.io/library/redis:7"), Some("7".to_string()));
}
}
+24 -6
View File
@@ -32,19 +32,27 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Verify an update is actually available. Prefer the remote app catalog
// (decoupled from the binary OTA), falling back to the image-versions.sh
// pin when the catalog is absent or doesn't cover this app.
// Resolve the target image. Prefer the remote app catalog (decoupled
// from the binary OTA), falling back to the image-versions.sh pin. This
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
// the image itself (manifest + catalog + version_config pin) in its
// upgrade path, so an app the catalog doesn't carry a primary image for
// (e.g. bitcoin-core, image lives in the embedded manifest + versions[])
// still upgrades. Only the legacy/stack path below hard-requires it.
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
.or_else(|| image_versions::pinned_image_for_app(package_id))
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
.or_else(|| image_versions::pinned_image_for_app(package_id));
// Note: the `already updating` guard lives in `spawn_package_update`
// (the async wrapper that dispatch actually routes to). By the time
// this inner function runs, the wrapper has already flipped state to
// `Updating`, so duplicating the check here would be a false positive.
install_log(&format!("UPDATE: {}{}", package_id, pinned)).await;
install_log(&format!(
"UPDATE: {}{}",
package_id,
pinned.as_deref().unwrap_or("(orchestrator-resolved)")
))
.await;
// Set state to Updating
{
@@ -114,6 +122,16 @@ impl RpcHandler {
}
}
// Legacy/stack path hard-requires a concrete primary image (the
// orchestrator path above already returned for apps it manages).
let pinned = match pinned {
Some(p) => p,
None => {
self.clear_update_state(package_id).await;
return Err(anyhow::anyhow!("No pinned image found for {}", package_id));
}
};
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);