Compare commits

...
15 changed files with 796 additions and 131 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.49-alpha" version = "1.7.51-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.49-alpha" version = "1.7.51-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]
@@ -405,6 +405,9 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> { pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
let base = "/var/lib/archipelago"; let base = "/var/lib/archipelago";
match package_id { match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
}
"mempool" | "mempool-web" => vec![ "mempool" | "mempool-web" => vec![
format!("{}/mempool", base), format!("{}/mempool", base),
format!("{}/mysql-mempool", base), format!("{}/mysql-mempool", base),
+198 -51
View File
@@ -10,6 +10,7 @@ use super::progress::parse_pull_progress;
use super::validation::validate_app_id; use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler; use crate::api::rpc::RpcHandler;
use crate::data_model::InstallPhase; use crate::data_model::InstallPhase;
use crate::update::host_sudo;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@@ -138,14 +139,20 @@ async fn inject_bitcoin_rpc_auth_into_running_container(container: &str, auth_b6
.await; .await;
match reload { match reload {
Ok(o) if o.status.success() => { Ok(o) if o.status.success() => {
info!("Injected Bitcoin RPC auth into {} (post-start, cp+SIGHUP)", container); info!(
"Injected Bitcoin RPC auth into {} (post-start, cp+SIGHUP)",
container
);
} }
Ok(o) => warn!( Ok(o) => warn!(
"Patched nginx.conf in {} but SIGHUP failed: {}", "Patched nginx.conf in {} but SIGHUP failed: {}",
container, container,
String::from_utf8_lossy(&o.stderr) String::from_utf8_lossy(&o.stderr)
), ),
Err(e) => warn!("Patched nginx.conf in {} but SIGHUP errored: {}", container, e), Err(e) => warn!(
"Patched nginx.conf in {} but SIGHUP errored: {}",
container, e
),
} }
} }
@@ -230,6 +237,11 @@ impl RpcHandler {
check_install_deps(package_id, &deps)?; check_install_deps(package_id, &deps)?;
log_optional_dep_info(package_id, &deps); log_optional_dep_info(package_id, &deps);
check_bitcoin_implementation_conflict(package_id).await?; check_bitcoin_implementation_conflict(package_id).await?;
let repaired_bitcoin_conf = if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
ensure_bitcoin_rpc_bindings().await?
} else {
false
};
// Check if container already exists // Check if container already exists
let check_output = tokio::process::Command::new("podman") let check_output = tokio::process::Command::new("podman")
@@ -270,7 +282,35 @@ impl RpcHandler {
.trim() .trim()
.to_string(); .to_string();
if state != "running" { if state == "running" && repaired_bitcoin_conf {
info!(
"Restarting existing container {} after bitcoin.conf RPC repair",
package_id
);
let restart_output = tokio::process::Command::new("podman")
.args(["restart", package_id])
.output()
.await
.context("Failed to restart existing container after bitcoin.conf repair")?;
if !restart_output.status.success() {
let stderr = String::from_utf8_lossy(&restart_output.stderr);
install_log(&format!(
"INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}",
package_id, stderr
))
.await;
return Err(anyhow::anyhow!(
"Container {} exists but failed to restart after RPC repair: {}",
package_id,
stderr
));
}
let _ = tokio::process::Command::new("podman")
.args(["restart", "archy-bitcoin-ui"])
.output()
.await;
wait_for_adopted_container(package_id, package_id).await?;
} else if state != "running" {
// Start the stopped/exited container // Start the stopped/exited container
info!("Starting existing container {} (was {})", package_id, state); info!("Starting existing container {} (was {})", package_id, state);
let start_output = tokio::process::Command::new("podman") let start_output = tokio::process::Command::new("podman")
@@ -291,6 +331,8 @@ impl RpcHandler {
stderr stderr
)); ));
} }
wait_for_adopted_container(package_id, package_id).await?;
} }
install_log(&format!( install_log(&format!(
@@ -575,7 +617,12 @@ impl RpcHandler {
// during its initial reorg/indexing phase. // during its initial reorg/indexing phase.
let cpu_capped = !matches!( let cpu_capped = !matches!(
package_id, package_id,
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrumx" | "electrs" | "mempool-electrs" "bitcoin"
| "bitcoin-core"
| "bitcoin-knots"
| "electrumx"
| "electrs"
| "mempool-electrs"
); );
if cpu_capped { if cpu_capped {
run_args.push("--cpus=2"); run_args.push("--cpus=2");
@@ -1092,12 +1139,14 @@ impl RpcHandler {
// user" and skip. Matches the lnd.conf behavior below. // user" and skip. Matches the lnd.conf behavior below.
match tokio::fs::metadata(&conf_path).await { match tokio::fs::metadata(&conf_path).await {
Ok(_) => { Ok(_) => {
info!("bitcoin.conf already exists, skipping write"); ensure_bitcoin_rpc_bindings().await?;
info!("bitcoin.conf already exists, ensured RPC bind settings");
return Ok(()); return Ok(());
} }
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => { Err(_) => {
info!("bitcoin.conf path inaccessible (container-owned data dir), skipping write"); ensure_bitcoin_rpc_bindings().await?;
info!("bitcoin.conf path inaccessible, ensured RPC bind settings via host helper");
return Ok(()); return Ok(());
} }
} }
@@ -1512,56 +1561,60 @@ autopilot.active=false\n",
// 2. Post-start: `podman exec` into the running container to patch // 2. Post-start: `podman exec` into the running container to patch
// nginx.conf and reload. Authoritative for both paths — runs // nginx.conf and reload. Authoritative for both paths — runs
// regardless of how the image was built. // regardless of how the image was built.
let bitcoin_rpc_auth_b64: Option<String> = let bitcoin_rpc_auth_b64: Option<String> = if matches!(
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { package_id,
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await; "bitcoin" | "bitcoin-core" | "bitcoin-knots"
use base64::Engine; ) {
let auth_b64 = base64::engine::general_purpose::STANDARD let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
.encode(format!("{}:{}", rpc_user, rpc_pass)); use base64::Engine;
for dir in [ let auth_b64 = base64::engine::general_purpose::STANDARD
"/opt/archipelago/docker/bitcoin-ui", .encode(format!("{}:{}", rpc_user, rpc_pass));
"/home/archipelago/archy/docker/bitcoin-ui", for dir in [
] { "/opt/archipelago/docker/bitcoin-ui",
let conf_path = format!("{}/nginx.conf", dir); "/home/archipelago/archy/docker/bitcoin-ui",
match tokio::fs::read_to_string(&conf_path).await { ] {
Ok(content) => { let conf_path = format!("{}/nginx.conf", dir);
let updated = content match tokio::fs::read_to_string(&conf_path).await {
.replace("__BITCOIN_RPC_AUTH__", &auth_b64) Ok(content) => {
.lines() let updated = content
.map(|line| { .replace("__BITCOIN_RPC_AUTH__", &auth_b64)
if line.contains("proxy_set_header Authorization") .lines()
&& line.contains("Basic") .map(|line| {
{ if line.contains("proxy_set_header Authorization")
format!( && line.contains("Basic")
" proxy_set_header Authorization \"Basic {}\";", {
auth_b64 format!(
) " proxy_set_header Authorization \"Basic {}\";",
} else { auth_b64
line.to_string() )
} } else {
}) line.to_string()
.collect::<Vec<_>>() }
.join("\n"); })
if let Err(e) = .collect::<Vec<_>>()
tokio::fs::write(&conf_path, format!("{}\n", updated)).await .join("\n");
{ if let Err(e) = tokio::fs::write(&conf_path, format!("{}\n", updated)).await
warn!("Failed to write {} with injected RPC auth: {}", conf_path, e); {
} else { warn!(
info!("Injected Bitcoin RPC auth into {} (build-time)", conf_path); "Failed to write {} with injected RPC auth: {}",
} conf_path, e
);
} else {
info!("Injected Bitcoin RPC auth into {} (build-time)", conf_path);
} }
Err(_) => { }
debug!( Err(_) => {
debug!(
"No build-time nginx.conf at {} (will patch running container after start)", "No build-time nginx.conf at {} (will patch running container after start)",
conf_path conf_path
); );
}
} }
} }
Some(auth_b64) }
} else { Some(auth_b64)
None } else {
}; None
};
// Build and start companion UI containers for headless services. // Build and start companion UI containers for headless services.
// All UIs proxy to localhost (backend :5678 or bitcoin :8332) so they need --network=host. // All UIs proxy to localhost (backend :5678 or bitcoin :8332) so they need --network=host.
@@ -1958,6 +2011,97 @@ async fn resolve_host_gateway() -> String {
"--add-host=host.containers.internal:10.0.2.2".to_string() "--add-host=host.containers.internal:10.0.2.2".to_string()
} }
async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> Result<()> {
for _ in 0..12u32 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output()
.await;
let Ok(output) = status else {
continue;
};
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
match state.as_str() {
"running" => return Ok(()),
"exited" | "dead" => {
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "40", container_name])
.output()
.await;
let log_output = logs
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
format!("{}{}", stdout, stderr)
})
.unwrap_or_default();
install_log(&format!(
"INSTALL ADOPT CRASH: {} - existing container {} exited. Logs:\n{}",
package_id,
container_name,
&log_output.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"Existing container {} exited after start. Logs: {}",
container_name,
log_output.chars().take(500).collect::<String>()
));
}
_ => {}
}
}
install_log(&format!(
"INSTALL ADOPT TIMEOUT: {} - existing container {} did not stay running",
package_id, container_name
))
.await;
Err(anyhow::anyhow!(
"Existing container {} did not reach running state within 60s",
container_name
))
}
async fn ensure_bitcoin_rpc_bindings() -> Result<bool> {
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line rpcport=8332
ensure_line listen=1
[ "$changed" -eq 0 ] && exit 0
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("ensure bitcoin.conf RPC bind settings")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => {
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
Ok(true)
}
_ => Err(anyhow::anyhow!(
"bitcoin.conf RPC repair helper exited with {}",
status
)),
}
}
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)
} }
@@ -2092,7 +2236,10 @@ mod tests {
#[test] #[test]
fn install_aliases_map_to_manifest_app_ids() { fn install_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_install_app_id("bitcoin-knots"), "bitcoin-knots"); assert_eq!(
orchestrator_install_app_id("bitcoin-knots"),
"bitcoin-knots"
);
assert_eq!(orchestrator_install_app_id("bitcoin-core"), "bitcoin-core"); assert_eq!(orchestrator_install_app_id("bitcoin-core"), "bitcoin-core");
assert_eq!(orchestrator_install_app_id("electrs"), "electrumx"); assert_eq!(orchestrator_install_app_id("electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx"); assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
+159 -38
View File
@@ -48,6 +48,12 @@ async fn adopt_stack_if_exists(
.await; .await;
} }
} }
let existing: Vec<&str> = all_containers
.iter()
.copied()
.filter(|container| names.iter().any(|n| n == container))
.collect();
wait_for_stack_containers(stack_name, &existing, 60).await?;
install_log(&format!( install_log(&format!(
"INSTALL ADOPT OK: {} — started existing containers", "INSTALL ADOPT OK: {} — started existing containers",
@@ -61,6 +67,107 @@ async fn adopt_stack_if_exists(
}))) })))
} }
async fn run_required_stack_command(
stack_name: &str,
label: &str,
cmd: &mut tokio::process::Command,
) -> Result<()> {
let output = cmd
.output()
.await
.with_context(|| format!("{}: failed to run {}", stack_name, label))?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = format!("{} failed: {}{}", label, stdout, stderr);
install_log(&format!("INSTALL FAIL: {} - {}", stack_name, msg.trim())).await;
Err(anyhow::anyhow!("{} {}", stack_name, msg.trim()))
}
async fn wait_for_stack_containers(
stack_name: &str,
containers: &[&str],
timeout_secs: u64,
) -> Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
let mut pending = Vec::new();
for container in containers {
let status = tokio::process::Command::new("podman")
.args(["inspect", container, "--format", "{{.State.Status}}"])
.output()
.await;
match status {
Ok(output) if output.status.success() => {
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
match state.as_str() {
"running" => {}
"exited" | "dead" => {
let logs = stack_container_logs(container, 40).await;
install_log(&format!(
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
stack_name,
container,
logs.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"{} container {} exited after install. Logs: {}",
stack_name,
container,
logs.chars().take(500).collect::<String>()
));
}
other => pending.push(format!("{}={}", container, other)),
}
}
Ok(output) => {
pending.push(format!(
"{}=missing({})",
container,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Err(e) => pending.push(format!("{}=inspect-error({})", container, e)),
}
}
if pending.is_empty() {
return Ok(());
}
if std::time::Instant::now() >= deadline {
install_log(&format!(
"INSTALL TIMEOUT: {} - containers not running: {}",
stack_name,
pending.join(", ")
))
.await;
return Err(anyhow::anyhow!(
"{} containers did not reach running state: {}",
stack_name,
pending.join(", ")
));
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
async fn stack_container_logs(container: &str, lines: u32) -> String {
tokio::process::Command::new("podman")
.args(["logs", "--tail", &lines.to_string(), container])
.output()
.await
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
format!("{}{}", stdout, stderr)
})
.unwrap_or_default()
}
async fn install_stack_via_orchestrator( async fn install_stack_via_orchestrator(
handler: &RpcHandler, handler: &RpcHandler,
stack_name: &str, stack_name: &str,
@@ -208,10 +315,12 @@ impl RpcHandler {
.await; .await;
let n_images = images.len() as u64; let n_images = images.len() as u64;
for (i, img) in images.iter().enumerate() { for (i, img) in images.iter().enumerate() {
self.set_install_progress("immich", i as u64, n_images).await; self.set_install_progress("immich", i as u64, n_images)
.await;
pull_image_with_retry(img).await?; pull_image_with_retry(img).await?;
} }
self.set_install_progress("immich", n_images, n_images).await; self.set_install_progress("immich", n_images, n_images)
.await;
self.set_install_phase("immich", InstallPhase::CreatingContainer) self.set_install_phase("immich", InstallPhase::CreatingContainer)
.await; .await;
@@ -626,10 +735,12 @@ impl RpcHandler {
.await; .await;
let n_images = images.len() as u64; let n_images = images.len() as u64;
for (i, img) in images.iter().enumerate() { for (i, img) in images.iter().enumerate() {
self.set_install_progress("mempool", i as u64, n_images).await; self.set_install_progress("mempool", i as u64, n_images)
.await;
pull_image_with_retry(img).await?; pull_image_with_retry(img).await?;
} }
self.set_install_progress("mempool", n_images, n_images).await; self.set_install_progress("mempool", n_images, n_images)
.await;
self.set_install_phase("mempool", InstallPhase::CreatingContainer) self.set_install_phase("mempool", InstallPhase::CreatingContainer)
.await; .await;
@@ -901,7 +1012,8 @@ impl RpcHandler {
let minio_pass = super::config::read_or_generate_secret("indeedhub-minio-password").await; let minio_pass = super::config::read_or_generate_secret("indeedhub-minio-password").await;
// 1. Postgres // 1. Postgres
let _ = tokio::process::Command::new("podman") let mut postgres_cmd = tokio::process::Command::new("podman");
postgres_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@@ -923,12 +1035,12 @@ impl RpcHandler {
"indeedhub-postgres-data:/var/lib/postgresql/data", "indeedhub-postgres-data:/var/lib/postgresql/data",
&format!("{}/postgres:16.13-alpine", registry), &format!("{}/postgres:16.13-alpine", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create postgres", &mut postgres_cmd).await?;
.await;
// 2. Redis // 2. Redis
let _ = tokio::process::Command::new("podman") let mut redis_cmd = tokio::process::Command::new("podman");
redis_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@@ -944,12 +1056,12 @@ impl RpcHandler {
"indeedhub-redis-data:/data", "indeedhub-redis-data:/data",
&format!("{}/redis:7.4.8-alpine", registry), &format!("{}/redis:7.4.8-alpine", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create redis", &mut redis_cmd).await?;
.await;
// 3. MinIO // 3. MinIO
let _ = tokio::process::Command::new("podman") let mut minio_cmd = tokio::process::Command::new("podman");
minio_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@@ -971,12 +1083,12 @@ impl RpcHandler {
"server", "server",
"/data", "/data",
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create minio", &mut minio_cmd).await?;
.await;
// 4. Nostr relay // 4. Nostr relay
let _ = tokio::process::Command::new("podman") let mut relay_cmd = tokio::process::Command::new("podman");
relay_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@@ -992,12 +1104,12 @@ impl RpcHandler {
"indeedhub-relay-data:/usr/src/app/db", "indeedhub-relay-data:/usr/src/app/db",
&format!("{}/nostr-rs-relay:0.9.0", registry), &format!("{}/nostr-rs-relay:0.9.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create relay", &mut relay_cmd).await?;
.await;
// 5. API // 5. API
let _ = tokio::process::Command::new("podman") let mut api_cmd = tokio::process::Command::new("podman");
api_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@@ -1049,12 +1161,12 @@ impl RpcHandler {
"ENVIRONMENT=production", "ENVIRONMENT=production",
&format!("{}/indeedhub-api:1.0.0", registry), &format!("{}/indeedhub-api:1.0.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create api", &mut api_cmd).await?;
.await;
// 6. FFmpeg worker // 6. FFmpeg worker
let _ = tokio::process::Command::new("podman") let mut ffmpeg_cmd = tokio::process::Command::new("podman");
ffmpeg_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@@ -1096,15 +1208,15 @@ impl RpcHandler {
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef", "AES_MASTER_SECRET=0123456789abcdef0123456789abcdef",
&format!("{}/indeedhub-ffmpeg:1.0.0", registry), &format!("{}/indeedhub-ffmpeg:1.0.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create ffmpeg worker", &mut ffmpeg_cmd).await?;
.await;
// Wait for backend services to start // Wait for backend services to start
tokio::time::sleep(std::time::Duration::from_secs(5)).await; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// 7. Frontend (nginx) // 7. Frontend (nginx)
let run = tokio::process::Command::new("podman") let mut frontend_cmd = tokio::process::Command::new("podman");
frontend_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@@ -1118,15 +1230,23 @@ impl RpcHandler {
"7778:7777", "7778:7777",
&format!("{}/indeedhub:1.0.0", registry), &format!("{}/indeedhub:1.0.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.output() run_required_stack_command("indeedhub", "create frontend", &mut frontend_cmd).await?;
.await
.context("Failed to create indeedhub container")?;
if !run.status.success() { wait_for_stack_containers(
let err = String::from_utf8_lossy(&run.stderr); "indeedhub",
return Err(anyhow::anyhow!("IndeedHub frontend failed: {}", err)); &[
} "indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
],
60,
)
.await?;
// Phase: WaitingHealthy → PostInstall → clear. The actual readiness // Phase: WaitingHealthy → PostInstall → clear. The actual readiness
// gate is the package scanner's next sweep; this just gives the UI a // gate is the package scanner's next sweep; this just gives the UI a
@@ -1136,7 +1256,8 @@ impl RpcHandler {
.await; .await;
self.set_install_phase("indeedhub", InstallPhase::PostInstall) self.set_install_phase("indeedhub", InstallPhase::PostInstall)
.await; .await;
self.set_install_phase("indeedhub", InstallPhase::Done).await; self.set_install_phase("indeedhub", InstallPhase::Done)
.await;
self.clear_install_progress("indeedhub").await; self.clear_install_progress("indeedhub").await;
install_log("INSTALL OK: indeedhub stack").await; install_log("INSTALL OK: indeedhub stack").await;
+171 -1
View File
@@ -15,7 +15,7 @@
//! best-effort — failures are logged but never abort the backend. //! best-effort — failures are logged but never abort the backend.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::path::Path; use std::path::{Path, PathBuf};
use tokio::fs; use tokio::fs;
use tracing::{debug, info, warn}; 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 DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago"; 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 /// Inserted into every server block of the nginx config that lacks the
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in /// `/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 — /// 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. /// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() { 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 { match run().await {
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"), Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
Ok(_) => debug!("Doctor artifacts already in sync"), Ok(_) => debug!("Doctor artifacts already in sync"),
@@ -50,6 +56,170 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("Nginx already has /api/app-catalog block"), Ok(false) => debug!("Nginx already has /api/app-catalog block"),
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e), Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
} }
match run_bitcoin_rpc_repair().await {
Ok(true) => info!("Repaired Bitcoin RPC bind settings and restarted Bitcoin containers"),
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
}
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_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. In that state bitcoind is healthy internally, but the
// host-network bitcoin-ui proxy to 127.0.0.1:8332 gets connection resets.
// Repair it at startup so OTA fixes existing nodes without a manual
// uninstall/reinstall.
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line rpcport=8332
ensure_line listen=1
[ "$changed" -eq 0 ] && exit 0
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("repair bitcoin.conf RPC bind settings")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => {
for name in ["bitcoin-knots", "bitcoin-core", "archy-bitcoin-ui"] {
let _ = tokio::process::Command::new("podman")
.args(["restart", name])
.status()
.await;
}
Ok(true)
}
_ => {
warn!("Bitcoin RPC repair helper exited with {}", status);
Ok(false)
}
}
} }
async fn run() -> Result<bool> { async fn run() -> Result<bool> {
+48 -2
View File
@@ -11,6 +11,8 @@ use tokio::fs;
use tracing::{debug, info}; use tracing::{debug, info};
const REGISTRY_FILE: &str = "config/registries.json"; const REGISTRY_FILE: &str = "config/registries.json";
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
/// A single container registry. /// A single container registry.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -44,14 +46,14 @@ impl Default for RegistryConfig {
Self { Self {
registries: vec![ registries: vec![
Registry { Registry {
url: "146.59.87.168:3000/lfg2025".to_string(), url: OVH_REGISTRY_URL.to_string(),
name: "Server 1 (OVH)".to_string(), name: "Server 1 (OVH)".to_string(),
tls_verify: false, tls_verify: false,
enabled: true, enabled: true,
priority: 0, priority: 0,
}, },
Registry { Registry {
url: "git.tx1138.com/lfg2025".to_string(), url: TX1138_REGISTRY_URL.to_string(),
name: "Server 2 (tx1138)".to_string(), name: "Server 2 (tx1138)".to_string(),
tls_verify: true, tls_verify: true,
enabled: true, enabled: true,
@@ -139,6 +141,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
changed = true; changed = true;
} }
} }
let before_order: Vec<(String, bool, u32)> = config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect();
force_ovh_registry_primary(&mut config);
changed = changed
|| before_order
!= config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect::<Vec<_>>();
if changed { if changed {
// Persist so the next load doesn't have to re-merge. // Persist so the next load doesn't have to re-merge.
let _ = save_registries(data_dir, &config).await; let _ = save_registries(data_dir, &config).await;
@@ -146,6 +161,37 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
Ok(config) Ok(config)
} }
fn force_ovh_registry_primary(config: &mut RegistryConfig) {
let defaults = RegistryConfig::default();
for def in defaults.registries {
if !config.registries.iter().any(|r| r.url == def.url) {
config.registries.push(def);
}
}
for registry in config.registries.iter_mut() {
match registry.url.as_str() {
OVH_REGISTRY_URL => {
registry.name = "Server 1 (OVH)".to_string();
registry.tls_verify = false;
registry.enabled = true;
registry.priority = 0;
}
TX1138_REGISTRY_URL => {
registry.name = "Server 2 (tx1138)".to_string();
registry.tls_verify = true;
registry.enabled = true;
registry.priority = 10;
}
_ => {
if registry.priority <= 10 {
registry.priority = registry.priority.saturating_add(20);
}
}
}
}
}
/// Save registry config to disk. /// Save registry config to disk.
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> { pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
let dir = data_dir.join("config"); let dir = data_dir.join("config");
+149 -7
View File
@@ -163,12 +163,40 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
changed = true; changed = true;
} }
} }
let before_order: Vec<String> = list.iter().map(|m| m.url.clone()).collect();
force_ovh_update_primary(&mut list);
changed = changed || before_order != list.iter().map(|m| m.url.clone()).collect::<Vec<_>>();
if changed { if changed {
let _ = save_mirrors(data_dir, &list).await; let _ = save_mirrors(data_dir, &list).await;
} }
Ok(list) Ok(list)
} }
fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
let defaults = default_mirrors();
for def in &defaults {
if !list.iter().any(|m| m.url == def.url) {
list.push(def.clone());
}
}
for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Server 1 (OVH)".to_string();
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
mirror.label = "Server 2 (tx1138)".to_string();
}
}
list.sort_by_key(|m| {
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
0
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
1
} else {
2
}
});
}
pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> { pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> {
fs::create_dir_all(data_dir) fs::create_dir_all(data_dir)
.await .await
@@ -571,15 +599,10 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
mirror = %manifest_url, mirror = %manifest_url,
"No newer version in manifest" "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.manifest_mirror = None;
state.available_update = None; state.available_update = None;
handled = true;
continue 'mirrors;
} }
handled = true; handled = true;
break 'mirrors; break 'mirrors;
@@ -1173,6 +1196,125 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
} }
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui"); 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"); debug!(name = %name, "Unknown component, skipping");
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.49-alpha", "version": "1.7.51-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.49-alpha", "version": "1.7.51-alpha",
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.49-alpha", "version": "1.7.51-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
+4 -2
View File
@@ -83,9 +83,11 @@ const launchableApps = computed<KioskApp[]>(() => {
const pkgs = store.data?.['package-data'] || {} const pkgs = store.data?.['package-data'] || {}
const apps: KioskApp[] = [] const apps: KioskApp[] = []
// App URL mappings — use nginx proxy paths for local apps // App URL mappings. Bitcoin UI uses its direct host-network port; loading it
// through /app/bitcoin-ui/ can render a blank shell because its assets are
// rooted at /.
const urlMap: Record<string, string> = { const urlMap: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/', 'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
'lnd': '/app/lnd/', 'lnd': '/app/lnd/',
'mempool': '/app/mempool/', 'mempool': '/app/mempool/',
'btcpay-server': '/app/btcpay/', 'btcpay-server': '/app/btcpay/',
@@ -55,9 +55,6 @@ export const PROXY_APPS: Record<string, string> = {
/** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe). /** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */ * On HTTP, direct port access is used instead (faster, no proxy). */
export const HTTPS_PROXY_PATHS: Record<string, string> = { export const HTTPS_PROXY_PATHS: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'bitcoin-core': '/app/bitcoin-ui/',
'bitcoin-ui': '/app/bitcoin-ui/',
'lnd': '/app/lnd/', 'lnd': '/app/lnd/',
'electrumx': '/app/electrumx/', 'electrumx': '/app/electrumx/',
'electrs': '/app/electrumx/', 'electrs': '/app/electrumx/',
@@ -137,9 +134,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
const ext = EXTERNAL_URLS[id] const ext = EXTERNAL_URLS[id]
if (ext) return ext if (ext) return ext
// Bitcoin apps always go through nginx proxy so browser basic-auth prompts never appear. // Bitcoin UI is a host-network companion on :8334. Do not launch it via
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') { if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
return window.location.protocol + '//' + window.location.hostname + '/app/bitcoin-ui/' return 'http://' + window.location.hostname + ':8334'
} }
// HTTPS pages cannot embed plain HTTP port origins (mixed-content). // HTTPS pages cannot embed plain HTTP port origins (mixed-content).
+18 -20
View File
@@ -1,31 +1,29 @@
{ {
"version": "1.7.44-alpha", "version": "1.7.51-alpha",
"release_date": "2026-04-28", "release_date": "2026-05-01",
"changelog": [ "changelog": [
"43de3b73 feat(orchestrator): complete container migration and release hardening", "Install success now requires adopted containers and IndeedHub stack containers to stay running; failed starts surface logs instead of disappearing from My Apps.",
"ce39430b feat(self-update): sync and rebuild UI containers on OTA", "Bitcoin uninstall removes the shared Bitcoin data/UI directories when data is not preserved, preventing stale partial installs from being adopted as success.",
"72dec5aa fix(lnd-ui): align container port across all specs", "Bitcoin RPC bind settings are repaired on startup and before adopting existing Bitcoin containers, fixing older nodes where bitcoin-ui showed endless getblockchaininfo/502.",
"83aacdf2 chore(release): archive ISO build recipes, tarball-only releases", "Bitcoin Core/Knots launch the Bitcoin UI on direct port 8334 instead of the /app/bitcoin-ui path proxy.",
"All notable changes to Archipelago will be documented in this file.", "Nodes force OVH as the primary update mirror and app registry on next startup, with tx1138 retained as fallback."
"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)."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.44-alpha", "current_version": "1.7.50-alpha",
"new_version": "1.7.44-alpha", "new_version": "1.7.51-alpha",
"download_url": "https://github.com/archipelago-os/releases/releases/download/v1.7.44-alpha/archipelago", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.51-alpha/archipelago",
"sha256": "ea9167d376210bdc416dd0dcf3c1737e2fa2b1bdb9f192e473f2edaaf0d046a7", "sha256": "f761e659d661f0a83cd3a67a086bb2279398bc05e50ee3c52e769e52d11e476c",
"size_bytes": 41373032 "size_bytes": 41637536
}, },
{ {
"name": "archipelago-frontend-1.7.44-alpha.tar.gz", "name": "archipelago-frontend-1.7.51-alpha.tar.gz",
"current_version": "1.7.44-alpha", "current_version": "1.7.50-alpha",
"new_version": "1.7.44-alpha", "new_version": "1.7.51-alpha",
"download_url": "https://github.com/archipelago-os/releases/releases/download/v1.7.44-alpha/archipelago-frontend-1.7.44-alpha.tar.gz", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.51-alpha/archipelago-frontend-1.7.51-alpha.tar.gz",
"sha256": "28bdbccd8151bfa7834761adb5363862ef54b76c6010d1446fb8cca4ed4fe18c", "sha256": "3403f4e38202bf56c53407dd62e66899693ee73252bf203475715532ac6ae326",
"size_bytes": 162089899 "size_bytes": 165155462
} }
] ]
} }
+24
View File
@@ -87,6 +87,30 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
echo " Including AIUI from demo/aiui/" echo " Including AIUI from demo/aiui/"
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui" cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
fi 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 # Force world-readable perms on every entry BEFORE tar, so the
# archive's internal mode bits are 755/644 regardless of what # archive's internal mode bits are 755/644 regardless of what
# the staging dir's umask gave us. Without this, mktemp -d # the staging dir's umask gave us. Without this, mktemp -d
+13
View File
@@ -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 sudo cp "$REPO_DIR/scripts/image-versions.sh" /opt/archipelago/image-versions.sh
fi 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). # 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 # 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. # future boot or reconciler invocation uses current port specs and caps.