Heurística de build systems: autotools, cmake, meson, make
detect_build_system inspecciona el árbol del source y elige los comandos: - configure.ac sin configure → autoreconf -fi && ./configure --prefix=/usr - configure presente → ./configure --prefix=/usr - CMakeLists.txt → cmake -B _build ... ; cmake --build ; cmake --install - meson.build → meson setup _build ... ; meson compile ; meson install - Makefile / GNUmakefile → make -j && make install DESTDIR=/out PREFIX=/usr - nada → la receta debe traer [build.phases] Overrides en la receta siguen ganando. recipe.build.flags se concatenan al configure. Cuando link = "static", el orquestador inyecta LDFLAGS=-static para que el link final autotools/cmake produzca el binario monolítico que quiere nuestra hidratación primaria. Tests: - 7 unit tests de detección + emisión de comandos por sistema. - 1 integration test end-to-end con autotools real (configure.ac + Makefile.am + hello.c → autoreconf+configure+make+install dentro del sandbox, sellado, ejecutado bajo bwrap). Requiere el rootfs Alpine con `apk add make autoconf automake m4 patch coreutils libtool pkgconf bash`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7b362e9d6a
commit
736e73e2ef
@@ -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<Phases> {
|
||||
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,
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user