Skip to main content

horto_os_ui_shared/steps/
d0_docker_engine.rs

1//! Ensure Docker Engine is installed before stack files (d1) and compose up (d2).
2//!
3//! Prefer Armbian `armbian-config --cmd CON001` when available; otherwise apt
4//! packages `docker.io` and `docker-compose-v2`.
5
6use 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
12/// Ensure Docker Engine is installed before stack init (`d0`).
13pub 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/// True when `docker` is on PATH and `docker info` succeeds.
60#[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)]
218/// Clear thread-local Docker-engine test overrides.
219pub(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)]
228/// Override `docker` readiness for unit tests (`None` restores real probe).
229pub(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        // Still missing after install helpers.
381        let err = D0DockerEngine.apply(&mut ctx).unwrap_err();
382        assert!(err.to_string().contains("still missing"));
383        reset_test_hooks();
384    }
385}