2026-04-12 08:09:14 -04:00
|
|
|
//! 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;
|
|
|
|
|
use tracing::{debug, info};
|
|
|
|
|
|
|
|
|
|
const REGISTRY_FILE: &str = "config/registries.json";
|
|
|
|
|
|
|
|
|
|
/// A single container registry.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct Registry {
|
|
|
|
|
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "23.182.128.160:3000/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 {
|
release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
that mirrors the update-mirrors experience: list of configured
registries, test reachability, set primary, add/remove. New
registry.set-primary RPC; existing registry.{list,add,remove,test}
reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
URL before attempting it. Before this, installs always hit whichever
registry the image was hardcoded to, so changing the primary didn't
actually affect where images came from. On failure, the existing
fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
(matches the screensaver composition). Extracted the logo-wrapper
pattern inline.
7/7 registry tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
|
|
|
url: "23.182.128.160:3000/lfg2025".to_string(),
|
|
|
|
|
name: "Server 1 (VPS)".to_string(),
|
|
|
|
|
tls_verify: false,
|
2026-04-12 08:09:14 -04:00
|
|
|
enabled: true,
|
|
|
|
|
priority: 0,
|
|
|
|
|
},
|
|
|
|
|
Registry {
|
release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
that mirrors the update-mirrors experience: list of configured
registries, test reachability, set primary, add/remove. New
registry.set-primary RPC; existing registry.{list,add,remove,test}
reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
URL before attempting it. Before this, installs always hit whichever
registry the image was hardcoded to, so changing the primary didn't
actually affect where images came from. On failure, the existing
fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
(matches the screensaver composition). Extracted the logo-wrapper
pattern inline.
7/7 registry tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
|
|
|
url: "git.tx1138.com/lfg2025".to_string(),
|
|
|
|
|
name: "Server 2 (tx1138)".to_string(),
|
|
|
|
|
tls_verify: true,
|
2026-04-12 08:09:14 -04:00
|
|
|
enabled: true,
|
|
|
|
|
priority: 10,
|
|
|
|
|
},
|
2026-04-21 19:11:36 -04:00
|
|
|
Registry {
|
|
|
|
|
url: "146.59.87.168:3000/lfg2025".to_string(),
|
|
|
|
|
name: "Server 3 (OVH)".to_string(),
|
|
|
|
|
tls_verify: false,
|
|
|
|
|
enabled: true,
|
|
|
|
|
priority: 20,
|
|
|
|
|
},
|
2026-04-12 08:09:14 -04:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "23.182.128.160:3000/lfg2025"
|
|
|
|
|
/// becomes "23.182.128.160:3000/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.
|
|
|
|
|
/// "git.tx1138.com/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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 03:26:09 -04:00
|
|
|
/// 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.
|
2026-04-12 08:09:14 -04:00
|
|
|
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")?;
|
2026-04-22 03:26:09 -04:00
|
|
|
let mut config: RegistryConfig =
|
2026-04-12 08:09:14 -04:00
|
|
|
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
|
2026-04-22 03:26:09 -04:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-04-12 08:09:14 -04:00
|
|
|
Ok(config)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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("git.tx1138.com/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();
|
release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
that mirrors the update-mirrors experience: list of configured
registries, test reachability, set primary, add/remove. New
registry.set-primary RPC; existing registry.{list,add,remove,test}
reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
URL before attempting it. Before this, installs always hit whichever
registry the image was hardcoded to, so changing the primary didn't
actually affect where images came from. On failure, the existing
fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
(matches the screensaver composition). Extracted the logo-wrapper
pattern inline.
7/7 registry tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
|
|
|
// Default primary is now VPS (index 0). A tx1138-hardcoded image
|
|
|
|
|
// rewrites to VPS when asked for the primary mirror.
|
|
|
|
|
let primary = &config.registries[0];
|
2026-04-12 08:09:14 -04:00
|
|
|
assert_eq!(
|
release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
that mirrors the update-mirrors experience: list of configured
registries, test reachability, set primary, add/remove. New
registry.set-primary RPC; existing registry.{list,add,remove,test}
reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
URL before attempting it. Before this, installs always hit whichever
registry the image was hardcoded to, so changing the primary didn't
actually affect where images came from. On failure, the existing
fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
(matches the screensaver composition). Extracted the logo-wrapper
pattern inline.
7/7 registry tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
|
|
|
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
|
2026-04-12 08:09:14 -04:00
|
|
|
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_active_registries_sorted() {
|
|
|
|
|
let config = RegistryConfig::default();
|
|
|
|
|
let active = config.active_registries();
|
2026-04-21 19:11:36 -04:00
|
|
|
assert_eq!(active.len(), 3);
|
2026-04-12 08:09:14 -04:00
|
|
|
assert!(active[0].priority <= active[1].priority);
|
2026-04-21 19:11:36 -04:00
|
|
|
assert!(active[1].priority <= active[2].priority);
|
2026-04-12 08:09:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_load_default() {
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
let config = load_registries(tmp.path()).await.unwrap();
|
2026-04-21 19:11:36 -04:00
|
|
|
assert_eq!(config.registries.len(), 3);
|
2026-04-12 08:09:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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();
|
2026-04-21 19:11:36 -04:00
|
|
|
assert_eq!(loaded.registries.len(), 4);
|
2026-04-12 08:09:14 -04:00
|
|
|
}
|
|
|
|
|
}
|