Skip to main content

purple_ssh/
containers.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use log::{error, info};
6
7use serde::{Deserialize, Serialize};
8
9// ---------------------------------------------------------------------------
10// ContainerInfo model
11// ---------------------------------------------------------------------------
12
13/// Metadata for a single container (from `docker ps -a` / `podman ps -a`).
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct ContainerInfo {
16    #[serde(rename = "ID")]
17    pub id: String,
18    #[serde(rename = "Names")]
19    pub names: String,
20    #[serde(rename = "Image")]
21    pub image: String,
22    #[serde(rename = "State")]
23    pub state: String,
24    #[serde(rename = "Status")]
25    pub status: String,
26    #[serde(rename = "Ports")]
27    pub ports: String,
28}
29
30/// Parse NDJSON output from `docker ps --format '{{json .}}'`.
31/// Invalid lines are silently ignored (MOTD lines, blank lines, etc.).
32pub fn parse_container_ps(output: &str) -> Vec<ContainerInfo> {
33    output
34        .lines()
35        .filter_map(|line| {
36            let trimmed = line.trim();
37            if trimmed.is_empty() {
38                return None;
39            }
40            serde_json::from_str(trimmed).ok()
41        })
42        .collect()
43}
44
45// ---------------------------------------------------------------------------
46// ContainerRuntime
47// ---------------------------------------------------------------------------
48
49/// Supported container runtimes.
50#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub enum ContainerRuntime {
52    Docker,
53    Podman,
54}
55
56impl ContainerRuntime {
57    /// Returns the CLI binary name.
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            ContainerRuntime::Docker => "docker",
61            ContainerRuntime::Podman => "podman",
62        }
63    }
64}
65
66/// Detect runtime from command output by matching the LAST non-empty trimmed
67/// line. Only "docker" or "podman" are accepted. MOTD-resilient.
68/// Currently unused (sentinel-based detection handles this inline) but kept
69/// as a public utility for potential future two-step detection paths.
70#[allow(dead_code)]
71pub fn parse_runtime(output: &str) -> Option<ContainerRuntime> {
72    let last = output
73        .lines()
74        .rev()
75        .map(|l| l.trim())
76        .find(|l| !l.is_empty())?;
77    match last {
78        "docker" => Some(ContainerRuntime::Docker),
79        "podman" => Some(ContainerRuntime::Podman),
80        _ => None,
81    }
82}
83
84// ---------------------------------------------------------------------------
85// ContainerAction
86// ---------------------------------------------------------------------------
87
88/// Actions that can be performed on a container.
89#[derive(Copy, Clone, Debug, PartialEq)]
90pub enum ContainerAction {
91    Start,
92    Stop,
93    Restart,
94}
95
96impl ContainerAction {
97    /// Returns the CLI sub-command string.
98    pub fn as_str(&self) -> &'static str {
99        match self {
100            ContainerAction::Start => "start",
101            ContainerAction::Stop => "stop",
102            ContainerAction::Restart => "restart",
103        }
104    }
105}
106
107/// Build the shell command to perform an action on a container.
108pub fn container_action_command(
109    runtime: ContainerRuntime,
110    action: ContainerAction,
111    container_id: &str,
112) -> String {
113    format!("{} {} {}", runtime.as_str(), action.as_str(), container_id)
114}
115
116// ---------------------------------------------------------------------------
117// Container ID validation
118// ---------------------------------------------------------------------------
119
120/// Validate a container ID or name.
121/// Accepts ASCII alphanumeric, hyphen, underscore, dot.
122/// Rejects empty, non-ASCII, shell metacharacters, colon.
123pub fn validate_container_id(id: &str) -> Result<(), String> {
124    if id.is_empty() {
125        return Err("Container ID must not be empty.".to_string());
126    }
127    for c in id.chars() {
128        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
129            return Err(format!("Container ID contains invalid character: '{c}'"));
130        }
131    }
132    Ok(())
133}
134
135// ---------------------------------------------------------------------------
136// Combined SSH command + output parsing
137// ---------------------------------------------------------------------------
138
139/// Build the SSH command string for listing containers.
140///
141/// - `Some(Docker)` / `Some(Podman)`: direct listing for the known runtime.
142/// - `None`: combined detection + listing with sentinel markers in one SSH call.
143pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
144    match runtime {
145        Some(ContainerRuntime::Docker) => "docker ps -a --format '{{json .}}'".to_string(),
146        Some(ContainerRuntime::Podman) => "podman ps -a --format '{{json .}}'".to_string(),
147        None => concat!(
148            "if command -v docker >/dev/null 2>&1; then ",
149            "echo '##purple:docker##' && docker ps -a --format '{{json .}}'; ",
150            "elif command -v podman >/dev/null 2>&1; then ",
151            "echo '##purple:podman##' && podman ps -a --format '{{json .}}'; ",
152            "else echo '##purple:none##'; fi"
153        )
154        .to_string(),
155    }
156}
157
158/// Parse the stdout of a container listing command.
159///
160/// When sentinels are present (combined detection run): extract runtime from
161/// the sentinel line, parse remaining lines as NDJSON. When `caller_runtime`
162/// is provided (subsequent run with known runtime): parse all lines as NDJSON.
163pub fn parse_container_output(
164    output: &str,
165    caller_runtime: Option<ContainerRuntime>,
166) -> Result<(ContainerRuntime, Vec<ContainerInfo>), String> {
167    if let Some(sentinel_line) = output.lines().find(|l| l.trim().starts_with("##purple:")) {
168        let sentinel = sentinel_line.trim();
169        if sentinel == "##purple:none##" {
170            return Err("No container runtime found. Install Docker or Podman.".to_string());
171        }
172        let runtime = if sentinel == "##purple:docker##" {
173            ContainerRuntime::Docker
174        } else if sentinel == "##purple:podman##" {
175            ContainerRuntime::Podman
176        } else {
177            return Err(format!("Unknown sentinel: {sentinel}"));
178        };
179        let containers: Vec<ContainerInfo> = output
180            .lines()
181            .filter(|l| !l.trim().starts_with("##purple:"))
182            .filter_map(|line| {
183                let t = line.trim();
184                if t.is_empty() {
185                    return None;
186                }
187                serde_json::from_str(t).ok()
188            })
189            .collect();
190        return Ok((runtime, containers));
191    }
192
193    match caller_runtime {
194        Some(rt) => Ok((rt, parse_container_ps(output))),
195        None => Err("No sentinel found and no runtime provided.".to_string()),
196    }
197}
198
199// ---------------------------------------------------------------------------
200// SSH fetch functions
201// ---------------------------------------------------------------------------
202
203/// Error from a container listing operation. Preserves the detected runtime
204/// even when the `ps` command fails so it can be cached for future calls.
205#[derive(Debug)]
206pub struct ContainerError {
207    pub runtime: Option<ContainerRuntime>,
208    pub message: String,
209}
210
211impl std::fmt::Display for ContainerError {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        write!(f, "{}", self.message)
214    }
215}
216
217/// Translate SSH stderr into a user-friendly error message.
218fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
219    let lower = stderr.to_lowercase();
220    if lower.contains("command not found") {
221        "Docker or Podman not found on remote host.".to_string()
222    } else if lower.contains("permission denied") || lower.contains("got permission denied") {
223        "Permission denied. Is your user in the docker group?".to_string()
224    } else if lower.contains("cannot connect to the docker daemon")
225        || lower.contains("cannot connect to podman")
226    {
227        "Container daemon is not running.".to_string()
228    } else if lower.contains("connection refused") {
229        "Connection refused.".to_string()
230    } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
231        "Host unreachable.".to_string()
232    } else {
233        format!("Command failed with code {}.", code.unwrap_or(1))
234    }
235}
236
237/// Fetch container list synchronously via SSH.
238/// Follows the `fetch_remote_listing` pattern.
239#[allow(clippy::too_many_arguments)]
240pub fn fetch_containers(
241    alias: &str,
242    config_path: &Path,
243    askpass: Option<&str>,
244    bw_session: Option<&str>,
245    has_tunnel: bool,
246    cached_runtime: Option<ContainerRuntime>,
247) -> Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError> {
248    let command = container_list_command(cached_runtime);
249    let result = crate::snippet::run_snippet(
250        alias,
251        config_path,
252        &command,
253        askpass,
254        bw_session,
255        true,
256        has_tunnel,
257    );
258    match result {
259        Ok(r) if r.status.success() => {
260            parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
261                error!("[external] Container list parse failed: alias={alias}: {e}");
262                ContainerError {
263                    runtime: cached_runtime,
264                    message: e,
265                }
266            })
267        }
268        Ok(r) => {
269            let stderr = r.stderr.trim().to_string();
270            let msg = friendly_container_error(&stderr, r.status.code());
271            error!("[external] Container fetch failed: alias={alias}: {msg}");
272            Err(ContainerError {
273                runtime: cached_runtime,
274                message: msg,
275            })
276        }
277        Err(e) => {
278            error!("[external] Container fetch failed: alias={alias}: {e}");
279            Err(ContainerError {
280                runtime: cached_runtime,
281                message: e.to_string(),
282            })
283        }
284    }
285}
286
287/// Spawn a background thread to fetch container listings.
288/// Follows the `spawn_remote_listing` pattern.
289#[allow(clippy::too_many_arguments)]
290pub fn spawn_container_listing<F>(
291    alias: String,
292    config_path: PathBuf,
293    askpass: Option<String>,
294    bw_session: Option<String>,
295    has_tunnel: bool,
296    cached_runtime: Option<ContainerRuntime>,
297    send: F,
298) where
299    F: FnOnce(String, Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError>)
300        + Send
301        + 'static,
302{
303    std::thread::spawn(move || {
304        let result = fetch_containers(
305            &alias,
306            &config_path,
307            askpass.as_deref(),
308            bw_session.as_deref(),
309            has_tunnel,
310            cached_runtime,
311        );
312        send(alias, result);
313    });
314}
315
316/// Spawn a background thread to perform a container action (start/stop/restart).
317/// Validates the container ID before executing.
318#[allow(clippy::too_many_arguments)]
319pub fn spawn_container_action<F>(
320    alias: String,
321    config_path: PathBuf,
322    runtime: ContainerRuntime,
323    action: ContainerAction,
324    container_id: String,
325    askpass: Option<String>,
326    bw_session: Option<String>,
327    has_tunnel: bool,
328    send: F,
329) where
330    F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
331{
332    std::thread::spawn(move || {
333        if let Err(e) = validate_container_id(&container_id) {
334            send(alias, action, Err(e));
335            return;
336        }
337        info!(
338            "Container action: {} container={container_id} alias={alias}",
339            action.as_str()
340        );
341        let command = container_action_command(runtime, action, &container_id);
342        let result = crate::snippet::run_snippet(
343            &alias,
344            &config_path,
345            &command,
346            askpass.as_deref(),
347            bw_session.as_deref(),
348            true,
349            has_tunnel,
350        );
351        match result {
352            Ok(r) if r.status.success() => send(alias, action, Ok(())),
353            Ok(r) => {
354                let err = friendly_container_error(r.stderr.trim(), r.status.code());
355                error!(
356                    "[external] Container {} failed: alias={alias} container={container_id}: {err}",
357                    action.as_str()
358                );
359                send(alias, action, Err(err));
360            }
361            Err(e) => {
362                error!(
363                    "[external] Container {} failed: alias={alias} container={container_id}: {e}",
364                    action.as_str()
365                );
366                send(alias, action, Err(e.to_string()));
367            }
368        }
369    });
370}
371
372// ---------------------------------------------------------------------------
373// JSON lines cache
374// ---------------------------------------------------------------------------
375
376/// A cached container listing for a single host.
377#[derive(Debug, Clone)]
378pub struct ContainerCacheEntry {
379    pub timestamp: u64,
380    pub runtime: ContainerRuntime,
381    pub containers: Vec<ContainerInfo>,
382}
383
384/// Serde helper for a single JSON line in the cache file.
385#[derive(Serialize, Deserialize)]
386struct CacheLine {
387    alias: String,
388    timestamp: u64,
389    runtime: ContainerRuntime,
390    containers: Vec<ContainerInfo>,
391}
392
393/// Load container cache from `~/.purple/container_cache.jsonl`.
394/// Malformed lines are silently ignored. Duplicate aliases: last-write-wins.
395pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
396    let mut map = HashMap::new();
397    let Some(home) = dirs::home_dir() else {
398        return map;
399    };
400    let path = home.join(".purple").join("container_cache.jsonl");
401    let Ok(content) = std::fs::read_to_string(&path) else {
402        return map;
403    };
404    for line in content.lines() {
405        let trimmed = line.trim();
406        if trimmed.is_empty() {
407            continue;
408        }
409        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
410            map.insert(
411                entry.alias,
412                ContainerCacheEntry {
413                    timestamp: entry.timestamp,
414                    runtime: entry.runtime,
415                    containers: entry.containers,
416                },
417            );
418        }
419    }
420    map
421}
422
423/// Parse container cache from JSONL content string (for demo/test use).
424pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
425    let mut map = HashMap::new();
426    for line in content.lines() {
427        let trimmed = line.trim();
428        if trimmed.is_empty() {
429            continue;
430        }
431        if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
432            map.insert(
433                entry.alias,
434                ContainerCacheEntry {
435                    timestamp: entry.timestamp,
436                    runtime: entry.runtime,
437                    containers: entry.containers,
438                },
439            );
440        }
441    }
442    map
443}
444
445/// Save container cache to `~/.purple/container_cache.jsonl` via atomic write.
446pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
447    if crate::demo_flag::is_demo() {
448        return;
449    }
450    let Some(home) = dirs::home_dir() else {
451        return;
452    };
453    let path = home.join(".purple").join("container_cache.jsonl");
454    let mut lines = Vec::with_capacity(cache.len());
455    for (alias, entry) in cache {
456        let line = CacheLine {
457            alias: alias.clone(),
458            timestamp: entry.timestamp,
459            runtime: entry.runtime,
460            containers: entry.containers.clone(),
461        };
462        if let Ok(s) = serde_json::to_string(&line) {
463            lines.push(s);
464        }
465    }
466    let content = lines.join("\n");
467    if let Err(e) = crate::fs_util::atomic_write(&path, content.as_bytes()) {
468        log::warn!(
469            "[config] Failed to write container cache {}: {e}",
470            path.display()
471        );
472    }
473}
474
475// ---------------------------------------------------------------------------
476// String truncation
477// ---------------------------------------------------------------------------
478
479/// Truncate a string to at most `max` characters. Appends ".." if truncated.
480pub fn truncate_str(s: &str, max: usize) -> String {
481    let count = s.chars().count();
482    if count <= max {
483        s.to_string()
484    } else {
485        let cut = max.saturating_sub(2);
486        let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
487        format!("{}..", &s[..end])
488    }
489}
490
491// ---------------------------------------------------------------------------
492// Relative time
493// ---------------------------------------------------------------------------
494
495/// Format a Unix timestamp as a human-readable relative time string.
496pub fn format_relative_time(timestamp: u64) -> String {
497    let now = SystemTime::now()
498        .duration_since(UNIX_EPOCH)
499        .unwrap_or_default()
500        .as_secs();
501    let diff = now.saturating_sub(timestamp);
502    if diff < 60 {
503        "just now".to_string()
504    } else if diff < 3600 {
505        format!("{}m ago", diff / 60)
506    } else if diff < 86400 {
507        format!("{}h ago", diff / 3600)
508    } else {
509        format!("{}d ago", diff / 86400)
510    }
511}
512
513// ---------------------------------------------------------------------------
514// Tests
515// ---------------------------------------------------------------------------
516
517#[cfg(test)]
518#[path = "containers_tests.rs"]
519mod tests;