Skip to main content

monitrs_core/model/
host.rs

1//! System identity and environment.
2
3use core::time::Duration;
4use std::time::SystemTime;
5
6use crate::model::{Confidence, MetricState};
7
8/// Whether we appear to be running on hardware, in a VM, or in a container.
9///
10/// §7.5 requires this to be *clearly labelled heuristic*, which is why
11/// [`HostEnvironment`] carries both the evidence and a [`Confidence`] and why
12/// there is no `BareMetal` variant — absence of container and VM evidence is not
13/// proof of bare metal.
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize))]
16#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
17pub enum EnvironmentKind {
18    /// No container or virtualization evidence was found.
19    #[default]
20    NoEvidenceFound,
21    /// Container evidence was found, e.g. a Docker or Kubernetes cgroup path.
22    Container,
23    /// Virtualization evidence was found, e.g. a hypervisor DMI string.
24    VirtualMachine,
25}
26
27impl EnvironmentKind {
28    /// Lower-case label.
29    #[must_use]
30    pub const fn label(self) -> &'static str {
31        match self {
32            Self::NoEvidenceFound => "no container/VM evidence",
33            Self::Container => "container",
34            Self::VirtualMachine => "virtual machine",
35        }
36    }
37}
38
39/// A container runtime recognised from a cgroup path.
40///
41/// Recognition is by naming convention, so this is evidence rather than proof.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize))]
44#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
45pub enum ContainerRuntime {
46    /// `docker-<id>.scope` or `/docker/<id>`.
47    Docker,
48    /// `cri-containerd-<id>.scope` or `containerd-<id>.scope`.
49    Containerd,
50    /// `crio-<id>.scope`.
51    CriO,
52    /// `libpod-<id>.scope`.
53    Podman,
54    /// `lxc.payload.<name>` or `/lxc/<name>`.
55    Lxc,
56    /// `machine-<name>.scope`, which is `systemd-nspawn` or a `machinectl` VM.
57    SystemdMachine,
58    /// A path that looks like a container but matches no known convention.
59    Unknown,
60}
61
62impl ContainerRuntime {
63    /// The runtime name for the Inspect screen.
64    #[must_use]
65    pub const fn label(self) -> &'static str {
66        match self {
67            Self::Docker => "docker",
68            Self::Containerd => "containerd",
69            Self::CriO => "cri-o",
70            Self::Podman => "podman",
71            Self::Lxc => "lxc",
72            Self::SystemdMachine => "systemd-machine",
73            Self::Unknown => "container",
74        }
75    }
76}
77
78/// A container identified from a cgroup path.
79#[derive(Clone, Debug, Eq, PartialEq)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize))]
81pub struct ContainerIdentity {
82    /// Which convention matched.
83    pub runtime: ContainerRuntime,
84    /// The identifier the path carried, usually a 64-character hex digest.
85    pub id: Box<str>,
86    /// Whether the path also names a Kubernetes pod.
87    pub kubernetes: bool,
88}
89
90impl ContainerIdentity {
91    /// The abbreviated identifier people actually recognise.
92    ///
93    /// Twelve characters, matching `docker ps` output, so a user can compare what
94    /// this screen shows with what their own tooling shows.
95    #[must_use]
96    pub fn short_id(&self) -> &str {
97        let cut = self
98            .id
99            .char_indices()
100            .nth(12)
101            .map_or(self.id.len(), |(index, _)| index);
102        self.id.get(..cut).unwrap_or(&self.id)
103    }
104
105    /// A one-line label such as `docker 3f4a1b2c9d8e`.
106    #[must_use]
107    pub fn label(&self) -> String {
108        if self.kubernetes {
109            format!("kubernetes/{} {}", self.runtime.label(), self.short_id())
110        } else {
111            format!("{} {}", self.runtime.label(), self.short_id())
112        }
113    }
114}
115
116/// A heuristic environment classification together with its evidence.
117#[derive(Clone, Debug, PartialEq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize))]
119pub struct HostEnvironment {
120    /// What we think this is.
121    pub kind: EnvironmentKind,
122    /// What led to that conclusion, e.g. `"/proc/1/cgroup names docker"`.
123    ///
124    /// Rendered next to the classification so the user can judge it themselves.
125    pub evidence: Box<str>,
126    /// How much the evidence supports the conclusion.
127    pub confidence: Confidence,
128    /// Which container this is, where the evidence named one.
129    ///
130    /// [`EnvironmentKind::Container`] answers *whether*; this answers *which*, and the
131    /// two are separate because the evidence often supports the first without the
132    /// second — a `/.dockerenv` file, or a `container=` environment variable, says a
133    /// container without naming it. `None` alongside `Container` is therefore an
134    /// ordinary outcome and not a gap to be filled with a placeholder id.
135    pub container: Option<ContainerIdentity>,
136}
137
138/// System identity, mostly from the slow sampling tier (§8.6).
139#[derive(Clone, Debug, PartialEq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize))]
141pub struct HostSnapshot {
142    /// Host name.
143    pub hostname: MetricState<Box<str>>,
144    /// OS name, e.g. `macOS` or `Debian GNU/Linux`.
145    pub os_name: MetricState<Box<str>>,
146    /// OS version.
147    pub os_version: MetricState<Box<str>>,
148    /// Kernel version.
149    pub kernel_version: MetricState<Box<str>>,
150    /// Target architecture. Known at compile time, so never unavailable.
151    pub arch: &'static str,
152    /// CPU model string.
153    pub cpu_brand: MetricState<Box<str>>,
154    /// Time since boot.
155    pub uptime: MetricState<Duration>,
156    /// Wall-clock boot time.
157    pub boot_time: MetricState<SystemTime>,
158    /// Heuristic container/VM classification.
159    pub environment: MetricState<HostEnvironment>,
160}
161
162impl HostSnapshot {
163    /// A snapshot with nothing resolved yet.
164    #[must_use]
165    pub const fn warming_up() -> Self {
166        Self {
167            hostname: MetricState::WarmingUp,
168            os_name: MetricState::WarmingUp,
169            os_version: MetricState::WarmingUp,
170            kernel_version: MetricState::WarmingUp,
171            arch: std::env::consts::ARCH,
172            cpu_brand: MetricState::WarmingUp,
173            uptime: MetricState::WarmingUp,
174            boot_time: MetricState::WarmingUp,
175            environment: MetricState::WarmingUp,
176        }
177    }
178
179    /// The host name for the header, or a neutral placeholder.
180    ///
181    /// §5.5 puts the host name in the title bar; an empty title would look
182    /// broken, and "unknown" is honest.
183    #[must_use]
184    pub fn display_hostname(&self) -> &str {
185        self.hostname
186            .displayable()
187            .map_or("unknown", |(name, _)| name)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn arch_is_known_at_compile_time_and_never_unavailable() {
197        let host = HostSnapshot::warming_up();
198        assert!(!host.arch.is_empty());
199        assert!(
200            [
201                "aarch64",
202                "x86_64",
203                "arm",
204                "x86",
205                "powerpc64",
206                "riscv64",
207                "s390x",
208                "loongarch64"
209            ]
210            .contains(&host.arch),
211            "unexpected arch {}",
212            host.arch
213        );
214    }
215
216    #[test]
217    fn an_unresolved_hostname_renders_as_unknown_not_as_an_empty_title() {
218        let host = HostSnapshot::warming_up();
219        assert_eq!(host.display_hostname(), "unknown");
220    }
221
222    #[test]
223    fn a_stale_hostname_is_still_displayable() {
224        let mut host = HostSnapshot::warming_up();
225        host.hostname = MetricState::Available("dev-mbp".into()).into_stale(Duration::from_secs(5));
226        assert_eq!(host.display_hostname(), "dev-mbp");
227    }
228
229    #[test]
230    fn absence_of_evidence_is_not_reported_as_bare_metal() {
231        assert_eq!(EnvironmentKind::default(), EnvironmentKind::NoEvidenceFound);
232        assert!(
233            EnvironmentKind::NoEvidenceFound
234                .label()
235                .contains("evidence")
236        );
237    }
238
239    #[test]
240    fn an_environment_classification_carries_its_evidence_and_confidence() {
241        let env = HostEnvironment {
242            kind: EnvironmentKind::Container,
243            evidence: "/proc/1/cgroup names docker".into(),
244            confidence: Confidence::High,
245            container: None,
246        };
247        assert!(!env.evidence.is_empty());
248        assert_eq!(env.confidence, Confidence::High);
249    }
250}