Skip to main content

sysknife_daemon/
distro.rs

1//! Runtime distro detection.
2//!
3//! Reads `/etc/os-release` once at startup and caches the result. All parts
4//! of the daemon that need to make distro-specific decisions (primarily the
5//! executor's package management dispatch) call [`current()`].
6//!
7//! # Design
8//!
9//! Action *names* are universal — `AddLayeredPackage` means "install a package"
10//! regardless of distro. The *commands* that implement those actions differ:
11//! rpm-ostree on Fedora Atomic, apt on Ubuntu. The executor's `build_action_spec`
12//! checks the distro and delegates to the appropriate action module.
13//!
14//! Adding support for a new distro means:
15//!
16//! 1. Add a variant here (e.g. `ArchLinux`).
17//! 2. Update `detect()` to recognise it via `ID=` in `/etc/os-release`.
18//! 3. Create `crates/sysknife-daemon/src/actions/<distro>.rs` with the
19//!    distro-specific `ActionSpec` builders.
20//! 4. Add match arms in `executor::build_action_spec` for the new variant.
21//!
22//! See [`HACKING.md §18`](../../../../HACKING.md) for the full checklist.
23
24use std::sync::OnceLock;
25
26// ---------------------------------------------------------------------------
27// Distro type
28// ---------------------------------------------------------------------------
29
30/// Detected Linux distribution family.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Distro {
33    /// Fedora Atomic Desktop (Silverblue, Kinoite, Sericea, Onyx, COSMIC Atomic).
34    ///
35    /// Package management via `rpm-ostree`; deployment lifecycle via OSTree.
36    /// Packages are staged into the next deployment and activated on reboot.
37    FedoraAtomic,
38
39    /// Ubuntu (Desktop, Server, or LTS variants).
40    ///
41    /// Package management via `apt`/`apt-get`; changes take effect immediately
42    /// without a reboot (except kernel updates which require a reboot by convention).
43    Ubuntu,
44
45    /// Any other distribution.
46    ///
47    /// Only universally available actions (service control, identity, SSH keys,
48    /// user management) are safe to call. Distro-specific actions (rpm-ostree
49    /// layering, apt install) will return an error.
50    Unknown,
51}
52
53impl Distro {
54    /// Human-readable name shown in error messages.
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Distro::FedoraAtomic => "Fedora Atomic",
58            Distro::Ubuntu => "Ubuntu",
59            Distro::Unknown => "Unknown",
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Detection
66// ---------------------------------------------------------------------------
67
68static CURRENT: OnceLock<Distro> = OnceLock::new();
69
70/// Return the detected distro, cached after the first call.
71///
72/// The detection reads `/etc/os-release` exactly once; subsequent calls are
73/// free (just a pointer load). Safe to call from any async context.
74pub fn current() -> Distro {
75    *CURRENT.get_or_init(detect)
76}
77
78fn detect() -> Distro {
79    let content = std::fs::read_to_string("/etc/os-release").unwrap_or_default();
80    let id = parse_os_release_field(&content, "ID").unwrap_or_default();
81    let variant_id = parse_os_release_field(&content, "VARIANT_ID").unwrap_or_default();
82
83    match id.as_str() {
84        "fedora"
85            if matches!(
86                variant_id.as_str(),
87                "silverblue" | "kinoite" | "sericea" | "onyx" | "cosmic-atomic"
88            ) =>
89        {
90            Distro::FedoraAtomic
91        }
92        "ubuntu" => Distro::Ubuntu,
93        _ => Distro::Unknown,
94    }
95}
96
97/// Extract a single `KEY=value` or `KEY="value"` field from `/etc/os-release` content.
98fn parse_os_release_field(content: &str, key: &str) -> Option<String> {
99    for line in content.lines() {
100        if let Some(rest) = line.strip_prefix(&format!("{key}=")) {
101            // Strip optional surrounding double quotes.
102            return Some(rest.trim_matches('"').to_string());
103        }
104    }
105    None
106}
107
108// ---------------------------------------------------------------------------
109// Tests
110// ---------------------------------------------------------------------------
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn parse_unquoted_id() {
118        let content = "ID=fedora\nVARIANT_ID=silverblue\n";
119        assert_eq!(
120            parse_os_release_field(content, "ID"),
121            Some("fedora".to_string())
122        );
123        assert_eq!(
124            parse_os_release_field(content, "VARIANT_ID"),
125            Some("silverblue".to_string())
126        );
127    }
128
129    #[test]
130    fn parse_quoted_id() {
131        let content = "ID=\"ubuntu\"\nVERSION_ID=\"24.04\"\n";
132        assert_eq!(
133            parse_os_release_field(content, "ID"),
134            Some("ubuntu".to_string())
135        );
136    }
137
138    #[test]
139    fn parse_returns_none_for_missing_key() {
140        let content = "ID=fedora\n";
141        assert_eq!(parse_os_release_field(content, "VARIANT_ID"), None);
142    }
143
144    #[test]
145    fn detect_fedora_atomic_silverblue() {
146        let content = "ID=fedora\nVARIANT_ID=silverblue\n";
147        let id = parse_os_release_field(content, "ID").unwrap_or_default();
148        let variant = parse_os_release_field(content, "VARIANT_ID").unwrap_or_default();
149        assert_eq!(id.as_str(), "fedora");
150        assert!(matches!(
151            variant.as_str(),
152            "silverblue" | "kinoite" | "sericea" | "onyx" | "cosmic-atomic"
153        ));
154    }
155
156    #[test]
157    fn detect_ubuntu() {
158        let content = "ID=ubuntu\nVERSION_ID=\"24.04\"\n";
159        let id = parse_os_release_field(content, "ID").unwrap_or_default();
160        assert_eq!(id.as_str(), "ubuntu");
161    }
162
163    #[test]
164    fn distro_as_str() {
165        assert_eq!(Distro::FedoraAtomic.as_str(), "Fedora Atomic");
166        assert_eq!(Distro::Ubuntu.as_str(), "Ubuntu");
167        assert_eq!(Distro::Unknown.as_str(), "Unknown");
168    }
169}