horto_os_ui_shared/steps/
s6_validate.rs1use crate::context::{HostContext, PlannedAction};
3use crate::error::{HortoError, Result};
4use crate::kits::{envfile, systemd, template};
5use crate::step::Step;
6use std::fs;
7use std::process::Command;
8
9pub struct S6Validate;
11
12impl Step for S6Validate {
13 fn id(&self) -> &'static str {
14 "s6"
15 }
16 fn title(&self) -> &'static str {
17 "Validate applied configs and enable services"
18 }
19 fn reference_script(&self) -> &'static str {
20 "s6_validate_configs.sh"
21 }
22 fn step_version(&self) -> u32 {
23 2
24 }
25 fn depends_on(&self) -> &'static [&'static str] {
26 &["s5"]
27 }
28 fn is_done(&self, ctx: &HostContext) -> bool {
29 let etc = &ctx.paths.etc;
30 if !etc.join("netplan/99-iot-lan.yaml").exists() {
31 return false;
32 }
33 if wifi_ap_from_ctx(ctx) {
34 return etc.join("hostapd/hostapd.conf").exists();
35 }
36 true
37 }
38 fn plan(&self, ctx: &mut HostContext) -> Result<Vec<PlannedAction>> {
39 ctx.plan_action("validate /etc hosts, netplan, avahi, resolv, dnsmasq, sysctl");
40 ctx.plan_action("netplan generate");
41 if wifi_ap_from_ctx(ctx) {
42 ctx.plan_action("validate hostapd and systemctl unmask/enable/start hostapd");
43 } else {
44 ctx.plan_action("skip hostapd (WIFI_INTERFACE=none)");
45 }
46 Ok(ctx.planned.clone())
47 }
48 fn apply(&self, ctx: &mut HostContext) -> Result<()> {
49 if !ctx.paths.full_env_file().exists() {
50 if ctx.is_dry_run() {
51 self.plan(ctx)?;
52 return Ok(());
53 }
54 ctx.log("s6 only needed for IoT-LAN (full) setup; skipping");
55 return Ok(());
56 }
57 if ctx.is_dry_run() {
58 self.plan(ctx)?;
59 return Ok(());
60 }
61 let wifi = wifi_ap_from_ctx(ctx);
62 let etc = ctx.paths.etc.clone();
63 let mut failed = false;
64 failed |= !check_exists(ctx, &etc.join("hosts"));
65 failed |= !check_no_ph(ctx, &etc.join("hosts"));
66 failed |= !check_exists(ctx, &etc.join("netplan/99-iot-lan.yaml"));
67 failed |= !check_no_ph(ctx, &etc.join("netplan/99-iot-lan.yaml"));
68 if wifi {
69 failed |= !check_exists(ctx, &etc.join("hostapd/hostapd.conf"));
70 failed |= !check_no_ph(ctx, &etc.join("hostapd/hostapd.conf"));
71 }
72 failed |= !check_exists(ctx, &etc.join("avahi/avahi-daemon.conf"));
73 failed |= !check_exists(ctx, &etc.join("avahi/hosts"));
74 failed |= !check_exists(ctx, &etc.join("resolv.conf"));
75 failed |= !check_exists(ctx, &etc.join("dnsmasq.d/iot-lan.conf"));
76 failed |= !check_exists(ctx, &etc.join("sysctl.d/packet_forwarding.conf"));
77
78 if which::which("netplan").is_ok() {
81 if etc != std::path::Path::new("/etc") {
82 ctx.log("WARNING: etc is not /etc; skipping netplan generate");
83 } else if !crate::context::is_root() {
84 ctx.log("WARNING: not root; skipping netplan generate");
85 } else {
86 let status = Command::new("netplan").arg("generate").status();
87 match status {
88 Ok(s) if s.success() => ctx.log("OK: netplan generate succeeded"),
89 Ok(_) => {
90 ctx.log("ERROR: netplan generate failed");
91 failed = true;
92 }
93 Err(e) => {
94 ctx.log(format!("ERROR: netplan generate: {e}"));
95 failed = true;
96 }
97 }
98 }
99 } else {
100 ctx.log("WARNING: netplan command not found; skipping netplan validation");
101 }
102
103 if failed {
104 return Err(HortoError::msg(
105 "Step s6 failed: configuration validation found errors",
106 ));
107 }
108
109 if wifi {
110 ctx.log("Enabling hostapd...");
111 if crate::context::is_root() {
112 systemd::try_unmask(ctx, "hostapd");
113 systemd::try_enable(ctx, "hostapd");
114 systemd::try_start(ctx, "hostapd");
115 } else {
116 ctx.log("Not root; skip hostapd unmask/enable/start (avoids polkit prompts).");
117 }
118 } else {
119 ctx.log("WIFI_INTERFACE=none; skipping hostapd enable");
120 }
121 ctx.log("Step s6 complete: configuration validation passed.");
122 Ok(())
123 }
124}
125
126fn wifi_ap_from_ctx(ctx: &HostContext) -> bool {
127 envfile::load(&ctx.paths.full_env_file()).is_ok_and(|m| envfile::wifi_ap_enabled(&m))
128}
129
130fn check_exists(ctx: &mut HostContext, path: &std::path::Path) -> bool {
131 if path.is_file() {
132 ctx.log(format!("OK: file exists: {}", path.display()));
133 true
134 } else {
135 ctx.log(format!("ERROR: missing file: {}", path.display()));
136 false
137 }
138}
139
140fn check_no_ph(ctx: &mut HostContext, path: &std::path::Path) -> bool {
141 let Ok(text) = fs::read_to_string(path) else {
142 ctx.log(format!(
143 "ERROR: cannot validate missing file: {}",
144 path.display()
145 ));
146 return false;
147 };
148 if template::has_unreplaced_placeholders(&text) {
149 ctx.log(format!(
150 "ERROR: unreplaced placeholder found in {}",
151 path.display()
152 ));
153 false
154 } else {
155 ctx.log(format!("OK: no placeholders remain in {}", path.display()));
156 true
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::context::{ApplyMode, NonInteractivePrompts};
164 use crate::kits::envfile;
165 use crate::paths::HostPaths;
166 use crate::pipeline::SetupKind;
167 use std::collections::BTreeMap;
168 use tempfile::TempDir;
169
170 fn temp_paths(root: &std::path::Path) -> HostPaths {
171 HostPaths {
172 active_setup: root.join("active_setup"),
173 backup: root.join("backup"),
174 docker: root.join("docker"),
175 etc: root.join("etc"),
176 lease_file: root.join("leases"),
177 }
178 }
179
180 fn seed_full_env(paths: &HostPaths) {
181 std::fs::create_dir_all(&paths.active_setup).unwrap();
182 let mut map = BTreeMap::new();
183 map.insert("MY_HOSTNAME".into(), "cov-box".into());
184 map.insert("WIFI_INTERFACE".into(), "wlan0".into());
185 map.insert("WIFI_SSID".into(), "Horto-IoT-LAN".into());
186 map.insert("ETH_LAN".into(), "wan".into());
187 map.insert("ETH_IOT1".into(), "lan1".into());
188 envfile::write(&paths.full_env_file(), &map).unwrap();
189 }
190
191 fn seed_ethernet_only_env(paths: &HostPaths) {
192 std::fs::create_dir_all(&paths.active_setup).unwrap();
193 let mut map = BTreeMap::new();
194 map.insert("MY_HOSTNAME".into(), "cov-box".into());
195 map.insert("WIFI_INTERFACE".into(), "none".into());
196 map.insert("ETH_LAN".into(), "wan".into());
197 map.insert("ETH_IOT1".into(), "lan1".into());
198 envfile::write(&paths.full_env_file(), &map).unwrap();
199 }
200
201 #[test]
202 fn trait_metadata_is_stable() {
203 let step = S6Validate;
204 assert_eq!(step.id(), "s6");
205 assert_eq!(step.reference_script(), "s6_validate_configs.sh");
206 assert_eq!(step.step_version(), 2);
207 assert_eq!(step.depends_on(), &["s5"]);
208 assert_ne!(step.title(), "");
209 assert!(!step.needs_reboot_after());
210 assert!(!step.destructive());
211 }
212
213 #[test]
214 fn is_done_requires_hostapd_when_wifi_enabled() {
215 let tmp = TempDir::new().unwrap();
216 let paths = temp_paths(tmp.path());
217 seed_full_env(&paths);
218 std::fs::create_dir_all(paths.etc.join("hostapd")).unwrap();
219 std::fs::create_dir_all(paths.etc.join("netplan")).unwrap();
220 let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
221 assert!(!S6Validate.is_done(&ctx));
222
223 std::fs::write(
224 ctx.paths.etc.join("hostapd/hostapd.conf"),
225 b"interface=wlan0\n",
226 )
227 .unwrap();
228 std::fs::write(
229 ctx.paths.etc.join("netplan/99-iot-lan.yaml"),
230 b"network: {version: 2}\n",
231 )
232 .unwrap();
233 assert!(S6Validate.is_done(&ctx));
234 }
235
236 #[test]
237 fn is_done_allows_ethernet_only_without_hostapd() {
238 let tmp = TempDir::new().unwrap();
239 let paths = temp_paths(tmp.path());
240 seed_ethernet_only_env(&paths);
241 std::fs::create_dir_all(paths.etc.join("netplan")).unwrap();
242 let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
243 assert!(!S6Validate.is_done(&ctx));
244 std::fs::write(
245 ctx.paths.etc.join("netplan/99-iot-lan.yaml"),
246 b"network: {version: 2}\n",
247 )
248 .unwrap();
249 assert!(S6Validate.is_done(&ctx));
250 }
251
252 #[test]
253 fn plan_records_validate_and_hostapd_actions() {
254 let tmp = TempDir::new().unwrap();
255 let paths = temp_paths(tmp.path());
256 seed_full_env(&paths);
257 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
258 let planned = S6Validate.plan(&mut ctx).unwrap();
259 assert!(planned.iter().any(|p| p.summary.contains("validate /etc")));
260 assert!(planned
261 .iter()
262 .any(|p| p.summary.contains("netplan generate")));
263 assert!(planned.iter().any(|p| p.summary.contains("hostapd")));
264 }
265
266 #[test]
267 fn plan_skips_hostapd_when_wifi_none() {
268 let tmp = TempDir::new().unwrap();
269 let paths = temp_paths(tmp.path());
270 seed_ethernet_only_env(&paths);
271 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
272 let planned = S6Validate.plan(&mut ctx).unwrap();
273 assert!(planned.iter().any(|p| p.summary.contains("skip hostapd")));
274 }
275
276 #[test]
277 fn apply_dry_run_without_full_env_calls_plan() {
278 let tmp = TempDir::new().unwrap();
279 let mut ctx =
280 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
281 S6Validate.apply(&mut ctx).unwrap();
282 assert!(ctx
283 .planned
284 .iter()
285 .any(|p| p.summary.contains("validate /etc")));
286 }
287
288 #[test]
289 fn apply_dry_run_with_full_env_calls_plan() {
290 let tmp = TempDir::new().unwrap();
291 let paths = temp_paths(tmp.path());
292 seed_full_env(&paths);
293 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
294 S6Validate.apply(&mut ctx).unwrap();
295 assert!(ctx
296 .planned
297 .iter()
298 .any(|p| p.summary.contains("validate /etc")));
299 }
300
301 #[test]
302 fn apply_mode_skips_when_full_env_missing() {
303 let tmp = TempDir::new().unwrap();
304 let mut ctx =
305 HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(temp_paths(tmp.path()));
306 S6Validate.apply(&mut ctx).unwrap();
307 assert!(ctx.logs.iter().any(|l| l.contains("skipping")));
308 }
309
310 #[test]
311 fn apply_mode_fails_when_expected_files_missing() {
312 let tmp = TempDir::new().unwrap();
313 let paths = temp_paths(tmp.path());
314 seed_full_env(&paths);
315 std::fs::create_dir_all(&paths.etc).unwrap();
316 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
317 .with_paths(paths)
318 .with_prompts(Box::new(NonInteractivePrompts));
319 let err = S6Validate.apply(&mut ctx).unwrap_err();
320 assert!(err.to_string().contains("validation"));
321 assert!(ctx.logs.iter().any(|l| l.contains("ERROR: missing")));
322 }
323
324 #[test]
325 fn apply_mode_ethernet_only_skips_hostapd_enable() {
326 let tmp = TempDir::new().unwrap();
327 let paths = temp_paths(tmp.path());
328 seed_ethernet_only_env(&paths);
329 for dir in ["netplan", "avahi", "dnsmasq.d", "sysctl.d"] {
330 std::fs::create_dir_all(paths.etc.join(dir)).unwrap();
331 }
332 std::fs::write(paths.etc.join("hosts"), b"127.0.0.1 localhost\n").unwrap();
333 std::fs::write(
334 paths.etc.join("netplan/99-iot-lan.yaml"),
335 b"network: {version: 2}\n",
336 )
337 .unwrap();
338 std::fs::write(paths.etc.join("avahi/avahi-daemon.conf"), b"[server]\n").unwrap();
339 std::fs::write(paths.etc.join("avahi/hosts"), b"\n").unwrap();
340 std::fs::write(paths.etc.join("resolv.conf"), b"nameserver 1.1.1.1\n").unwrap();
341 std::fs::write(paths.etc.join("dnsmasq.d/iot-lan.conf"), b"# ok\n").unwrap();
342 std::fs::write(
343 paths.etc.join("sysctl.d/packet_forwarding.conf"),
344 b"net.ipv4.ip_forward=1\n",
345 )
346 .unwrap();
347 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
348 S6Validate.apply(&mut ctx).unwrap();
349 assert!(ctx
350 .logs
351 .iter()
352 .any(|l| l.contains("WIFI_INTERFACE=none; skipping hostapd enable")));
353 assert!(!ctx.paths.etc.join("hostapd/hostapd.conf").exists());
354 }
355
356 #[test]
357 fn apply_mode_fails_when_placeholder_remains() {
358 let tmp = TempDir::new().unwrap();
359 let paths = temp_paths(tmp.path());
360 seed_full_env(&paths);
361 for dir in ["netplan", "hostapd", "avahi", "dnsmasq.d", "sysctl.d"] {
362 std::fs::create_dir_all(paths.etc.join(dir)).unwrap();
363 }
364 std::fs::write(paths.etc.join("hosts"), b"127.0.0.1 {{MY_HOSTNAME}}\n").unwrap();
366 std::fs::write(
367 paths.etc.join("netplan/99-iot-lan.yaml"),
368 b"network: {version: 2}\n",
369 )
370 .unwrap();
371 std::fs::write(paths.etc.join("hostapd/hostapd.conf"), b"interface=wlan0\n").unwrap();
372 std::fs::write(paths.etc.join("avahi/avahi-daemon.conf"), b"[server]\n").unwrap();
373 std::fs::write(paths.etc.join("avahi/hosts"), b"\n").unwrap();
374 std::fs::write(paths.etc.join("resolv.conf"), b"nameserver 1.1.1.1\n").unwrap();
375 std::fs::write(paths.etc.join("dnsmasq.d/iot-lan.conf"), b"# ok\n").unwrap();
376 std::fs::write(
377 paths.etc.join("sysctl.d/packet_forwarding.conf"),
378 b"net.ipv4.ip_forward=1\n",
379 )
380 .unwrap();
381 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
382 let err = S6Validate.apply(&mut ctx).unwrap_err();
383 assert!(err.to_string().contains("validation"));
384 assert!(ctx.logs.iter().any(|l| l.contains("placeholder")));
385 }
386
387 #[test]
388 fn check_no_ph_reports_missing_and_placeholders() {
389 let tmp = TempDir::new().unwrap();
390 let mut ctx =
391 HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(temp_paths(tmp.path()));
392 assert!(!check_no_ph(&mut ctx, &tmp.path().join("missing")));
393
394 let good = tmp.path().join("good.txt");
395 std::fs::write(&good, b"nameserver 1.1.1.1\n").unwrap();
396 assert!(check_no_ph(&mut ctx, &good));
397
398 let bad = tmp.path().join("bad.txt");
399 std::fs::write(&bad, b"{{PLACEHOLDER}} still here\n").unwrap();
400 assert!(!check_no_ph(&mut ctx, &bad));
401 }
402}