//! 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, } impl Default for RegistryConfig { fn default() -> Self { Self { registries: vec![ Registry { url: "23.182.128.160:3000/lfg2025".to_string(), name: "Server 1 (VPS)".to_string(), tls_verify: false, enabled: true, priority: 0, }, Registry { url: "git.tx1138.com/lfg2025".to_string(), name: "Server 2 (tx1138)".to_string(), tls_verify: true, enabled: true, priority: 10, }, Registry { url: "146.59.87.168:3000/lfg2025".to_string(), name: "Server 3 (OVH)".to_string(), tls_verify: false, enabled: true, priority: 20, }, ], } } } 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) } /// 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() { 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()); // 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) } /// 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(); // 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]; assert_eq!( config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary), "23.182.128.160:3000/lfg2025/bitcoin-knots:latest" ); } #[test] fn test_active_registries_sorted() { let config = RegistryConfig::default(); let active = config.active_registries(); assert_eq!(active.len(), 3); assert!(active[0].priority <= active[1].priority); assert!(active[1].priority <= active[2].priority); } #[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(), 3); } #[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(), 4); } }