1use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::process::{Command, Stdio};
15use std::sync::mpsc::{self, Receiver};
16use std::thread;
17use std::time::{Duration, Instant};
18
19use super::SysRoots;
20use crate::snapshot::ProcessInfo;
21
22const NAME_LOOKUP_INTERVAL: Duration = Duration::from_secs(30);
24
25const SHORT_ID: usize = 12;
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub(crate) enum ContainerRef {
31 Id(String),
33 Named(String),
36}
37
38pub(crate) fn parse_cgroup(text: &str) -> Option<ContainerRef> {
42 text.lines()
43 .filter_map(|line| line.splitn(3, ':').nth(2))
44 .find_map(|path| {
45 path.rsplit('/')
46 .find_map(parse_component)
47 .or_else(|| proxmox_guest(path))
48 })
49}
50
51fn proxmox_guest(path: &str) -> Option<ContainerRef> {
55 let mut parts = path.trim_start_matches('/').split('/');
56 let (first, second) = (parts.next()?, parts.next()?);
57 let vmid = |text: &str| !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit());
58 match first {
59 "qemu.slice" => second
60 .strip_suffix(".scope")
61 .filter(|id| vmid(id))
62 .map(|id| ContainerRef::Named(format!("vm {id}"))),
63 "lxc" if vmid(second) => Some(ContainerRef::Named(format!("ct {second}"))),
64 _ => None,
65 }
66}
67
68fn parse_component(part: &str) -> Option<ContainerRef> {
70 for prefix in ["docker-", "libpod-", "cri-containerd-", "crio-"] {
73 if let Some(id) = part
74 .strip_prefix(prefix)
75 .and_then(|rest| rest.strip_suffix(".scope"))
76 .filter(|id| is_container_id(id))
77 {
78 return Some(ContainerRef::Id(id.to_owned()));
79 }
80 }
81 if is_container_id(part) {
83 return Some(ContainerRef::Id(part.to_owned()));
84 }
85 if let Some(name) = part.strip_prefix("lxc.payload.") {
86 return Some(ContainerRef::Named(name.to_owned()));
87 }
88 if let Some(name) = part
89 .strip_prefix("systemd-nspawn@")
90 .and_then(|rest| rest.strip_suffix(".service"))
91 {
92 return Some(ContainerRef::Named(name.to_owned()));
93 }
94 part.strip_prefix("machine-")
98 .and_then(|rest| rest.strip_suffix(".scope"))
99 .map(|name| ContainerRef::Named(machine_name(&unescape_unit(name))))
100}
101
102fn machine_name(name: &str) -> String {
105 for driver in ["qemu-", "lxc-"] {
106 if let Some((id, rest)) = name.strip_prefix(driver).and_then(|r| r.split_once('-'))
107 && !id.is_empty()
108 && id.bytes().all(|b| b.is_ascii_digit())
109 && !rest.is_empty()
110 {
111 return rest.to_owned();
112 }
113 }
114 name.to_owned()
115}
116
117fn unescape_unit(text: &str) -> String {
119 let bytes = text.as_bytes();
120 let mut out = Vec::with_capacity(bytes.len());
121 let mut i = 0;
122 while i < bytes.len() {
123 if bytes[i] == b'\\'
124 && bytes.get(i + 1) == Some(&b'x')
125 && let Some(byte) = text
126 .get(i + 2..i + 4)
127 .and_then(|hex| u8::from_str_radix(hex, 16).ok())
128 {
129 out.push(byte);
130 i += 4;
131 } else {
132 out.push(bytes[i]);
133 i += 1;
134 }
135 }
136 String::from_utf8_lossy(&out).into_owned()
137}
138
139fn is_container_id(text: &str) -> bool {
141 text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit())
142}
143
144#[derive(Debug, Default)]
147pub struct ContainerTracker {
148 by_pid: HashMap<u32, (String, Option<ContainerRef>)>,
151 names: HashMap<String, String>,
153 unnamed: HashSet<String>,
155 last_lookup: Option<Instant>,
157 pending: Option<Receiver<HashMap<String, String>>>,
159}
160
161impl ContainerTracker {
162 pub fn new() -> Self {
164 Self::default()
165 }
166
167 pub fn annotate(&mut self, roots: &SysRoots, processes: &mut [ProcessInfo], now: Instant) {
169 self.receive_names();
170 let alive: HashSet<u32> = processes.iter().map(|p| p.pid).collect();
171 self.by_pid.retain(|pid, _| alive.contains(pid));
172
173 let mut unknown = false;
174 for process in processes.iter_mut() {
175 let cached = self
176 .by_pid
177 .get(&process.pid)
178 .filter(|(name, _)| *name == process.name);
179 let container = match cached {
180 Some((_, container)) => container.clone(),
181 None => {
182 let path = roots.proc.join(process.pid.to_string()).join("cgroup");
183 let container = fs::read_to_string(path)
184 .ok()
185 .and_then(|text| parse_cgroup(&text));
186 self.by_pid
187 .insert(process.pid, (process.name.clone(), container.clone()));
188 container
189 }
190 };
191 process.container = container.map(|c| match c {
192 ContainerRef::Named(name) => name,
193 ContainerRef::Id(id) => match self.names.get(&id) {
194 Some(name) => name.clone(),
195 None => {
196 unknown |= !self.unnamed.contains(&id);
197 id[..SHORT_ID].to_owned()
198 }
199 },
200 });
201 }
202 if unknown {
203 self.start_lookup(now);
204 }
205 }
206
207 fn receive_names(&mut self) {
209 let Some(rx) = &self.pending else {
210 return;
211 };
212 let names = match rx.try_recv() {
213 Ok(names) => names,
214 Err(mpsc::TryRecvError::Empty) => return,
215 Err(mpsc::TryRecvError::Disconnected) => {
216 self.pending = None;
217 return;
218 }
219 };
220 self.pending = None;
221 let ids = self.by_pid.values().filter_map(|(_, c)| match c {
224 Some(ContainerRef::Id(id)) => Some(id.clone()),
225 _ => None,
226 });
227 self.unnamed = ids.filter(|id| !names.contains_key(id)).collect();
228 self.names = names;
229 }
230
231 fn start_lookup(&mut self, now: Instant) {
234 let recent = self
235 .last_lookup
236 .is_some_and(|at| now.saturating_duration_since(at) < NAME_LOOKUP_INTERVAL);
237 if self.pending.is_some() || recent {
238 return;
239 }
240 self.last_lookup = Some(now);
241 let (tx, rx) = mpsc::channel();
242 self.pending = Some(rx);
243 thread::spawn(move || {
244 let mut names = HashMap::new();
245 for program in ["docker", "podman"] {
246 names.extend(list_containers(program));
247 }
248 let _ = tx.send(names);
249 });
250 }
251}
252
253fn list_containers(program: &str) -> HashMap<String, String> {
257 Command::new(program)
258 .args(["ps", "--no-trunc", "--format", "{{.ID}} {{.Names}}"])
259 .stdin(Stdio::null())
260 .stderr(Stdio::null())
261 .output()
262 .ok()
263 .filter(|out| out.status.success())
264 .map(|out| parse_ps(&String::from_utf8_lossy(&out.stdout)))
265 .unwrap_or_default()
266}
267
268pub(crate) fn parse_ps(text: &str) -> HashMap<String, String> {
270 text.lines()
271 .filter_map(|line| line.trim().split_once(' '))
272 .filter(|(id, _)| is_container_id(id))
273 .map(|(id, name)| (id.to_owned(), name.trim().to_owned()))
274 .collect()
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::linux::testutil::write;
281
282 const ID: &str = "1712446b193ad552c0d920be599af9ba2bf571a05315a64c1be25fd712e1f17f";
283
284 #[test]
285 fn recognizes_container_cgroups() {
286 let id = |s: &str| Some(ContainerRef::Id(s.to_owned()));
287 let cases = [
288 (format!("0::/system.slice/docker-{ID}.scope"), id(ID)),
289 (format!("0::/docker/{ID}"), id(ID)),
290 (
291 format!(
292 "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-{ID}.scope/container"
293 ),
294 id(ID),
295 ),
296 (
297 format!(
298 "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod1.slice/cri-containerd-{ID}.scope"
299 ),
300 id(ID),
301 ),
302 (
304 format!("12:pids:/docker/{ID}\n11:cpu,cpuacct:/docker/{ID}\n0::/"),
305 id(ID),
306 ),
307 (
308 "0::/lxc.payload.web/system.slice/nginx.service".to_owned(),
309 Some(ContainerRef::Named("web".to_owned())),
310 ),
311 (
312 "0::/machine.slice/systemd-nspawn@build.service/payload".to_owned(),
313 Some(ContainerRef::Named("build".to_owned())),
314 ),
315 (
317 r"0::/machine.slice/machine-qemu\x2d3\x2dwin11\x2deval.scope/libvirt/emulator"
318 .to_owned(),
319 Some(ContainerRef::Named("win11-eval".to_owned())),
320 ),
321 (
322 r"0::/machine.slice/machine-qemu\x2d3\x2dwin11\x2deval.scope/libvirt/vcpu0"
323 .to_owned(),
324 Some(ContainerRef::Named("win11-eval".to_owned())),
325 ),
326 (
327 r"0::/machine.slice/machine-lxc\x2d4242\x2dweb.scope".to_owned(),
328 Some(ContainerRef::Named("web".to_owned())),
329 ),
330 (
331 "0::/machine.slice/machine-build.scope/payload".to_owned(),
332 Some(ContainerRef::Named("build".to_owned())),
333 ),
334 (
336 "0::/qemu.slice/100.scope".to_owned(),
337 Some(ContainerRef::Named("vm 100".to_owned())),
338 ),
339 (
340 "0::/lxc/101/ns/system.slice/nginx.service".to_owned(),
341 Some(ContainerRef::Named("ct 101".to_owned())),
342 ),
343 (
344 format!("0::/lxc/102/ns/system.slice/docker-{ID}.scope"),
345 id(ID),
346 ),
347 ];
348 for (text, expected) in cases {
349 assert_eq!(parse_cgroup(&text), expected, "{text}");
350 }
351 for text in [
352 "0::/system.slice/containerd.service".to_owned(),
353 "0::/user.slice/user-1000.slice/session-2.scope".to_owned(),
354 format!(
355 "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-conmon-{ID}.scope"
356 ),
357 "0::/".to_owned(),
358 "0::/machine.slice".to_owned(),
361 "0::/system.slice/libvirtd.service".to_owned(),
362 "0::/lxc.monitor/101".to_owned(),
363 "0::/qemu.slice".to_owned(),
364 ] {
365 assert_eq!(parse_cgroup(&text), None, "{text}");
366 }
367 }
368
369 #[test]
370 fn parses_ps_output() {
371 let text = format!("{ID} web-frontend-1\nshort nope\n\n");
372 let names = parse_ps(&text);
373 assert_eq!(names.len(), 1);
374 assert_eq!(names[ID], "web-frontend-1");
375 }
376
377 #[test]
378 fn annotates_processes_and_remembers_them() {
379 let tmp = tempfile::tempdir().unwrap();
380 write(
381 tmp.path(),
382 "proc/10/cgroup",
383 &format!("0::/system.slice/docker-{ID}.scope\n"),
384 );
385 write(
386 tmp.path(),
387 "proc/11/cgroup",
388 "0::/user.slice/session-2.scope\n",
389 );
390 write(tmp.path(), "proc/12/cgroup", "0::/lxc.payload.web\n");
391 let roots = SysRoots {
392 sys: tmp.path().join("sys"),
393 proc: tmp.path().join("proc"),
394 };
395 let process = |pid, name: &str| ProcessInfo {
396 pid,
397 name: name.to_owned(),
398 ..Default::default()
399 };
400 let mut processes = vec![
401 process(10, "nginx"),
402 process(11, "bash"),
403 process(12, "init"),
404 ];
405 let mut tracker = ContainerTracker::new();
406 tracker.pending = Some(mpsc::channel().1);
408 tracker.last_lookup = Some(Instant::now());
409 tracker.annotate(&roots, &mut processes, Instant::now());
410 let containers: Vec<Option<&str>> =
411 processes.iter().map(|p| p.container.as_deref()).collect();
412 assert_eq!(containers, vec![Some(&ID[..12]), None, Some("web")]);
413
414 let (tx, rx) = mpsc::channel();
416 tx.send(HashMap::from([(ID.to_owned(), "frontend".to_owned())]))
417 .unwrap();
418 tracker.pending = Some(rx);
419 fs::remove_file(tmp.path().join("proc/10/cgroup")).unwrap();
420 tracker.annotate(&roots, &mut processes, Instant::now());
421 assert_eq!(processes[0].container.as_deref(), Some("frontend"));
422
423 let mut reused = vec![process(10, "python3")];
425 tracker.annotate(&roots, &mut reused, Instant::now());
426 assert_eq!(reused[0].container, None);
427 }
428}