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:
co-authored by
Claude Opus 4.8
parent
d7c6f8c348
commit
6aa74c7386
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
¤t,
|
||||
) {
|
||||
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, ¤t)
|
||||
.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, ¤t)
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user