diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index 6e9ae901..3c1de99b 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -49,12 +49,18 @@ pub fn build( } let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?; + let mut env: Vec<(String, String)> = Vec::new(); + if matches!(recipe.build.link, LinkMode::Static) { + // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario + // monolítico que pide nuestra estrategia de hidratación primaria. + env.push(("LDFLAGS".into(), "-static".into())); + } let sb = Sandbox { rootfs: cfg.rootfs.clone(), zig_dir: cfg.zig_dir.clone(), src_dir: src_tree.clone(), out_dir: out_dir.clone(), - env: Vec::new(), + env, }; let phases = resolve_phases(recipe, &src_tree)?; @@ -84,22 +90,196 @@ pub fn build( Ok(h) } -/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos > heurística -/// (de momento: presencia de `Makefile` → make). +/// Sistemas de build que reconoce la heurística por inspección del árbol del source. +/// Ver `docs/02-build-lab.md` §4. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BuildSys { + /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero. + AutoconfRaw, + /// `configure` ya está en el tarball release: vamos directo a `./configure`. + AutoconfReady, + /// `CMakeLists.txt`. + CMake, + /// `meson.build`. + Meson, + /// `Makefile` plano (o `makefile`) sin sistema generador. + Make, + /// No detectamos nada: la receta debe traer overrides en `[build.phases]`. + Unknown, +} + +fn detect_build_system(src: &Path) -> BuildSys { + let has = |p: &str| src.join(p).exists(); + // El orden importa: cuando coexisten varios (un tarball release de autotools trae el + // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el + // sistema "más alto" que sí sabe regenerar todo. + if has("configure.ac") || has("configure.in") { + if has("configure") { + return BuildSys::AutoconfReady; + } + return BuildSys::AutoconfRaw; + } + if has("configure") { + return BuildSys::AutoconfReady; + } + if has("CMakeLists.txt") { + return BuildSys::CMake; + } + if has("meson.build") { + return BuildSys::Meson; + } + if has("Makefile") || has("makefile") || has("GNUmakefile") { + return BuildSys::Make; + } + BuildSys::Unknown +} + +/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta +/// ganan; el resto lo deriva la heurística según el sistema de build detectado. fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result { let mut out = recipe.build.phases.clone(); + let bs = detect_build_system(src); + let flags = recipe.build.flags.join(" "); + let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") }; + + if out.configure.is_none() { + out.configure = match bs { + BuildSys::AutoconfRaw => Some(format!( + "autoreconf -fi && ./configure --prefix=/usr{flags_suffix}" + )), + BuildSys::AutoconfReady => Some(format!("./configure --prefix=/usr{flags_suffix}")), + BuildSys::CMake => Some(format!( + "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}" + )), + BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")), + BuildSys::Make | BuildSys::Unknown => None, + }; + } if out.compile.is_none() { - if src.join("Makefile").exists() || src.join("makefile").exists() { - out.compile = Some("make".to_string()); - if out.install.is_none() { - out.install = Some("make install DESTDIR=/out PREFIX=/usr".to_string()); + out.compile = match bs { + BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => { + Some(r#"make -j"$(nproc)""#.to_string()) } - } - // TODO(fase-0): configure.ac → autoreconf+./configure; CMakeLists.txt → cmake; meson.build → meson. + BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()), + BuildSys::Meson => Some("meson compile -C _build".to_string()), + BuildSys::Unknown => None, + }; + } + if out.install.is_none() { + out.install = match bs { + BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => { + Some("make install DESTDIR=/out PREFIX=/usr".to_string()) + } + BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()), + BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()), + BuildSys::Unknown => None, + }; } Ok(out) } +#[cfg(test)] +mod heuristic_tests { + use super::*; + + fn touch(dir: &Path, name: &str) { + std::fs::write(dir.join(name), b"").unwrap(); + } + + fn recipe(flags: &[&str]) -> Recipe { + let mut r = Recipe::from_toml( + r#" +name = "x" +version = "0" +[source] +repo = "git://x" +commit = "deadbeef" +[build] +"#, + ) + .unwrap(); + r.build.flags = flags.iter().map(|s| (*s).to_string()).collect(); + r + } + + #[test] + fn detect_autoconf_raw_when_only_configure_ac() { + let d = tempfile::tempdir().unwrap(); + touch(d.path(), "configure.ac"); + assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw); + let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); + assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&")); + assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out")); + } + + #[test] + fn detect_autoconf_ready_when_configure_exists() { + let d = tempfile::tempdir().unwrap(); + touch(d.path(), "configure.ac"); + touch(d.path(), "configure"); + assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady); + let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap(); + let c = p.configure.unwrap(); + assert!(c.starts_with("./configure --prefix=/usr"), "{c}"); + assert!(c.ends_with("--enable-foo"), "{c}"); + } + + #[test] + fn detect_cmake() { + let d = tempfile::tempdir().unwrap(); + touch(d.path(), "CMakeLists.txt"); + assert_eq!(detect_build_system(d.path()), BuildSys::CMake); + let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); + assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr")); + assert!(p.compile.unwrap().contains("cmake --build _build")); + assert!(p.install.unwrap().contains("DESTDIR=/out")); + } + + #[test] + fn detect_meson() { + let d = tempfile::tempdir().unwrap(); + touch(d.path(), "meson.build"); + assert_eq!(detect_build_system(d.path()), BuildSys::Meson); + let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); + assert!(p.configure.unwrap().contains("meson setup _build")); + assert!(p.install.unwrap().contains("meson install")); + } + + #[test] + fn detect_make() { + let d = tempfile::tempdir().unwrap(); + touch(d.path(), "Makefile"); + assert_eq!(detect_build_system(d.path()), BuildSys::Make); + let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); + assert!(p.configure.is_none(), "make plano no necesita configure"); + assert!(p.compile.unwrap().starts_with("make")); + } + + #[test] + fn unknown_returns_no_phases() { + let d = tempfile::tempdir().unwrap(); + // sólo un README, ningún archivo característico. + std::fs::write(d.path().join("README"), b"hi").unwrap(); + assert_eq!(detect_build_system(d.path()), BuildSys::Unknown); + let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); + assert!(p.compile.is_none()); + assert!(p.install.is_none()); + } + + #[test] + fn recipe_override_wins_over_heuristic() { + let d = tempfile::tempdir().unwrap(); + touch(d.path(), "Makefile"); + let mut r = recipe(&[]); + r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string()); + r.build.phases.install = Some("cp foo /out/bin/foo".to_string()); + let p = resolve_phases(&r, d.path()).unwrap(); + assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c"); + assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo"); + } +} + + fn unique_out_dir( work_root: &Path, name: &str, diff --git a/crates/hammer-build/tests/end_to_end_autotools.rs b/crates/hammer-build/tests/end_to_end_autotools.rs new file mode 100644 index 00000000..1380b14a --- /dev/null +++ b/crates/hammer-build/tests/end_to_end_autotools.rs @@ -0,0 +1,161 @@ +//! Integration test del path autotools: +//! - Repo git con `configure.ac` + `Makefile.am` + `hello.c` (sin `configure` generado). +//! - Receta SIN `[build.phases]` → la heurística debe detectar AutoconfRaw y emitir +//! `autoreconf -fi && ./configure --prefix=/usr` + `make` + `make install DESTDIR=/out`. +//! - El binario instalado en el store debe correr en el sandbox. +//! +//! Skip transparente si el rootfs Alpine o zig no están bootstrappeados. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use hammer_build::{build, BuildConfig}; +use hammer_core::{Recipe, Store}; + +const HELLO_C: &str = r#" +#include +int main(void) { puts("hammer-autotools OK"); return 0; } +"#; + +const CONFIGURE_AC: &str = r#" +AC_INIT([helloat], [1.0]) +AM_INIT_AUTOMAKE([foreign -Wall]) +AC_PROG_CC +AC_CONFIG_FILES([Makefile]) +AC_OUTPUT +"#; + +const MAKEFILE_AM: &str = r#" +bin_PROGRAMS = helloat +helloat_SOURCES = hello.c +"#; + +const RECIPE_TOML: &str = r#" +name = "helloat" +version = "1.0" + +[source] +repo = "PLACEHOLDER_REPO_URL" +commit = "PLACEHOLDER_COMMIT" + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "static" +"#; + +fn project_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} + +fn skip_if_no_layout(cfg: &BuildConfig) -> bool { + if !cfg.rootfs.join("bin/busybox").is_file() { + eprintln!("SKIP: rootfs Alpine no encontrado en {}.", cfg.rootfs.display()); + return true; + } + // Las tools de autotools se metieron con `apk add make autoconf automake m4 ...` + // — comprobamos uno para no engañarnos si el rootfs es el minirootfs pelado. + if !cfg.rootfs.join("usr/bin/autoreconf").is_file() { + eprintln!( + "SKIP: rootfs sin autotools. Corre: bwrap --bind {} / --share-net /sbin/apk add make autoconf automake m4 patch coreutils libtool pkgconf bash", + cfg.rootfs.display() + ); + return true; + } + if !cfg.zig_dir.join("zig").is_file() { + eprintln!("SKIP: zig no encontrado en {}.", cfg.zig_dir.display()); + return true; + } + false +} + +fn git(args: &[&str], cwd: &Path) { + let st = Command::new("git").args(args).current_dir(cwd).status().expect("git"); + assert!(st.success(), "git {args:?} falló"); +} + +fn init_repo(upstream: &Path) -> String { + std::fs::create_dir_all(upstream).unwrap(); + std::fs::write(upstream.join("configure.ac"), CONFIGURE_AC).unwrap(); + std::fs::write(upstream.join("Makefile.am"), MAKEFILE_AM).unwrap(); + std::fs::write(upstream.join("hello.c"), HELLO_C).unwrap(); + git(&["init", "-q", "-b", "main"], upstream); + git(&["config", "user.email", "test@hammer"], upstream); + git(&["config", "user.name", "hammer-test"], upstream); + git(&["add", "."], upstream); + git(&["commit", "-q", "-m", "init"], upstream); + let out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(upstream) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +#[test] +fn build_autotools_hello_end_to_end() { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + + let project = project_root(); + let cfg = BuildConfig { + rootfs: project.join(".dev-fs/alpine"), + zig_dir: project.join(".dev-fs/tools/zig"), + work_root: tempfile::tempdir().unwrap().keep(), + }; + if skip_if_no_layout(&cfg) { + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let store_root = tmp.path().join("store"); + std::fs::create_dir_all(&store_root).unwrap(); + + let upstream = tmp.path().join("upstream"); + let commit = init_repo(&upstream); + let repo_url = format!("file://{}", upstream.display()); + + let recipe_dir = tmp.path().join("recipe"); + std::fs::create_dir_all(&recipe_dir).unwrap(); + let toml = RECIPE_TOML + .replace("PLACEHOLDER_REPO_URL", &repo_url) + .replace("PLACEHOLDER_COMMIT", &commit); + let recipe_path = recipe_dir.join("helloat.toml"); + std::fs::write(&recipe_path, toml).unwrap(); + let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe"); + + let store = Store::open(&store_root).unwrap(); + let h = build(&recipe, &cfg, &store).expect("build helloat"); + let sealed = store.path_of(&h, &recipe.name); + + let bin = sealed.join("usr/bin/helloat"); + assert!(bin.is_file(), "esperaba {}", bin.display()); + + let header = std::fs::read(&bin).unwrap(); + assert_eq!(&header[..4], b"\x7fELF", "es ELF"); + + let out = Command::new("bwrap") + .args([ + "--overlay-src", + cfg.rootfs.to_str().unwrap(), + "--tmp-overlay", + "/", + "--proc", + "/proc", + "--dev", + "/dev", + "--ro-bind", + sealed.to_str().unwrap(), + "/art", + "--unshare-all", + "/art/usr/bin/helloat", + ]) + .output() + .expect("spawn bwrap"); + assert!(out.status.success(), "helloat falló: {:?}", out); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-autotools OK"); +}