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("remote host identification has changed")
222        || (lower.contains("host key for") && lower.contains("has changed"))
223    {
224        log::debug!("[external] Host key CHANGED detected; returning HOST_KEY_CHANGED toast");
225        crate::messages::HOST_KEY_CHANGED.to_string()
226    } else if lower.contains("host key verification failed")
227        || lower.contains("no matching host key")
228        || lower.contains("no ed25519 host key is known")
229        || lower.contains("no rsa host key is known")
230        || lower.contains("no ecdsa host key is known")
231        || lower.contains("host key is not known")
232    {
233        log::debug!("[external] Host key UNKNOWN detected; returning HOST_KEY_UNKNOWN toast");
234        crate::messages::HOST_KEY_UNKNOWN.to_string()
235    } else if lower.contains("command not found") {
236        "Docker or Podman not found on remote host.".to_string()
237    } else if lower.contains("permission denied") || lower.contains("got permission denied") {
238        "Permission denied. Is your user in the docker group?".to_string()
239    } else if lower.contains("cannot connect to the docker daemon")
240        || lower.contains("cannot connect to podman")
241    {
242        "Container daemon is not running.".to_string()
243    } else if lower.contains("connection refused") {
244        "Connection refused.".to_string()
245    } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
246        "Host unreachable.".to_string()
247    } else {
248        format!("Command failed with code {}.", code.unwrap_or(1))
249    }
250}
251
252/// Fetch container list synchronously via SSH.
253/// Follows the `fetch_remote_listing` pattern.
254pub fn fetch_containers(
255    ctx: &SshContext<'_>,
256    cached_runtime: Option<ContainerRuntime>,
257) -> Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError> {
258    let command = container_list_command(cached_runtime);
259    let result = crate::snippet::run_snippet(
260        ctx.alias,
261        ctx.config_path,
262        &command,
263        ctx.askpass,
264        ctx.bw_session,
265        true,
266        ctx.has_tunnel,
267    );
268    let alias = ctx.alias;
269    match result {
270        Ok(r) if r.status.success() => {
271            parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
272                error!("[external] Container list parse failed: alias={alias}: {e}");
273                ContainerError {
274                    runtime: cached_runtime,
275                    message: e,
276                }
277            })
278        }
279        Ok(r) => {
280            let stderr = r.stderr.trim().to_string();
281            let msg = friendly_container_error(&stderr, r.status.code());
282            error!("[external] Container fetch failed: alias={alias}: {msg}");
283            Err(ContainerError {
284                runtime: cached_runtime,
285                message: msg,
286            })
287        }
288        Err(e) => {
289            error!("[external] Container fetch failed: alias={alias}: {e}");
290            Err(ContainerError {
291                runtime: cached_runtime,
292                message: e.to_string(),
293            })
294        }
295    }
296}
297
298/// Spawn a background thread to fetch container listings.
299/// Follows the `spawn_remote_listing` pattern.
300pub fn spawn_container_listing<F>(
301    ctx: OwnedSshContext,
302    cached_runtime: Option<ContainerRuntime>,
303    send: F,
304) where
305    F: FnOnce(String, Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError>)
306        + Send
307        + 'static,
308{
309    std::thread::spawn(move || {
310        let borrowed = SshContext {
311            alias: &ctx.alias,
312            config_path: &ctx.config_path,
313            askpass: ctx.askpass.as_deref(),
314            bw_session: ctx.bw_session.as_deref(),
315            has_tunnel: ctx.has_tunnel,
316        };
317        let result = fetch_containers(&borrowed, cached_runtime);
318        send(ctx.alias, result);
319    });
320}
321
322/// Spawn a background thread to perform a container action (start/stop/restart).
323/// Validates the container ID before executing.
324pub fn spawn_container_action<F>(
325    ctx: OwnedSshContext,
326    runtime: ContainerRuntime,
327    action: ContainerAction,
328    container_id: String,
329    send: F,
330) where
331    F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
332{
333    std::thread::spawn(move || {
334        if let Err(e) = validate_container_id(&container_id) {
335            send(ctx.alias, action, Err(e));
336            return;
337        }
338        let alias = &ctx.alias;
339        info!(
340            "Container action: {} container={container_id} alias={alias}",
341            action.as_str()
342        );
343        let command = container_action_command(runtime, action, &container_id);
344        let result = crate::snippet::run_snippet(
345            alias,
346            &ctx.config_path,
347            &command,
348            ctx.askpass.as_deref(),
349            ctx.bw_session.as_deref(),
350            true,
351            ctx.has_tunnel,
352        );
353        match result {
354            Ok(r) if r.status.success() => send(ctx.alias, action, Ok(())),
355            Ok(r) => {
356                let err = friendly_container_error(r.stderr.trim(), r.status.code());
357                error!(
358                    "[external] Container {} failed: alias={alias} container={container_id}: {err}",
359                    action.as_str()
360                );
361                send(ctx.alias, action, Err(err));
362            }
363            Err(e) => {
364                error!(
365                    "[external] Container {} failed: alias={alias} container={container_id}: {e}",
366                    action.as_str()
367                );
368                send(ctx.alias, action, Err(e.to_string()));
369            }
370        }
371    });
372}
373
374// ---------------------------------------------------------------------------
375// JSON lines cache
376// ---------------------------------------------------------------------------
377
378/// A cached container listing for a single host.
379#[derive(Debug, Clone)]
380pub struct ContainerCacheEntry {
381    pub timestamp: u64,
382    pub runtime: ContainerRuntime,
383    pub containers: Vec<ContainerInfo>,
384}
385
386/// Serde helper for a single JSON line in the cache file.
387#[derive(Serialize, Deserialize)]
388struct CacheLine {
389    alias: String,
390    timestamp: u64,
391    runtime: ContainerRuntime,
392    containers: Vec<ContainerInfo>,
393}
394
395/// Load container cache from `~/.purple/container_cache.jsonl`.
396/// Malformed lines are silently ignored. Duplicate aliases: last-write-wins.
397pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
398    let mut map = HashMap::new();
399    let Some(home) = dirs::home_dir() else {
400        return map;
401    };
402    let path = home.join(".purple").join("container_cache.jsonl");
403    let Ok(content) = std::fs::read_to_string(&path) else {
404        return map;
405    };
406    for line in content.lines() {
407        let trimmed = line.trim();
408        if trimmed.is_empty() {
409            continue;
410        }
411        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
412            map.insert(
413                entry.alias,
414                ContainerCacheEntry {
415                    timestamp: entry.timestamp,
416                    runtime: entry.runtime,
417                    containers: entry.containers,
418                },
419            );
420        }
421    }
422    map
423}
424
425/// Parse container cache from JSONL content string (for demo/test use).
426pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
427    let mut map = HashMap::new();
428    for line in content.lines() {
429        let trimmed = line.trim();
430        if trimmed.is_empty() {
431            continue;
432        }
433        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
434            map.insert(
435                entry.alias,
436                ContainerCacheEntry {
437                    timestamp: entry.timestamp,
438                    runtime: entry.runtime,
439                    containers: entry.containers,
440                },
441            );
442        }
443    }
444    map
445}
446
447/// Save container cache to `~/.purple/container_cache.jsonl` via atomic write.
448pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
449    if crate::demo_flag::is_demo() {
450        return;
451    }
452    let Some(home) = dirs::home_dir() else {
453        return;
454    };
455    let path = home.join(".purple").join("container_cache.jsonl");
456    let mut lines = Vec::with_capacity(cache.len());
457    for (alias, entry) in cache {
458        let line = CacheLine {
459            alias: alias.clone(),
460            timestamp: entry.timestamp,
461            runtime: entry.runtime,
462            containers: entry.containers.clone(),
463        };
464        if let Ok(s) = serde_json::to_string(&line) {
465            lines.push(s);
466        }
467    }
468    let content = lines.join("\n");
469    if let Err(e) = crate::fs_util::atomic_write(&path, content.as_bytes()) {
470        log::warn!(
471            "[config] Failed to write container cache {}: {e}",
472            path.display()
473        );
474    }
475}
476
477// ---------------------------------------------------------------------------
478// String truncation
479// ---------------------------------------------------------------------------
480
481/// Truncate a string to at most `max` characters. Appends ".." if truncated.
482pub fn truncate_str(s: &str, max: usize) -> String {
483    let count = s.chars().count();
484    if count <= max {
485        s.to_string()
486    } else {
487        let cut = max.saturating_sub(2);
488        let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
489        format!("{}..", &s[..end])
490    }
491}
492
493// ---------------------------------------------------------------------------
494// Relative time
495// ---------------------------------------------------------------------------
496
497/// Format a Unix timestamp as a human-readable relative time string.
498pub fn format_relative_time(timestamp: u64) -> String {
499    let now = SystemTime::now()
500        .duration_since(UNIX_EPOCH)
501        .unwrap_or_default()
502        .as_secs();
503    let diff = now.saturating_sub(timestamp);
504    if diff < 60 {
505        "just now".to_string()
506    } else if diff < 3600 {
507        format!("{}m ago", diff / 60)
508    } else if diff < 86400 {
509        format!("{}h ago", diff / 3600)
510    } else {
511        format!("{}d ago", diff / 86400)
512    }
513}
514
515// ---------------------------------------------------------------------------
516// Tests
517// ---------------------------------------------------------------------------
518
519#[cfg(test)]
520#[path = "containers_tests.rs"]
521mod tests;