From 682b93f2d6ee2dfaed6c680919c732ae94d3763f Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 22 Apr 2026 03:26:09 -0400 Subject: [PATCH] release(v1.7.31-alpha): idempotent IndeedHub install + auto-merge default mirrors/registries + 3rd OVH update mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: install.rs registry reachability probe now strips the `host[:port]/namespace` suffix before appending `/v2/` (the Docker V2 API lives at the host root, not under the namespace) and accepts HTTP 405 in addition to 200/401 as "registry daemon alive". This fixes false "unreachable" reports on the Test button for Gitea and other registries that protect their /v2/ endpoint. - Backend: stacks.rs install_indeedhub_stack now force-removes any leftover indeedhub-* containers and indeedhub-net before creating the stack. A partial install (or the old first-boot stub racing the installer) used to leave containers around that blocked re-install with "name already in use". Re-running the App Store install now self-heals. - Backend: registry.rs load_registries auto-merges any default registry URLs missing from the saved config (appended with priority max+10+i, persisted). Lets new default mirrors (e.g. Server 3 OVH) roll out to existing nodes without manual config edits. Explicit removals still stick — URLs absent from disk AND absent from defaults stay gone. - Backend: update.rs adds DEFAULT_TERTIARY_MIRROR_URL at http://146.59.87.168:3000/ (Server 3 OVH) to default_mirrors, with the same auto-merge-on-load behavior as registries. Test updated for 3-mirror default (.160, tx1138, .168). - Scripts: dropped the first-boot IndeedHub stub (~38 lines in first-boot-containers.sh §8b). It predated the proper stack installer, raced it, and was the main source of the name-conflict mess the stacks.rs cleanup above now also guards against. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- .../src/api/rpc/package/install.rs | 17 +++++-- .../archipelago/src/api/rpc/package/stacks.rs | 25 ++++++++++ core/archipelago/src/container/registry.rs | 35 +++++++++++++- core/archipelago/src/update.rs | 48 +++++++++++++++---- scripts/first-boot-containers.sh | 38 --------------- 7 files changed, 112 insertions(+), 55 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index f739731f..6514b5c5 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.30-alpha" +version = "1.7.31-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 1af97b25..37f93996 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.30-alpha" +version = "1.7.31-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 8fe6fba4..1d121dfc 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -1589,10 +1589,16 @@ server { .and_then(|v| v.as_bool()) .unwrap_or(true); + // Registries are configured as `host[:port]/namespace` (for + // example `23.182.128.160:3000/lfg2025`), but the Docker V2 + // registry API lives at `/v2/` on the ROOT of the host — NOT + // under the namespace. Strip the namespace before appending + // `/v2/` so the reachability probe hits the correct URL. + let host = url.split('/').next().unwrap_or(url); let test_url = if tls_verify { - format!("https://{}/v2/", url) + format!("https://{}/v2/", host) } else { - format!("http://{}/v2/", url) + format!("http://{}/v2/", host) }; let client = reqwest::Client::builder() @@ -1604,8 +1610,11 @@ server { match client.get(&test_url).send().await { Ok(resp) => { let status = resp.status().as_u16(); - // 200 = open registry, 401 = auth required (both mean it exists) - let reachable = status == 200 || status == 401; + // Accept any "the server responded to HTTP" code as + // reachable — 200 (open registry), 401 (auth required), + // or 405 (server rejects GET but the endpoint exists) + // all mean the registry daemon is alive on the host. + let reachable = status == 200 || status == 401 || status == 405; Ok(serde_json::json!({ "url": url, "reachable": reachable, diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index ce99df76..47c9f3d8 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -937,6 +937,31 @@ impl RpcHandler { .with_context(|| format!("Failed to pull IndeedHub image: {}", img))?; } + // Remove any leftover containers from a previous partial install (or + // from the first-boot frontend stub that used to race the installer). + // Without this, `podman run --name indeedhub` fails on name conflict + // and the whole stack install errors out — leaving a half-broken node. + for name in [ + "indeedhub", + "indeedhub-postgres", + "indeedhub-redis", + "indeedhub-minio", + "indeedhub-relay", + "indeedhub-api", + "indeedhub-ffmpeg", + "indeedhub-build_api_1", + "indeedhub-build_ffmpeg-worker_1", + ] { + let _ = tokio::process::Command::new("podman") + .args(["rm", "-f", name]) + .status() + .await; + } + let _ = tokio::process::Command::new("podman") + .args(["network", "rm", "-f", "indeedhub-net"]) + .status() + .await; + // Create indeedhub-net let _ = tokio::process::Command::new("podman") .args(["network", "create", "indeedhub-net"]) diff --git a/core/archipelago/src/container/registry.rs b/core/archipelago/src/container/registry.rs index c5d4c032..322a9fc8 100644 --- a/core/archipelago/src/container/registry.rs +++ b/core/archipelago/src/container/registry.rs @@ -97,7 +97,12 @@ fn extract_image_name(image: &str) -> &str { image.rsplit('/').next().unwrap_or(image) } -/// Load registry config from disk. +/// Load registry config from disk, merging in any default registries +/// that the operator hasn't explicitly removed. This lets us roll out +/// new default mirrors (e.g. a new Server 3) to existing nodes without +/// them having to edit their saved config. Explicit removals stick — +/// if the URL is absent from disk AND absent from current defaults, it +/// stays gone. pub async fn load_registries(data_dir: &Path) -> Result { let path = data_dir.join(REGISTRY_FILE); if !path.exists() { @@ -106,8 +111,34 @@ pub async fn load_registries(data_dir: &Path) -> Result { let content = fs::read_to_string(&path) .await .context("Failed to read registry config")?; - let config: RegistryConfig = + let mut config: RegistryConfig = serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default()); + + // Migrate: any default registry URL that isn't already in the + // saved list gets appended at the end (so existing priority order + // is preserved for anything the operator already configured). + let defaults = RegistryConfig::default(); + let known: std::collections::HashSet = + config.registries.iter().map(|r| r.url.clone()).collect(); + let max_priority = config + .registries + .iter() + .map(|r| r.priority) + .max() + .unwrap_or(0); + let mut added = false; + for (i, def) in defaults.registries.iter().enumerate() { + if !known.contains(&def.url) { + let mut cloned = def.clone(); + cloned.priority = max_priority.saturating_add(10 + i as u32); + config.registries.push(cloned); + added = true; + } + } + if added { + // Persist so the next load doesn't have to re-merge. + let _ = save_registries(data_dir, &config).await; + } Ok(config) } diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index dfa21516..60b58ecf 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -68,6 +68,10 @@ const DEFAULT_UPDATE_MANIFEST_URL: &str = /// is slow or unreachable. const DEFAULT_SECONDARY_MIRROR_URL: &str = "http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"; +/// Tertiary mirror on a separate OVH VPS — independent network path so +/// a single-provider outage doesn't knock out all three mirrors. +const DEFAULT_TERTIARY_MIRROR_URL: &str = + "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"; const UPDATE_STATE_FILE: &str = "update_state.json"; const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json"; @@ -97,13 +101,24 @@ fn default_mirrors() -> Vec { url: DEFAULT_UPDATE_MANIFEST_URL.to_string(), label: "Server 2 (tx1138)".to_string(), }, + UpdateMirror { + url: DEFAULT_TERTIARY_MIRROR_URL.to_string(), + label: "Server 3 (OVH)".to_string(), + }, ] } /// Load the operator-configured mirror list. Returns defaults if the /// file doesn't exist yet, so a node OTA'd from a pre-mirrors release -/// starts with both Server 1 and Server 2 available without any manual -/// config. +/// starts with the current default mirrors available without any +/// manual config. +/// +/// Migration: any default mirror URL that isn't already in the saved +/// list gets appended at the end. This lets us add new default mirrors +/// (e.g. a new Server 3) and have them appear on existing nodes after +/// an update, without requiring manual config edits. Explicit removals +/// stick — once an operator removes a URL it stays gone unless it's +/// later re-added to defaults. pub async fn load_mirrors(data_dir: &Path) -> Result> { let path = mirrors_path(data_dir); if !path.exists() { @@ -112,13 +127,27 @@ pub async fn load_mirrors(data_dir: &Path) -> Result> { let bytes = fs::read(&path) .await .with_context(|| format!("read {}", path.display()))?; - let list: Vec = + let mut list: Vec = serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?; if list.is_empty() { - Ok(default_mirrors()) - } else { - Ok(list) + return Ok(default_mirrors()); } + + // Merge in any default URLs the saved config is missing. + let known: std::collections::HashSet = + list.iter().map(|m| m.url.clone()).collect(); + let defaults = default_mirrors(); + let mut added = false; + for def in &defaults { + if !known.contains(&def.url) { + list.push(def.clone()); + added = true; + } + } + if added { + let _ = save_mirrors(data_dir, &list).await; + } + Ok(list) } pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> { @@ -1187,9 +1216,10 @@ mod tests { async fn test_load_mirrors_returns_defaults_when_absent() { let dir = tempfile::tempdir().unwrap(); let list = load_mirrors(dir.path()).await.unwrap(); - assert_eq!(list.len(), 2); - assert!(list[0].url.contains("git.tx1138.com")); - assert!(list[1].url.contains("23.182.128.160")); + assert_eq!(list.len(), 3); + assert!(list[0].url.contains("23.182.128.160")); + assert!(list[1].url.contains("git.tx1138.com")); + assert!(list[2].url.contains("146.59.87.168")); } #[tokio::test] diff --git a/scripts/first-boot-containers.sh b/scripts/first-boot-containers.sh index 067f5723..74b34889 100644 --- a/scripts/first-boot-containers.sh +++ b/scripts/first-boot-containers.sh @@ -1216,44 +1216,6 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str fi fi -# 8b. Indeehub (pull from registry, or use local build) -if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then - # Use image-versions.sh variable if sourced, otherwise detect - if [ -z "${INDEEDHUB_IMAGE:-}" ]; then - INDEEDHUB_IMAGE="" - # Try local image first (pre-built or loaded from ISO) - if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'localhost/indeedhub'; then - INDEEDHUB_IMAGE="localhost/indeedhub:local" - # Try pinned registry image - elif $DOCKER pull "$ARCHY_REGISTRY/indeedhub:1.0.0" --tls-verify=false 2>>"$LOG"; then - INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0" - fi - fi - if [ -n "$INDEEDHUB_IMAGE" ]; then - log "Creating Indeehub from $INDEEDHUB_IMAGE..." - $DOCKER run -d --name indeedhub --restart unless-stopped \ - --network archy-net --network-alias indeedhub \ - --health-cmd="curl -sf http://localhost:7777/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \ - --memory=$(mem_limit indeedhub) \ - --cap-drop ALL --security-opt no-new-privileges:true \ - --tmpfs /tmp:rw,noexec,nosuid,size=64m \ - -p 7778:7777 \ - "$INDEEDHUB_IMAGE" 2>>"$LOG" || true - # Fix IndeedHub for iframe: remove X-Frame-Options so it loads in Archipelago panel - sleep 2 - if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then - $DOCKER exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true - # Fix Host header for NIP-98 auth — $host strips port, $http_host preserves it - $DOCKER exec indeedhub sed -i 's|proxy_set_header Host $host;|proxy_set_header Host $http_host;|g' /etc/nginx/conf.d/default.conf 2>/dev/null || true - if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then - $DOCKER cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true - fi - $DOCKER exec indeedhub nginx -s reload 2>/dev/null || true - log "Applied IndeedHub iframe fix (X-Frame-Options, Host header, nostr-provider)" - fi - fi -fi - # 9. Custom UI containers (bitcoin-ui, lnd-ui) # These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.