bootstrap: builder rootfs — fixes destapados al armarlo con caché real

Al construir el hammer estático musl y armar el builder con --work-cache work,
dos problemas reales (cazados antes de llenar el disco):

1. Recursión. --work-cache work --out work/builder-rootfs deja el destino DENTRO
   de la caché: copiar la caché se tragaba el propio builder a medio armar
   (work/builder-rootfs/work/builder-rootfs/… sin fondo). Fix: link_or_copy_tree
   toma un `skip` (path canónico a no descender), que corta el ciclo.

2. Bloat de sources. --work-cache copiaba TODO work/, incluidos los árboles ya
   materializados de work/sources (4 GB, regenerables). Fix: sólo se embeben
   work/repos (mirrors git) y work/tarballs (descargas verificadas); de ahí
   `fetch` rematerializa los sources en la VM. El builder pasa de ~5 GB a ~750 MB.

+2 tests (recursión termina y no se auto-copia; sources/ no viaja). Runbook §8c
aclara la semántica de --work-cache. El hammer estático musl
(target/x86_64-unknown-linux-musl/release/hammer) sale static-pie linked, listo
para bootear como PID-algo en la VM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-11 14:52:49 +00:00
co-authored by Claude Opus 4.8
parent f17a8fa301
commit 83d4b2aa17
2 changed files with 59 additions and 8 deletions
+55 -6
View File
@@ -595,7 +595,16 @@ fn recipes_digest(dir: &Path) -> Result<String> {
/// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y
/// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder
/// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`).
fn link_or_copy_tree(src: &Path, dst: &Path) -> Result<()> {
///
/// `skip` es un path a no descender: imprescindible cuando `dst` vive **dentro** de `src` (p. ej.
/// `--work-cache work --out work/builder-rootfs`), donde copiar `src` ciegamente se tragaría el
/// propio destino en una recursión infinita. Se compara por path canónico.
fn link_or_copy_tree(src: &Path, dst: &Path, skip: Option<&Path>) -> Result<()> {
if let Some(skip) = skip {
if src.canonicalize().ok().as_deref() == Some(skip) {
return Ok(());
}
}
let meta = std::fs::symlink_metadata(src)?;
let ft = meta.file_type();
if ft.is_symlink() {
@@ -606,7 +615,7 @@ fn link_or_copy_tree(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()), skip)?;
}
} else if ft.is_file() && std::fs::hard_link(src, dst).is_err() {
std::fs::copy(src, dst)?;
@@ -641,7 +650,7 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result
spec.toolchain_src.display()
)));
}
link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"))?;
link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?;
// 4) Semilla → /store/<hash>-seed-<kind>, tal cual está sellada afuera, para que el `hammer` de
// adentro resuelva el toolchain por hash sin re-ingerirla.
@@ -657,6 +666,7 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result
link_or_copy_tree(
&seed_dir,
&staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)),
None,
)?;
// 5) Binario hammer → /usr/bin/hammer (+x).
@@ -675,10 +685,17 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result
}
}
// 7) Caché de fuentes → /work (opcional, para rebuild offline).
// 7) Caché de fetch → /work (opcional, para rebuild offline). Sólo `repos/` (mirrors git) y
// `tarballs/` (descargas verificadas): de ahí `fetch` rematerializa los árboles fuente. NO se
// copia `sources/` (los árboles ya materializados) — es regenerable y pesado (varios GB). El
// `skip = staging` es defensa extra contra `--work-cache work --out work/builder-rootfs`.
if let Some(cache) = &spec.work_cache {
if cache.is_dir() {
link_or_copy_tree(cache, &staging.join("work"))?;
let skip = staging.canonicalize().ok();
for sub in ["repos", "tarballs"] {
let src = cache.join(sub);
if src.is_dir() {
link_or_copy_tree(&src, &staging.join("work").join(sub), skip.as_deref())?;
}
}
}
@@ -1300,6 +1317,38 @@ mod tests {
assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}");
}
#[test]
fn builder_rootfs_with_work_cache_containing_out_dir_terminates() {
// Regresión: --work-cache work --out work/builder-rootfs. Copiar la caché no debe tragarse
// el propio destino (recursión infinita que llena el disco). El skip lo corta.
let tmp = tempfile::tempdir().unwrap();
let (store, mut spec) = builder_fixture(tmp.path());
// Caché de fetch con un mirror sintético + un árbol sources/ regenerable (no debe viajar).
let work = tmp.path().join("work");
std::fs::create_dir_all(work.join("repos/musl.git")).unwrap();
std::fs::write(work.join("repos/musl.git/HEAD"), b"ref: refs/heads/master\n").unwrap();
std::fs::create_dir_all(work.join("sources/musl-abc")).unwrap();
std::fs::write(work.join("sources/musl-abc/configure"), b"#!/bin/sh\n").unwrap();
spec.work_cache = Some(work.clone());
// out_dir vive DENTRO de la caché: el caso que disparaba la recursión.
let out = work.join("builder-rootfs");
let report = builder_rootfs(&spec, &store, &out).unwrap();
assert!(report.builder_hash.as_str().starts_with("b3:"));
// Sólo viajan los mirrors; el destino NO se copió en sí mismo; sources/ NO se embebe.
assert!(out.join("work/repos/musl.git/HEAD").is_file(), "los mirrors git se embeben");
assert!(
!out.join("work/builder-rootfs").exists(),
"el out_dir no debe haberse copiado en su propio /work (recursión)",
);
assert!(
!out.join("work/sources").exists(),
"sources/ es regenerable y pesado: no debe viajar en el builder",
);
}
#[test]
fn builder_rootfs_errors_when_stage1_unsealed() {
let tmp = tempfile::tempdir().unwrap();