Skip to main content

sysknife_daemon/actions/
reboot.rs

1//! Pending-reboot detection action (Ubuntu / Debian).
2//!
3//! ## CheckPendingReboot
4//!
5//! Checks whether a reboot is pending by inspecting
6//! `/var/run/reboot-required`.  When a kernel or glibc update is installed via
7//! `apt`, the installer touches that file.  If the file exists this action also
8//! cats `/var/run/reboot-required.pkgs` (which lists the packages that require
9//! the reboot) so the operator sees the full picture in one step.
10//!
11//! ### Why Ubuntu-only?
12//!
13//! On Fedora/Silverblue the equivalent information is surfaced through
14//! `rpm-ostree status --json` (field `deployments[0].staged`).  That path is
15//! already covered by the existing `GetPendingUpdates` action in the Fedora
16//! action catalogue.  Adding a cross-distro `CheckPendingReboot` action would
17//! require runtime distro detection inside the executor — a 50-line refactor
18//! with no architectural precedent in the codebase.  Path (b) from the spec
19//! was therefore chosen: `CheckPendingReboot` covers Debian/Ubuntu, and
20//! Fedora operators use `GetPendingUpdates`.  The prompt places
21//! `CheckPendingReboot` in `DEBIAN_RISK_TABLES` and notes the Fedora
22//! equivalent in `DEBIAN_SELECTION_RULES`.
23
24use super::{command_mechanism, ActionSpec};
25use sysknife_types::RiskLevel;
26
27// ---------------------------------------------------------------------------
28// Constants
29// ---------------------------------------------------------------------------
30
31/// Sentinel file written by apt/dpkg when a reboot is required.
32const REBOOT_REQUIRED_FILE: &str = "/var/run/reboot-required";
33
34/// Optional file listing the packages that triggered the reboot requirement.
35/// Ubuntu's `update-notifier` writes the dot form (`reboot-required.pkgs`);
36/// the hyphen form is not a file the distro produces.
37const REBOOT_REQUIRED_PKGS_FILE: &str = "/var/run/reboot-required.pkgs";
38
39// ---------------------------------------------------------------------------
40// specs() — for action_consistency tests
41// ---------------------------------------------------------------------------
42
43/// Return one representative `ActionSpec` for this module.
44pub fn specs() -> Vec<ActionSpec> {
45    vec![check_pending_reboot()]
46}
47
48// ---------------------------------------------------------------------------
49// Action constructor
50// ---------------------------------------------------------------------------
51
52/// Check whether a system reboot is pending on a Debian/Ubuntu host.
53///
54/// Risk: Low. Read-only file inspection; no system changes.
55///
56/// Exit-code semantics:
57/// - **No `/var/run/reboot-required`**: echoes "No reboot required." and
58///   exits 0.
59/// - **Sentinel exists**: prints the sentinel contents, then
60///   `/var/run/reboot-required.pkgs` when that file is readable, and
61///   always exits 0. A missing packages file is optional, not an error —
62///   the dispatcher would otherwise turn a pending reboot into
63///   `CheckPendingReboot failed with exit code 1` and drop stdout.
64pub fn check_pending_reboot() -> ActionSpec {
65    // `test -f` returns 1 when the file is absent; the shell fragment treats
66    // that as "no reboot needed" and echoes a human-readable message instead
67    // of failing the whole action.  `cat pkgs` is optional: Ubuntu writes
68    // the file alongside the sentinel, but a missing copy must not become
69    // the `if` block's exit status. Ending the then-branch with `true`
70    // keeps the action successful whenever the sentinel is present.
71    let script = format!(
72        "if test -f {sentinel}; then cat {sentinel}; cat {pkgs} 2>/dev/null; true; else echo 'No reboot required.'; fi",
73        sentinel = REBOOT_REQUIRED_FILE,
74        pkgs = REBOOT_REQUIRED_PKGS_FILE,
75    );
76    ActionSpec {
77        action_name: "CheckPendingReboot",
78        mechanism: command_mechanism("bash", ["-c", &script]),
79        risk_level: RiskLevel::Low,
80        reboot_required: false,
81        rollback_available: false,
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Tests
87// ---------------------------------------------------------------------------
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::actions::ActionMechanism;
93
94    fn extract_cmd(spec: &ActionSpec) -> (&'static str, Vec<String>) {
95        match &spec.mechanism {
96            ActionMechanism::Command { program, args } => (*program, args.clone()),
97            _ => panic!("expected Command mechanism"),
98        }
99    }
100
101    #[test]
102    fn check_pending_reboot_action_name() {
103        assert_eq!(check_pending_reboot().action_name, "CheckPendingReboot");
104    }
105
106    #[test]
107    fn check_pending_reboot_uses_bash() {
108        let spec = check_pending_reboot();
109        let (prog, _) = extract_cmd(&spec);
110        assert_eq!(prog, "bash");
111    }
112
113    #[test]
114    fn check_pending_reboot_script_references_sentinel_file() {
115        let spec = check_pending_reboot();
116        let (_, args) = extract_cmd(&spec);
117        let joined = args.join(" ");
118        assert!(
119            joined.contains(REBOOT_REQUIRED_FILE),
120            "missing sentinel path in script: {joined}"
121        );
122    }
123
124    #[test]
125    fn check_pending_reboot_script_references_pkgs_file() {
126        let spec = check_pending_reboot();
127        let (_, args) = extract_cmd(&spec);
128        let joined = args.join(" ");
129        assert!(
130            joined.contains(REBOOT_REQUIRED_PKGS_FILE),
131            "missing pkgs path in script: {joined}"
132        );
133    }
134
135    #[test]
136    fn check_pending_reboot_script_uses_ubuntu_dot_pkgs_filename() {
137        let spec = check_pending_reboot();
138        let (_, args) = extract_cmd(&spec);
139        let joined = args.join(" ");
140        assert!(
141            joined.contains("/var/run/reboot-required.pkgs"),
142            "Ubuntu writes reboot-required.pkgs, not the hyphen form: {joined}"
143        );
144        assert!(
145            !joined.contains("reboot-required-pkgs"),
146            "hyphen form is not a file Ubuntu writes: {joined}"
147        );
148    }
149
150    fn run_pending_reboot_script(sentinel: Option<&str>, pkgs: Option<&str>) -> (i32, String) {
151        let dir = tempfile::tempdir().unwrap();
152        let sentinel_path = dir.path().join("reboot-required");
153        let pkgs_path = dir.path().join("reboot-required.pkgs");
154        if let Some(body) = sentinel {
155            std::fs::write(&sentinel_path, body).unwrap();
156        }
157        if let Some(body) = pkgs {
158            std::fs::write(&pkgs_path, body).unwrap();
159        }
160
161        let spec = check_pending_reboot();
162        let (_, args) = extract_cmd(&spec);
163        assert_eq!(args.first().map(String::as_str), Some("-c"));
164        // Replace the longer pkgs path first so the sentinel prefix cannot
165        // rewrite `/var/run/reboot-required-pkgs` into a fixture path + `-pkgs`.
166        let script = args[1]
167            .replace(REBOOT_REQUIRED_PKGS_FILE, &pkgs_path.to_string_lossy())
168            .replace(REBOOT_REQUIRED_FILE, &sentinel_path.to_string_lossy());
169
170        let output = std::process::Command::new("bash")
171            .args(["-c", &script])
172            .output()
173            .expect("bash");
174        (
175            output.status.code().unwrap_or(-1),
176            String::from_utf8_lossy(&output.stdout).into_owned(),
177        )
178    }
179
180    #[test]
181    fn pending_reboot_without_sentinel_reports_no_reboot() {
182        let (code, stdout) = run_pending_reboot_script(None, None);
183        assert_eq!(code, 0, "stdout={stdout:?}");
184        assert_eq!(stdout.trim(), "No reboot required.");
185    }
186
187    #[test]
188    fn pending_reboot_with_sentinel_and_pkgs_lists_both() {
189        let (code, stdout) = run_pending_reboot_script(
190            Some("*** System restart required ***\n"),
191            Some("linux-image-6.8.0-40-generic\n"),
192        );
193        assert_eq!(code, 0, "stdout={stdout:?}");
194        assert!(
195            stdout.contains("*** System restart required ***"),
196            "stdout={stdout:?}"
197        );
198        assert!(
199            stdout.contains("linux-image-6.8.0-40-generic"),
200            "stdout={stdout:?}"
201        );
202    }
203
204    #[test]
205    fn pending_reboot_with_sentinel_only_is_not_an_error() {
206        let (code, stdout) =
207            run_pending_reboot_script(Some("*** System restart required ***\n"), None);
208        assert_eq!(
209            code, 0,
210            "a missing packages file must not fail the action; stdout={stdout:?}"
211        );
212        assert!(
213            stdout.contains("*** System restart required ***"),
214            "stdout={stdout:?}"
215        );
216    }
217
218    #[test]
219    fn check_pending_reboot_risk_is_low() {
220        assert_eq!(check_pending_reboot().risk_level, RiskLevel::Low);
221    }
222
223    #[test]
224    fn check_pending_reboot_no_reboot_no_rollback() {
225        let spec = check_pending_reboot();
226        assert!(!spec.reboot_required);
227        assert!(!spec.rollback_available);
228    }
229
230    #[test]
231    fn specs_covers_check_pending_reboot() {
232        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
233        assert!(
234            spec_names.contains(&"CheckPendingReboot"),
235            "specs() missing CheckPendingReboot"
236        );
237    }
238}