tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 03e908fa20939db7003e6aeecc78d6d930971d21 crates/takana-build/src/config.rs Rust · 257 líneas · 11.6 KB Historia Quién tocó qué crudo

fix(build): la derivación de `work_root` hacía lo contrario de lo que su comentario prometía 03e908fa2 · Sergio ·

1//! Configuración del lab: paths del rootfs base, del compilador inyectado y del work dir.
2//!
3//! La idea es que `BuildConfig` se construye una vez (en la CLI desde env vars + defaults) y
4//! se pasa explícitamente a `build`, de modo que los tests pueden montar configuraciones
5//! aisladas sin tocar variables de entorno globales.
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct BuildConfig {
11    /// Rootfs base read-only del sandbox (Alpine para la fase actual del roadmap).
12    pub rootfs: PathBuf,
13    /// Directorio con el binario `zig` y su `lib/`.
14    pub zig_dir: PathBuf,
15    /// Raíz para directorios de trabajo (repos clonados, copias del source para
16    /// patch+compile, y `out` por build). Debe estar en el mismo filesystem que el store
17    /// para que el `seal` final sea un rename atómico.
18    pub work_root: PathBuf,
19    /// Directorio host persistente que se bindea como `/cache` en el sandbox; alberga la
20    /// caché global de `zig` (objetos de musl, libc, etc.) entre builds. `None` desactiva.
21    pub cache_root: Option<PathBuf>,
22}
23
24/// Nombre del directorio del lab, buscado hacia arriba como hace git con `.git`.
25const LAB_DIR: &str = ".dev-fs";
26
27/// Busca el lab (`.dev-fs`) desde `start` hacia arriba. Pura y testeable: no mira env ni CWD.
28fn find_lab_upward(start: &Path) -> Option<PathBuf> {
29    start.ancestors().map(|d| d.join(LAB_DIR)).find(|c| c.is_dir())
30}
31
32/// Resuelve dónde vive el lab SIN atarlo a dónde vive el store.
33///
34/// Orden, de más explícito a más adivinado:
35///   1. `TAKANA_LAB` — el operador manda.
36///   2. Hermano del store (`<store>/../.dev-fs`) **si existe**. Preserva exactamente el
37///      comportamiento histórico en la topología normal (lab y store en la raíz del proyecto).
38///   3. Hacia arriba desde el CWD. Ésta es la que desacopla: cubre el caso en que el store fue
39///      movido a otro filesystem (volumen persistente) y el lab se quedó en el proyecto.
40///   4. `None` ⇒ el llamador cae al default histórico, para que los mensajes de error sigan
41///      apuntando a un lugar previsible en vez de a la nada.
42fn resolve_lab_dir(store_root: &Path) -> Option<PathBuf> {
43    if let Some(v) = takana_core::env::var_os("TAKANA_LAB") {
44        return Some(PathBuf::from(v));
45    }
46    if let Some(parent) = store_root.parent() {
47        let sibling = parent.join(LAB_DIR);
48        if sibling.is_dir() {
49            return Some(sibling);
50        }
51    }
52    find_lab_upward(&std::env::current_dir().ok()?)
53}
54
55impl BuildConfig {
56    /// Defaults relativos al store: `.dev-fs` y `work/` como hermanos del store.
57    /// `store_root = /…/store` → rootfs = `/…/.dev-fs/alpine`, zig = `/…/.dev-fs/tools/zig`,
58    /// work = `/…/work`, cache = `/…/.dev-fs/cache`.
59    ///
60    /// PURA a propósito (no mira env ni CWD): los tests montan configs herméticas con esto. Quien
61    /// quiera la resolución real del entorno usa [`Self::from_env_or_defaults`].
62    pub fn defaults_for_store(store_root: &Path) -> Self {
63        let project_root = store_root
64            .parent()
65            .unwrap_or_else(|| Path::new("."))
66            .to_path_buf();
67        Self::defaults_for_store_with_lab(store_root, &project_root.join(LAB_DIR))
68    }
69
70    /// Igual que [`Self::defaults_for_store`] pero con el lab (`.dev-fs`) puesto a mano.
71    ///
72    /// POR QUÉ EXISTE (2026-07-22): atar el lab al PADRE DEL STORE resultó ser una suposición
73    /// oculta y cara. Al anclar el store de un worker a un volumen persistente (symlink a
74    /// `/mnt/cosecha/store`), el lab pasó a buscarse en `/mnt/cosecha/.dev-fs` — que no existe — y
75    /// TODA receta con `zig_version` murió con "no encuentro el ejecutable zig en …", un mensaje
76    /// que apunta al lugar equivocado (el zig estaba, y estaba bien). Dos campañas leyeron eso como
77    /// deuda de la cascada GUI. Son dos decisiones independientes: dónde se GUARDA lo sellado
78    /// (almacenamiento) y dónde vive el toolchain de desarrollo (entorno).
79    ///
80    /// `work_root` SÍ sigue al store, y es deliberado: el `seal` final es un rename, que sólo es
81    /// atómico dentro del mismo filesystem. Ése es un acoplamiento real, no una suposición.
82    pub fn defaults_for_store_with_lab(store_root: &Path, lab: &Path) -> Self {
83        Self {
84            rootfs: lab.join("alpine"),
85            zig_dir: lab.join("tools/zig"),
86            work_root: work_root_para(store_root, store_es_punto_de_montaje(store_root)),
87            cache_root: Some(lab.join("cache")),
88        }
89    }
90
91}
92
93/// ¿Es `store_root` un punto de montaje? (su `st_dev` difiere del de su padre)
94///
95/// Devuelve `false` cuando no se puede saber —alguna de las dos rutas no existe, como en los
96/// tests— y eso es lo conservador: sin evidencia de que la premisa esté rota, se mantiene la
97/// derivación de siempre.
98fn store_es_punto_de_montaje(store_root: &Path) -> bool {
99    use std::os::unix::fs::MetadataExt;
100    let Some(padre) = store_root.parent() else { return false };
101    let (Ok(a), Ok(b)) = (std::fs::metadata(store_root), std::fs::metadata(padre)) else {
102        return false;
103    };
104    a.dev() != b.dev()
105}
106
107/// Dónde va el scratch de build, dada la topología del store.
108///
109/// ══ POR QUÉ ESTO NO ES `dirname(store)/work` A SECAS ══════════════════════════════════════════
110/// El comentario de `defaults_for_store_with_lab` dice —y es correcto— que `work_root` sigue al
111/// store **porque el `seal` es un `rename`, atómico sólo dentro del mismo filesystem**. Eso es un
112/// acoplamiento real, no una suposición.
113///
114/// ⚠ Pero `dirname(store)/work` sólo CONSIGUE eso si el store **no** es un punto de montaje.
115/// Cuando lo es, su padre está en otro filesystem y la derivación hace exactamente lo contrario de
116/// lo que pretende, **en silencio**. Medido en el anfitrión el 2026-10-15:
117///
118///     /store  → /dev/sdc   (98 G)      ⇒ dirname = "/"
119///     /work   → /dev/sda4  (68 G)      ⇒ OTRO filesystem: el `rename` del seal NO es atómico
120///
121/// Y el precio no fue sólo teórico: ese disco de 68 G se llenó al 100 % con 15 G de scratch que
122/// debía estar en el de 98 G, y el 21-sep alguien lo había parcheado con symlinks que se perdieron
123/// al rehacer la jaula —un arreglo que hay que recordar no es un arreglo—.
124///
125/// ⇒ Si el store es un punto de montaje, el scratch va **dentro** del store, en `.work`: ahí está
126/// en su filesystem POR CONSTRUCCIÓN, sin variable de entorno que nadie recuerde. El punto va con
127/// la convención que el store ya usa para lo que no es un artefacto (`.dmerge`, `.delta-tree`,
128/// `.bootstrap-tmp`, `.apartado-*`).
129fn work_root_para(store_root: &Path, store_es_montaje: bool) -> PathBuf {
130    if store_es_montaje {
131        store_root.join(".work")
132    } else {
133        store_root
134            .parent()
135            .unwrap_or_else(|| Path::new("."))
136            .join("work")
137    }
138}
139
140impl BuildConfig {
141    /// Lee overrides desde entorno (`TAKANA_LAB`, `TAKANA_ROOTFS`, `TAKANA_ZIG`, `TAKANA_WORK`,
142    /// `TAKANA_CACHE`). `TAKANA_CACHE=""` (vacío) desactiva la caché.
143    ///
144    /// Es el constructor que HUELE EL ENTORNO (env + CWD); `defaults_for_store` se mantiene pura.
145    /// `TAKANA_LAB` mueve el lab entero de una; los `TAKANA_ROOTFS`/`TAKANA_ZIG`/`TAKANA_CACHE`
146    /// siguen pisando pieza por pieza y ganan sobre él.
147    pub fn from_env_or_defaults(store_root: &Path) -> Self {
148        let mut cfg = match resolve_lab_dir(store_root) {
149            Some(lab) => Self::defaults_for_store_with_lab(store_root, &lab),
150            None => Self::defaults_for_store(store_root),
151        };
152        if let Some(v) = takana_core::env::var("TAKANA_ROOTFS") {
153            cfg.rootfs = PathBuf::from(v);
154        }
155        if let Some(v) = takana_core::env::var("TAKANA_ZIG") {
156            cfg.zig_dir = PathBuf::from(v);
157        }
158        if let Some(v) = takana_core::env::var("TAKANA_WORK") {
159            cfg.work_root = PathBuf::from(v);
160        }
161        if let Some(v) = takana_core::env::var("TAKANA_CACHE") {
162            cfg.cache_root = if v.is_empty() { None } else { Some(PathBuf::from(v)) };
163        }
164        cfg
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    /// La topología normal (lab y store hermanos en la raíz) no se mueve.
173    #[test]
174    fn lab_hermano_del_store_sigue_igual() {
175        let cfg = BuildConfig::defaults_for_store(Path::new("/proj/store"));
176        assert_eq!(cfg.rootfs, Path::new("/proj/.dev-fs/alpine"));
177        assert_eq!(cfg.zig_dir, Path::new("/proj/.dev-fs/tools/zig"));
178        assert_eq!(cfg.cache_root.unwrap(), Path::new("/proj/.dev-fs/cache"));
179        assert_eq!(cfg.work_root, Path::new("/proj/work"));
180    }
181
182    /// LA REGRESIÓN: store mudado a un volumen, lab quieto en el proyecto. El lab debe seguir al
183    /// proyecto y NO al store; `work` debe seguir al STORE (mismo filesystem ⇒ seal atómico).
184    #[test]
185    fn lab_y_store_pueden_vivir_en_filesystems_distintos() {
186        let cfg = BuildConfig::defaults_for_store_with_lab(
187            Path::new("/mnt/cosecha/store"),
188            Path::new("/opt/hammer/.dev-fs"),
189        );
190        assert_eq!(cfg.zig_dir, Path::new("/opt/hammer/.dev-fs/tools/zig"));
191        assert_eq!(cfg.rootfs, Path::new("/opt/hammer/.dev-fs/alpine"));
192        assert_eq!(cfg.cache_root.unwrap(), Path::new("/opt/hammer/.dev-fs/cache"));
193        assert_eq!(
194            cfg.work_root,
195            Path::new("/mnt/cosecha/work"),
196            "work DEBE seguir al store: el seal es un rename y sólo es atómico en el mismo FS"
197        );
198    }
199
200    /// El store NO es punto de montaje: la derivación de siempre, intacta.
201    #[test]
202    fn work_sigue_al_padre_cuando_el_store_no_es_montaje() {
203        assert_eq!(
204            work_root_para(Path::new("/proj/store"), false),
205            Path::new("/proj/work")
206        );
207    }
208
209    /// LA PREMISA QUE SE ROMPÍA EN SILENCIO: con el store montado aparte, `dirname(store)/work`
210    /// cae en OTRO filesystem y el `rename` del seal deja de ser atómico. Medido en el anfitrión:
211    /// `/store` en sdc y `/work` en sda4.
212    #[test]
213    fn work_entra_al_store_cuando_el_store_es_montaje() {
214        assert_eq!(
215            work_root_para(Path::new("/store"), true),
216            Path::new("/store/.work"),
217            "con el store montado aparte, el scratch va DENTRO: mismo FS por construcción"
218        );
219    }
220
221    /// Sin evidencia no se cambia nada: una ruta que no existe no prueba que la premisa esté rota.
222    #[test]
223    fn sin_poder_mirar_el_filesystem_se_mantiene_la_derivacion() {
224        assert!(!store_es_punto_de_montaje(Path::new(
225            "/no/existe/este/store/jamas"
226        )));
227    }
228
229    #[test]
230    fn find_lab_upward_encuentra_desde_un_subdirectorio() {
231        let d = tempfile::tempdir().unwrap();
232        let raiz = d.path().join("proj");
233        std::fs::create_dir_all(raiz.join(LAB_DIR)).unwrap();
234        let hondo = raiz.join("a/b/c");
235        std::fs::create_dir_all(&hondo).unwrap();
236        assert_eq!(find_lab_upward(&hondo), Some(raiz.join(LAB_DIR)));
237    }
238
239    #[test]
240    fn find_lab_upward_no_inventa_lo_que_no_existe() {
241        let d = tempfile::tempdir().unwrap();
242        let hondo = d.path().join("sin/lab/alguno");
243        std::fs::create_dir_all(&hondo).unwrap();
244        assert_eq!(find_lab_upward(&hondo), None);
245    }
246
247    /// Un fichero llamado `.dev-fs` no es un lab: la búsqueda exige directorio.
248    #[test]
249    fn find_lab_upward_ignora_un_fichero_homonimo() {
250        let d = tempfile::tempdir().unwrap();
251        let raiz = d.path().join("proj");
252        std::fs::create_dir_all(&raiz).unwrap();
253        std::fs::write(raiz.join(LAB_DIR), b"no soy un lab").unwrap();
254        assert_eq!(find_lab_upward(&raiz), None);
255    }
256}
257