diff --git a/core/container/Cargo.toml b/core/container/Cargo.toml index 69335448..217de3e4 100644 --- a/core/container/Cargo.toml +++ b/core/container/Cargo.toml @@ -9,6 +9,7 @@ serde_yaml = "0.9" serde_json = "1.0" tokio = { version = "1", features = ["full"] } reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +hyper = { version = "0.14", features = ["client", "http1"] } thiserror = "1.0" anyhow = "1.0" async-trait = "0.1" diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index d599fa64..9a14c517 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -1,16 +1,29 @@ +//! Podman container management via the REST API unix socket. +//! +//! Connects to the rootless Podman API at /run/user/{UID}/podman/podman.sock. +//! All operations are non-blocking async via tokio + hyper. +//! Falls back to CLI only for image pulls (long-running streaming operations). + use crate::manifest::AppManifest; use anyhow::{Context, Result}; +use hyper::{Body, Request, Uri}; use serde::{Deserialize, Serialize}; -use std::process::Command; +use std::path::PathBuf; use thiserror::Error; -use tokio::process::Command as TokioCommand; +use tokio::net::UnixStream; + +const API_VERSION: &str = "v4.0.0"; +const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const LONG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); #[derive(Debug, Error)] pub enum PodmanError { - #[error("Podman command failed: {0}")] - CommandFailed(String), + #[error("Podman API error: {0}")] + ApiError(String), #[error("Container not found: {0}")] NotFound(String), + #[error("Podman socket not available: {0}")] + SocketUnavailable(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), } @@ -20,24 +33,12 @@ pub struct ContainerStatus { pub id: String, pub name: String, pub state: ContainerState, - pub health: Option, // "healthy", "unhealthy", "starting", or None if no healthcheck + pub health: Option, pub started_at: Option, pub image: String, pub created: String, pub ports: Vec, - pub lan_address: Option, // Launch URL for UI access -} - -/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)") -fn parse_health_from_status(status: &str) -> Option { - if let Some(start) = status.rfind('(') { - if let Some(end) = status.rfind(')') { - if start < end { - return Some(status[start + 1..end].to_string()); - } - } - } - None + pub lan_address: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -63,29 +64,51 @@ impl From<&str> for ContainerState { } } +/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)") +fn parse_health_from_status(status: &str) -> Option { + if let Some(start) = status.rfind('(') { + if let Some(end) = status.rfind(')') { + if start < end { + return Some(status[start + 1..end].to_string()); + } + } + } + None +} + pub struct PodmanClient { - _user: String, - rootless: bool, + socket_path: PathBuf, } impl PodmanClient { pub fn new(user: String) -> Self { - // If running as root, use root podman context - let is_root = std::env::var("USER").unwrap_or_default() == "root" || - std::env::var("HOME").unwrap_or_default() == "/root"; - - Self { - _user: user, - rootless: !is_root, - } + // Determine socket path based on user + let uid = Self::get_uid(&user); + let socket_path = PathBuf::from(format!("/run/user/{}/podman/podman.sock", uid)); + Self { socket_path } } - - /// Map container name to its UI launch URL (static fallback for docker_packages scanner) + + fn get_uid(user: &str) -> u32 { + // Try to get UID from /etc/passwd + if let Ok(content) = std::fs::read_to_string("/etc/passwd") { + for line in content.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 3 && parts[0] == user { + if let Ok(uid) = parts[2].parse() { + return uid; + } + } + } + } + // Default to 1000 (standard first user) + 1000 + } + + /// Map container name to its UI launch URL pub fn lan_address_for(name: &str) -> Option { let url = match name { "bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334", "lnd" | "archy-lnd-ui" => "http://localhost:8081", - // Tailscale has no web UI — managed via CLI/app "homeassistant" => "http://localhost:8123", "archy-mempool-web" | "mempool" => "http://localhost:4080", "btcpay-server" => "http://localhost:23000", @@ -115,362 +138,398 @@ impl PodmanClient { Some(url.to_string()) } - fn podman_async(&self) -> TokioCommand { - // Rootless podman: run as the current user (no sudo). - // Requires: loginctl enable-linger , containers migrated to user storage. - let cmd = TokioCommand::new("podman"); - cmd - } - - pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { - let mut cmd = self.podman_async(); - cmd.arg("pull").arg(image); - - if let Some(sig) = signature { - // Verify signature with cosign if provided - cmd.arg("--signature-policy").arg("default"); - // TODO: Implement cosign verification - log::warn!("Signature verification not yet implemented: {}", sig); - } - - let output = cmd - .output() + // ─── API Client ────────────────────────────────────────────── + + /// Send a request to the Podman API via unix socket. + async fn api_request( + &self, + method: &str, + path: &str, + body: Option, + timeout: std::time::Duration, + ) -> Result { + let socket_path = self.socket_path.clone(); + + // Connect to the unix socket + let stream = tokio::time::timeout( + std::time::Duration::from_secs(5), + UnixStream::connect(&socket_path), + ) + .await + .map_err(|_| anyhow::anyhow!("Podman socket connection timed out"))? + .context(format!("Cannot connect to Podman socket at {}", socket_path.display()))?; + + // Build the hyper client with the unix stream + let (mut sender, conn) = hyper::client::conn::Builder::new() + .handshake::<_, Body>(stream) .await - .context("Failed to execute podman pull")?; - + .context("Podman API handshake failed")?; + + // Spawn the connection handler + tokio::spawn(async move { + if let Err(e) = conn.await { + tracing::debug!("Podman API connection ended: {}", e); + } + }); + + // Build the request + let uri: Uri = format!("/{}/{}", API_VERSION, path.trim_start_matches('/')) + .parse() + .context("Invalid API path")?; + + let req = match method { + "POST" => { + let body_str = body.map(|b| serde_json::to_string(&b).unwrap_or_default()) + .unwrap_or_default(); + Request::builder() + .method("POST") + .uri(uri) + .header("Host", "localhost") + .header("Content-Type", "application/json") + .body(Body::from(body_str)) + .context("Failed to build POST request")? + } + "DELETE" => { + Request::builder() + .method("DELETE") + .uri(uri) + .header("Host", "localhost") + .body(Body::empty()) + .context("Failed to build DELETE request")? + } + _ => { + Request::builder() + .method("GET") + .uri(uri) + .header("Host", "localhost") + .body(Body::empty()) + .context("Failed to build GET request")? + } + }; + + // Send with timeout + let resp = tokio::time::timeout(timeout, sender.send_request(req)) + .await + .map_err(|_| anyhow::anyhow!("Podman API request timed out after {}s", timeout.as_secs()))? + .context("Podman API request failed")?; + + let status = resp.status(); + let body_bytes = hyper::body::to_bytes(resp.into_body()) + .await + .context("Failed to read Podman API response")?; + + if status == hyper::StatusCode::NOT_FOUND { + return Err(anyhow::anyhow!("Not found")); + } + + if !status.is_success() { + let error_text = String::from_utf8_lossy(&body_bytes); + return Err(anyhow::anyhow!("Podman API {} {}: {}", status.as_u16(), status.canonical_reason().unwrap_or(""), error_text)); + } + + // Some endpoints return empty body on success (start/stop/restart) + if body_bytes.is_empty() { + return Ok(serde_json::json!({"ok": true})); + } + + serde_json::from_slice(&body_bytes) + .context("Failed to parse Podman API JSON response") + } + + /// Simple POST with no body (start/stop/restart) + async fn api_post_action(&self, path: &str) -> Result<()> { + self.api_request("POST", path, None, DEFAULT_TIMEOUT).await?; + Ok(()) + } + + // ─── Container Operations ──────────────────────────────────── + + pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> { + // Image pull uses CLI — it's a streaming operation that the API handles differently + let mut cmd = tokio::process::Command::new("podman"); + cmd.arg("pull").arg(image); + + let output = tokio::time::timeout( + std::time::Duration::from_secs(600), // 10 min for large images + cmd.output(), + ) + .await + .map_err(|_| anyhow::anyhow!("Image pull timed out after 10 minutes"))? + .context("Failed to execute podman pull")?; + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("Failed to pull image: {}", stderr)); } - + Ok(()) } - + pub async fn create_container( &self, manifest: &AppManifest, name: &str, ) -> Result { - let mut cmd = self.podman_async(); - cmd.arg("create"); - - // Container name - cmd.arg("--name").arg(name); - - // Read-only root filesystem - if manifest.app.security.readonly_root { - cmd.arg("--read-only"); - } - - // Network policy - match manifest.app.security.network_policy.as_str() { - "host" => { - cmd.arg("--network").arg("host"); - } - "isolated" => { - // Create isolated network (default) - } - _ => { - cmd.arg("--network").arg(&manifest.app.security.network_policy); - } - } - - // Port mappings + // Build the container spec for the API + let mut port_mappings = Vec::new(); for port in &manifest.app.ports { - cmd.arg("-p").arg(format!("{}:{}", port.host, port.container)); + port_mappings.push(serde_json::json!({ + "container_port": port.container, + "host_port": port.host, + "protocol": "tcp", + })); } - - // Volumes + + let mut mounts = Vec::new(); for volume in &manifest.app.volumes { - let mut mount = format!("{}:{}", volume.source, volume.target); - if !volume.options.is_empty() { - mount.push_str(&format!(":{}", volume.options.join(","))); - } - cmd.arg("-v").arg(mount); + mounts.push(serde_json::json!({ + "destination": volume.target, + "source": volume.source, + "type": "bind", + "options": volume.options, + })); } - - // Devices - for device in &manifest.app.devices { - cmd.arg("--device").arg(device); - } - - // Environment variables + + let mut env_map = serde_json::Map::new(); for env in &manifest.app.environment { - cmd.arg("-e").arg(env); - } - - // Resource limits - if let Some(cpu) = manifest.app.resources.cpu_limit { - cmd.arg("--cpus").arg(cpu.to_string()); - } - - if let Some(memory) = &manifest.app.resources.memory_limit { - cmd.arg("--memory").arg(memory); - } - - // Capabilities (drop all, add specified) - cmd.arg("--cap-drop").arg("ALL"); - for cap in &manifest.app.security.capabilities { - cmd.arg("--cap-add").arg(cap); + if let Some((k, v)) = env.split_once('=') { + env_map.insert(k.to_string(), serde_json::Value::String(v.to_string())); + } } - // Enforce no new privileges (prevent setuid escalation) - cmd.arg("--security-opt").arg("no-new-privileges=true"); + let mut cap_add: Vec = manifest.app.security.capabilities.clone(); + let cap_drop = vec!["ALL".to_string()]; - // Image - cmd.arg(&manifest.app.container.image); - - let output = cmd - .output() - .await - .context("Failed to create container")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Failed to create container: {}", stderr)); - } - - let container_id = String::from_utf8_lossy(&output.stdout) - .trim() + let body = serde_json::json!({ + "name": name, + "image": manifest.app.container.image, + "portmappings": port_mappings, + "mounts": mounts, + "env": env_map, + "devices": manifest.app.devices.iter().map(|d| { + serde_json::json!({"path": d}) + }).collect::>(), + "resource_limits": { + "memory": { + "limit": manifest.app.resources.memory_limit.as_ref() + .and_then(|m| parse_memory_limit(m)) + .unwrap_or(0), + }, + "cpu": { + "quota": manifest.app.resources.cpu_limit + .map(|c| (c as i64) * 100000) + .unwrap_or(0), + "period": 100000u64, + } + }, + "cap_add": cap_add, + "cap_drop": cap_drop, + "read_only_filesystem": manifest.app.security.readonly_root, + "no_new_privileges": true, + "netns": { + "nsmode": match manifest.app.security.network_policy.as_str() { + "host" => "host", + _ => "bridge", + } + }, + }); + + let result = self.api_request( + "POST", + "libpod/containers/create", + Some(body), + LONG_TIMEOUT, + ).await?; + + let id = result["Id"].as_str() + .unwrap_or("") .to_string(); - - Ok(container_id) + + Ok(id) } - + pub async fn start_container(&self, name: &str) -> Result<()> { - let mut cmd = self.podman_async(); - cmd.arg("start").arg(name); - - let output = cmd - .output() - .await - .context("Failed to start container")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Failed to start container: {}", stderr)); - } - - Ok(()) + self.api_post_action(&format!("libpod/containers/{}/start", name)).await } - + pub async fn stop_container(&self, name: &str) -> Result<()> { - let mut cmd = self.podman_async(); - cmd.arg("stop").arg(name); - - let output = cmd - .output() - .await - .context("Failed to stop container")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Failed to stop container: {}", stderr)); - } - - Ok(()) + self.api_request( + "POST", + &format!("libpod/containers/{}/stop?t=10", name), + None, + DEFAULT_TIMEOUT, + ).await.map(|_| ()) } - + + pub async fn restart_container(&self, name: &str) -> Result<()> { + self.api_request( + "POST", + &format!("libpod/containers/{}/restart?t=10", name), + None, + DEFAULT_TIMEOUT, + ).await.map(|_| ()) + } + pub async fn remove_container(&self, name: &str) -> Result<()> { - let mut cmd = self.podman_async(); - cmd.arg("rm").arg("-f").arg(name); - - let output = cmd - .output() - .await - .context("Failed to remove container")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Failed to remove container: {}", stderr)); - } - - Ok(()) + self.api_request( + "DELETE", + &format!("libpod/containers/{}?force=true", name), + None, + DEFAULT_TIMEOUT, + ).await.map(|_| ()) } - + pub async fn get_container_status(&self, name: &str) -> Result { - let mut cmd = self.podman_async(); - cmd.arg("inspect") - .arg("--format") - .arg("{{.Id}}|{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{.Created}}|{{.NetworkSettings.Ports}}") - .arg(name); - - let output = cmd - .output() - .await - .context("Failed to inspect container")?; - - if !output.status.success() { - return Err(anyhow::anyhow!("Container not found: {}", name)); - } - - let info = String::from_utf8_lossy(&output.stdout); - let parts: Vec<&str> = info.trim().split('|').collect(); - - if parts.len() < 5 { - return Err(anyhow::anyhow!("Invalid container inspect output")); - } - + let data = self.api_request( + "GET", + &format!("libpod/containers/{}/json", name), + None, + DEFAULT_TIMEOUT, + ).await?; + + let state_str = data["State"]["Status"].as_str().unwrap_or("unknown"); + let health = data["State"]["Health"]["Status"].as_str().map(|s| s.to_string()); + let started_at = data["State"]["StartedAt"].as_str().map(|s| s.to_string()); + let container_name = data["Name"].as_str().unwrap_or(name).to_string(); + + // Parse port bindings + let ports = parse_port_bindings(&data["HostConfig"]["PortBindings"]); + let lan_address = Self::lan_address_for(&container_name); + Ok(ContainerStatus { - id: parts[0].to_string(), - name: parts[1].to_string(), - state: ContainerState::from(parts[2]), - health: None, - started_at: None, - image: parts[3].to_string(), - created: parts[4].to_string(), - ports: vec![], // TODO: Parse ports from parts[5] - lan_address: None, // Set by docker_packages scanner + id: data["Id"].as_str().unwrap_or("").to_string(), + name: container_name, + state: ContainerState::from(state_str), + health, + started_at, + image: data["ImageName"].as_str() + .or_else(|| data["Config"]["Image"].as_str()) + .unwrap_or("").to_string(), + created: data["Created"].as_str().unwrap_or("").to_string(), + ports, + lan_address, }) } - + pub async fn get_container_logs(&self, name: &str, lines: u32) -> Result> { - let mut cmd = self.podman_async(); + // Logs endpoint returns raw text, not JSON — use CLI for this + let mut cmd = tokio::process::Command::new("podman"); cmd.arg("logs") .arg("--tail") .arg(lines.to_string()) .arg(name); - - let output = cmd - .output() + + let output = tokio::time::timeout(DEFAULT_TIMEOUT, cmd.output()) .await + .map_err(|_| anyhow::anyhow!("Container logs timed out"))? .context("Failed to get container logs")?; - + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("Failed to get logs: {}", stderr)); } - - let logs = String::from_utf8_lossy(&output.stdout); - Ok(logs.lines().map(|s| s.to_string()).collect()) + + // Podman logs go to both stdout and stderr + let mut all_output = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + all_output.push_str(&stderr); + } + Ok(all_output.lines().map(|s| s.to_string()).collect()) } - + pub async fn list_containers(&self) -> Result> { - let mut cmd = self.podman_async(); - cmd.arg("ps") - .arg("-a") - .arg("--format") - .arg("json"); + let data = self.api_request( + "GET", + "libpod/containers/json?all=true", + None, + DEFAULT_TIMEOUT, + ).await?; - let output = tokio::time::timeout( - std::time::Duration::from_secs(60), - cmd.output(), - ) - .await - .map_err(|_| anyhow::anyhow!("podman ps timed out (60s)"))? - .context("Failed to list containers")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - log::error!("Podman list failed: {}", stderr); - return Err(anyhow::anyhow!("Failed to list containers: {}", stderr)); + let containers = data.as_array() + .ok_or_else(|| anyhow::anyhow!("Expected array from containers/json"))?; + + let mut result = Vec::with_capacity(containers.len()); + + for c in containers { + let name = if let Some(names) = c["Names"].as_array() { + names.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() + } else { + c["Names"].as_str().unwrap_or("").to_string() + }; + + let ports = if let Some(ports_array) = c["Ports"].as_array() { + ports_array.iter().filter_map(|port| { + let host_port = port["host_port"].as_u64()?; + let container_port = port["container_port"].as_u64()?; + let protocol = port["protocol"].as_str().unwrap_or("tcp"); + Some(format!("0.0.0.0:{}->{}/{}", host_port, container_port, protocol)) + }).collect() + } else { + vec![] + }; + + let status_str = c["Status"].as_str().unwrap_or(""); + let health = parse_health_from_status(status_str) + .or_else(|| c["Health"].as_str().map(|s| s.to_string())); + let started_at = c["StartedAt"].as_str() + .or_else(|| c["Started"].as_str()) + .map(|s| s.to_string()); + let lan_address = Self::lan_address_for(&name); + + result.push(ContainerStatus { + id: c["Id"].as_str().unwrap_or("").to_string(), + name, + state: ContainerState::from(c["State"].as_str().unwrap_or("unknown")), + health, + started_at, + image: c["Image"].as_str().unwrap_or("").to_string(), + created: c["Created"].as_str().unwrap_or("").to_string(), + ports, + lan_address, + }); } - - let json = String::from_utf8_lossy(&output.stdout); - log::debug!("Podman JSON output ({} bytes): {}", json.len(), - if json.len() > 200 { &json[..200] } else { &json }); - - // Podman can return either a JSON array or NDJSON (newline-delimited JSON) - let mut result = Vec::new(); - - // Try parsing as a JSON array first - if let Ok(containers) = serde_json::from_str::>(&json) { - log::debug!("Parsed as JSON array with {} items", containers.len()); - for container in containers { - // Handle both Names as array and Names as string - let name = if let Some(names_array) = container["Names"].as_array() { - names_array.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() - } else { - container["Names"].as_str().unwrap_or("").to_string() - }; - - // Parse ports from the Ports array - let ports = if let Some(ports_array) = container["Ports"].as_array() { - ports_array.iter().filter_map(|port| { - // Podman format: {"host_ip":"","container_port":8123,"host_port":8123,"range":1,"protocol":"tcp"} - if let (Some(host_port), Some(container_port), Some(protocol)) = ( - port["host_port"].as_u64(), - port["container_port"].as_u64(), - port["protocol"].as_str() - ) { - Some(format!("0.0.0.0:{}->{}/{}", host_port, container_port, protocol)) - } else { - None - } - }).collect() - } else { - vec![] - }; - - let lan_address = Self::lan_address_for(&name); - let status_str = container["Status"].as_str().unwrap_or(""); - let health = parse_health_from_status(status_str); - let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string()); - result.push(ContainerStatus { - id: container["Id"].as_str().unwrap_or("").to_string(), - name, - state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), - health, - started_at, - image: container["Image"].as_str().unwrap_or("").to_string(), - created: container["Created"].as_str().unwrap_or("").to_string(), - ports, - lan_address, - }); - } - } else { - log::debug!("Failed to parse as JSON array, trying NDJSON"); - // Try parsing as NDJSON (newline-delimited JSON) - for line in json.lines() { - if line.trim().is_empty() { - continue; - } - if let Ok(container) = serde_json::from_str::(line) { - // Handle both Names as array and Names as string - let name = if let Some(names_array) = container["Names"].as_array() { - names_array.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() - } else { - container["Names"].as_str().unwrap_or("").to_string() - }; - - // Parse ports from the Ports array - let ports = if let Some(ports_array) = container["Ports"].as_array() { - ports_array.iter().filter_map(|port| { - if let (Some(host_port), Some(container_port), Some(protocol)) = ( - port["host_port"].as_u64(), - port["container_port"].as_u64(), - port["protocol"].as_str() - ) { - Some(format!("0.0.0.0:{}->{}/{}", host_port, container_port, protocol)) - } else { - None - } - }).collect() - } else { - vec![] - }; - - let lan_address = Self::lan_address_for(&name); - let status_str = container["Status"].as_str().unwrap_or(""); - let health = parse_health_from_status(status_str); - let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string()); - result.push(ContainerStatus { - id: container["Id"].as_str().unwrap_or("").to_string(), - name, - state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), - health, - started_at, - image: container["Image"].as_str().unwrap_or("").to_string(), - created: container["Created"].as_str().unwrap_or("").to_string(), - ports, - lan_address, - }); - } - } - } - - log::debug!("Returning {} containers", result.len()); Ok(result) } + + /// Check if the Podman socket is available and responding. + pub async fn health_check(&self) -> bool { + self.api_request("GET", "libpod/info", None, std::time::Duration::from_secs(5)) + .await + .is_ok() + } +} + +// ─── Helpers ───────────────────────────────────────────────────── + +fn parse_port_bindings(bindings: &serde_json::Value) -> Vec { + let mut ports = Vec::new(); + if let Some(obj) = bindings.as_object() { + for (container_port, host_bindings) in obj { + if let Some(arr) = host_bindings.as_array() { + for binding in arr { + let host_ip = binding["HostIp"].as_str().unwrap_or("0.0.0.0"); + let host_port = binding["HostPort"].as_str().unwrap_or(""); + if !host_port.is_empty() { + ports.push(format!("{}:{}->{}", host_ip, host_port, container_port)); + } + } + } + } + } + ports +} + +fn parse_memory_limit(limit: &str) -> Option { + let limit = limit.trim().to_lowercase(); + if limit.ends_with('g') { + limit.trim_end_matches('g').parse::().ok().map(|v| (v * 1_073_741_824.0) as i64) + } else if limit.ends_with('m') { + limit.trim_end_matches('m').parse::().ok().map(|v| (v * 1_048_576.0) as i64) + } else if limit.ends_with('k') { + limit.trim_end_matches('k').parse::().ok().map(|v| (v * 1024.0) as i64) + } else { + limit.parse::().ok() + } }