horto_os_ui_shared/steps/
d2_start_stacks.rs1use 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
10pub 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}