Demo images / Build & push demo images (push) Failing after 2m22s
Replaces the registry host across 86 files: 309 references, covering all 40 app manifests, the orchestrator and container crates, the release and catalog scripts, both demo-images workflows, the ISO builder, demo-deploy, and the frontend marketplace data. Verified the domain actually serves the registry before rewriting anything, rather than assuming the web host implies the registry: - TLS verifies clean, HTTP/2 on the web root - an anonymous token grants a manifest fetch (HTTP 200) with no credentials - skopeo inspect --no-creds resolves an image and lists its tags That last check is the one that matters: an outside developer with no account can now pull, which was the functional blocker for publishing at all. Plain-HTTP references become HTTPS in the same pass, so OTA downloads stop crossing the network in the clear. Deliberately NOT rewritten: - The public FIPS anchor on port 8444. It is a functional network endpoint every node dials to bootstrap the mesh — closer to Bitcoin Core's hardcoded seeds than to leaked infrastructure. The domain does resolve to the same host, so it could become a hostname, but that adds a DNS dependency to the path used precisely when things are broken. Worth a deliberate decision, not a side effect of this change. - The companion APK on port 2100. The domain returns 404 for that path, so rewriting it would swap a working URL for a broken one. The Releases page does serve (200), which is where the plan already wants those binaries. - releases/app-catalog.json, releases/manifest.json and release-manifest.json. These carry `signature` and `signed_by`; editing their contents invalidates the signature and the fleet refuses artifacts that fail verification. They were rewritten in a first pass and reverted — they must be regenerated and re-signed through the signing ceremony instead, which needs the mnemonic. So the catalog still advertises the old host until that ceremony runs. Nodes resolve images through the signed catalog, not the on-disk manifests, so this commit alone does not change what a node pulls. Verified: archipelago-container 75/75; every manifest still parses with a top-level app block; no signed artifact modified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
304 lines
11 KiB
Rust
304 lines
11 KiB
Rust
//! Dynamic container registry configuration.
|
|
//!
|
|
//! Manages a list of container registries that the node uses to pull app images.
|
|
//! Registries are tried in order — if the first fails, the next is attempted.
|
|
//! Configuration is persisted to disk and editable via RPC.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
use tokio::fs;
|
|
|
|
const REGISTRY_FILE: &str = "config/registries.json";
|
|
const OVH_REGISTRY_URL: &str = "source.archipelago-foundation.org/lfg2025";
|
|
/// Retired registry host (release server retired 2026-06-13; the registry
|
|
/// frontend was fully dead by 2026-07-10 — 500 on every /v2 manifest read).
|
|
/// Never a default, never force-enabled; stripped from saved configs on
|
|
/// load. The literal exists ONLY so the strip can match — nothing may pull
|
|
/// through this host.
|
|
const RETIRED_TX1138_HOST: &str = "git.tx1138.com";
|
|
|
|
/// A single container registry.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Registry {
|
|
/// Registry URL (e.g., "source.archipelago-foundation.org/lfg2025").
|
|
pub url: String,
|
|
/// Human-readable name.
|
|
pub name: String,
|
|
/// Whether TLS verification is required (false for HTTP registries).
|
|
pub tls_verify: bool,
|
|
/// Whether this registry is enabled.
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
/// Priority (lower = tried first).
|
|
#[serde(default)]
|
|
pub priority: u32,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Registry configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RegistryConfig {
|
|
pub registries: Vec<Registry>,
|
|
}
|
|
|
|
impl Default for RegistryConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
registries: vec![Registry {
|
|
url: OVH_REGISTRY_URL.to_string(),
|
|
name: "Server 1 (OVH)".to_string(),
|
|
tls_verify: false,
|
|
enabled: true,
|
|
priority: 0,
|
|
}],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RegistryConfig {
|
|
/// Get enabled registries sorted by priority.
|
|
pub fn active_registries(&self) -> Vec<&Registry> {
|
|
let mut regs: Vec<&Registry> = self.registries.iter().filter(|r| r.enabled).collect();
|
|
regs.sort_by_key(|r| r.priority);
|
|
regs
|
|
}
|
|
|
|
/// Rewrite an image reference to use a specific registry.
|
|
/// E.g., "docker.io/lfg2025/bitcoin-knots:latest" with registry "source.archipelago-foundation.org/lfg2025"
|
|
/// becomes "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest".
|
|
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
|
|
// Extract the image name (last component after the org/namespace)
|
|
// Handles: "registry/org/image:tag" -> "image:tag"
|
|
let image_name = extract_image_name(image);
|
|
format!("{}/{}", registry.url, image_name)
|
|
}
|
|
}
|
|
|
|
/// Extract the image name from a full image reference.
|
|
/// "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest"
|
|
/// "docker.io/gitea/gitea:1.23" -> "gitea:1.23"
|
|
fn extract_image_name(image: &str) -> &str {
|
|
// Split by '/' and take the last segment (image:tag)
|
|
image.rsplit('/').next().unwrap_or(image)
|
|
}
|
|
|
|
/// 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() {
|
|
return Ok(RegistryConfig::default());
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.await
|
|
.context("Failed to read registry config")?;
|
|
let mut config: RegistryConfig =
|
|
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
|
|
|
|
// One-time migration: the Hetzner VPS at 23.182.128.160 was
|
|
// decommissioned 2026-04-23. Existing nodes have it baked into
|
|
// their saved registry list (was the original Server 1). Strip it
|
|
// on load so every container pull doesn't pay a connection-refused
|
|
// timeout against a dead host. Exception to the usual "explicit
|
|
// removals stick" rule: the user never chose to add this — it
|
|
// was a default.
|
|
let before = config.registries.len();
|
|
config
|
|
.registries
|
|
.retain(|r| !r.url.contains("23.182.128.160"));
|
|
// Same treatment for the retired tx1138 registry (was Server 2 in older
|
|
// defaults): strip it on load so nothing ever pulls through the dead
|
|
// host again.
|
|
config
|
|
.registries
|
|
.retain(|r| !r.url.contains(RETIRED_TX1138_HOST));
|
|
let mut changed = config.registries.len() != before;
|
|
|
|
// 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);
|
|
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);
|
|
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.
|
|
if let Err(e) = save_registries(data_dir, &config).await {
|
|
tracing::warn!("Failed to persist migrated registry config: {e:#}");
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
_ => {
|
|
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");
|
|
fs::create_dir_all(&dir)
|
|
.await
|
|
.context("Failed to create config dir")?;
|
|
let path = data_dir.join(REGISTRY_FILE);
|
|
let content =
|
|
serde_json::to_string_pretty(config).context("Failed to serialize registry config")?;
|
|
fs::write(&path, content)
|
|
.await
|
|
.context("Failed to write registry config")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_extract_image_name() {
|
|
assert_eq!(
|
|
extract_image_name("source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest"),
|
|
"bitcoin-knots:latest"
|
|
);
|
|
assert_eq!(
|
|
extract_image_name("docker.io/gitea/gitea:1.23"),
|
|
"gitea:1.23"
|
|
);
|
|
assert_eq!(extract_image_name("localhost/myimage:v1"), "myimage:v1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_image() {
|
|
let config = RegistryConfig::default();
|
|
// An image hardcoded to some other registry rewrites to OVH when
|
|
// asked for the primary mirror.
|
|
let primary = &config.registries[0];
|
|
assert_eq!(
|
|
config.rewrite_image("docker.io/lfg2025/bitcoin-knots:latest", primary),
|
|
"source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_active_registries_sorted() {
|
|
let config = RegistryConfig::default();
|
|
let active = config.active_registries();
|
|
assert_eq!(active.len(), 1);
|
|
assert_eq!(active[0].url, OVH_REGISTRY_URL);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_default() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = load_registries(tmp.path()).await.unwrap();
|
|
assert_eq!(config.registries.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_strips_retired_tx1138_registry() {
|
|
// Nodes provisioned before the retirement have the tx1138 registry
|
|
// baked into their saved config (was Server 2). It must be stripped
|
|
// on load and never re-added by the defaults merge.
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = RegistryConfig {
|
|
registries: vec![
|
|
Registry {
|
|
url: format!("{RETIRED_TX1138_HOST}/lfg2025"),
|
|
name: "Server 2 (tx1138)".into(),
|
|
tls_verify: true,
|
|
enabled: true,
|
|
priority: 10,
|
|
},
|
|
Registry {
|
|
url: OVH_REGISTRY_URL.into(),
|
|
name: "Server 1 (OVH)".into(),
|
|
tls_verify: false,
|
|
enabled: true,
|
|
priority: 0,
|
|
},
|
|
],
|
|
};
|
|
save_registries(tmp.path(), &config).await.unwrap();
|
|
let loaded = load_registries(tmp.path()).await.unwrap();
|
|
assert!(
|
|
!loaded
|
|
.registries
|
|
.iter()
|
|
.any(|r| r.url.contains(RETIRED_TX1138_HOST)),
|
|
"retired tx1138 registry must be stripped on load; got {:?}",
|
|
loaded.registries
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_save_load_roundtrip() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = RegistryConfig::default();
|
|
config.registries.push(Registry {
|
|
url: "myregistry.com/apps".into(),
|
|
name: "Custom".into(),
|
|
tls_verify: true,
|
|
enabled: true,
|
|
priority: 5,
|
|
});
|
|
save_registries(tmp.path(), &config).await.unwrap();
|
|
let loaded = load_registries(tmp.path()).await.unwrap();
|
|
assert_eq!(loaded.registries.len(), 2);
|
|
}
|
|
}
|