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#[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
31pub 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#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
52pub enum ContainerRuntime {
53 Docker,
54 Podman,
55}
56
57impl ContainerRuntime {
58 pub fn as_str(&self) -> &'static str {
60 match self {
61 ContainerRuntime::Docker => "docker",
62 ContainerRuntime::Podman => "podman",
63 }
64 }
65}
66
67#[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#[derive(Copy, Clone, Debug, PartialEq)]
91pub enum ContainerAction {
92 Start,
93 Stop,
94 Restart,
95}
96
97impl ContainerAction {
98 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
108pub 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
117pub 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
136pub 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
159pub 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#[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
218fn 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
252pub 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
298pub 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
322pub 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#[derive(Debug, Clone)]
380pub struct ContainerCacheEntry {
381 pub timestamp: u64,
382 pub runtime: ContainerRuntime,
383 pub containers: Vec<ContainerInfo>,
384}
385
386#[derive(Serialize, Deserialize)]
388struct CacheLine {
389 alias: String,
390 timestamp: u64,
391 runtime: ContainerRuntime,
392 containers: Vec<ContainerInfo>,
393}
394
395pub 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
425pub 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
447pub 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
477pub 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
493pub 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#[cfg(test)]
520#[path = "containers_tests.rs"]
521mod tests;