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]]
name = "archipelago"
version = "1.7.30-alpha"
version = "1.7.31-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -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"]

View File

@ -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,

View File

@ -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"])

View File

@ -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<RegistryConfig> {
let path = data_dir.join(REGISTRY_FILE);
if !path.exists() {
@ -106,8 +111,34 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
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<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)
}

View File

@ -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<UpdateMirror> {
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<Vec<UpdateMirror>> {
let path = mirrors_path(data_dir);
if !path.exists() {
@ -112,13 +127,27 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let bytes = fs::read(&path)
.await
.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()))?;
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<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<()> {
@ -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]

View File

@ -1,27 +1,28 @@
{
"version": "1.7.30-alpha",
"release_date": "2026-04-21",
"version": "1.7.31-alpha",
"release_date": "2026-04-22",
"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.",
"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.",
"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."
"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.",
"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.",
"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": [
{
"name": "archipelago",
"current_version": "1.7.29-alpha",
"new_version": "1.7.30-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.30-alpha/archipelago",
"sha256": "1ec270c338f85dc9dd8de0d06f8a9a342808543859c732db8c3212c1d0eb598d",
"size_bytes": 40916480
"current_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.31-alpha/archipelago",
"sha256": "ce2f899d3c4b615136223ae6295ee4b5de4009d1db926f7648a788c0ad3c84b8",
"size_bytes": 40786728
},
{
"name": "archipelago-frontend-1.7.30-alpha.tar.gz",
"current_version": "1.7.29-alpha",
"new_version": "1.7.30-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",
"sha256": "8f2a56cc08f648b1ff0c4181b0a2d19b3e3b6a599166cfe15f1eb585282bb552",
"size_bytes": 77007976
"name": "archipelago-frontend-1.7.31-alpha.tar.gz",
"current_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.31-alpha/archipelago-frontend-1.7.31-alpha.tar.gz",
"sha256": "00f474725edaf14dc41d0c02abd3afcff1b30fa50846adec9e11b3c5b2188564",
"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
# 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.