horto_os_ui_shared/steps/
s5_apply.rs1use crate::context::{HostContext, PlannedAction};
5use crate::error::{HortoError, Result};
6use crate::kits::{fs, systemd};
7use crate::step::Step;
8use walkdir::WalkDir;
9
10pub 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 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}