Skip to main content

monitord/
system.rs

1//! # system module
2//!
3//! Handle systemd's overall "system" state. Basically says if we've successfully
4//! booted, stated all units or have been asked to stop, be offline etc.
5
6use std::convert::TryInto;
7use std::fmt;
8use std::sync::Arc;
9
10use int_enum::IntEnum;
11use serde_repr::Deserialize_repr;
12use serde_repr::Serialize_repr;
13use strum_macros::EnumIter;
14use strum_macros::EnumString;
15use thiserror::Error;
16use tokio::sync::RwLock;
17use tracing::error;
18use tracing::Instrument;
19
20use crate::MachineStats;
21
22#[derive(Error, Debug)]
23pub enum MonitordSystemError {
24    #[error("Unable to connect to DBUS via zbus: {0:#}")]
25    ZbusError(#[from] zbus::Error),
26    #[error("Version parse error: {0}")]
27    VersionParseError(String),
28    #[error("Integer parse error: {0}")]
29    IntParseError(#[from] std::num::ParseIntError),
30}
31
32/// Overall system state reported by the systemd manager (PID 1).
33/// Reflects whether the system has fully booted, is shutting down, or has failures.
34/// Queried via the SystemState property on org.freedesktop.systemd1.Manager.
35#[allow(non_camel_case_types)]
36#[derive(
37    Serialize_repr,
38    Deserialize_repr,
39    Clone,
40    Copy,
41    Debug,
42    Default,
43    Eq,
44    PartialEq,
45    EnumIter,
46    EnumString,
47    IntEnum,
48    strum_macros::Display,
49)]
50#[repr(u8)]
51pub enum SystemdSystemState {
52    /// System state could not be determined
53    #[default]
54    unknown = 0,
55    /// systemd is loading and setting up its internal state early in the boot process
56    initializing = 1,
57    /// systemd is starting units as part of the boot sequence
58    starting = 2,
59    /// All units have been started successfully and the system is fully operational
60    running = 3,
61    /// System is operational but one or more units have failed
62    degraded = 4,
63    /// System is in rescue or emergency mode (single-user maintenance)
64    maintenance = 5,
65    /// System is shutting down
66    stopping = 6,
67    /// systemd is not running (seen on non-booted containers or during very early boot)
68    offline = 7,
69}
70
71/// Parsed systemd version from the Version property on org.freedesktop.systemd1.Manager.
72///
73/// systemd version strings follow the [UAPI Version Format Specification][uapi]
74/// (what `systemd-analyze compare-versions` implements), which standardises how
75/// versions *sort* — `~` sorts below everything, `-` separates major parts, `.`
76/// separates parts of a component — but not how they split into fields. So
77/// there is no official parser to defer to for this: the major/minor/revision/os
78/// split below is monitord's own, for the JSON output, and the separator
79/// handling follows the spec's meanings. Should monitord ever need to *compare*
80/// versions, use that spec (e.g. the zero-dependency `uapi-version` crate)
81/// rather than these fields — today it feature-detects instead, by looking for
82/// the metrics a systemd release added.
83///
84/// Format: "major[.minor][.revision][.os]" (e.g. "256.1.fc40",
85/// "255.6-9.9.hs+fb.el9", "260~rc1-5.fc45", "262-1.fc46").
86///
87/// [uapi]: https://uapi-group.org/specifications/specs/version_format_specification/
88#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
89pub struct SystemdVersion {
90    /// Major version number (e.g. 256)
91    major: u32,
92    /// Minor version string; may contain hyphens for distro-patched versions (e.g. "6-9")
93    minor: String,
94    /// Optional patch/revision number, present when the version string has 4+ dot-separated parts
95    revision: Option<u32>,
96    /// OS/distribution suffix (e.g. "fc40", "hs+fb.el9")
97    os: String,
98}
99impl SystemdVersion {
100    pub fn new(major: u32, minor: String, revision: Option<u32>, os: String) -> SystemdVersion {
101        Self {
102            major,
103            minor,
104            revision,
105            os,
106        }
107    }
108}
109impl fmt::Display for SystemdVersion {
110    /// Dot-joined, skipping parts this version does not have. Note the
111    /// separators are normalised: "260~rc1-5.fc45" renders as
112    /// "260.rc1-5.fc45", since which separator followed the major version is
113    /// not kept.
114    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
115        write!(f, "{}", self.major)?;
116        if !self.minor.is_empty() {
117            write!(f, ".{}", self.minor)?;
118        }
119        if let Some(revision) = self.revision {
120            write!(f, ".{revision}")?;
121        }
122        if !self.os.is_empty() {
123            write!(f, ".{}", self.os)?;
124        }
125        Ok(())
126    }
127}
128impl TryFrom<String> for SystemdVersion {
129    type Error = MonitordSystemError;
130
131    fn try_from(s: String) -> Result<Self, Self::Error> {
132        let no_v_version = s.strip_prefix('v').unwrap_or(&s);
133
134        // Handle versions whose major number is followed by a package release
135        // rather than a dotted minor: "260~rc1-5.fc45" (pre-release) and
136        // "262-1.fc46" (Fedora once the release is final). Only a separator
137        // before the first '.' counts — a '-' after one belongs to a distro
138        // minor such as the "6-9" in "969.6-9.9.hs+fb.el9".
139        let first_dot = no_v_version.find('.').unwrap_or(no_v_version.len());
140        if let Some(sep_pos) = no_v_version[..first_dot].find(['~', '-']) {
141            let major = no_v_version[..sep_pos].parse::<u32>()?;
142            let after_sep = &no_v_version[sep_pos + 1..];
143            let mut tilde_parts = after_sep.splitn(2, '.');
144            let minor = tilde_parts.next().unwrap_or("").to_string();
145            let os = tilde_parts.next().unwrap_or("").to_string();
146            return Ok(SystemdVersion {
147                major,
148                minor,
149                revision: None,
150                os,
151            });
152        }
153
154        let mut parts = no_v_version.split('.');
155        let split_count = parts.clone().count();
156        let major = parts
157            .next()
158            .ok_or_else(|| MonitordSystemError::VersionParseError("No valid major version".into()))?
159            .parse::<u32>()?;
160        // A version can be just the major number ("263", as built from an
161        // upstream tarball): no minor, revision or OS suffix to report.
162        let minor = parts.next().unwrap_or_default().to_string();
163        let mut revision = None;
164        if split_count > 3 {
165            revision = parts.next().and_then(|s| s.parse::<u32>().ok());
166        }
167        let os = parts.collect::<Vec<&str>>().join(".");
168        Ok(SystemdVersion {
169            major,
170            minor,
171            revision,
172            os,
173        })
174    }
175}
176
177/// Map systemd's system state string to our enum.
178///
179/// Shared by the D-Bus `SystemState` property and the varlink
180/// `io.systemd.Manager.Describe` runtime field, which use the same vocabulary.
181pub fn parse_system_state(system_state: &str) -> SystemdSystemState {
182    match system_state {
183        "initializing" => SystemdSystemState::initializing,
184        "starting" => SystemdSystemState::starting,
185        "running" => SystemdSystemState::running,
186        "degraded" => SystemdSystemState::degraded,
187        "maintenance" => SystemdSystemState::maintenance,
188        "stopping" => SystemdSystemState::stopping,
189        "offline" => SystemdSystemState::offline,
190        _ => SystemdSystemState::unknown,
191    }
192}
193
194//pub fn get_system_state(dbus_address: &str) -> Result<SystemdSystemState, dbus::Error> {
195pub async fn get_system_state(
196    connection: &zbus::Connection,
197) -> Result<SystemdSystemState, MonitordSystemError> {
198    let p = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
199        .cache_properties(zbus::proxy::CacheProperties::No)
200        .build()
201        .await
202        .map_err(MonitordSystemError::ZbusError)?;
203
204    let state = match p.system_state().await {
205        Ok(system_state) => parse_system_state(&system_state),
206        Err(err) => {
207            error!("Failed to get system-state: {:?}", err);
208            crate::system::SystemdSystemState::unknown
209        }
210    };
211    Ok(state)
212}
213
214/// Async wrapper than can update system stats when passed a locked struct
215///
216/// The D-Bus call runs before the write lock is taken (not while holding it):
217/// `locked_machine_stats` is shared by every collector, so holding it across an
218/// awaited D-Bus round trip would block every other collector's own (already
219/// finished) write until this one's D-Bus call completes.
220pub async fn update_system_stats(
221    connection: zbus::Connection,
222    locked_machine_stats: Arc<RwLock<MachineStats>>,
223) -> anyhow::Result<()> {
224    let system_state = crate::system::get_system_state(&connection)
225        .instrument(tracing::debug_span!("system_state_dbus_call"))
226        .await
227        .map_err(|e| anyhow::anyhow!("Error getting system state: {:?}", e))?;
228    let mut machine_stats = locked_machine_stats
229        .write()
230        .instrument(tracing::debug_span!("system_state_acquire_write_lock"))
231        .await;
232    machine_stats.system_state = system_state;
233    Ok(())
234}
235
236pub async fn get_version(
237    connection: &zbus::Connection,
238) -> Result<SystemdVersion, MonitordSystemError> {
239    let p = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
240        .cache_properties(zbus::proxy::CacheProperties::No)
241        .build()
242        .await
243        .map_err(MonitordSystemError::ZbusError)?;
244    let version_string = p.version().await?;
245    version_string.try_into()
246}
247
248/// Async wrapper than can update system stats when passed a locked struct
249///
250/// See `update_system_stats` above: the D-Bus call runs before the write lock
251/// is taken so it doesn't hold the shared lock for the round trip's duration.
252pub async fn update_version(
253    connection: zbus::Connection,
254    locked_machine_stats: Arc<RwLock<MachineStats>>,
255) -> anyhow::Result<()> {
256    let version = crate::system::get_version(&connection)
257        .instrument(tracing::debug_span!("version_dbus_call"))
258        .await
259        .map_err(|e| anyhow::anyhow!("Error getting systemd version: {:?}", e))?;
260    let mut machine_stats = locked_machine_stats
261        .write()
262        .instrument(tracing::debug_span!("version_acquire_write_lock"))
263        .await;
264    machine_stats.version = version;
265    Ok(())
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_display_struct() {
274        assert_eq!(
275            format!("{}", SystemdSystemState::running),
276            String::from("running"),
277        )
278    }
279
280    #[test]
281    fn test_parse_system_state() {
282        assert_eq!(parse_system_state("running"), SystemdSystemState::running);
283        assert_eq!(parse_system_state("degraded"), SystemdSystemState::degraded);
284        // Anything systemd grows later reads as unknown rather than failing.
285        assert_eq!(parse_system_state("brand-new"), SystemdSystemState::unknown);
286    }
287
288    #[test]
289    fn test_parsing_systemd_versions() -> Result<(), MonitordSystemError> {
290        let parsed: SystemdVersion = "969.1.69.fc69".to_string().try_into()?;
291        assert_eq!(
292            SystemdVersion::new(969, String::from("1"), Some(69), String::from("fc69")),
293            parsed
294        );
295
296        // No revision
297        let parsed: SystemdVersion = "969.1.fc69".to_string().try_into()?;
298        assert_eq!(
299            SystemdVersion::new(969, String::from("1"), None, String::from("fc69")),
300            parsed
301        );
302
303        // #bigCompany strings
304        let parsed: SystemdVersion = String::from("969.6-9.9.hs+fb.el9").try_into()?;
305        assert_eq!(
306            SystemdVersion::new(969, String::from("6-9"), Some(9), String::from("hs+fb.el9")),
307            parsed
308        );
309
310        let parsed: SystemdVersion = String::from("v299.6-9.9.hs+fb.el9").try_into()?;
311        assert_eq!(
312            SystemdVersion::new(299, String::from("6-9"), Some(9), String::from("hs+fb.el9")),
313            parsed
314        );
315
316        // RC / pre-release versions like those seen on Fedora Rawhide: "260~rc1-5.fc45"
317        let parsed: SystemdVersion = String::from("260~rc1-5.fc45").try_into()?;
318        assert_eq!(
319            SystemdVersion::new(260, String::from("rc1-5"), None, String::from("fc45")),
320            parsed
321        );
322
323        // Fedora once the release is final and the '~rc' is gone: "262-1.fc46".
324        // The package release follows the major version directly, with no
325        // dotted minor at all.
326        let parsed: SystemdVersion = String::from("262-1.fc46").try_into()?;
327        assert_eq!(
328            SystemdVersion::new(262, String::from("1"), None, String::from("fc46")),
329            parsed
330        );
331
332        // A release with no OS suffix still parses.
333        let parsed: SystemdVersion = String::from("262-1").try_into()?;
334        assert_eq!(
335            SystemdVersion::new(262, String::from("1"), None, String::new()),
336            parsed
337        );
338
339        // A '-' after the first dot is part of the minor, not a release
340        // separator: this must keep parsing as it did before.
341        let parsed: SystemdVersion = String::from("969.6-9.9.hs+fb.el9").try_into()?;
342        assert_eq!(
343            SystemdVersion::new(969, String::from("6-9"), Some(9), String::from("hs+fb.el9")),
344            parsed
345        );
346
347        // Just the major version, as built from an upstream tarball.
348        let parsed: SystemdVersion = String::from("263").try_into()?;
349        assert_eq!(
350            SystemdVersion::new(263, String::new(), None, String::new()),
351            parsed
352        );
353        assert_eq!(parsed.to_string(), "263");
354
355        // Anything that does not start with a number is still an error: a
356        // version monitord cannot read must not silently become 0.
357        assert!(SystemdVersion::try_from(String::from("not-a-version")).is_err());
358        assert!(SystemdVersion::try_from(String::new()).is_err());
359
360        Ok(())
361    }
362}