diff --git a/apps/bitcoin-core/Dockerfile b/apps/bitcoin-core/Dockerfile index a901df86..883f977f 100644 --- a/apps/bitcoin-core/Dockerfile +++ b/apps/bitcoin-core/Dockerfile @@ -1,5 +1,29 @@ -# Bitcoin Core - uses official image -FROM bitcoin/bitcoin:24.0 - -# Default user is already 'bitcoin' -# No additional setup needed +# Bitcoin Core — minimal rootless image built from the OFFICIAL upstream release. +# +# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which +# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature +# (fail-closed), and tags/pushes /bitcoin:. This Dockerfile +# mirrors that image for a manual/local build and replaces the old stale +# community base (`FROM bitcoin/bitcoin:24.0`). +# +# Build (binaries must be pre-fetched + verified into ./bin — see the script): +# scripts/build-bitcoin-image.sh core 31.0 +FROM debian:bookworm-slim +ARG BITCOIN_VERSION=31.0 +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/*; \ + useradd -m -u 1000 -s /bin/bash bitcoin; \ + mkdir -p /home/bitcoin/.bitcoin; \ + chown -R bitcoin:bitcoin /home/bitcoin +# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Guix-built, +# x86_64-linux-gnu) extracted from the official release tarball. +COPY bin/bitcoind /usr/local/bin/bitcoind +COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli +RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli +USER bitcoin +WORKDIR /home/bitcoin +VOLUME ["/home/bitcoin/.bitcoin"] +EXPOSE 8332 8333 +ENTRYPOINT ["bitcoind"] diff --git a/apps/bitcoin-knots/Dockerfile b/apps/bitcoin-knots/Dockerfile new file mode 100644 index 00000000..5f2afb4d --- /dev/null +++ b/apps/bitcoin-knots/Dockerfile @@ -0,0 +1,30 @@ +# Bitcoin Knots — minimal rootless image built from the OFFICIAL upstream release. +# +# Knots previously had NO Dockerfile (the :latest tag was built/pushed by hand). +# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which +# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature +# (fail-closed, Luke-Jr release key), and tags/pushes +# /bitcoin-knots:. Knots version strings embed a build date, +# e.g. 29.3.knots20260508 — the full string is the tag. +# +# Build (binaries must be pre-fetched + verified into ./bin — see the script): +# scripts/build-bitcoin-image.sh knots 29.3.knots20260508 +FROM debian:bookworm-slim +ARG KNOTS_VERSION=29.3.knots20260508 +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/*; \ + useradd -m -u 1000 -s /bin/bash bitcoin; \ + mkdir -p /home/bitcoin/.bitcoin; \ + chown -R bitcoin:bitcoin /home/bitcoin +# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Knots, Guix-built, +# x86_64-linux-gnu) extracted from the official release tarball. +COPY bin/bitcoind /usr/local/bin/bitcoind +COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli +RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli +USER bitcoin +WORKDIR /home/bitcoin +VOLUME ["/home/bitcoin/.bitcoin"] +EXPOSE 8332 8333 +ENTRYPOINT ["bitcoind"] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index f35e436c..b7db5354 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 6fbdabb5..d4f506a5 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -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) } diff --git a/core/archipelago/src/api/rpc/package/mod.rs b/core/archipelago/src/api/rpc/package/mod.rs index b1b24d29..4c165026 100644 --- a/core/archipelago/src/api/rpc/package/mod.rs +++ b/core/archipelago/src/api/rpc/package/mod.rs @@ -5,6 +5,7 @@ mod install; mod lifecycle; mod progress; mod runtime; +mod set_config; mod stacks; mod update; mod validation; diff --git a/core/archipelago/src/api/rpc/package/set_config.rs b/core/archipelago/src/api/rpc/package/set_config.rs new file mode 100644 index 00000000..9c8fdfcb --- /dev/null +++ b/core/archipelago/src/api/rpc/package/set_config.rs @@ -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 { + 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 { + let containers = get_containers_for_app(app_id).await.ok()?; + // Prefer the backend container (exact id / `archy-`) 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, + ) -> Result { + 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::>(), + })) + } + + /// `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, + params: Option, + ) -> Result { + 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(¤t, 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())); + } +} diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs index af5203b4..a90bd5f3 100644 --- a/core/archipelago/src/api/rpc/package/update.rs +++ b/core/archipelago/src/api/rpc/package/update.rs @@ -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); diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index 3c47ed71..54c11fcc 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -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, + /// 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, /// Full app manifest, embedded so the app installs from the registry alone — /// no OTA-shipped `apps//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, } +/// 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 `:` from the entry image. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + /// 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, +} + /// 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 { @@ -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 { + 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 { + 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 { + 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 Option { + // 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( diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index 80f9957f..1ee128e7 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -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; diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 965386d2..cd4fe297 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -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" diff --git a/core/archipelago/src/container/version_config.rs b/core/archipelago/src/container/version_config.rs new file mode 100644 index 00000000..e484c1a0 --- /dev/null +++ b/core/archipelago/src/container/version_config.rs @@ -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/.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, + /// 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 { + 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 { + let path = config_path(app_id); + match std::fs::read_to_string(&path) { + Ok(s) => serde_json::from_str::(&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 { + 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::() + .parse::() + .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 = std::sync::Mutex::new(0); + + fn with_tmp_data_dir(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); + }); + } +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 0b5d71e7..f937d058 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -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 diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 651cb94a..762ccdad 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -1702,7 +1702,67 @@ pub async fn get_schedule(data_dir: &Path) -> Result { /// 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>, +) { + 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::>(); + 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, ¤t_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>, +) { 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) => { diff --git a/docs/bitcoin-multi-version-design.md b/docs/bitcoin-multi-version-design.md index a3e4c0f9..3f74ff01 100644 --- a/docs/bitcoin-multi-version-design.md +++ b/docs/bitcoin-multi-version-design.md @@ -1,5 +1,90 @@ # Bitcoin Multi-Version Support — Design + + **Status:** design (2026-06-22) **Goal:** let a user choose *which* version of Bitcoin Core / Bitcoin Knots to install (latest pre-selected, older versions in a dropdown), and later switch diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 1857ea07..48801f37 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -15,6 +15,40 @@ export interface RPCResponse { } } +// Multi-version support (docs/bitcoin-multi-version-design.md). Mirrors the +// `package.versions` / `package.set-config` RPC payloads. +export interface CatalogVersionInfo { + version: string + default: boolean + deprecated: boolean + eol: string | null +} + +export interface PackageVersionsResponse { + id: string + supportsVersions: boolean + default: string | null + installedVersion: string | null + pinnedVersion: string | null + autoUpdate: boolean + versions: CatalogVersionInfo[] +} + +export interface SetPackageConfigResponse { + status: 'ok' | 'confirm_required' + id: string + // present on status === 'ok' + pinnedVersion?: string | null + autoUpdate?: boolean + versionChanged?: boolean + recreating?: boolean + // present on status === 'confirm_required' + kind?: string + currentVersion?: string + targetVersion?: string + warning?: string +} + /// Mirrors `crate::federation::pending::PendingPeerRequest` on the backend. export type PendingState = 'pending' | 'sent' | 'approved' | 'rejected' | 'expired' @@ -586,6 +620,31 @@ class RPCClient { }) } + // Multi-version support (docs/bitcoin-multi-version-design.md): list the + // versions a runner may install / switch to for an app, plus the current pin + // / auto-update preference and the version actually running. + async getPackageVersions(id: string): Promise { + return this.call({ + method: 'package.versions', + params: { id }, + timeout: 15000, + }) + } + + // Persist a version pin (or un-pin to track latest) and/or the auto-update + // toggle. A DOWNGRADE returns { status: 'confirm_required', warning } unless + // confirm:true is passed — the UI warns first, then re-calls with confirm. + async setPackageConfig( + id: string, + opts: { version?: string; autoUpdate?: boolean; confirm?: boolean }, + ): Promise { + return this.call({ + method: 'package.set-config', + params: { id, ...opts }, + timeout: 600000, + }) + } + async checkPackageUpdates(): Promise<{ status: string refreshed: boolean diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 08e92021..c8aa340a 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -568,8 +568,16 @@ "notFoundTitle": "App Not Found", "notFoundMessage": "The requested application could not be found", "installed": "Installed", - "channels": "Channels", - "noLaunchUrl": "No launch URL available for this app yet" + "noLaunchUrl": "No launch URL available for this app yet", + "versionUpdates": "Version & Updates", + "runningVersion": "Running version", + "selectVersion": "Version", + "autoUpdateLatest": "Auto-update to latest", + "autoUpdatePinnedNote": "Auto-update is disabled while a version is pinned.", + "confirmDowngrade": "Downgrade anyway", + "applyingVersion": "Applying…", + "applyVersion": "Apply", + "downgradeGeneric": "This is a downgrade and may require a re-sync. Re-confirm to proceed." }, "containerDetails": { "back": "Back", @@ -608,7 +616,8 @@ "installFailed": "Installation Failed", "depRunning": "Running", "depStopped": "Installed but stopped", - "depNotInstalled": "Not installed" + "depNotInstalled": "Not installed", + "selectVersion": "Select version" }, "goalDetail": { "backToGoals": "Back to Goals", @@ -761,7 +770,7 @@ "auto": "Auto", "lightning": "Lightning", "ecash": "Ecash", - "autoMethodDesc": "Auto-selects method based on amount: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k", + "autoMethodDesc": "Auto-selects method based on amount: ecash < 1k sats, Lightning 1k–500k, on-chain > 500k", "amountSats": "Amount (sats)", "lightningInvoice": "Lightning Invoice (BOLT11)", "bitcoinAddress": "Bitcoin Address", diff --git a/neode-ui/src/views/MarketplaceAppDetails.vue b/neode-ui/src/views/MarketplaceAppDetails.vue index b6d13954..de997d5c 100644 --- a/neode-ui/src/views/MarketplaceAppDetails.vue +++ b/neode-ui/src/views/MarketplaceAppDetails.vue @@ -60,8 +60,19 @@ {{ t('marketplaceDetails.open') }} + + + + + + +

