1use crate::context::{HostContext, PlannedAction};
5use crate::embed::{self, Assets};
6use crate::error::{HortoError, Result};
7use crate::kits::{envfile, fs, template};
8use crate::step::Step;
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13pub struct D1Docker;
15
16impl Step for D1Docker {
17 fn id(&self) -> &'static str {
18 "d1"
19 }
20 fn title(&self) -> &'static str {
21 "Initialize Docker stacks from embedded source"
22 }
23 fn reference_script(&self) -> &'static str {
24 "d1_docker_init.sh"
25 }
26 fn step_version(&self) -> u32 {
27 3
28 }
29 fn depends_on(&self) -> &'static [&'static str] {
30 &["d0"]
31 }
32 fn is_done(&self, ctx: &HostContext) -> bool {
33 ctx.paths.docker.join("dockge/compose.yaml").exists()
34 || ctx.paths.docker.join("stacks").is_dir()
35 || ctx.paths.docker.join("homepage/compose.yaml").exists()
36 }
37 fn plan(&self, ctx: &mut HostContext) -> Result<Vec<PlannedAction>> {
38 let npu = resolve_npu_type(ctx)?;
39 ctx.plan_action(format!(
40 "ensure {}",
41 ctx.paths.os_configuration_file().display()
42 ));
43 ctx.plan_action(format!(
44 "extract embedded docker_source/ (non-stacks) -> {}",
45 ctx.paths.docker.display()
46 ));
47 ctx.plan_action("merge stacks/common into docker tree");
48 match npu.as_str() {
49 "none" => ctx.plan_action("NPU_TYPE=none; skip NPU/GPU stack merge"),
50 other => ctx.plan_action(format!("merge stacks/{other} into docker tree")),
51 }
52 ctx.plan_action("copy homepage_assets/* into docker/assets");
53 ctx.plan_action("render {{VAR}} placeholders under docker tree");
54 if ctx.skip_piper {
55 ctx.plan_action("skip piper model download (--skip-piper)");
56 } else {
57 ctx.plan_action("download piper models (warn-only on failure)");
58 }
59 Ok(ctx.planned.clone())
60 }
61 fn apply(&self, ctx: &mut HostContext) -> Result<()> {
62 ensure_os_configuration(ctx)?;
63 let vars = load_render_vars(ctx)?;
64 let npu = resolve_npu_type(ctx)?;
65 let target = ctx.paths.docker.clone();
66 fs::ensure_dir(ctx, &target)?;
67
68 extract_non_stacks(ctx, &target)?;
69 merge_stack_prefix(ctx, &target, "stacks/common/")?;
70 if npu == "none" {
71 ctx.log("NPU_TYPE=none; skipped NPU/GPU stack merge");
72 } else {
73 let prefix = format!("stacks/{npu}/");
74 merge_stack_prefix(ctx, &target, &prefix)?;
75 }
76
77 copy_homepage_assets(ctx)?;
78
79 if ctx.is_dry_run() {
80 ctx.plan_action("render placeholders in docker tree");
81 } else {
82 render_tree(ctx, &target, &vars)?;
83 }
84
85 if ctx.skip_piper || ctx.is_dry_run() {
86 ctx.log("Skipping piper model download.");
87 } else {
88 download_piper_models(ctx);
89 }
90
91 ctx.log(format!(
92 "d1 complete: docker source ready under {} (stacks not started; see d2)",
93 target.display()
94 ));
95 Ok(())
96 }
97}
98
99fn ensure_os_configuration(ctx: &mut HostContext) -> Result<()> {
100 let dest = ctx.paths.os_configuration_file();
101 if dest.exists() {
102 return Ok(());
103 }
104 let template = embed::get("config/os-configuration.env")
105 .ok_or_else(|| HortoError::EmbedMissing("config/os-configuration.env".into()))?;
106 fs::ensure_dir(ctx, &ctx.paths.active_setup.clone())?;
107 fs::write_file(ctx, &dest, &template)?;
108 if ctx.is_dry_run() {
109 ctx.plan_action(format!("write {}", dest.display()));
110 } else {
111 ctx.log(format!("wrote {}", dest.display()));
112 }
113 Ok(())
114}
115
116fn normalize_npu_type(raw: &str) -> String {
118 match raw.trim() {
119 "" | "none" => "none".into(),
120 "rkRK3588" | "rk3588" | "RK3588" => "rk3588".into(),
121 "rkRK3576" | "rk3576" | "RK3576" => "rk3576".into(),
122 "no_wyoming" => "no_wyoming".into(),
123 other => other.to_string(),
124 }
125}
126
127fn resolve_npu_type(ctx: &HostContext) -> Result<String> {
128 let path = ctx.paths.os_configuration_file();
129 let map = if path.exists() {
130 envfile::load(&path)?
131 } else if ctx.is_dry_run() {
132 let template = embed::get_str("config/os-configuration.env").unwrap_or_default();
133 envfile::parse(&template)
134 } else {
135 return Err(HortoError::msg(format!(
136 "missing {}; run setup so os-configuration is staged",
137 path.display()
138 )));
139 };
140 let raw = map.get("NPU_TYPE").map_or("none", String::as_str);
141 let npu = normalize_npu_type(raw);
142 if npu == "none" {
143 return Ok(npu);
144 }
145 let probe = format!("docker_source/stacks/{npu}/");
146 let exists = Assets::iter().any(|p| p.starts_with(&probe));
147 if !exists {
148 return Err(HortoError::msg(format!(
149 "NPU_TYPE={npu} has no embedded stacks/{npu}/ (fix OS_TYPE/NPU_TYPE in os-configuration.env)"
150 )));
151 }
152 Ok(npu)
153}
154
155fn extract_non_stacks(ctx: &mut HostContext, target: &Path) -> Result<()> {
156 for path in Assets::iter() {
157 let Some(rel) = path.strip_prefix("docker_source/") else {
158 continue;
159 };
160 if rel.is_empty() || rel.starts_with("stacks/") {
161 continue;
162 }
163 let dest = target.join(rel);
164 let data = embed::get(path.as_ref())
165 .ok_or_else(|| HortoError::msg(format!("missing embed {path}")))?;
166 fs::write_file(ctx, &dest, &data)?;
167 }
168 ctx.log(format!(
169 "Copied docker source tree into {} (excluded stacks/)",
170 target.display()
171 ));
172 Ok(())
173}
174
175fn merge_stack_prefix(ctx: &mut HostContext, target: &Path, stack_prefix: &str) -> Result<()> {
176 let embed_prefix = format!("docker_source/{stack_prefix}");
177 let mut count = 0u32;
178 for path in Assets::iter() {
179 let Some(rel) = path.strip_prefix(&embed_prefix) else {
180 continue;
181 };
182 if rel.is_empty() {
183 continue;
184 }
185 let dest = target.join(rel);
186 let data = embed::get(path.as_ref())
187 .ok_or_else(|| HortoError::msg(format!("missing embed {path}")))?;
188 fs::write_file(ctx, &dest, &data)?;
189 count += 1;
190 }
191 ctx.log(format!(
192 "Merged {stack_prefix} ({count} files) into {}",
193 target.display()
194 ));
195 Ok(())
196}
197
198fn load_render_vars(ctx: &HostContext) -> Result<BTreeMap<String, String>> {
199 if ctx.paths.full_env_file().exists() {
200 return envfile::load(&ctx.paths.full_env_file());
201 }
202 if ctx.paths.minimal_env_file().exists() {
203 return envfile::load(&ctx.paths.minimal_env_file());
204 }
205 if ctx.is_dry_run() {
206 let mut demo = BTreeMap::new();
207 demo.insert("MY_HOSTNAME".into(), "horto-dryrun".into());
208 demo.insert("WIFI_INTERFACE".into(), "wlan0".into());
209 demo.insert("WIFI_SSID".into(), "Horto-IoT-LAN".into());
210 demo.insert("WIFI_PASSPHRASE".into(), String::new());
211 demo.insert("MY_URL".into(), "example.net".into());
212 return Ok(demo);
213 }
214 Err(HortoError::msg(
215 "no active setup file found; run s2 or m1 first",
216 ))
217}
218
219fn copy_homepage_assets(ctx: &mut HostContext) -> Result<()> {
220 let assets_target = ctx.paths.docker_assets();
221 fs::ensure_dir(ctx, &assets_target)?;
222 let mut copied = 0u32;
223 for path in Assets::iter() {
224 let Some(name) = path.strip_prefix("homepage_assets/") else {
225 continue;
226 };
227 if name.is_empty() || !name.starts_with("homepage") {
228 continue;
229 }
230 let data = embed::get(path.as_ref())
231 .ok_or_else(|| HortoError::msg(format!("missing embed {path}")))?;
232 let dest = assets_target.join(name);
233 fs::write_file(ctx, &dest, &data)?;
234 ctx.log(format!("Copied asset: {}", dest.display()));
235 copied += 1;
236 }
237 if copied == 0 {
238 ctx.log("No homepage* assets found; skipping asset copy.");
239 }
240 Ok(())
241}
242
243fn render_tree(
244 ctx: &mut HostContext,
245 root: &PathBuf,
246 vars: &BTreeMap<String, String>,
247) -> Result<()> {
248 for entry in walkdir::WalkDir::new(root)
249 .into_iter()
250 .filter_map(std::result::Result::ok)
251 {
252 if !entry.file_type().is_file() {
253 continue;
254 }
255 let path = entry.path();
256 let Ok(text) = std::fs::read_to_string(path) else {
257 continue;
258 };
259 if !template::has_unreplaced_placeholders(&text) {
260 continue;
261 }
262 let rendered = template::render(&text, vars);
263 std::fs::write(path, rendered)?;
264 ctx.log(format!("Rendered placeholders in: {}", path.display()));
265 }
266 Ok(())
267}
268
269fn download_piper_models(ctx: &mut HostContext) {
270 let piper_dir = ctx.paths.docker.join("docker_repos/piper");
271 if let Err(e) = std::fs::create_dir_all(&piper_dir) {
272 ctx.log(format!("warning: cannot create piper dir: {e}"));
273 return;
274 }
275 let url = "https://github.com/Hanzo-Huang/rk3576-home-assistant-voice/releases/download/models-v1/piper-rk3576-models.tar.gz";
276 let archive = piper_dir.join("piper-rk3576-models.tar.gz");
277 ctx.log("Downloading piper models...");
278 let status = Command::new("wget")
279 .args(["-O", archive.to_str().unwrap_or("/dev/null"), url])
280 .current_dir(&piper_dir)
281 .status();
282 match status {
283 Ok(s) if s.success() => extract_piper_archive(ctx, &archive),
284 _ => {
285 ctx.log(
286 "warning: piper model download failed or wget missing; continuing without models",
287 );
288 }
289 }
290}
291
292fn extract_piper_archive(ctx: &mut HostContext, archive: &Path) {
297 let extract_root = ctx.paths.docker.clone();
298 if let Err(e) = std::fs::create_dir_all(&extract_root) {
299 ctx.log(format!(
300 "warning: cannot create piper extract dir {}: {e}",
301 extract_root.display()
302 ));
303 return;
304 }
305 let Some(archive_s) = archive.to_str() else {
306 ctx.log("warning: piper archive path is not UTF-8; continuing without models");
307 return;
308 };
309 let Some(root_s) = extract_root.to_str() else {
310 ctx.log("warning: piper extract path is not UTF-8; continuing without models");
311 return;
312 };
313 match Command::new("tar")
314 .args(["-xzf", archive_s, "-C", root_s])
315 .status()
316 {
317 Ok(s) if s.success() => ctx.log(format!(
318 "Piper models extracted under {}.",
319 extract_root.display()
320 )),
321 Ok(s) => ctx.log(format!(
322 "warning: piper model extract failed (tar exit {}); continuing without models",
323 s.code().unwrap_or(-1)
324 )),
325 Err(e) => ctx.log(format!(
326 "warning: piper model extract failed ({e}); continuing without models"
327 )),
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::context::{ApplyMode, NonInteractivePrompts};
335 use crate::kits::envfile;
336 use crate::paths::HostPaths;
337 use crate::pipeline::SetupKind;
338 use std::collections::BTreeMap;
339 use tempfile::TempDir;
340
341 static PIPER_PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
346
347 fn temp_paths(root: &std::path::Path) -> HostPaths {
348 HostPaths {
349 active_setup: root.join("active_setup"),
350 backup: root.join("backup"),
351 docker: root.join("docker"),
352 etc: root.join("etc"),
353 lease_file: root.join("leases"),
354 }
355 }
356
357 #[test]
358 fn npu_aliases_map_to_stack_folders() {
359 assert_eq!(normalize_npu_type("rkRK3588"), "rk3588");
360 assert_eq!(normalize_npu_type("rk3588"), "rk3588");
361 assert_eq!(normalize_npu_type("RK3588"), "rk3588");
362 assert_eq!(normalize_npu_type("rkRK3576"), "rk3576");
363 assert_eq!(normalize_npu_type("RK3576"), "rk3576");
364 assert_eq!(normalize_npu_type("rk3576"), "rk3576");
365 assert_eq!(normalize_npu_type("none"), "none");
366 assert_eq!(normalize_npu_type(""), "none");
367 assert_eq!(normalize_npu_type("no_wyoming"), "no_wyoming");
368 assert_eq!(normalize_npu_type("custom"), "custom");
369 }
370
371 #[test]
372 fn trait_metadata_is_stable() {
373 let step = D1Docker;
374 assert_eq!(step.id(), "d1");
375 assert_eq!(step.reference_script(), "d1_docker_init.sh");
376 assert_eq!(step.step_version(), 3);
377 assert_eq!(step.depends_on(), &["d0"]);
378 assert_ne!(step.title(), "");
379 assert!(!step.needs_reboot_after());
380 assert!(!step.destructive());
381 }
382
383 #[test]
384 fn is_done_detects_docker_artifacts() {
385 let tmp = TempDir::new().unwrap();
386 let paths = temp_paths(tmp.path());
387 let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
388 assert!(!D1Docker.is_done(&ctx));
389
390 std::fs::create_dir_all(ctx.paths.docker.join("stacks")).unwrap();
391 assert!(D1Docker.is_done(&ctx));
392 }
393
394 #[test]
395 fn is_done_detects_dockge_compose() {
396 let tmp = TempDir::new().unwrap();
397 let paths = temp_paths(tmp.path());
398 let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
399 std::fs::create_dir_all(ctx.paths.docker.join("dockge")).unwrap();
400 std::fs::write(
401 ctx.paths.docker.join("dockge/compose.yaml"),
402 b"services: {}\n",
403 )
404 .unwrap();
405 assert!(D1Docker.is_done(&ctx));
406 }
407
408 #[test]
409 fn plan_dry_run_lists_stack_merges() {
410 let tmp = TempDir::new().unwrap();
411 let mut ctx =
412 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
413 let planned = D1Docker.plan(&mut ctx).unwrap();
414 assert!(planned.iter().any(|p| p.summary.contains("stacks/common")));
415 assert!(planned
416 .iter()
417 .any(|p| p.summary.contains("extract embedded docker_source")));
418 assert!(planned.iter().any(|p| p.summary.contains("piper")));
419 }
420
421 #[test]
422 fn plan_skip_piper_when_flag_set() {
423 let tmp = TempDir::new().unwrap();
424 let mut ctx =
425 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
426 ctx.skip_piper = true;
427 let planned = D1Docker.plan(&mut ctx).unwrap();
428 assert!(planned
429 .iter()
430 .any(|p| p.summary.contains("skip piper model download")));
431 }
432
433 #[test]
434 fn resolve_npu_type_reads_config_file() {
435 let tmp = TempDir::new().unwrap();
436 let paths = temp_paths(tmp.path());
437 std::fs::create_dir_all(&paths.active_setup).unwrap();
438 let mut map = BTreeMap::new();
439 map.insert("NPU_TYPE".into(), "rk3588".into());
440 envfile::write(&paths.os_configuration_file(), &map).unwrap();
441 let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
442 assert_eq!(resolve_npu_type(&ctx).unwrap(), "rk3588");
443 }
444
445 #[test]
446 fn resolve_npu_type_rejects_unknown_stack() {
447 let tmp = TempDir::new().unwrap();
448 let paths = temp_paths(tmp.path());
449 std::fs::create_dir_all(&paths.active_setup).unwrap();
450 let mut map = BTreeMap::new();
451 map.insert("NPU_TYPE".into(), "bogus".into());
452 envfile::write(&paths.os_configuration_file(), &map).unwrap();
453 let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
454 let err = resolve_npu_type(&ctx).unwrap_err();
455 assert!(err.to_string().contains("bogus"));
456 }
457
458 #[test]
459 fn resolve_npu_type_dry_run_reads_embedded_template() {
460 let tmp = TempDir::new().unwrap();
461 let ctx =
462 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
463 let npu = resolve_npu_type(&ctx).unwrap();
465 assert_eq!(npu, "rk3588");
466 }
467
468 #[test]
469 fn resolve_npu_type_apply_without_file_errors() {
470 let tmp = TempDir::new().unwrap();
471 let ctx =
472 HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(temp_paths(tmp.path()));
473 let err = resolve_npu_type(&ctx).unwrap_err();
474 assert!(err.to_string().contains("os-configuration"));
475 }
476
477 #[test]
478 fn ensure_os_configuration_writes_template_when_missing() {
479 let tmp = TempDir::new().unwrap();
480 let paths = temp_paths(tmp.path());
481 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
482 ensure_os_configuration(&mut ctx).unwrap();
483 assert!(ctx.paths.os_configuration_file().is_file());
484 }
485
486 #[test]
487 fn ensure_os_configuration_leaves_existing_file() {
488 let tmp = TempDir::new().unwrap();
489 let paths = temp_paths(tmp.path());
490 std::fs::create_dir_all(&paths.active_setup).unwrap();
491 std::fs::write(paths.os_configuration_file(), b"KEEP=me\n").unwrap();
492 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
493 ensure_os_configuration(&mut ctx).unwrap();
494 let body = std::fs::read_to_string(ctx.paths.os_configuration_file()).unwrap();
495 assert!(body.contains("KEEP=me"));
496 }
497
498 #[test]
499 fn copy_homepage_assets_creates_docker_assets_dir() {
500 let tmp = TempDir::new().unwrap();
501 let mut ctx =
502 HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(temp_paths(tmp.path()));
503 copy_homepage_assets(&mut ctx).unwrap();
504 assert!(ctx.paths.docker_assets().is_dir());
505 }
506
507 #[test]
508 fn load_render_vars_prefers_full_env() {
509 let tmp = TempDir::new().unwrap();
510 let paths = temp_paths(tmp.path());
511 std::fs::create_dir_all(&paths.active_setup).unwrap();
512 let mut map = BTreeMap::new();
513 map.insert("MY_HOSTNAME".into(), "prefer-full".into());
514 envfile::write(&paths.full_env_file(), &map).unwrap();
515 let mut minimal_map = BTreeMap::new();
517 minimal_map.insert("MY_HOSTNAME".into(), "prefer-minimal".into());
518 envfile::write(&paths.minimal_env_file(), &minimal_map).unwrap();
519 let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
520 let vars = load_render_vars(&ctx).unwrap();
521 assert_eq!(
522 vars.get("MY_HOSTNAME").map(String::as_str),
523 Some("prefer-full")
524 );
525 }
526
527 #[test]
528 fn load_render_vars_falls_back_to_minimal() {
529 let tmp = TempDir::new().unwrap();
530 let paths = temp_paths(tmp.path());
531 std::fs::create_dir_all(&paths.active_setup).unwrap();
532 let mut map = BTreeMap::new();
533 map.insert("MY_HOSTNAME".into(), "mini-only".into());
534 envfile::write(&paths.minimal_env_file(), &map).unwrap();
535 let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
536 let vars = load_render_vars(&ctx).unwrap();
537 assert_eq!(
538 vars.get("MY_HOSTNAME").map(String::as_str),
539 Some("mini-only")
540 );
541 }
542
543 #[test]
544 fn load_render_vars_dry_run_returns_demo_vars() {
545 let tmp = TempDir::new().unwrap();
546 let ctx =
547 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
548 let vars = load_render_vars(&ctx).unwrap();
549 assert!(vars.contains_key("MY_HOSTNAME"));
550 assert!(vars.contains_key("WIFI_INTERFACE"));
551 }
552
553 #[test]
554 fn load_render_vars_apply_without_files_errors() {
555 let tmp = TempDir::new().unwrap();
556 let ctx =
557 HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(temp_paths(tmp.path()));
558 let err = load_render_vars(&ctx).unwrap_err();
559 assert!(err.to_string().contains("active setup"));
560 }
561
562 #[test]
563 fn render_tree_replaces_placeholders_in_files() {
564 let tmp = TempDir::new().unwrap();
565 let paths = temp_paths(tmp.path());
566 let root = tmp.path().join("tree");
567 std::fs::create_dir_all(&root).unwrap();
568 let target = root.join("hosts");
569 std::fs::write(&target, b"host={{MY_HOSTNAME}}\n").unwrap();
570 let untouched = root.join("static");
572 std::fs::write(&untouched, b"plain content\n").unwrap();
573
574 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
575 let mut vars = BTreeMap::new();
576 vars.insert("MY_HOSTNAME".into(), "cov-box".into());
577 render_tree(&mut ctx, &root, &vars).unwrap();
578 let rendered = std::fs::read_to_string(&target).unwrap();
579 assert!(rendered.contains("cov-box"));
580 assert_eq!(
581 std::fs::read_to_string(&untouched).unwrap(),
582 "plain content\n"
583 );
584 }
585
586 #[test]
587 fn resolve_npu_type_none_from_config() {
588 let tmp = TempDir::new().unwrap();
589 let paths = temp_paths(tmp.path());
590 std::fs::create_dir_all(&paths.active_setup).unwrap();
591 let mut map = BTreeMap::new();
592 map.insert("NPU_TYPE".into(), "none".into());
593 envfile::write(&paths.os_configuration_file(), &map).unwrap();
594 let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
595 assert_eq!(resolve_npu_type(&ctx).unwrap(), "none");
596 }
597
598 #[test]
599 fn apply_dry_run_skips_npu_stack_when_none() {
600 let tmp = TempDir::new().unwrap();
601 let paths = temp_paths(tmp.path());
602 std::fs::create_dir_all(&paths.active_setup).unwrap();
603 let mut map = BTreeMap::new();
604 map.insert("NPU_TYPE".into(), "none".into());
605 envfile::write(&paths.os_configuration_file(), &map).unwrap();
606 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full)
607 .with_paths(paths)
608 .with_prompts(Box::new(NonInteractivePrompts));
609 D1Docker.apply(&mut ctx).unwrap();
610 assert!(ctx
611 .logs
612 .iter()
613 .any(|l| l.contains("NPU_TYPE=none; skipped NPU/GPU stack merge")));
614 }
615
616 #[test]
617 fn apply_skips_piper_when_dry_run() {
618 let tmp = TempDir::new().unwrap();
619 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full)
620 .with_paths(temp_paths(tmp.path()))
621 .with_prompts(Box::new(NonInteractivePrompts));
622 D1Docker.apply(&mut ctx).unwrap();
623 assert_ne!(ctx.planned.as_slice(), &[]);
624 }
625
626 #[test]
627 fn extract_piper_archive_into_docker_root() {
628 let _path_guard = PIPER_PATH_LOCK
629 .lock()
630 .unwrap_or_else(std::sync::PoisonError::into_inner);
631 let tmp = TempDir::new().unwrap();
632 let docker = tmp.path().join("docker");
633 std::fs::create_dir_all(&docker).unwrap();
634 let archive = tmp.path().join("piper-models.tar.gz");
635 let status = std::process::Command::new("bash")
637 .args([
638 "-c",
639 &format!(
640 "mkdir -p piper/model && echo voice > piper/model/voice.bin && tar -czf '{}' piper",
641 archive.display()
642 ),
643 ])
644 .current_dir(tmp.path())
645 .status()
646 .unwrap();
647 assert!(status.success());
648
649 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
650 .with_paths(temp_paths(tmp.path()))
651 .with_prompts(Box::new(NonInteractivePrompts));
652 extract_piper_archive(&mut ctx, &archive);
653 assert!(
654 docker.join("piper/model/voice.bin").is_file(),
655 "expected extract under docker/; logs={:?}",
656 ctx.logs
657 );
658 assert!(ctx
659 .logs
660 .iter()
661 .any(|l| l.contains("Piper models extracted under")));
662 assert!(!docker.join("stacks").exists());
663 }
664
665 #[test]
666 fn extract_piper_archive_warns_when_tar_fails() {
667 let _path_guard = PIPER_PATH_LOCK
668 .lock()
669 .unwrap_or_else(std::sync::PoisonError::into_inner);
670 let tmp = TempDir::new().unwrap();
671 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
672 .with_paths(temp_paths(tmp.path()))
673 .with_prompts(Box::new(NonInteractivePrompts));
674 let missing = tmp.path().join("missing.tar.gz");
675 extract_piper_archive(&mut ctx, &missing);
676 assert!(ctx
677 .logs
678 .iter()
679 .any(|l| l.contains("piper model extract failed")));
680 }
681
682 #[test]
683 fn extract_piper_archive_warns_when_extract_dir_blocked() {
684 let _path_guard = PIPER_PATH_LOCK
685 .lock()
686 .unwrap_or_else(std::sync::PoisonError::into_inner);
687 let tmp = TempDir::new().unwrap();
688 let docker_as_file = tmp.path().join("docker");
690 std::fs::write(&docker_as_file, b"not-a-dir").unwrap();
691 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
692 .with_paths(temp_paths(tmp.path()))
693 .with_prompts(Box::new(NonInteractivePrompts));
694 extract_piper_archive(&mut ctx, &tmp.path().join("unused.tar.gz"));
695 assert!(ctx
696 .logs
697 .iter()
698 .any(|l| l.contains("cannot create piper extract dir")));
699 }
700
701 #[cfg(unix)]
702 #[test]
703 fn extract_piper_archive_warns_on_non_utf8_archive_path() {
704 use std::ffi::OsStr;
705 use std::os::unix::ffi::OsStrExt;
706
707 let _path_guard = PIPER_PATH_LOCK
708 .lock()
709 .unwrap_or_else(std::sync::PoisonError::into_inner);
710 let tmp = TempDir::new().unwrap();
711 std::fs::create_dir_all(tmp.path().join("docker")).unwrap();
712 let archive = tmp.path().join(OsStr::from_bytes(b"bad-\xff.tar.gz"));
713 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
714 .with_paths(temp_paths(tmp.path()))
715 .with_prompts(Box::new(NonInteractivePrompts));
716 extract_piper_archive(&mut ctx, &archive);
717 assert!(ctx
718 .logs
719 .iter()
720 .any(|l| l.contains("piper archive path is not UTF-8")));
721 }
722
723 #[cfg(unix)]
724 #[test]
725 fn extract_piper_archive_warns_on_non_utf8_extract_root() {
726 use std::ffi::OsStr;
727 use std::os::unix::ffi::OsStrExt;
728
729 let _path_guard = PIPER_PATH_LOCK
730 .lock()
731 .unwrap_or_else(std::sync::PoisonError::into_inner);
732 let tmp = TempDir::new().unwrap();
733 let docker = tmp.path().join(OsStr::from_bytes(b"docker-\xff"));
734 std::fs::create_dir_all(&docker).unwrap();
735 let mut paths = temp_paths(tmp.path());
736 paths.docker = docker;
737 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
738 .with_paths(paths)
739 .with_prompts(Box::new(NonInteractivePrompts));
740 extract_piper_archive(&mut ctx, &tmp.path().join("models.tar.gz"));
741 assert!(ctx
742 .logs
743 .iter()
744 .any(|l| l.contains("piper extract path is not UTF-8")));
745 }
746
747 #[test]
748 fn extract_piper_archive_warns_when_tar_binary_missing() {
749 let _path_guard = PIPER_PATH_LOCK
750 .lock()
751 .unwrap_or_else(std::sync::PoisonError::into_inner);
752 let tmp = TempDir::new().unwrap();
753 std::fs::create_dir_all(tmp.path().join("docker")).unwrap();
754 let empty_bin = tmp.path().join("empty-bin");
755 std::fs::create_dir_all(&empty_bin).unwrap();
756
757 let previous = std::env::var_os("PATH");
758 unsafe {
760 std::env::set_var("PATH", &empty_bin);
761 }
762
763 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
764 .with_paths(temp_paths(tmp.path()))
765 .with_prompts(Box::new(NonInteractivePrompts));
766 extract_piper_archive(&mut ctx, &tmp.path().join("models.tar.gz"));
767
768 match previous {
769 Some(p) => unsafe { std::env::set_var("PATH", p) },
770 None => unsafe { std::env::remove_var("PATH") },
771 }
772
773 assert!(ctx
774 .logs
775 .iter()
776 .any(|l| { l.contains("piper model extract failed") && !l.contains("tar exit") }));
777 }
778}