sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*
1//! Integration test del pipeline completo de Fase 0:
2//! 1. Inicializa un repo git local con un `hello.c`.
3//! 2. Escribe una receta TOML que apunta a ese repo con phases override.
4//! 3. Llama a `takana_build::build` con un store/work temporales.
5//! 4. Verifica que el artefacto sellado contiene `usr/bin/hello`, es ELF estático y
6//! se ejecuta dentro del sandbox imprimiendo el output esperado.
7//!
8//! El test se SALTA (con un mensaje claro) si el host no tiene el rootfs Alpine
9//! y/o zig instalados en `.dev-fs/`. Eso permite que `cargo test` no falle en máquinas
10//! limpias; el desarrollador lee el mensaje y corre el bootstrap.
11
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use takana_build::{build, BuildConfig};
16use takana_core::{Recipe, Store};
17
18const HELLO_C: &str = r#"
19#include <stdio.h>
20int main(void) { puts("hammer-e2e OK"); return 0; }
21"#;
22
23const RECIPE_TOML: &str = r#"
24name = "hello"
25version = "1.0"
26
27[source]
28repo = "PLACEHOLDER_REPO_URL"
29commit = "PLACEHOLDER_COMMIT"
30
31[build]
32compiler = "zig-cc"
33target = "x86_64-linux-musl"
34link = "static"
35
36[build.phases]
37compile = "zig cc -static -O2 -o hello hello.c"
38install = "install -D -m755 hello /out/usr/bin/hello"
39"#;
40
41fn project_root() -> PathBuf {
42 // CARGO_MANIFEST_DIR = crates/hammer-build → subir dos para llegar al workspace root.
43 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
44 .parent()
45 .unwrap()
46 .parent()
47 .unwrap()
48 .to_path_buf()
49}
50
51fn skip_if_no_layout(cfg: &BuildConfig) -> bool {
52 if !cfg.rootfs.join("bin/busybox").is_file() {
53 eprintln!(
54 "SKIP: rootfs Alpine no encontrado en {} — corre el bootstrap (ver README).",
55 cfg.rootfs.display()
56 );
57 return true;
58 }
59 if !cfg.zig_dir.join("zig").is_file() {
60 eprintln!(
61 "SKIP: zig no encontrado en {} — instalalo en .dev-fs/tools/zig.",
62 cfg.zig_dir.display()
63 );
64 return true;
65 }
66 if Command::new("git").arg("--version").output().is_err() {
67 eprintln!("SKIP: git no disponible en PATH.");
68 return true;
69 }
70 false
71}
72
73fn git(args: &[&str], cwd: &Path) {
74 let st = Command::new("git")
75 .args(args)
76 .current_dir(cwd)
77 .status()
78 .expect("git spawn");
79 assert!(st.success(), "git {args:?} falló");
80}
81
82fn init_repo_with_hello(repo_dir: &Path) -> String {
83 std::fs::create_dir_all(repo_dir).unwrap();
84 std::fs::write(repo_dir.join("hello.c"), HELLO_C).unwrap();
85 git(&["init", "-q", "-b", "main"], repo_dir);
86 // Identidad local para el commit, sin tocar la global del usuario.
87 git(&["config", "user.email", "test@hammer"], repo_dir);
88 git(&["config", "user.name", "hammer-test"], repo_dir);
89 git(&["add", "."], repo_dir);
90 git(&["commit", "-q", "-m", "hello"], repo_dir);
91 let out = Command::new("git")
92 .args(["rev-parse", "HEAD"])
93 .current_dir(repo_dir)
94 .output()
95 .expect("rev-parse");
96 String::from_utf8(out.stdout).unwrap().trim().to_string()
97}
98
99#[test]
100fn build_hello_end_to_end() {
101 let _ = tracing_subscriber::fmt().with_test_writer().try_init();
102
103 let project = project_root();
104 let rootfs = project.join(".dev-fs/alpine");
105 let zig_dir = project.join(".dev-fs/tools/zig");
106
107 let tmp = tempfile::tempdir().unwrap();
108 let store_root = tmp.path().join("store");
109 let work_root = tmp.path().join("work");
110 std::fs::create_dir_all(&store_root).unwrap();
111 std::fs::create_dir_all(&work_root).unwrap();
112
113 let cfg = BuildConfig {
114 rootfs: rootfs.clone(),
115 zig_dir: zig_dir.clone(),
116 work_root,
117 // Reusa la caché global del dev — la primera corrida tras `cargo clean` paga
118 // ~30s recompilando musl; las siguientes terminan en segundos.
119 cache_root: Some(project.join(".dev-fs/cache")),
120 };
121 if skip_if_no_layout(&cfg) {
122 return;
123 }
124
125 // 1) repo upstream simulado
126 let upstream = tmp.path().join("upstream-hello");
127 let commit = init_repo_with_hello(&upstream);
128 let repo_url = format!("file://{}", upstream.display());
129
130 // 2) escribir receta
131 let recipe_dir = tmp.path().join("recipe");
132 std::fs::create_dir_all(&recipe_dir).unwrap();
133 let toml = RECIPE_TOML
134 .replace("PLACEHOLDER_REPO_URL", &repo_url)
135 .replace("PLACEHOLDER_COMMIT", &commit);
136 let recipe_path = recipe_dir.join("hello.toml");
137 std::fs::write(&recipe_path, toml).unwrap();
138 let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
139
140 // 3) build
141 let store = Store::open(&store_root).unwrap();
142 let h = build(&recipe, &cfg, &store).expect("build hello");
143 let sealed = store.path_of(&h, &recipe.name);
144 assert!(sealed.is_dir(), "store dir debe existir: {}", sealed.display());
145
146 // 4) artefacto correcto
147 let hello = sealed.join("usr/bin/hello");
148 assert!(hello.is_file(), "usr/bin/hello debe existir");
149 let meta = std::fs::metadata(&hello).unwrap();
150 use std::os::unix::fs::PermissionsExt;
151 assert_eq!(meta.permissions().mode() & 0o111, 0o111, "ejecutable");
152 // Read-only en el store
153 assert_eq!(meta.permissions().mode() & 0o222, 0, "read-only en el store");
154
155 // ELF
156 let header = std::fs::read(&hello).unwrap();
157 assert_eq!(&header[..4], b"\x7fELF", "es ELF");
158
159 // 5) ejecuta dentro de bwrap+rootfs y observa el output
160 let out = Command::new("bwrap")
161 .args([
162 "--overlay-src",
163 rootfs.to_str().unwrap(),
164 "--tmp-overlay",
165 "/",
166 "--proc",
167 "/proc",
168 "--dev",
169 "/dev",
170 "--ro-bind",
171 sealed.to_str().unwrap(),
172 "/artefacto",
173 "--unshare-all",
174 "/artefacto/usr/bin/hello",
175 ])
176 .output()
177 .expect("spawn bwrap run hello");
178 assert!(out.status.success(), "hello no corrió: {:?}", out);
179 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-e2e OK");
180
181 // 6) cache: segunda build con misma receta no rehidrata
182 let h2 = build(&recipe, &cfg, &store).expect("second build");
183 assert_eq!(h, h2);
184}