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(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
136pub 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#[derive(Debug, Clone, PartialEq)]
182pub struct ContainerListing {
183 pub runtime: ContainerRuntime,
184 pub engine_version: Option<String>,
185 pub containers: Vec<ContainerInfo>,
186}
187
188pub 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 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#[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
266fn 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
300pub 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
346pub 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
368pub 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#[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 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 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 pub pid: Option<u32>,
458 pub stop_signal: Option<String>,
459 pub stop_timeout: Option<u32>,
460 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 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 pub network_mode: Option<String>,
473 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
492pub fn container_inspect_command(runtime: ContainerRuntime, container_id: &str) -> String {
494 format!("{} inspect {}", runtime.as_str(), container_id)
495}
496
497pub 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
516pub 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 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 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 let pids_limit = host_config["PidsLimit"].as_i64().filter(|n| *n > 0);
674 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
748pub 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
781pub 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
808pub 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
831pub 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
842pub 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
871pub(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
891pub 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#[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#[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#[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 #[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
981pub 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
1011pub 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
1034pub 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
1064pub 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
1080pub 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
1100pub 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#[cfg(test)]
1130#[path = "containers_tests.rs"]
1131mod tests;