Files
takana/crates/takana-build/tests/end_to_end_grep.rs
T
Sergio 24bcf1783c takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*
hammer-{core,build,bootstrap,overlay,journal,mirror,upgrade,agent,recover,cli}
→ takana-*, con sus deps de workspace, sus identificadores en el fuente y las
referencias -p de los scripts.

VERIFICADO que no mueve nada del corpus: `takana hash recipes/zlib.toml`
devuelve b3:dc363f26… , idéntico a antes del renombre. Los nombres de crate no
entran en hash_inputs, pero eso se comprueba, no se supone. 600 tests en verde.

DOS BINARIOS SE CONGELAN, y no por prolijidad:

- `hammerd` — paquete Y binario. Es componente de Stage 1 de la distro (musl,
  busybox, hammerd, arje-zero), lo supervisa arje-zero en el sistema arrancado,
  `PRESEED=hammerd` lo nombra en selfhost-verify y sus bytes anclan el baseline
  of_tree. El nombre del crate va en los símbolos ⇒ renombrarlo mueve los bytes.

- `hammer-recover` — el PAQUETE se renombra a takana-recover, el BINARIO no.
  hammer-live-install.sh lo copia a /usr/sbin/hammer-recover en sistemas ya
  instalados y hornea un hook de arranque que lo invoca por ese nombre:
  renombrarlo rompe máquinas instaladas, no el repo.

Consecuencia que hay que anotar igual: al renombrar hammer-core, los bytes de
hammerd cambian de todos modos porque linkea contra un crate con otro nombre.
El baseline of_tree del selfhost hay que rehacerlo — es efecto de la etapa 4,
no de un cambio de hammerd.

Las referencias en comentarios de recetas y docs (rutas hammer-core/src/…)
quedan para la etapa 5: son texto, no mueven hash.
2026-09-09 18:46:41 +00:00

167 lines
5.8 KiB
Rust

//! End-to-end del primer entregable: receta `grep` real → build → hidratación → ejecución
//! dentro del rootfs Alpine. Es el test que cierra Fase 0 + Fase 1.
//!
//! Doblemente gateado para no penalizar `cargo test` por defecto:
//! - Requiere `.dev-fs/` bootstrapeado (rootfs Alpine + zig + cache).
//! - Requiere `HAMMER_NETWORK_TESTS=1` (descarga ~3 MB del tarball de GNU).
//!
//! Comparte la dev cache (`.dev-fs/cache`) para que la segunda corrida en una máquina dada
//! reuse los objetos de musl y termine en segundos.
use std::path::PathBuf;
use std::process::Command;
use takana_build::{build, run_hydrate, BuildConfig};
use takana_core::{LinkMode, Recipe, Store};
const RECIPE_TOML: &str = r#"
name = "grep"
version = "3.12"
[source]
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.12.tar.gz"
sha256 = "badda546dfc4b9d97e992e2c35f3b5c7f20522ffcbe2f01ba1e9cdcbe7644cdc"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
flags = ["--disable-perl-regexp", "--disable-nls"]
"#;
fn project_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
fn skip_with_reason(reason: &str) -> bool {
eprintln!("SKIP end_to_end_grep: {reason}");
true
}
fn should_skip(cfg: &BuildConfig) -> bool {
if std::env::var("HAMMER_NETWORK_TESTS").ok().as_deref() != Some("1") {
return skip_with_reason(
"HAMMER_NETWORK_TESTS != 1 (este test descarga ~3 MB de ftp.gnu.org y compila ~30s).",
);
}
if !cfg.rootfs.join("bin/busybox").is_file() {
return skip_with_reason("rootfs Alpine no bootstrapped (corre scripts/bootstrap-devfs.sh).");
}
if !cfg.rootfs.join("usr/bin/autoreconf").is_file()
|| !cfg.rootfs.join("usr/bin/ld").is_file()
{
return skip_with_reason(
"rootfs sin build tools (autoreconf/ld). Re-corre bootstrap-devfs.sh.",
);
}
if !cfg.zig_dir.join("zig").is_file() {
return skip_with_reason("zig no encontrado en .dev-fs/tools/zig.");
}
false
}
#[test]
fn grep_real_build_hydrate_run() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let project = project_root();
// El store y la salida hidratada DEBEN vivir en el mismo filesystem (los hardlinks no
// cruzan device boundaries). Mantenemos ambos bajo el repo, en un subdir efímero.
let scratch = project.join("work").join("test-e2e-grep");
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch).unwrap();
let store_root = scratch.join("store");
let work_root = scratch.join("work");
std::fs::create_dir_all(&store_root).unwrap();
std::fs::create_dir_all(&work_root).unwrap();
let cfg = BuildConfig {
rootfs: project.join(".dev-fs/alpine"),
zig_dir: project.join(".dev-fs/tools/zig"),
work_root,
cache_root: Some(project.join(".dev-fs/cache")),
};
if should_skip(&cfg) {
return;
}
// 1) Receta
let recipe_dir = scratch.join("recipe");
std::fs::create_dir_all(&recipe_dir).unwrap();
let recipe_path = recipe_dir.join("grep.toml");
std::fs::write(&recipe_path, RECIPE_TOML).unwrap();
let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
// 2) Build
let store = Store::open(&store_root).unwrap();
let h = build(&recipe, &cfg, &store).expect("build grep");
let sealed = store.path_of(&h, &recipe.name);
let grep = sealed.join("usr/bin/grep");
assert!(grep.is_file(), "esperaba {}", grep.display());
// ELF estático: cabecera ELF + ningún DT_NEEDED (chequeo barato con `file`/`readelf` si
// existen, fallback al magic byte).
let header = std::fs::read(&grep).unwrap();
assert_eq!(&header[..4], b"\x7fELF", "grep debe ser ELF");
// 3) Hidratación al mismo filesystem
let fhs = scratch.join("fhs");
let report = run_hydrate(&sealed, &fhs, LinkMode::Static, None).expect("hydrate");
assert!(report.files.iter().any(|f| f.dst.ends_with("usr/bin/grep")));
// Compartir inode con el store: la propiedad central de hidratar por hardlink.
use std::os::unix::fs::MetadataExt;
let src_ino = std::fs::metadata(&grep).unwrap().ino();
let dst_ino = std::fs::metadata(fhs.join("usr/bin/grep")).unwrap().ino();
assert_eq!(src_ino, dst_ino, "hardlink: inode debe coincidir");
// 4) Ejecutar dentro del rootfs Alpine bindeando el binario hidratado como /usr/bin/grep.
// Ejercitamos un patrón regex y un literal; v3.12 NO debe dar "memory exhausted".
let out = Command::new("bwrap")
.args([
"--overlay-src",
cfg.rootfs.to_str().unwrap(),
"--tmp-overlay",
"/",
"--ro-bind",
fhs.join("usr/bin/grep").to_str().unwrap(),
"/usr/bin/grep",
"--proc",
"/proc",
"--dev",
"/dev",
"--unshare-all",
"/usr/bin/grep",
"-oE",
"[0-9]+",
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn bwrap");
use std::io::Write;
out.stdin
.as_ref()
.unwrap()
.write_all(b"prefijo 12345 sufijo\n")
.unwrap();
let out = out.wait_with_output().expect("wait bwrap");
assert!(
out.status.success(),
"grep en bwrap falló: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "12345");
// 5) Cache hit en segunda build: mismo hash.
let h2 = build(&recipe, &cfg, &store).expect("rebuild grep");
assert_eq!(h, h2, "segunda build debe ser cache hit");
}