Skip to main content

purple_ssh/
containers.rs

1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use log::{error, info};
5
6use serde::{Deserialize, Serialize};
7
8use crate::ssh_context::{OwnedSshContext, SshContext};
9
10// ---------------------------------------------------------------------------
11// ContainerInfo model
12// ---------------------------------------------------------------------------
13
14/// Metadata for a single container (from `docker ps -a` / `podman ps -a`).
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ContainerInfo {
17    #[serde(rename = "ID")]
18    pub id: String,
19    #[serde(rename = "Names")]
20    pub names: String,
21    #[serde(rename = "Image")]
22    pub image: String,
23    #[serde(rename = "State")]
24    pub state: String,
25    #[serde(rename = "Status")]
26    pub status: String,
27    #[serde(rename = "Ports")]
28    pub ports: String,
29}
30
31/// Parse NDJSON output from `docker ps --format '{{json .}}'`.
32/// Invalid lines are silently ignored (MOTD lines, blank lines, etc.).
33pub fn parse_container_ps(output: &str) -> Vec<ContainerInfo> {
34    output
35        .lines()
36        .filter_map(|line| {
37            let trimmed = line.trim();
38            if trimmed.is_empty() {
39                return None;
40            }
41            serde_json::from_str(trimmed).ok()
42        })
43        .collect()
44}
45
46// ---------------------------------------------------------------------------
47// ContainerRuntime
48// ---------------------------------------------------------------------------
49
50/// Supported container runtimes.
51#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
52pub enum ContainerRuntime {
53    Docker,
54    Podman,
55}
56
57impl ContainerRuntime {
58    /// Returns the CLI binary name.
59    pub fn as_str(&self) -> &'static str {
60        match self {
61            ContainerRuntime::Docker => "docker",
62            ContainerRuntime::Podman => "podman",
63        }
64    }
65}
66
67/// Detect runtime from command output by matching the LAST non-empty trimmed
68/// line. Only "docker" or "podman" are accepted. MOTD-resilient.
69/// Currently unused (sentinel-based detection handles this inline) but kept
70/// as a public utility for potential future two-step detection paths.
71#[allow(dead_code)]
72pub fn parse_runtime(output: &str) -> Option<ContainerRuntime> {
73    let last = output
74        .lines()
75        .rev()
76        .map(|l| l.trim())
77        .find(|l| !l.is_empty())?;
78    match last {
79        "docker" => Some(ContainerRuntime::Docker),
80        "podman" => Some(ContainerRuntime::Podman),
81        _ => None,
82    }
83}
84
85// ---------------------------------------------------------------------------
86// ContainerAction
87// ---------------------------------------------------------------------------
88
89/// Actions that can be performed on a container.
90#[derive(Copy, Clone, Debug, PartialEq)]
91pub enum ContainerAction {
92    Start,
93    Stop,
94    Restart,
95}
96
97impl ContainerAction {
98    /// Returns the CLI sub-command string.
99    pub fn as_str(&self) -> &'static str {
100        match self {
101            ContainerAction::Start => "start",
102            ContainerAction::Stop => "stop",
103            ContainerAction::Restart => "restart",
104        }
105    }
106}
107
108/// Build the shell command to perform an action on a container.
109pub fn container_action_command(
110    runtime: ContainerRuntime,
111    action: ContainerAction,
112    container_id: &str,
113) -> String {
114    format!("{} {} {}", runtime.as_str(), action.as_str(), container_id)
115}
116
117// ---------------------------------------------------------------------------
118// Container ID validation
119// ---------------------------------------------------------------------------
120
121/// Validate a container ID or name.
122/// Accepts ASCII alphanumeric, hyphen, underscore, dot.
123/// Rejects empty, non-ASCII, shell metacharacters, colon.
124pub fn validate_container_id(id: &str) -> Result<(), String> {
125    if id.is_empty() {
126        return Err(crate::messages::CONTAINER_ID_EMPTY.to_string());
127    }
128    for c in id.chars() {
129        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
130            return Err(crate::messages::container_id_invalid_char(c));
131        }
132    }
133    Ok(())
134}
135
136// ---------------------------------------------------------------------------
137// Combined SSH command + output parsing
138// ---------------------------------------------------------------------------
139
140/// Build the SSH command string for listing containers. Output is the
141/// container NDJSON, then the `##purple:engine##` sentinel, then the
142/// daemon version on its own line. The version subcall is suffixed with
143/// `|| true` so its failure cannot mask a `docker ps` error: the chain
144/// surfaces ps's exit code, while a missing version line just yields
145/// `engine_version: None` downstream.
146///
147/// - `Some(Docker)` / `Some(Podman)`: direct listing for the known runtime.
148/// - `None`: combined detection + listing with sentinel markers in one SSH call.
149pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
150    match runtime {
151        Some(ContainerRuntime::Docker) => concat!(
152            "docker ps -a --format '{{json .}}' && ",
153            "echo '##purple:engine##' && ",
154            "{ docker version --format '{{.Server.Version}}' 2>/dev/null || true; }"
155        )
156        .to_string(),
157        Some(ContainerRuntime::Podman) => concat!(
158            "podman ps -a --format '{{json .}}' && ",
159            "echo '##purple:engine##' && ",
160            "{ podman version --format '{{.Server.Version}}' 2>/dev/null || true; }"
161        )
162        .to_string(),
163        None => concat!(
164            "if command -v docker >/dev/null 2>&1; then ",
165            "echo '##purple:docker##' && docker ps -a --format '{{json .}}' && ",
166            "echo '##purple:engine##' && ",
167            "{ docker version --format '{{.Server.Version}}' 2>/dev/null || true; }; ",
168            "elif command -v podman >/dev/null 2>&1; then ",
169            "echo '##purple:podman##' && podman ps -a --format '{{json .}}' && ",
170            "echo '##purple:engine##' && ",
171            "{ podman version --format '{{.Server.Version}}' 2>/dev/null || true; }; ",
172            "else echo '##purple:none##'; fi"
173        )
174        .to_string(),
175    }
176}
177
178/// Parsed result of a container listing command. `engine_version` is the
179/// daemon's `Server.Version` (best-effort, `None` when the version sub-call
180/// failed or the remote runtime predates the engine sentinel).
181#[derive(Debug, Clone, PartialEq)]
182pub struct ContainerListing {
183    pub runtime: ContainerRuntime,
184    pub engine_version: Option<String>,
185    pub containers: Vec<ContainerInfo>,
186}
187
188/// Parse the stdout of a container listing command.
189///
190/// When sentinels are present (combined detection run): extract runtime from
191/// the sentinel line, parse remaining lines as NDJSON. When `caller_runtime`
192/// is provided (subsequent run with known runtime): parse all lines as NDJSON.
193/// In both cases, `##purple:engine##` splits the listing from the optional
194/// trailing daemon version line.
195pub fn parse_container_output(
196    output: &str,
197    caller_runtime: Option<ContainerRuntime>,
198) -> Result<ContainerListing, String> {
199    let runtime = match output
200        .lines()
201        .map(str::trim)
202        .find(|l| l.starts_with("##purple:") && (*l != "##purple:engine##"))
203    {
204        Some("##purple:none##") => {
205            return Err(crate::messages::CONTAINER_RUNTIME_MISSING.to_string());
206        }
207        Some("##purple:docker##") => ContainerRuntime::Docker,
208        Some("##purple:podman##") => ContainerRuntime::Podman,
209        Some(other) => return Err(crate::messages::container_unknown_sentinel(other)),
210        None => match caller_runtime {
211            Some(rt) => rt,
212            None => return Err("No sentinel found and no runtime provided.".to_string()),
213        },
214    };
215
216    let mut listing_buf = String::new();
217    // Bound the version capture to the first non-empty post-sentinel line.
218    // A trailing logout banner or MOTD after `docker version` would
219    // otherwise concat into the cached engine_version and surface as
220    // "25.0.3\n-- session closed --" in the Runtime field.
221    let mut engine_version: Option<String> = None;
222    let mut after_engine = false;
223    for line in output.lines() {
224        let trimmed = line.trim();
225        if trimmed == "##purple:engine##" {
226            after_engine = true;
227            continue;
228        }
229        if trimmed.starts_with("##purple:") {
230            continue;
231        }
232        if after_engine {
233            if !trimmed.is_empty() && engine_version.is_none() {
234                engine_version = Some(trimmed.to_string());
235            }
236        } else {
237            listing_buf.push_str(line);
238            listing_buf.push('\n');
239        }
240    }
241    Ok(ContainerListing {
242        runtime,
243        engine_version,
244        containers: parse_container_ps(&listing_buf),
245    })
246}
247
248// ---------------------------------------------------------------------------
249// SSH fetch functions
250// ---------------------------------------------------------------------------
251
252/// Error from a container listing operation. Preserves the detected runtime
253/// even when the `ps` command fails so it can be cached for future calls.
254#[derive(Debug)]
255pub struct ContainerError {
256    pub runtime: Option<ContainerRuntime>,
257    pub message: String,
258}
259
260impl std::fmt::Display for ContainerError {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        write!(f, "{}", self.message)
263    }
264}
265
266/// Translate SSH stderr into a user-friendly error message.
267fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
268    let lower = stderr.to_lowercase();
269    if lower.contains("remote host identification has changed")
270        || (lower.contains("host key for") && lower.contains("has changed"))
271    {
272        log::debug!("[external] Host key CHANGED detected; returning HOST_KEY_CHANGED toast");
273        crate::messages::HOST_KEY_CHANGED.to_string()
274    } else if lower.contains("host key verification failed")
275        || lower.contains("no matching host key")
276        || lower.contains("no ed25519 host key is known")
277        || lower.contains("no rsa host key is known")
278        || lower.contains("no ecdsa host key is known")
279        || lower.contains("host key is not known")
280    {
281        log::debug!("[external] Host key UNKNOWN detected; returning HOST_KEY_UNKNOWN toast");
282        crate::messages::HOST_KEY_UNKNOWN.to_string()
283    } else if lower.contains("command not found") {
284        crate::messages::CONTAINER_RUNTIME_NOT_FOUND.to_string()
285    } else if lower.contains("permission denied") || lower.contains("got permission denied") {
286        crate::messages::CONTAINER_PERMISSION_DENIED.to_string()
287    } else if lower.contains("cannot connect to the docker daemon")
288        || lower.contains("cannot connect to podman")
289    {
290        crate::messages::CONTAINER_DAEMON_NOT_RUNNING.to_string()
291    } else if lower.contains("connection refused") {
292        crate::messages::CONTAINER_CONNECTION_REFUSED.to_string()
293    } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
294        crate::messages::CONTAINER_HOST_UNREACHABLE.to_string()
295    } else {
296        crate::messages::container_command_failed(code.unwrap_or(1))
297    }
298}
299
300/// Fetch container list synchronously via SSH.
301/// Follows the `fetch_remote_listing` pattern.
302pub fn fetch_containers(
303    ctx: &SshContext<'_>,
304    cached_runtime: Option<ContainerRuntime>,
305) -> Result<ContainerListing, ContainerError> {
306    let command = container_list_command(cached_runtime);
307    let result = crate::snippet::run_snippet(
308        ctx.alias,
309        ctx.config_path,
310        &command,
311        ctx.askpass,
312        ctx.bw_session,
313        true,
314        ctx.has_tunnel,
315    );
316    let alias = ctx.alias;
317    match result {
318        Ok(r) if r.status.success() => {
319            parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
320                error!("[external] Container list parse failed: alias={alias}: {e}");
321                ContainerError {
322                    runtime: cached_runtime,
323                    message: e,
324                }
325            })
326        }
327        Ok(r) => {
328            let stderr = r.stderr.trim().to_string();
329            let msg = friendly_container_error(&stderr, r.status.code());
330            error!("[external] Container fetch failed: alias={alias}: {msg}");
331            Err(ContainerError {
332                runtime: cached_runtime,
333                message: msg,
334            })
335        }
336        Err(e) => {
337            error!("[external] Container fetch failed: alias={alias}: {e}");
338            Err(ContainerError {
339                runtime: cached_runtime,
340                message: e.to_string(),
341            })
342        }
343    }
344}
345
346/// Spawn a background thread to fetch container listings.
347/// Follows the `spawn_remote_listing` pattern.
348pub fn spawn_container_listing<F>(
349    ctx: OwnedSshContext,
350    cached_runtime: Option<ContainerRuntime>,
351    send: F,
352) where
353    F: FnOnce(String, Result<ContainerListing, ContainerError>) + Send + 'static,
354{
355    std::thread::spawn(move || {
356        let borrowed = SshContext {
357            alias: &ctx.alias,
358            config_path: &ctx.config_path,
359            askpass: ctx.askpass.as_deref(),
360            bw_session: ctx.bw_session.as_deref(),
361            has_tunnel: ctx.has_tunnel,
362        };
363        let result = fetch_containers(&borrowed, cached_runtime);
364        send(ctx.alias, result);
365    });
366}
367
368/// Spawn a background thread to perform a container action (start/stop/restart).
369/// Validates the container ID before executing.
370pub fn spawn_container_action<F>(
371    ctx: OwnedSshContext,
372    runtime: ContainerRuntime,
373    action: ContainerAction,
374    container_id: String,
375    send: F,
376) where
377    F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
378{
379    std::thread::spawn(move || {
380        if let Err(e) = validate_container_id(&container_id) {
381            send(ctx.alias, action, Err(e));
382            return;
383        }
384        let alias = &ctx.alias;
385        info!(
386            "Container action: {} container={container_id} alias={alias}",
387            action.as_str()
388        );
389        let command = container_action_command(runtime, action, &container_id);
390        let result = crate::snippet::run_snippet(
391            alias,
392            &ctx.config_path,
393            &command,
394            ctx.askpass.as_deref(),
395            ctx.bw_session.as_deref(),
396            true,
397            ctx.has_tunnel,
398        );
399        match result {
400            Ok(r) if r.status.success() => send(ctx.alias, action, Ok(())),
401            Ok(r) => {
402                let err = friendly_container_error(r.stderr.trim(), r.status.code());
403                error!(
404                    "[external] Container {} failed: alias={alias} container={container_id}: {err}",
405                    action.as_str()
406                );
407                send(ctx.alias, action, Err(err));
408            }
409            Err(e) => {
410                error!(
411                    "[external] Container {} failed: alias={alias} container={container_id}: {e}",
412                    action.as_str()
413                );
414                send(ctx.alias, action, Err(e.to_string()));
415            }
416        }
417    });
418}
419
420// ---------------------------------------------------------------------------
421// ContainerInspect: subset of `docker inspect` output we surface in the UI
422// ---------------------------------------------------------------------------
423
424/// Parsed subset of `docker inspect <id>` (or `podman inspect`). Only the
425/// fields purple's container detail panel renders are extracted; the rest
426/// of the JSON document is discarded so cache size stays bounded.
427#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
428pub struct ContainerInspect {
429    pub exit_code: i32,
430    pub oom_killed: bool,
431    pub started_at: String,
432    pub finished_at: String,
433    pub created_at: String,
434    /// `Some("healthy" | "unhealthy" | "starting")` when the image defines
435    /// a HEALTHCHECK. `None` when no healthcheck is configured.
436    pub health: Option<String>,
437    pub restart_count: u32,
438    pub command: Option<Vec<String>>,
439    pub entrypoint: Option<Vec<String>>,
440    pub env_count: usize,
441    pub mount_count: usize,
442    pub networks: Vec<NetworkInfo>,
443    // Audit-relevant fields surfaced in the right-side detail panel.
444    pub image_digest: Option<String>,
445    pub restart_policy: Option<String>,
446    pub user: Option<String>,
447    pub privileged: bool,
448    pub readonly_rootfs: bool,
449    pub apparmor_profile: Option<String>,
450    pub seccomp_profile: Option<String>,
451    pub cap_add: Vec<String>,
452    pub cap_drop: Vec<String>,
453    pub mounts: Vec<MountInfo>,
454    pub compose_project: Option<String>,
455    pub compose_service: Option<String>,
456    // Lifecycle / runtime details surfaced in the LIFECYCLE card.
457    pub pid: Option<u32>,
458    pub stop_signal: Option<String>,
459    pub stop_timeout: Option<u32>,
460    // App identity from OCI image labels (visible in APP card).
461    pub image_version: Option<String>,
462    pub image_revision: Option<String>,
463    pub image_source: Option<String>,
464    pub working_dir: Option<String>,
465    pub hostname: Option<String>,
466    // Resource constraints (RESOURCES card). 0 / None means unlimited.
467    pub memory_limit: Option<u64>,
468    pub cpu_limit_nanos: Option<u64>,
469    pub pids_limit: Option<i64>,
470    pub log_driver: Option<String>,
471    // Network mode (NETWORK card): bridge / host / none / container:xyz.
472    pub network_mode: Option<String>,
473    // Healthcheck definition + recent stats (HEALTH card when present).
474    pub health_test: Option<Vec<String>>,
475    pub health_interval_ns: Option<u64>,
476    pub health_failing_streak: Option<u32>,
477}
478
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
480pub struct NetworkInfo {
481    pub name: String,
482    pub ip_address: String,
483}
484
485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
486pub struct MountInfo {
487    pub source: String,
488    pub destination: String,
489    pub read_only: bool,
490}
491
492/// Build the SSH command string for inspecting a single container.
493pub fn container_inspect_command(runtime: ContainerRuntime, container_id: &str) -> String {
494    format!("{} inspect {}", runtime.as_str(), container_id)
495}
496
497/// Translate a non-zero docker/podman exit code into a short
498/// human-readable hint. Returns `None` for codes without a well-known
499/// meaning so the UI can fall back to the bare number. Exit 0 has no
500/// entry because the detail panel only annotates failed exits.
501/// Sources: docker docs + Linux signal table.
502pub fn exit_code_meaning(code: i32) -> Option<&'static str> {
503    match code {
504        1 => Some("application error"),
505        125 => Some("docker run failed"),
506        126 => Some("command not executable"),
507        127 => Some("command not found"),
508        130 => Some("interrupted (SIGINT)"),
509        137 => Some("killed (SIGKILL / OOM)"),
510        139 => Some("segfault (SIGSEGV)"),
511        143 => Some("terminated (SIGTERM)"),
512        _ => None,
513    }
514}
515
516/// Parse `docker inspect <id>` stdout into `ContainerInspect`. The command
517/// always returns a JSON array; we take the first element. Missing fields
518/// degrade to defaults rather than fail so a partial response still
519/// renders something useful.
520pub fn parse_container_inspect(output: &str) -> Result<ContainerInspect, String> {
521    let trimmed = output.trim();
522    if trimmed.is_empty() {
523        return Err(crate::messages::CONTAINER_INSPECT_EMPTY.to_string());
524    }
525    let value: serde_json::Value = serde_json::from_str(trimmed)
526        .map_err(|e| crate::messages::container_inspect_parse_failed(&e.to_string()))?;
527    let entry = value
528        .as_array()
529        .and_then(|a| a.first())
530        .ok_or_else(|| crate::messages::CONTAINER_INSPECT_EMPTY.to_string())?;
531
532    let state = &entry["State"];
533    let config = &entry["Config"];
534    let network_settings = &entry["NetworkSettings"];
535
536    let exit_code = state["ExitCode"].as_i64().unwrap_or(0) as i32;
537    let oom_killed = state["OOMKilled"].as_bool().unwrap_or(false);
538    let started_at = state["StartedAt"].as_str().unwrap_or("").to_string();
539    let finished_at = state["FinishedAt"].as_str().unwrap_or("").to_string();
540    let health = state
541        .get("Health")
542        .and_then(|h| h.get("Status"))
543        .and_then(|s| s.as_str())
544        .map(|s| s.to_string());
545    let restart_count = entry["RestartCount"].as_u64().unwrap_or(0) as u32;
546
547    let command = config["Cmd"].as_array().map(|arr| {
548        arr.iter()
549            .filter_map(|v| v.as_str().map(|s| s.to_string()))
550            .collect()
551    });
552    let entrypoint = config["Entrypoint"].as_array().map(|arr| {
553        arr.iter()
554            .filter_map(|v| v.as_str().map(|s| s.to_string()))
555            .collect()
556    });
557    let env_count = config["Env"].as_array().map(|arr| arr.len()).unwrap_or(0);
558    let mount_count = entry["Mounts"].as_array().map(|arr| arr.len()).unwrap_or(0);
559
560    let networks = network_settings
561        .get("Networks")
562        .and_then(|n| n.as_object())
563        .map(|map| {
564            map.iter()
565                .map(|(name, cfg)| NetworkInfo {
566                    name: name.clone(),
567                    ip_address: cfg
568                        .get("IPAddress")
569                        .and_then(|v| v.as_str())
570                        .unwrap_or("")
571                        .to_string(),
572                })
573                .collect::<Vec<_>>()
574        })
575        .unwrap_or_default();
576
577    let host_config = &entry["HostConfig"];
578
579    let image_digest = entry["Image"]
580        .as_str()
581        .filter(|s| !s.is_empty())
582        .map(|s| s.to_string());
583    let restart_policy = host_config
584        .get("RestartPolicy")
585        .and_then(|p| p.get("Name"))
586        .and_then(|s| s.as_str())
587        .filter(|s| !s.is_empty() && *s != "no")
588        .map(|s| s.to_string());
589    let user = config["User"]
590        .as_str()
591        .filter(|s| !s.is_empty())
592        .map(|s| s.to_string());
593    let privileged = host_config["Privileged"].as_bool().unwrap_or(false);
594    let readonly_rootfs = host_config["ReadonlyRootfs"].as_bool().unwrap_or(false);
595    let apparmor_profile = host_config["AppArmorProfile"]
596        .as_str()
597        .or_else(|| entry["AppArmorProfile"].as_str())
598        .filter(|s| !s.is_empty())
599        .map(|s| s.to_string());
600    let seccomp_profile = host_config["SecurityOpt"].as_array().and_then(|arr| {
601        arr.iter()
602            .filter_map(|v| v.as_str())
603            .find_map(|s| s.strip_prefix("seccomp=").map(|v| v.to_string()))
604    });
605    let cap_add = host_config["CapAdd"]
606        .as_array()
607        .map(|arr| {
608            arr.iter()
609                .filter_map(|v| v.as_str().map(|s| s.to_string()))
610                .collect()
611        })
612        .unwrap_or_default();
613    let cap_drop = host_config["CapDrop"]
614        .as_array()
615        .map(|arr| {
616            arr.iter()
617                .filter_map(|v| v.as_str().map(|s| s.to_string()))
618                .collect()
619        })
620        .unwrap_or_default();
621    let mounts = entry["Mounts"]
622        .as_array()
623        .map(|arr| {
624            arr.iter()
625                .map(|m| MountInfo {
626                    source: m["Source"].as_str().unwrap_or("").to_string(),
627                    destination: m["Destination"].as_str().unwrap_or("").to_string(),
628                    read_only: !m["RW"].as_bool().unwrap_or(true),
629                })
630                .collect()
631        })
632        .unwrap_or_default();
633    let labels = config.get("Labels").and_then(|l| l.as_object());
634    let label = |key: &str| {
635        labels
636            .and_then(|l| l.get(key))
637            .and_then(|v| v.as_str())
638            .filter(|s| !s.is_empty())
639            .map(|s| s.to_string())
640    };
641    let compose_project = label("com.docker.compose.project");
642    let compose_service = label("com.docker.compose.service");
643    let image_version = label("org.opencontainers.image.version");
644    let image_revision = label("org.opencontainers.image.revision");
645    let image_source = label("org.opencontainers.image.source");
646
647    let created_at = entry["Created"].as_str().unwrap_or("").to_string();
648    // State.Pid is `0` when the container is not running. Drop the zero so
649    // the UI does not render a misleading "pid 0" row for exited rows.
650    let pid = state["Pid"].as_u64().filter(|n| *n > 0).map(|n| n as u32);
651    let hostname = config["Hostname"]
652        .as_str()
653        .filter(|s| !s.is_empty())
654        .map(|s| s.to_string());
655    let working_dir = config["WorkingDir"]
656        .as_str()
657        .filter(|s| !s.is_empty())
658        .map(|s| s.to_string());
659    let stop_signal = config["StopSignal"]
660        .as_str()
661        .filter(|s| !s.is_empty())
662        .map(|s| s.to_string());
663    let stop_timeout = config["StopTimeout"].as_u64().map(|n| n as u32);
664
665    let network_mode = host_config["NetworkMode"]
666        .as_str()
667        .filter(|s| !s.is_empty() && *s != "default")
668        .map(|s| s.to_string());
669    // HostConfig.Memory is bytes, 0 = unlimited (drop). Same for NanoCpus.
670    let memory_limit = host_config["Memory"].as_u64().filter(|n| *n > 0);
671    let cpu_limit_nanos = host_config["NanoCpus"].as_u64().filter(|n| *n > 0);
672    // PidsLimit is i64. 0 or -1 means unlimited; drop both.
673    let pids_limit = host_config["PidsLimit"].as_i64().filter(|n| *n > 0);
674    // LogConfig.Type defaults to "json-file" on docker. Always carry it
675    // so the renderer can decide whether to surface "Logs" only when
676    // non-default.
677    let log_driver = host_config
678        .get("LogConfig")
679        .and_then(|l| l.get("Type"))
680        .and_then(|v| v.as_str())
681        .filter(|s| !s.is_empty())
682        .map(|s| s.to_string());
683
684    let healthcheck = config.get("Healthcheck");
685    let health_test = healthcheck
686        .and_then(|h| h.get("Test"))
687        .and_then(|t| t.as_array())
688        .map(|arr| {
689            arr.iter()
690                .filter_map(|v| v.as_str().map(|s| s.to_string()))
691                .collect::<Vec<_>>()
692        })
693        .filter(|v| !v.is_empty());
694    let health_interval_ns = healthcheck
695        .and_then(|h| h.get("Interval"))
696        .and_then(|v| v.as_u64())
697        .filter(|n| *n > 0);
698    let health_failing_streak = state
699        .get("Health")
700        .and_then(|h| h.get("FailingStreak"))
701        .and_then(|v| v.as_u64())
702        .map(|n| n as u32);
703
704    Ok(ContainerInspect {
705        exit_code,
706        oom_killed,
707        started_at,
708        finished_at,
709        created_at,
710        health,
711        restart_count,
712        command,
713        entrypoint,
714        env_count,
715        mount_count,
716        networks,
717        image_digest,
718        restart_policy,
719        user,
720        privileged,
721        readonly_rootfs,
722        apparmor_profile,
723        seccomp_profile,
724        cap_add,
725        cap_drop,
726        mounts,
727        compose_project,
728        compose_service,
729        pid,
730        stop_signal,
731        stop_timeout,
732        image_version,
733        image_revision,
734        image_source,
735        working_dir,
736        hostname,
737        memory_limit,
738        cpu_limit_nanos,
739        pids_limit,
740        log_driver,
741        network_mode,
742        health_test,
743        health_interval_ns,
744        health_failing_streak,
745    })
746}
747
748/// Parse a Docker `Up …` status string into a compact uptime label.
749/// Returns `None` for any non-running state (Exited, Created, Restarting,
750/// Paused without an `Up` prefix, empty). Cells render `<1m` for
751/// sub-minute uptimes, `1m` / `5m` / `12h` / `5w` / `3mo` / `2y` otherwise.
752/// Format follows Docker's `units.HumanDuration`.
753pub fn parse_uptime_from_status(s: &str) -> Option<String> {
754    let body = s.strip_prefix("Up ")?;
755    let body = body.split('(').next()?.trim();
756    if body == "Less than a second" {
757        return Some("<1m".to_string());
758    }
759    if body == "About a minute" {
760        return Some("1m".to_string());
761    }
762    if body == "About an hour" {
763        return Some("1h".to_string());
764    }
765    let mut parts = body.split_whitespace();
766    let count: u64 = parts.next()?.parse().ok()?;
767    let unit = parts.next()?;
768    let suffix = match unit {
769        "second" | "seconds" => return Some("<1m".to_string()),
770        "minute" | "minutes" => "m",
771        "hour" | "hours" => "h",
772        "day" | "days" => "d",
773        "week" | "weeks" => "w",
774        "month" | "months" => "mo",
775        "year" | "years" => "y",
776        _ => return None,
777    };
778    Some(format!("{count}{suffix}"))
779}
780
781/// Synchronously fetch + parse `container inspect`. Validates the
782/// container ID before issuing the SSH call.
783pub fn fetch_container_inspect(
784    ctx: &SshContext<'_>,
785    runtime: ContainerRuntime,
786    container_id: &str,
787) -> Result<ContainerInspect, String> {
788    validate_container_id(container_id)?;
789    let command = container_inspect_command(runtime, container_id);
790    let result = crate::snippet::run_snippet(
791        ctx.alias,
792        ctx.config_path,
793        &command,
794        ctx.askpass,
795        ctx.bw_session,
796        true,
797        ctx.has_tunnel,
798    );
799    match result {
800        Ok(r) if r.status.success() => parse_container_inspect(&r.stdout),
801        Ok(r) => Err(crate::messages::container_command_failed(
802            r.status.code().unwrap_or(1),
803        )),
804        Err(e) => Err(e.to_string()),
805    }
806}
807
808/// Spawn a background thread to run `container inspect`. Mirrors the
809/// `spawn_container_listing` pattern so the call site looks identical.
810pub fn spawn_container_inspect_listing<F>(
811    ctx: OwnedSshContext,
812    runtime: ContainerRuntime,
813    container_id: String,
814    send: F,
815) where
816    F: FnOnce(String, String, Result<ContainerInspect, String>) + Send + 'static,
817{
818    std::thread::spawn(move || {
819        let borrowed = SshContext {
820            alias: &ctx.alias,
821            config_path: &ctx.config_path,
822            askpass: ctx.askpass.as_deref(),
823            bw_session: ctx.bw_session.as_deref(),
824            has_tunnel: ctx.has_tunnel,
825        };
826        let result = fetch_container_inspect(&borrowed, runtime, &container_id);
827        send(ctx.alias, container_id, result);
828    });
829}
830
831/// Build the `<runtime> logs --tail <n> <id>` command. The
832/// `--tail` cap is enforced server-side so the SSH stream stays
833/// bounded even on a noisy container.
834pub fn container_logs_command(
835    runtime: ContainerRuntime,
836    container_id: &str,
837    tail: usize,
838) -> String {
839    format!("{} logs --tail {} {}", runtime.as_str(), tail, container_id)
840}
841
842/// Synchronously fetch logs and split into lines. Returns the raw
843/// captured stdout split on `\n` so the renderer does not have to
844/// re-parse. Empty trailing lines are dropped.
845pub fn fetch_container_logs(
846    ctx: &SshContext<'_>,
847    runtime: ContainerRuntime,
848    container_id: &str,
849    tail: usize,
850) -> Result<Vec<String>, String> {
851    validate_container_id(container_id)?;
852    let command = container_logs_command(runtime, container_id, tail);
853    let result = crate::snippet::run_snippet(
854        ctx.alias,
855        ctx.config_path,
856        &command,
857        ctx.askpass,
858        ctx.bw_session,
859        true,
860        ctx.has_tunnel,
861    );
862    match result {
863        Ok(r) if r.status.success() => Ok(parse_log_output(&r.stdout, &r.stderr)),
864        Ok(r) => Err(crate::messages::container_command_failed(
865            r.status.code().unwrap_or(1),
866        )),
867        Err(e) => Err(e.to_string()),
868    }
869}
870
871/// Merge stdout (app logs) and stderr (errors) into a single chronological
872/// stream. Many container runtimes split levels across the two streams;
873/// re-interleaving them is closer to what `docker logs` shows on a TTY.
874/// Trailing blank lines are stripped from each stream before merging so a
875/// stdout block that ends in a newline does not introduce a phantom empty
876/// row between the two streams.
877pub(crate) fn parse_log_output(stdout: &str, stderr: &str) -> Vec<String> {
878    let mut lines: Vec<String> = stdout.lines().map(|s| s.to_string()).collect();
879    while lines.last().map(|s| s.is_empty()).unwrap_or(false) {
880        lines.pop();
881    }
882    for s in stderr.lines() {
883        lines.push(s.to_string());
884    }
885    while lines.last().map(|s| s.is_empty()).unwrap_or(false) {
886        lines.pop();
887    }
888    lines
889}
890
891/// Spawn a background thread to run `container logs`. Same shape as
892/// `spawn_container_inspect_listing`.
893pub fn spawn_container_logs_fetch<F>(
894    ctx: OwnedSshContext,
895    runtime: ContainerRuntime,
896    container_id: String,
897    container_name: String,
898    tail: usize,
899    send: F,
900) where
901    F: FnOnce(String, String, String, Result<Vec<String>, String>) + Send + 'static,
902{
903    std::thread::spawn(move || {
904        let borrowed = SshContext {
905            alias: &ctx.alias,
906            config_path: &ctx.config_path,
907            askpass: ctx.askpass.as_deref(),
908            bw_session: ctx.bw_session.as_deref(),
909            has_tunnel: ctx.has_tunnel,
910        };
911        let result = fetch_container_logs(&borrowed, runtime, &container_id, tail);
912        send(ctx.alias, container_id, container_name, result);
913    });
914}
915
916// ---------------------------------------------------------------------------
917// JSON lines cache
918// ---------------------------------------------------------------------------
919
920/// A cached container listing for a single host. `engine_version` is the
921/// daemon's `Server.Version` captured during the last refresh, surfaced in
922/// the host detail panel; `None` means the version sub-call did not return
923/// or the cache was written by an older purple build.
924#[derive(Debug, Clone)]
925pub struct ContainerCacheEntry {
926    pub timestamp: u64,
927    pub runtime: ContainerRuntime,
928    pub engine_version: Option<String>,
929    pub containers: Vec<ContainerInfo>,
930}
931
932/// Serde helper for a single JSON line in the cache file. `engine_version`
933/// uses `serde(default)` so cache files written before this field existed
934/// still deserialize cleanly.
935#[derive(Serialize, Deserialize)]
936struct CacheLine {
937    alias: String,
938    timestamp: u64,
939    runtime: ContainerRuntime,
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    engine_version: Option<String>,
942    containers: Vec<ContainerInfo>,
943}
944
945// Test-only thread-local override for the cache file path.
946// Mirrors `preferences::set_path_override` so unit tests can write
947// to a tempdir instead of polluting the real `~/.purple/`.
948#[cfg(test)]
949thread_local! {
950    static PATH_OVERRIDE: std::cell::RefCell<Option<std::path::PathBuf>> =
951        const { std::cell::RefCell::new(None) };
952}
953
954#[cfg(test)]
955pub fn set_path_override(path: std::path::PathBuf) {
956    PATH_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
957}
958
959#[cfg(test)]
960#[allow(dead_code)]
961pub fn clear_path_override() {
962    PATH_OVERRIDE.with(|p| *p.borrow_mut() = None);
963}
964
965fn cache_path() -> Option<std::path::PathBuf> {
966    // Tests MUST opt in via `set_path_override` before any code
967    // path that loads or saves the cache. Falling through to the
968    // production path lets a forgotten override pollute (and in
969    // the orphan-prune branch of `reload_hosts`, wipe) the user's
970    // real `~/.purple/container_cache.jsonl`.
971    #[cfg(test)]
972    {
973        PATH_OVERRIDE.with(|p| p.borrow().clone())
974    }
975    #[cfg(not(test))]
976    {
977        dirs::home_dir().map(|h| h.join(".purple").join("container_cache.jsonl"))
978    }
979}
980
981/// Load container cache from `~/.purple/container_cache.jsonl`.
982/// Malformed lines are silently ignored. Duplicate aliases: last-write-wins.
983pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
984    let mut map = HashMap::new();
985    let Some(path) = cache_path() else {
986        return map;
987    };
988    let Ok(content) = std::fs::read_to_string(&path) else {
989        return map;
990    };
991    for line in content.lines() {
992        let trimmed = line.trim();
993        if trimmed.is_empty() {
994            continue;
995        }
996        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
997            map.insert(
998                entry.alias,
999                ContainerCacheEntry {
1000                    timestamp: entry.timestamp,
1001                    runtime: entry.runtime,
1002                    engine_version: entry.engine_version,
1003                    containers: entry.containers,
1004                },
1005            );
1006        }
1007    }
1008    map
1009}
1010
1011/// Parse container cache from JSONL content string (for demo/test use).
1012pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
1013    let mut map = HashMap::new();
1014    for line in content.lines() {
1015        let trimmed = line.trim();
1016        if trimmed.is_empty() {
1017            continue;
1018        }
1019        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
1020            map.insert(
1021                entry.alias,
1022                ContainerCacheEntry {
1023                    timestamp: entry.timestamp,
1024                    runtime: entry.runtime,
1025                    engine_version: entry.engine_version,
1026                    containers: entry.containers,
1027                },
1028            );
1029        }
1030    }
1031    map
1032}
1033
1034/// Save container cache to `~/.purple/container_cache.jsonl` via atomic write.
1035pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
1036    if crate::demo_flag::is_demo() {
1037        return;
1038    }
1039    let Some(path) = cache_path() else {
1040        return;
1041    };
1042    let mut lines = Vec::with_capacity(cache.len());
1043    for (alias, entry) in cache {
1044        let line = CacheLine {
1045            alias: alias.clone(),
1046            timestamp: entry.timestamp,
1047            runtime: entry.runtime,
1048            engine_version: entry.engine_version.clone(),
1049            containers: entry.containers.clone(),
1050        };
1051        if let Ok(s) = serde_json::to_string(&line) {
1052            lines.push(s);
1053        }
1054    }
1055    let content = lines.join("\n");
1056    if let Err(e) = crate::fs_util::atomic_write(&path, content.as_bytes()) {
1057        log::warn!(
1058            "[config] Failed to write container cache {}: {e}",
1059            path.display()
1060        );
1061    }
1062}
1063
1064// ---------------------------------------------------------------------------
1065// String truncation
1066// ---------------------------------------------------------------------------
1067
1068/// Truncate a string to at most `max` characters. Appends ".." if truncated.
1069pub fn truncate_str(s: &str, max: usize) -> String {
1070    let count = s.chars().count();
1071    if count <= max {
1072        s.to_string()
1073    } else {
1074        let cut = max.saturating_sub(2);
1075        let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
1076        format!("{}..", &s[..end])
1077    }
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Relative time
1082// ---------------------------------------------------------------------------
1083
1084/// Format a duration in seconds as a compact label (`12s`, `5m`,
1085/// `2h`, `3d`). Used for the in-border staleness badge where width
1086/// is precious and the surrounding label (`synced`) already says
1087/// "ago" without the suffix.
1088pub fn format_uptime_short(seconds: u64) -> String {
1089    if seconds < 60 {
1090        format!("{seconds}s")
1091    } else if seconds < 3600 {
1092        format!("{}m", seconds / 60)
1093    } else if seconds < 86400 {
1094        format!("{}h", seconds / 3600)
1095    } else {
1096        format!("{}d", seconds / 86400)
1097    }
1098}
1099
1100/// Format a Unix timestamp as a human-readable relative time string.
1101/// Honours `demo_flag::now_secs()` when demo mode is active so visual
1102/// regression goldens stay byte-stable across long-running test
1103/// processes (same pattern as `history::format_time_ago`).
1104pub fn format_relative_time(timestamp: u64) -> String {
1105    let now = if crate::demo_flag::is_demo() {
1106        crate::demo_flag::now_secs()
1107    } else {
1108        SystemTime::now()
1109            .duration_since(UNIX_EPOCH)
1110            .unwrap_or_default()
1111            .as_secs()
1112    };
1113    let diff = now.saturating_sub(timestamp);
1114    if diff < 60 {
1115        "just now".to_string()
1116    } else if diff < 3600 {
1117        format!("{}m ago", diff / 60)
1118    } else if diff < 86400 {
1119        format!("{}h ago", diff / 3600)
1120    } else {
1121        format!("{}d ago", diff / 86400)
1122    }
1123}
1124
1125// ---------------------------------------------------------------------------
1126// Tests
1127// ---------------------------------------------------------------------------
1128
1129#[cfg(test)]
1130#[path = "containers_tests.rs"]
1131mod tests;