// Docker Package Scanner // Scans docker-compose containers and converts them to package data use anyhow::Result; use archipelago_container::{ContainerRuntime as ContainerRuntimeTrait, ContainerState}; use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info}; use crate::data_model::{ Description, InstalledPackageDataEntry, InterfaceAddress, Interfaces, MainInterface, Manifest, PackageDataEntry, PackageState, ServiceStatus, StaticFiles, }; pub struct DockerPackageScanner { runtime: Arc, } impl DockerPackageScanner { pub fn new(runtime: Arc) -> Self { Self { runtime } } /// Scan Docker containers and convert to package data pub async fn scan_containers(&self) -> Result> { let containers = self.runtime.list_containers().await?; debug!("Found {} containers", containers.len()); let mut packages = HashMap::new(); // Backend services that should not appear as apps let excluded_services = [ "btcpay-db", "mempool-db", "mempool-api", "penpot-db", "penpot-backend", "penpot-redis", "bitcoin-ui", "lnd-ui", ]; for container in containers { // Only process archy-* containers from docker-compose if !container.name.starts_with("archy-") { continue; } // Extract app ID from container name (archy-bitcoin -> bitcoin) let app_id = container.name.strip_prefix("archy-") .unwrap_or(&container.name) .to_string(); // Skip backend services (databases, APIs, etc.) if excluded_services.contains(&app_id.as_str()) { debug!("Skipping backend service: {}", app_id); continue; } // Get metadata for this app let metadata = get_app_metadata(&app_id); // Extract port from container let lan_address = extract_lan_address(&container.ports); // Convert container state to package/service state let (package_state, service_status) = convert_state(&container.state); let package = PackageDataEntry { state: package_state.clone(), static_files: StaticFiles { license: "MIT".to_string(), instructions: metadata.description.clone(), icon: metadata.icon.clone(), }, manifest: Manifest { id: app_id.clone(), title: metadata.title.clone(), version: "1.0.0".to_string(), description: Description { short: metadata.description.clone(), long: metadata.description.clone(), }, release_notes: "Docker container".to_string(), license: "MIT".to_string(), wrapper_repo: metadata.repo.clone(), upstream_repo: metadata.repo.clone(), support_site: metadata.repo.clone(), marketing_site: metadata.repo.clone(), donation_url: None, author: Some("Archipelago".to_string()), website: lan_address.clone(), interfaces: if lan_address.is_some() { Some(Interfaces { main: Some(MainInterface { ui: Some("true".to_string()), tor_config: None, lan_config: None, }), }) } else { None }, }, installed: Some(InstalledPackageDataEntry { current_dependents: HashMap::new(), current_dependencies: HashMap::new(), last_backup: None, interface_addresses: if let Some(addr) = lan_address { let mut addresses = HashMap::new(); addresses.insert( "main".to_string(), InterfaceAddress { tor_address: format!("{}.onion", app_id), lan_address: Some(addr), }, ); addresses } else { HashMap::new() }, status: service_status, }), install_progress: None, }; packages.insert(app_id.clone(), package); info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state)); } Ok(packages) } } struct AppMetadata { title: String, description: String, icon: String, repo: String, } fn get_app_metadata(app_id: &str) -> AppMetadata { match app_id { "bitcoin" => AppMetadata { title: "Bitcoin Core".to_string(), description: "Full Bitcoin node implementation".to_string(), icon: "/assets/img/app-icons/bitcoin.svg".to_string(), repo: "https://github.com/bitcoin/bitcoin".to_string(), }, "btcpay" | "btcpay-server" => AppMetadata { title: "BTCPay Server".to_string(), description: "Self-hosted Bitcoin payment processor".to_string(), icon: "/assets/img/app-icons/btcpay-server.png".to_string(), repo: "https://github.com/btcpayserver/btcpayserver".to_string(), }, "homeassistant" => AppMetadata { title: "Home Assistant".to_string(), description: "Open source home automation platform".to_string(), icon: "/assets/img/app-icons/homeassistant.png".to_string(), repo: "https://github.com/home-assistant/core".to_string(), }, "grafana" => AppMetadata { title: "Grafana".to_string(), description: "Analytics and monitoring platform".to_string(), icon: "/assets/img/grafana.png".to_string(), repo: "https://github.com/grafana/grafana".to_string(), }, "endurain" => AppMetadata { title: "Endurain".to_string(), description: "Application platform".to_string(), icon: "/assets/img/endurain.png".to_string(), repo: "#".to_string(), }, "fedimint" => AppMetadata { title: "Fedimint".to_string(), description: "Federated Bitcoin mint".to_string(), icon: "/assets/img/icon-fedimint.jpeg".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(), }, "morphos" | "morphos-server" => AppMetadata { title: "MorphOS Server".to_string(), description: "Server platform".to_string(), icon: "/assets/img/morphos.png".to_string(), repo: "#".to_string(), }, "lnd" | "lightning-stack" => AppMetadata { title: "Lightning Stack".to_string(), description: "Lightning Network (LND)".to_string(), icon: "/assets/img/app-icons/lightning-stack.png".to_string(), repo: "https://github.com/lightningnetwork/lnd".to_string(), }, "mempool" | "mempool-web" => AppMetadata { title: "Mempool".to_string(), description: "Bitcoin blockchain explorer".to_string(), icon: "/assets/img/app-icons/mempool.png".to_string(), repo: "https://github.com/mempool/mempool".to_string(), }, "ollama" => AppMetadata { title: "Ollama".to_string(), description: "Run large language models locally".to_string(), icon: "/assets/img/ollama.webp".to_string(), repo: "https://github.com/ollama/ollama".to_string(), }, "searxng" => AppMetadata { title: "SearXNG".to_string(), description: "Privacy-respecting metasearch engine".to_string(), icon: "/assets/img/app-icons/searxng.png".to_string(), repo: "https://github.com/searxng/searxng".to_string(), }, "onlyoffice" | "onlyoffice-documentserver" => AppMetadata { title: "OnlyOffice".to_string(), description: "Office suite and document collaboration".to_string(), icon: "/assets/img/onlyoffice.webp".to_string(), repo: "https://github.com/ONLYOFFICE/DocumentServer".to_string(), }, "penpot" | "penpot-frontend" => AppMetadata { title: "Penpot".to_string(), description: "Open-source design and prototyping".to_string(), icon: "/assets/img/penpot.webp".to_string(), repo: "https://github.com/penpot/penpot".to_string(), }, _ => AppMetadata { title: app_id.to_string(), description: format!("{} application", app_id), icon: "/assets/img/favico.png".to_string(), repo: "#".to_string(), }, } } fn extract_lan_address(ports: &[String]) -> Option { for port_str in ports { // Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp" if let Some(public_part) = port_str.split("->").next() { if let Some(port_part) = public_part.split(':').nth(1) { // Extract just the first port if it's a range (e.g., "18443-18444" -> "18443") let single_port = port_part.split('-').next().unwrap_or(port_part); return Some(format!("http://localhost:{}", single_port)); } } } None } fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) { match container_state { ContainerState::Running => (PackageState::Running, ServiceStatus::Running), ContainerState::Stopped | ContainerState::Exited => { (PackageState::Stopped, ServiceStatus::Stopped) } ContainerState::Created => (PackageState::Starting, ServiceStatus::Starting), ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped), } } fn package_state_str(state: &PackageState) -> &str { match state { PackageState::Installing => "installing", PackageState::Installed => "installed", PackageState::Stopping => "stopping", PackageState::Stopped => "stopped", PackageState::Starting => "starting", PackageState::Running => "running", PackageState::Restarting => "restarting", PackageState::CreatingBackup => "creating-backup", PackageState::RestoringBackup => "restoring-backup", PackageState::Removing => "removing", PackageState::BackingUp => "backing-up", } }