build: desacoplar el lab (.dev-fs) del padre del store
Atar el lab al PADRE DEL STORE era una suposición oculta y cara. Al anclar el store de un worker a un volumen persistente, el lab pasó a buscarse en /mnt/cosecha/.dev-fs —que no existe— y toda receta con `zig_version` murió con "no encuentro el ejecutable zig en …", un mensaje que apunta al lugar equivocado: el zig estaba, y estaba bien, en /opt/hammer/.dev-fs/tools/. Dos campañas leyeron eso como deuda de la cascada GUI. El commit anterior lo tapó con un bind-mount, que respeta la suposición en vez de eliminarla; esto la elimina. Son dos decisiones independientes: dónde se GUARDA lo sellado (almacenamiento) y dónde vive el toolchain de desarrollo (entorno). `work_root` NO se desacopla, y es deliberado: el seal final es un rename, que sólo es atómico dentro del mismo filesystem, así que work DEBE seguir al store. Ése es un acoplamiento real, no una suposición — el test lo fija para que nadie lo "arregle" de más. Resolución del lab, de más explícito a más adivinado: `HAMMER_LAB` → hermano del store si existe (preserva EXACTAMENTE el comportamiento histórico en la topología normal) → hacia arriba desde el CWD, como git con .git (ésta es la que desacopla) → None, que cae al default de siempre para que los errores sigan apuntando a un lugar previsible. La parte que huele el entorno (env + CWD) queda sólo en `from_env_or_defaults`; `defaults_for_store` se mantiene PURA para que los tests sigan siendo herméticos. Los hashes NO se mueven: `artifact_hash` no recibe BuildConfig y `hash_inputs` sólo mezcla contenido de la receta (source id, compiler, target, link, el string zig_version, patches, flags, phases, hashes de deps) — ninguna ruta del lab. Verificado empíricamente además de por lectura: `hammer hash --check` sobre el corpus da 754 selladas / 14 sin sellar de 768, calcado al grafo de estado (12 deuda + 2 nunca). Si algún hash se hubiera movido, una sellada diría NO-SELLADO. Verificado también end-to-end: con un store cuyo padre no tiene .dev-fs, hammer sube desde el CWD, encuentra el lab y construye (antes moría en el acto); y la topología normal sigue dando cache-hit instantáneo con el mismo hash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,27 +21,88 @@ pub struct BuildConfig {
|
||||
pub cache_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Nombre del directorio del lab, buscado hacia arriba como hace git con `.git`.
|
||||
const LAB_DIR: &str = ".dev-fs";
|
||||
|
||||
/// Busca el lab (`.dev-fs`) desde `start` hacia arriba. Pura y testeable: no mira env ni CWD.
|
||||
fn find_lab_upward(start: &Path) -> Option<PathBuf> {
|
||||
start.ancestors().map(|d| d.join(LAB_DIR)).find(|c| c.is_dir())
|
||||
}
|
||||
|
||||
/// Resuelve dónde vive el lab SIN atarlo a dónde vive el store.
|
||||
///
|
||||
/// Orden, de más explícito a más adivinado:
|
||||
/// 1. `HAMMER_LAB` — el operador manda.
|
||||
/// 2. Hermano del store (`<store>/../.dev-fs`) **si existe**. Preserva exactamente el
|
||||
/// comportamiento histórico en la topología normal (lab y store en la raíz del proyecto).
|
||||
/// 3. Hacia arriba desde el CWD. Ésta es la que desacopla: cubre el caso en que el store fue
|
||||
/// movido a otro filesystem (volumen persistente) y el lab se quedó en el proyecto.
|
||||
/// 4. `None` ⇒ el llamador cae al default histórico, para que los mensajes de error sigan
|
||||
/// apuntando a un lugar previsible en vez de a la nada.
|
||||
fn resolve_lab_dir(store_root: &Path) -> Option<PathBuf> {
|
||||
if let Some(v) = std::env::var_os("HAMMER_LAB") {
|
||||
return Some(PathBuf::from(v));
|
||||
}
|
||||
if let Some(parent) = store_root.parent() {
|
||||
let sibling = parent.join(LAB_DIR);
|
||||
if sibling.is_dir() {
|
||||
return Some(sibling);
|
||||
}
|
||||
}
|
||||
find_lab_upward(&std::env::current_dir().ok()?)
|
||||
}
|
||||
|
||||
impl BuildConfig {
|
||||
/// Defaults relativos al store: `.dev-fs` y `work/` como hermanos del store.
|
||||
/// `store_root = /…/store` → rootfs = `/…/.dev-fs/alpine`, zig = `/…/.dev-fs/tools/zig`,
|
||||
/// work = `/…/work`, cache = `/…/.dev-fs/cache`.
|
||||
///
|
||||
/// PURA a propósito (no mira env ni CWD): los tests montan configs herméticas con esto. Quien
|
||||
/// quiera la resolución real del entorno usa [`Self::from_env_or_defaults`].
|
||||
pub fn defaults_for_store(store_root: &Path) -> Self {
|
||||
let project_root = store_root
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
Self::defaults_for_store_with_lab(store_root, &project_root.join(LAB_DIR))
|
||||
}
|
||||
|
||||
/// Igual que [`Self::defaults_for_store`] pero con el lab (`.dev-fs`) puesto a mano.
|
||||
///
|
||||
/// POR QUÉ EXISTE (2026-07-22): atar el lab al PADRE DEL STORE resultó ser una suposición
|
||||
/// oculta y cara. Al anclar el store de un worker a un volumen persistente (symlink a
|
||||
/// `/mnt/cosecha/store`), el lab pasó a buscarse en `/mnt/cosecha/.dev-fs` — que no existe — y
|
||||
/// TODA receta con `zig_version` murió con "no encuentro el ejecutable zig en …", un mensaje
|
||||
/// que apunta al lugar equivocado (el zig estaba, y estaba bien). Dos campañas leyeron eso como
|
||||
/// deuda de la cascada GUI. Son dos decisiones independientes: dónde se GUARDA lo sellado
|
||||
/// (almacenamiento) y dónde vive el toolchain de desarrollo (entorno).
|
||||
///
|
||||
/// `work_root` SÍ sigue al store, y es deliberado: el `seal` final es un rename, que sólo es
|
||||
/// atómico dentro del mismo filesystem. Ése es un acoplamiento real, no una suposición.
|
||||
pub fn defaults_for_store_with_lab(store_root: &Path, lab: &Path) -> Self {
|
||||
let store_parent = store_root
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
Self {
|
||||
rootfs: project_root.join(".dev-fs/alpine"),
|
||||
zig_dir: project_root.join(".dev-fs/tools/zig"),
|
||||
work_root: project_root.join("work"),
|
||||
cache_root: Some(project_root.join(".dev-fs/cache")),
|
||||
rootfs: lab.join("alpine"),
|
||||
zig_dir: lab.join("tools/zig"),
|
||||
work_root: store_parent.join("work"),
|
||||
cache_root: Some(lab.join("cache")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lee overrides desde entorno (`HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`,
|
||||
/// Lee overrides desde entorno (`HAMMER_LAB`, `HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`,
|
||||
/// `HAMMER_CACHE`). `HAMMER_CACHE=""` (vacío) desactiva la caché.
|
||||
///
|
||||
/// Es el constructor que HUELE EL ENTORNO (env + CWD); `defaults_for_store` se mantiene pura.
|
||||
/// `HAMMER_LAB` mueve el lab entero de una; los `HAMMER_ROOTFS`/`HAMMER_ZIG`/`HAMMER_CACHE`
|
||||
/// siguen pisando pieza por pieza y ganan sobre él.
|
||||
pub fn from_env_or_defaults(store_root: &Path) -> Self {
|
||||
let mut cfg = Self::defaults_for_store(store_root);
|
||||
let mut cfg = match resolve_lab_dir(store_root) {
|
||||
Some(lab) => Self::defaults_for_store_with_lab(store_root, &lab),
|
||||
None => Self::defaults_for_store(store_root),
|
||||
};
|
||||
if let Ok(v) = std::env::var("HAMMER_ROOTFS") {
|
||||
cfg.rootfs = PathBuf::from(v);
|
||||
}
|
||||
@@ -57,3 +118,64 @@ impl BuildConfig {
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// La topología normal (lab y store hermanos en la raíz) no se mueve.
|
||||
#[test]
|
||||
fn lab_hermano_del_store_sigue_igual() {
|
||||
let cfg = BuildConfig::defaults_for_store(Path::new("/proj/store"));
|
||||
assert_eq!(cfg.rootfs, Path::new("/proj/.dev-fs/alpine"));
|
||||
assert_eq!(cfg.zig_dir, Path::new("/proj/.dev-fs/tools/zig"));
|
||||
assert_eq!(cfg.cache_root.unwrap(), Path::new("/proj/.dev-fs/cache"));
|
||||
assert_eq!(cfg.work_root, Path::new("/proj/work"));
|
||||
}
|
||||
|
||||
/// LA REGRESIÓN: store mudado a un volumen, lab quieto en el proyecto. El lab debe seguir al
|
||||
/// proyecto y NO al store; `work` debe seguir al STORE (mismo filesystem ⇒ seal atómico).
|
||||
#[test]
|
||||
fn lab_y_store_pueden_vivir_en_filesystems_distintos() {
|
||||
let cfg = BuildConfig::defaults_for_store_with_lab(
|
||||
Path::new("/mnt/cosecha/store"),
|
||||
Path::new("/opt/hammer/.dev-fs"),
|
||||
);
|
||||
assert_eq!(cfg.zig_dir, Path::new("/opt/hammer/.dev-fs/tools/zig"));
|
||||
assert_eq!(cfg.rootfs, Path::new("/opt/hammer/.dev-fs/alpine"));
|
||||
assert_eq!(cfg.cache_root.unwrap(), Path::new("/opt/hammer/.dev-fs/cache"));
|
||||
assert_eq!(
|
||||
cfg.work_root,
|
||||
Path::new("/mnt/cosecha/work"),
|
||||
"work DEBE seguir al store: el seal es un rename y sólo es atómico en el mismo FS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_lab_upward_encuentra_desde_un_subdirectorio() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let raiz = d.path().join("proj");
|
||||
std::fs::create_dir_all(raiz.join(LAB_DIR)).unwrap();
|
||||
let hondo = raiz.join("a/b/c");
|
||||
std::fs::create_dir_all(&hondo).unwrap();
|
||||
assert_eq!(find_lab_upward(&hondo), Some(raiz.join(LAB_DIR)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_lab_upward_no_inventa_lo_que_no_existe() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let hondo = d.path().join("sin/lab/alguno");
|
||||
std::fs::create_dir_all(&hondo).unwrap();
|
||||
assert_eq!(find_lab_upward(&hondo), None);
|
||||
}
|
||||
|
||||
/// Un fichero llamado `.dev-fs` no es un lab: la búsqueda exige directorio.
|
||||
#[test]
|
||||
fn find_lab_upward_ignora_un_fichero_homonimo() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let raiz = d.path().join("proj");
|
||||
std::fs::create_dir_all(&raiz).unwrap();
|
||||
std::fs::write(raiz.join(LAB_DIR), b"no soy un lab").unwrap();
|
||||
assert_eq!(find_lab_upward(&raiz), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user