Skip to main content

sysknife_daemon/actions/
netplan.rs

1//! Netplan network configuration actions (Ubuntu Server).
2//!
3//! Netplan is the default network configuration tool on Ubuntu Server. It
4//! generates backend configuration for either `systemd-networkd` or
5//! `NetworkManager` from YAML files in `/etc/netplan/`.
6//!
7//! ## When to use netplan vs NetworkManager
8//!
9//! - Ubuntu **Desktop**: `nmcli` / `NetworkManager` (Phase 2a actions)
10//! - Ubuntu **Server** / headless: netplan → detected via `which netplan`
11//!
12//! The executor routes `NetplanApply` and `NetplanGetConfig` here when the
13//! distro is Ubuntu. On Fedora the actions return an unsupported-on-distro
14//! error (netplan is not installed by default on Fedora).
15//!
16//! ## SSH disconnect risk
17//!
18//! `NetplanApply` reloads network interfaces immediately. On a remote session,
19//! a misconfigured netplan YAML can drop the SSH connection with no path to
20//! recovery other than console or OOB access. The preview profile carries an
21//! explicit warning about this risk.
22
23use super::{command_mechanism, ActionSpec};
24use sysknife_types::RiskLevel;
25
26// ---------------------------------------------------------------------------
27// specs() — for action_consistency tests
28// ---------------------------------------------------------------------------
29
30/// Return one representative `ActionSpec` per netplan action name.
31pub fn specs() -> Vec<ActionSpec> {
32    vec![
33        netplan_get_config(),
34        netplan_apply(),
35        netplan_set("ethernets.eth0.dhcp4", "true"),
36        netplan_generate(),
37    ]
38}
39
40// ---------------------------------------------------------------------------
41// Action constructors
42// ---------------------------------------------------------------------------
43
44/// Read current netplan configuration files via
45/// `find /etc/netplan -name '*.yaml' -print -exec cat {} +`.
46///
47/// Risk: Low / Observer. Reads YAML config from `/etc/netplan/`. Does not
48/// apply or change anything.
49///
50/// Uses `find` with no shell so error states surface distinctly:
51/// `/etc/netplan` missing or permission-denied causes `find` to exit non-zero
52/// with a stderr diagnostic, while a directory with no YAML files exits 0
53/// with empty stdout. The previous `bash -c "cat ... 2>/dev/null || echo
54/// 'no netplan files found'"` pattern collapsed all three states into a
55/// single fake-success exit 0, hiding real diagnostics from operators.
56pub fn netplan_get_config() -> ActionSpec {
57    ActionSpec {
58        action_name: "NetplanGetConfig",
59        mechanism: command_mechanism(
60            "find",
61            [
62                "/etc/netplan",
63                "-maxdepth",
64                "1",
65                "-name",
66                "*.yaml",
67                "-print",
68                "-exec",
69                "cat",
70                "{}",
71                "+",
72            ],
73        ),
74        risk_level: RiskLevel::Low,
75        reboot_required: false,
76        rollback_available: false,
77    }
78}
79
80/// Set a single netplan key to a value (`sudo netplan set <key>=<value>`).
81///
82/// Risk: High / Admin. Modifies the active netplan configuration in-memory.
83/// Run `NetplanApply` afterward to apply the change to the live network stack.
84///
85/// `rollback_available` is false: the daemon has no automatic rollback for
86/// netplan YAML — it edits `/etc/netplan/` directly with no rpm-ostree-style
87/// deployment to auto-revert to on failure (see
88/// `docs/automatic-rollback.md`). An operator can manually undo the change
89/// with another `NetplanSet` call, but that is not what this flag means.
90///
91/// The argv passes `<key>=<value>` as a single argument with no shell
92/// involvement — `validated_safe_arg` (applied at the executor boundary)
93/// rejects spaces in `value`, so any further quoting would only inject
94/// literal quote bytes that netplan would then read as part of the value.
95pub fn netplan_set(key: &str, value: &str) -> ActionSpec {
96    let kv = format!("{}={}", key, value);
97    ActionSpec {
98        action_name: "NetplanSet",
99        mechanism: command_mechanism("sudo", ["netplan", "set", &kv]),
100        risk_level: RiskLevel::High,
101        reboot_required: false,
102        rollback_available: false,
103    }
104}
105
106/// Regenerate backend configuration from netplan YAML without applying it
107/// (`sudo netplan generate`).
108///
109/// Risk: Medium. Regenerates the systemd-networkd / NetworkManager config
110/// files from the current netplan YAML but does NOT reload interfaces.
111/// Safe to use as a dry-run check before `NetplanApply`.
112pub fn netplan_generate() -> ActionSpec {
113    ActionSpec {
114        action_name: "NetplanGenerate",
115        mechanism: command_mechanism("sudo", ["netplan", "generate"]),
116        risk_level: RiskLevel::Medium,
117        reboot_required: false,
118        rollback_available: false,
119    }
120}
121
122/// Apply the current netplan configuration (`netplan apply`).
123///
124/// Risk: High / Admin. Re-configures network interfaces immediately.
125/// Can disconnect an SSH session if the configuration is wrong or if the
126/// interface IP changes.
127///
128/// **Warning:** run `netplan try` (with a rollback timeout) in preference
129/// to `netplan apply` when testing new configurations. `netplan try` is not
130/// exposed as a daemon action because it requires an interactive terminal to
131/// accept or reject the change.
132pub fn netplan_apply() -> ActionSpec {
133    ActionSpec {
134        action_name: "NetplanApply",
135        mechanism: command_mechanism("sudo", ["netplan", "apply"]),
136        risk_level: RiskLevel::High,
137        reboot_required: false,
138        rollback_available: false,
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Tests
144// ---------------------------------------------------------------------------
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::actions::ActionMechanism;
150
151    fn extract_cmd(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
152        match &spec.mechanism {
153            ActionMechanism::Command { program, args } => {
154                (*program, args.iter().map(String::as_str).collect())
155            }
156            _ => panic!("expected Command mechanism"),
157        }
158    }
159
160    // ── netplan_get_config ───────────────────────────────────────────────────
161
162    #[test]
163    fn netplan_get_config_action_name() {
164        assert_eq!(netplan_get_config().action_name, "NetplanGetConfig");
165    }
166
167    #[test]
168    fn netplan_get_config_risk_low() {
169        assert_eq!(netplan_get_config().risk_level, RiskLevel::Low);
170    }
171
172    #[test]
173    fn netplan_get_config_no_reboot() {
174        assert!(!netplan_get_config().reboot_required);
175    }
176
177    #[test]
178    fn netplan_get_config_uses_find_directly() {
179        // Regression: previously shelled out to `bash -c "cat ... 2>/dev/null
180        // || echo 'no netplan files found'"`, which collapsed three distinct
181        // error states into one fake-success exit 0. Now invokes `find`
182        // directly so missing-dir vs no-files vs permission-denied are
183        // distinguishable.
184        let spec = netplan_get_config();
185        let (prog, args) = extract_cmd(&spec);
186        assert_eq!(prog, "find");
187        // Must reference /etc/netplan and the *.yaml glob; no shell in argv.
188        assert!(!args.contains(&"-c"));
189        assert!(args.contains(&"/etc/netplan"));
190        assert!(args.contains(&"*.yaml"));
191    }
192
193    // ── netplan_apply ────────────────────────────────────────────────────────
194
195    #[test]
196    fn netplan_apply_action_name() {
197        assert_eq!(netplan_apply().action_name, "NetplanApply");
198    }
199
200    #[test]
201    fn netplan_apply_risk_high() {
202        assert_eq!(netplan_apply().risk_level, RiskLevel::High);
203    }
204
205    #[test]
206    fn netplan_apply_no_reboot() {
207        // netplan apply takes effect immediately; no reboot is required.
208        assert!(!netplan_apply().reboot_required);
209    }
210
211    #[test]
212    fn netplan_apply_argv() {
213        let spec = netplan_apply();
214        let (prog, args) = extract_cmd(&spec);
215        assert_eq!(prog, "sudo");
216        assert!(args.contains(&"netplan"));
217        assert!(args.contains(&"apply"));
218    }
219
220    // ── netplan_set ──────────────────────────────────────────────────────────
221
222    #[test]
223    fn netplan_set_action_name() {
224        assert_eq!(
225            netplan_set("ethernets.eth0.dhcp4", "true").action_name,
226            "NetplanSet"
227        );
228    }
229
230    #[test]
231    fn netplan_set_risk_high() {
232        assert_eq!(
233            netplan_set("ethernets.eth0.dhcp4", "true").risk_level,
234            RiskLevel::High
235        );
236    }
237
238    #[test]
239    fn netplan_set_no_automatic_rollback() {
240        assert!(!netplan_set("ethernets.eth0.dhcp4", "true").rollback_available);
241    }
242
243    #[test]
244    fn netplan_set_no_reboot() {
245        assert!(!netplan_set("ethernets.eth0.dhcp4", "true").reboot_required);
246    }
247
248    #[test]
249    fn netplan_set_argv_simple_value() {
250        let spec = netplan_set("ethernets.eth0.dhcp4", "true");
251        let (prog, args) = extract_cmd(&spec);
252        assert_eq!(prog, "sudo");
253        assert!(args.contains(&"netplan"));
254        assert!(args.contains(&"set"));
255        // key=value appears as a single argument
256        assert!(
257            args.contains(&"ethernets.eth0.dhcp4=true"),
258            "expected key=value arg, got: {:?}",
259            args
260        );
261    }
262
263    #[test]
264    fn netplan_set_argv_never_contains_literal_quote_bytes() {
265        // Regression test: an earlier version wrapped values containing spaces
266        // in literal single-quotes, which (because there's no shell in the
267        // execve path) made netplan see the quotes as part of the value.
268        // Now `validated_safe_arg` at the executor rejects spaces in `value`,
269        // so the kv string never carries quote characters.
270        let spec = netplan_set("renderer", "NetworkManager");
271        let (_, args) = extract_cmd(&spec);
272        for a in &args {
273            assert!(
274                !a.contains('\''),
275                "argv must not contain literal '\\'' bytes"
276            );
277        }
278    }
279
280    // ── netplan_generate ─────────────────────────────────────────────────────
281
282    #[test]
283    fn netplan_generate_action_name() {
284        assert_eq!(netplan_generate().action_name, "NetplanGenerate");
285    }
286
287    #[test]
288    fn netplan_generate_risk_medium() {
289        assert_eq!(netplan_generate().risk_level, RiskLevel::Medium);
290    }
291
292    #[test]
293    fn netplan_generate_no_reboot() {
294        assert!(!netplan_generate().reboot_required);
295    }
296
297    #[test]
298    fn netplan_generate_no_rollback() {
299        assert!(!netplan_generate().rollback_available);
300    }
301
302    #[test]
303    fn netplan_generate_argv() {
304        let spec = netplan_generate();
305        let (prog, args) = extract_cmd(&spec);
306        assert_eq!(prog, "sudo");
307        assert!(args.contains(&"netplan"));
308        assert!(args.contains(&"generate"));
309    }
310
311    // ── specs() completeness ─────────────────────────────────────────────────
312
313    #[test]
314    fn specs_covers_all_action_names() {
315        let expected = [
316            "NetplanGetConfig",
317            "NetplanApply",
318            "NetplanSet",
319            "NetplanGenerate",
320        ];
321        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
322        for name in &expected {
323            assert!(spec_names.contains(name), "specs() missing {name}");
324        }
325    }
326}