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);
@@ -86,6 +86,12 @@ pub struct AppCatalogEntry {
/// Optional human-readable changelog lines for this version.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changelog: Vec<String>,
/// Multi-version support (`docs/bitcoin-multi-version-design.md`): the bounded
/// set of versions a user may install or switch to for this app. Empty for
/// single-version apps; `version`/`image` above remain the default/latest for
/// back-compat. Old nodes ignore this field (no `deny_unknown_fields`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub versions: Vec<CatalogVersion>,
/// Full app manifest, embedded so the app installs from the registry alone —
/// no OTA-shipped `apps/<id>/manifest.yml`. Carried as the raw value the
/// publisher signed (so it stays part of the verified preimage) and
@@ -97,6 +103,29 @@ pub struct AppCatalogEntry {
pub manifest: Option<serde_json::Value>,
}
/// One selectable version in an app's `versions[]` list. The catalog carries a
/// curated, bounded set (current + a few majors back); see
/// `docs/bitcoin-multi-version-design.md` §3 Phase 1.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct CatalogVersion {
/// User-facing + tag-matching version string (e.g. `31.0`,
/// `29.3.knots20260508`). Treated as the image tag.
pub version: String,
/// Concrete image reference for this version. When omitted the orchestrator
/// falls back to composing `<default-repo>:<version>` from the entry image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
/// Marks the default / latest version pre-selected in the install modal.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
/// Deprecated versions are still installable but badged in the UI.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub deprecated: bool,
/// Optional end-of-life date (YYYY-MM-DD), surfaced in the UI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eol: Option<String>,
}
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
/// daemon's data dir first (via env for dev), then the canonical runtime path.
fn cache_paths() -> Vec<PathBuf> {
@@ -187,6 +216,64 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
.collect()
}
/// The catalog's default/latest version string for an app (the top-level
/// `version` field), if covered. Used to decide whether an install-time
/// selection should pin (older) or track-latest (default).
pub fn catalog_default_version(app_id: &str) -> Option<String> {
entry_for(app_id).map(|e| e.version).filter(|v| !v.is_empty())
}
/// Curated, selectable versions for an app per the remote catalog. Empty when
/// the catalog is absent or the app is single-version. The default entry (if
/// any) sorts first so callers can pre-select it.
pub fn catalog_versions(app_id: &str) -> Vec<CatalogVersion> {
let mut versions = entry_for(app_id).map(|e| e.versions).unwrap_or_default();
versions.sort_by_key(|v| !v.default); // default first, stable otherwise
versions
}
/// Resolve the image for a specific selectable `version` of `app_id`, validated
/// same-repo against `manifest_image` (the same guard `catalog_image_override`
/// applies). The version's explicit `image` is used when present; otherwise the
/// repo of `manifest_image` is retagged with `version`. Returns `None` when the
/// version is unknown or would point at a different repository — the caller then
/// keeps the default resolution and the switch is refused upstream.
pub fn catalog_image_for_version(
app_id: &str,
version: &str,
manifest_image: &str,
) -> Option<String> {
let entry = catalog_versions(app_id)
.into_iter()
.find(|v| v.version == version)?;
let manifest_repo =
crate::container::image_versions::image_without_registry_or_tag(manifest_image);
let candidate = match entry.image {
Some(img) => img,
None => {
// Retag the manifest's full registry/repo with the requested version.
let repo = manifest_image
.rsplit_once(':')
// keep registry:port colons intact: only strip a tag after the last '/'
.filter(|(left, _)| left.contains('/'))
.map(|(left, _)| left)
.unwrap_or(manifest_image);
format!("{repo}:{version}")
}
};
let same_repo =
crate::container::image_versions::image_without_registry_or_tag(&candidate) == manifest_repo;
if same_repo {
Some(candidate)
} else {
warn!(
"app-catalog: ignoring version {} for {} — repo mismatch (candidate={}, manifest={})",
version, app_id, candidate, manifest_image
);
None
}
}
/// Image override for the orchestrator's install/upgrade path. Returns the
/// catalog's primary image for `app_id` ONLY when it refers to the same
/// repository as the manifest's current image — a guard so a catalog typo can
@@ -214,6 +301,12 @@ pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option<Stri
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
/// catalog is missing or doesn't cover the app.
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
// A runner-pinned version is an explicit "stay here" choice — never advertise
// an update over it (design §3 Phase 3). Auto-update, when enabled, ignores
// the pin and is driven by the catalog tick, not this badge.
if crate::container::version_config::pinned_version(app_id).is_some() {
return None;
}
if let Some(catalog_image) = catalog_primary_image(app_id) {
// Catalog covers this app with a concrete image -> authoritative.
return crate::container::image_versions::available_update_for_images(
+1
View File
@@ -14,6 +14,7 @@ pub mod quadlet;
pub mod registry;
pub mod secrets;
pub mod traits;
pub mod version_config;
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
pub use dev_orchestrator::DevContainerOrchestrator;
@@ -1816,13 +1816,30 @@ impl ProdContainerOrchestrator {
// so an app update no longer requires a binary/runtime release. Falls
// back to the manifest image when the catalog is absent/uncovered.
if let Some(current) = resolved_manifest.app.container.image.clone() {
if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override(
&resolved_manifest.app.id,
&current,
) {
let app_id = resolved_manifest.app.id.clone();
// Multi-version support: a runner-pinned version wins over the catalog
// default so "install/switch to the version I chose" is honored across
// every install + recreate. Falls through to the catalog default when
// unpinned or the pin can't be resolved (unknown/repo-mismatch).
let resolved_image = crate::container::version_config::pinned_version(&app_id)
.and_then(|v| {
crate::container::app_catalog::catalog_image_for_version(&app_id, &v, &current)
.or_else(|| {
tracing::warn!(
app_id = %app_id,
pinned = %v,
"version_config: pinned version not resolvable in catalog — using default"
);
None
})
})
.or_else(|| {
crate::container::app_catalog::catalog_image_override(&app_id, &current)
});
if let Some(catalog_image) = resolved_image {
if catalog_image != current {
tracing::info!(
app_id = %resolved_manifest.app.id,
app_id = %app_id,
from = %current,
to = %catalog_image,
"app-catalog: overriding manifest image"
@@ -0,0 +1,278 @@
//! Per-app version preferences — the persistence layer for multi-version support.
//!
//! Multi-version support (`docs/bitcoin-multi-version-design.md`) lets a node
//! runner pin Bitcoin Core / Knots to a specific version and opt into
//! auto-update-to-latest. Both choices live in the existing per-app config file
//! at `/var/lib/archipelago/app-configs/<id>.json` as two keys:
//!
//! ```jsonc
//! { "pinnedVersion": "29.3.knots20260508", "autoUpdate": false }
//! ```
//!
//! This is the single source of truth the orchestrator's install path reads to
//! resolve the image, and that the auto-update tick + "available update" badge
//! consult. Reads/writes are merge-preserving so they never clobber any
//! `containerConfig` (ports/volumes/env) a generic app may also store here.
//!
//! Platform-managed apps (bitcoin-core/knots/…) never use the
//! `containerConfig`-style keys (see `config.rs::dynamic_app_config`, which
//! returns early for them), so adding these keys to their file is collision-free.
use serde_json::{Map, Value};
use std::path::PathBuf;
/// Resolved version preferences for one app. Defaults: no pin, auto-update off
/// (consensus-critical apps opt in explicitly — design open-question #4).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AppVersionConfig {
/// The version string the runner pinned, if any. Suppresses the update badge
/// and overrides the catalog default at install/recreate time.
pub pinned_version: Option<String>,
/// When true, the hourly catalog tick updates this app to the catalog
/// default automatically. Ignored while a version is pinned.
pub auto_update: bool,
}
fn config_dir() -> PathBuf {
let base = std::env::var("ARCHIPELAGO_DATA_DIR")
.unwrap_or_else(|_| "/var/lib/archipelago".to_string());
PathBuf::from(base).join("app-configs")
}
fn config_path(app_id: &str) -> PathBuf {
config_dir().join(format!("{app_id}.json"))
}
/// App ids that have opted into auto-update-to-latest AND are not pinned (a pin
/// is an explicit "stay here"). Drives the hourly per-app auto-update tick. The
/// app id is the config file stem. Returns empty when the dir is absent.
pub fn auto_update_apps() -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(config_dir()) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(app_id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let cfg = read(app_id);
if cfg.auto_update && cfg.pinned_version.is_none() {
out.push(app_id.to_string());
}
}
out
}
fn read_raw(app_id: &str) -> Map<String, Value> {
let path = config_path(app_id);
match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str::<Value>(&s)
.ok()
.and_then(|v| v.as_object().cloned())
.unwrap_or_default(),
Err(_) => Map::new(),
}
}
/// Read the version preferences for `app_id`. Returns defaults when the file is
/// absent or the keys are unset.
pub fn read(app_id: &str) -> AppVersionConfig {
let obj = read_raw(app_id);
AppVersionConfig {
pinned_version: obj
.get("pinnedVersion")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from),
auto_update: obj
.get("autoUpdate")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
/// The pinned version for `app_id`, if set. Convenience for the hot path.
pub fn pinned_version(app_id: &str) -> Option<String> {
read(app_id).pinned_version
}
/// Parse the leading numeric `major.minor.patch` of a version string into a
/// comparable tuple. Stops at the first non-numeric component, so Bitcoin Core
/// (`31.0`, `28.4`) and the Knots date-suffixed form (`29.3.knots20260508` →
/// `(29, 3, 0)`) both compare on their consensus-relevant major/minor. The
/// Knots build-date suffix is intentionally ignored — a same-major.minor Knots
/// rebuild is not a chainstate downgrade.
fn version_key(version: &str) -> (u64, u64, u64) {
let mut it = version.split('.').map(|c| {
// Take the leading digit run of each dotted component (`knots20260508`
// yields no leading digits → 0; `3` → 3).
c.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>()
.parse::<u64>()
.unwrap_or(0)
});
(
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
)
}
/// True when installing `candidate` over `current` is a DOWNGRADE — an older
/// Bitcoin release over a chainstate written by a newer one. This is the
/// highest-risk operation (Core refuses to start on a newer chainstate without
/// an expensive reindex; pruned nodes can lose data), so the UI must warn and
/// the switch must be explicitly confirmed (design §4). Equal or newer → false.
pub fn is_downgrade(current: &str, candidate: &str) -> bool {
version_key(candidate) < version_key(current)
}
/// Merge `cfg` into the on-disk config, preserving every other key. A
/// `pinned_version` of `None` removes the `pinnedVersion` key (un-pins / "track
/// latest"). Creates the directory and file on first write.
pub fn write(app_id: &str, cfg: &AppVersionConfig) -> std::io::Result<()> {
let path = config_path(app_id);
let mut obj = read_raw(app_id);
match &cfg.pinned_version {
Some(v) => {
obj.insert("pinnedVersion".to_string(), Value::String(v.clone()));
}
None => {
obj.remove("pinnedVersion");
}
}
obj.insert("autoUpdate".to_string(), Value::Bool(cfg.auto_update));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let serialized = serde_json::to_string_pretty(&Value::Object(obj))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
// Atomic-ish write: temp + rename so a crash mid-write can't truncate config.
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serialized.as_bytes())?;
std::fs::rename(&tmp, &path)
}
#[cfg(test)]
mod tests {
use super::*;
// `ARCHIPELAGO_DATA_DIR` is process-global, so the write/read tests must not
// run concurrently — serialize them and give each a unique dir. Without this
// lock, parallel `cargo test` races on the env var (poisoning is fine: a
// panicking test still releases a usable guard).
static ENV_LOCK: std::sync::Mutex<u64> = std::sync::Mutex::new(0);
fn with_tmp_data_dir<F: FnOnce()>(f: F) {
let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
*counter += 1;
let dir = std::env::temp_dir().join(format!(
"archy-vc-test-{}-{}",
std::process::id(),
*counter
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir);
f();
std::env::remove_var("ARCHIPELAGO_DATA_DIR");
let _ = std::fs::remove_dir_all(&dir);
// `counter` guard drops here, releasing the lock for the next test.
}
#[test]
fn defaults_when_absent() {
with_tmp_data_dir(|| {
let cfg = read("bitcoin-core");
assert_eq!(cfg.pinned_version, None);
assert!(!cfg.auto_update);
});
}
#[test]
fn write_then_read_roundtrips() {
with_tmp_data_dir(|| {
write(
"bitcoin-knots",
&AppVersionConfig {
pinned_version: Some("29.3.knots20260508".into()),
auto_update: false,
},
)
.unwrap();
let cfg = read("bitcoin-knots");
assert_eq!(cfg.pinned_version.as_deref(), Some("29.3.knots20260508"));
assert!(!cfg.auto_update);
});
}
#[test]
fn write_preserves_existing_keys() {
with_tmp_data_dir(|| {
// Simulate a generic app's containerConfig already on disk.
let path = config_path("someapp");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"ports":["80:80"],"autoUpdate":false}"#).unwrap();
write(
"someapp",
&AppVersionConfig {
pinned_version: Some("1.2.3".into()),
auto_update: true,
},
)
.unwrap();
let raw = read_raw("someapp");
assert!(raw.contains_key("ports"), "ports key must survive");
assert_eq!(raw.get("pinnedVersion").unwrap(), "1.2.3");
assert_eq!(raw.get("autoUpdate").unwrap(), &Value::Bool(true));
});
}
#[test]
fn downgrade_detection() {
// Older over newer = downgrade.
assert!(is_downgrade("31.0", "30.0"));
assert!(is_downgrade("28.4", "27.2"));
// Same or newer = not a downgrade.
assert!(!is_downgrade("30.0", "31.0"));
assert!(!is_downgrade("28.4", "28.4"));
// Knots date-suffixed strings compare on major.minor only.
assert!(is_downgrade("29.3.knots20260508", "28.1.knots20251010"));
assert!(!is_downgrade(
"29.3.knots20260101",
"29.3.knots20260508"
));
}
#[test]
fn unpin_removes_key() {
with_tmp_data_dir(|| {
write(
"bitcoin-core",
&AppVersionConfig {
pinned_version: Some("31.0".into()),
auto_update: true,
},
)
.unwrap();
write(
"bitcoin-core",
&AppVersionConfig {
pinned_version: None,
auto_update: true,
},
)
.unwrap();
let raw = read_raw("bitcoin-core");
assert!(!raw.contains_key("pinnedVersion"));
assert_eq!(read("bitcoin-core").pinned_version, None);
assert!(read("bitcoin-core").auto_update);
});
}
}
+7 -3
View File
@@ -288,7 +288,9 @@ async fn main() -> Result<()> {
// via auth.setup RPC. The Login page detects is_setup=false and shows
// "Create Password" form instead of login form.
// Create server
// Create server. Keep a clone of the orchestrator handle for the background
// update scheduler (per-app auto-update applies via the orchestrator).
let update_orchestrator = orchestrator.clone();
let server = Server::new(config.clone(), orchestrator, dev_orchestrator).await?;
// Start server
@@ -313,10 +315,12 @@ async fn main() -> Result<()> {
});
}
// Spawn background update scheduler
// Spawn background update scheduler. Pass the orchestrator so the scheduler
// can apply per-app auto-update-to-latest (multi-version support) via the
// safe orchestrator upgrade path; None in dev mode disables it.
let update_data_dir = config.data_dir.clone();
tokio::spawn(async move {
update::run_update_scheduler(update_data_dir).await;
update::run_update_scheduler(update_data_dir, update_orchestrator).await;
});
// Synchronize host-side doctor artifacts (script + systemd units) with
+65 -1
View File
@@ -1702,7 +1702,67 @@ pub async fn get_schedule(data_dir: &Path) -> Result<UpdateSchedule> {
/// Background update scheduler. Runs in a loop, checking/applying based on schedule.
/// Call this once at startup via `tokio::spawn`.
pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
/// Apply per-app auto-update-to-latest for apps the runner opted in
/// (`docs/bitcoin-multi-version-design.md` §3 Phase 3). Independent of the
/// binary OTA schedule below. Conservative: only upgrades an app when the fresh
/// catalog actually advertises a newer image than the one running, and only via
/// the orchestrator's normal upgrade lifecycle (the same safe path as the
/// manual "Update" button). Pinned apps are excluded upstream in
/// `auto_update_apps()`. Best-effort — failures are logged, never fatal.
async fn apply_per_app_auto_updates(
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
) {
let Some(orchestrator) = orchestrator.as_ref() else {
return;
};
for app_id in crate::container::version_config::auto_update_apps() {
// Determine the version actually running by inspecting the backend
// container's image. Skip when not installed / unreadable.
let running_image = ["", "archy-"]
.iter()
.map(|p| format!("{p}{app_id}"))
.collect::<Vec<_>>();
let mut current_image = None;
for name in &running_image {
if let Ok(out) = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.ImageName}}"])
.output()
.await
{
if out.status.success() {
let img = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !img.is_empty() {
current_image = Some(img);
break;
}
}
}
}
let Some(current_image) = current_image else {
continue;
};
// Only act when the catalog advertises a genuine update over what's
// running (this also re-checks the pin guard inside the helper).
if crate::container::app_catalog::available_update_for_app(&app_id, &current_image)
.is_none()
{
continue;
}
info!(
"auto-update: {} has a newer catalog image (running {}), upgrading",
app_id, current_image
);
match orchestrator.upgrade(&app_id).await {
Ok(()) => info!("auto-update: {} upgraded to catalog latest", app_id),
Err(e) => warn!("auto-update: {} upgrade failed: {}", app_id, e),
}
}
}
pub async fn run_update_scheduler(
data_dir: std::path::PathBuf,
orchestrator: Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
) {
use tokio::time::{interval, Duration};
// Check every hour; act based on schedule setting
@@ -1728,6 +1788,10 @@ pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
debug!("Update scheduler: app-catalog refresh failed: {}", e);
}
// Per-app auto-update-to-latest (multi-version support). Runs every tick
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
apply_per_app_auto_updates(&orchestrator).await;
let state = match load_state(&data_dir).await {
Ok(s) => s,
Err(e) => {