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}