// Docker Package Scanner // Scans docker-compose containers and converts them to package data use anyhow::Result; use archipelago_container::{ ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient, }; use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info}; use super::image_versions; 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", "nbxplorer", "mempool-db", "mempool-api", "immich_postgres", "immich_redis", "endurain-db", "nextcloud-db", "indeedhub-api", "indeedhub-ffmpeg", "indeedhub-postgres", "indeedhub-redis", "indeedhub-minio", "indeedhub-relay", "indeedhub-build_api_1", "indeedhub-build_postgres_1", "indeedhub-build_redis_1", "indeedhub-build_minio_1", "indeedhub-build_minio-init_1", "indeedhub-build_relay_1", "indeedhub-build_ffmpeg-worker_1", "netbird-server", "netbird-dashboard", "buildx_buildkit_default", ]; // First pass: collect running UI containers. Custom UI-backed apps must // not advertise a launch URL unless their companion is actually alive. let mut ui_containers: HashMap = HashMap::new(); for container in &containers { if container.name.ends_with("-ui") { if !matches!(container.state, ContainerState::Running) { continue; } // Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup) let parent_app = container .name .strip_suffix("-ui") .unwrap_or(&container.name); let canonical_id = parent_app .strip_prefix("archy-") .unwrap_or(parent_app) .to_string(); let ui_address = extract_lan_address(&container.ports) .or_else(|| companion_lan_address(&canonical_id)); if let Some(ui_address) = ui_address { ui_containers.insert(canonical_id, ui_address); } } } debug!("Found {} UI containers", ui_containers.len()); for container in containers { // Extract app ID from container name // Support both archy-* containers (docker-compose) and plain names (manual) let app_id = if container.name.starts_with("archy-") { container .name .strip_prefix("archy-") .unwrap_or(&container.name) .to_string() } else { // Use the container name as-is for manually started containers container.name.clone() }; // Normalize multi-container app IDs to their canonical names let app_id = match app_id.as_str() { "immich_server" => "immich".to_string(), _ => app_id, }; // Skip backend services (databases, APIs, etc.) if excluded_services.contains(&app_id.as_str()) { debug!("Skipping backend service: {}", app_id); continue; } if is_transient_podman_helper(&app_id, &container.ports) { debug!("Skipping transient Podman helper container: {}", app_id); continue; } // Skip podman-compose infrastructure containers (e.g. indeedhub-build_api_1) // These have the project prefix pattern: {project}_{service}_{instance} if app_id.starts_with("indeedhub-build_") { debug!("Skipping IndeedHub compose service: {}", app_id); continue; } if app_id.starts_with("buildx_buildkit") { debug!("Skipping BuildKit helper container: {}", app_id); continue; } // Skip UI containers (they're merged with their parent apps) if app_id.ends_with("-ui") { debug!("Skipping UI container: {}", app_id); continue; } // Get metadata for this app let metadata = get_app_metadata(&app_id); // Resolve UI address: separate UI containers > static map > dynamic ports let lan_address = if app_id == "netbird" { reachable_lan_address(&app_id, netbird_configured_launch_url().await).await } else if let Some(ui_address) = ui_containers.get(&app_id) { // Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui) debug!("Using UI container for {}: {}", app_id, ui_address); reachable_lan_address(&app_id, Some(ui_address.clone())).await } else { // Prefer the known web UI port over arbitrary first binding // (for example Gitea exposes SSH on 2222 before web on 3001). let candidate = if uses_allocated_launch_port(&app_id) { extract_lan_address(&container.ports) .or_else(|| PodmanClient::lan_address_for(&app_id)) } else { PodmanClient::lan_address_for(&app_id) .or_else(|| extract_lan_address(&container.ports)) }; reachable_lan_address(&app_id, candidate).await }; debug!( "Container {}: ports={:?}, lan_address={:?}", app_id, container.ports, lan_address ); // Convert container state to package/service state let (package_state, service_status) = convert_state(&container.state); let tor_address = read_tor_address(&app_id).await; // Extract actual version from container image tag let running_version = image_versions::extract_version_from_image(&container.image); let available_update = image_versions::available_update_for_app(&app_id, &container.image); let package = PackageDataEntry { state: package_state.clone(), health: container.health.clone(), exit_code: if package_state == PackageState::Exited { container.exit_code } else { None }, 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: running_version, 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(), tier: Some(metadata.tier.to_string()), interfaces: if lan_address.is_some() || tor_address.is_some() { Some(Interfaces { main: Some(MainInterface { ui: Some("true".to_string()), tor_config: tor_address.clone(), lan_config: None, }), }) } else { None }, }, available_update, installed: Some(InstalledPackageDataEntry { current_dependents: HashMap::new(), current_dependencies: HashMap::new(), last_backup: None, interface_addresses: if lan_address.is_some() || tor_address.is_some() { let mut addresses = HashMap::new(); // Only include tor_address if we have a real v3 .onion (not placeholder) let tor = tor_address .filter(|s| is_real_onion_address(s)) .unwrap_or_default(); addresses.insert( "main".to_string(), InterfaceAddress { tor_address: tor, lan_address, }, ); addresses } else { HashMap::new() }, status: service_status, }), install_progress: None, uninstall_stage: 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, tier: &'static str, } /// Get the app tier: "core", "recommended", or "optional". fn get_app_tier(app_id: &str) -> &'static str { match app_id { // Core: required for basic Bitcoin node "bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core", "lnd" => "core", "mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => { "core" } "btcpay" | "btcpay-server" | "btcpayserver" => "core", "dwn" => "core", "filebrowser" => "core", // Recommended: enhanced functionality "fedimint" | "fedimint-gateway" => "recommended", "vaultwarden" => "recommended", "uptime-kuma" => "recommended", "grafana" => "recommended", "searxng" => "recommended", "tailscale" | "netbird" => "recommended", "portainer" => "recommended", // Optional: everything else _ => "optional", } } fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool { if !ports.is_empty() { return false; } let Some((left, right)) = app_id.split_once('_') else { return false; }; !left.is_empty() && !right.is_empty() && left.chars().all(|c| c.is_ascii_lowercase()) && right.chars().all(|c| c.is_ascii_lowercase()) } fn get_app_metadata(app_id: &str) -> AppMetadata { let mut meta = match app_id { "bitcoin-core" => AppMetadata { title: "Bitcoin Core".to_string(), description: "Reference Bitcoin node implementation".to_string(), icon: "/assets/img/app-icons/bitcoin-core.svg".to_string(), repo: "https://github.com/bitcoin/bitcoin".to_string(), tier: "", }, "bitcoin" | "bitcoin-knots" => AppMetadata { title: "Bitcoin Knots".to_string(), description: "Enhanced Bitcoin node implementation".to_string(), icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(), repo: "https://github.com/bitcoinknots/bitcoin".to_string(), tier: "", }, "btcpay" | "btcpay-server" | "btcpayserver" => 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(), tier: "", }, "homeassistant" | "home-assistant" => 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(), tier: "", }, "grafana" => AppMetadata { title: "Grafana".to_string(), description: "Analytics and monitoring platform".to_string(), icon: "/assets/img/app-icons/grafana.png".to_string(), repo: "https://github.com/grafana/grafana".to_string(), tier: "", }, "endurain" => AppMetadata { title: "Endurain".to_string(), description: "Self-hosted fitness tracking platform".to_string(), icon: "/assets/img/app-icons/endurain.png".to_string(), repo: "https://github.com/joaovitoriasilva/endurain".to_string(), tier: "", }, "fedimint" | "fedimintd" => AppMetadata { title: "Fedimint Guardian".to_string(), description: "Federated Bitcoin mint — Guardian node for federation consensus".to_string(), icon: "/assets/img/app-icons/fedimint.png".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(), tier: "", }, "fedimint-gateway" => AppMetadata { title: "Fedimint Gateway".to_string(), description: "Fedimint Lightning gateway for ecash payments".to_string(), icon: "/assets/img/app-icons/fedimint.png".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(), tier: "", }, "morphos" | "morphos-server" => AppMetadata { title: "Morphos".to_string(), description: "Self-hosted file converter".to_string(), icon: "/assets/img/app-icons/morphos.png".to_string(), repo: "https://github.com/danvergara/morphos".to_string(), tier: "", }, "lnd" | "lightning-stack" => AppMetadata { title: "LND".to_string(), description: "Lightning Network Daemon".to_string(), icon: "/assets/img/app-icons/lnd.svg".to_string(), repo: "https://github.com/lightningnetwork/lnd".to_string(), tier: "", }, "mempool" | "mempool-web" => AppMetadata { title: "Mempool".to_string(), description: "Bitcoin blockchain explorer".to_string(), icon: "/assets/img/app-icons/mempool.webp".to_string(), repo: "https://github.com/mempool/mempool".to_string(), tier: "", }, "electrumx" | "mempool-electrs" | "electrs" => AppMetadata { title: "ElectrumX".to_string(), description: "ElectrumX server — full Electrum protocol indexer for Bitcoin. Powers Mempool and Electrum wallets.".to_string(), icon: "/assets/img/app-icons/electrs.svg".to_string(), repo: "https://github.com/spesmilo/electrumx".to_string(), tier: "", }, "ollama" => AppMetadata { title: "Ollama".to_string(), description: "Run large language models locally".to_string(), icon: "/assets/img/app-icons/ollama.png".to_string(), repo: "https://github.com/ollama/ollama".to_string(), tier: "", }, "monerod" | "monero" => AppMetadata { title: "Monero".to_string(), description: "Private cryptocurrency full node (Monero)".to_string(), icon: "/assets/img/app-icons/monero.png".to_string(), repo: "https://github.com/monero-project/monero".to_string(), tier: "", }, "elementsd" | "liquid" => AppMetadata { title: "Liquid Network".to_string(), description: "Bitcoin sidechain for confidential transactions and faster settlements".to_string(), icon: "/assets/img/app-icons/liquid.png".to_string(), repo: "https://github.com/ElementsProject/elements".to_string(), tier: "", }, "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(), tier: "", }, "cryptpad" => AppMetadata { title: "CryptPad".to_string(), description: "End-to-end encrypted document collaboration".to_string(), icon: "/assets/img/app-icons/cryptpad.webp".to_string(), repo: "https://github.com/cryptpad/cryptpad".to_string(), tier: "", }, "nextcloud" => AppMetadata { title: "Nextcloud".to_string(), description: "Self-hosted cloud storage and file management".to_string(), icon: "/assets/img/app-icons/nextcloud.webp".to_string(), repo: "https://github.com/nextcloud/server".to_string(), tier: "", }, "vaultwarden" => AppMetadata { title: "Vaultwarden".to_string(), description: "Self-hosted password manager (Bitwarden compatible)".to_string(), icon: "/assets/img/app-icons/vaultwarden.webp".to_string(), repo: "https://github.com/dani-garcia/vaultwarden".to_string(), tier: "", }, "jellyfin" => AppMetadata { title: "Jellyfin".to_string(), description: "Free media server system".to_string(), icon: "/assets/img/app-icons/jellyfin.webp".to_string(), repo: "https://github.com/jellyfin/jellyfin".to_string(), tier: "", }, "photoprism" => AppMetadata { title: "PhotoPrism".to_string(), description: "AI-powered photo management".to_string(), icon: "/assets/img/app-icons/photoprism.svg".to_string(), repo: "https://github.com/photoprism/photoprism".to_string(), tier: "", }, "immich" | "immich_server" => AppMetadata { title: "Immich".to_string(), description: "High-performance self-hosted photo and video backup".to_string(), icon: "/assets/img/app-icons/immich.png".to_string(), repo: "https://github.com/immich-app/immich".to_string(), tier: "", }, "filebrowser" => AppMetadata { title: "File Browser".to_string(), description: "Web-based file manager".to_string(), icon: "/assets/img/app-icons/file-browser.webp".to_string(), repo: "https://github.com/filebrowser/filebrowser".to_string(), tier: "", }, "nginx-proxy-manager" => AppMetadata { title: "Nginx Proxy Manager".to_string(), description: "Easy proxy management with SSL".to_string(), icon: "/assets/img/app-icons/nginx.svg".to_string(), repo: "https://github.com/NginxProxyManager/nginx-proxy-manager".to_string(), tier: "", }, "portainer" => AppMetadata { title: "Portainer".to_string(), description: "Container management UI".to_string(), icon: "/assets/img/app-icons/portainer.webp".to_string(), repo: "https://github.com/portainer/portainer".to_string(), tier: "", }, "uptime-kuma" => AppMetadata { title: "Uptime Kuma".to_string(), description: "Self-hosted monitoring tool".to_string(), icon: "/assets/img/app-icons/uptime-kuma.webp".to_string(), repo: "https://github.com/louislam/uptime-kuma".to_string(), tier: "", }, "tailscale" => AppMetadata { title: "Tailscale".to_string(), description: "Zero-config VPN for secure remote access".to_string(), icon: "/assets/img/app-icons/tailscale.webp".to_string(), repo: "https://github.com/tailscale/tailscale".to_string(), tier: "", }, "netbird" => AppMetadata { title: "NetBird".to_string(), description: "Self-hosted WireGuard mesh VPN control plane and dashboard".to_string(), icon: "/assets/img/app-icons/netbird.svg".to_string(), repo: "https://github.com/netbirdio/netbird".to_string(), tier: "", }, "gitea" => AppMetadata { title: "Gitea".to_string(), description: "Self-hosted Git service with repository and package hosting".to_string(), icon: "/assets/img/app-icons/gitea.svg".to_string(), repo: "https://gitea.com".to_string(), tier: "", }, "indeedhub" | "indeehub" => AppMetadata { title: "IndeedHub".to_string(), description: "Decentralized media streaming platform".to_string(), icon: "/assets/img/app-icons/indeedhub.png".to_string(), repo: "https://github.com/indeedhub/indeedhub".to_string(), tier: "", }, "dwn" => AppMetadata { title: "Decentralized Web Node".to_string(), description: "Store and sync personal data with DID-based access control".to_string(), icon: "/assets/img/app-icons/dwn.svg".to_string(), repo: "https://github.com/TBD54566975/dwn-server".to_string(), tier: "", }, "tor" | "archy-tor" => AppMetadata { title: "Tor".to_string(), description: "Anonymous overlay network for privacy".to_string(), icon: "/assets/img/app-icons/tor.svg".to_string(), repo: "https://gitlab.torproject.org/tpo/core/tor".to_string(), tier: "", }, "botfights" => AppMetadata { title: "BotFights".to_string(), description: "AI bot arena — build, train, and battle autonomous agents".to_string(), icon: "/assets/img/app-icons/botfights.svg".to_string(), repo: "https://botfights.net".to_string(), tier: "", }, "nwnn" => AppMetadata { title: "Next Web News Network".to_string(), description: "Decentralized news and link aggregator, synced from Telegram".to_string(), icon: "/assets/img/app-icons/nwnn.png".to_string(), repo: "https://nwnn.l484.com".to_string(), tier: "", }, "484-kitchen" => AppMetadata { title: "484 Kitchen".to_string(), description: "K484 application platform".to_string(), icon: "/assets/img/app-icons/484-kitchen.png".to_string(), repo: "https://484.kitchen".to_string(), tier: "", }, "call-the-operator" => AppMetadata { title: "Call the Operator".to_string(), description: "Escape the Matrix — explore decentralized alternatives".to_string(), icon: "/assets/img/app-icons/call-the-operator.png".to_string(), repo: "https://cta.tx1138.com".to_string(), tier: "", }, "arch-presentation" => AppMetadata { title: "Arch Presentation".to_string(), description: "Archipelago: The Future of Decentralized Infrastructure".to_string(), icon: "/assets/img/app-icons/arch-presentation.png".to_string(), repo: "https://present.l484.com".to_string(), tier: "", }, "syntropy-institute" => AppMetadata { title: "Syntropy Institute".to_string(), description: "Medicine Reimagined — frequency analysis-therapy and digital homeopathy".to_string(), icon: "/assets/img/app-icons/syntropy-institute.png".to_string(), repo: "https://syntropy.institute".to_string(), tier: "", }, "t-zero" => AppMetadata { title: "T-0".to_string(), description: "Documentary series on decentralization, Bitcoin, and the ungovernable future".to_string(), icon: "/assets/img/app-icons/t-zero.png".to_string(), repo: "https://teeminuszero.net".to_string(), tier: "", }, _ => AppMetadata { title: app_id.to_string(), description: format!("{} application", app_id), icon: "/assets/img/favico.png".to_string(), repo: "#".to_string(), tier: "", }, }; apply_dynamic_metadata(app_id, &mut meta); meta.tier = get_app_tier(app_id); meta } fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) { let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id); let Ok(data) = std::fs::read_to_string(config_path) else { return; }; let Ok(cfg) = serde_json::from_str::(&data) else { return; }; if let Some(title) = cfg .get("title") .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty() && s.len() <= 80) { meta.title = title.to_string(); } if let Some(description) = cfg .get("description") .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty() && s.len() <= 240) { meta.description = description.to_string(); } } /// Map app_id to Tor hidden service directory name. /// "archipelago" is the main web UI (nginx port 80). /// Supports container names from deploy (archy-*, btcpay-server, etc.). fn tor_service_name(app_id: &str) -> Option<&'static str> { match app_id { "archipelago" => Some("archipelago"), "bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"), "electrumx" | "electrs" | "electrum" => Some("electrumx"), "lnd" | "lnd-ui" => Some("lnd"), "btcpay" | "btcpay-server" | "btcpayserver" => Some("btcpay"), "mempool" | "mempool-web" | "mempool-frontend" => Some("mempool"), "fedimint" | "fedimint-gateway" => Some("fedimint"), "filebrowser" => Some("filebrowser"), _ => None, } } /// V3 onion addresses are 56 base32 chars + ".onion". Placeholders like "btcpay.onion" are not real. fn is_real_onion_address(s: &str) -> bool { s.ends_with(".onion") && s.len() >= 60 && s.len() <= 70 } /// Read real .onion address from Tor hidden service hostname file. /// Service name "archipelago" is for the main web UI (nginx port 80). /// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor. pub async fn read_tor_address(app_id: &str) -> Option { let service = tor_service_name(app_id)?; let base = std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string()); // Try readable hostname copy first (when system Tor owns hidden_service dirs) let hostnames_path = std::path::Path::new(&base) .parent() .unwrap_or(std::path::Path::new("/var/lib/archipelago")) .join("tor-hostnames") .join(service); if let Some(addr) = tokio::fs::read_to_string(&hostnames_path) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| s.ends_with(".onion") && !s.is_empty()) { return Some(addr); } // Fall back to hidden_service directory let path = std::path::Path::new(&base) .join(format!("hidden_service_{}", service)) .join("hostname"); tokio::fs::read_to_string(&path) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| s.ends_with(".onion") && !s.is_empty()) } 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 } async fn netbird_configured_launch_url() -> Option { let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env") .await .ok()?; env.lines() .find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT=")) .map(str::trim) .filter(|s| !s.is_empty()) .map(ToOwned::to_owned) .or_else(|| PodmanClient::lan_address_for("netbird")) } async fn reachable_lan_address(app_id: &str, candidate: Option) -> Option { let url = candidate?; if !requires_reachable_launch(app_id) { return Some(url); } let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::().ok()) else { return None; }; if launch_port_reachable(port).await { Some(url) } else { debug!(app_id = %app_id, port, "suppressing unreachable launch URL"); None } } async fn launch_port_reachable(port: u16) -> bool { matches!( tokio::time::timeout( std::time::Duration::from_secs(2), tokio::net::TcpStream::connect(("127.0.0.1", port)), ) .await, Ok(Ok(_)) ) } fn requires_reachable_launch(app_id: &str) -> bool { matches!( app_id, "botfights" | "btcpay-server" | "fedimint" | "filebrowser" | "grafana" | "homeassistant" | "home-assistant" | "jellyfin" | "mempool" | "nginx-proxy-manager" | "uptime-kuma" | "gitea" | "nextcloud" | "portainer" | "tailscale" | "immich" | "searxng" ) } fn companion_lan_address(app_id: &str) -> Option { match app_id { "bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()), "electrumx" | "mempool-electrs" | "electrs" => Some("http://localhost:50002".to_string()), _ => None, } } fn uses_allocated_launch_port(app_id: &str) -> bool { matches!( app_id, "filebrowser" | "nextcloud" | "nginx-proxy-manager" | "vaultwarden" ) } fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) { match container_state { ContainerState::Running => (PackageState::Running, ServiceStatus::Running), ContainerState::Stopping => (PackageState::Stopping, ServiceStatus::Stopped), ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped), ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped), 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::Exited => "exited", PackageState::Starting => "starting", PackageState::Running => "running", PackageState::Restarting => "restarting", PackageState::CreatingBackup => "creating-backup", PackageState::RestoringBackup => "restoring-backup", PackageState::Removing => "removing", PackageState::BackingUp => "backing-up", PackageState::Updating => "updating", } }