use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use log::{error, info};
use serde::{Deserialize, Serialize};
use crate::ssh_context::{OwnedSshContext, SshContext};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContainerInfo {
#[serde(rename = "ID", alias = "Id")]
pub id: String,
#[serde(rename = "Names", deserialize_with = "deserialize_names_field")]
pub names: String,
#[serde(rename = "Image")]
pub image: String,
#[serde(rename = "State")]
pub state: String,
#[serde(rename = "Status", default)]
pub status: String,
#[serde(
rename = "Ports",
deserialize_with = "deserialize_ports_field",
default
)]
pub ports: String,
}
fn deserialize_names_field<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NamesField {
Scalar(String),
Array(Vec<String>),
}
match NamesField::deserialize(deserializer)? {
NamesField::Scalar(s) => Ok(s),
NamesField::Array(arr) => Ok(arr.join(",")),
}
}
fn deserialize_ports_field<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum PortsField {
Scalar(String),
Array(Vec<PodmanPort>),
}
match Option::<PortsField>::deserialize(deserializer)? {
Some(PortsField::Scalar(s)) => Ok(s),
Some(PortsField::Array(arr)) => Ok(format_podman_ports(&arr)),
None => Ok(String::new()),
}
}
#[derive(Deserialize)]
struct PodmanPort {
#[serde(default)]
host_ip: String,
#[serde(default)]
container_port: u32,
#[serde(default)]
host_port: u32,
#[serde(default = "podman_port_default_range")]
range: u32,
#[serde(default)]
protocol: String,
}
fn podman_port_default_range() -> u32 {
1
}
fn format_podman_ports(ports: &[PodmanPort]) -> String {
let mut out = String::with_capacity(ports.len().saturating_mul(24));
for (i, p) in ports.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_podman_port(p, &mut out);
}
out
}
fn write_podman_port(p: &PodmanPort, out: &mut String) {
use std::fmt::Write as _;
let protocol = if p.protocol.is_empty() {
"tcp"
} else {
p.protocol.as_str()
};
if p.host_port != 0 {
if !p.host_ip.is_empty() {
let _ = write!(out, "{}:", p.host_ip);
}
if p.range > 1 {
let _ = write!(
out,
"{}-{}->",
p.host_port,
p.host_port.saturating_add(p.range.saturating_sub(1))
);
} else {
let _ = write!(out, "{}->", p.host_port);
}
}
if p.range > 1 {
let _ = write!(
out,
"{}-{}",
p.container_port,
p.container_port.saturating_add(p.range.saturating_sub(1))
);
} else {
let _ = write!(out, "{}", p.container_port);
}
let _ = write!(out, "/{protocol}");
}
fn try_parse_container_line(trimmed: &str) -> Option<ContainerInfo> {
if trimmed.is_empty() {
return None;
}
match serde_json::from_str(trimmed) {
Ok(c) => Some(c),
Err(e) if trimmed.starts_with('{') => {
log::debug!(
"[external] container parse: dropped JSON line: {} (err: {})",
&trimmed[..trimmed.len().min(120)],
e
);
None
}
Err(_) => None,
}
}
#[allow(dead_code)]
pub fn parse_container_ps(output: &str) -> Vec<ContainerInfo> {
output
.lines()
.filter_map(|line| try_parse_container_line(line.trim()))
.collect()
}
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ContainerRuntime {
Docker,
Podman,
}
impl ContainerRuntime {
pub fn as_str(&self) -> &'static str {
match self {
ContainerRuntime::Docker => "docker",
ContainerRuntime::Podman => "podman",
}
}
}
#[allow(dead_code)]
pub fn parse_runtime(output: &str) -> Option<ContainerRuntime> {
let last = output
.lines()
.rev()
.map(|l| l.trim())
.find(|l| !l.is_empty())?;
match last {
"docker" => Some(ContainerRuntime::Docker),
"podman" => Some(ContainerRuntime::Podman),
_ => None,
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ContainerAction {
Start,
Stop,
Restart,
}
impl ContainerAction {
pub fn as_str(&self) -> &'static str {
match self {
ContainerAction::Start => "start",
ContainerAction::Stop => "stop",
ContainerAction::Restart => "restart",
}
}
}
pub fn container_action_command(
runtime: ContainerRuntime,
action: ContainerAction,
container_id: &str,
) -> String {
format!("{} {} {}", runtime.as_str(), action.as_str(), container_id)
}
pub fn validate_container_id(id: &str) -> Result<(), String> {
if id.is_empty() {
return Err(crate::messages::CONTAINER_ID_EMPTY.to_string());
}
for c in id.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
return Err(crate::messages::container_id_invalid_char(c));
}
}
Ok(())
}
pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
match runtime {
Some(ContainerRuntime::Docker) => concat!(
"docker ps -a --format '{{json .}}' && ",
"echo '##purple:engine##' && ",
"{ docker version --format '{{.Server.Version}}' 2>/dev/null || true; }"
)
.to_string(),
Some(ContainerRuntime::Podman) => concat!(
"podman ps -a --format '{{json .}}' && ",
"echo '##purple:engine##' && ",
"{ podman version --format '{{.Server.Version}}' 2>/dev/null || true; }"
)
.to_string(),
None => concat!(
"if command -v docker >/dev/null 2>&1; then ",
"echo '##purple:docker##' && docker ps -a --format '{{json .}}' && ",
"echo '##purple:engine##' && ",
"{ docker version --format '{{.Server.Version}}' 2>/dev/null || true; }; ",
"elif command -v podman >/dev/null 2>&1; then ",
"echo '##purple:podman##' && podman ps -a --format '{{json .}}' && ",
"echo '##purple:engine##' && ",
"{ podman version --format '{{.Server.Version}}' 2>/dev/null || true; }; ",
"else echo '##purple:none##'; fi"
)
.to_string(),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContainerListing {
pub runtime: ContainerRuntime,
pub engine_version: Option<String>,
pub containers: Vec<ContainerInfo>,
}
pub fn parse_container_output(
output: &str,
caller_runtime: Option<ContainerRuntime>,
) -> Result<ContainerListing, String> {
let runtime = match output
.lines()
.map(str::trim)
.find(|l| l.starts_with("##purple:") && (*l != "##purple:engine##"))
{
Some("##purple:none##") => {
return Err(crate::messages::CONTAINER_RUNTIME_MISSING.to_string());
}
Some("##purple:docker##") => ContainerRuntime::Docker,
Some("##purple:podman##") => ContainerRuntime::Podman,
Some(other) => return Err(crate::messages::container_unknown_sentinel(other)),
None => match caller_runtime {
Some(rt) => rt,
None => return Err("No sentinel found and no runtime provided.".to_string()),
},
};
let mut engine_version: Option<String> = None;
let mut after_engine = false;
let mut containers: Vec<ContainerInfo> = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if trimmed == "##purple:engine##" {
after_engine = true;
continue;
}
if trimmed.starts_with("##purple:") {
continue;
}
if after_engine {
if !trimmed.is_empty() && engine_version.is_none() {
engine_version = Some(trimmed.to_string());
}
continue;
}
if let Some(c) = try_parse_container_line(trimmed) {
containers.push(c);
}
}
let runtime = if matches!(runtime, ContainerRuntime::Docker) && looks_like_podman(output) {
log::debug!(
"[external] container detection: docker sentinel emitted podman-shaped JSON, relabeling runtime to Podman"
);
ContainerRuntime::Podman
} else {
runtime
};
log::debug!(
"[external] container listing parsed: runtime={:?} version={:?} containers={}",
runtime,
engine_version,
containers.len()
);
Ok(ContainerListing {
runtime,
engine_version,
containers,
})
}
fn looks_like_podman(output: &str) -> bool {
for line in output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("##purple:") || !trimmed.starts_with('{') {
continue;
}
return trimmed.contains("\"Names\":[") || trimmed.contains("\"Names\": [");
}
false
}
#[derive(Debug)]
pub struct ContainerError {
pub runtime: Option<ContainerRuntime>,
pub message: String,
}
impl std::fmt::Display for ContainerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
let lower = stderr.to_lowercase();
if lower.contains("remote host identification has changed")
|| (lower.contains("host key for") && lower.contains("has changed"))
{
log::debug!("[external] Host key CHANGED detected; returning HOST_KEY_CHANGED toast");
crate::messages::HOST_KEY_CHANGED.to_string()
} else if lower.contains("host key verification failed")
|| lower.contains("no matching host key")
|| lower.contains("no ed25519 host key is known")
|| lower.contains("no rsa host key is known")
|| lower.contains("no ecdsa host key is known")
|| lower.contains("host key is not known")
{
log::debug!("[external] Host key UNKNOWN detected; returning HOST_KEY_UNKNOWN toast");
crate::messages::HOST_KEY_UNKNOWN.to_string()
} else if lower.contains("command not found") {
crate::messages::CONTAINER_RUNTIME_NOT_FOUND.to_string()
} else if lower.contains("permission denied") || lower.contains("got permission denied") {
crate::messages::CONTAINER_PERMISSION_DENIED.to_string()
} else if lower.contains("cannot connect to the docker daemon")
|| lower.contains("cannot connect to podman")
{
crate::messages::CONTAINER_DAEMON_NOT_RUNNING.to_string()
} else if lower.contains("connection refused") {
crate::messages::CONTAINER_CONNECTION_REFUSED.to_string()
} else if lower.contains("no route to host") || lower.contains("network is unreachable") {
crate::messages::CONTAINER_HOST_UNREACHABLE.to_string()
} else {
crate::messages::container_command_failed(code.unwrap_or(1))
}
}
pub fn fetch_containers(
ctx: &SshContext<'_>,
cached_runtime: Option<ContainerRuntime>,
) -> Result<ContainerListing, ContainerError> {
let command = container_list_command(cached_runtime);
let result = crate::snippet::run_snippet(
ctx.alias,
ctx.config_path,
&command,
ctx.askpass,
ctx.bw_session,
true,
ctx.has_tunnel,
);
let alias = ctx.alias;
match result {
Ok(r) if r.status.success() => {
parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
error!("[external] Container list parse failed: alias={alias}: {e}");
ContainerError {
runtime: cached_runtime,
message: e,
}
})
}
Ok(r) => {
let stderr = r.stderr.trim().to_string();
let msg = friendly_container_error(&stderr, r.status.code());
error!("[external] Container fetch failed: alias={alias}: {msg}");
Err(ContainerError {
runtime: cached_runtime,
message: msg,
})
}
Err(e) => {
error!("[external] Container fetch failed: alias={alias}: {e}");
Err(ContainerError {
runtime: cached_runtime,
message: e.to_string(),
})
}
}
}
pub fn spawn_container_listing<F>(
ctx: OwnedSshContext,
cached_runtime: Option<ContainerRuntime>,
send: F,
) where
F: FnOnce(String, Result<ContainerListing, ContainerError>) + Send + 'static,
{
std::thread::spawn(move || {
let borrowed = SshContext {
alias: &ctx.alias,
config_path: &ctx.config_path,
askpass: ctx.askpass.as_deref(),
bw_session: ctx.bw_session.as_deref(),
has_tunnel: ctx.has_tunnel,
};
let result = fetch_containers(&borrowed, cached_runtime);
send(ctx.alias, result);
});
}
pub fn spawn_container_action<F>(
ctx: OwnedSshContext,
runtime: ContainerRuntime,
action: ContainerAction,
container_id: String,
send: F,
) where
F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
{
std::thread::spawn(move || {
if let Err(e) = validate_container_id(&container_id) {
send(ctx.alias, action, Err(e));
return;
}
let alias = &ctx.alias;
info!(
"Container action: {} container={container_id} alias={alias}",
action.as_str()
);
let command = container_action_command(runtime, action, &container_id);
let result = crate::snippet::run_snippet(
alias,
&ctx.config_path,
&command,
ctx.askpass.as_deref(),
ctx.bw_session.as_deref(),
true,
ctx.has_tunnel,
);
match result {
Ok(r) if r.status.success() => send(ctx.alias, action, Ok(())),
Ok(r) => {
let err = friendly_container_error(r.stderr.trim(), r.status.code());
error!(
"[external] Container {} failed: alias={alias} container={container_id}: {err}",
action.as_str()
);
send(ctx.alias, action, Err(err));
}
Err(e) => {
error!(
"[external] Container {} failed: alias={alias} container={container_id}: {e}",
action.as_str()
);
send(ctx.alias, action, Err(e.to_string()));
}
}
});
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ContainerInspect {
pub exit_code: i32,
pub oom_killed: bool,
pub started_at: String,
pub finished_at: String,
pub created_at: String,
pub health: Option<String>,
pub restart_count: u32,
pub command: Option<Vec<String>>,
pub entrypoint: Option<Vec<String>>,
pub env_count: usize,
pub mount_count: usize,
pub networks: Vec<NetworkInfo>,
pub image_digest: Option<String>,
pub restart_policy: Option<String>,
pub user: Option<String>,
pub privileged: bool,
pub readonly_rootfs: bool,
pub apparmor_profile: Option<String>,
pub seccomp_profile: Option<String>,
pub cap_add: Vec<String>,
pub cap_drop: Vec<String>,
pub mounts: Vec<MountInfo>,
pub compose_project: Option<String>,
pub compose_service: Option<String>,
pub pid: Option<u32>,
pub stop_signal: Option<String>,
pub stop_timeout: Option<u32>,
pub image_version: Option<String>,
pub image_revision: Option<String>,
pub image_source: Option<String>,
pub working_dir: Option<String>,
pub hostname: Option<String>,
pub memory_limit: Option<u64>,
pub cpu_limit_nanos: Option<u64>,
pub pids_limit: Option<i64>,
pub log_driver: Option<String>,
pub network_mode: Option<String>,
pub health_test: Option<Vec<String>>,
pub health_interval_ns: Option<u64>,
pub health_failing_streak: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NetworkInfo {
pub name: String,
pub ip_address: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MountInfo {
pub source: String,
pub destination: String,
pub read_only: bool,
}
pub fn container_inspect_command(runtime: ContainerRuntime, container_id: &str) -> String {
format!("{} inspect {}", runtime.as_str(), container_id)
}
pub fn exit_code_meaning(code: i32) -> Option<&'static str> {
match code {
1 => Some("application error"),
125 => Some("docker run failed"),
126 => Some("command not executable"),
127 => Some("command not found"),
130 => Some("interrupted (SIGINT)"),
137 => Some("killed (SIGKILL / OOM)"),
139 => Some("segfault (SIGSEGV)"),
143 => Some("terminated (SIGTERM)"),
_ => None,
}
}
pub fn parse_container_inspect(output: &str) -> Result<ContainerInspect, String> {
let trimmed = output.trim();
if trimmed.is_empty() {
return Err(crate::messages::CONTAINER_INSPECT_EMPTY.to_string());
}
let value: serde_json::Value = serde_json::from_str(trimmed)
.map_err(|e| crate::messages::container_inspect_parse_failed(&e.to_string()))?;
let entry = value
.as_array()
.and_then(|a| a.first())
.ok_or_else(|| crate::messages::CONTAINER_INSPECT_EMPTY.to_string())?;
let state = &entry["State"];
let config = &entry["Config"];
let network_settings = &entry["NetworkSettings"];
let exit_code = state["ExitCode"].as_i64().unwrap_or(0) as i32;
let oom_killed = state["OOMKilled"]
.as_bool()
.or_else(|| state["OomKilled"].as_bool())
.unwrap_or(false);
let started_at = state["StartedAt"].as_str().unwrap_or("").to_string();
let finished_at = state["FinishedAt"].as_str().unwrap_or("").to_string();
let health = state
.get("Health")
.and_then(|h| h.get("Status"))
.and_then(|s| s.as_str())
.map(|s| s.to_string());
let restart_count = entry["RestartCount"].as_u64().unwrap_or(0) as u32;
let command = config["Cmd"].as_array().map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
});
let entrypoint = config["Entrypoint"].as_array().map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
});
let env_count = config["Env"].as_array().map(|arr| arr.len()).unwrap_or(0);
let mount_count = entry["Mounts"].as_array().map(|arr| arr.len()).unwrap_or(0);
let networks = network_settings
.get("Networks")
.and_then(|n| n.as_object())
.map(|map| {
map.iter()
.map(|(name, cfg)| NetworkInfo {
name: name.clone(),
ip_address: cfg
.get("IPAddress")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let host_config = &entry["HostConfig"];
let image_digest = entry["Image"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let restart_policy = host_config
.get("RestartPolicy")
.and_then(|p| p.get("Name"))
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != "no")
.map(|s| s.to_string());
let user = config["User"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let privileged = host_config["Privileged"].as_bool().unwrap_or(false);
let readonly_rootfs = host_config["ReadonlyRootfs"].as_bool().unwrap_or(false);
let apparmor_profile = host_config["AppArmorProfile"]
.as_str()
.or_else(|| entry["AppArmorProfile"].as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let seccomp_profile = host_config["SecurityOpt"].as_array().and_then(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.find_map(|s| s.strip_prefix("seccomp=").map(|v| v.to_string()))
});
let cap_add = host_config["CapAdd"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let cap_drop = host_config["CapDrop"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let mounts = entry["Mounts"]
.as_array()
.map(|arr| {
arr.iter()
.map(|m| MountInfo {
source: m["Source"].as_str().unwrap_or("").to_string(),
destination: m["Destination"].as_str().unwrap_or("").to_string(),
read_only: !m["RW"].as_bool().unwrap_or(true),
})
.collect()
})
.unwrap_or_default();
let labels = config.get("Labels").and_then(|l| l.as_object());
let label = |key: &str| {
labels
.and_then(|l| l.get(key))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
};
let compose_project = label("com.docker.compose.project");
let compose_service = label("com.docker.compose.service");
let image_version = label("org.opencontainers.image.version");
let image_revision = label("org.opencontainers.image.revision");
let image_source = label("org.opencontainers.image.source");
let created_at = entry["Created"].as_str().unwrap_or("").to_string();
let pid = state["Pid"].as_u64().filter(|n| *n > 0).map(|n| n as u32);
let hostname = config["Hostname"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let working_dir = config["WorkingDir"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let stop_signal = config["StopSignal"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let stop_timeout = config["StopTimeout"].as_u64().map(|n| n as u32);
let network_mode = host_config["NetworkMode"]
.as_str()
.filter(|s| !s.is_empty() && *s != "default")
.map(|s| s.to_string());
let memory_limit = host_config["Memory"].as_u64().filter(|n| *n > 0);
let cpu_limit_nanos = host_config["NanoCpus"].as_u64().filter(|n| *n > 0);
let pids_limit = host_config["PidsLimit"].as_i64().filter(|n| *n > 0);
let log_driver = host_config
.get("LogConfig")
.and_then(|l| l.get("Type"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let healthcheck = config.get("Healthcheck");
let health_test = healthcheck
.and_then(|h| h.get("Test"))
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty());
let health_interval_ns = healthcheck
.and_then(|h| h.get("Interval"))
.and_then(|v| v.as_u64())
.filter(|n| *n > 0);
let health_failing_streak = state
.get("Health")
.and_then(|h| h.get("FailingStreak"))
.and_then(|v| v.as_u64())
.map(|n| n as u32);
Ok(ContainerInspect {
exit_code,
oom_killed,
started_at,
finished_at,
created_at,
health,
restart_count,
command,
entrypoint,
env_count,
mount_count,
networks,
image_digest,
restart_policy,
user,
privileged,
readonly_rootfs,
apparmor_profile,
seccomp_profile,
cap_add,
cap_drop,
mounts,
compose_project,
compose_service,
pid,
stop_signal,
stop_timeout,
image_version,
image_revision,
image_source,
working_dir,
hostname,
memory_limit,
cpu_limit_nanos,
pids_limit,
log_driver,
network_mode,
health_test,
health_interval_ns,
health_failing_streak,
})
}
pub fn parse_uptime_from_status(s: &str) -> Option<String> {
let body = s.strip_prefix("Up ")?;
let body = body.split('(').next()?.trim();
if body == "Less than a second" {
return Some("<1m".to_string());
}
if body == "About a minute" {
return Some("1m".to_string());
}
if body == "About an hour" {
return Some("1h".to_string());
}
let mut parts = body.split_whitespace();
let count: u64 = parts.next()?.parse().ok()?;
let unit = parts.next()?;
let suffix = match unit {
"second" | "seconds" => return Some("<1m".to_string()),
"minute" | "minutes" => "m",
"hour" | "hours" => "h",
"day" | "days" => "d",
"week" | "weeks" => "w",
"month" | "months" => "mo",
"year" | "years" => "y",
_ => return None,
};
Some(format!("{count}{suffix}"))
}
pub fn fetch_container_inspect(
ctx: &SshContext<'_>,
runtime: ContainerRuntime,
container_id: &str,
) -> Result<ContainerInspect, String> {
validate_container_id(container_id)?;
let command = container_inspect_command(runtime, container_id);
let result = crate::snippet::run_snippet(
ctx.alias,
ctx.config_path,
&command,
ctx.askpass,
ctx.bw_session,
true,
ctx.has_tunnel,
);
match result {
Ok(r) if r.status.success() => parse_container_inspect(&r.stdout),
Ok(r) => Err(crate::messages::container_command_failed(
r.status.code().unwrap_or(1),
)),
Err(e) => Err(e.to_string()),
}
}
pub fn spawn_container_inspect_listing<F>(
ctx: OwnedSshContext,
runtime: ContainerRuntime,
container_id: String,
send: F,
) where
F: FnOnce(String, String, Result<ContainerInspect, String>) + Send + 'static,
{
std::thread::spawn(move || {
let borrowed = SshContext {
alias: &ctx.alias,
config_path: &ctx.config_path,
askpass: ctx.askpass.as_deref(),
bw_session: ctx.bw_session.as_deref(),
has_tunnel: ctx.has_tunnel,
};
let result = fetch_container_inspect(&borrowed, runtime, &container_id);
send(ctx.alias, container_id, result);
});
}
pub fn container_logs_command(
runtime: ContainerRuntime,
container_id: &str,
tail: usize,
) -> String {
format!("{} logs --tail {} {}", runtime.as_str(), tail, container_id)
}
pub fn fetch_container_logs(
ctx: &SshContext<'_>,
runtime: ContainerRuntime,
container_id: &str,
tail: usize,
) -> Result<Vec<String>, String> {
validate_container_id(container_id)?;
let command = container_logs_command(runtime, container_id, tail);
let result = crate::snippet::run_snippet(
ctx.alias,
ctx.config_path,
&command,
ctx.askpass,
ctx.bw_session,
true,
ctx.has_tunnel,
);
match result {
Ok(r) if r.status.success() => Ok(parse_log_output(&r.stdout, &r.stderr)),
Ok(r) => Err(crate::messages::container_command_failed(
r.status.code().unwrap_or(1),
)),
Err(e) => Err(e.to_string()),
}
}
pub(crate) fn parse_log_output(stdout: &str, stderr: &str) -> Vec<String> {
let mut lines: Vec<String> = stdout.lines().map(|s| s.to_string()).collect();
while lines.last().map(|s| s.is_empty()).unwrap_or(false) {
lines.pop();
}
for s in stderr.lines() {
lines.push(s.to_string());
}
while lines.last().map(|s| s.is_empty()).unwrap_or(false) {
lines.pop();
}
lines
}
pub fn spawn_container_logs_fetch<F>(
ctx: OwnedSshContext,
runtime: ContainerRuntime,
container_id: String,
container_name: String,
tail: usize,
send: F,
) where
F: FnOnce(String, String, String, Result<Vec<String>, String>) + Send + 'static,
{
if crate::demo_flag::is_demo() {
let lines = demo_log_lines(&container_name, tail);
log::debug!(
"[purple] container_logs_fetch: demo short-circuit alias={} id={} lines={}",
ctx.alias,
container_id,
lines.len()
);
send(ctx.alias, container_id, container_name, Ok(lines));
return;
}
std::thread::spawn(move || {
let borrowed = SshContext {
alias: &ctx.alias,
config_path: &ctx.config_path,
askpass: ctx.askpass.as_deref(),
bw_session: ctx.bw_session.as_deref(),
has_tunnel: ctx.has_tunnel,
};
let result = fetch_container_logs(&borrowed, runtime, &container_id, tail);
send(ctx.alias, container_id, container_name, result);
});
}
pub(crate) fn demo_log_lines(container_name: &str, tail: usize) -> Vec<String> {
use std::time::{Duration, UNIX_EPOCH};
let seed: u32 = container_name
.bytes()
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
let templates: &[&str] = &[
"INFO [{}] handled GET /api/v1/health 200 in 14ms",
"INFO [{}] handled POST /api/v1/orders 201 in 38ms (user_id={user})",
"DEBUG [{}] cache hit key=session:{user} ttl=3600",
"INFO [{}] handled GET /api/v1/users/{user} 200 in 11ms",
"WARN [{}] slow query detected duration=812ms statement=SELECT FROM orders",
"INFO [{}] connection pool size=12 idle=8 in_use=4",
"DEBUG [{}] flushing metrics batch size=64",
"INFO [{}] handled GET /api/v1/inventory 200 in 22ms",
"ERROR [{}] upstream timeout after 5000ms target=payments retry=1",
"WARN [{}] retrying request attempt=2 backoff=250ms",
"INFO [{}] handled POST /api/v1/login 200 in 31ms",
"DEBUG [{}] gc cycle reclaimed=42MB took=18ms",
"INFO [{}] heartbeat ok rss=128MB cpu=4%",
"ERROR [{}] failed to acquire lock resource=cache_warmer waiter=3",
"INFO [{}] handled DELETE /api/v1/sessions/{user} 204 in 9ms",
"WARN [{}] disk usage at 78% mount=/data threshold=80%",
"INFO [{}] handled GET /api/v1/search?q=widget 200 in 47ms",
"DEBUG [{}] websocket ping rtt=12ms",
];
let now = crate::demo_flag::now_secs();
let mut lines = Vec::with_capacity(tail);
for i in 0..tail {
let template = templates[(i + seed as usize) % templates.len()];
let user = 1000 + ((seed as usize + i * 7) % 50);
let secs_back = (i as u64) * 3;
let line_time = UNIX_EPOCH + Duration::from_secs(now.saturating_sub(secs_back));
let ts = format_demo_timestamp(line_time);
let body = template
.replace("{}", container_name)
.replace("{user}", &user.to_string());
lines.push(format!("{} {}", ts, body));
}
lines.reverse();
lines
}
fn format_demo_timestamp(t: std::time::SystemTime) -> String {
use std::time::UNIX_EPOCH;
let secs = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days_since_epoch = (secs / 86_400) as i64;
let seconds_in_day = (secs % 86_400) as u32;
let h = seconds_in_day / 3600;
let m = (seconds_in_day % 3600) / 60;
let s = seconds_in_day % 60;
let (y, mo, d) = civil_from_days(days_since_epoch);
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, mo, d, h, m, s)
}
fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m, d)
}
#[derive(Debug, Clone)]
pub struct ContainerCacheEntry {
pub timestamp: u64,
pub runtime: ContainerRuntime,
pub engine_version: Option<String>,
pub containers: Vec<ContainerInfo>,
}
#[derive(Serialize, Deserialize)]
struct CacheLine {
alias: String,
timestamp: u64,
runtime: ContainerRuntime,
#[serde(default, skip_serializing_if = "Option::is_none")]
engine_version: Option<String>,
containers: Vec<ContainerInfo>,
}
#[cfg(test)]
thread_local! {
static PATH_OVERRIDE: std::cell::RefCell<Option<std::path::PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub fn set_path_override(path: std::path::PathBuf) {
PATH_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
}
#[cfg(test)]
#[allow(dead_code)]
pub fn clear_path_override() {
PATH_OVERRIDE.with(|p| *p.borrow_mut() = None);
}
fn cache_path() -> Option<std::path::PathBuf> {
#[cfg(test)]
{
PATH_OVERRIDE.with(|p| p.borrow().clone())
}
#[cfg(not(test))]
{
dirs::home_dir().map(|h| h.join(".purple").join("container_cache.jsonl"))
}
}
pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
let mut map = HashMap::new();
let Some(path) = cache_path() else {
return map;
};
let Ok(content) = std::fs::read_to_string(&path) else {
return map;
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
map.insert(
entry.alias,
ContainerCacheEntry {
timestamp: entry.timestamp,
runtime: entry.runtime,
engine_version: entry.engine_version,
containers: entry.containers,
},
);
}
}
map
}
pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
let mut map = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
map.insert(
entry.alias,
ContainerCacheEntry {
timestamp: entry.timestamp,
runtime: entry.runtime,
engine_version: entry.engine_version,
containers: entry.containers,
},
);
}
}
map
}
pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
if crate::demo_flag::is_demo() {
return;
}
let Some(path) = cache_path() else {
return;
};
let mut lines = Vec::with_capacity(cache.len());
for (alias, entry) in cache {
let line = CacheLine {
alias: alias.clone(),
timestamp: entry.timestamp,
runtime: entry.runtime,
engine_version: entry.engine_version.clone(),
containers: entry.containers.clone(),
};
if let Ok(s) = serde_json::to_string(&line) {
lines.push(s);
}
}
let content = lines.join("\n");
log::debug!(
"[purple] save_container_cache: {} host entries, {} bytes -> {}",
cache.len(),
content.len(),
path.display()
);
if let Err(e) = crate::fs_util::atomic_write(&path, content.as_bytes()) {
log::warn!(
"[config] Failed to write container cache {}: {e}",
path.display()
);
}
}
pub fn truncate_str(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
s.to_string()
} else {
let cut = max.saturating_sub(2);
let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
format!("{}..", &s[..end])
}
}
pub fn format_uptime_short(seconds: u64) -> String {
if seconds < 60 {
format!("{seconds}s")
} else if seconds < 3600 {
format!("{}m", seconds / 60)
} else if seconds < 86400 {
format!("{}h", seconds / 3600)
} else {
format!("{}d", seconds / 86400)
}
}
pub fn format_relative_time(timestamp: u64) -> String {
let now = if crate::demo_flag::is_demo() {
crate::demo_flag::now_secs()
} else {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
};
let diff = now.saturating_sub(timestamp);
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86400)
}
}
#[cfg(test)]
#[path = "containers_tests.rs"]
mod tests;