//! 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: "Archipelago Primary".to_string(), tls_verify: false, enabled: true, priority: 0, }, Registry { url: "git.tx1138.com/lfg2025".to_string(), name: "Archipelago Legacy".to_string(), tls_verify: true, enabled: true, priority: 10, }, ], } } } 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) } /// Generate all image URLs to try for a given image, in priority order. pub fn image_candidates(&self, image: &str) -> Vec<(String, bool)> { let mut candidates = Vec::new(); // First: the original image as-is candidates.push((image.to_string(), true)); // Then: rewritten for each active registry for reg in self.active_registries() { let rewritten = self.rewrite_image(image, reg); if rewritten != image { candidates.push((rewritten, reg.tls_verify)); } } candidates } } /// 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. 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 config: RegistryConfig = serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default()); 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(()) } /// Try pulling an image from configured registries in priority order. /// Returns the image reference that succeeded. pub async fn pull_from_registries( data_dir: &Path, image: &str, tmpdir: &str, ) -> Result { let config = load_registries(data_dir).await?; let candidates = config.image_candidates(image); for (candidate, tls_verify) in &candidates { debug!("Trying registry: {}", candidate); let mut args = vec!["pull".to_string(), candidate.clone()]; if !tls_verify { args.push("--tls-verify=false".to_string()); } let status = tokio::process::Command::new("podman") .args(&args) .env("TMPDIR", tmpdir) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await; if status.map(|s| s.success()).unwrap_or(false) { // If we pulled from a non-original registry, tag it with the original name if candidate != image { let _ = tokio::process::Command::new("podman") .args(["tag", candidate, image]) .status() .await; info!("Pulled {} from fallback registry, tagged as {}", candidate, image); } else { info!("Pulled {} from primary registry", image); } return Ok(candidate.clone()); } debug!("Failed to pull from {}", candidate); } Err(anyhow::anyhow!( "Failed to pull {} from all {} configured registries", image, candidates.len() )) } #[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(); let fallback = &config.registries[1]; assert_eq!( config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback), "23.182.128.160:3000/lfg2025/bitcoin-knots:latest" ); } #[test] fn test_image_candidates() { let config = RegistryConfig::default(); let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); assert!(candidates.len() >= 2); assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); } #[test] fn test_active_registries_sorted() { let config = RegistryConfig::default(); let active = config.active_registries(); assert_eq!(active.len(), 2); assert!(active[0].priority <= active[1].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(), 2); } #[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(), 3); } }