Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e6c2e738 | ||
|
|
be9f9528c3 |
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.49-alpha"
|
||||
version = "1.7.51-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.49-alpha"
|
||||
version = "1.7.51-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
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> {
|
||||
let base = "/var/lib/archipelago";
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
|
||||
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::InstallPhase;
|
||||
use crate::update::host_sudo;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -138,14 +139,20 @@ async fn inject_bitcoin_rpc_auth_into_running_container(container: &str, auth_b6
|
||||
.await;
|
||||
match reload {
|
||||
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!(
|
||||
"Patched nginx.conf in {} but SIGHUP failed: {}",
|
||||
container,
|
||||
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)?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
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
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
@@ -270,7 +282,35 @@ impl RpcHandler {
|
||||
.trim()
|
||||
.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
|
||||
info!("Starting existing container {} (was {})", package_id, state);
|
||||
let start_output = tokio::process::Command::new("podman")
|
||||
@@ -291,6 +331,8 @@ impl RpcHandler {
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
wait_for_adopted_container(package_id, package_id).await?;
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
@@ -575,7 +617,12 @@ impl RpcHandler {
|
||||
// during its initial reorg/indexing phase.
|
||||
let cpu_capped = !matches!(
|
||||
package_id,
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrumx" | "electrs" | "mempool-electrs"
|
||||
"bitcoin"
|
||||
| "bitcoin-core"
|
||||
| "bitcoin-knots"
|
||||
| "electrumx"
|
||||
| "electrs"
|
||||
| "mempool-electrs"
|
||||
);
|
||||
if cpu_capped {
|
||||
run_args.push("--cpus=2");
|
||||
@@ -1092,12 +1139,14 @@ impl RpcHandler {
|
||||
// user" and skip. Matches the lnd.conf behavior below.
|
||||
match tokio::fs::metadata(&conf_path).await {
|
||||
Ok(_) => {
|
||||
info!("bitcoin.conf already exists, skipping write");
|
||||
ensure_bitcoin_rpc_bindings().await?;
|
||||
info!("bitcoin.conf already exists, ensured RPC bind settings");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
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(());
|
||||
}
|
||||
}
|
||||
@@ -1512,56 +1561,60 @@ autopilot.active=false\n",
|
||||
// 2. Post-start: `podman exec` into the running container to patch
|
||||
// nginx.conf and reload. Authoritative for both paths — runs
|
||||
// regardless of how the image was built.
|
||||
let bitcoin_rpc_auth_b64: Option<String> =
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
use base64::Engine;
|
||||
let auth_b64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(format!("{}:{}", rpc_user, rpc_pass));
|
||||
for dir in [
|
||||
"/opt/archipelago/docker/bitcoin-ui",
|
||||
"/home/archipelago/archy/docker/bitcoin-ui",
|
||||
] {
|
||||
let conf_path = format!("{}/nginx.conf", dir);
|
||||
match tokio::fs::read_to_string(&conf_path).await {
|
||||
Ok(content) => {
|
||||
let updated = content
|
||||
.replace("__BITCOIN_RPC_AUTH__", &auth_b64)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.contains("proxy_set_header Authorization")
|
||||
&& line.contains("Basic")
|
||||
{
|
||||
format!(
|
||||
" proxy_set_header Authorization \"Basic {}\";",
|
||||
auth_b64
|
||||
)
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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 {
|
||||
info!("Injected Bitcoin RPC auth into {} (build-time)", conf_path);
|
||||
}
|
||||
let bitcoin_rpc_auth_b64: Option<String> = if matches!(
|
||||
package_id,
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots"
|
||||
) {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
use base64::Engine;
|
||||
let auth_b64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(format!("{}:{}", rpc_user, rpc_pass));
|
||||
for dir in [
|
||||
"/opt/archipelago/docker/bitcoin-ui",
|
||||
"/home/archipelago/archy/docker/bitcoin-ui",
|
||||
] {
|
||||
let conf_path = format!("{}/nginx.conf", dir);
|
||||
match tokio::fs::read_to_string(&conf_path).await {
|
||||
Ok(content) => {
|
||||
let updated = content
|
||||
.replace("__BITCOIN_RPC_AUTH__", &auth_b64)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.contains("proxy_set_header Authorization")
|
||||
&& line.contains("Basic")
|
||||
{
|
||||
format!(
|
||||
" proxy_set_header Authorization \"Basic {}\";",
|
||||
auth_b64
|
||||
)
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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 {
|
||||
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)",
|
||||
conf_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(auth_b64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
Some(auth_b64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build and start companion UI containers for headless services.
|
||||
// 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()
|
||||
}
|
||||
|
||||
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 {
|
||||
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
||||
}
|
||||
@@ -2092,7 +2236,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("electrs"), "electrumx");
|
||||
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
|
||||
|
||||
@@ -48,6 +48,12 @@ async fn adopt_stack_if_exists(
|
||||
.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 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(
|
||||
handler: &RpcHandler,
|
||||
stack_name: &str,
|
||||
@@ -208,10 +315,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
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?;
|
||||
}
|
||||
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)
|
||||
.await;
|
||||
|
||||
@@ -626,10 +735,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
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?;
|
||||
}
|
||||
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)
|
||||
.await;
|
||||
|
||||
@@ -901,7 +1012,8 @@ impl RpcHandler {
|
||||
let minio_pass = super::config::read_or_generate_secret("indeedhub-minio-password").await;
|
||||
|
||||
// 1. Postgres
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut postgres_cmd = tokio::process::Command::new("podman");
|
||||
postgres_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -923,12 +1035,12 @@ impl RpcHandler {
|
||||
"indeedhub-postgres-data:/var/lib/postgresql/data",
|
||||
&format!("{}/postgres:16.13-alpine", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create postgres", &mut postgres_cmd).await?;
|
||||
|
||||
// 2. Redis
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut redis_cmd = tokio::process::Command::new("podman");
|
||||
redis_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -944,12 +1056,12 @@ impl RpcHandler {
|
||||
"indeedhub-redis-data:/data",
|
||||
&format!("{}/redis:7.4.8-alpine", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create redis", &mut redis_cmd).await?;
|
||||
|
||||
// 3. MinIO
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut minio_cmd = tokio::process::Command::new("podman");
|
||||
minio_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -971,12 +1083,12 @@ impl RpcHandler {
|
||||
"server",
|
||||
"/data",
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create minio", &mut minio_cmd).await?;
|
||||
|
||||
// 4. Nostr relay
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut relay_cmd = tokio::process::Command::new("podman");
|
||||
relay_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -992,12 +1104,12 @@ impl RpcHandler {
|
||||
"indeedhub-relay-data:/usr/src/app/db",
|
||||
&format!("{}/nostr-rs-relay:0.9.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create relay", &mut relay_cmd).await?;
|
||||
|
||||
// 5. API
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut api_cmd = tokio::process::Command::new("podman");
|
||||
api_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1049,12 +1161,12 @@ impl RpcHandler {
|
||||
"ENVIRONMENT=production",
|
||||
&format!("{}/indeedhub-api:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create api", &mut api_cmd).await?;
|
||||
|
||||
// 6. FFmpeg worker
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut ffmpeg_cmd = tokio::process::Command::new("podman");
|
||||
ffmpeg_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1096,15 +1208,15 @@ impl RpcHandler {
|
||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef",
|
||||
&format!("{}/indeedhub-ffmpeg:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create ffmpeg worker", &mut ffmpeg_cmd).await?;
|
||||
|
||||
// Wait for backend services to start
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
// 7. Frontend (nginx)
|
||||
let run = tokio::process::Command::new("podman")
|
||||
let mut frontend_cmd = tokio::process::Command::new("podman");
|
||||
frontend_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1118,15 +1230,23 @@ impl RpcHandler {
|
||||
"7778:7777",
|
||||
&format!("{}/indeedhub:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to create indeedhub container")?;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create frontend", &mut frontend_cmd).await?;
|
||||
|
||||
if !run.status.success() {
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!("IndeedHub frontend failed: {}", err));
|
||||
}
|
||||
wait_for_stack_containers(
|
||||
"indeedhub",
|
||||
&[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase: WaitingHealthy → PostInstall → clear. The actual readiness
|
||||
// gate is the package scanner's next sweep; this just gives the UI a
|
||||
@@ -1136,7 +1256,8 @@ impl RpcHandler {
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::Done).await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::Done)
|
||||
.await;
|
||||
self.clear_install_progress("indeedhub").await;
|
||||
|
||||
install_log("INSTALL OK: indeedhub stack").await;
|
||||
|
||||
@@ -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"),
|
||||
@@ -50,6 +56,170 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
|
||||
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> {
|
||||
|
||||
@@ -11,6 +11,8 @@ use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -44,14 +46,14 @@ impl Default for RegistryConfig {
|
||||
Self {
|
||||
registries: vec![
|
||||
Registry {
|
||||
url: "146.59.87.168:3000/lfg2025".to_string(),
|
||||
url: OVH_REGISTRY_URL.to_string(),
|
||||
name: "Server 1 (OVH)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
},
|
||||
Registry {
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
url: TX1138_REGISTRY_URL.to_string(),
|
||||
name: "Server 2 (tx1138)".to_string(),
|
||||
tls_verify: true,
|
||||
enabled: true,
|
||||
@@ -139,6 +141,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
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 {
|
||||
// Persist so the next load doesn't have to re-merge.
|
||||
let _ = save_registries(data_dir, &config).await;
|
||||
@@ -146,6 +161,37 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
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.
|
||||
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
|
||||
let dir = data_dir.join("config");
|
||||
|
||||
@@ -163,12 +163,40 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
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 {
|
||||
let _ = save_mirrors(data_dir, &list).await;
|
||||
}
|
||||
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<()> {
|
||||
fs::create_dir_all(data_dir)
|
||||
.await
|
||||
@@ -571,15 +599,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 +1196,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");
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.49-alpha",
|
||||
"version": "1.7.51-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.49-alpha",
|
||||
"version": "1.7.51-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.49-alpha",
|
||||
"version": "1.7.51-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -83,9 +83,11 @@ const launchableApps = computed<KioskApp[]>(() => {
|
||||
const pkgs = store.data?.['package-data'] || {}
|
||||
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> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
|
||||
'lnd': '/app/lnd/',
|
||||
'mempool': '/app/mempool/',
|
||||
'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).
|
||||
* On HTTP, direct port access is used instead (faster, no proxy). */
|
||||
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/',
|
||||
'electrumx': '/app/electrumx/',
|
||||
'electrs': '/app/electrumx/',
|
||||
@@ -137,9 +134,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
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') {
|
||||
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).
|
||||
|
||||
+18
-20
@@ -1,31 +1,29 @@
|
||||
{
|
||||
"version": "1.7.44-alpha",
|
||||
"release_date": "2026-04-28",
|
||||
"version": "1.7.51-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)."
|
||||
"Install success now requires adopted containers and IndeedHub stack containers to stay running; failed starts surface logs instead of disappearing from My Apps.",
|
||||
"Bitcoin uninstall removes the shared Bitcoin data/UI directories when data is not preserved, preventing stale partial installs from being adopted as success.",
|
||||
"Bitcoin RPC bind settings are repaired on startup and before adopting existing Bitcoin containers, fixing older nodes where bitcoin-ui showed endless getblockchaininfo/502.",
|
||||
"Bitcoin Core/Knots launch the Bitcoin UI on direct port 8334 instead of the /app/bitcoin-ui path proxy.",
|
||||
"Nodes force OVH as the primary update mirror and app registry on next startup, with tx1138 retained as fallback."
|
||||
],
|
||||
"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.50-alpha",
|
||||
"new_version": "1.7.51-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.51-alpha/archipelago",
|
||||
"sha256": "f761e659d661f0a83cd3a67a086bb2279398bc05e50ee3c52e769e52d11e476c",
|
||||
"size_bytes": 41637536
|
||||
},
|
||||
{
|
||||
"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.51-alpha.tar.gz",
|
||||
"current_version": "1.7.50-alpha",
|
||||
"new_version": "1.7.51-alpha",
|
||||
"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": "3403f4e38202bf56c53407dd62e66899693ee73252bf203475715532ac6ae326",
|
||||
"size_bytes": 165155462
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user