Skip to main content

horto_os_ui_shared/steps/
s6_validate.rs

1//! reference: horto-os/scripts/s6_validate_configs.sh
2use 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
9/// Validate applied configs and enable required services (`s6`).
10pub 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        // `netplan generate` always reads the host /etc/netplan, not ctx.paths.etc.
79        // Skip when testing against a temp tree, or when not root (CI / dry hosts).
80        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        // hosts still has an unreplaced placeholder => check_no_ph returns false.
365        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}