- `BuildConfig.cache_root` (override `HAMMER_CACHE`): bindea `/cache` al sandbox y exporta `ZIG_GLOBAL_CACHE_DIR=/cache/zig`. Evita ~30s de recompilación de musl en cada build. - `scripts/bootstrap-devfs.sh`: idempotente, descarga+verifica alpine-minirootfs 3.23.4 y zig 0.16.0 con sha256 fijo, instala build tools en el rootfs vía apk bajo bwrap. - `make_tree_read_only`: solo archivos regulares pierden `w`; los directorios conservan 0o755 para no estorbar GC ni `rm -rf` administrativo. La inmutabilidad estricta del store se delega al mount RO de la distro propia. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
185 lines
6.1 KiB
Rust
185 lines
6.1 KiB
Rust
//! Integration test del pipeline completo de Fase 0:
|
|
//! 1. Inicializa un repo git local con un `hello.c`.
|
|
//! 2. Escribe una receta TOML que apunta a ese repo con phases override.
|
|
//! 3. Llama a `hammer_build::build` con un store/work temporales.
|
|
//! 4. Verifica que el artefacto sellado contiene `usr/bin/hello`, es ELF estático y
|
|
//! se ejecuta dentro del sandbox imprimiendo el output esperado.
|
|
//!
|
|
//! El test se SALTA (con un mensaje claro) si el host no tiene el rootfs Alpine
|
|
//! y/o zig instalados en `.dev-fs/`. Eso permite que `cargo test` no falle en máquinas
|
|
//! limpias; el desarrollador lee el mensaje y corre el bootstrap.
|
|
|
|
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-e2e OK"); return 0; }
|
|
"#;
|
|
|
|
const RECIPE_TOML: &str = r#"
|
|
name = "hello"
|
|
version = "1.0"
|
|
|
|
[source]
|
|
repo = "PLACEHOLDER_REPO_URL"
|
|
commit = "PLACEHOLDER_COMMIT"
|
|
|
|
[build]
|
|
compiler = "zig-cc"
|
|
target = "x86_64-linux-musl"
|
|
link = "static"
|
|
|
|
[build.phases]
|
|
compile = "zig cc -static -O2 -o hello hello.c"
|
|
install = "install -D -m755 hello /out/usr/bin/hello"
|
|
"#;
|
|
|
|
fn project_root() -> PathBuf {
|
|
// CARGO_MANIFEST_DIR = crates/hammer-build → subir dos para llegar al workspace root.
|
|
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 {} — corre el bootstrap (ver README).",
|
|
cfg.rootfs.display()
|
|
);
|
|
return true;
|
|
}
|
|
if !cfg.zig_dir.join("zig").is_file() {
|
|
eprintln!(
|
|
"SKIP: zig no encontrado en {} — instalalo en .dev-fs/tools/zig.",
|
|
cfg.zig_dir.display()
|
|
);
|
|
return true;
|
|
}
|
|
if Command::new("git").arg("--version").output().is_err() {
|
|
eprintln!("SKIP: git no disponible en PATH.");
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn git(args: &[&str], cwd: &Path) {
|
|
let st = Command::new("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.status()
|
|
.expect("git spawn");
|
|
assert!(st.success(), "git {args:?} falló");
|
|
}
|
|
|
|
fn init_repo_with_hello(repo_dir: &Path) -> String {
|
|
std::fs::create_dir_all(repo_dir).unwrap();
|
|
std::fs::write(repo_dir.join("hello.c"), HELLO_C).unwrap();
|
|
git(&["init", "-q", "-b", "main"], repo_dir);
|
|
// Identidad local para el commit, sin tocar la global del usuario.
|
|
git(&["config", "user.email", "test@hammer"], repo_dir);
|
|
git(&["config", "user.name", "hammer-test"], repo_dir);
|
|
git(&["add", "."], repo_dir);
|
|
git(&["commit", "-q", "-m", "hello"], repo_dir);
|
|
let out = Command::new("git")
|
|
.args(["rev-parse", "HEAD"])
|
|
.current_dir(repo_dir)
|
|
.output()
|
|
.expect("rev-parse");
|
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn build_hello_end_to_end() {
|
|
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
|
|
|
|
let project = project_root();
|
|
let rootfs = project.join(".dev-fs/alpine");
|
|
let zig_dir = project.join(".dev-fs/tools/zig");
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let store_root = tmp.path().join("store");
|
|
let work_root = tmp.path().join("work");
|
|
std::fs::create_dir_all(&store_root).unwrap();
|
|
std::fs::create_dir_all(&work_root).unwrap();
|
|
|
|
let cfg = BuildConfig {
|
|
rootfs: rootfs.clone(),
|
|
zig_dir: zig_dir.clone(),
|
|
work_root,
|
|
// Reusa la caché global del dev — la primera corrida tras `cargo clean` paga
|
|
// ~30s recompilando musl; las siguientes terminan en segundos.
|
|
cache_root: Some(project.join(".dev-fs/cache")),
|
|
};
|
|
if skip_if_no_layout(&cfg) {
|
|
return;
|
|
}
|
|
|
|
// 1) repo upstream simulado
|
|
let upstream = tmp.path().join("upstream-hello");
|
|
let commit = init_repo_with_hello(&upstream);
|
|
let repo_url = format!("file://{}", upstream.display());
|
|
|
|
// 2) escribir receta
|
|
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("hello.toml");
|
|
std::fs::write(&recipe_path, toml).unwrap();
|
|
let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
|
|
|
|
// 3) build
|
|
let store = Store::open(&store_root).unwrap();
|
|
let h = build(&recipe, &cfg, &store).expect("build hello");
|
|
let sealed = store.path_of(&h, &recipe.name);
|
|
assert!(sealed.is_dir(), "store dir debe existir: {}", sealed.display());
|
|
|
|
// 4) artefacto correcto
|
|
let hello = sealed.join("usr/bin/hello");
|
|
assert!(hello.is_file(), "usr/bin/hello debe existir");
|
|
let meta = std::fs::metadata(&hello).unwrap();
|
|
use std::os::unix::fs::PermissionsExt;
|
|
assert_eq!(meta.permissions().mode() & 0o111, 0o111, "ejecutable");
|
|
// Read-only en el store
|
|
assert_eq!(meta.permissions().mode() & 0o222, 0, "read-only en el store");
|
|
|
|
// ELF
|
|
let header = std::fs::read(&hello).unwrap();
|
|
assert_eq!(&header[..4], b"\x7fELF", "es ELF");
|
|
|
|
// 5) ejecuta dentro de bwrap+rootfs y observa el output
|
|
let out = Command::new("bwrap")
|
|
.args([
|
|
"--overlay-src",
|
|
rootfs.to_str().unwrap(),
|
|
"--tmp-overlay",
|
|
"/",
|
|
"--proc",
|
|
"/proc",
|
|
"--dev",
|
|
"/dev",
|
|
"--ro-bind",
|
|
sealed.to_str().unwrap(),
|
|
"/artefacto",
|
|
"--unshare-all",
|
|
"/artefacto/usr/bin/hello",
|
|
])
|
|
.output()
|
|
.expect("spawn bwrap run hello");
|
|
assert!(out.status.success(), "hello no corrió: {:?}", out);
|
|
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-e2e OK");
|
|
|
|
// 6) cache: segunda build con misma receta no rehidrata
|
|
let h2 = build(&recipe, &cfg, &store).expect("second build");
|
|
assert_eq!(h, h2);
|
|
}
|