From be9f9528c3b01f26cb0f38bc9ba627d7ae594aac Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 1 May 2026 03:14:07 -0400 Subject: [PATCH] fix: release v1.7.50-alpha OTA runtime repair --- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- core/archipelago/src/bootstrap.rs | 119 ++++++++++++++++++++++++++- core/archipelago/src/update.rs | 128 +++++++++++++++++++++++++++-- neode-ui/package-lock.json | 4 +- neode-ui/package.json | 2 +- release-manifest.json | 36 ++++---- scripts/create-release-manifest.sh | 24 ++++++ scripts/self-update.sh | 13 +++ 9 files changed, 297 insertions(+), 33 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 7109daca..a6fdf67a 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.49-alpha" +version = "1.7.50-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index f3d852af..6b641a98 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.49-alpha" +version = "1.7.50-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index ffc07215..15fe5a86 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -15,7 +15,7 @@ //! best-effort — failures are logged but never abort the backend. use anyhow::{Context, Result}; -use std::path::Path; +use std::path::{Path, PathBuf}; use tokio::fs; use tracing::{debug, info, warn}; @@ -31,6 +31,7 @@ const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.servic const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer"; const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago"; +const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime"; /// Inserted into every server block of the nginx config that lacks the /// `/api/app-catalog` proxy. Kept in sync with the canonical block in @@ -40,6 +41,11 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe /// Entry point called from main startup. Never returns an error to the caller — /// failing to bootstrap host artifacts must not prevent the backend from serving. pub async fn ensure_doctor_installed() { + match run_runtime_assets().await { + Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"), + Ok(_) => debug!("No OTA runtime payload to synchronize"), + Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e), + } match run().await { Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"), Ok(_) => debug!("Doctor artifacts already in sync"), @@ -52,6 +58,117 @@ pub async fn ensure_doctor_installed() { } } +async fn run_runtime_assets() -> Result { + // The v1.7.50 OTA bridge puts scripts/apps/docker assets inside the + // frontend tarball because older binaries only know how to apply the + // backend binary and frontend archive. Once the new backend starts, it + // promotes that payload into /opt so app installs use the matching specs. + let runtime_dir = Path::new(RUNTIME_ASSETS_DIR); + if !runtime_dir.exists() { + return Ok(false); + } + + let mut changed = false; + for (relative, dest) in [ + ("apps", "/opt/archipelago/apps"), + ("scripts", "/opt/archipelago/scripts"), + ("docker", "/opt/archipelago/docker"), + ] { + let src = runtime_dir.join(relative); + if src.exists() { + replace_dir_from_runtime(&src, dest).await?; + if relative == "scripts" { + let _ = host_sudo(&[ + "find", dest, "-type", "f", "-name", "*.sh", "-exec", "chmod", "755", "{}", "+", + ]) + .await; + let image_versions = format!("{}/image-versions.sh", dest); + if Path::new(&image_versions).exists() { + let _ = + host_sudo(&["cp", &image_versions, "/opt/archipelago/image-versions.sh"]) + .await; + } + } + changed = true; + } + } + + let configs = runtime_dir.join("image-recipe/configs"); + for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] { + let src = configs.join(unit); + if src.exists() { + let src_s = src.to_string_lossy().to_string(); + let dest = format!("/etc/systemd/system/{}", unit); + let status = host_sudo(&["install", "-m", "644", &src_s, &dest]) + .await + .with_context(|| format!("install {}", unit))?; + if !status.success() { + anyhow::bail!("install {} exited with {}", unit, status); + } + changed = true; + } + } + + if changed { + let _ = host_sudo(&["systemctl", "daemon-reload"]).await; + let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await; + } + Ok(changed) +} + +async fn replace_dir_from_runtime(src: &Path, dest: &str) -> Result<()> { + let tmp = format!("{}.new.{}", dest, chrono::Utc::now().timestamp_millis()); + let src_dot = path_dot(src); + let mkdir = host_sudo(&["mkdir", "-p", &tmp]) + .await + .with_context(|| format!("mkdir {}", tmp))?; + if !mkdir.success() { + anyhow::bail!("mkdir {} exited with {}", tmp, mkdir); + } + let copy = host_sudo(&["cp", "-a", &src_dot, &tmp]) + .await + .with_context(|| format!("copy runtime {} -> {}", src.display(), tmp))?; + if !copy.success() { + let _ = host_sudo(&["rm", "-rf", &tmp]).await; + anyhow::bail!("copy runtime {} exited with {}", src.display(), copy); + } + let _ = host_sudo(&["mkdir", "-p", dest]).await; + let cleanup = host_sudo(&[ + "find", + dest, + "-mindepth", + "1", + "-maxdepth", + "1", + "-exec", + "rm", + "-rf", + "{}", + "+", + ]) + .await + .with_context(|| format!("clean {}", dest))?; + if !cleanup.success() { + let _ = host_sudo(&["rm", "-rf", &tmp]).await; + anyhow::bail!("clean {} exited with {}", dest, cleanup); + } + let tmp_dot = format!("{}/.", tmp); + let promote = host_sudo(&["cp", "-a", &tmp_dot, dest]) + .await + .with_context(|| format!("promote {} -> {}", tmp, dest))?; + let _ = host_sudo(&["rm", "-rf", &tmp]).await; + if !promote.success() { + anyhow::bail!("promote {} exited with {}", dest, promote); + } + Ok(()) +} + +fn path_dot(path: &Path) -> String { + let mut p = PathBuf::from(path); + p.push("."); + p.to_string_lossy().to_string() +} + async fn run() -> Result { // Dev-box guard: on contributors' laptops `/home/archipelago/archy` is // typically a symlink into the git checkout, and writing through it diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 100a086d..abce2909 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -571,15 +571,10 @@ pub async fn check_for_updates(data_dir: &Path) -> Result { mirror = %manifest_url, "No newer version in manifest" ); - if state.available_update.is_some() { - // A later mirror might still have a - // newer version — don't clobber what an - // earlier mirror told us. But also don't - // break: another mirror could be ahead. - continue 'mirrors; - } state.manifest_mirror = None; state.available_update = None; + handled = true; + continue 'mirrors; } handled = true; break 'mirrors; @@ -1173,6 +1168,125 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> { } info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui"); } + _ if name.contains("runtime") && name.ends_with(".tar.gz") => { + let ts = chrono::Utc::now().timestamp_millis(); + let staging_new = format!("/opt/archipelago/runtime.new.{}", ts); + let archive = src.to_string_lossy().to_string(); + + let mk = host_sudo(&["mkdir", "-p", &staging_new]) + .await + .context("Failed to create runtime staging dir")?; + if !mk.success() { + anyhow::bail!("mkdir {} failed", staging_new); + } + + let extract = host_sudo(&["tar", "-xzf", &archive, "-C", &staging_new]) + .await + .with_context(|| format!("Failed to extract {}", name))?; + if !extract.success() { + let _ = host_sudo(&["rm", "-rf", &staging_new]).await; + anyhow::bail!("tar extraction failed for {}", name); + } + + let runtime_paths = [ + ("apps", "apps"), + ("scripts", "scripts"), + ("docker", "docker"), + ( + "image-recipe/configs/archipelago-doctor.service", + "archipelago-doctor.service", + ), + ( + "image-recipe/configs/archipelago-doctor.timer", + "archipelago-doctor.timer", + ), + ]; + + for (relative, label) in runtime_paths { + let staged_path = format!("{}/{}", staging_new, relative); + if !Path::new(&staged_path).exists() { + tracing::debug!(path = %relative, "Runtime artifact path absent, skipping"); + continue; + } + + match label { + "apps" | "scripts" | "docker" => { + let dest = format!("/opt/archipelago/{}", label); + let tmp_dest = + format!("{}.new.{}", dest, chrono::Utc::now().timestamp_millis()); + let _ = host_sudo(&["mkdir", "-p", &tmp_dest]).await; + let staged_dot = format!("{}/.", staged_path); + let copy = host_sudo(&["cp", "-a", &staged_dot, &tmp_dest]) + .await + .with_context(|| format!("Failed to copy runtime {}", label))?; + if !copy.success() { + let _ = host_sudo(&["rm", "-rf", &tmp_dest]).await; + anyhow::bail!("runtime copy failed for {}", label); + } + let _ = host_sudo(&["mkdir", "-p", &dest]).await; + let clean = host_sudo(&[ + "find", + &dest, + "-mindepth", + "1", + "-maxdepth", + "1", + "-exec", + "rm", + "-rf", + "{}", + "+", + ]) + .await + .with_context(|| format!("Failed to clean runtime {}", label))?; + if !clean.success() { + let _ = host_sudo(&["rm", "-rf", &tmp_dest]).await; + anyhow::bail!("runtime clean failed for {}", label); + } + let tmp_dot = format!("{}/.", tmp_dest); + let promote = host_sudo(&["cp", "-a", &tmp_dot, &dest]) + .await + .with_context(|| format!("Failed to promote runtime {}", label))?; + let _ = host_sudo(&["rm", "-rf", &tmp_dest]).await; + if !promote.success() { + anyhow::bail!("runtime promote failed for {}", label); + } + if label == "scripts" { + let _ = host_sudo(&[ + "find", &dest, "-type", "f", "-name", "*.sh", "-exec", "chmod", + "755", "{}", "+", + ]) + .await; + } + } + "archipelago-doctor.service" | "archipelago-doctor.timer" => { + let dest = format!("/etc/systemd/system/{}", label); + let install = host_sudo(&["install", "-m", "644", &staged_path, &dest]) + .await + .with_context(|| format!("Failed to install {}", label))?; + if !install.success() { + anyhow::bail!("runtime unit install failed for {}", label); + } + } + _ => {} + } + } + + if Path::new(&format!("{}/scripts/image-versions.sh", staging_new)).exists() { + let _ = host_sudo(&[ + "cp", + &format!("{}/scripts/image-versions.sh", staging_new), + "/opt/archipelago/image-versions.sh", + ]) + .await; + } + + let _ = host_sudo(&["systemctl", "daemon-reload"]).await; + let _ = + host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await; + let _ = host_sudo(&["rm", "-rf", &staging_new]).await; + info!(name = %name, "Runtime assets applied to /opt/archipelago"); + } _ => { debug!(name = %name, "Unknown component, skipping"); } diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index cde8444a..886aaf78 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.49-alpha", + "version": "1.7.50-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.49-alpha", + "version": "1.7.50-alpha", "dependencies": { "@types/dompurify": "^3.0.5", "@vue-leaflet/vue-leaflet": "^0.10.1", diff --git a/neode-ui/package.json b/neode-ui/package.json index a8a108da..c22ea42d 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.49-alpha", + "version": "1.7.50-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/release-manifest.json b/release-manifest.json index 8c33ef72..3c511fe8 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,31 +1,27 @@ { - "version": "1.7.44-alpha", - "release_date": "2026-04-28", + "version": "1.7.50-alpha", + "release_date": "2026-05-01", "changelog": [ - "43de3b73 feat(orchestrator): complete container migration and release hardening", - "ce39430b feat(self-update): sync and rebuild UI containers on OTA", - "72dec5aa fix(lnd-ui): align container port across all specs", - "83aacdf2 chore(release): archive ISO build recipes, tarball-only releases", - "All notable changes to Archipelago will be documented in this file.", - "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),", - "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." + "OTA now carries app manifests, scripts, Docker contexts, and doctor units inside the frontend payload so older nodes restore /opt/archipelago runtime assets after updating.", + "Startup bootstrap promotes the embedded runtime payload into /opt/archipelago and reloads the doctor timer without requiring rsync.", + "Update checks now continue past stale mirrors so nodes can discover a newer manifest from the next configured mirror." ], "components": [ { "name": "archipelago", - "current_version": "1.7.44-alpha", - "new_version": "1.7.44-alpha", - "download_url": "https://github.com/archipelago-os/releases/releases/download/v1.7.44-alpha/archipelago", - "sha256": "ea9167d376210bdc416dd0dcf3c1737e2fa2b1bdb9f192e473f2edaaf0d046a7", - "size_bytes": 41373032 + "current_version": "1.7.49-alpha", + "new_version": "1.7.50-alpha", + "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.50-alpha/archipelago", + "sha256": "494f71d58608787a4f485ec6e295057e70045e905d745837d1cd6089e484197d", + "size_bytes": 41779280 }, { - "name": "archipelago-frontend-1.7.44-alpha.tar.gz", - "current_version": "1.7.44-alpha", - "new_version": "1.7.44-alpha", - "download_url": "https://github.com/archipelago-os/releases/releases/download/v1.7.44-alpha/archipelago-frontend-1.7.44-alpha.tar.gz", - "sha256": "28bdbccd8151bfa7834761adb5363862ef54b76c6010d1446fb8cca4ed4fe18c", - "size_bytes": 162089899 + "name": "archipelago-frontend-1.7.50-alpha.tar.gz", + "current_version": "1.7.49-alpha", + "new_version": "1.7.50-alpha", + "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.50-alpha/archipelago-frontend-1.7.50-alpha.tar.gz", + "sha256": "6684d5f1083fd2d1db236fcc4a94efee7986ab54602b3231b031f3e9f7dec361", + "size_bytes": 165153241 } ] } diff --git a/scripts/create-release-manifest.sh b/scripts/create-release-manifest.sh index 0c18953e..91472cd5 100755 --- a/scripts/create-release-manifest.sh +++ b/scripts/create-release-manifest.sh @@ -87,6 +87,30 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then echo " Including AIUI from demo/aiui/" cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui" fi + # OTA bridge for nodes running older updaters: they only know how to + # apply the backend binary and frontend archive. Carry host runtime + # assets inside the frontend tarball; the new backend promotes them + # from /opt/archipelago/web-ui/archipelago-runtime on first startup. + RUNTIME_DIR="$STAGING_DIR/archipelago-runtime" + mkdir -p "$RUNTIME_DIR" + for runtime_path in apps scripts docker; do + if [ -d "$PROJECT_ROOT/$runtime_path" ]; then + echo " Including runtime $runtime_path/" + cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path" + fi + done + if [ -f "$PROJECT_ROOT/image-recipe/configs/archipelago-doctor.service" ] || \ + [ -f "$PROJECT_ROOT/image-recipe/configs/archipelago-doctor.timer" ]; then + mkdir -p "$RUNTIME_DIR/image-recipe/configs" + for unit in archipelago-doctor.service archipelago-doctor.timer; do + if [ -f "$PROJECT_ROOT/image-recipe/configs/$unit" ]; then + echo " Including runtime unit $unit" + cp "$PROJECT_ROOT/image-recipe/configs/$unit" "$RUNTIME_DIR/image-recipe/configs/$unit" + fi + done + fi + rm -rf "$RUNTIME_DIR/scripts/resilience/reports" + find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '._*' -o -name '*.log' \) -delete # Force world-readable perms on every entry BEFORE tar, so the # archive's internal mode bits are 755/644 regardless of what # the staging dir's umask gave us. Without this, mktemp -d diff --git a/scripts/self-update.sh b/scripts/self-update.sh index 362f26c4..2858ce8d 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -200,6 +200,19 @@ if [ -f "$REPO_DIR/scripts/image-versions.sh" ]; then sudo cp "$REPO_DIR/scripts/image-versions.sh" /opt/archipelago/image-versions.sh fi +# Sync app manifests and app-local build contexts into the canonical +# production manifest root. The backend orchestrator loads install specs from +# /opt/archipelago/apps; updating only the binary/frontend can leave a node +# with new installer logic but stale or missing app manifests. +APPS_DEST="/opt/archipelago/apps" +if [ -d "$REPO_DIR/apps" ]; then + sudo mkdir -p "$APPS_DEST" + sudo rsync -a --delete "$REPO_DIR/apps/" "$APPS_DEST/" + ok "App manifests synced" +else + warn "Apps directory not found at $REPO_DIR/apps — install manifests may be stale" +fi + # Update first-boot-containers.sh too (the canonical first-boot orchestrator). # Nodes run it once on install, but keeping a fresh copy on disk means any # future boot or reconciler invocation uses current port specs and caps.