release(v1.7.31-alpha): idempotent IndeedHub install + auto-merge default mirrors/registries + 3rd OVH update mirror
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled

- 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) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-22 03:26:09 -04:00
parent f9b44f5e2e
commit fdaa5646b2
10 changed files with 129 additions and 71 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.30-alpha" version = "1.7.31-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.30-alpha" version = "1.7.31-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"]

View File

@ -1589,10 +1589,16 @@ server {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(true); .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 { let test_url = if tls_verify {
format!("https://{}/v2/", url) format!("https://{}/v2/", host)
} else { } else {
format!("http://{}/v2/", url) format!("http://{}/v2/", host)
}; };
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
@ -1604,8 +1610,11 @@ server {
match client.get(&test_url).send().await { match client.get(&test_url).send().await {
Ok(resp) => { Ok(resp) => {
let status = resp.status().as_u16(); let status = resp.status().as_u16();
// 200 = open registry, 401 = auth required (both mean it exists) // Accept any "the server responded to HTTP" code as
let reachable = status == 200 || status == 401; // 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!({ Ok(serde_json::json!({
"url": url, "url": url,
"reachable": reachable, "reachable": reachable,

View File

@ -937,6 +937,31 @@ impl RpcHandler {
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?; .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 // Create indeedhub-net
let _ = tokio::process::Command::new("podman") let _ = tokio::process::Command::new("podman")
.args(["network", "create", "indeedhub-net"]) .args(["network", "create", "indeedhub-net"])

View File

@ -97,7 +97,12 @@ fn extract_image_name(image: &str) -> &str {
image.rsplit('/').next().unwrap_or(image) 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<RegistryConfig> { pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let path = data_dir.join(REGISTRY_FILE); let path = data_dir.join(REGISTRY_FILE);
if !path.exists() { if !path.exists() {
@ -106,8 +111,34 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.await .await
.context("Failed to read registry config")?; .context("Failed to read registry config")?;
let config: RegistryConfig = let mut config: RegistryConfig =
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default()); 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<String> =
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) Ok(config)
} }

View File

@ -68,6 +68,10 @@ const DEFAULT_UPDATE_MANIFEST_URL: &str =
/// is slow or unreachable. /// is slow or unreachable.
const DEFAULT_SECONDARY_MIRROR_URL: &str = const DEFAULT_SECONDARY_MIRROR_URL: &str =
"http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"; "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_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json"; const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
@ -97,13 +101,24 @@ fn default_mirrors() -> Vec<UpdateMirror> {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(), url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 2 (tx1138)".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 /// 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 /// 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 /// starts with the current default mirrors available without any
/// config. /// 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<Vec<UpdateMirror>> { pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let path = mirrors_path(data_dir); let path = mirrors_path(data_dir);
if !path.exists() { if !path.exists() {
@ -112,13 +127,27 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let bytes = fs::read(&path) let bytes = fs::read(&path)
.await .await
.with_context(|| format!("read {}", path.display()))?; .with_context(|| format!("read {}", path.display()))?;
let list: Vec<UpdateMirror> = let mut list: Vec<UpdateMirror> =
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?; serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
if list.is_empty() { if list.is_empty() {
Ok(default_mirrors()) return Ok(default_mirrors());
} else {
Ok(list)
} }
// Merge in any default URLs the saved config is missing.
let known: std::collections::HashSet<String> =
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<()> { 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() { async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap(); let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 2); assert_eq!(list.len(), 3);
assert!(list[0].url.contains("git.tx1138.com")); assert!(list[0].url.contains("23.182.128.160"));
assert!(list[1].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] #[tokio::test]

View File

@ -1,27 +1,28 @@
{ {
"version": "1.7.30-alpha", "version": "1.7.31-alpha",
"release_date": "2026-04-21", "release_date": "2026-04-22",
"changelog": [ "changelog": [
"App installs now show a real download progress bar — same accuracy as the system update bar. You'll see 'Downloading: 50.5 / 200.0 MB (25%)' with a live percentage instead of a generic spinner. The bar keeps streaming even when the install falls back from one registry to another, so you'll never see a 'stuck at 0%' again.", "Installing IndeedHub is now fully self-healing — if a previous install was interrupted, re-running from the App Store automatically cleans up the leftover containers and tries again instead of failing with a 'name already in use' error.",
"Uninstalls now show what's actually happening: 'Stopping containers (2/5)', 'Cleaning up volumes', 'Removing app data' — labelled per app so you can fire off multiple uninstalls in parallel and watch each one's stage on its own card.", "New default app registries and update mirrors now appear automatically on existing nodes after an update — no more needing to manually add Server 3 (OVH) from the settings page. Anything you've explicitly removed stays removed.",
"OVH (146.59.87.168) is now baked in as Server 3 by default for both updates and the app registry — extra mirror, completely independent network path so a single-provider outage can't take everything down." "Fixed the 'Test' button on registries that protect their API endpoint — it used to falsely report those registries as unreachable. It now correctly recognizes a protected-but-alive registry as reachable.",
"First-boot cleanup: removed an old IndeedHub stub from the first-boot script that used to race the main installer and occasionally leave a half-installed IndeedHub behind."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.29-alpha", "current_version": "1.7.30-alpha",
"new_version": "1.7.30-alpha", "new_version": "1.7.31-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.30-alpha/archipelago", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.31-alpha/archipelago",
"sha256": "1ec270c338f85dc9dd8de0d06f8a9a342808543859c732db8c3212c1d0eb598d", "sha256": "ce2f899d3c4b615136223ae6295ee4b5de4009d1db926f7648a788c0ad3c84b8",
"size_bytes": 40916480 "size_bytes": 40786728
}, },
{ {
"name": "archipelago-frontend-1.7.30-alpha.tar.gz", "name": "archipelago-frontend-1.7.31-alpha.tar.gz",
"current_version": "1.7.29-alpha", "current_version": "1.7.30-alpha",
"new_version": "1.7.30-alpha", "new_version": "1.7.31-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.30-alpha/archipelago-frontend-1.7.30-alpha.tar.gz", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.31-alpha/archipelago-frontend-1.7.31-alpha.tar.gz",
"sha256": "8f2a56cc08f648b1ff0c4181b0a2d19b3e3b6a599166cfe15f1eb585282bb552", "sha256": "00f474725edaf14dc41d0c02abd3afcff1b30fa50846adec9e11b3c5b2188564",
"size_bytes": 77007976 "size_bytes": 77008771
} }
] ]
} }

Binary file not shown.

View File

@ -1216,44 +1216,6 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
fi fi
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) # 9. Custom UI containers (bitcoin-ui, lnd-ui)
# These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images. # These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.