Skip to main content

sysknife_daemon/actions/
mounts.rs

1//! Filesystem mount + swap actions.
2//!
3//! `GetMounts` is a read-only inventory (`findmnt --json`). The four mutating
4//! actions (`AddMount`, `RemoveMount`, `AddSwap`, `RemoveSwap`) all delegate to
5//! the root-owned helper `/usr/lib/sysknife/mount-edit`, which keeps `/etc/fstab`
6//! in sync and — crucially — writes every managed entry with `nofail`, so a bad
7//! or absent device can never wedge the boot. See `packaging/sysknife-mount-edit`.
8
9use super::{command_mechanism, ActionSpec};
10use sysknife_types::RiskLevel;
11
12const HELPER: &str = "/usr/lib/sysknife/mount-edit";
13
14pub fn specs() -> Vec<ActionSpec> {
15    vec![
16        get_mounts(),
17        add_mount("/dev/sdb1", "/mnt/data", "ext4", Some("defaults")),
18        remove_mount("/mnt/data"),
19        add_swap("/swapfile", 2048),
20        remove_swap("/swapfile"),
21    ]
22}
23
24/// List mounted filesystems as JSON (`findmnt --json`). Read-only.
25pub fn get_mounts() -> ActionSpec {
26    ActionSpec {
27        action_name: "GetMounts",
28        mechanism: command_mechanism("findmnt", ["--json"]),
29        risk_level: RiskLevel::Low,
30        reboot_required: false,
31        rollback_available: false,
32    }
33}
34
35/// Mount a device and persist it to `/etc/fstab` (with `nofail`).
36pub fn add_mount(
37    device: &str,
38    mountpoint: &str,
39    fstype: &str,
40    options: Option<&str>,
41) -> ActionSpec {
42    let mut args = vec![
43        HELPER.to_string(),
44        "--op".to_string(),
45        "mount".to_string(),
46        "--device".to_string(),
47        device.to_string(),
48        "--mountpoint".to_string(),
49        mountpoint.to_string(),
50        "--fstype".to_string(),
51        fstype.to_string(),
52    ];
53    if let Some(opts) = options {
54        args.push("--options".to_string());
55        args.push(opts.to_string());
56    }
57    ActionSpec {
58        action_name: "AddMount",
59        mechanism: command_mechanism("sudo", args),
60        risk_level: RiskLevel::High,
61        reboot_required: false,
62        rollback_available: false,
63    }
64}
65
66/// Unmount and remove the `/etc/fstab` entry for a mountpoint.
67pub fn remove_mount(mountpoint: &str) -> ActionSpec {
68    ActionSpec {
69        action_name: "RemoveMount",
70        mechanism: command_mechanism(
71            "sudo",
72            [HELPER, "--op", "unmount", "--mountpoint", mountpoint],
73        ),
74        risk_level: RiskLevel::High,
75        reboot_required: false,
76        rollback_available: false,
77    }
78}
79
80/// Create a swap file, enable it, and persist it to `/etc/fstab`.
81pub fn add_swap(file: &str, size_mb: u32) -> ActionSpec {
82    let size = size_mb.to_string();
83    ActionSpec {
84        action_name: "AddSwap",
85        mechanism: command_mechanism(
86            "sudo",
87            [
88                HELPER,
89                "--op",
90                "addswap",
91                "--file",
92                file,
93                "--size-mb",
94                &size,
95            ],
96        ),
97        risk_level: RiskLevel::High,
98        reboot_required: false,
99        rollback_available: false,
100    }
101}
102
103/// Disable a swap file, remove it, and drop its `/etc/fstab` entry.
104pub fn remove_swap(file: &str) -> ActionSpec {
105    ActionSpec {
106        action_name: "RemoveSwap",
107        mechanism: command_mechanism("sudo", [HELPER, "--op", "rmswap", "--file", file]),
108        risk_level: RiskLevel::High,
109        reboot_required: false,
110        rollback_available: false,
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::actions::ActionMechanism;
118
119    fn args_of(spec: &ActionSpec) -> (&'static str, Vec<String>) {
120        match &spec.mechanism {
121            ActionMechanism::Command { program, args } => (program, args.clone()),
122            other => panic!("expected Command, got {other:?}"),
123        }
124    }
125
126    #[test]
127    fn get_mounts_is_read_only_json() {
128        let (program, args) = args_of(&get_mounts());
129        assert_eq!(program, "findmnt");
130        assert_eq!(args, vec!["--json"]);
131        assert_eq!(get_mounts().risk_level, RiskLevel::Low);
132    }
133
134    #[test]
135    fn add_mount_passes_op_and_optional_options() {
136        let (program, args) = args_of(&add_mount(
137            "/dev/sdb1",
138            "/mnt/data",
139            "ext4",
140            Some("noatime"),
141        ));
142        assert_eq!(program, "sudo");
143        assert_eq!(
144            args,
145            vec![
146                HELPER,
147                "--op",
148                "mount",
149                "--device",
150                "/dev/sdb1",
151                "--mountpoint",
152                "/mnt/data",
153                "--fstype",
154                "ext4",
155                "--options",
156                "noatime"
157            ]
158        );
159        // options omitted → no --options flag
160        let (_, no_opts) = args_of(&add_mount("/dev/sdb1", "/mnt/data", "ext4", None));
161        assert!(!no_opts.iter().any(|a| a == "--options"));
162    }
163
164    #[test]
165    fn swap_ops_shapes() {
166        let (_, add) = args_of(&add_swap("/swapfile", 2048));
167        assert_eq!(
168            add,
169            vec![
170                HELPER,
171                "--op",
172                "addswap",
173                "--file",
174                "/swapfile",
175                "--size-mb",
176                "2048"
177            ]
178        );
179        let (_, rm) = args_of(&remove_swap("/swapfile"));
180        assert_eq!(rm, vec![HELPER, "--op", "rmswap", "--file", "/swapfile"]);
181        assert_eq!(add_swap("/s", 1).risk_level, RiskLevel::High);
182    }
183}