Skip to main content

horto_os_ui_shared/steps/
d1_docker.rs

1//! reference: horto-os/scripts/d1_docker_init.sh
2//! Multi-platform tip (`dev_multi-platform-os`): copy non-stacks tree, then
3//! `stacks/common`, then `stacks/$NPU_TYPE` from `os-configuration.env`.
4use 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
13/// Initialize Docker stack trees from embedded assets (`d1`).
14pub 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
116/// Map WIP / alias values onto folders under `docker_source/stacks/`.
117fn 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
292/// Extract a piper models tarball into the docker tree root.
293///
294/// Tip layout merges stack folders into `/srv/docker` (no `stacks/` parent), so
295/// archive members like `piper/model/…` land beside the piper compose project.
296fn 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    /// Serialize PATH mutations / `tar` lookups across piper extract tests.
342    ///
343    /// `extract_piper_archive_warns_when_tar_binary_missing` empties PATH; without
344    /// this lock, parallel tests that call `tar` or `bash` flake in CI.
345    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        // Embedded os-configuration.env ships NPU_TYPE=rkRK3588 => normalizes to rk3588.
464        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        // Also drop a minimal env; full should win.
516        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        // Non-placeholder file should be untouched.
571        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        // Minimal gzipped tar with member piper/model/voice.bin
636        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        // docker path is a file so create_dir_all fails
689        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        // SAFETY: single-threaded critical section under PIPER_PATH_LOCK for this process.
759        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}