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.
35const REBOOT_REQUIRED_PKGS_FILE: &str = "/var/run/reboot-required-pkgs";
36
37// ---------------------------------------------------------------------------
38// specs() — for action_consistency tests
39// ---------------------------------------------------------------------------
40
41/// Return one representative `ActionSpec` for this module.
42pub fn specs() -> Vec<ActionSpec> {
43 vec![check_pending_reboot()]
44}
45
46// ---------------------------------------------------------------------------
47// Action constructor
48// ---------------------------------------------------------------------------
49
50/// Check whether a system reboot is pending on a Debian/Ubuntu host.
51///
52/// Risk: Low. Read-only file inspection; no system changes.
53///
54/// Exit-code semantics depend on which script branch fires:
55/// - **No `/var/run/reboot-required`**: echoes "No reboot required." and
56/// exits 0.
57/// - **Sentinel exists, packages file readable**: prints the sentinel
58/// contents plus `/var/run/reboot-required.pkgs`; exits 0.
59/// - **Sentinel exists but packages file is unreadable**: prints the
60/// sentinel; the inner `cat $pkgs 2>/dev/null` suppresses stderr but
61/// the script's overall exit code is the last command's status, which
62/// can be non-zero. Stdout still carries the human-readable status.
63///
64/// Callers should treat stdout as authoritative and not rely on a clean
65/// exit code as the "no reboot needed" signal.
66pub fn check_pending_reboot() -> ActionSpec {
67 // `test -f` returns 1 when the file is absent; the shell fragment treats
68 // that as "no reboot needed" and echoes a human-readable message instead
69 // of failing the whole action. `cat pkgs` may silently fail if the
70 // packages file does not exist — the `2>/dev/null` suppresses that.
71 let script = format!(
72 "if test -f {sentinel}; then cat {sentinel}; cat {pkgs} 2>/dev/null; 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_risk_is_low() {
137 assert_eq!(check_pending_reboot().risk_level, RiskLevel::Low);
138 }
139
140 #[test]
141 fn check_pending_reboot_no_reboot_no_rollback() {
142 let spec = check_pending_reboot();
143 assert!(!spec.reboot_required);
144 assert!(!spec.rollback_available);
145 }
146
147 #[test]
148 fn specs_covers_check_pending_reboot() {
149 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
150 assert!(
151 spec_names.contains(&"CheckPendingReboot"),
152 "specs() missing CheckPendingReboot"
153 );
154 }
155}