archy/core/container/src/podman_client.rs
archipelago 2dad64b2ee fix(stop): honour per-app graceful-stop grace in orchestrator stop path
package.stop left slow-to-SIGTERM apps (fedimint/electrumx/bitcoin/btcpay/immich)
running: the orchestrator path hardcoded podman API ?t=10 / CLI -t 30 and the CLI
wrapper deadline (30s) equalled the -t grace, so the await fired exactly as podman
SIGKILLed -> stop reported failed -> state reverted to running. Reproduced live on
clean .198 (fedimint).

- container/runtime.rs: add ContainerRuntime::stop_container_with_grace (defaulted
  so mock/dev impls are unchanged); PodmanRuntime honours grace for API + CLI with
  deadline = grace + 15s buffer; AutoRuntime delegates. New canonical per-app table
  stop_grace_secs_for() + DEFAULT_STOP_GRACE_SECS / STOP_GRACE_DEADLINE_BUFFER_SECS.
- podman_client.rs: stop_container_with_grace uses ?t=<grace> + longer HTTP deadline.
- prod_orchestrator::stop: resolve grace = manifest stop_grace_secs (north-star) else
  the table; pass to quadlet::stop_service_with_timeout AND stop_container_with_grace.
- quadlet.rs: stop_service_with_timeout so slow apps aren't SIGKILLed at 45s.
- rpc/package/runtime.rs: doc-note its &str stop_timeout_secs mirrors the canonical table.
- tests: resolve_stop_grace_secs (manifest field wins / table fallback / default 30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:59:40 -04:00

939 lines
33 KiB
Rust

//! 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::path::{Path, PathBuf};
use thiserror::Error;
use tokio::net::UnixStream;
const API_VERSION: &str = "v4.0.0";
const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const LONG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
#[derive(Debug, Error)]
pub enum PodmanError {
#[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),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerStatus {
pub id: String,
pub name: String,
pub state: ContainerState,
pub health: Option<String>,
pub exit_code: Option<i32>,
pub started_at: Option<String>,
pub image: String,
pub created: String,
pub ports: Vec<String>,
pub lan_address: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ContainerState {
Created,
Running,
Stopping,
Stopped,
Exited,
Paused,
Unknown(String),
}
impl From<&str> for ContainerState {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"created" | "initialized" => ContainerState::Created,
"running" => ContainerState::Running,
"stopping" | "removing" => ContainerState::Stopping,
"stopped" => ContainerState::Stopped,
"exited" => ContainerState::Exited,
"paused" => ContainerState::Paused,
other => ContainerState::Unknown(other.to_string()),
}
}
}
/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)")
fn parse_health_from_status(status: &str) -> Option<String> {
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 {
socket_path: PathBuf,
}
impl PodmanClient {
pub fn new(user: String) -> Self {
// 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 }
}
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<String> {
if let Some(url) = manifest_lan_address_for(name) {
return Some(url);
}
let url = match name {
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
"archy-mempool-web" | "mempool" => "http://localhost:4080",
"ollama" => "http://localhost:11434",
"cryptpad" => "http://localhost:3003",
"penpot" => "http://localhost:9001",
"immich_server" | "immich" => "http://localhost:2283",
"nginx-proxy-manager" => "http://localhost:8081",
"fedimint-gateway" => "http://localhost:8176",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
_ => return None,
};
Some(url.to_string())
}
// ─── API Client ──────────────────────────────────────────────
/// Send a request to the Podman API via unix socket.
async fn api_request(
&self,
method: &str,
path: &str,
body: Option<serde_json::Value>,
timeout: std::time::Duration,
) -> Result<serde_json::Value> {
let socket_path = self.socket_path.clone();
// Connect to the unix socket (30s timeout — podman can be slow under load on boot)
let stream = tokio::time::timeout(
std::time::Duration::from_secs(30),
UnixStream::connect(&socket_path),
)
.await
.map_err(|_| anyhow::anyhow!("Podman socket connection timed out (30s)"))?
.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("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 = match body {
Some(b) => serde_json::to_string(&b)
.context("Failed to serialize request body to JSON")?,
None => String::new(),
};
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");
if image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
cmd.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<String> {
// Build the container spec for the API
let mut port_mappings = Vec::new();
for port in &manifest.app.ports {
port_mappings.push(serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": "tcp",
}));
}
let mut mounts = Vec::new();
for volume in &manifest.app.volumes {
if volume.volume_type == "tmpfs" {
let options: Vec<String> = volume
.tmpfs_options
.as_deref()
.unwrap_or("")
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
mounts.push(serde_json::json!({
"destination": volume.target,
"type": "tmpfs",
"options": options,
}));
} else {
mounts.push(serde_json::json!({
"destination": volume.target,
"source": volume.source,
"type": "bind",
"options": volume.options,
}));
}
}
let mut env_map = serde_json::Map::new();
for env in &manifest.app.environment {
if let Some((k, v)) = env.split_once('=') {
env_map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
}
}
let cap_add: Vec<String> = manifest.app.security.capabilities.clone();
let cap_drop = vec!["ALL".to_string()];
let image_ref = manifest.app.container.image_ref().ok_or_else(|| {
anyhow::anyhow!(
"container config for {} has neither a valid image nor build source",
manifest.app.id
)
})?;
// Build resource_limits conditionally: if the manifest has no memory or
// cpu limit, OMIT the field entirely rather than sending 0. The podman
// libpod HTTP API treats `memory.limit: 0` as "set MemoryMax=0" which
// systemd then rejects at container-start time. Absent = unlimited.
let mut resource_limits = serde_json::Map::new();
if let Some(mem_bytes) = manifest
.app
.resources
.memory_limit
.as_ref()
.and_then(|m| parse_memory_limit(m))
{
resource_limits.insert(
"memory".to_string(),
serde_json::json!({ "limit": mem_bytes }),
);
}
if let Some(cpu) = manifest.app.resources.cpu_limit {
resource_limits.insert(
"cpu".to_string(),
serde_json::json!({
"quota": (cpu as i64) * 100_000,
"period": 100_000u64,
}),
);
}
let (net_mode, custom_network) = podman_network_settings(
manifest.app.container.network.as_deref(),
manifest.app.security.network_policy.as_str(),
);
let mut body = serde_json::json!({
"name": name,
"image": image_ref,
"portmappings": port_mappings,
"mounts": mounts,
"env": env_map,
"entrypoint": manifest.app.container.entrypoint.clone(),
"command": manifest.app.container.custom_args.clone(),
"hostadd": [
"host.containers.internal:host-gateway",
"host.archipelago:10.89.0.1",
],
"devices": manifest.app.devices.iter().map(|d| {
serde_json::json!({"path": d})
}).collect::<Vec<_>>(),
"resource_limits": resource_limits,
"cap_add": cap_add,
"cap_drop": cap_drop,
"read_only_filesystem": manifest.app.security.readonly_root,
"no_new_privileges": manifest.app.security.no_new_privileges,
"restart_policy": "unless-stopped",
"restart_tries": 5,
"netns": {
"nsmode": net_mode
},
});
if let Some(network) = custom_network {
// The container always answers to its own name; manifest
// network_aliases add extra short hostnames peers may bake in
// (e.g. indeedhub's api/minio/relay). Dedup so a manifest that
// redundantly lists its own name doesn't double it.
let mut aliases = vec![name.to_string()];
for a in &manifest.app.container.network_aliases {
if !aliases.iter().any(|x| x == a) {
aliases.push(a.clone());
}
}
body.as_object_mut()
.expect("container create body is a JSON object")
.insert(
"networks".to_string(),
serde_json::json!({ network: { "aliases": aliases } }),
);
}
let result = self
.api_request("POST", "libpod/containers/create", Some(body), LONG_TIMEOUT)
.await?;
let id = result["Id"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.context("Podman API returned no container ID — creation may have failed")?;
Ok(id)
}
pub async fn start_container(&self, name: &str) -> Result<()> {
self.api_post_action(&format!("libpod/containers/{}/start", name))
.await
}
pub async fn stop_container(&self, name: &str) -> Result<()> {
self.stop_container_with_grace(name, 10).await
}
/// Stop via libpod honouring a per-app grace (seconds). The HTTP deadline is
/// kept above the grace so the post-grace SIGKILL lands before we give up —
/// otherwise slow-to-SIGTERM apps (fedimint, bitcoin-core, electrumx…) time
/// out at exactly the grace boundary and the stop is reported as failed.
pub async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> {
let deadline = std::time::Duration::from_secs(
grace_secs + crate::runtime::STOP_GRACE_DEADLINE_BUFFER_SECS,
);
self.api_request(
"POST",
&format!("libpod/containers/{}/stop?t={}", name, grace_secs),
None,
deadline,
)
.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<()> {
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<ContainerStatus> {
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);
let exit_code = data["State"]["ExitCode"].as_i64().map(|c| c as i32);
Ok(ContainerStatus {
id: data["Id"].as_str().unwrap_or("").to_string(),
name: container_name,
state: ContainerState::from(state_str),
health,
exit_code,
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<Vec<String>> {
// 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 = 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));
}
// 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<Vec<ContainerStatus>> {
let data = self
.api_request(
"GET",
"libpod/containers/json?all=true",
None,
DEFAULT_TIMEOUT,
)
.await?;
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
.first()
.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);
let exit_code = c["ExitCode"]
.as_i64()
.or_else(|| c["State"]["ExitCode"].as_i64())
.map(|c| c as i32);
result.push(ContainerStatus {
id: c["Id"].as_str().unwrap_or("").to_string(),
name,
state: ContainerState::from(c["State"].as_str().unwrap_or("unknown")),
health,
exit_code,
started_at,
image: c["Image"].as_str().unwrap_or("").to_string(),
created: c["Created"].as_str().unwrap_or("").to_string(),
ports,
lan_address,
});
}
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()
}
}
/// Registries we ship with as `--tls-verify=false` because they're internal
/// HTTP mirrors. Add a host:port here only if it's a controlled mirror that
/// the fleet trusts and operators won't ever paste a malicious URL into.
const INSECURE_REGISTRY_HOSTS: &[&str] = &["146.59.87.168:3000"];
pub fn image_uses_insecure_registry(image: &str) -> bool {
image
.split('/')
.next()
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
}
fn podman_network_settings(
network: Option<&str>,
network_policy: &str,
) -> (&'static str, Option<String>) {
match network {
Some("") => ("bridge", None),
Some("host") => ("host", None),
Some("bridge") => ("bridge", None),
Some("none") => ("none", None),
Some("slirp4netns") => ("slirp4netns", None),
Some("pasta") => ("pasta", None),
Some("private") => ("private", None),
Some(custom) => ("bridge", Some(custom.to_string())),
None if network_policy == "host" => ("host", None),
None => ("bridge", None),
}
}
// ─── Helpers ─────────────────────────────────────────────────────
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
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 manifest_lan_address_for(container_name: &str) -> Option<String> {
for apps_dir in manifest_apps_dirs() {
let Ok(entries) = std::fs::read_dir(apps_dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path().join("manifest.yml");
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(manifest) = AppManifest::parse(&contents) else {
continue;
};
if manifest_runtime_names(&manifest)
.iter()
.any(|name| name == container_name)
{
if let Some(url) = manifest_primary_interface_url(&manifest) {
return Some(url);
}
if manifest_has_http_health(&manifest) {
if let Some(port) = manifest
.app
.ports
.iter()
.find(|port| port.protocol.eq_ignore_ascii_case("tcp"))
.map(|port| port.host)
{
return Some(format!("http://localhost:{port}"));
}
}
}
}
}
None
}
fn manifest_primary_interface_url(manifest: &AppManifest) -> Option<String> {
let main = manifest.app.interfaces.get("main")?;
if main.interface_type != "ui" {
return None;
}
Some(format!(
"{}://localhost:{}{}",
main.protocol, main.port, main.path
))
}
fn manifest_has_http_health(manifest: &AppManifest) -> bool {
manifest
.app
.health_check
.as_ref()
.is_some_and(|health| health.check_type.eq_ignore_ascii_case("http"))
}
fn manifest_runtime_names(manifest: &AppManifest) -> Vec<String> {
let mut names = vec![manifest_container_name(manifest)];
match manifest.app.id.as_str() {
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => names.push(manifest.app.id.clone()),
"fedimint" => names.push("fedimintd".to_string()),
"immich" => names.push("immich_server".to_string()),
_ => {}
}
names
}
fn manifest_container_name(manifest: &AppManifest) -> String {
if let Some(v) = manifest.app.extensions.get("container_name") {
if let Some(s) = v.as_str() {
if !s.is_empty() {
return s.to_string();
}
}
}
match manifest.app.id.as_str() {
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => format!("archy-{}", manifest.app.id),
id => id.to_string(),
}
}
fn manifest_apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
dirs.extend([
Path::new("apps").to_path_buf(),
Path::new("/opt/archipelago/apps").to_path_buf(),
Path::new("/opt/archipelago/web-ui/archipelago-runtime/apps").to_path_buf(),
]);
dirs
}
fn parse_memory_limit(limit: &str) -> Option<i64> {
// Supports the Kubernetes-style suffixes used throughout apps/*/manifest.yml
// (IEC binary: Ki/Mi/Gi/Ti) as well as the shorter docker-style k/m/g/t.
// Longest suffix matched first so "Mi" isn't mis-matched as "m".
//
// Historical bug: we used to lowercase+trim_end_matches('m'), which turned
// "128Mi" into "128i" → parse::<f64> failed → None → .unwrap_or(0) wrote
// memory.limit:0 into the OCI spec, which systemd then rejected at start
// time with "MemoryMax is out of range" on rootless podman. See
// docs/rust-orchestrator-migration.md Step 9 notes.
let trimmed = limit.trim();
if trimmed.is_empty() {
return None;
}
const UNITS: &[(&str, i64)] = &[
("Ki", 1024),
("Mi", 1024 * 1024),
("Gi", 1024 * 1024 * 1024),
("Ti", 1024i64 * 1024 * 1024 * 1024),
("kB", 1000),
("MB", 1_000_000),
("GB", 1_000_000_000),
("TB", 1_000_000_000_000),
("k", 1024),
("K", 1024),
("m", 1024 * 1024),
("M", 1024 * 1024),
("g", 1024 * 1024 * 1024),
("G", 1024 * 1024 * 1024),
("t", 1024i64 * 1024 * 1024 * 1024),
("T", 1024i64 * 1024 * 1024 * 1024),
("b", 1),
("B", 1),
];
for (suffix, multiplier) in UNITS {
if let Some(num) = trimmed.strip_suffix(suffix) {
let num = num.trim();
return num
.parse::<f64>()
.ok()
.map(|v| (v * (*multiplier as f64)) as i64)
.filter(|n| *n > 0);
}
}
// No recognised suffix — treat as raw bytes.
trimmed.parse::<i64>().ok().filter(|n| *n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insecure_registry_detection_matches_http_mirrors_only() {
assert!(image_uses_insecure_registry(
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
));
// The legacy Hetzner mirror at 23.182.128.160 was decommissioned and
// is no longer trusted — it must NOT bypass TLS even if a stale
// registry config still references it.
assert!(!image_uses_insecure_registry(
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
));
assert!(!image_uses_insecure_registry(
"git.tx1138.com/lfg2025/bitcoin-knots:latest"
));
assert!(!image_uses_insecure_registry(
"docker.io/library/nginx:latest"
));
// Spoofing immune: an attacker host that prefixes the trusted IP
// string into its own URL still has the attacker host in the
// registry-host slot, so it does NOT match.
assert!(!image_uses_insecure_registry(
"evil.example:80/146.59.87.168:3000/lfg2025/x:latest"
));
}
#[test]
fn podman_network_settings_uses_networks_map_for_custom_networks() {
assert_eq!(
podman_network_settings(Some("archy-net"), "isolated"),
("bridge", Some("archy-net".to_string()))
);
assert_eq!(
podman_network_settings(Some("host"), "isolated"),
("host", None)
);
assert_eq!(
podman_network_settings(Some(""), "isolated"),
("bridge", None)
);
assert_eq!(podman_network_settings(None, "host"), ("host", None));
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
}
#[test]
fn lan_address_uses_manifest_http_port_for_regular_apps() {
assert_eq!(
PodmanClient::lan_address_for("filebrowser").as_deref(),
Some("http://localhost:8083")
);
}
#[test]
fn lan_address_prefers_manifest_main_interface() {
assert_eq!(
PodmanClient::lan_address_for("fedimint").as_deref(),
Some("http://localhost:8175/")
);
}
#[test]
fn lan_address_does_not_expose_tcp_only_service_ports() {
assert_eq!(
PodmanClient::lan_address_for("bitcoin-knots").as_deref(),
Some("http://localhost:8334")
);
}
#[test]
fn parse_memory_limit_iec_binary_suffixes() {
// Kubernetes-style — this is what apps/*/manifest.yml uses.
assert_eq!(parse_memory_limit("128Mi"), Some(128 * 1024 * 1024));
assert_eq!(parse_memory_limit("64Mi"), Some(64 * 1024 * 1024));
assert_eq!(parse_memory_limit("4Gi"), Some(4i64 * 1024 * 1024 * 1024));
assert_eq!(parse_memory_limit("512Ki"), Some(512 * 1024));
}
#[test]
fn parse_memory_limit_shorthand_suffixes() {
// Docker-style shorthand — treated as IEC binary for backwards compat.
assert_eq!(parse_memory_limit("128m"), Some(128 * 1024 * 1024));
assert_eq!(parse_memory_limit("128M"), Some(128 * 1024 * 1024));
assert_eq!(parse_memory_limit("2g"), Some(2i64 * 1024 * 1024 * 1024));
assert_eq!(parse_memory_limit("2G"), Some(2i64 * 1024 * 1024 * 1024));
}
#[test]
fn parse_memory_limit_si_decimal_suffixes() {
assert_eq!(parse_memory_limit("1MB"), Some(1_000_000));
assert_eq!(parse_memory_limit("1GB"), Some(1_000_000_000));
}
#[test]
fn parse_memory_limit_raw_bytes() {
assert_eq!(parse_memory_limit("134217728"), Some(134_217_728));
assert_eq!(parse_memory_limit(" 134217728 "), Some(134_217_728));
}
#[test]
fn parse_memory_limit_invalid_returns_none() {
// Regression guard: the old implementation returned Some(0) for "128Mi"
// because lowercase+trim_end_matches('m') left "128i" which parse::<f64>
// rejected. The new implementation must never return Some(0) or Some of
// a negative number from any input.
assert_eq!(parse_memory_limit(""), None);
assert_eq!(parse_memory_limit(" "), None);
assert_eq!(parse_memory_limit("abc"), None);
assert_eq!(parse_memory_limit("0"), None);
assert_eq!(parse_memory_limit("0Mi"), None);
assert_eq!(parse_memory_limit("-1Mi"), None);
}
#[test]
fn parse_memory_limit_tolerates_whitespace_and_fractional() {
assert_eq!(
parse_memory_limit(" 1.5Gi "),
Some((1.5 * (1024.0 * 1024.0 * 1024.0)) as i64)
);
}
}