Skip to main content

sysknife_daemon/actions/
layering.rs

1//! rpm-ostree package layering actions (Fedora Atomic).
2//!
3//! The actions that take a package (`InstallPackages`, `AddLayeredPackage`,
4//! `RemovePackages`, `RemoveLayeredPackage`, `ReplaceLayeredPackage`,
5//! `RemoveBasePackage`) run `sudo /usr/lib/sysknife/action-steps rpm-ostree
6//! <verb> <package>...`. The helper validates every package again and builds the
7//! rpm-ostree argv itself. A direct `/usr/bin/rpm-ostree install *` grant
8//! admitted a local `.rpm`, whose scriptlets run as root in the next
9//! deployment, and every rpm-ostree option; sudoers cannot forbid the options
10//! that follow a wildcard, so no operand-taking rpm-ostree call has a grant of
11//! its own. `ResetLayeredPackageOverride` takes no operand and keeps the exact
12//! grant `rpm-ostree override reset --all`.
13
14use super::{command_mechanism, ActionSpec};
15use sysknife_types::RiskLevel;
16
17/// The root-owned helper every operand-taking rpm-ostree action goes through.
18///
19/// `tests/sudoers_grants_match_argv.rs` checks the packaged sudoers file admits
20/// every argv built here, and `tests/release/action-steps.test.sh` reads the
21/// verbs below out of this file and checks the helper accepts exactly those.
22const ACTION_STEPS: &str = "/usr/lib/sysknife/action-steps";
23
24pub fn specs() -> Vec<ActionSpec> {
25    vec![
26        install_packages(&["podman"]),
27        remove_packages(&["podman"]),
28        get_layered_packages(),
29        add_layered_package("podman"),
30        remove_layered_package("podman"),
31        replace_layered_package("vim", "neovim"),
32        reset_layered_package_override(),
33        remove_base_package("gedit"),
34        get_pending_updates(),
35    ]
36}
37
38pub fn install_packages(packages: &[&str]) -> ActionSpec {
39    ActionSpec {
40        action_name: "InstallPackages",
41        // The helper runs `rpm-ostree install --idempotent <package>...`.
42        mechanism: command_mechanism(
43            "sudo",
44            [ACTION_STEPS, "rpm-ostree", "install"]
45                .into_iter()
46                .chain(packages.iter().copied()),
47        ),
48        risk_level: RiskLevel::High,
49        reboot_required: true,
50        rollback_available: true,
51    }
52}
53
54pub fn remove_packages(packages: &[&str]) -> ActionSpec {
55    ActionSpec {
56        action_name: "RemovePackages",
57        // The helper runs `rpm-ostree uninstall <package>...`.
58        mechanism: command_mechanism(
59            "sudo",
60            [ACTION_STEPS, "rpm-ostree", "uninstall"]
61                .into_iter()
62                .chain(packages.iter().copied()),
63        ),
64        risk_level: RiskLevel::High,
65        reboot_required: true,
66        rollback_available: true,
67    }
68}
69
70pub fn get_layered_packages() -> ActionSpec {
71    ActionSpec {
72        action_name: "GetLayeredPackages",
73        mechanism: command_mechanism("rpm-ostree", ["status", "--json"]),
74        risk_level: RiskLevel::Low,
75        reboot_required: false,
76        rollback_available: false,
77    }
78}
79
80pub fn add_layered_package(package: &str) -> ActionSpec {
81    ActionSpec {
82        action_name: "AddLayeredPackage",
83        // The helper runs `rpm-ostree install --idempotent <package>`.
84        mechanism: command_mechanism("sudo", [ACTION_STEPS, "rpm-ostree", "install", package]),
85        risk_level: RiskLevel::High,
86        reboot_required: true,
87        rollback_available: true,
88    }
89}
90
91pub fn remove_layered_package(package: &str) -> ActionSpec {
92    ActionSpec {
93        action_name: "RemoveLayeredPackage",
94        // The helper runs `rpm-ostree uninstall <package>`.
95        mechanism: command_mechanism("sudo", [ACTION_STEPS, "rpm-ostree", "uninstall", package]),
96        risk_level: RiskLevel::High,
97        reboot_required: true,
98        rollback_available: true,
99    }
100}
101
102pub fn replace_layered_package(old: &str, new: &str) -> ActionSpec {
103    // Atomically swap one layered package for another in a single transaction.
104    // `rpm-ostree install NEW --uninstall OLD` produces one pending deployment
105    // that contains both changes — no intermediate deployment exists where
106    // neither package is present. The running system is unchanged until reboot.
107    // The helper verb `replace OLD NEW` builds that argv.
108    ActionSpec {
109        action_name: "ReplaceLayeredPackage",
110        mechanism: command_mechanism("sudo", [ACTION_STEPS, "rpm-ostree", "replace", old, new]),
111        risk_level: RiskLevel::High,
112        reboot_required: true,
113        rollback_available: true,
114    }
115}
116
117pub fn remove_base_package(package: &str) -> ActionSpec {
118    // Override-remove a package that ships in the base OS image.
119    // Unlike `uninstall` (which removes layered packages), `override remove`
120    // hides the base package from the deployment without it ever having been
121    // explicitly installed by the user.
122    ActionSpec {
123        action_name: "RemoveBasePackage",
124        // The helper runs `rpm-ostree override remove <package>`.
125        mechanism: command_mechanism(
126            "sudo",
127            [ACTION_STEPS, "rpm-ostree", "override-remove", package],
128        ),
129        risk_level: RiskLevel::High,
130        reboot_required: true,
131        rollback_available: true,
132    }
133}
134
135pub fn get_pending_updates() -> ActionSpec {
136    // Check for available OS updates without applying them.
137    // `--check` exits 77 when updates ARE available, 0 when up-to-date.
138    // Both exit codes produce informative stdout. The dispatcher special-cases
139    // exit 77 as informational (not an error) so the update list reaches the
140    // caller in both the "updates available" and "up-to-date" cases.
141    ActionSpec {
142        action_name: "GetPendingUpdates",
143        mechanism: command_mechanism("rpm-ostree", ["upgrade", "--check"]),
144        risk_level: RiskLevel::Low,
145        reboot_required: false,
146        rollback_available: false,
147    }
148}
149
150pub fn reset_layered_package_override() -> ActionSpec {
151    ActionSpec {
152        action_name: "ResetLayeredPackageOverride",
153        mechanism: command_mechanism("sudo", ["rpm-ostree", "override", "reset", "--all"]),
154        risk_level: RiskLevel::High,
155        reboot_required: true,
156        rollback_available: true,
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::actions::{exclusive_resource, ActionMechanism, ExclusiveResource};
164
165    fn argv(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
166        match &spec.mechanism {
167            ActionMechanism::Command { program, args } => {
168                (*program, args.iter().map(String::as_str).collect())
169            }
170            other => panic!("{} is not a command: {other:?}", spec.action_name),
171        }
172    }
173
174    /// The six layering actions that take a package go through the helper verb:
175    /// `/usr/bin/rpm-ostree install *` and its siblings admitted a local `.rpm`
176    /// (scriptlets run as root) and every rpm-ostree option.
177    #[test]
178    fn layering_actions_with_an_operand_go_through_the_helper_verb() {
179        for (spec, expected) in [
180            (
181                install_packages(&["podman", "vim"]),
182                vec![ACTION_STEPS, "rpm-ostree", "install", "podman", "vim"],
183            ),
184            (
185                add_layered_package("podman"),
186                vec![ACTION_STEPS, "rpm-ostree", "install", "podman"],
187            ),
188            (
189                remove_packages(&["podman", "vim"]),
190                vec![ACTION_STEPS, "rpm-ostree", "uninstall", "podman", "vim"],
191            ),
192            (
193                remove_layered_package("podman"),
194                vec![ACTION_STEPS, "rpm-ostree", "uninstall", "podman"],
195            ),
196            (
197                replace_layered_package("vim", "neovim"),
198                vec![ACTION_STEPS, "rpm-ostree", "replace", "vim", "neovim"],
199            ),
200            (
201                remove_base_package("gedit"),
202                vec![ACTION_STEPS, "rpm-ostree", "override-remove", "gedit"],
203            ),
204        ] {
205            let (program, args) = argv(&spec);
206            assert_eq!(program, "sudo", "{}", spec.action_name);
207            assert_eq!(args, expected, "{}", spec.action_name);
208            assert!(
209                args.iter().all(|a| !a.starts_with('-')),
210                "{} sends an option; the helper adds rpm-ostree's flags itself: {args:?}",
211                spec.action_name
212            );
213        }
214    }
215
216    /// `override reset --all` takes no operand, so it keeps an exact grant.
217    #[test]
218    fn reset_override_keeps_its_fixed_argv() {
219        let reset = reset_layered_package_override();
220        let (program, args) = argv(&reset);
221        assert_eq!(program, "sudo");
222        assert_eq!(args, ["rpm-ostree", "override", "reset", "--all"]);
223    }
224
225    /// The dispatcher serialises on the lock named by the argv, so the helper
226    /// verb has to keep naming rpm-ostree first, whatever the package is called.
227    #[test]
228    fn layering_actions_still_contend_for_the_rpm_ostree_lock() {
229        for spec in specs().into_iter().chain([
230            add_layered_package("apt-get"),
231            install_packages(&["flatpak"]),
232        ]) {
233            assert_eq!(
234                exclusive_resource(&spec),
235                Some(ExclusiveResource::RpmOstree),
236                "{} lost the rpm-ostree lock",
237                spec.action_name
238            );
239        }
240    }
241}