Skip to main content

horto_os_ui_shared/steps/
s5_apply.rs

1//! Apply staged configs from `active_setup/etc` onto host `/etc` (`s5`).
2//!
3//! reference: horto-os/scripts/s5_apply_configs.sh
4use crate::context::{HostContext, PlannedAction};
5use crate::error::{HortoError, Result};
6use crate::kits::{fs, systemd};
7use crate::step::Step;
8use walkdir::WalkDir;
9
10/// Copy staged files into [`HostContext`] `/etc` and disable `systemd-resolved`.
11pub struct S5Apply;
12
13impl Step for S5Apply {
14    fn id(&self) -> &'static str {
15        "s5"
16    }
17    fn title(&self) -> &'static str {
18        "Apply staged configs to /etc"
19    }
20    fn reference_script(&self) -> &'static str {
21        "s5_apply_configs.sh"
22    }
23    fn step_version(&self) -> u32 {
24        1
25    }
26    fn depends_on(&self) -> &'static [&'static str] {
27        &["s4"]
28    }
29    fn needs_reboot_after(&self) -> bool {
30        true
31    }
32    fn destructive(&self) -> bool {
33        true
34    }
35    fn is_done(&self, ctx: &HostContext) -> bool {
36        // Heuristic: hostname file exists under /etc and staging exists.
37        ctx.paths.etc.join("hostname").exists() && ctx.paths.staging_etc().is_dir()
38    }
39    fn plan(&self, ctx: &mut HostContext) -> Result<Vec<PlannedAction>> {
40        if !ctx.paths.full_env_file().exists() && !ctx.is_dry_run() {
41            ctx.plan_action("skip s5 (IoT-LAN / full env not present)");
42            return Ok(ctx.planned.clone());
43        }
44        ctx.plan_action(format!(
45            "copy {}/* -> {}/",
46            ctx.paths.staging_etc().display(),
47            ctx.paths.etc.display()
48        ));
49        ctx.plan_action("handle resolv.conf symlink specially");
50        ctx.plan_action("systemctl stop/disable systemd-resolved");
51        Ok(ctx.planned.clone())
52    }
53    fn apply(&self, ctx: &mut HostContext) -> Result<()> {
54        if !ctx.paths.full_env_file().exists() {
55            if ctx.is_dry_run() {
56                self.plan(ctx)?;
57                return Ok(());
58            }
59            ctx.log("s5 only needed for IoT-LAN (full) setup; skipping");
60            return Ok(());
61        }
62        let staging = ctx.paths.staging_etc();
63        if !staging.is_dir() && !ctx.is_dry_run() {
64            return Err(HortoError::msg(format!(
65                "staging directory not found: {}",
66                staging.display()
67            )));
68        }
69        if ctx.is_dry_run() {
70            self.plan(ctx)?;
71            return Ok(());
72        }
73        for entry in WalkDir::new(&staging)
74            .into_iter()
75            .filter_map(std::result::Result::ok)
76        {
77            if !entry.file_type().is_file() {
78                continue;
79            }
80            let staged = entry.path();
81            let rel = staged
82                .strip_prefix(&staging)
83                .unwrap_or(staged)
84                .to_path_buf();
85            let target = ctx.paths.etc.join(&rel);
86            if rel.as_os_str() == "resolv.conf" && (target.is_symlink() || target.exists()) {
87                ctx.log("Removing existing resolv.conf before apply...");
88                fs::remove_path(ctx, &target)?;
89            }
90            fs::copy_file(ctx, staged, &target)?;
91            let mode = file_mode_for_staged(&rel);
92            fs::chmod(ctx, &target, mode)?;
93            ctx.log(format!(
94                "Applied file: {} -> {}",
95                staged.display(),
96                target.display()
97            ));
98        }
99        if crate::context::is_root() {
100            systemd::try_stop(ctx, "systemd-resolved");
101            systemd::try_disable(ctx, "systemd-resolved");
102        } else {
103            ctx.log("Not root; skipping systemd-resolved stop/disable");
104        }
105        ctx.log("Step s5 complete: staged configuration applied. Reboot recommended for hostname.");
106        Ok(())
107    }
108}
109
110fn file_mode_for_staged(rel: &std::path::Path) -> u32 {
111    if rel.starts_with("netplan") {
112        0o640
113    } else {
114        0o644
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::path::Path;
122
123    #[test]
124    fn netplan_files_get_mode_640() {
125        assert_eq!(
126            file_mode_for_staged(Path::new("netplan/99-iot.yaml")),
127            0o640
128        );
129        assert_eq!(file_mode_for_staged(Path::new("hostname")), 0o644);
130        assert_eq!(file_mode_for_staged(Path::new("dnsmasq.d/iot.conf")), 0o644);
131    }
132}