//! Configuración del lab: paths del rootfs base, del compilador inyectado y del work dir. //! //! La idea es que `BuildConfig` se construye una vez (en la CLI desde env vars + defaults) y //! se pasa explícitamente a `build`, de modo que los tests pueden montar configuraciones //! aisladas sin tocar variables de entorno globales. use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct BuildConfig { /// Rootfs base read-only del sandbox (Alpine para la fase actual del roadmap). pub rootfs: PathBuf, /// Directorio con el binario `zig` y su `lib/`. pub zig_dir: PathBuf, /// Raíz para directorios de trabajo (repos clonados, copias del source para /// patch+compile, y `out` por build). Debe estar en el mismo filesystem que el store /// para que el `seal` final sea un rename atómico. pub work_root: PathBuf, /// Directorio host persistente que se bindea como `/cache` en el sandbox; alberga la /// caché global de `zig` (objetos de musl, libc, etc.) entre builds. `None` desactiva. pub cache_root: Option, } /// 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 { 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. `TAKANA_LAB` — el operador manda. /// 2. Hermano del 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 { if let Some(v) = takana_core::env::var_os("TAKANA_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 { Self { rootfs: lab.join("alpine"), zig_dir: lab.join("tools/zig"), work_root: work_root_para(store_root, store_es_punto_de_montaje(store_root)), cache_root: Some(lab.join("cache")), } } } /// ¿Es `store_root` un punto de montaje? (su `st_dev` difiere del de su padre) /// /// Devuelve `false` cuando no se puede saber —alguna de las dos rutas no existe, como en los /// tests— y eso es lo conservador: sin evidencia de que la premisa esté rota, se mantiene la /// derivación de siempre. fn store_es_punto_de_montaje(store_root: &Path) -> bool { use std::os::unix::fs::MetadataExt; let Some(padre) = store_root.parent() else { return false }; let (Ok(a), Ok(b)) = (std::fs::metadata(store_root), std::fs::metadata(padre)) else { return false; }; a.dev() != b.dev() } /// Dónde va el scratch de build, dada la topología del store. /// /// ══ POR QUÉ ESTO NO ES `dirname(store)/work` A SECAS ══════════════════════════════════════════ /// El comentario de `defaults_for_store_with_lab` dice —y es correcto— que `work_root` sigue al /// store **porque el `seal` es un `rename`, atómico sólo dentro del mismo filesystem**. Eso es un /// acoplamiento real, no una suposición. /// /// ⚠ Pero `dirname(store)/work` sólo CONSIGUE eso si el store **no** es un punto de montaje. /// Cuando lo es, su padre está en otro filesystem y la derivación hace exactamente lo contrario de /// lo que pretende, **en silencio**. Medido en el anfitrión el 2026-10-15: /// /// /store → /dev/sdc (98 G) ⇒ dirname = "/" /// /work → /dev/sda4 (68 G) ⇒ OTRO filesystem: el `rename` del seal NO es atómico /// /// Y el precio no fue sólo teórico: ese disco de 68 G se llenó al 100 % con 15 G de scratch que /// debía estar en el de 98 G, y el 21-sep alguien lo había parcheado con symlinks que se perdieron /// al rehacer la jaula —un arreglo que hay que recordar no es un arreglo—. /// /// ⇒ Si el store es un punto de montaje, el scratch va **dentro** del store, en `.work`: ahí está /// en su filesystem POR CONSTRUCCIÓN, sin variable de entorno que nadie recuerde. El punto va con /// la convención que el store ya usa para lo que no es un artefacto (`.dmerge`, `.delta-tree`, /// `.bootstrap-tmp`, `.apartado-*`). fn work_root_para(store_root: &Path, store_es_montaje: bool) -> PathBuf { if store_es_montaje { store_root.join(".work") } else { store_root .parent() .unwrap_or_else(|| Path::new(".")) .join("work") } } impl BuildConfig { /// Lee overrides desde entorno (`TAKANA_LAB`, `TAKANA_ROOTFS`, `TAKANA_ZIG`, `TAKANA_WORK`, /// `TAKANA_CACHE`). `TAKANA_CACHE=""` (vacío) desactiva la caché. /// /// Es el constructor que HUELE EL ENTORNO (env + CWD); `defaults_for_store` se mantiene pura. /// `TAKANA_LAB` mueve el lab entero de una; los `TAKANA_ROOTFS`/`TAKANA_ZIG`/`TAKANA_CACHE` /// siguen pisando pieza por pieza y ganan sobre él. pub fn from_env_or_defaults(store_root: &Path) -> Self { 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 Some(v) = takana_core::env::var("TAKANA_ROOTFS") { cfg.rootfs = PathBuf::from(v); } if let Some(v) = takana_core::env::var("TAKANA_ZIG") { cfg.zig_dir = PathBuf::from(v); } if let Some(v) = takana_core::env::var("TAKANA_WORK") { cfg.work_root = PathBuf::from(v); } if let Some(v) = takana_core::env::var("TAKANA_CACHE") { cfg.cache_root = if v.is_empty() { None } else { Some(PathBuf::from(v)) }; } 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" ); } /// El store NO es punto de montaje: la derivación de siempre, intacta. #[test] fn work_sigue_al_padre_cuando_el_store_no_es_montaje() { assert_eq!( work_root_para(Path::new("/proj/store"), false), Path::new("/proj/work") ); } /// LA PREMISA QUE SE ROMPÍA EN SILENCIO: con el store montado aparte, `dirname(store)/work` /// cae en OTRO filesystem y el `rename` del seal deja de ser atómico. Medido en el anfitrión: /// `/store` en sdc y `/work` en sda4. #[test] fn work_entra_al_store_cuando_el_store_es_montaje() { assert_eq!( work_root_para(Path::new("/store"), true), Path::new("/store/.work"), "con el store montado aparte, el scratch va DENTRO: mismo FS por construcción" ); } /// Sin evidencia no se cambia nada: una ruta que no existe no prueba que la premisa esté rota. #[test] fn sin_poder_mirar_el_filesystem_se_mantiene_la_derivacion() { assert!(!store_es_punto_de_montaje(Path::new( "/no/existe/este/store/jamas" ))); } #[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); } }