Skip to main content

purple_ssh/app/
container_state.rs

1//! Containers overlay state.
2
3use crate::app::{ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest};
4
5/// Per-host overlay session state; only valid while the containers overlay is open.
6///
7/// No `Default` impl: construction always requires an alias and runtime
8/// metadata, so a default-constructed value would be meaningless.
9pub struct ContainerSession {
10    pub alias: String,
11    pub askpass: Option<String>,
12    pub runtime: Option<crate::containers::ContainerRuntime>,
13    pub containers: Vec<crate::containers::ContainerInfo>,
14    pub list_state: ratatui::widgets::ListState,
15    pub loading: bool,
16    pub error: Option<String>,
17    pub action_in_progress: Option<String>,
18    /// Pending confirmation for stop/restart actions: (action, container_name, container_id).
19    pub confirm_action: Option<(crate::containers::ContainerAction, String, String)>,
20}
21
22/// Always-present container-domain state: cache and cross-host pending operations.
23/// Separate from `ContainerSession`, which is the per-host overlay session state.
24#[derive(Debug, Default)]
25pub struct ContainerState {
26    pub(in crate::app) pending_exec: Option<ContainerExecRequest>,
27    pub(in crate::app) pending_logs: Option<ContainerLogsRequest>,
28    pub(in crate::app) pending_actions: std::collections::VecDeque<ContainerActionRequest>,
29    pub(in crate::app) pending_fetch_aliases: Vec<String>,
30    pub(in crate::app) cache:
31        std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
32}
33
34impl ContainerState {
35    pub fn cache(
36        &self,
37    ) -> &std::collections::HashMap<String, crate::containers::ContainerCacheEntry> {
38        &self.cache
39    }
40
41    pub fn set_cache(
42        &mut self,
43        cache: std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
44    ) {
45        self.cache = cache;
46    }
47
48    pub fn cache_entry(&self, alias: &str) -> Option<&crate::containers::ContainerCacheEntry> {
49        self.cache.get(alias)
50    }
51
52    pub fn cache_entry_mut(
53        &mut self,
54        alias: &str,
55    ) -> Option<&mut crate::containers::ContainerCacheEntry> {
56        self.cache.get_mut(alias)
57    }
58
59    pub fn cache_contains(&self, alias: &str) -> bool {
60        self.cache.contains_key(alias)
61    }
62
63    pub fn cache_len(&self) -> usize {
64        self.cache.len()
65    }
66
67    pub fn insert_cache_entry(
68        &mut self,
69        alias: String,
70        entry: crate::containers::ContainerCacheEntry,
71    ) {
72        self.cache.insert(alias, entry);
73    }
74
75    pub fn remove_cache_entry(&mut self, alias: &str) {
76        self.cache.remove(alias);
77    }
78
79    pub fn clear_cache(&mut self) {
80        self.cache.clear();
81    }
82
83    pub fn pending_exec_request(&self) -> Option<&ContainerExecRequest> {
84        self.pending_exec.as_ref()
85    }
86
87    pub fn pending_logs_request(&self) -> Option<&ContainerLogsRequest> {
88        self.pending_logs.as_ref()
89    }
90
91    pub fn has_pending_fetches(&self) -> bool {
92        !self.pending_fetch_aliases.is_empty()
93    }
94
95    pub fn pending_actions_len(&self) -> usize {
96        self.pending_actions.len()
97    }
98
99    pub fn take_pending_exec(&mut self) -> Option<ContainerExecRequest> {
100        self.pending_exec.take()
101    }
102
103    pub fn take_pending_logs(&mut self) -> Option<ContainerLogsRequest> {
104        self.pending_logs.take()
105    }
106
107    pub fn pop_next_action(&mut self) -> Option<ContainerActionRequest> {
108        self.pending_actions.pop_front()
109    }
110
111    pub fn pending_actions_iter(&self) -> impl Iterator<Item = &ContainerActionRequest> {
112        self.pending_actions.iter()
113    }
114
115    pub fn pending_actions_at(&self, idx: usize) -> Option<&ContainerActionRequest> {
116        self.pending_actions.get(idx)
117    }
118
119    pub fn pending_fetch_aliases(&self) -> &[String] {
120        &self.pending_fetch_aliases
121    }
122
123    pub fn extend_pending_fetches<I: IntoIterator<Item = String>>(&mut self, iter: I) {
124        self.pending_fetch_aliases.extend(iter);
125    }
126
127    /// Queue a logs request for the main loop to drain. Replaces any
128    /// previous pending logs request and logs the displaced alias so a
129    /// dropped request is traceable.
130    pub fn queue_logs(&mut self, req: ContainerLogsRequest) {
131        if let Some(prev) = self.pending_logs.as_ref() {
132            log::debug!(
133                "[purple] queue_logs replaced pending request for alias={} id={}",
134                prev.alias,
135                prev.container_id,
136            );
137        }
138        self.pending_logs = Some(req);
139    }
140
141    /// Queue an exec request for the main loop to drain. Same replace
142    /// and log semantics as `queue_logs`.
143    pub fn queue_exec(&mut self, req: ContainerExecRequest) {
144        if let Some(prev) = self.pending_exec.as_ref() {
145            log::debug!(
146                "[purple] queue_exec replaced pending request for alias={} id={}",
147                prev.alias,
148                prev.container_id,
149            );
150        }
151        self.pending_exec = Some(req);
152    }
153
154    /// Queue a non-interactive container action for the worker thread.
155    /// Actions are FIFO via `VecDeque::push_back`; multiple actions
156    /// against the same alias process in order.
157    pub fn queue_action(&mut self, req: ContainerActionRequest) {
158        self.pending_actions.push_back(req);
159    }
160
161    /// Enqueue an alias for the initial container-cache fetch. Drained by
162    /// the main loop on the next tick via `drain_pending_fetches`.
163    pub fn queue_fetch(&mut self, alias: String) {
164        self.pending_fetch_aliases.push(alias);
165    }
166
167    /// Take the full fetch queue, leaving it empty.
168    pub fn drain_pending_fetches(&mut self) -> Vec<String> {
169        std::mem::take(&mut self.pending_fetch_aliases)
170    }
171
172    /// Drop cache entries whose host alias is no longer in
173    /// `valid_aliases`. Returns `true` when anything was dropped so the
174    /// caller can persist the trimmed cache via
175    /// `containers::save_container_cache`. The cache is also the source
176    /// of valid container IDs for downstream inspect/logs pruning.
177    pub fn prune_orphans(&mut self, valid_aliases: &std::collections::HashSet<&str>) -> bool {
178        let pre = self.cache.len();
179        self.cache
180            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
181        let dropped = pre.saturating_sub(self.cache.len());
182        if dropped > 0 {
183            log::debug!("[purple] reload_hosts: dropped {dropped} orphan container_cache host(s)");
184            true
185        } else {
186            false
187        }
188    }
189
190    /// Move a cache entry from `old` to `new` on host rename. Returns
191    /// `true` when the cache changed so the caller can persist. No-op
192    /// (returns `false`) when `old == new` or no entry exists under `old`.
193    pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
194        if old == new {
195            return false;
196        }
197        if let Some(v) = self.cache.remove(old) {
198            debug_assert!(
199                !self.cache.contains_key(new),
200                "container_state.cache collision on rename {old} -> {new}"
201            );
202            self.cache.insert(new.to_string(), v);
203            true
204        } else {
205            false
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::containers::{ContainerCacheEntry, ContainerRuntime};
214
215    fn make_logs_request(alias: &str) -> ContainerLogsRequest {
216        ContainerLogsRequest {
217            alias: alias.to_string(),
218            askpass: None,
219            runtime: ContainerRuntime::Docker,
220            container_id: "abc123".to_string(),
221            container_name: "nginx".to_string(),
222        }
223    }
224
225    fn make_cache_entry() -> ContainerCacheEntry {
226        ContainerCacheEntry {
227            timestamp: 1700000000,
228            runtime: ContainerRuntime::Docker,
229            engine_version: Some("28.0.0".to_string()),
230            containers: vec![],
231        }
232    }
233
234    #[test]
235    fn queue_logs_sets_pending() {
236        let mut state = ContainerState::default();
237        assert!(state.pending_logs.is_none());
238        state.queue_logs(make_logs_request("host-a"));
239        assert!(state.pending_logs.is_some());
240        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-a");
241    }
242
243    #[test]
244    fn queue_logs_replaces_previous() {
245        let mut state = ContainerState::default();
246        state.queue_logs(make_logs_request("host-a"));
247        state.queue_logs(make_logs_request("host-b"));
248        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-b");
249    }
250
251    #[test]
252    fn queue_exec_sets_pending() {
253        let mut state = ContainerState::default();
254        assert!(state.pending_exec.is_none());
255        state.queue_exec(ContainerExecRequest {
256            alias: "host-a".to_string(),
257            askpass: None,
258            runtime: ContainerRuntime::Docker,
259            container_id: "abc".to_string(),
260            container_name: "nginx".to_string(),
261            command: Some("echo hi".to_string()),
262        });
263        assert!(state.pending_exec.is_some());
264        assert_eq!(state.pending_exec.as_ref().unwrap().alias, "host-a");
265    }
266
267    #[test]
268    fn queue_fetch_pushes_alias() {
269        let mut state = ContainerState::default();
270        state.queue_fetch("host-a".to_string());
271        state.queue_fetch("host-b".to_string());
272        assert_eq!(state.pending_fetch_aliases, vec!["host-a", "host-b"]);
273    }
274
275    #[test]
276    fn drain_pending_fetches_returns_and_clears() {
277        let mut state = ContainerState::default();
278        state.queue_fetch("host-a".to_string());
279        state.queue_fetch("host-b".to_string());
280        let drained = state.drain_pending_fetches();
281        assert_eq!(drained, vec!["host-a", "host-b"]);
282        assert!(state.pending_fetch_aliases.is_empty());
283    }
284
285    #[test]
286    fn drain_pending_fetches_empty_when_no_aliases() {
287        let mut state = ContainerState::default();
288        let drained = state.drain_pending_fetches();
289        assert!(drained.is_empty());
290        assert!(state.pending_fetch_aliases.is_empty());
291    }
292
293    #[test]
294    fn migrate_alias_renames_cache_entry() {
295        let mut state = ContainerState::default();
296        state.cache.insert("old".to_string(), make_cache_entry());
297        assert!(state.migrate_alias("old", "new"));
298        assert!(state.cache.contains_key("new"));
299        assert!(!state.cache.contains_key("old"));
300    }
301
302    #[test]
303    fn migrate_alias_returns_false_when_no_entry() {
304        let mut state = ContainerState::default();
305        assert!(!state.migrate_alias("missing", "new"));
306        assert!(state.cache.is_empty());
307    }
308
309    #[test]
310    fn migrate_alias_self_rename_is_noop() {
311        let mut state = ContainerState::default();
312        state.cache.insert("same".to_string(), make_cache_entry());
313        assert!(!state.migrate_alias("same", "same"));
314        assert!(state.cache.contains_key("same"));
315    }
316
317    #[test]
318    fn queue_action_pushes_back_in_order() {
319        let mut state = ContainerState::default();
320        for id in ["a", "b", "c"] {
321            state.queue_action(ContainerActionRequest {
322                alias: "host".to_string(),
323                askpass: None,
324                runtime: ContainerRuntime::Docker,
325                container_id: id.to_string(),
326                container_name: id.to_string(),
327                action: crate::containers::ContainerAction::Restart,
328            });
329        }
330        assert_eq!(state.pending_actions.len(), 3);
331        let ids: Vec<String> = state
332            .pending_actions
333            .iter()
334            .map(|r| r.container_id.clone())
335            .collect();
336        assert_eq!(ids, vec!["a", "b", "c"]);
337    }
338
339    #[test]
340    fn prune_orphans_drops_unknown_aliases_and_signals_persist() {
341        let mut state = ContainerState::default();
342        state.cache.insert("keep".to_string(), make_cache_entry());
343        state.cache.insert("drop".to_string(), make_cache_entry());
344
345        let valid: std::collections::HashSet<&str> = ["keep"].into_iter().collect();
346        let changed = state.prune_orphans(&valid);
347
348        assert!(changed, "returns true so caller persists the trimmed cache");
349        assert!(state.cache.contains_key("keep"));
350        assert!(!state.cache.contains_key("drop"));
351    }
352
353    #[test]
354    fn prune_orphans_returns_false_when_nothing_dropped() {
355        let mut state = ContainerState::default();
356        state.cache.insert("keep".to_string(), make_cache_entry());
357
358        let valid: std::collections::HashSet<&str> = ["keep"].into_iter().collect();
359        assert!(!state.prune_orphans(&valid));
360    }
361}