TAKANA_X con caída a HAMMER_X (ADR 0016). No es un sed: estas variables son
contrato de usuario —knobs del instalador, el entorno del worker, mirror-env.sh—
y viven en perfiles de shell y units FUERA del repo, así que un renombre duro no
falla ruidosamente: la variable no aparece, se toma el default y el build se
comporta distinto sin que nada lo diga.
Rust: takana_core::env::{var,var_os} toma el nombre canónico y deriva el viejo
cambiando el prefijo — se le pasa TAKANA_* para que un grep del nombre nuevo
encuentre todas las lecturas. 5 tests, con DOS controles negativos: sin ninguna
de las dos no hay valor, y un nombre sin prefijo no inventa una caída.
Migrados los 17 sitios directos y los indirectos que el grep no mostraba
(bases_de_mirror, las constantes de kernel_cmd, ROOT_ENV de qorpa, env_path de
recover). takana-recover lleva la caída inline: es un mini-binario que se copia
a /usr/sbin y no vale arrastrarle una dep entera por dos líneas.
Scripts: 30 lecturas pasan a default, manteniendo el
nombre INTERNO de la variable para no tocar sus 190 usos.
Y donde el script EXPORTA en vez de leer, se ponen LAS DOS (mirror-env.sh y el
fragmento in-VM de bootstrap): ahí el lector puede ser un binario viejo —worker
sin recompilar, el /usr/bin/hammer pinado del baseline— que sólo conoce HAMMER_*.
La caída sirve para lectores nuevos; los viejos necesitan que la vieja siga puesta.
Verificado de punta a punta con el binario, no sólo con unit tests: HAMMER_LAB
sigue surtiendo efecto, TAKANA_LAB hace lo mismo, y con las dos gana TAKANA_LAB.
605 tests en verde y el hash de zlib sigue en b3:dc363f26… , intacto.
Cambio de comportamiento que va aparte y hay que decir: el hostname por defecto
de una instalación nueva pasa de 'hammer' a 'takana' (sólo si no se fija ninguna
de las dos variables).
NO se tocan: las rutas /var/lib/hammer de sistemas instalados, el volid
HAMMER_LIVE del ISO, ni el namespace HARKAQ_*, que es de otro subsistema.
167 lines
5.8 KiB
Rust
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 `TAKANA_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 takana_core::env::var("TAKANA_NETWORK_TESTS").as_deref() != Some("1") {
|
|
return skip_with_reason(
|
|
"TAKANA_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");
|
|
}
|