gioser

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…

⎇ e53877a53209cf15e3cd682fa97b16048e6ffc62 crates/hammer-build/src/lib.rs Rust · 312 lines · 11.1 KB History Who touched what raw

Caché persistente de zig + bootstrap reproducible del .dev-fs e53877a53 · Sergio ·

1//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata
2//! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`.
3
4use std::path::{Path, PathBuf};
5
6use hammer_core::{ArtifactHash, LinkMode, Phases, Recipe, Store};
7
8pub mod config;
9pub mod fetch;
10pub mod hydrate;
11pub mod sandbox;
12
13pub use config::BuildConfig;
14pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile};
15pub use sandbox::Sandbox;
16
17/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
18/// Ver `docs/02-build-lab.md` §2 y §5.
19pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
20    let dep_hashes: Vec<ArtifactHash> = Vec::new();
21    for _dep in &recipe.deps.build {
22        // TODO(fase-0): cargar la receta de la dep y recursar.
23    }
24    let inputs = recipe.hash_inputs(&dep_hashes)?;
25    let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
26    Ok(ArtifactHash::of_inputs(&refs))
27}
28
29/// Compila una receta y devuelve el hash del artefacto sellado en el store.
30/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
31pub fn build(
32    recipe: &Recipe,
33    cfg: &BuildConfig,
34    store: &Store,
35) -> hammer_core::Result<ArtifactHash> {
36    let h = artifact_hash(recipe, store)?;
37    if store.has(&h, &recipe.name) {
38        tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
39        return Ok(h);
40    }
41    sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?;
42
43    tracing::info!(hash = %h, name = %recipe.name, "fetch");
44    let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
45
46    if !recipe.source.patches.is_empty() {
47        tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
48        fetch::apply_patches(recipe, &src_tree)?;
49    }
50
51    let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
52    let mut env: Vec<(String, String)> = Vec::new();
53    if matches!(recipe.build.link, LinkMode::Static) {
54        // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
55        // monolítico que pide nuestra estrategia de hidratación primaria.
56        env.push(("LDFLAGS".into(), "-static".into()));
57    }
58    if let Some(cache) = &cfg.cache_root {
59        std::fs::create_dir_all(cache.join("zig"))?;
60    }
61    let sb = Sandbox {
62        rootfs: cfg.rootfs.clone(),
63        zig_dir: cfg.zig_dir.clone(),
64        src_dir: src_tree.clone(),
65        out_dir: out_dir.clone(),
66        cache_dir: cfg.cache_root.clone(),
67        env,
68    };
69
70    let phases = resolve_phases(recipe, &src_tree)?;
71    if let Some(cmd) = &phases.configure {
72        tracing::info!(cmd, "phase: configure");
73        sb.run(cmd)?;
74    }
75    if let Some(cmd) = &phases.compile {
76        tracing::info!(cmd, "phase: compile");
77        sb.run(cmd)?;
78    } else {
79        return Err(hammer_core::Error::Recipe(
80            "receta sin fase 'compile' y heurística no encontró ninguna".into(),
81        ));
82    }
83    if let Some(cmd) = &phases.install {
84        tracing::info!(cmd, "phase: install");
85        sb.run(cmd)?;
86    } else {
87        return Err(hammer_core::Error::Recipe(
88            "receta sin fase 'install' y heurística no encontró ninguna".into(),
89        ));
90    }
91
92    let sealed = store.seal(&out_dir, &h, &recipe.name)?;
93    tracing::info!(path = %sealed.display(), "sealed");
94    Ok(h)
95}
96
97/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
98/// Ver `docs/02-build-lab.md` §4.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum BuildSys {
101    /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
102    AutoconfRaw,
103    /// `configure` ya está en el tarball release: vamos directo a `./configure`.
104    AutoconfReady,
105    /// `CMakeLists.txt`.
106    CMake,
107    /// `meson.build`.
108    Meson,
109    /// `Makefile` plano (o `makefile`) sin sistema generador.
110    Make,
111    /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
112    Unknown,
113}
114
115fn detect_build_system(src: &Path) -> BuildSys {
116    let has = |p: &str| src.join(p).exists();
117    // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
118    // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
119    // sistema "más alto" que sí sabe regenerar todo.
120    if has("configure.ac") || has("configure.in") {
121        if has("configure") {
122            return BuildSys::AutoconfReady;
123        }
124        return BuildSys::AutoconfRaw;
125    }
126    if has("configure") {
127        return BuildSys::AutoconfReady;
128    }
129    if has("CMakeLists.txt") {
130        return BuildSys::CMake;
131    }
132    if has("meson.build") {
133        return BuildSys::Meson;
134    }
135    if has("Makefile") || has("makefile") || has("GNUmakefile") {
136        return BuildSys::Make;
137    }
138    BuildSys::Unknown
139}
140
141/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
142/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
143fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
144    let mut out = recipe.build.phases.clone();
145    let bs = detect_build_system(src);
146    let flags = recipe.build.flags.join(" ");
147    let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
148
149    if out.configure.is_none() {
150        out.configure = match bs {
151            BuildSys::AutoconfRaw => Some(format!(
152                "autoreconf -fi && ./configure --prefix=/usr{flags_suffix}"
153            )),
154            BuildSys::AutoconfReady => Some(format!("./configure --prefix=/usr{flags_suffix}")),
155            BuildSys::CMake => Some(format!(
156                "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
157            )),
158            BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
159            BuildSys::Make | BuildSys::Unknown => None,
160        };
161    }
162    if out.compile.is_none() {
163        out.compile = match bs {
164            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
165                Some(r#"make -j"$(nproc)""#.to_string())
166            }
167            BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
168            BuildSys::Meson => Some("meson compile -C _build".to_string()),
169            BuildSys::Unknown => None,
170        };
171    }
172    if out.install.is_none() {
173        out.install = match bs {
174            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
175                Some("make install DESTDIR=/out PREFIX=/usr".to_string())
176            }
177            BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
178            BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
179            BuildSys::Unknown => None,
180        };
181    }
182    Ok(out)
183}
184
185#[cfg(test)]
186mod heuristic_tests {
187    use super::*;
188
189    fn touch(dir: &Path, name: &str) {
190        std::fs::write(dir.join(name), b"").unwrap();
191    }
192
193    fn recipe(flags: &[&str]) -> Recipe {
194        let mut r = Recipe::from_toml(
195            r#"
196name = "x"
197version = "0"
198[source]
199repo = "git://x"
200commit = "deadbeef"
201[build]
202"#,
203        )
204        .unwrap();
205        r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
206        r
207    }
208
209    #[test]
210    fn detect_autoconf_raw_when_only_configure_ac() {
211        let d = tempfile::tempdir().unwrap();
212        touch(d.path(), "configure.ac");
213        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
214        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
215        assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
216        assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
217    }
218
219    #[test]
220    fn detect_autoconf_ready_when_configure_exists() {
221        let d = tempfile::tempdir().unwrap();
222        touch(d.path(), "configure.ac");
223        touch(d.path(), "configure");
224        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
225        let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
226        let c = p.configure.unwrap();
227        assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
228        assert!(c.ends_with("--enable-foo"), "{c}");
229    }
230
231    #[test]
232    fn detect_cmake() {
233        let d = tempfile::tempdir().unwrap();
234        touch(d.path(), "CMakeLists.txt");
235        assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
236        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
237        assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
238        assert!(p.compile.unwrap().contains("cmake --build _build"));
239        assert!(p.install.unwrap().contains("DESTDIR=/out"));
240    }
241
242    #[test]
243    fn detect_meson() {
244        let d = tempfile::tempdir().unwrap();
245        touch(d.path(), "meson.build");
246        assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
247        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
248        assert!(p.configure.unwrap().contains("meson setup _build"));
249        assert!(p.install.unwrap().contains("meson install"));
250    }
251
252    #[test]
253    fn detect_make() {
254        let d = tempfile::tempdir().unwrap();
255        touch(d.path(), "Makefile");
256        assert_eq!(detect_build_system(d.path()), BuildSys::Make);
257        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
258        assert!(p.configure.is_none(), "make plano no necesita configure");
259        assert!(p.compile.unwrap().starts_with("make"));
260    }
261
262    #[test]
263    fn unknown_returns_no_phases() {
264        let d = tempfile::tempdir().unwrap();
265        // sólo un README, ningún archivo característico.
266        std::fs::write(d.path().join("README"), b"hi").unwrap();
267        assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
268        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
269        assert!(p.compile.is_none());
270        assert!(p.install.is_none());
271    }
272
273    #[test]
274    fn recipe_override_wins_over_heuristic() {
275        let d = tempfile::tempdir().unwrap();
276        touch(d.path(), "Makefile");
277        let mut r = recipe(&[]);
278        r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
279        r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
280        let p = resolve_phases(&r, d.path()).unwrap();
281        assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
282        assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
283    }
284}
285
286
287fn unique_out_dir(
288    work_root: &Path,
289    name: &str,
290    h: &ArtifactHash,
291) -> hammer_core::Result<PathBuf> {
292    let dir = work_root
293        .join("out")
294        .join(h.store_dir_name(name));
295    if dir.exists() {
296        std::fs::remove_dir_all(&dir)?;
297    }
298    std::fs::create_dir_all(&dir)?;
299    Ok(dir)
300}
301
302/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
303/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
304pub fn hydrate(
305    h: &ArtifactHash,
306    store: &Store,
307    target_fhs: &Path,
308    mode: LinkMode,
309) -> hammer_core::Result<HydrateReport> {
310    let artifact_dir = store.find_by_hash(h.as_str())?;
311    hydrate::hydrate(&artifact_dir, target_fhs, mode)
312}