{{ versionError }}

+ + +

{{ t('appDetails.services') }}

@@ -188,9 +249,10 @@ diff --git a/releases/app-catalog.json b/releases/app-catalog.json index d72b92af..f5347a69 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -431,7 +431,40 @@ "pruning_support": true } } - } + }, + "versions": [ + { + "version": "31.0", + "image": "146.59.87.168:3000/lfg2025/bitcoin:31.0" + }, + { + "version": "30.2", + "image": "146.59.87.168:3000/lfg2025/bitcoin:30.2" + }, + { + "version": "29.3", + "image": "146.59.87.168:3000/lfg2025/bitcoin:29.3" + }, + { + "version": "28.4.0", + "image": "146.59.87.168:3000/lfg2025/bitcoin:28.4", + "default": true + }, + { + "version": "27.2", + "image": "146.59.87.168:3000/lfg2025/bitcoin:27.2" + }, + { + "version": "26.2", + "image": "146.59.87.168:3000/lfg2025/bitcoin:26.2", + "deprecated": true + }, + { + "version": "25.2", + "image": "146.59.87.168:3000/lfg2025/bitcoin:25.2", + "deprecated": true + } + ] }, "bitcoin-knots": { "version": "latest", @@ -532,7 +565,14 @@ "pruning_support": true } } - } + }, + "versions": [ + { + "version": "29.3.knots20260508", + "image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508", + "default": true + } + ] }, "bitcoin-ui": { "version": "1.7.84-alpha", diff --git a/scripts/build-bitcoin-image.sh b/scripts/build-bitcoin-image.sh new file mode 100755 index 00000000..0a2d1a8b --- /dev/null +++ b/scripts/build-bitcoin-image.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# build-bitcoin-image.sh — reproducible, verified, rootless Bitcoin image builder +# (docs/bitcoin-multi-version-design.md §3 Phase 0). +# +# Downloads an OFFICIAL upstream release tarball + SHA256SUMS(.asc), verifies the +# SHA-256 AND the OpenPGP signature (fail-closed), then builds a minimal rootless +# image and tags/pushes it to our registry as :. Nodes only ever pull +# from our registry — they never fetch bitcoincore.org / bitcoinknots.org. The +# DHT Phase-0 catalog signature then carries provenance to the fleet. +# +# Usage: +# scripts/build-bitcoin-image.sh core 31.0 +# scripts/build-bitcoin-image.sh knots 29.3.knots20260508 +# NO_PUSH=1 scripts/build-bitcoin-image.sh core 31.0 # build + verify only +# +# Env: +# NO_PUSH=1 build + verify, do not push +# ALLOW_UNSIGNED=1 skip the GPG signature check (NOT for production) +# REQUIRE_PINNED=1 additionally require a signature from a pinned release key +# ARCHY_REGISTRY overrides the push registry (default from image-versions.sh) +set -euo pipefail + +IMPL="${1:?usage: build-bitcoin-image.sh }" +VERSION="${2:?usage: build-bitcoin-image.sh }" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# shellcheck disable=SC1091 +source "$ROOT/scripts/image-versions.sh" +REGISTRY="${ARCHY_REGISTRY:?ARCHY_REGISTRY unset}" + +# Pinned upstream release-signing fingerprints (REQUIRE_PINNED=1 enforces these). +# Bitcoin Core SHA256SUMS for 25.x–31.x are signed by these maintainers; Knots by +# Luke Dashjr. Verified against the live signatures at build time. +# SHA256SUMS is a MULTI-signature file (every Guix builder signs it). We require +# a valid signature from at least one of these well-known release maintainers — +# the ones who sign every Bitcoin Core / Knots SHA256SUMS — and ignore builder +# sigs whose keys we don't hold. Both the primary fpr and the signing-subkey fpr +# that may appear in VALIDSIG are listed. +CORE_SIGNERS=( + "0CCBAAFD76A2ECE2CCD3141DE2FFD5B1D88CA97D" # fanquake (primary) + "E777299FC265DD04793070EB944D35F9AC3DB76A" # fanquake (subkey) + "152812300785C96444D3334D17565732E08E5E41" # achow101 + "71A3B16735405025D447E8F274810B012346C9A6" # laanwj (older releases) +) +KNOTS_SIGNERS=( + "1A3E761F19D2CC7785C5502EA291A2C45D0C504A" # Luke Dashjr +) + +case "$IMPL" in + core) + TARBALL="bitcoin-${VERSION}-x86_64-linux-gnu.tar.gz" + BASEURL="https://bitcoincore.org/bin/bitcoin-core-${VERSION}" + IMAGE_REPO="bitcoin" + SIGNERS=("${CORE_SIGNERS[@]}") + ;; + knots) + MAJOR="${VERSION%%.*}" + TARBALL="bitcoin-${VERSION}-x86_64-linux-gnu.tar.gz" + BASEURL="https://bitcoinknots.org/files/${MAJOR}.x/${VERSION}" + IMAGE_REPO="bitcoin-knots" + SIGNERS=("${KNOTS_SIGNERS[@]}") + ;; + *) echo "impl must be 'core' or 'knots'" >&2; exit 2 ;; +esac + +TAG="${REGISTRY}/${IMAGE_REPO}:${VERSION}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" +# podman/skopeo stage image copies under TMPDIR (default /var/tmp). Point it at a +# writable dir so `podman push` works in sandboxes where /var/tmp is read-only. +export TMPDIR="$WORK/tmp"; mkdir -p "$TMPDIR" + +echo "==> [$IMPL $VERSION] downloading from $BASEURL" +curl -fsSL -o "$TARBALL" "${BASEURL}/${TARBALL}" +curl -fsSL -o SHA256SUMS "${BASEURL}/SHA256SUMS" +curl -fsSL -o SHA256SUMS.asc "${BASEURL}/SHA256SUMS.asc" + +echo "==> verifying SHA-256" +# SHA256SUMS lists every platform; check only our tarball line. Fail-closed. +grep " ${TARBALL}\$" SHA256SUMS | sha256sum -c - \ + || { echo "FATAL: SHA-256 mismatch for ${TARBALL}" >&2; exit 1; } + +if [[ "${ALLOW_UNSIGNED:-0}" == "1" ]]; then + echo "==> WARNING: ALLOW_UNSIGNED=1 — skipping GPG verification (NOT production)" +else + echo "==> verifying OpenPGP signature on SHA256SUMS" + # A persistent, pre-seeded keyring (BITCOIN_KEYRING_DIR) makes verification + # reliable across many builds — keyserver fetches are flaky when each build + # starts from an empty keyring. Falls back to a per-build keyring + fetch. + if [[ -n "${BITCOIN_KEYRING_DIR:-}" ]]; then + export GNUPGHOME="$BITCOIN_KEYRING_DIR" + else + export GNUPGHOME="$WORK/gnupg" + fi + mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" + # Ensure each pinned maintainer key is present (best-effort fetch). + for kid in "${SIGNERS[@]}"; do + gpg --list-keys "$kid" >/dev/null 2>&1 && continue + for ks in hkps://keys.openpgp.org hkps://keyserver.ubuntu.com hkp://keyserver.ubuntu.com; do + gpg --keyserver "$ks" --recv-keys "$kid" >/dev/null 2>&1 && break || true + done + done + # SHA256SUMS carries many builder signatures; `gpg --verify`'s exit code is + # unreliable for multi-sig files (one unheld key flips it). Instead collect the + # VALIDSIG fingerprints via --status-fd and REQUIRE at least one from a pinned + # maintainer. Fail-closed otherwise. + # `|| true`: gpg exits non-zero on multi-sig files even with good sigs; we + # judge trust from VALIDSIG below, not the exit code (and set -e/pipefail would + # otherwise abort here). + VALID_FPRS="$(gpg --status-fd=1 --verify SHA256SUMS.asc SHA256SUMS 2>/dev/null \ + | awk '/^\[GNUPG:\] VALIDSIG/ {print $3; print $NF}' | sort -u || true)" + ok=0; matched="" + for fpr in $VALID_FPRS; do + for want in "${SIGNERS[@]}"; do + [[ "$fpr" == "$want" ]] && { ok=1; matched="$fpr"; } + done + done + if [[ "$ok" != "1" ]]; then + echo "FATAL: no valid signature from a pinned release maintainer on SHA256SUMS" >&2 + echo " valid signers seen: ${VALID_FPRS:-none}" >&2 + exit 1 + fi + echo " verified: valid maintainer signature ($matched)" +fi + +echo "==> extracting binaries" +tar -xzf "$TARBALL" +SRC="bitcoin-${VERSION}" +[[ -x "${SRC}/bin/bitcoind" ]] || { echo "FATAL: bitcoind missing in tarball" >&2; exit 1; } +mkdir -p ctx/bin +cp "${SRC}/bin/bitcoind" "${SRC}/bin/bitcoin-cli" ctx/bin/ + +echo "==> building rootless image $TAG" +cat > ctx/Containerfile <<'EOF' +FROM debian:bookworm-slim +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/*; \ + useradd -m -u 1000 -s /bin/bash bitcoin; \ + mkdir -p /home/bitcoin/.bitcoin; \ + chown -R bitcoin:bitcoin /home/bitcoin +COPY bin/bitcoind /usr/local/bin/bitcoind +COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli +RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli +USER bitcoin +WORKDIR /home/bitcoin +VOLUME ["/home/bitcoin/.bitcoin"] +EXPOSE 8332 8333 +ENTRYPOINT ["bitcoind"] +EOF +podman build -t "$TAG" ctx + +echo "==> smoke test (bitcoind --version)" +podman run --rm --entrypoint bitcoind "$TAG" --version | head -1 + +if [[ "${NO_PUSH:-0}" == "1" ]]; then + echo "==> NO_PUSH=1 — built + verified $TAG (not pushed)" +else + echo "==> pushing $TAG" + # The lfg2025 registry serves plain HTTP (matches image_uses_insecure_registry + # in the Rust runtime). PODMAN_PUSH_TLS_VERIFY=true forces TLS for HTTPS regs. + podman push --tls-verify="${PODMAN_PUSH_TLS_VERIFY:-false}" "$TAG" + echo "==> pushed $TAG" +fi diff --git a/scripts/generate-app-catalog.sh b/scripts/generate-app-catalog.sh index fa3cf181..ea8967e1 100755 --- a/scripts/generate-app-catalog.sh +++ b/scripts/generate-app-catalog.sh @@ -162,6 +162,46 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir: entry["manifest"] = data embedded += 1 +# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1): +# curated, bounded `versions[]` a runner may install or switch to. The entry +# marked default:true MUST equal the app's top-level catalog `version` (the +# manifest version for embedded apps) so selecting it un-pins / tracks latest. +# +# ONLY list versions whose tagged image is actually published to the registry — +# an unbuilt tag 404s on install. Extend each list as scripts/build-bitcoin- +# image.sh (Phase 0) publishes more tagged images, e.g.: +# {"version": "30.0", "image": f"{REGISTRY}/bitcoin:30.0"}, +# {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2", "deprecated": True, "eol": "2026-12-31"}, +REGISTRY = os.environ.get("ARCHY_REGISTRY", "146.59.87.168:3000/lfg2025") +VERSIONS = { + # Curated Core set (latest patch per major, current → 25). Images built + + # verified (SHA-256 + OpenPGP, fail-closed) and pushed by + # scripts/build-bitcoin-image.sh. `28.4.0` is the default (== the manifest's + # top-level version) so existing/new installs are undisturbed; runners switch + # up to 31.0 (e.g. for BIP-110 signalling) or down to 25.2 from the app's + # "Version & Updates" card. Add the next release by building its image then + # prepending it here. + "bitcoin-core": [ + {"version": "31.0", "image": f"{REGISTRY}/bitcoin:31.0"}, + {"version": "30.2", "image": f"{REGISTRY}/bitcoin:30.2"}, + {"version": "29.3", "image": f"{REGISTRY}/bitcoin:29.3"}, + {"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4", "default": True}, + {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2"}, + {"version": "26.2", "image": f"{REGISTRY}/bitcoin:26.2", "deprecated": True}, + {"version": "25.2", "image": f"{REGISTRY}/bitcoin:25.2", "deprecated": True}, + ], + # Knots: a real tagged build is now published, so it's selectable + pinnable + # in the Knots app interface (the floating :latest remains the manifest + # default; pinning here moves a runner off the floating tag). + "bitcoin-knots": [ + {"version": "29.3.knots20260508", + "image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508", "default": True}, + ], +} +for app_id, versions in VERSIONS.items(): + if app_id in apps and versions: + apps[app_id]["versions"] = versions + catalog = { "schema": 1, "updated": os.environ["UPDATED"],