fix: release v1.7.50-alpha OTA runtime repair

This commit is contained in:
archipelago
2026-05-01 03:14:07 -04:00
parent 7ab788d178
commit be9f9528c3
9 changed files with 297 additions and 33 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.49-alpha"
version = "1.7.50-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -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"]
+118 -1
View File
@@ -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<bool> {
// 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<bool> {
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
// typically a symlink into the git checkout, and writing through it
+121 -7
View File
@@ -571,15 +571,10 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
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");
}