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("Container ID must not be empty.".to_string());
127    }
128    for c in id.chars() {
129        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
130            return Err(format!("Container ID contains invalid character: '{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.
141///
142/// - `Some(Docker)` / `Some(Podman)`: direct listing for the known runtime.
143/// - `None`: combined detection + listing with sentinel markers in one SSH call.
144pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
145    match runtime {
146        Some(ContainerRuntime::Docker) => "docker ps -a --format '{{json .}}'".to_string(),
147        Some(ContainerRuntime::Podman) => "podman ps -a --format '{{json .}}'".to_string(),
148        None => concat!(
149            "if command -v docker >/dev/null 2>&1; then ",
150            "echo '##purple:docker##' && docker ps -a --format '{{json .}}'; ",
151            "elif command -v podman >/dev/null 2>&1; then ",
152            "echo '##purple:podman##' && podman ps -a --format '{{json .}}'; ",
153            "else echo '##purple:none##'; fi"
154        )
155        .to_string(),
156    }
157}
158
159/// Parse the stdout of a container listing command.
160///
161/// When sentinels are present (combined detection run): extract runtime from
162/// the sentinel line, parse remaining lines as NDJSON. When `caller_runtime`
163/// is provided (subsequent run with known runtime): parse all lines as NDJSON.
164pub fn parse_container_output(
165    output: &str,
166    caller_runtime: Option<ContainerRuntime>,
167) -> Result<(ContainerRuntime, Vec<ContainerInfo>), String> {
168    if let Some(sentinel_line) = output.lines().find(|l| l.trim().starts_with("##purple:")) {
169        let sentinel = sentinel_line.trim();
170        if sentinel == "##purple:none##" {
171            return Err("No container runtime found. Install Docker or Podman.".to_string());
172        }
173        let runtime = if sentinel == "##purple:docker##" {
174            ContainerRuntime::Docker
175        } else if sentinel == "##purple:podman##" {
176            ContainerRuntime::Podman
177        } else {
178            return Err(format!("Unknown sentinel: {sentinel}"));
179        };
180        let containers: Vec<ContainerInfo> = output
181            .lines()
182            .filter(|l| !l.trim().starts_with("##purple:"))
183            .filter_map(|line| {
184                let t = line.trim();
185                if t.is_empty() {
186                    return None;
187                }
188                serde_json::from_str(t).ok()
189            })
190            .collect();
191        return Ok((runtime, containers));
192    }
193
194    match caller_runtime {
195        Some(rt) => Ok((rt, parse_container_ps(output))),
196        None => Err("No sentinel found and no runtime provided.".to_string()),
197    }
198}
199
200// ---------------------------------------------------------------------------
201// SSH fetch functions
202// ---------------------------------------------------------------------------
203
204/// Error from a container listing operation. Preserves the detected runtime
205/// even when the `ps` command fails so it can be cached for future calls.
206#[derive(Debug)]
207pub struct ContainerError {
208    pub runtime: Option<ContainerRuntime>,
209    pub message: String,
210}
211
212impl std::fmt::Display for ContainerError {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        write!(f, "{}", self.message)
215    }
216}
217
218/// Translate SSH stderr into a user-friendly error message.
219fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
220    let lower = stderr.to_lowercase();
221    if lower.contains("command not found") {
222        "Docker or Podman not found on remote host.".to_string()
223    } else if lower.contains("permission denied") || lower.contains("got permission denied") {
224        "Permission denied. Is your user in the docker group?".to_string()
225    } else if lower.contains("cannot connect to the docker daemon")
226        || lower.contains("cannot connect to podman")
227    {
228        "Container daemon is not running.".to_string()
229    } else if lower.contains("connection refused") {
230        "Connection refused.".to_string()
231    } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
232        "Host unreachable.".to_string()
233    } else {
234        format!("Command failed with code {}.", code.unwrap_or(1))
235    }
236}
237
238/// Fetch container list synchronously via SSH.
239/// Follows the `fetch_remote_listing` pattern.
240pub fn fetch_containers(
241    ctx: &SshContext<'_>,
242    cached_runtime: Option<ContainerRuntime>,
243) -> Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError> {
244    let command = container_list_command(cached_runtime);
245    let result = crate::snippet::run_snippet(
246        ctx.alias,
247        ctx.config_path,
248        &command,
249        ctx.askpass,
250        ctx.bw_session,
251        true,
252        ctx.has_tunnel,
253    );
254    let alias = ctx.alias;
255    match result {
256        Ok(r) if r.status.success() => {
257            parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
258                error!("[external] Container list parse failed: alias={alias}: {e}");
259                ContainerError {
260                    runtime: cached_runtime,
261                    message: e,
262                }
263            })
264        }
265        Ok(r) => {
266            let stderr = r.stderr.trim().to_string();
267            let msg = friendly_container_error(&stderr, r.status.code());
268            error!("[external] Container fetch failed: alias={alias}: {msg}");
269            Err(ContainerError {
270                runtime: cached_runtime,
271                message: msg,
272            })
273        }
274        Err(e) => {
275            error!("[external] Container fetch failed: alias={alias}: {e}");
276            Err(ContainerError {
277                runtime: cached_runtime,
278                message: e.to_string(),
279            })
280        }
281    }
282}
283
284/// Spawn a background thread to fetch container listings.
285/// Follows the `spawn_remote_listing` pattern.
286pub fn spawn_container_listing<F>(
287    ctx: OwnedSshContext,
288    cached_runtime: Option<ContainerRuntime>,
289    send: F,
290) where
291    F: FnOnce(String, Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError>)
292        + Send
293        + 'static,
294{
295    std::thread::spawn(move || {
296        let borrowed = SshContext {
297            alias: &ctx.alias,
298            config_path: &ctx.config_path,
299            askpass: ctx.askpass.as_deref(),
300            bw_session: ctx.bw_session.as_deref(),
301            has_tunnel: ctx.has_tunnel,
302        };
303        let result = fetch_containers(&borrowed, cached_runtime);
304        send(ctx.alias, result);
305    });
306}
307
308/// Spawn a background thread to perform a container action (start/stop/restart).
309/// Validates the container ID before executing.
310pub fn spawn_container_action<F>(
311    ctx: OwnedSshContext,
312    runtime: ContainerRuntime,
313    action: ContainerAction,
314    container_id: String,
315    send: F,
316) where
317    F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
318{
319    std::thread::spawn(move || {
320        if let Err(e) = validate_container_id(&container_id) {
321            send(ctx.alias, action, Err(e));
322            return;
323        }
324        let alias = &ctx.alias;
325        info!(
326            "Container action: {} container={container_id} alias={alias}",
327            action.as_str()
328        );
329        let command = container_action_command(runtime, action, &container_id);
330        let result = crate::snippet::run_snippet(
331            alias,
332            &ctx.config_path,
333            &command,
334            ctx.askpass.as_deref(),
335            ctx.bw_session.as_deref(),
336            true,
337            ctx.has_tunnel,
338        );
339        match result {
340            Ok(r) if r.status.success() => send(ctx.alias, action, Ok(())),
341            Ok(r) => {
342                let err = friendly_container_error(r.stderr.trim(), r.status.code());
343                error!(
344                    "[external] Container {} failed: alias={alias} container={container_id}: {err}",
345                    action.as_str()
346                );
347                send(ctx.alias, action, Err(err));
348            }
349            Err(e) => {
350                error!(
351                    "[external] Container {} failed: alias={alias} container={container_id}: {e}",
352                    action.as_str()
353                );
354                send(ctx.alias, action, Err(e.to_string()));
355            }
356        }
357    });
358}
359
360// ---------------------------------------------------------------------------
361// JSON lines cache
362// ---------------------------------------------------------------------------
363
364/// A cached container listing for a single host.
365#[derive(Debug, Clone)]
366pub struct ContainerCacheEntry {
367    pub timestamp: u64,
368    pub runtime: ContainerRuntime,
369    pub containers: Vec<ContainerInfo>,
370}
371
372/// Serde helper for a single JSON line in the cache file.
373#[derive(Serialize, Deserialize)]
374struct CacheLine {
375    alias: String,
376    timestamp: u64,
377    runtime: ContainerRuntime,
378    containers: Vec<ContainerInfo>,
379}
380
381/// Load container cache from `~/.purple/container_cache.jsonl`.
382/// Malformed lines are silently ignored. Duplicate aliases: last-write-wins.
383pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
384    let mut map = HashMap::new();
385    let Some(home) = dirs::home_dir() else {
386        return map;
387    };
388    let path = home.join(".purple").join("container_cache.jsonl");
389    let Ok(content) = std::fs::read_to_string(&path) else {
390        return map;
391    };
392    for line in content.lines() {
393        let trimmed = line.trim();
394        if trimmed.is_empty() {
395            continue;
396        }
397        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
398            map.insert(
399                entry.alias,
400                ContainerCacheEntry {
401                    timestamp: entry.timestamp,
402                    runtime: entry.runtime,
403                    containers: entry.containers,
404                },
405            );
406        }
407    }
408    map
409}
410
411/// Parse container cache from JSONL content string (for demo/test use).
412pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
413    let mut map = HashMap::new();
414    for line in content.lines() {
415        let trimmed = line.trim();
416        if trimmed.is_empty() {
417            continue;
418        }
419        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
420            map.insert(
421                entry.alias,
422                ContainerCacheEntry {
423                    timestamp: entry.timestamp,
424                    runtime: entry.runtime,
425                    containers: entry.containers,
426                },
427            );
428        }
429    }
430    map
431}
432
433/// Save container cache to `~/.purple/container_cache.jsonl` via atomic write.
434pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
435    if crate::demo_flag::is_demo() {
436        return;
437    }
438    let Some(home) = dirs::home_dir() else {
439        return;
440    };
441    let path = home.join(".purple").join("container_cache.jsonl");
442    let mut lines = Vec::with_capacity(cache.len());
443    for (alias, entry) in cache {
444        let line = CacheLine {
445            alias: alias.clone(),
446            timestamp: entry.timestamp,
447            runtime: entry.runtime,
448            containers: entry.containers.clone(),
449        };
450        if let Ok(s) = serde_json::to_string(&line) {
451            lines.push(s);
452        }
453    }
454    let content = lines.join("\n");
455    if let Err(e) = crate::fs_util::atomic_write(&path, content.as_bytes()) {
456        log::warn!(
457            "[config] Failed to write container cache {}: {e}",
458            path.display()
459        );
460    }
461}
462
463// ---------------------------------------------------------------------------
464// String truncation
465// ---------------------------------------------------------------------------
466
467/// Truncate a string to at most `max` characters. Appends ".." if truncated.
468pub fn truncate_str(s: &str, max: usize) -> String {
469    let count = s.chars().count();
470    if count <= max {
471        s.to_string()
472    } else {
473        let cut = max.saturating_sub(2);
474        let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
475        format!("{}..", &s[..end])
476    }
477}
478
479// ---------------------------------------------------------------------------
480// Relative time
481// ---------------------------------------------------------------------------
482
483/// Format a Unix timestamp as a human-readable relative time string.
484pub fn format_relative_time(timestamp: u64) -> String {
485    let now = SystemTime::now()
486        .duration_since(UNIX_EPOCH)
487        .unwrap_or_default()
488        .as_secs();
489    let diff = now.saturating_sub(timestamp);
490    if diff < 60 {
491        "just now".to_string()
492    } else if diff < 3600 {
493        format!("{}m ago", diff / 60)
494    } else if diff < 86400 {
495        format!("{}h ago", diff / 3600)
496    } else {
497        format!("{}d ago", diff / 86400)
498    }
499}
500
501// ---------------------------------------------------------------------------
502// Tests
503// ---------------------------------------------------------------------------
504
505#[cfg(test)]
506#[path = "containers_tests.rs"]
507mod tests;