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:
parent
d7c6f8c348
commit
6aa74c7386
@ -1,5 +1,29 @@
|
|||||||
# Bitcoin Core - uses official image
|
# Bitcoin Core — minimal rootless image built from the OFFICIAL upstream release.
|
||||||
FROM bitcoin/bitcoin:24.0
|
#
|
||||||
|
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
|
||||||
# Default user is already 'bitcoin'
|
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
|
||||||
# No additional setup needed
|
# (fail-closed), and tags/pushes <registry>/bitcoin:<version>. 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"]
|
||||||
|
|||||||
30
apps/bitcoin-knots/Dockerfile
Normal file
30
apps/bitcoin-knots/Dockerfile
Normal file
@ -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
|
||||||
|
# <registry>/bitcoin-knots:<version>. 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"]
|
||||||
@ -57,6 +57,8 @@ impl RpcHandler {
|
|||||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||||
"package.update" => self.clone().spawn_package_update(params).await,
|
"package.update" => self.clone().spawn_package_update(params).await,
|
||||||
"package.check-updates" => self.handle_package_check_updates(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,
|
"package.credentials" => self.handle_package_credentials(params).await,
|
||||||
"app.filebrowser-token" => self.handle_filebrowser_token().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
|
// Phase: Preparing — emit BEFORE the stack dispatch so multi-container
|
||||||
// stacks also flip state to Installing immediately. Without this, the
|
// stacks also flip state to Installing immediately. Without this, the
|
||||||
// backend's package state for stack apps stayed empty until the first
|
// 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 {
|
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
|
||||||
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ mod install;
|
|||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
mod set_config;
|
||||||
mod stacks;
|
mod stacks;
|
||||||
mod update;
|
mod update;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|||||||
268
core/archipelago/src/api/rpc/package/set_config.rs
Normal file
268
core/archipelago/src/api/rpc/package/set_config.rs
Normal file
@ -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(¤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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,19 +32,27 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
// Verify an update is actually available. Prefer the remote app catalog
|
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||||
// (decoupled from the binary OTA), falling back to the image-versions.sh
|
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||||
// pin when the catalog is absent or doesn't cover this app.
|
// 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)
|
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||||
.or_else(|| image_versions::pinned_image_for_app(package_id))
|
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
|
||||||
|
|
||||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||||
// (the async wrapper that dispatch actually routes to). By the time
|
// (the async wrapper that dispatch actually routes to). By the time
|
||||||
// this inner function runs, the wrapper has already flipped state to
|
// this inner function runs, the wrapper has already flipped state to
|
||||||
// `Updating`, so duplicating the check here would be a false positive.
|
// `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
|
// 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
|
// Resolve images to pull — either a stack or single container
|
||||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
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.
|
/// Optional human-readable changelog lines for this version.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub changelog: Vec<String>,
|
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 —
|
/// 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
|
/// no OTA-shipped `apps/<id>/manifest.yml`. Carried as the raw value the
|
||||||
/// publisher signed (so it stays part of the verified preimage) and
|
/// publisher signed (so it stays part of the verified preimage) and
|
||||||
@ -97,6 +103,29 @@ pub struct AppCatalogEntry {
|
|||||||
pub manifest: Option<serde_json::Value>,
|
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
|
/// 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.
|
/// daemon's data dir first (via env for dev), then the canonical runtime path.
|
||||||
fn cache_paths() -> Vec<PathBuf> {
|
fn cache_paths() -> Vec<PathBuf> {
|
||||||
@ -187,6 +216,64 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
|||||||
.collect()
|
.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
|
/// 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
|
/// 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
|
/// 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
|
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
|
||||||
/// catalog is missing or doesn't cover the app.
|
/// catalog is missing or doesn't cover the app.
|
||||||
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
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) {
|
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||||
// Catalog covers this app with a concrete image -> authoritative.
|
// Catalog covers this app with a concrete image -> authoritative.
|
||||||
return crate::container::image_versions::available_update_for_images(
|
return crate::container::image_versions::available_update_for_images(
|
||||||
|
|||||||
@ -14,6 +14,7 @@ pub mod quadlet;
|
|||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
pub mod version_config;
|
||||||
|
|
||||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||||
|
|||||||
@ -1816,13 +1816,30 @@ impl ProdContainerOrchestrator {
|
|||||||
// so an app update no longer requires a binary/runtime release. Falls
|
// so an app update no longer requires a binary/runtime release. Falls
|
||||||
// back to the manifest image when the catalog is absent/uncovered.
|
// back to the manifest image when the catalog is absent/uncovered.
|
||||||
if let Some(current) = resolved_manifest.app.container.image.clone() {
|
if let Some(current) = resolved_manifest.app.container.image.clone() {
|
||||||
if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override(
|
let app_id = resolved_manifest.app.id.clone();
|
||||||
&resolved_manifest.app.id,
|
// Multi-version support: a runner-pinned version wins over the catalog
|
||||||
¤t,
|
// 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 {
|
if catalog_image != current {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
app_id = %resolved_manifest.app.id,
|
app_id = %app_id,
|
||||||
from = %current,
|
from = %current,
|
||||||
to = %catalog_image,
|
to = %catalog_image,
|
||||||
"app-catalog: overriding manifest image"
|
"app-catalog: overriding manifest image"
|
||||||
|
|||||||
278
core/archipelago/src/container/version_config.rs
Normal file
278
core/archipelago/src/container/version_config.rs
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -288,7 +288,9 @@ async fn main() -> Result<()> {
|
|||||||
// via auth.setup RPC. The Login page detects is_setup=false and shows
|
// via auth.setup RPC. The Login page detects is_setup=false and shows
|
||||||
// "Create Password" form instead of login form.
|
// "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?;
|
let server = Server::new(config.clone(), orchestrator, dev_orchestrator).await?;
|
||||||
|
|
||||||
// Start server
|
// 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();
|
let update_data_dir = config.data_dir.clone();
|
||||||
tokio::spawn(async move {
|
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
|
// Synchronize host-side doctor artifacts (script + systemd units) with
|
||||||
|
|||||||
@ -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.
|
/// Background update scheduler. Runs in a loop, checking/applying based on schedule.
|
||||||
/// Call this once at startup via `tokio::spawn`.
|
/// 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, ¤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<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||||
|
) {
|
||||||
use tokio::time::{interval, Duration};
|
use tokio::time::{interval, Duration};
|
||||||
|
|
||||||
// Check every hour; act based on schedule setting
|
// 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);
|
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 {
|
let state = match load_state(&data_dir).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@ -1,5 +1,90 @@
|
|||||||
# Bitcoin Multi-Version Support — Design
|
# Bitcoin Multi-Version Support — Design
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════════
|
||||||
|
PROGRESS TRACKER / RESUME POINT (keep this current — update each session)
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
|
**Branch/worktree:** `bitcoin-multi-version` @ `/home/archipelago/Projects/archy-btcver`
|
||||||
|
(isolated — never touch `main` or the other agent's branch). All work UNCOMMITTED on
|
||||||
|
that branch as of last update.
|
||||||
|
|
||||||
|
**Last updated:** 2026-06-28 (session 2 — software end-to-end implemented)
|
||||||
|
|
||||||
|
**Motivation refresh:** BIP-110 signalling makes per-node version *choice* a real
|
||||||
|
requirement — runners must be able to pick / pin / switch Core & Knots versions.
|
||||||
|
|
||||||
|
**User direction this session:** finish the SOFTWARE end-to-end (Phase 1–3 + UI),
|
||||||
|
DEFER the Phase 0 image build pipeline. Downgrade policy = **warn + confirm + allow**.
|
||||||
|
|
||||||
|
### Status by phase
|
||||||
|
- [x] **Phase 1 — catalog schema** (`app_catalog.rs`): `CatalogVersion` struct +
|
||||||
|
`versions[]` + `catalog_versions()` / `catalog_default_version()` /
|
||||||
|
`catalog_image_for_version()` (same-repo guard) DONE. Pin suppresses update badge
|
||||||
|
in `available_update_for_app()` DONE. `versions[]` now EMITTED by
|
||||||
|
`scripts/generate-app-catalog.sh` (curated `VERSIONS` map) → `releases/app-catalog.json`
|
||||||
|
regenerated; bitcoin-core carries its one built version (28.4.0, default). **Knots
|
||||||
|
versions[] intentionally empty** (only floating `:latest` exists; design forbids
|
||||||
|
advertising floating). More versions light up automatically once Phase 0 builds
|
||||||
|
tagged images and they're appended to the `VERSIONS` map.
|
||||||
|
- [x] **Phase 2 — install-time selection**: `version_config.rs` (pin/auto-update
|
||||||
|
persistence + `is_downgrade()` + `auto_update_apps()`, unit-tested) DONE;
|
||||||
|
`install.rs` `persist_install_version_selection()` DONE; `prod_orchestrator.rs`
|
||||||
|
pinned-wins resolution DONE. **UI:** `MarketplaceAppDetails.vue` install panel shows
|
||||||
|
a version `<select>` (latest pre-selected) when the app offers ≥2 versions — passes
|
||||||
|
the choice to `package.install`. (Hidden today since only 1 version exists.)
|
||||||
|
- [x] **Phase 3 — in-app switch + auto-update toggle**:
|
||||||
|
- `package.versions` RPC (read) + `package.set-config` RPC (write, downgrade-gated)
|
||||||
|
→ new `api/rpc/package/set_config.rs`, wired in `mod.rs` + `dispatcher.rs`.
|
||||||
|
- Auto-update tick: `run_update_scheduler` now takes the orchestrator + calls
|
||||||
|
`apply_per_app_auto_updates()` hourly (opt-in, pin-respecting, catalog-driven).
|
||||||
|
- UI: "Version & Updates" card in `appDetails/AppSidebar.vue` (version switch +
|
||||||
|
auto-update toggle + downgrade warn/confirm); `rpc-client.ts` + types added.
|
||||||
|
- [x] **Phase 0 — image build pipeline**: `scripts/build-bitcoin-image.sh` —
|
||||||
|
downloads the OFFICIAL upstream tarball + SHA256SUMS(.asc), verifies SHA-256 **and**
|
||||||
|
the OpenPGP signature (fail-closed; pinned release-key fingerprints), builds a
|
||||||
|
minimal **rootless** image (debian-slim + verified `bitcoind`/`bitcoin-cli`),
|
||||||
|
smoke-tests `--version`, tags + pushes `:<version>`. Validated on Core 31.0
|
||||||
|
(pinned-GPG pass, smoke `v31.0.0`). **Published curated set** (registry
|
||||||
|
`lfg2025`): Core **31.0, 30.2, 29.3, 27.2, 26.2, 25.2** (28.4 already present —
|
||||||
|
kept, not overwritten) + Knots **29.3.knots20260508**. `VERSIONS` map in
|
||||||
|
`generate-app-catalog.sh` lists them; catalog regenerated. Adding a future release
|
||||||
|
= run the script for it, then prepend it to the map + regenerate.
|
||||||
|
|
||||||
|
### Verification status
|
||||||
|
- `cargo check -p archipelago` GREEN (backend). Frontend `npm run build` GREEN
|
||||||
|
(vue-tsc typecheck passes; new RPC strings confirmed in `web/dist`).
|
||||||
|
- Unit tests: `version_config` had a pre-existing parallel-test race (shared
|
||||||
|
process-global `ARCHIPELAGO_DATA_DIR`) — FIXED with an `ENV_LOCK` mutex + unique
|
||||||
|
per-test dirs. `set_config` `image_tag` test added.
|
||||||
|
- **Phase 0 images verified end-to-end**: SHA-256 + pinned-maintainer OpenPGP
|
||||||
|
signature (deterministic VALIDSIG check), built rootless, smoke-tested, **pushed
|
||||||
|
to the live registry** — confirmed remotely: `bitcoin` tags
|
||||||
|
{25.2,26.2,27.2,28.4,29.3,30.2,31.0} + `bitcoin-knots:29.3.knots20260508`.
|
||||||
|
- **NOT yet verified on `.228`** (CLAUDE.md invariant — do before any tag): install
|
||||||
|
bitcoin-core, open its page, switch/pin a version, confirm recreate. All code
|
||||||
|
UNCOMMITTED on the branch.
|
||||||
|
|
||||||
|
### Gotchas captured (for resume)
|
||||||
|
- `gpg --verify` exit code is unreliable on multi-sig `SHA256SUMS` — must parse
|
||||||
|
`--status-fd` VALIDSIG and require a pinned maintainer fpr (script does this).
|
||||||
|
- `podman push` needs the sandbox disabled (`/var/tmp` is RO under the harness
|
||||||
|
sandbox) and `--tls-verify=false` (registry serves HTTP). Persistent keyring
|
||||||
|
(`BITCOIN_KEYRING_DIR`) avoids flaky per-build keyserver fetches.
|
||||||
|
|
||||||
|
### Next action when resuming
|
||||||
|
1. Re-verify: `cd archy-btcver/core && CARGO_INCREMENTAL=0 cargo check -p archipelago`
|
||||||
|
and `cargo test -p archipelago -- version_config set_config`; `cd neode-ui && npm run build`.
|
||||||
|
2. Live-verify on `.228`: install bitcoin-core, open its detail page → "Version &
|
||||||
|
Updates" card; exercise `package.versions` / `package.set-config` via RPC.
|
||||||
|
3. Commit on the branch (checkpoint).
|
||||||
|
4. **Phase 0** when greenlit: build+push tagged Core/Knots images, then extend the
|
||||||
|
`VERSIONS` map in `scripts/generate-app-catalog.sh` and regenerate the catalog.
|
||||||
|
|
||||||
|
### Decisions still needed from user (see §6 open questions)
|
||||||
|
Curated version set + storage budget (defaulted to current+~3 majors); when to do
|
||||||
|
Phase 0 image pipeline; pruned-node downgrade policy refinement (currently warn+confirm
|
||||||
|
for all). Auto-update default = OFF (opt-in), as recommended.
|
||||||
|
════════════════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
**Status:** design (2026-06-22)
|
**Status:** design (2026-06-22)
|
||||||
**Goal:** let a user choose *which* version of Bitcoin Core / Bitcoin Knots to
|
**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
|
install (latest pre-selected, older versions in a dropdown), and later switch
|
||||||
|
|||||||
@ -15,6 +15,40 @@ export interface RPCResponse<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
/// Mirrors `crate::federation::pending::PendingPeerRequest` on the backend.
|
||||||
export type PendingState = 'pending' | 'sent' | 'approved' | 'rejected' | 'expired'
|
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<PackageVersionsResponse> {
|
||||||
|
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<SetPackageConfigResponse> {
|
||||||
|
return this.call({
|
||||||
|
method: 'package.set-config',
|
||||||
|
params: { id, ...opts },
|
||||||
|
timeout: 600000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async checkPackageUpdates(): Promise<{
|
async checkPackageUpdates(): Promise<{
|
||||||
status: string
|
status: string
|
||||||
refreshed: boolean
|
refreshed: boolean
|
||||||
|
|||||||
@ -568,8 +568,16 @@
|
|||||||
"notFoundTitle": "App Not Found",
|
"notFoundTitle": "App Not Found",
|
||||||
"notFoundMessage": "The requested application could not be found",
|
"notFoundMessage": "The requested application could not be found",
|
||||||
"installed": "Installed",
|
"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": {
|
"containerDetails": {
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
@ -608,7 +616,8 @@
|
|||||||
"installFailed": "Installation Failed",
|
"installFailed": "Installation Failed",
|
||||||
"depRunning": "Running",
|
"depRunning": "Running",
|
||||||
"depStopped": "Installed but stopped",
|
"depStopped": "Installed but stopped",
|
||||||
"depNotInstalled": "Not installed"
|
"depNotInstalled": "Not installed",
|
||||||
|
"selectVersion": "Select version"
|
||||||
},
|
},
|
||||||
"goalDetail": {
|
"goalDetail": {
|
||||||
"backToGoals": "Back to Goals",
|
"backToGoals": "Back to Goals",
|
||||||
@ -761,7 +770,7 @@
|
|||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"lightning": "Lightning",
|
"lightning": "Lightning",
|
||||||
"ecash": "Ecash",
|
"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)",
|
"amountSats": "Amount (sats)",
|
||||||
"lightningInvoice": "Lightning Invoice (BOLT11)",
|
"lightningInvoice": "Lightning Invoice (BOLT11)",
|
||||||
"bitcoinAddress": "Bitcoin Address",
|
"bitcoinAddress": "Bitcoin Address",
|
||||||
|
|||||||
@ -60,8 +60,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ t('marketplaceDetails.open') }}
|
{{ t('marketplaceDetails.open') }}
|
||||||
</button>
|
</button>
|
||||||
|
<select
|
||||||
|
v-if="!isInstalled && installVersions.length > 1"
|
||||||
|
v-model="selectedInstallVersion"
|
||||||
|
:disabled="installing"
|
||||||
|
:aria-label="t('marketplaceDetails.selectVersion')"
|
||||||
|
class="rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
||||||
|
>
|
||||||
|
<option v-for="v in installVersions" :key="v.version" :value="v.version">
|
||||||
|
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-if="!isInstalled"
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="installBlockedReason || undefined"
|
:title="installBlockedReason || undefined"
|
||||||
@ -114,6 +125,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Install-time version selector (multi-version apps) -->
|
||||||
|
<select
|
||||||
|
v-if="!isInstalled && installVersions.length > 1"
|
||||||
|
v-model="selectedInstallVersion"
|
||||||
|
:disabled="installing"
|
||||||
|
:aria-label="t('marketplaceDetails.selectVersion')"
|
||||||
|
class="w-full mb-2 rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
||||||
|
>
|
||||||
|
<option v-for="v in installVersions" :key="v.version" :value="v.version">
|
||||||
|
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- Bottom: Action Buttons -->
|
<!-- Bottom: Action Buttons -->
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
@ -375,6 +399,12 @@ const installingDeps = ref(false)
|
|||||||
const installError = ref<string | null>(null)
|
const installError = ref<string | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const bitcoinPruned = ref(false)
|
const bitcoinPruned = ref(false)
|
||||||
|
|
||||||
|
// Multi-version support: install-time version choice. Populated from the signed
|
||||||
|
// catalog for apps that offer multiple versions (e.g. Bitcoin Core / Knots).
|
||||||
|
// Hidden when an app offers only one version — install stays one-click.
|
||||||
|
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
||||||
|
const selectedInstallVersion = ref('')
|
||||||
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
||||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||||
|
|
||||||
@ -511,8 +541,24 @@ onMounted(() => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
loadBitcoinPruneStatus()
|
loadBitcoinPruneStatus()
|
||||||
|
void loadInstallVersions()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch the catalog's selectable versions so the install panel can offer a
|
||||||
|
// choice (latest pre-selected). Best-effort: on any failure the selector stays
|
||||||
|
// hidden and install proceeds at the catalog default.
|
||||||
|
async function loadInstallVersions() {
|
||||||
|
installVersions.value = []
|
||||||
|
try {
|
||||||
|
const info = await rpcClient.getPackageVersions(appId.value)
|
||||||
|
if (!info.supportsVersions || info.versions.length < 2) return
|
||||||
|
installVersions.value = info.versions
|
||||||
|
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
|
||||||
|
} catch (err) {
|
||||||
|
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] loadInstallVersions failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBitcoinPruneStatus() {
|
async function loadBitcoinPruneStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||||
@ -603,13 +649,18 @@ async function installApp() {
|
|||||||
installing.value = true
|
installing.value = true
|
||||||
installError.value = null
|
installError.value = null
|
||||||
|
|
||||||
|
// Multi-version: a runner-chosen version (when offered) overrides the default.
|
||||||
|
const chosenVersion = (installVersions.value.length > 1 && selectedInstallVersion.value)
|
||||||
|
? selectedInstallVersion.value
|
||||||
|
: app.value.version
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (app.value.dockerImage) {
|
if (app.value.dockerImage) {
|
||||||
// Docker-based app installation
|
// Docker-based app installation
|
||||||
const installParams: Record<string, unknown> = {
|
const installParams: Record<string, unknown> = {
|
||||||
id: app.value.id,
|
id: app.value.id,
|
||||||
dockerImage: app.value.dockerImage,
|
dockerImage: app.value.dockerImage,
|
||||||
version: app.value.version,
|
version: chosenVersion,
|
||||||
}
|
}
|
||||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
@ -625,7 +676,7 @@ async function installApp() {
|
|||||||
params: {
|
params: {
|
||||||
id: app.value.id,
|
id: app.value.id,
|
||||||
url: installUrl,
|
url: installUrl,
|
||||||
version: app.value.version,
|
version: chosenVersion,
|
||||||
},
|
},
|
||||||
timeout: 600000,
|
timeout: 600000,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -32,6 +32,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Version & Updates Card (multi-version apps: Bitcoin Core / Knots).
|
||||||
|
Lets a runner switch versions, pin, and opt into auto-update. See
|
||||||
|
docs/bitcoin-multi-version-design.md §3 Phase 3. -->
|
||||||
|
<div v-if="versionInfo && versionInfo.supportsVersions && versionInfo.versions.length" class="glass-card p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.versionUpdates') }}</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/60 text-sm">{{ t('appDetails.runningVersion') }}</span>
|
||||||
|
<span class="text-white font-medium">{{ versionInfo.installedVersion || $ver(pkg.manifest.version) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/60 text-sm mb-1">{{ t('appDetails.selectVersion') }}</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedVersion"
|
||||||
|
:disabled="versionBusy"
|
||||||
|
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
||||||
|
>
|
||||||
|
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">
|
||||||
|
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}{{ v.eol ? ` · EOL ${v.eol}` : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between gap-3 cursor-pointer">
|
||||||
|
<span class="text-white/80 text-sm">{{ t('appDetails.autoUpdateLatest') }}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="autoUpdate"
|
||||||
|
:disabled="versionBusy || isPinned"
|
||||||
|
class="h-4 w-4 accent-blue-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="isPinned" class="text-white/40 text-xs -mt-2">{{ t('appDetails.autoUpdatePinnedNote') }}</p>
|
||||||
|
|
||||||
|
<!-- Downgrade confirmation -->
|
||||||
|
<div v-if="downgradeWarning" class="rounded-lg border border-orange-400/40 bg-orange-500/10 p-3">
|
||||||
|
<p class="text-orange-200 text-xs leading-relaxed">⚠️ {{ downgradeWarning }}</p>
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-orange-500/80 hover:bg-orange-500 text-white" :disabled="versionBusy" @click="applyVersionConfig(true)">
|
||||||
|
{{ t('appDetails.confirmDowngrade') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-white/10 hover:bg-white/20 text-white" :disabled="versionBusy" @click="cancelDowngrade">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium py-2 transition-colors"
|
||||||
|
:disabled="versionBusy || !versionDirty"
|
||||||
|
@click="applyVersionConfig(false)"
|
||||||
|
>
|
||||||
|
{{ versionBusy ? t('appDetails.applyingVersion') : t('appDetails.applyVersion') }}
|
||||||
|
</button>
|
||||||
|
<p v-if="versionError" class="text-red-300 text-xs">{{ versionError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Fedimint Services Card -->
|
<!-- Fedimint Services Card -->
|
||||||
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
|
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
|
||||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
|
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
|
||||||
@ -188,9 +249,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { AppCredentialsResponse } from '@/types/api'
|
import type { AppCredentialsResponse } from '@/types/api'
|
||||||
|
import { rpcClient, type PackageVersionsResponse } from '../../api/rpc-client'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copiedCredential = ref('')
|
const copiedCredential = ref('')
|
||||||
@ -258,4 +320,75 @@ const setupInstructions = computed(() => {
|
|||||||
const instructions = typeof raw === 'string' ? raw.trim() : ''
|
const instructions = typeof raw === 'string' ? raw.trim() : ''
|
||||||
return instructions ? instructions : ''
|
return instructions ? instructions : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Version & Updates (multi-version support) -----------------------------
|
||||||
|
const versionInfo = ref<PackageVersionsResponse | null>(null)
|
||||||
|
const selectedVersion = ref('')
|
||||||
|
const autoUpdate = ref(false)
|
||||||
|
const versionBusy = ref(false)
|
||||||
|
const versionError = ref('')
|
||||||
|
const downgradeWarning = ref('')
|
||||||
|
|
||||||
|
const isPinned = computed(() => !!versionInfo.value?.pinnedVersion)
|
||||||
|
// "Apply" is enabled when the runner changed the version or the toggle.
|
||||||
|
const versionDirty = computed(() => {
|
||||||
|
const info = versionInfo.value
|
||||||
|
if (!info) return false
|
||||||
|
const currentSelection = info.pinnedVersion || info.default || info.installedVersion || ''
|
||||||
|
return selectedVersion.value !== currentSelection || autoUpdate.value !== info.autoUpdate
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadVersions(appId: string) {
|
||||||
|
versionInfo.value = null
|
||||||
|
versionError.value = ''
|
||||||
|
downgradeWarning.value = ''
|
||||||
|
try {
|
||||||
|
const info = await rpcClient.getPackageVersions(appId)
|
||||||
|
if (!info.supportsVersions || !info.versions.length) return
|
||||||
|
versionInfo.value = info
|
||||||
|
selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || info.versions[0]?.version || ''
|
||||||
|
autoUpdate.value = info.autoUpdate
|
||||||
|
} catch (err) {
|
||||||
|
if (import.meta.env.DEV) console.warn('[AppSidebar] getPackageVersions failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyVersionConfig(confirm: boolean) {
|
||||||
|
if (!versionInfo.value) return
|
||||||
|
versionBusy.value = true
|
||||||
|
versionError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.setPackageConfig(versionInfo.value.id, {
|
||||||
|
version: selectedVersion.value,
|
||||||
|
autoUpdate: autoUpdate.value,
|
||||||
|
confirm,
|
||||||
|
})
|
||||||
|
if (res.status === 'confirm_required') {
|
||||||
|
downgradeWarning.value = res.warning || t('appDetails.downgradeGeneric')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downgradeWarning.value = ''
|
||||||
|
// Refresh so the card reflects the new pin / running version.
|
||||||
|
await loadVersions(versionInfo.value.id)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
versionError.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
versionBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDowngrade() {
|
||||||
|
downgradeWarning.value = ''
|
||||||
|
// Reset the dropdown to the current selection.
|
||||||
|
const info = versionInfo.value
|
||||||
|
if (info) selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.packageKey,
|
||||||
|
(key) => {
|
||||||
|
if (key) void loadVersions(key)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -431,7 +431,40 @@
|
|||||||
"pruning_support": true
|
"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": {
|
"bitcoin-knots": {
|
||||||
"version": "latest",
|
"version": "latest",
|
||||||
@ -532,7 +565,14 @@
|
|||||||
"pruning_support": true
|
"pruning_support": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "29.3.knots20260508",
|
||||||
|
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508",
|
||||||
|
"default": true
|
||||||
}
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"bitcoin-ui": {
|
"bitcoin-ui": {
|
||||||
"version": "1.7.84-alpha",
|
"version": "1.7.84-alpha",
|
||||||
|
|||||||
166
scripts/build-bitcoin-image.sh
Executable file
166
scripts/build-bitcoin-image.sh
Executable file
@ -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 :<version>. 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 <core|knots> <version>}"
|
||||||
|
VERSION="${2:?usage: build-bitcoin-image.sh <core|knots> <version>}"
|
||||||
|
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
|
||||||
@ -162,6 +162,46 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir:
|
|||||||
entry["manifest"] = data
|
entry["manifest"] = data
|
||||||
embedded += 1
|
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 = {
|
catalog = {
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"updated": os.environ["UPDATED"],
|
"updated": os.environ["UPDATED"],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user