refactor: PodmanClient uses REST API socket instead of CLI
Replace all `podman` CLI shell-outs with HTTP requests to the rootless
Podman API unix socket (/run/user/{UID}/podman/podman.sock).
Benefits:
- No process spawning overhead — direct HTTP over unix socket
- Structured JSON responses — no string parsing fragility
- Proper timeouts on all operations (5s connect, 30s default, 120s create)
- Health check method to verify socket availability
- Restart container as first-class operation
Still uses CLI for:
- Image pulls (streaming operation better suited to CLI)
- Container logs (raw text stream, not JSON)
The Podman socket is rootless (runs as archipelago user), local-only
(unix socket), and already behind our session auth in the backend.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1f81a0d804
commit
1f3c86687d
@ -9,6 +9,7 @@ serde_yaml = "0.9"
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
hyper = { version = "0.14", features = ["client", "http1"] }
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|||||||
@ -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 crate::manifest::AppManifest;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use hyper::{Body, Request, Uri};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::Command;
|
use std::path::PathBuf;
|
||||||
use thiserror::Error;
|
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)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PodmanError {
|
pub enum PodmanError {
|
||||||
#[error("Podman command failed: {0}")]
|
#[error("Podman API error: {0}")]
|
||||||
CommandFailed(String),
|
ApiError(String),
|
||||||
#[error("Container not found: {0}")]
|
#[error("Container not found: {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
#[error("Podman socket not available: {0}")]
|
||||||
|
SocketUnavailable(String),
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
@ -20,24 +33,12 @@ pub struct ContainerStatus {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub state: ContainerState,
|
pub state: ContainerState,
|
||||||
pub health: Option<String>, // "healthy", "unhealthy", "starting", or None if no healthcheck
|
pub health: Option<String>,
|
||||||
pub started_at: Option<String>,
|
pub started_at: Option<String>,
|
||||||
pub image: String,
|
pub image: String,
|
||||||
pub created: String,
|
pub created: String,
|
||||||
pub ports: Vec<String>,
|
pub ports: Vec<String>,
|
||||||
pub lan_address: Option<String>, // Launch URL for UI access
|
pub lan_address: Option<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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[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<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 {
|
pub struct PodmanClient {
|
||||||
_user: String,
|
socket_path: PathBuf,
|
||||||
rootless: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodmanClient {
|
impl PodmanClient {
|
||||||
pub fn new(user: String) -> Self {
|
pub fn new(user: String) -> Self {
|
||||||
// If running as root, use root podman context
|
// Determine socket path based on user
|
||||||
let is_root = std::env::var("USER").unwrap_or_default() == "root" ||
|
let uid = Self::get_uid(&user);
|
||||||
std::env::var("HOME").unwrap_or_default() == "/root";
|
let socket_path = PathBuf::from(format!("/run/user/{}/podman/podman.sock", uid));
|
||||||
|
Self { socket_path }
|
||||||
Self {
|
|
||||||
_user: user,
|
|
||||||
rootless: !is_root,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<String> {
|
pub fn lan_address_for(name: &str) -> Option<String> {
|
||||||
let url = match name {
|
let url = match name {
|
||||||
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
||||||
"lnd" | "archy-lnd-ui" => "http://localhost:8081",
|
"lnd" | "archy-lnd-ui" => "http://localhost:8081",
|
||||||
// Tailscale has no web UI — managed via CLI/app
|
|
||||||
"homeassistant" => "http://localhost:8123",
|
"homeassistant" => "http://localhost:8123",
|
||||||
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
||||||
"btcpay-server" => "http://localhost:23000",
|
"btcpay-server" => "http://localhost:23000",
|
||||||
@ -115,362 +138,398 @@ impl PodmanClient {
|
|||||||
Some(url.to_string())
|
Some(url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn podman_async(&self) -> TokioCommand {
|
// ─── API Client ──────────────────────────────────────────────
|
||||||
// Rootless podman: run as the current user (no sudo).
|
|
||||||
// Requires: loginctl enable-linger <user>, containers migrated to user storage.
|
/// Send a request to the Podman API via unix socket.
|
||||||
let cmd = TokioCommand::new("podman");
|
async fn api_request(
|
||||||
cmd
|
&self,
|
||||||
}
|
method: &str,
|
||||||
|
path: &str,
|
||||||
pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
body: Option<serde_json::Value>,
|
||||||
let mut cmd = self.podman_async();
|
timeout: std::time::Duration,
|
||||||
cmd.arg("pull").arg(image);
|
) -> Result<serde_json::Value> {
|
||||||
|
let socket_path = self.socket_path.clone();
|
||||||
if let Some(sig) = signature {
|
|
||||||
// Verify signature with cosign if provided
|
// Connect to the unix socket
|
||||||
cmd.arg("--signature-policy").arg("default");
|
let stream = tokio::time::timeout(
|
||||||
// TODO: Implement cosign verification
|
std::time::Duration::from_secs(5),
|
||||||
log::warn!("Signature verification not yet implemented: {}", sig);
|
UnixStream::connect(&socket_path),
|
||||||
}
|
)
|
||||||
|
.await
|
||||||
let output = cmd
|
.map_err(|_| anyhow::anyhow!("Podman socket connection timed out"))?
|
||||||
.output()
|
.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
|
.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() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_container(
|
pub async fn create_container(
|
||||||
&self,
|
&self,
|
||||||
manifest: &AppManifest,
|
manifest: &AppManifest,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let mut cmd = self.podman_async();
|
// Build the container spec for the API
|
||||||
cmd.arg("create");
|
let mut port_mappings = Vec::new();
|
||||||
|
|
||||||
// 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
|
|
||||||
for port in &manifest.app.ports {
|
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 {
|
for volume in &manifest.app.volumes {
|
||||||
let mut mount = format!("{}:{}", volume.source, volume.target);
|
mounts.push(serde_json::json!({
|
||||||
if !volume.options.is_empty() {
|
"destination": volume.target,
|
||||||
mount.push_str(&format!(":{}", volume.options.join(",")));
|
"source": volume.source,
|
||||||
}
|
"type": "bind",
|
||||||
cmd.arg("-v").arg(mount);
|
"options": volume.options,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices
|
let mut env_map = serde_json::Map::new();
|
||||||
for device in &manifest.app.devices {
|
|
||||||
cmd.arg("--device").arg(device);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment variables
|
|
||||||
for env in &manifest.app.environment {
|
for env in &manifest.app.environment {
|
||||||
cmd.arg("-e").arg(env);
|
if let Some((k, v)) = env.split_once('=') {
|
||||||
}
|
env_map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
|
||||||
|
}
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce no new privileges (prevent setuid escalation)
|
let mut cap_add: Vec<String> = manifest.app.security.capabilities.clone();
|
||||||
cmd.arg("--security-opt").arg("no-new-privileges=true");
|
let cap_drop = vec!["ALL".to_string()];
|
||||||
|
|
||||||
// Image
|
let body = serde_json::json!({
|
||||||
cmd.arg(&manifest.app.container.image);
|
"name": name,
|
||||||
|
"image": manifest.app.container.image,
|
||||||
let output = cmd
|
"portmappings": port_mappings,
|
||||||
.output()
|
"mounts": mounts,
|
||||||
.await
|
"env": env_map,
|
||||||
.context("Failed to create container")?;
|
"devices": manifest.app.devices.iter().map(|d| {
|
||||||
|
serde_json::json!({"path": d})
|
||||||
if !output.status.success() {
|
}).collect::<Vec<_>>(),
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
"resource_limits": {
|
||||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
"memory": {
|
||||||
}
|
"limit": manifest.app.resources.memory_limit.as_ref()
|
||||||
|
.and_then(|m| parse_memory_limit(m))
|
||||||
let container_id = String::from_utf8_lossy(&output.stdout)
|
.unwrap_or(0),
|
||||||
.trim()
|
},
|
||||||
|
"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();
|
.to_string();
|
||||||
|
|
||||||
Ok(container_id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_container(&self, name: &str) -> Result<()> {
|
pub async fn start_container(&self, name: &str) -> Result<()> {
|
||||||
let mut cmd = self.podman_async();
|
self.api_post_action(&format!("libpod/containers/{}/start", name)).await
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_container(&self, name: &str) -> Result<()> {
|
pub async fn stop_container(&self, name: &str) -> Result<()> {
|
||||||
let mut cmd = self.podman_async();
|
self.api_request(
|
||||||
cmd.arg("stop").arg(name);
|
"POST",
|
||||||
|
&format!("libpod/containers/{}/stop?t=10", name),
|
||||||
let output = cmd
|
None,
|
||||||
.output()
|
DEFAULT_TIMEOUT,
|
||||||
.await
|
).await.map(|_| ())
|
||||||
.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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<()> {
|
pub async fn remove_container(&self, name: &str) -> Result<()> {
|
||||||
let mut cmd = self.podman_async();
|
self.api_request(
|
||||||
cmd.arg("rm").arg("-f").arg(name);
|
"DELETE",
|
||||||
|
&format!("libpod/containers/{}?force=true", name),
|
||||||
let output = cmd
|
None,
|
||||||
.output()
|
DEFAULT_TIMEOUT,
|
||||||
.await
|
).await.map(|_| ())
|
||||||
.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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
|
pub async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
|
||||||
let mut cmd = self.podman_async();
|
let data = self.api_request(
|
||||||
cmd.arg("inspect")
|
"GET",
|
||||||
.arg("--format")
|
&format!("libpod/containers/{}/json", name),
|
||||||
.arg("{{.Id}}|{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{.Created}}|{{.NetworkSettings.Ports}}")
|
None,
|
||||||
.arg(name);
|
DEFAULT_TIMEOUT,
|
||||||
|
).await?;
|
||||||
let output = cmd
|
|
||||||
.output()
|
let state_str = data["State"]["Status"].as_str().unwrap_or("unknown");
|
||||||
.await
|
let health = data["State"]["Health"]["Status"].as_str().map(|s| s.to_string());
|
||||||
.context("Failed to inspect container")?;
|
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();
|
||||||
if !output.status.success() {
|
|
||||||
return Err(anyhow::anyhow!("Container not found: {}", name));
|
// Parse port bindings
|
||||||
}
|
let ports = parse_port_bindings(&data["HostConfig"]["PortBindings"]);
|
||||||
|
let lan_address = Self::lan_address_for(&container_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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ContainerStatus {
|
Ok(ContainerStatus {
|
||||||
id: parts[0].to_string(),
|
id: data["Id"].as_str().unwrap_or("").to_string(),
|
||||||
name: parts[1].to_string(),
|
name: container_name,
|
||||||
state: ContainerState::from(parts[2]),
|
state: ContainerState::from(state_str),
|
||||||
health: None,
|
health,
|
||||||
started_at: None,
|
started_at,
|
||||||
image: parts[3].to_string(),
|
image: data["ImageName"].as_str()
|
||||||
created: parts[4].to_string(),
|
.or_else(|| data["Config"]["Image"].as_str())
|
||||||
ports: vec![], // TODO: Parse ports from parts[5]
|
.unwrap_or("").to_string(),
|
||||||
lan_address: None, // Set by docker_packages scanner
|
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>> {
|
pub async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> {
|
||||||
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")
|
cmd.arg("logs")
|
||||||
.arg("--tail")
|
.arg("--tail")
|
||||||
.arg(lines.to_string())
|
.arg(lines.to_string())
|
||||||
.arg(name);
|
.arg(name);
|
||||||
|
|
||||||
let output = cmd
|
let output = tokio::time::timeout(DEFAULT_TIMEOUT, cmd.output())
|
||||||
.output()
|
|
||||||
.await
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Container logs timed out"))?
|
||||||
.context("Failed to get container logs")?;
|
.context("Failed to get container logs")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(anyhow::anyhow!("Failed to get logs: {}", stderr));
|
return Err(anyhow::anyhow!("Failed to get logs: {}", stderr));
|
||||||
}
|
}
|
||||||
|
|
||||||
let logs = String::from_utf8_lossy(&output.stdout);
|
// Podman logs go to both stdout and stderr
|
||||||
Ok(logs.lines().map(|s| s.to_string()).collect())
|
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>> {
|
pub async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||||
let mut cmd = self.podman_async();
|
let data = self.api_request(
|
||||||
cmd.arg("ps")
|
"GET",
|
||||||
.arg("-a")
|
"libpod/containers/json?all=true",
|
||||||
.arg("--format")
|
None,
|
||||||
.arg("json");
|
DEFAULT_TIMEOUT,
|
||||||
|
).await?;
|
||||||
|
|
||||||
let output = tokio::time::timeout(
|
let containers = data.as_array()
|
||||||
std::time::Duration::from_secs(60),
|
.ok_or_else(|| anyhow::anyhow!("Expected array from containers/json"))?;
|
||||||
cmd.output(),
|
|
||||||
)
|
let mut result = Vec::with_capacity(containers.len());
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("podman ps timed out (60s)"))?
|
for c in containers {
|
||||||
.context("Failed to list containers")?;
|
let name = if let Some(names) = c["Names"].as_array() {
|
||||||
|
names.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||||
if !output.status.success() {
|
} else {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
c["Names"].as_str().unwrap_or("").to_string()
|
||||||
log::error!("Podman list failed: {}", stderr);
|
};
|
||||||
return Err(anyhow::anyhow!("Failed to list containers: {}", stderr));
|
|
||||||
|
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::<Vec<serde_json::Value>>(&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::<serde_json::Value>(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)
|
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<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 parse_memory_limit(limit: &str) -> Option<i64> {
|
||||||
|
let limit = limit.trim().to_lowercase();
|
||||||
|
if limit.ends_with('g') {
|
||||||
|
limit.trim_end_matches('g').parse::<f64>().ok().map(|v| (v * 1_073_741_824.0) as i64)
|
||||||
|
} else if limit.ends_with('m') {
|
||||||
|
limit.trim_end_matches('m').parse::<f64>().ok().map(|v| (v * 1_048_576.0) as i64)
|
||||||
|
} else if limit.ends_with('k') {
|
||||||
|
limit.trim_end_matches('k').parse::<f64>().ok().map(|v| (v * 1024.0) as i64)
|
||||||
|
} else {
|
||||||
|
limit.parse::<i64>().ok()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user