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}