Skip to main content

horto_os_ui_shared/steps/
d2_start_stacks.rs

1//! Start Dockge and Homepage after d1 prepared `/srv/docker`.
2
3use crate::context::{HostContext, PlannedAction};
4use crate::error::{HortoError, Result};
5use crate::kits::docker;
6use crate::step::Step;
7use crate::steps::d0_docker_engine::docker_engine_ready;
8use std::path::Path;
9
10/// Start Dockge and Homepage compose stacks (`d2`).
11pub struct D2StartStacks;
12
13impl Step for D2StartStacks {
14    fn id(&self) -> &'static str {
15        "d2"
16    }
17    fn title(&self) -> &'static str {
18        "Start Dockge and Homepage"
19    }
20    fn reference_script(&self) -> &'static str {
21        "d2_start_stacks.sh"
22    }
23    fn step_version(&self) -> u32 {
24        1
25    }
26    fn depends_on(&self) -> &'static [&'static str] {
27        &["d1"]
28    }
29    fn is_done(&self, ctx: &HostContext) -> bool {
30        if !docker_engine_ready() {
31            return false;
32        }
33        if !compose_present(&ctx.paths.docker.join("dockge"))
34            || !compose_present(&ctx.paths.docker.join("homepage"))
35        {
36            return false;
37        }
38        stacks_running()
39    }
40    fn plan(&self, ctx: &mut HostContext) -> Result<Vec<PlannedAction>> {
41        let dockge = ctx.paths.docker.join("dockge");
42        let homepage = ctx.paths.docker.join("homepage");
43        ctx.plan_action(format!("docker compose up -d in {}", dockge.display()));
44        ctx.plan_action(format!("docker compose up -d in {}", homepage.display()));
45        ctx.plan_action("Dockge http://<hostname>:5001 ; Homepage http://<hostname>:3021");
46        Ok(ctx.planned.clone())
47    }
48    fn apply(&self, ctx: &mut HostContext) -> Result<()> {
49        if ctx.is_dry_run() {
50            self.plan(ctx)?;
51            return Ok(());
52        }
53        if !docker_engine_ready() {
54            return Err(HortoError::msg(
55                "d2 requires Docker Engine; run d0 first (or install Docker)",
56            ));
57        }
58        let dockge = ctx.paths.docker.join("dockge");
59        let homepage = ctx.paths.docker.join("homepage");
60        ensure_compose(&dockge, "Dockge")?;
61        ensure_compose(&homepage, "Homepage")?;
62        start_stack(ctx, "Dockge", &dockge)?;
63        start_stack(ctx, "Homepage", &homepage)?;
64        ctx.log(
65            "Step d2 complete: Dockge http://<hostname>:5001 ; Homepage http://<hostname>:3021",
66        );
67        Ok(())
68    }
69}
70
71fn compose_present(dir: &Path) -> bool {
72    dir.join("compose.yaml").is_file() || dir.join("compose.yml").is_file()
73}
74
75fn ensure_compose(dir: &Path, name: &str) -> Result<()> {
76    if compose_present(dir) {
77        return Ok(());
78    }
79    Err(HortoError::msg(format!(
80        "{name} compose missing under {}; run d1 first",
81        dir.display()
82    )))
83}
84
85fn stacks_running() -> bool {
86    docker::list_containers().is_ok_and(|rows| {
87        let names: String = rows
88            .iter()
89            .map(|c| c.names.to_ascii_lowercase())
90            .collect::<Vec<_>>()
91            .join(",");
92        names.contains("dockge") && names.contains("homepage")
93    })
94}
95
96fn start_stack(ctx: &mut HostContext, name: &str, dir: &Path) -> Result<()> {
97    ctx.log(format!("Starting {name} in {}", dir.display()));
98    #[cfg(test)]
99    {
100        if SKIP_COMPOSE.with(std::cell::Cell::get) {
101            ctx.log(format!("compose up skipped for {name} (test)"));
102            return Ok(());
103        }
104    }
105    docker::compose_up(dir)
106}
107
108#[cfg(test)]
109thread_local! {
110    static SKIP_COMPOSE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::context::ApplyMode;
117    use crate::paths::HostPaths;
118    use crate::pipeline::SetupKind;
119    use crate::steps::d0_docker_engine::{reset_test_hooks, set_ready_override};
120    use tempfile::TempDir;
121
122    fn temp_paths(root: &std::path::Path) -> HostPaths {
123        HostPaths {
124            active_setup: root.join("active_setup"),
125            backup: root.join("backup"),
126            docker: root.join("docker"),
127            etc: root.join("etc"),
128            lease_file: root.join("leases"),
129        }
130    }
131
132    fn write_compose(dir: &std::path::Path) {
133        std::fs::create_dir_all(dir).unwrap();
134        std::fs::write(dir.join("compose.yaml"), "services: {}\n").unwrap();
135    }
136
137    #[test]
138    fn metadata_is_stable() {
139        let step = D2StartStacks;
140        assert_eq!(step.id(), "d2");
141        assert_eq!(step.depends_on(), &["d1"]);
142        assert_eq!(step.step_version(), 1);
143        assert_ne!(step.title(), "");
144        assert_eq!(step.reference_script(), "d2_start_stacks.sh");
145    }
146
147    #[test]
148    fn dry_run_plans_compose_ups() {
149        let tmp = TempDir::new().unwrap();
150        let mut ctx =
151            HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
152        D2StartStacks.apply(&mut ctx).unwrap();
153        assert!(ctx
154            .planned
155            .iter()
156            .any(|p| p.summary.contains("dockge") && p.summary.contains("compose up")));
157        assert!(ctx
158            .planned
159            .iter()
160            .any(|p| p.summary.contains("homepage") && p.summary.contains("compose up")));
161        assert!(ctx
162            .planned
163            .iter()
164            .any(|p| p.summary.contains(":5001") && p.summary.contains(":3021")));
165    }
166
167    #[test]
168    fn is_done_false_without_compose() {
169        reset_test_hooks();
170        set_ready_override(Some(true));
171        let tmp = TempDir::new().unwrap();
172        let ctx =
173            HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
174        assert!(!D2StartStacks.is_done(&ctx));
175        reset_test_hooks();
176    }
177
178    #[test]
179    fn is_done_false_when_engine_missing() {
180        reset_test_hooks();
181        set_ready_override(Some(false));
182        let tmp = TempDir::new().unwrap();
183        let paths = temp_paths(tmp.path());
184        write_compose(&paths.docker.join("dockge"));
185        write_compose(&paths.docker.join("homepage"));
186        let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
187        assert!(!D2StartStacks.is_done(&ctx));
188        reset_test_hooks();
189    }
190
191    #[test]
192    fn is_done_false_with_only_dockge_compose() {
193        reset_test_hooks();
194        set_ready_override(Some(true));
195        let tmp = TempDir::new().unwrap();
196        let paths = temp_paths(tmp.path());
197        write_compose(&paths.docker.join("dockge"));
198        let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
199        assert!(!D2StartStacks.is_done(&ctx));
200        reset_test_hooks();
201    }
202
203    #[test]
204    fn apply_errors_without_engine() {
205        reset_test_hooks();
206        set_ready_override(Some(false));
207        let tmp = TempDir::new().unwrap();
208        let mut ctx =
209            HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(temp_paths(tmp.path()));
210        let err = D2StartStacks.apply(&mut ctx).unwrap_err();
211        assert!(err.to_string().contains("requires Docker Engine"));
212        reset_test_hooks();
213    }
214
215    #[test]
216    fn apply_errors_when_dockge_compose_missing() {
217        reset_test_hooks();
218        set_ready_override(Some(true));
219        let tmp = TempDir::new().unwrap();
220        let paths = temp_paths(tmp.path());
221        write_compose(&paths.docker.join("homepage"));
222        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
223        let err = D2StartStacks.apply(&mut ctx).unwrap_err();
224        assert!(err.to_string().contains("Dockge compose missing"));
225        reset_test_hooks();
226    }
227
228    #[test]
229    fn apply_errors_when_homepage_compose_missing() {
230        reset_test_hooks();
231        set_ready_override(Some(true));
232        let tmp = TempDir::new().unwrap();
233        let paths = temp_paths(tmp.path());
234        write_compose(&paths.docker.join("dockge"));
235        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
236        let err = D2StartStacks.apply(&mut ctx).unwrap_err();
237        assert!(err.to_string().contains("Homepage compose missing"));
238        reset_test_hooks();
239    }
240
241    #[test]
242    fn apply_starts_both_stacks_with_compose_skip() {
243        reset_test_hooks();
244        set_ready_override(Some(true));
245        SKIP_COMPOSE.with(|c| c.set(true));
246        let tmp = TempDir::new().unwrap();
247        let paths = temp_paths(tmp.path());
248        write_compose(&paths.docker.join("dockge"));
249        write_compose(&paths.docker.join("homepage"));
250        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
251        D2StartStacks.apply(&mut ctx).unwrap();
252        assert!(ctx.logs.iter().any(|l| l.contains("Starting Dockge")));
253        assert!(ctx.logs.iter().any(|l| l.contains("Starting Homepage")));
254        assert!(ctx.logs.iter().any(|l| l.contains("d2 complete")));
255        SKIP_COMPOSE.with(|c| c.set(false));
256        reset_test_hooks();
257    }
258
259    #[test]
260    fn compose_present_accepts_yml() {
261        let tmp = TempDir::new().unwrap();
262        let dir = tmp.path().join("stack");
263        std::fs::create_dir_all(&dir).unwrap();
264        std::fs::write(dir.join("compose.yml"), "services: {}\n").unwrap();
265        assert!(compose_present(&dir));
266        assert!(ensure_compose(&dir, "X").is_ok());
267    }
268
269    #[test]
270    fn ensure_compose_errors_when_absent() {
271        let tmp = TempDir::new().unwrap();
272        let err = ensure_compose(tmp.path(), "X").unwrap_err();
273        assert!(err.to_string().contains("X compose missing"));
274    }
275
276    #[test]
277    fn stacks_running_is_bool() {
278        let _ = stacks_running();
279    }
280}