1use std::collections::{HashMap, HashSet};
12use std::fs;
13use std::process::{Command, Stdio};
14use std::sync::mpsc::{self, Receiver};
15use std::thread;
16use std::time::{Duration, Instant};
17
18use super::SysRoots;
19use crate::snapshot::ProcessInfo;
20
21const NAME_LOOKUP_INTERVAL: Duration = Duration::from_secs(30);
23
24const SHORT_ID: usize = 12;
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub(crate) enum ContainerRef {
30 Id(String),
32 Named(String),
34}
35
36pub(crate) fn parse_cgroup(text: &str) -> Option<ContainerRef> {
40 text.lines()
41 .filter_map(|line| line.splitn(3, ':').nth(2))
42 .find_map(|path| path.rsplit('/').find_map(parse_component))
43}
44
45fn parse_component(part: &str) -> Option<ContainerRef> {
47 for prefix in ["docker-", "libpod-", "cri-containerd-", "crio-"] {
50 if let Some(id) = part
51 .strip_prefix(prefix)
52 .and_then(|rest| rest.strip_suffix(".scope"))
53 .filter(|id| is_container_id(id))
54 {
55 return Some(ContainerRef::Id(id.to_owned()));
56 }
57 }
58 if is_container_id(part) {
60 return Some(ContainerRef::Id(part.to_owned()));
61 }
62 if let Some(name) = part.strip_prefix("lxc.payload.") {
63 return Some(ContainerRef::Named(name.to_owned()));
64 }
65 part.strip_prefix("systemd-nspawn@")
66 .and_then(|rest| rest.strip_suffix(".service"))
67 .map(|name| ContainerRef::Named(name.to_owned()))
68}
69
70fn is_container_id(text: &str) -> bool {
72 text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit())
73}
74
75#[derive(Debug, Default)]
78pub struct ContainerTracker {
79 by_pid: HashMap<u32, (String, Option<ContainerRef>)>,
82 names: HashMap<String, String>,
84 unnamed: HashSet<String>,
86 last_lookup: Option<Instant>,
88 pending: Option<Receiver<HashMap<String, String>>>,
90}
91
92impl ContainerTracker {
93 pub fn new() -> Self {
95 Self::default()
96 }
97
98 pub fn annotate(&mut self, roots: &SysRoots, processes: &mut [ProcessInfo], now: Instant) {
100 self.receive_names();
101 let alive: HashSet<u32> = processes.iter().map(|p| p.pid).collect();
102 self.by_pid.retain(|pid, _| alive.contains(pid));
103
104 let mut unknown = false;
105 for process in processes.iter_mut() {
106 let cached = self
107 .by_pid
108 .get(&process.pid)
109 .filter(|(name, _)| *name == process.name);
110 let container = match cached {
111 Some((_, container)) => container.clone(),
112 None => {
113 let path = roots.proc.join(process.pid.to_string()).join("cgroup");
114 let container = fs::read_to_string(path)
115 .ok()
116 .and_then(|text| parse_cgroup(&text));
117 self.by_pid
118 .insert(process.pid, (process.name.clone(), container.clone()));
119 container
120 }
121 };
122 process.container = container.map(|c| match c {
123 ContainerRef::Named(name) => name,
124 ContainerRef::Id(id) => match self.names.get(&id) {
125 Some(name) => name.clone(),
126 None => {
127 unknown |= !self.unnamed.contains(&id);
128 id[..SHORT_ID].to_owned()
129 }
130 },
131 });
132 }
133 if unknown {
134 self.start_lookup(now);
135 }
136 }
137
138 fn receive_names(&mut self) {
140 let Some(rx) = &self.pending else {
141 return;
142 };
143 let names = match rx.try_recv() {
144 Ok(names) => names,
145 Err(mpsc::TryRecvError::Empty) => return,
146 Err(mpsc::TryRecvError::Disconnected) => {
147 self.pending = None;
148 return;
149 }
150 };
151 self.pending = None;
152 let ids = self.by_pid.values().filter_map(|(_, c)| match c {
155 Some(ContainerRef::Id(id)) => Some(id.clone()),
156 _ => None,
157 });
158 self.unnamed = ids.filter(|id| !names.contains_key(id)).collect();
159 self.names = names;
160 }
161
162 fn start_lookup(&mut self, now: Instant) {
165 let recent = self
166 .last_lookup
167 .is_some_and(|at| now.saturating_duration_since(at) < NAME_LOOKUP_INTERVAL);
168 if self.pending.is_some() || recent {
169 return;
170 }
171 self.last_lookup = Some(now);
172 let (tx, rx) = mpsc::channel();
173 self.pending = Some(rx);
174 thread::spawn(move || {
175 let mut names = HashMap::new();
176 for program in ["docker", "podman"] {
177 names.extend(list_containers(program));
178 }
179 let _ = tx.send(names);
180 });
181 }
182}
183
184fn list_containers(program: &str) -> HashMap<String, String> {
188 Command::new(program)
189 .args(["ps", "--no-trunc", "--format", "{{.ID}} {{.Names}}"])
190 .stdin(Stdio::null())
191 .stderr(Stdio::null())
192 .output()
193 .ok()
194 .filter(|out| out.status.success())
195 .map(|out| parse_ps(&String::from_utf8_lossy(&out.stdout)))
196 .unwrap_or_default()
197}
198
199pub(crate) fn parse_ps(text: &str) -> HashMap<String, String> {
201 text.lines()
202 .filter_map(|line| line.trim().split_once(' '))
203 .filter(|(id, _)| is_container_id(id))
204 .map(|(id, name)| (id.to_owned(), name.trim().to_owned()))
205 .collect()
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::linux::testutil::write;
212
213 const ID: &str = "1712446b193ad552c0d920be599af9ba2bf571a05315a64c1be25fd712e1f17f";
214
215 #[test]
216 fn recognizes_container_cgroups() {
217 let id = |s: &str| Some(ContainerRef::Id(s.to_owned()));
218 let cases = [
219 (format!("0::/system.slice/docker-{ID}.scope"), id(ID)),
220 (format!("0::/docker/{ID}"), id(ID)),
221 (
222 format!(
223 "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-{ID}.scope/container"
224 ),
225 id(ID),
226 ),
227 (
228 format!(
229 "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod1.slice/cri-containerd-{ID}.scope"
230 ),
231 id(ID),
232 ),
233 (
235 format!("12:pids:/docker/{ID}\n11:cpu,cpuacct:/docker/{ID}\n0::/"),
236 id(ID),
237 ),
238 (
239 "0::/lxc.payload.web/system.slice/nginx.service".to_owned(),
240 Some(ContainerRef::Named("web".to_owned())),
241 ),
242 (
243 "0::/machine.slice/systemd-nspawn@build.service/payload".to_owned(),
244 Some(ContainerRef::Named("build".to_owned())),
245 ),
246 ];
247 for (text, expected) in cases {
248 assert_eq!(parse_cgroup(&text), expected, "{text}");
249 }
250 for text in [
251 "0::/system.slice/containerd.service".to_owned(),
252 "0::/user.slice/user-1000.slice/session-2.scope".to_owned(),
253 format!(
254 "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-conmon-{ID}.scope"
255 ),
256 "0::/".to_owned(),
257 ] {
258 assert_eq!(parse_cgroup(&text), None, "{text}");
259 }
260 }
261
262 #[test]
263 fn parses_ps_output() {
264 let text = format!("{ID} web-frontend-1\nshort nope\n\n");
265 let names = parse_ps(&text);
266 assert_eq!(names.len(), 1);
267 assert_eq!(names[ID], "web-frontend-1");
268 }
269
270 #[test]
271 fn annotates_processes_and_remembers_them() {
272 let tmp = tempfile::tempdir().unwrap();
273 write(
274 tmp.path(),
275 "proc/10/cgroup",
276 &format!("0::/system.slice/docker-{ID}.scope\n"),
277 );
278 write(
279 tmp.path(),
280 "proc/11/cgroup",
281 "0::/user.slice/session-2.scope\n",
282 );
283 write(tmp.path(), "proc/12/cgroup", "0::/lxc.payload.web\n");
284 let roots = SysRoots {
285 sys: tmp.path().join("sys"),
286 proc: tmp.path().join("proc"),
287 };
288 let process = |pid, name: &str| ProcessInfo {
289 pid,
290 name: name.to_owned(),
291 ..Default::default()
292 };
293 let mut processes = vec![
294 process(10, "nginx"),
295 process(11, "bash"),
296 process(12, "init"),
297 ];
298 let mut tracker = ContainerTracker::new();
299 tracker.pending = Some(mpsc::channel().1);
301 tracker.last_lookup = Some(Instant::now());
302 tracker.annotate(&roots, &mut processes, Instant::now());
303 let containers: Vec<Option<&str>> =
304 processes.iter().map(|p| p.container.as_deref()).collect();
305 assert_eq!(containers, vec![Some(&ID[..12]), None, Some("web")]);
306
307 let (tx, rx) = mpsc::channel();
309 tx.send(HashMap::from([(ID.to_owned(), "frontend".to_owned())]))
310 .unwrap();
311 tracker.pending = Some(rx);
312 fs::remove_file(tmp.path().join("proc/10/cgroup")).unwrap();
313 tracker.annotate(&roots, &mut processes, Instant::now());
314 assert_eq!(processes[0].container.as_deref(), Some("frontend"));
315
316 let mut reused = vec![process(10, "python3")];
318 tracker.annotate(&roots, &mut reused, Instant::now());
319 assert_eq!(reused[0].container, None);
320 }
321}