saluki_env/workload/helpers/
cgroups.rs

1#![allow(dead_code)]
2
3use std::{
4    collections::HashMap,
5    fs::{self, OpenOptions},
6    io::{self, BufRead as _, BufReader},
7    os::unix::fs::MetadataExt as _,
8    path::{Path, PathBuf},
9    sync::LazyLock,
10};
11
12use regex::Regex;
13use saluki_error::{generic_error, ErrorContext as _, GenericError};
14use stringtheory::{
15    interning::{GenericMapInterner, Interner as _},
16    MetaString,
17};
18use tracing::{debug, error, trace, warn};
19
20use crate::features::{Feature, FeatureDetector};
21
22const DEFAULT_PROCFS_ROOT: &str = "/proc";
23const DEFAULT_CGROUPFS_ROOT: &str = "/sys/fs/cgroup";
24const DEFAULT_LEGACY_CGROUPFS_ROOT: &str = "/cgroup";
25const DEFAULT_HOST_MAPPED_PROCFS_ROOT: &str = "/host/proc";
26const DEFAULT_HOST_MAPPED_CGROUPFS_ROOT: &str = "/host/sys/fs/cgroup";
27const CGROUPS_V1_BASE_CONTROLLER_NAME: &str = "memory";
28const CGROUPS_V2_CONTROLLERS_FILE: &str = "cgroup.controllers";
29const SELF_CGROUP_PATH: &str = "/proc/self/cgroup";
30
31/// Highest inode number that can't refer to a specific cgroup controller.
32///
33/// Inodes 0 and 1 are never valid, and inode 2 is conventionally the root of a filesystem.
34const MAX_RESERVED_INODE: u64 = 2;
35
36/// Linux Control Groups-specific configuration.
37///
38/// Provides environment-specific paths to both "procfs" and "cgroupfs" filesystems, necessary for querying the Linux
39/// Control Groups v2 unified hierarchy.
40pub struct CgroupsConfiguration {
41    procfs_root: PathBuf,
42    cgroupfs_root: PathBuf,
43}
44
45impl CgroupsConfiguration {
46    /// Creates a new `CgroupsConfiguration` from the given filesystem roots.
47    ///
48    /// If a root is given, that path is used. Otherwise, each root falls back to its host-mapped default when its own
49    /// filesystem is detected as host-mapped, and to its local default when it isn't. The cgroupfs root has one more
50    /// fallback: the legacy `/cgroup` root, when that layout is detected.
51    pub fn new(
52        procfs_root: Option<PathBuf>, cgroupfs_root: Option<PathBuf>, feature_detector: &FeatureDetector,
53    ) -> Self {
54        let procfs_root = procfs_root.unwrap_or_else(|| {
55            if feature_detector.is_feature_available(Feature::HostMappedProcfs) {
56                PathBuf::from(DEFAULT_HOST_MAPPED_PROCFS_ROOT)
57            } else {
58                PathBuf::from(DEFAULT_PROCFS_ROOT)
59            }
60        });
61
62        let cgroupfs_root = cgroupfs_root.unwrap_or_else(|| {
63            // Detected separately from procfs: the two are independent mounts, and a deployment can map one
64            // without the other. Keying this off the procfs feature would point us at a cgroupfs path that isn't
65            // there, or make us miss the host's hierarchy in favor of our own container's.
66            if feature_detector.is_feature_available(Feature::HostMappedCgroupfs) {
67                PathBuf::from(DEFAULT_HOST_MAPPED_CGROUPFS_ROOT)
68            } else if feature_detector.is_feature_available(Feature::LegacyCgroupfsRoot) {
69                // Older Amazon Linux hosts put the cgroups v1 hierarchy at `/cgroup`. The Datadog Agent detects that
70                // layout and defaults `container_cgroup_root` to it, but it sends us the result as a default value,
71                // which we can't tell apart from a schema default, so we detect the layout ourselves.
72                PathBuf::from(DEFAULT_LEGACY_CGROUPFS_ROOT)
73            } else {
74                PathBuf::from(DEFAULT_CGROUPFS_ROOT)
75            }
76        });
77
78        Self {
79            procfs_root,
80            cgroupfs_root,
81        }
82    }
83
84    /// Returns the path to the "procfs" filesystem.
85    pub fn procfs_path(&self) -> &Path {
86        self.procfs_root.as_path()
87    }
88
89    /// Returns the path to the "cgroupfs" filesystem.
90    pub fn cgroupfs_path(&self) -> &Path {
91        self.cgroupfs_root.as_path()
92    }
93}
94
95/// Reader for querying control groups being used for containerization.
96///
97/// This reader is capable of querying both cgroups v1 and v2 hierarchies, and can be used to find cgroups -- either
98/// within the entire hierarchy, or for a specific process ID -- that are mapped specifically to containers. A simple
99/// naming heuristic is used to both identify and extract container IDs from cgroup names.
100#[derive(Clone)]
101pub struct CgroupsReader {
102    procfs_path: PathBuf,
103    hierarchy_reader: HierarchyReader,
104    interner: GenericMapInterner,
105}
106
107impl CgroupsReader {
108    /// Creates a new `CgroupsReader` from the given configuration and interner.
109    ///
110    /// If either a valid cgroups v1 or v2 hierarchy is found, `Ok(Some)` is returned with the reader. Otherwise,
111    /// `Ok(None)` is returned.
112    ///
113    /// The provided interner will be used exclusively for handling container IDs.
114    ///
115    /// # Errors
116    ///
117    /// If there is an I/O error while attempting to query the current cgroups hierarchy, an error will be returned.
118    pub fn try_from_config(
119        config: &CgroupsConfiguration, interner: GenericMapInterner,
120    ) -> Result<Option<Self>, GenericError> {
121        let hierarchy_reader = HierarchyReader::try_from_config(config)?;
122        Ok(hierarchy_reader.map(|hierarchy_reader| Self {
123            procfs_path: config.procfs_path().to_path_buf(),
124            hierarchy_reader,
125            interner,
126        }))
127    }
128
129    fn try_cgroup_from_path(&self, cgroup_path: &Path) -> Option<Cgroup> {
130        let container_id = extract_container_id_from_path(cgroup_path, &self.interner)?;
131
132        let metadata = match cgroup_path.metadata() {
133            Ok(metadata) => metadata,
134            Err(e) => {
135                trace!(error = %e, cgroup_controller_path = %cgroup_path.display(), "Failed to get metadata for possible cgroup controller path.");
136                return None;
137            }
138        };
139
140        // A reserved inode can't be attributed to this controller specifically, so we have nothing usable to key an
141        // alias on. Drop the cgroup rather than reporting it with an inode that would resolve the wrong workload -- or
142        // none at all.
143        let controller_inode = metadata.ino();
144        if !is_usable_controller_inode(controller_inode) {
145            debug!(
146                controller_inode,
147                %container_id,
148                cgroup_controller_path = %cgroup_path.display(),
149                "Ignoring cgroup controller with reserved inode.",
150            );
151            return None;
152        }
153
154        trace!(
155            controller_inode,
156            %container_id,
157            cgroup_controller_path = %cgroup_path.display(),
158            "Found valid cgroups controller for container.",
159        );
160
161        Some(Cgroup {
162            ino: Some(controller_inode),
163            container_id,
164        })
165    }
166
167    /// Gets a cgroup for the given process ID.
168    ///
169    /// This method will attempt to find the cgroup for the given process ID by looking at the `/proc/<pid>/cgroup`
170    /// file. If the process ID doesn't exist or isn't attached to a cgroup, `None` will be returned.
171    pub fn get_cgroup_by_pid(&self, pid: u32) -> Option<Cgroup> {
172        // See if the given process ID exists in the proc filesystem _and_ if there's a cgroup path for it.
173        let proc_pid_cgroup_path = self.procfs_path.join(pid.to_string()).join("cgroup");
174        let lines = match read_lines(&proc_pid_cgroup_path) {
175            Ok(lines) => lines,
176            Err(e) => match e.kind() {
177                io::ErrorKind::NotFound => {
178                    debug!(pid, cgroup_lookup_path = %proc_pid_cgroup_path.display(), "Process does not exist or is not attached to a cgroup.");
179                    return None;
180                }
181                _ => {
182                    debug!(error = %e, pid, cgroup_lookup_path = %proc_pid_cgroup_path.display(), "Failed to read cgroup file for process.");
183                    return None;
184                }
185            },
186        };
187
188        let base_controller_name = self.hierarchy_reader.base_controller();
189
190        // We're looking for the first line that matches our base controller name, and then we'll see if it's attached
191        // to the container based on the name, and if so, return it.
192        for entry in lines.iter().filter_map(|s| CgroupControllerEntry::try_from_str(s)) {
193            if entry.name == base_controller_name {
194                // We explicitly try to extract the container ID from the reported cgroup controller path, rather than
195                // trying to stick it on the end of our configured root cgroups path. This is because unless we're in the
196                // host's cgroup namespace, the path we get here will be the leaf directory -- the part with the
197                // container ID in it -- but it will be relative in a way that doesn't allow it to be appended to the
198                // root cgroups path, and so trying to query it to get the controller inode, and all of that, will fail.
199                //
200                // The names in that path are all we need: matching them doesn't touch the filesystem, so it works just
201                // as well for a relative path as an absolute one.
202                if let Some(container_id) = extract_container_id_from_path(entry.path, &self.interner) {
203                    return Some(Cgroup {
204                        ino: None,
205                        container_id,
206                    });
207                }
208            } else {
209                debug!(pid, cgroup_lookup_path = %proc_pid_cgroup_path.display(), base_controller_name, "Found cgroup controller for process, but it doesn't match the base controller.");
210            }
211        }
212
213        debug!(pid, cgroup_lookup_path = %proc_pid_cgroup_path.display(), base_controller_name, "Could not find matching base cgroup controller for process.");
214
215        None
216    }
217
218    /// Gets all child cgroups in the current cgroups hierarchy.
219    ///
220    /// Individual paths that can't be traversed -- most commonly because a container exited and its cgroup was removed
221    /// while we were walking the hierarchy -- are skipped rather than aborting the traversal. If any of those skips
222    /// could have hidden a cgroup that still exists, the returned traversal is marked as incomplete. See
223    /// [`TraversalResult::is_complete`] for why that distinction matters.
224    pub fn get_child_cgroups(&self) -> TraversalResult {
225        // Walk the cgroups hierarchy and collect all cgroups that we can find that are related to containers..
226        let root_path = self.hierarchy_reader.root_path();
227
228        match visit_subdirectories(root_path, |path| self.try_cgroup_from_path(path)) {
229            Ok(traversal) => traversal,
230            Err(e) => {
231                // We only get here if the hierarchy root itself couldn't be read, which generally points at a
232                // misconfigured cgroupfs path rather than a transient condition.
233                warn!(error = %e, cgroups_root = %root_path.display(), "Failed to visit cgroups hierarchy.");
234
235                TraversalResult::unreadable()
236            }
237        }
238    }
239}
240
241/// The result of traversing the cgroups hierarchy.
242///
243/// This accumulates as the traversal runs: [`visit_subdirectories`] creates one, records each cgroup it's handed and
244/// each path it couldn't read, and returns it.
245#[derive(Default)]
246pub struct TraversalResult {
247    cgroups: Vec<Cgroup>,
248    skipped: usize,
249    obscured: usize,
250}
251
252impl TraversalResult {
253    /// Creates a traversal representing a hierarchy that couldn't be read at all.
254    ///
255    /// The result is empty and not [complete][Self::is_complete], since failing to read the root hides everything
256    /// beneath it.
257    fn unreadable() -> Self {
258        Self {
259            cgroups: Vec::new(),
260            skipped: 0,
261            obscured: 1,
262        }
263    }
264
265    /// Records a container cgroup found during the traversal.
266    fn record_cgroup(&mut self, cgroup: Cgroup) {
267        self.cgroups.push(cgroup);
268    }
269
270    /// Records a path that couldn't be read, classifying whether skipping it may have hidden existing cgroups.
271    fn record_skip(&mut self, e: &io::Error, path: &Path) {
272        self.skipped += 1;
273
274        match e.kind() {
275            // The directory is gone, so everything beneath it is gone too. There's nothing left for the skip to hide,
276            // and callers tracking those cgroups are right to consider them removed.
277            //
278            // This is routine rather than exceptional: cgroups are removed as the workloads attached to them exit, and
279            // we have no way to hold the hierarchy still while we walk it.
280            io::ErrorKind::NotFound => {
281                trace!(error = %e, path = %path.display(), "Path disappeared during traversal. Skipping.");
282            }
283
284            // We can't read this subtree, so we've never reported anything from it, so there's nothing a caller could
285            // be tracking for us to hide from them.
286            //
287            // This assumes the permissions aren't changing underneath us: a directory that was readable and becomes
288            // unreadable would be misclassified here. That's rare enough to accept, and the alternative -- treating
289            // every permission error as obscuring -- would permanently mark traversals unreliable whenever some part
290            // of the tree is simply not ours to read.
291            io::ErrorKind::PermissionDenied => {
292                debug!(error = %e, path = %path.display(), "Path is not readable. Skipping.");
293            }
294
295            // The subtree is still there and we just failed to read it this time, so anything beneath it is now
296            // invisible to us despite still existing.
297            _ => {
298                self.obscured += 1;
299                debug!(error = %e, path = %path.display(), "Failed to traverse path. Skipping.");
300            }
301        }
302    }
303
304    /// Returns whether absence from this traversal can be taken to mean a cgroup no longer exists.
305    ///
306    /// When this is `false`, part of the hierarchy that may still hold live cgroups couldn't be read, so the cgroups
307    /// reported are not exhaustive. The entries present are still valid, and callers can safely treat them as live, but
308    /// callers **MUST NOT** infer that a previously known cgroup was removed simply because it's absent here.
309    ///
310    /// Note that this can be `true` even when [`skipped`][Self::skipped] is non-zero: a skipped path that couldn't have
311    /// hidden a live cgroup -- because the path is gone, or because we've never been able to read it -- doesn't make
312    /// the set unreliable.
313    pub fn is_complete(&self) -> bool {
314        self.obscured == 0
315    }
316
317    /// Returns the number of paths skipped due to recoverable errors during the traversal.
318    ///
319    /// This counts every skip, including those that don't affect [`is_complete`][Self::is_complete], and is intended
320    /// for telemetry rather than for deciding how much to trust the result.
321    pub fn skipped(&self) -> usize {
322        self.skipped
323    }
324
325    /// Consumes `self` and returns the container cgroups found during the traversal.
326    pub fn into_cgroups(self) -> Vec<Cgroup> {
327        self.cgroups
328    }
329}
330
331#[derive(Clone)]
332enum HierarchyReader {
333    V1 {
334        base_controller_path: PathBuf,
335        controllers: HashMap<String, PathBuf>,
336    },
337
338    V2 {
339        root: PathBuf,
340        controllers: Vec<String>,
341    },
342}
343
344impl HierarchyReader {
345    fn try_from_config(config: &CgroupsConfiguration) -> Result<Option<Self>, GenericError> {
346        // Open the mount file from procfs to scan through and find any cgroups subsystems.
347        let mounts_path = config.procfs_path().join("mounts");
348        let mount_entries = read_lines(&mounts_path)
349            .with_error_context(|| format!("Failed to read mount entries from procfs ({})", mounts_path.display()))?;
350
351        let mut controllers = HashMap::new();
352        let mut maybe_cgroups_v2 = None;
353
354        // For each mount line, check if its of the `cgroup` or `cgroup2` type. Skip everything else.
355        for mount_entry in mount_entries {
356            // Split the line into fields, and take the second and third values. We always expect at least three fields
357            // in a line if it's a line that might possibly be a cgroup mount.
358            let mut fields = mount_entry.split_whitespace();
359            let maybe_cgroup_path = fields.nth(1);
360            let maybe_fs_type = fields.nth(0);
361
362            if let (Some(raw_cgroup_path), Some(fs_type)) = (maybe_cgroup_path, maybe_fs_type) {
363                let cgroup_path = Path::new(raw_cgroup_path);
364
365                // Make sure this path is rooted within our configured cgroupfs path.
366                //
367                // When we're inside a container that has a host-mapped cgroupfs path, the `mounts` file might end up
368                // having duplicate entries (like one set as `/sys/fs/cgroup` and another set as `/host/sys/fs/cgroup`,
369                // etc)... and we want to use the one that matches our configured cgroupfs path as that's the one that
370                // will actually have the cgroups we care about.
371                if !cgroup_path.starts_with(config.cgroupfs_path()) {
372                    continue;
373                }
374
375                match fs_type {
376                    // For cgroups v1, we have to go through all mounts we see to build a full list of enabled controlled.
377                    "cgroup" => process_cgroupv1_mount_entry(cgroup_path, &mut controllers),
378                    // For cgroups v2, we only need to find the unified root mountpoint, and then we can create our reader.
379                    "cgroup2" => maybe_cgroups_v2 = process_cgroupv2_mount_entry(cgroup_path)?,
380                    _ => {}
381                }
382            }
383        }
384
385        // If we didn't find any cgroups v1 controllers, then we potentially return the cgroups v2 hierarchy if found...
386        // otherwise, this will just return `None`.
387        if controllers.is_empty() {
388            if maybe_cgroups_v2.is_some() {
389                debug!("Using cgroups v2 hierarchy.");
390            }
391
392            return Ok(maybe_cgroups_v2);
393        }
394
395        // If we're here, we potentially have a cgroups v1 hierarchy.  Find our base controller -- the memory controller
396        // -- and once we do that, we can create our reader.
397        let base_controller_path = controllers
398            .get(CGROUPS_V1_BASE_CONTROLLER_NAME)
399            .cloned()
400            .ok_or_else(|| {
401                generic_error!(
402                    "Failed to find base controller ({}) in cgroups v1 hierarchy.",
403                    CGROUPS_V1_BASE_CONTROLLER_NAME
404                )
405            })?;
406
407        debug!(root = %base_controller_path.display(), controllers_len = controllers.len(), "Using cgroups v1 hierarchy.");
408
409        Ok(Some(HierarchyReader::V1 {
410            base_controller_path,
411            controllers,
412        }))
413    }
414
415    fn base_controller(&self) -> Option<&'static str> {
416        match self {
417            Self::V1 { .. } => Some(CGROUPS_V1_BASE_CONTROLLER_NAME),
418
419            // Since cgroups v2 is "unified", there's no base controller path.
420            Self::V2 { .. } => None,
421        }
422    }
423
424    fn root_path(&self) -> &Path {
425        match self {
426            Self::V1 {
427                base_controller_path, ..
428            } => base_controller_path.as_path(),
429            Self::V2 { root, .. } => root.as_path(),
430        }
431    }
432}
433
434/// A container cgroup.
435pub struct Cgroup {
436    ino: Option<u64>,
437    container_id: MetaString,
438}
439
440impl Cgroup {
441    /// Returns the inode of the cgroup controller, if available.
442    pub fn inode(&self) -> Option<u64> {
443        self.ino
444    }
445
446    /// Consumes `self` and returns the container ID.
447    pub fn into_container_id(self) -> MetaString {
448        self.container_id
449    }
450}
451
452struct CgroupControllerEntry<'a> {
453    id: usize,
454    name: Option<&'a str>,
455    path: &'a Path,
456}
457
458impl<'a> CgroupControllerEntry<'a> {
459    fn try_from_str(line: &'a str) -> Option<Self> {
460        let mut fields = line.splitn(3, ':');
461
462        let id = fields.next()?.parse::<usize>().ok()?;
463        let name = fields.next().map(|s| if s.is_empty() { None } else { Some(s) })?;
464        let path = fields.next()?;
465
466        if path.is_empty() {
467            return None;
468        }
469
470        Some(Self {
471            id,
472            name,
473            path: Path::new(path),
474        })
475    }
476}
477
478fn process_cgroupv1_mount_entry(cgroup_path: &Path, controllers: &mut HashMap<String, PathBuf>) {
479    // Split the cgroup path, since there can be multiple controllers mounted at the same path.
480    let path_controllers = cgroup_path
481        .file_name()
482        .and_then(|s| s.to_str().map(|s| s.split(',')))
483        .into_iter()
484        .flatten();
485    for path_controller in path_controllers {
486        // If we have an existing path mapping for this controller, keep whichever one is the
487        // shortest, as we want the more generic path.
488        if let Some(existing_path) = controllers.get(path_controller) {
489            if existing_path.as_os_str().len() < cgroup_path.as_os_str().len() {
490                continue;
491            }
492        }
493
494        controllers.insert(path_controller.to_string(), PathBuf::from(cgroup_path));
495    }
496}
497
498fn process_cgroupv2_mount_entry(cgroup_path: &Path) -> Result<Option<HierarchyReader>, GenericError> {
499    // Read and get the list of active/enabled controllers.
500    let controllers_path = cgroup_path.join(CGROUPS_V2_CONTROLLERS_FILE);
501    let controllers = read_lines(&controllers_path)
502        .with_error_context(|| {
503            format!(
504                "Failed to read controllers from cgroups v2 hierarchy ({}).",
505                controllers_path.display()
506            )
507        })?
508        .into_iter()
509        .flat_map(|s| s.split_whitespace().map(|s| s.to_string()).collect::<Vec<_>>())
510        .collect::<Vec<_>>();
511
512    Ok(Some(HierarchyReader::V2 {
513        root: cgroup_path.to_path_buf(),
514        controllers,
515    }))
516}
517
518fn read_lines(path: &Path) -> io::Result<Vec<String>> {
519    let file = OpenOptions::new().read(true).open(path)?;
520
521    let reader = BufReader::new(file).lines();
522
523    let mut lines = Vec::new();
524    for line in reader {
525        lines.push(line?);
526    }
527
528    Ok(lines)
529}
530
531/// Visits every subdirectory beneath the given path, collecting the cgroups that `visit` identifies.
532///
533/// Subdirectories that can't be read are skipped, along with everything beneath them, and recorded in the returned
534/// [`TraversalResult`]. Callers that need to distinguish "this subdirectory is gone" from "we couldn't see this
535/// subdirectory" **MUST** check [`TraversalResult::is_complete`].
536///
537/// # Errors
538///
539/// If the given path itself can't be queried or listed, an error is returned: nothing was seen, so there's no result
540/// worth reporting. Failures below the given path are never fatal.
541fn visit_subdirectories<P, F>(path: P, mut visit: F) -> Result<TraversalResult, GenericError>
542where
543    P: AsRef<Path>,
544    F: FnMut(&Path) -> Option<Cgroup>,
545{
546    let root = path.as_ref();
547
548    // We can only visit directories, so if the initial path we're given isn't a directory, then we can't do anything.
549    let metadata = fs::metadata(root)
550        .with_error_context(|| format!("Failed to query metadata for traversal root ({}).", root.display()))?;
551    if !metadata.is_dir() {
552        return Ok(TraversalResult::default());
553    }
554
555    let mut traversal = TraversalResult::default();
556
557    // Do an initial pass on our path to get all of its subdirectories, which we'll visit, and then also use as the seed
558    // for further visiting.
559    let mut stack = vec![root.to_path_buf()];
560    while let Some(path) = stack.pop() {
561        // A directory can be removed between the point where we discovered it and the point where we pop it off the
562        // stack to read it, so failing here costs us that subtree but shouldn't stop us from walking the rest.
563        let dir_reader = match fs::read_dir(&path) {
564            Ok(dir_reader) => dir_reader,
565            Err(e) => {
566                // Failing on the root is fatal, unlike failing anywhere below it. Every other path costs us one
567                // subtree, but if we can't list the root then we haven't seen anything at all -- and an empty result
568                // that claims to be complete tells callers every cgroup they know about has gone away.
569                //
570                // Note that this is reachable even though we successfully stat'd the root above: listing a directory
571                // needs read permission, while stat'ing it only needs to traverse its parent.
572                if path.as_path() == root {
573                    return Err(e)
574                        .with_error_context(|| format!("Failed to read traversal root ({}).", root.display()));
575                }
576
577                traversal.record_skip(&e, &path);
578                continue;
579            }
580        };
581
582        for entry in dir_reader {
583            let entry = match entry {
584                Ok(entry) => entry,
585                Err(e) => {
586                    traversal.record_skip(&e, &path);
587                    continue;
588                }
589            };
590
591            let entry_path = entry.path();
592            let file_type = match entry.file_type() {
593                Ok(file_type) => file_type,
594                Err(e) => {
595                    traversal.record_skip(&e, &entry_path);
596                    continue;
597                }
598            };
599
600            if file_type.is_dir() {
601                if let Some(cgroup) = visit(&entry_path) {
602                    traversal.record_cgroup(cgroup);
603                }
604
605                stack.push(entry_path);
606            }
607        }
608    }
609
610    Ok(traversal)
611}
612
613/// Gets the current process's container ID from its local cgroup membership.
614///
615/// This intentionally reads the process namespace's `/proc/self/cgroup` instead of a configured procfs root, which may
616/// refer to the host namespace.
617pub(crate) fn get_self_container_id(interner: &GenericMapInterner) -> Option<MetaString> {
618    let lines = read_lines(Path::new(SELF_CGROUP_PATH)).ok()?;
619    get_container_id_from_cgroup_lines(&lines, interner)
620}
621
622fn get_container_id_from_cgroup_lines(lines: &[String], interner: &GenericMapInterner) -> Option<MetaString> {
623    lines
624        .iter()
625        .filter_map(|line| CgroupControllerEntry::try_from_str(line))
626        .filter_map(|entry| entry.path.file_name().and_then(|name| name.to_str()))
627        .find_map(|cgroup_name| extract_container_id(cgroup_name, interner))
628}
629
630/// Returns `true` if the given inode can identify a specific cgroup controller.
631///
632/// Reserved inodes -- see [`MAX_RESERVED_INODE`] -- are reported by some filesystems for paths that aren't a distinct
633/// object, so they can't be used to tell one controller apart from another.
634fn is_usable_controller_inode(inode: u64) -> bool {
635    inode > MAX_RESERVED_INODE
636}
637
638/// Matches a container ID anywhere within a cgroup name.
639///
640/// This regular expression is meant to capture:
641/// - 64 character hexadecimal strings (standard format for container IDs almost everywhere)
642/// - 32 character hexadecimal strings followed by a dash and a number (used by AWS ECS)
643/// - 8 character hexadecimal strings followed by up to four groups of 4 character hexadecimal strings separated by
644///   dashes (essentially a UUID, used by Pivotal Cloud Foundry's Garden technology)
645static CONTAINER_REGEX: LazyLock<Regex> =
646    LazyLock::new(|| Regex::new("([0-9a-f]{64})|([0-9a-f]{32}-\\d+)|([0-9a-f]{8}(-[0-9a-f]{4}){4}$)").unwrap());
647
648fn extract_container_id(cgroup_name: &str, interner: &GenericMapInterner) -> Option<MetaString> {
649    match match_container_id(cgroup_name, interner) {
650        ContainerIdMatch::Container(container_id) => Some(container_id),
651        ContainerIdMatch::Excluded | ContainerIdMatch::Uninternable | ContainerIdMatch::NoMatch => None,
652    }
653}
654
655/// What a single cgroup name turned out to be.
656enum ContainerIdMatch {
657    /// The cgroup belongs to a container, with the given ID.
658    Container(MetaString),
659
660    /// The cgroup is named after a container but doesn't represent one.
661    Excluded,
662
663    /// The cgroup belongs to a container, but interning its ID failed.
664    ///
665    /// We know which container this is and simply can't name it, which is different from not knowing: a caller walking
666    /// a path **MUST NOT** keep searching outwards, since the answer it found would be a different container.
667    Uninternable,
668
669    /// The cgroup isn't named after a container at all.
670    NoMatch,
671}
672
673/// Matches a single cgroup name against the container ID heuristic.
674///
675/// [`ContainerIdMatch::Excluded`] is reported separately from [`ContainerIdMatch::NoMatch`] because the two mean
676/// different things to a caller walking a path: a name that isn't a container tells you nothing about its ancestors,
677/// but a name that is deliberately excluded is a definitive answer for that cgroup.
678fn match_container_id(cgroup_name: &str, interner: &GenericMapInterner) -> ContainerIdMatch {
679    let container_id = match CONTAINER_REGEX.find(cgroup_name) {
680        Some(container_id) => container_id,
681        None => return ContainerIdMatch::NoMatch,
682    };
683
684    // Note that this is checked against the full cgroup name, not against the ID we just matched out of it: the match
685    // is a bare hexadecimal string, which can never carry any of these prefixes or suffixes.
686    if is_container_named_but_not_a_container(cgroup_name) {
687        return ContainerIdMatch::Excluded;
688    }
689
690    match interner.try_intern(container_id.as_str()) {
691        Some(interned) => ContainerIdMatch::Container(MetaString::from(interned)),
692        None => {
693            error!(container_id = %container_id.as_str(), "Failed to intern container ID.");
694            ContainerIdMatch::Uninternable
695        }
696    }
697}
698
699/// Resolves the container ID for a cgroup path, falling back to the path's ancestors.
700///
701/// A container's workload can sit in a cgroup nested below the one named for the container, in which case the leaf
702/// doesn't carry the ID but one of its ancestors does. The deepest ancestor that names a container wins, so the most
703/// specific enclosing container is the one reported.
704///
705/// The search stops early, returning `None`, in two cases where continuing outwards would answer with some *other*
706/// container:
707///
708/// - The leaf is named after a container but isn't one. Such a cgroup isn't part of the container's workload at all.
709/// - A container is identified but its ID can't be interned. We know which container it is and just can't name it,
710///   which is not the same as not knowing.
711fn extract_container_id_from_path(cgroup_path: &Path, interner: &GenericMapInterner) -> Option<MetaString> {
712    let leaf_name = cgroup_path.file_name().and_then(|s| s.to_str())?;
713
714    match match_container_id(leaf_name, interner) {
715        ContainerIdMatch::Container(container_id) => return Some(container_id),
716        ContainerIdMatch::Excluded | ContainerIdMatch::Uninternable => return None,
717        ContainerIdMatch::NoMatch => {}
718    }
719
720    // `ancestors` yields the path itself first, which we've already checked, so skip it.
721    for ancestor in cgroup_path.ancestors().skip(1) {
722        let ancestor_name = match ancestor.file_name().and_then(|s| s.to_str()) {
723            Some(ancestor_name) => ancestor_name,
724            None => continue,
725        };
726
727        match match_container_id(ancestor_name, interner) {
728            ContainerIdMatch::Container(container_id) => return Some(container_id),
729
730            // An excluded ancestor is a statement about that cgroup, not about the one we're resolving, so the
731            // container enclosing it can still be the right answer.
732            ContainerIdMatch::Excluded | ContainerIdMatch::NoMatch => {}
733
734            // Reporting the next container out would attribute this cgroup to the wrong one, so stop here.
735            ContainerIdMatch::Uninternable => return None,
736        }
737    }
738
739    None
740}
741
742/// Returns `true` if a cgroup is named after a container but doesn't represent that container's workload.
743fn is_container_named_but_not_a_container(cgroup_name: &str) -> bool {
744    // With the systemd cgroup driver, a `.mount` cgroup can sit alongside a container's own cgroup. It exists, but no
745    // process is ever attached to it, so it holds no stats.
746    //
747    // The `conmon` cgroups belong to the CRI-O/Podman monitor process supervising a container, rather than to the
748    // container itself.
749    cgroup_name.ends_with(".mount")
750        || cgroup_name.starts_with("crio-conmon-")
751        || cgroup_name.starts_with("libpod-conmon-")
752}
753
754#[cfg(test)]
755mod tests {
756    use std::{
757        collections::HashSet,
758        fs, io,
759        num::NonZeroUsize,
760        os::unix::fs::PermissionsExt as _,
761        path::{Path, PathBuf},
762    };
763
764    use stringtheory::{
765        interning::{GenericMapInterner, InternedString, Interner as _},
766        MetaString,
767    };
768    use tempfile::tempdir;
769
770    use super::{
771        extract_container_id, extract_container_id_from_path, get_container_id_from_cgroup_lines,
772        is_usable_controller_inode, visit_subdirectories, CgroupControllerEntry, CgroupsConfiguration, CgroupsReader,
773        Feature, FeatureDetector, HierarchyReader, TraversalResult, DEFAULT_CGROUPFS_ROOT,
774        DEFAULT_HOST_MAPPED_CGROUPFS_ROOT, DEFAULT_HOST_MAPPED_PROCFS_ROOT, DEFAULT_LEGACY_CGROUPFS_ROOT,
775        DEFAULT_PROCFS_ROOT,
776    };
777
778    #[test]
779    fn parse_controller_entry_cgroups_v1() {
780        let controller_id = 12;
781        let controller_name = "memory";
782        let controller_path_raw = "/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod095a9475_4c4f_4726_912c_65743701ef3f.slice/cri-containerd-06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe.scope";
783        let controller_path = Path::new(controller_path_raw);
784        let raw = format!("{}:{}:{}", controller_id, controller_name, controller_path_raw);
785
786        let entry = CgroupControllerEntry::try_from_str(&raw).unwrap();
787        assert_eq!(entry.id, controller_id);
788        assert_eq!(entry.name, Some(controller_name));
789        assert_eq!(entry.path, controller_path);
790    }
791
792    #[test]
793    fn parse_controller_entry_cgroups_v2() {
794        let controller_id = 0;
795        let controller_path_raw =
796            "/system.slice/docker-0b96e72f48e169638a735c0a05adcfc9d6aba2bf6697b627f1635b4f00ea011d.scope";
797        let controller_path = Path::new(controller_path_raw);
798        let raw = format!("{}::{}", controller_id, controller_path_raw);
799
800        let entry = CgroupControllerEntry::try_from_str(&raw).unwrap();
801        assert_eq!(entry.id, controller_id);
802        assert_eq!(entry.name, None);
803        assert_eq!(entry.path, controller_path);
804    }
805
806    fn extract(raw: &str) -> Option<MetaString> {
807        let interner = GenericMapInterner::new(NonZeroUsize::new(1024).unwrap());
808        extract_container_id(raw, &interner)
809    }
810
811    fn extract_from_path(raw: &str) -> Option<MetaString> {
812        let interner = GenericMapInterner::new(NonZeroUsize::new(1024).unwrap());
813        extract_container_id_from_path(Path::new(raw), &interner)
814    }
815
816    #[test]
817    fn resolves_container_id_from_current_process_cgroup_format() {
818        let container_id = "06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe";
819        let cgroup_lines = vec![format!("0::/system.slice/cri-containerd-{container_id}.scope")];
820        let interner = GenericMapInterner::new(NonZeroUsize::new(1024).unwrap());
821
822        assert_eq!(
823            get_container_id_from_cgroup_lines(&cgroup_lines, &interner),
824            Some(MetaString::from(container_id))
825        );
826    }
827
828    #[test]
829    fn does_not_resolve_self_container_from_non_container_cgroup_fixture() {
830        let cgroup_lines = include_str!("testdata/non-container-proc-self-cgroup")
831            .lines()
832            .map(str::to_owned)
833            .collect::<Vec<_>>();
834        let interner = GenericMapInterner::new(NonZeroUsize::new(1024).unwrap());
835
836        assert_eq!(get_container_id_from_cgroup_lines(&cgroup_lines, &interner), None);
837    }
838
839    #[test]
840    fn extract_container_id_cri_containerd() {
841        let expected_container_id =
842            MetaString::from("06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe");
843        let raw = format!("cri-containerd-{}.scope", expected_container_id);
844
845        assert_eq!(extract(&raw), Some(expected_container_id));
846    }
847
848    // The exclusions below have to be checked against the full cgroup name. Checking them against the matched
849    // container ID -- a bare hexadecimal string -- can never fire, which is precisely the bug these tests guard.
850
851    #[test]
852    fn extract_container_id_excludes_dot_mount_cgroups() {
853        let container_id = "06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe";
854        let raw = format!("{}.mount", container_id);
855
856        assert_eq!(extract(&raw), None);
857    }
858
859    #[test]
860    fn extract_container_id_excludes_crio_conmon_cgroups() {
861        let container_id = "06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe";
862        let raw = format!("crio-conmon-{}.scope", container_id);
863
864        assert_eq!(extract(&raw), None);
865    }
866
867    #[test]
868    fn extract_container_id_excludes_libpod_conmon_cgroups() {
869        let container_id = "06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe";
870        let raw = format!("libpod-conmon-{}.scope", container_id);
871
872        assert_eq!(extract(&raw), None);
873    }
874
875    #[test]
876    fn extract_container_id_includes_libpod_container_cgroups() {
877        // Only the `conmon` monitor cgroup is excluded -- the container's own Podman cgroup shares the `libpod-`
878        // prefix and must still resolve.
879        let container_id = "06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe";
880        let raw = format!("libpod-{}.scope", container_id);
881
882        assert_eq!(extract(&raw), Some(MetaString::from(container_id)));
883    }
884
885    #[test]
886    fn reserved_inodes_are_not_usable_controller_inodes() {
887        // 0 and 1 are never valid inodes, and 2 is conventionally the root of a filesystem.
888        assert!(!is_usable_controller_inode(0));
889        assert!(!is_usable_controller_inode(1));
890        assert!(!is_usable_controller_inode(2));
891    }
892
893    #[test]
894    fn ordinary_inodes_are_usable_controller_inodes() {
895        assert!(is_usable_controller_inode(3));
896        assert!(is_usable_controller_inode(4_026_531_835));
897        assert!(is_usable_controller_inode(u64::MAX));
898    }
899
900    const CONTAINER_ID_A: &str = "06d914d2013e51a777feead523895935e33d8ad725b3251ac74c491b3d55d8fe";
901    const CONTAINER_ID_B: &str = "1a2b3c4d5e6f70819293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9";
902
903    #[test]
904    fn extract_from_path_prefers_the_leaf() {
905        let path = format!(
906            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/cri-containerd-{}.scope",
907            CONTAINER_ID_A, CONTAINER_ID_B
908        );
909
910        // The deepest container wins, so a nested container isn't attributed to the one enclosing it.
911        assert_eq!(extract_from_path(&path), Some(MetaString::from(CONTAINER_ID_B)));
912    }
913
914    #[test]
915    fn extract_from_path_falls_back_to_ancestors() {
916        // A container's workload can live in a cgroup nested below the one named for the container.
917        let path = format!(
918            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/init",
919            CONTAINER_ID_A
920        );
921
922        assert_eq!(extract_from_path(&path), Some(MetaString::from(CONTAINER_ID_A)));
923    }
924
925    #[test]
926    fn extract_from_path_uses_the_deepest_matching_ancestor() {
927        let path = format!(
928            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/cri-containerd-{}.scope/init",
929            CONTAINER_ID_A, CONTAINER_ID_B
930        );
931
932        assert_eq!(extract_from_path(&path), Some(MetaString::from(CONTAINER_ID_B)));
933    }
934
935    #[test]
936    fn extract_from_path_returns_none_without_any_container_segment() {
937        let path = "/sys/fs/cgroup/system.slice/systemd-journald.service";
938
939        assert_eq!(extract_from_path(path), None);
940    }
941
942    #[test]
943    fn extract_from_path_does_not_rescue_excluded_leaves_from_ancestors() {
944        // A `.mount` or `conmon` cgroup isn't part of the container's workload, so even though an ancestor names a
945        // container, attributing it there would be wrong.
946        let mount_path = format!(
947            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/{}.mount",
948            CONTAINER_ID_A, CONTAINER_ID_B
949        );
950        let conmon_path = format!(
951            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/crio-conmon-{}.scope",
952            CONTAINER_ID_A, CONTAINER_ID_B
953        );
954
955        assert_eq!(extract_from_path(&mount_path), None);
956        assert_eq!(extract_from_path(&conmon_path), None);
957    }
958
959    #[test]
960    fn extract_from_path_skips_excluded_ancestors() {
961        // An excluded ancestor doesn't claim the cgroup either; the search continues past it.
962        let path = format!(
963            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/crio-conmon-{}.scope/init",
964            CONTAINER_ID_A, CONTAINER_ID_B
965        );
966
967        assert_eq!(extract_from_path(&path), Some(MetaString::from(CONTAINER_ID_A)));
968    }
969
970    /// Builds an interner that can still resolve `held_id` but has no room left for `blocked_id`.
971    ///
972    /// The returned handles have to be kept alive for as long as the interner is used: an entry is reclaimed as soon
973    /// as its last handle drops, so dropping them would un-fill the interner.
974    fn interner_holding_but_blocking(held_id: &str, blocked_id: &str) -> (GenericMapInterner, Vec<InternedString>) {
975        let interner = GenericMapInterner::new(NonZeroUsize::new(1024).unwrap());
976        let mut held = vec![interner.try_intern(held_id).expect("interner starts out empty")];
977
978        // The interner is sharded, so "full" is per-shard: we have to keep adding distinct strings until the shard
979        // that `blocked_id` hashes to is the one that fills up. Probing with `blocked_id` itself is harmless, since
980        // dropping the handle immediately gives the entry back.
981        for filler in 0.. {
982            if interner.try_intern(blocked_id).is_none() {
983                break;
984            }
985
986            assert!(filler < 100_000, "interner never filled up");
987
988            if let Some(interned) = interner.try_intern(&format!("filler-{:060}", filler)) {
989                held.push(interned);
990            }
991        }
992
993        // `held_id` has to still be resolvable, otherwise the tests below would pass for the wrong reason: they need
994        // the ancestor fallback to be *capable* of succeeding, so that declining to take it means something.
995        assert!(interner.try_intern(held_id).is_some());
996
997        (interner, held)
998    }
999
1000    #[test]
1001    fn extract_from_path_does_not_fall_back_when_the_leaf_id_cannot_be_interned() {
1002        let (interner, _held) = interner_holding_but_blocking(CONTAINER_ID_A, CONTAINER_ID_B);
1003
1004        // The leaf names a container we can identify but can't name. Walking out to the enclosing container would
1005        // attribute the inner container's workload to the outer one, which is worse than reporting nothing.
1006        let path = format!(
1007            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/cri-containerd-{}.scope",
1008            CONTAINER_ID_A, CONTAINER_ID_B
1009        );
1010
1011        assert_eq!(extract_container_id_from_path(Path::new(&path), &interner), None);
1012    }
1013
1014    #[test]
1015    fn extract_from_path_stops_at_an_ancestor_whose_id_cannot_be_interned() {
1016        let (interner, _held) = interner_holding_but_blocking(CONTAINER_ID_A, CONTAINER_ID_B);
1017
1018        // Same reasoning one level up: the nearest enclosing container is the right answer, so failing to name it
1019        // means we have no answer, not that we should keep searching outwards.
1020        let path = format!(
1021            "/sys/fs/cgroup/system.slice/cri-containerd-{}.scope/cri-containerd-{}.scope/init",
1022            CONTAINER_ID_A, CONTAINER_ID_B
1023        );
1024
1025        assert_eq!(extract_container_id_from_path(Path::new(&path), &interner), None);
1026    }
1027
1028    #[test]
1029    fn extract_from_path_handles_relative_paths() {
1030        // `/proc/<pid>/cgroup` reports a path that's relative to the cgroup namespace root, so resolution can't depend
1031        // on the path being absolute or on it existing on disk.
1032        let path = format!("kubepods.slice/cri-containerd-{}.scope/init", CONTAINER_ID_A);
1033
1034        assert_eq!(extract_from_path(&path), Some(MetaString::from(CONTAINER_ID_A)));
1035    }
1036
1037    /// Collects the names of every visited path, relative to `root`.
1038    fn visited_names(root: &Path, visited: &[PathBuf]) -> HashSet<String> {
1039        visited
1040            .iter()
1041            .map(|path| path.strip_prefix(root).unwrap().to_string_lossy().into_owned())
1042            .collect()
1043    }
1044
1045    fn names(names: &[&str]) -> HashSet<String> {
1046        names.iter().map(|name| (*name).to_owned()).collect()
1047    }
1048
1049    /// Makes `path` unreadable, returning `false` if the caller can still read it anyway.
1050    ///
1051    /// Tests running as root can read a directory regardless of its mode, and so have nothing to assert.
1052    fn make_unreadable(path: &Path) -> bool {
1053        fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
1054
1055        if fs::read_dir(path).is_ok() {
1056            make_readable(path);
1057            return false;
1058        }
1059
1060        true
1061    }
1062
1063    /// Restores `path` to a readable mode, so that its parent temporary directory can be cleaned up.
1064    fn make_readable(path: &Path) {
1065        fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
1066    }
1067
1068    fn reader_rooted_at(root: &Path) -> CgroupsReader {
1069        CgroupsReader {
1070            procfs_path: PathBuf::from(DEFAULT_PROCFS_ROOT),
1071            hierarchy_reader: HierarchyReader::V2 {
1072                root: root.to_path_buf(),
1073                controllers: Vec::new(),
1074            },
1075            interner: GenericMapInterner::new(NonZeroUsize::new(1024).unwrap()),
1076        }
1077    }
1078
1079    #[test]
1080    fn visit_subdirectories_visits_every_subdirectory() {
1081        let root = tempdir().unwrap();
1082        fs::create_dir_all(root.path().join("a/aa")).unwrap();
1083        fs::create_dir(root.path().join("b")).unwrap();
1084        fs::write(root.path().join("b/file"), "not a directory").unwrap();
1085
1086        let mut visited = Vec::new();
1087        let traversal = visit_subdirectories(root.path(), |path| {
1088            visited.push(path.to_path_buf());
1089            None
1090        })
1091        .unwrap();
1092
1093        assert_eq!(traversal.skipped, 0);
1094        assert_eq!(traversal.obscured, 0);
1095        assert_eq!(visited_names(root.path(), &visited), names(&["a", "a/aa", "b"]));
1096    }
1097
1098    #[test]
1099    fn record_skip_classifies_by_error_kind() {
1100        let mut traversal = TraversalResult::default();
1101
1102        // A path that's gone takes its subdirectories with it, and a path we can't read never showed us any, so
1103        // neither can be hiding anything from us.
1104        traversal.record_skip(&io::Error::from(io::ErrorKind::NotFound), Path::new("/gone"));
1105        traversal.record_skip(&io::Error::from(io::ErrorKind::PermissionDenied), Path::new("/denied"));
1106
1107        assert_eq!(traversal.skipped, 2);
1108        assert_eq!(traversal.obscured, 0);
1109        assert!(traversal.is_complete());
1110
1111        // Any other failure leaves a subtree that still exists but that we couldn't see into.
1112        traversal.record_skip(&io::Error::from(io::ErrorKind::Other), Path::new("/unreadable"));
1113
1114        assert_eq!(traversal.skipped, 3);
1115        assert_eq!(traversal.obscured, 1);
1116        assert!(!traversal.is_complete());
1117    }
1118
1119    #[test]
1120    fn visit_subdirectories_errors_when_root_is_missing() {
1121        let root = tempdir().unwrap();
1122
1123        assert!(visit_subdirectories(root.path().join("missing"), |_| None).is_err());
1124    }
1125
1126    #[test]
1127    fn visit_subdirectories_errors_when_root_is_unreadable() {
1128        // Nest the traversal root inside the temporary directory so its mode can be restored for cleanup.
1129        let parent = tempdir().unwrap();
1130        let root = parent.path().join("root");
1131        fs::create_dir_all(root.join("child")).unwrap();
1132
1133        if !make_unreadable(&root) {
1134            return;
1135        }
1136
1137        // Stat'ing the root still succeeds -- that only needs to traverse its parent -- so this exercises the
1138        // `read_dir` failure specifically, which is the path that used to be recorded as an ordinary skip.
1139        assert!(fs::metadata(&root).is_ok());
1140
1141        let result = visit_subdirectories(&root, |_| None);
1142
1143        make_readable(&root);
1144
1145        // An unreadable root has to be an error rather than an empty-but-complete traversal: we saw nothing, so we
1146        // can't let a caller conclude that everything it knew about has gone away.
1147        assert!(result.is_err());
1148    }
1149
1150    #[test]
1151    fn visit_subdirectories_ignores_non_directory_root() {
1152        let root = tempdir().unwrap();
1153        let file_path = root.path().join("file");
1154        fs::write(&file_path, "not a directory").unwrap();
1155
1156        let mut visited = Vec::new();
1157        let traversal = visit_subdirectories(&file_path, |path| {
1158            visited.push(path.to_path_buf());
1159            None
1160        })
1161        .unwrap();
1162
1163        assert_eq!(traversal.skipped, 0);
1164        assert!(visited.is_empty());
1165    }
1166
1167    #[test]
1168    fn visit_subdirectories_skips_directories_removed_mid_traversal() {
1169        let root = tempdir().unwrap();
1170        for name in ["a", "b", "c"] {
1171            fs::create_dir(root.path().join(name)).unwrap();
1172        }
1173
1174        // Every subdirectory of `root` is visited and pushed onto the traversal stack before any of them is read back,
1175        // so removing one that was already visited guarantees that reading it later fails with `ENOENT`. That's the
1176        // same race we lose in production when a container exits mid-traversal, but without depending on any timing.
1177        let mut visited = Vec::new();
1178        let traversal = visit_subdirectories(root.path(), |path| {
1179            visited.push(path.to_path_buf());
1180            if visited.len() == 2 {
1181                fs::remove_dir(&visited[0]).unwrap();
1182            }
1183            None
1184        })
1185        .unwrap();
1186
1187        // The removed directory was still visited -- we saw it before it went away -- but reading it was skipped
1188        // rather than aborting the traversal, so its two siblings were still read.
1189        assert_eq!(traversal.skipped, 1);
1190
1191        // A directory that's gone can't be hiding anything, so the traversal is still trustworthy.
1192        assert_eq!(traversal.obscured, 0);
1193        assert!(traversal.is_complete());
1194        assert_eq!(visited_names(root.path(), &visited), names(&["a", "b", "c"]));
1195    }
1196
1197    #[test]
1198    fn visit_subdirectories_reports_obscured_paths_for_unexpected_errors() {
1199        let root = tempdir().unwrap();
1200        for name in ["a", "b"] {
1201            fs::create_dir(root.path().join(name)).unwrap();
1202        }
1203
1204        // Same trick as the removal test, but the already-visited directory is replaced with a regular file instead of
1205        // being deleted, so reading it back fails with `ENOTDIR` rather than `ENOENT`.
1206        let mut visited = Vec::new();
1207        let traversal = visit_subdirectories(root.path(), |path| {
1208            visited.push(path.to_path_buf());
1209            if visited.len() == 2 {
1210                fs::remove_dir(&visited[0]).unwrap();
1211                fs::write(&visited[0], "no longer a directory").unwrap();
1212            }
1213            None
1214        })
1215        .unwrap();
1216
1217        // Unlike a removal, this is a path we can't account for, so it counts against the traversal's reliability.
1218        assert_eq!(traversal.skipped, 1);
1219        assert_eq!(traversal.obscured, 1);
1220        assert!(!traversal.is_complete());
1221    }
1222
1223    #[test]
1224    fn visit_subdirectories_skips_unreadable_directories() {
1225        let root = tempdir().unwrap();
1226        let unreadable = root.path().join("unreadable");
1227        fs::create_dir(&unreadable).unwrap();
1228        fs::create_dir_all(root.path().join("readable/nested")).unwrap();
1229
1230        if !make_unreadable(&unreadable) {
1231            return;
1232        }
1233
1234        let mut visited = Vec::new();
1235        let traversal = visit_subdirectories(root.path(), |path| {
1236            visited.push(path.to_path_buf());
1237            None
1238        });
1239
1240        make_readable(&unreadable);
1241
1242        let traversal = traversal.unwrap();
1243        assert_eq!(traversal.skipped, 1);
1244
1245        // We've never been able to see into this subtree, so skipping it doesn't hide anything we'd previously
1246        // reported. Counting it as obscuring would permanently taint every traversal on a host where part of the tree
1247        // simply isn't ours to read.
1248        assert_eq!(traversal.obscured, 0);
1249        assert!(traversal.is_complete());
1250
1251        // The unreadable directory itself is still visited -- we only fail on its contents.
1252        assert_eq!(
1253            visited_names(root.path(), &visited),
1254            names(&["readable", "readable/nested", "unreadable"])
1255        );
1256    }
1257
1258    #[test]
1259    fn get_child_cgroups_reports_complete_traversal() {
1260        let root = tempdir().unwrap();
1261        fs::create_dir(root.path().join(format!("cri-containerd-{}.scope", CONTAINER_ID_A))).unwrap();
1262
1263        let traversal = reader_rooted_at(root.path()).get_child_cgroups();
1264
1265        assert!(traversal.is_complete());
1266        assert_eq!(traversal.skipped, 0);
1267        assert_eq!(traversal.cgroups.len(), 1);
1268        assert_eq!(traversal.cgroups[0].container_id, MetaString::from(CONTAINER_ID_A));
1269    }
1270
1271    #[test]
1272    fn get_child_cgroups_stays_complete_when_subdirectory_is_unreadable() {
1273        let root = tempdir().unwrap();
1274        fs::create_dir(root.path().join(format!("cri-containerd-{}.scope", CONTAINER_ID_A))).unwrap();
1275
1276        let unreadable = root.path().join("unreadable");
1277        fs::create_dir(&unreadable).unwrap();
1278        if !make_unreadable(&unreadable) {
1279            return;
1280        }
1281
1282        let traversal = reader_rooted_at(root.path()).get_child_cgroups();
1283
1284        make_readable(&unreadable);
1285
1286        // The skip is reported for telemetry, but it can't have hidden a live cgroup, so callers can still act on
1287        // what's absent. Marking this incomplete would stop the collector from ever reaping cgroups on a host where
1288        // some part of the hierarchy is permanently unreadable.
1289        assert!(traversal.is_complete());
1290        assert_eq!(traversal.skipped, 1);
1291        assert_eq!(traversal.cgroups.len(), 1);
1292        assert_eq!(traversal.cgroups[0].container_id, MetaString::from(CONTAINER_ID_A));
1293    }
1294
1295    #[test]
1296    fn get_child_cgroups_reports_incomplete_traversal_when_root_is_missing() {
1297        let root = tempdir().unwrap();
1298
1299        let traversal = reader_rooted_at(&root.path().join("missing")).get_child_cgroups();
1300
1301        assert!(!traversal.is_complete());
1302        assert_eq!(traversal.skipped, 0);
1303        assert!(traversal.cgroups.is_empty());
1304    }
1305
1306    #[test]
1307    fn get_child_cgroups_reports_incomplete_traversal_when_root_is_unreadable() {
1308        let parent = tempdir().unwrap();
1309        let root = parent.path().join("root");
1310        fs::create_dir(&root).unwrap();
1311        fs::create_dir(root.join(format!("cri-containerd-{}.scope", CONTAINER_ID_A))).unwrap();
1312
1313        if !make_unreadable(&root) {
1314            return;
1315        }
1316
1317        let traversal = reader_rooted_at(&root).get_child_cgroups();
1318
1319        make_readable(&root);
1320
1321        // The container cgroup underneath is real but invisible to us, so reporting this as complete would have the
1322        // collector reap every alias it holds.
1323        assert!(!traversal.is_complete());
1324        assert!(traversal.cgroups.is_empty());
1325    }
1326
1327    fn cgroups_config_with(detected: Feature) -> CgroupsConfiguration {
1328        CgroupsConfiguration::new(None, None, &FeatureDetector::from_detected_features(detected))
1329    }
1330
1331    #[test]
1332    fn cgroupfs_root_defaults_to_local_when_nothing_is_host_mapped() {
1333        let config = cgroups_config_with(Feature::none());
1334
1335        assert_eq!(config.procfs_path(), Path::new(DEFAULT_PROCFS_ROOT));
1336        assert_eq!(config.cgroupfs_path(), Path::new(DEFAULT_CGROUPFS_ROOT));
1337    }
1338
1339    #[test]
1340    fn cgroupfs_root_follows_host_mapped_cgroupfs() {
1341        let config = cgroups_config_with(Feature::HostMappedCgroupfs);
1342
1343        assert_eq!(config.cgroupfs_path(), Path::new(DEFAULT_HOST_MAPPED_CGROUPFS_ROOT));
1344    }
1345
1346    #[test]
1347    fn cgroupfs_root_ignores_host_mapped_procfs() {
1348        // procfs and cgroupfs are independent mounts. A deployment that maps one without the other used to get the
1349        // host cgroupfs path off the back of the procfs mount, pointing the reader at a path that isn't there.
1350        let config = cgroups_config_with(Feature::HostMappedProcfs);
1351
1352        assert_eq!(config.procfs_path(), Path::new(DEFAULT_HOST_MAPPED_PROCFS_ROOT));
1353        assert_eq!(config.cgroupfs_path(), Path::new(DEFAULT_CGROUPFS_ROOT));
1354    }
1355
1356    #[test]
1357    fn cgroupfs_root_follows_legacy_root() {
1358        let config = cgroups_config_with(Feature::LegacyCgroupfsRoot);
1359
1360        assert_eq!(config.cgroupfs_path(), Path::new(DEFAULT_LEGACY_CGROUPFS_ROOT));
1361        assert_eq!(config.procfs_path(), Path::new(DEFAULT_PROCFS_ROOT));
1362    }
1363
1364    #[test]
1365    fn host_mapped_cgroupfs_takes_precedence_over_legacy_root() {
1366        // The legacy root is a host layout, so a container that has the host cgroupfs mapped in reads the host
1367        // hierarchy through that mount rather than through a `/cgroup` path in its own filesystem. Feature detection
1368        // never reports both at once, since it only looks for the legacy root when it isn't containerized, but the
1369        // ordering here is what makes that safe.
1370        let config = cgroups_config_with(Feature::HostMappedCgroupfs | Feature::LegacyCgroupfsRoot);
1371
1372        assert_eq!(config.cgroupfs_path(), Path::new(DEFAULT_HOST_MAPPED_CGROUPFS_ROOT));
1373    }
1374
1375    #[test]
1376    fn procfs_root_ignores_host_mapped_cgroupfs() {
1377        let config = cgroups_config_with(Feature::HostMappedCgroupfs);
1378
1379        assert_eq!(config.procfs_path(), Path::new(DEFAULT_PROCFS_ROOT));
1380    }
1381}