horto_os_ui_shared/steps/
d0_docker_engine.rs1use crate::context::{is_root, HostContext, PlannedAction};
7use crate::error::{HortoError, Result};
8use crate::kits::{apt, docker};
9use crate::step::Step;
10use std::process::Command;
11
12pub struct D0DockerEngine;
14
15const APT_PACKAGES: &[&str] = &["docker.io", "docker-compose-v2"];
16
17impl Step for D0DockerEngine {
18 fn id(&self) -> &'static str {
19 "d0"
20 }
21 fn title(&self) -> &'static str {
22 "Install Docker Engine"
23 }
24 fn reference_script(&self) -> &'static str {
25 "d0_docker_engine.sh"
26 }
27 fn step_version(&self) -> u32 {
28 1
29 }
30 fn depends_on(&self) -> &'static [&'static str] {
31 &[]
32 }
33 fn is_done(&self, _ctx: &HostContext) -> bool {
34 docker_engine_ready()
35 }
36 fn plan(&self, ctx: &mut HostContext) -> Result<Vec<PlannedAction>> {
37 plan_install_actions(ctx);
38 Ok(ctx.planned.clone())
39 }
40 fn apply(&self, ctx: &mut HostContext) -> Result<()> {
41 if docker_engine_ready() {
42 ctx.log("Docker Engine already available.");
43 return Ok(());
44 }
45 if ctx.is_dry_run() {
46 plan_install_actions(ctx);
47 return Ok(());
48 }
49 if !is_root() {
50 return Err(HortoError::msg(
51 "d0 requires root to install Docker Engine (or install Docker first)",
52 ));
53 }
54 install_docker_engine(ctx)?;
55 finish_after_install(ctx)
56 }
57}
58
59#[must_use]
61pub fn docker_engine_ready() -> bool {
62 #[cfg(test)]
63 {
64 if let Some(v) = ready_override() {
65 return v;
66 }
67 }
68 if !docker::docker_available() {
69 return false;
70 }
71 Command::new("docker")
72 .arg("info")
73 .output()
74 .is_ok_and(|o| o.status.success())
75}
76
77fn plan_install_actions(ctx: &mut HostContext) {
78 if docker_engine_ready() {
79 ctx.plan_action("docker engine already available (docker info ok)");
80 } else if prefer_armbian_config() {
81 ctx.plan_action("armbian-config --cmd CON001 (install Docker Engine)");
82 } else {
83 plan_apt_install(ctx);
84 }
85}
86
87fn prefer_armbian_config() -> bool {
88 #[cfg(test)]
89 {
90 if let Some(v) = armbian_present_override() {
91 return v;
92 }
93 }
94 which::which("armbian-config").is_ok()
95}
96
97fn plan_apt_install(ctx: &mut HostContext) {
98 ctx.plan_action("apt update");
99 ctx.plan_action(format!("apt install -y {}", APT_PACKAGES.join(" ")));
100 ctx.plan_action("systemctl enable --now docker");
101}
102
103fn install_docker_engine(ctx: &mut HostContext) -> Result<()> {
104 if ctx.is_dry_run() {
105 plan_install_actions(ctx);
106 return Ok(());
107 }
108 if prefer_armbian_config() {
109 if try_armbian_con001(ctx)? {
110 return Ok(());
111 }
112 ctx.log("armbian-config CON001 did not leave docker ready; falling back to apt packages");
113 }
114 apt_install_docker(ctx)?;
115 enable_docker_service(ctx)?;
116 Ok(())
117}
118
119fn try_armbian_con001(ctx: &mut HostContext) -> Result<bool> {
120 ctx.log("armbian-config --cmd CON001");
121 #[cfg(test)]
122 {
123 if let Some(ok) = armbian_result_override() {
124 return Ok(ok && docker_engine_ready());
125 }
126 }
127 let status = Command::new("armbian-config")
128 .args(["--cmd", "CON001"])
129 .status()
130 .map_err(|e| HortoError::command("armbian-config", e.to_string()))?;
131 Ok(status.success() && docker_engine_ready())
132}
133
134fn apt_install_docker(ctx: &mut HostContext) -> Result<()> {
135 #[cfg(test)]
136 {
137 if skip_real_apt() {
138 ctx.log("apt update");
139 ctx.log(format!("apt install -y {}", APT_PACKAGES.join(" ")));
140 return Ok(());
141 }
142 }
143 apt::apt_update(ctx)?;
144 apt::apt_install(ctx, APT_PACKAGES)?;
145 Ok(())
146}
147
148fn enable_docker_service(ctx: &mut HostContext) -> Result<()> {
149 if ctx.is_dry_run() {
150 ctx.plan_action("systemctl enable --now docker");
151 return Ok(());
152 }
153 ctx.log("systemctl enable --now docker");
154 #[cfg(test)]
155 {
156 if skip_real_systemctl() {
157 return Ok(());
158 }
159 }
160 let status = Command::new("systemctl")
161 .args(["enable", "--now", "docker"])
162 .status()
163 .map_err(|e| HortoError::command("systemctl", e.to_string()))?;
164 if !status.success() {
165 return Err(HortoError::command(
166 "systemctl",
167 format!("enable --now docker -> {status}"),
168 ));
169 }
170 Ok(())
171}
172
173fn finish_after_install(ctx: &mut HostContext) -> Result<()> {
174 if !docker_engine_ready() {
175 return Err(HortoError::msg(
176 "Docker Engine still missing after install. On Armbian run: sudo armbian-config --cmd CON001",
177 ));
178 }
179 ctx.log("Step d0 complete: Docker Engine ready.");
180 Ok(())
181}
182
183#[cfg(test)]
184thread_local! {
185 static READY_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
186 static ARMBIAN_PRESENT: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
187 static ARMBIAN_RESULT: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
188 static SKIP_APT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
189 static SKIP_SYSTEMCTL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
190}
191
192#[cfg(test)]
193fn ready_override() -> Option<bool> {
194 READY_OVERRIDE.with(std::cell::Cell::get)
195}
196
197#[cfg(test)]
198fn armbian_present_override() -> Option<bool> {
199 ARMBIAN_PRESENT.with(std::cell::Cell::get)
200}
201
202#[cfg(test)]
203fn armbian_result_override() -> Option<bool> {
204 ARMBIAN_RESULT.with(std::cell::Cell::get)
205}
206
207#[cfg(test)]
208fn skip_real_apt() -> bool {
209 SKIP_APT.with(std::cell::Cell::get)
210}
211
212#[cfg(test)]
213fn skip_real_systemctl() -> bool {
214 SKIP_SYSTEMCTL.with(std::cell::Cell::get)
215}
216
217#[cfg(test)]
218pub(crate) fn reset_test_hooks() {
220 READY_OVERRIDE.with(|c| c.set(None));
221 ARMBIAN_PRESENT.with(|c| c.set(None));
222 ARMBIAN_RESULT.with(|c| c.set(None));
223 SKIP_APT.with(|c| c.set(false));
224 SKIP_SYSTEMCTL.with(|c| c.set(false));
225}
226
227#[cfg(test)]
228pub(crate) fn set_ready_override(value: Option<bool>) {
230 READY_OVERRIDE.with(|c| c.set(value));
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::context::ApplyMode;
237 use crate::pipeline::SetupKind;
238
239 #[test]
240 fn metadata_is_stable() {
241 let step = D0DockerEngine;
242 assert_eq!(step.id(), "d0");
243 assert_eq!(step.reference_script(), "d0_docker_engine.sh");
244 assert_eq!(step.step_version(), 1);
245 assert_eq!(step.depends_on(), &[] as &[&str]);
246 assert_ne!(step.title(), "");
247 }
248
249 #[test]
250 fn dry_run_when_ready_logs_available() {
251 reset_test_hooks();
252 READY_OVERRIDE.with(|c| c.set(Some(true)));
253 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
254 D0DockerEngine.apply(&mut ctx).unwrap();
255 assert!(ctx.logs.iter().any(|l| l.contains("already available")));
256 assert!(D0DockerEngine.is_done(&ctx));
257 reset_test_hooks();
258 }
259
260 #[test]
261 fn dry_run_plans_armbian_when_present() {
262 reset_test_hooks();
263 READY_OVERRIDE.with(|c| c.set(Some(false)));
264 ARMBIAN_PRESENT.with(|c| c.set(Some(true)));
265 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
266 D0DockerEngine.apply(&mut ctx).unwrap();
267 assert!(ctx
268 .planned
269 .iter()
270 .any(|p| p.summary.contains("armbian-config")));
271 reset_test_hooks();
272 }
273
274 #[test]
275 fn dry_run_plans_apt_without_armbian() {
276 reset_test_hooks();
277 READY_OVERRIDE.with(|c| c.set(Some(false)));
278 ARMBIAN_PRESENT.with(|c| c.set(Some(false)));
279 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
280 let planned = D0DockerEngine.plan(&mut ctx).unwrap();
281 assert!(planned.iter().any(|p| p.summary.contains("apt update")));
282 assert!(planned.iter().any(|p| p.summary.contains("docker.io")));
283 assert!(planned
284 .iter()
285 .any(|p| p.summary.contains("systemctl enable")));
286 reset_test_hooks();
287 }
288
289 #[test]
290 fn apply_requires_root_when_engine_missing() {
291 reset_test_hooks();
292 READY_OVERRIDE.with(|c| c.set(Some(false)));
293 if is_root() {
294 reset_test_hooks();
295 return;
296 }
297 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
298 let err = D0DockerEngine.apply(&mut ctx).unwrap_err();
299 assert!(err.to_string().contains("requires root"));
300 reset_test_hooks();
301 }
302
303 #[test]
304 fn finish_after_install_ok_and_err() {
305 reset_test_hooks();
306 READY_OVERRIDE.with(|c| c.set(Some(false)));
307 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
308 let err = finish_after_install(&mut ctx).unwrap_err();
309 assert!(err.to_string().contains("still missing"));
310
311 READY_OVERRIDE.with(|c| c.set(Some(true)));
312 finish_after_install(&mut ctx).unwrap();
313 assert!(ctx.logs.iter().any(|l| l.contains("d0 complete")));
314 reset_test_hooks();
315 }
316
317 #[test]
318 fn install_armbian_success_short_circuits() {
319 reset_test_hooks();
320 READY_OVERRIDE.with(|c| c.set(Some(true)));
321 ARMBIAN_PRESENT.with(|c| c.set(Some(true)));
322 ARMBIAN_RESULT.with(|c| c.set(Some(true)));
323 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
324 install_docker_engine(&mut ctx).unwrap();
325 assert!(ctx.logs.iter().any(|l| l.contains("armbian-config")));
326 reset_test_hooks();
327 }
328
329 #[test]
330 fn install_falls_back_to_apt_after_armbian_miss() {
331 reset_test_hooks();
332 READY_OVERRIDE.with(|c| c.set(Some(false)));
333 ARMBIAN_PRESENT.with(|c| c.set(Some(true)));
334 ARMBIAN_RESULT.with(|c| c.set(Some(false)));
335 SKIP_APT.with(|c| c.set(true));
336 SKIP_SYSTEMCTL.with(|c| c.set(true));
337 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
338 install_docker_engine(&mut ctx).unwrap();
339 assert!(ctx.logs.iter().any(|l| l.contains("falling back to apt")));
340 assert!(ctx.logs.iter().any(|l| l.contains("apt install")));
341 assert!(ctx.logs.iter().any(|l| l.contains("systemctl enable")));
342 reset_test_hooks();
343 }
344
345 #[test]
346 fn install_apt_path_without_armbian() {
347 reset_test_hooks();
348 READY_OVERRIDE.with(|c| c.set(Some(false)));
349 ARMBIAN_PRESENT.with(|c| c.set(Some(false)));
350 SKIP_APT.with(|c| c.set(true));
351 SKIP_SYSTEMCTL.with(|c| c.set(true));
352 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
353 install_docker_engine(&mut ctx).unwrap();
354 assert!(ctx.logs.iter().any(|l| l.contains("apt install")));
355 reset_test_hooks();
356 }
357
358 #[test]
359 fn enable_docker_service_dry_run_plans() {
360 reset_test_hooks();
361 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
362 enable_docker_service(&mut ctx).unwrap();
363 assert!(ctx
364 .planned
365 .iter()
366 .any(|p| p.summary.contains("systemctl enable")));
367 }
368
369 #[test]
370 fn apply_full_path_with_hooks_when_root() {
371 reset_test_hooks();
372 if !is_root() {
373 return;
374 }
375 READY_OVERRIDE.with(|c| c.set(Some(false)));
376 ARMBIAN_PRESENT.with(|c| c.set(Some(false)));
377 SKIP_APT.with(|c| c.set(true));
378 SKIP_SYSTEMCTL.with(|c| c.set(true));
379 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
380 let err = D0DockerEngine.apply(&mut ctx).unwrap_err();
382 assert!(err.to_string().contains("still missing"));
383 reset_test_hooks();
384 }
385}