gioser

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 188Rust 55Shell 30Markdown 281211 Sutichaykuna

la imagen del servidor, armada y booteada — tres fallos que sólo aparecen ahí

Se armó la imagen del perfil `servidor` con gitea y se arrancó en QEMU. Los tres hallazgos, en el
orden en que aparecieron, son los que justifican probar la imagen en vez de dar por buena la receta.

1) ⚠⚠ ESCRIBIR EN EL ROOTFS HIDRATADO ES ESCRIBIR DENTRO DEL STORE

`takana users --merge` hacía `fs::write` sobre `<rootfs>/etc/passwd`. Un rootfs hidratado se arma con
HARDLINKS contra el store: medido, ese fichero y el del artefacto `product-rootfs` eran **el mismo
inode (1225824, 2 links, modo 444)**. Un `write` habría modificado el artefacto SELLADO, y todas las
imágenes futuras habrían salido con la cuenta metida dentro del producto. Acá se salvó porque el
store es de sólo lectura y salió `Permission denied` — confiar en eso es confiar en un permiso.

Ahora `escribir_rompiendo_hardlink()`: temporal + `rename`. Con su control, que afirma lo que
importa: tras escribir, el fichero del store **conserva su contenido**, baja a 1 link y el del
rootfs tiene otro inode. Verificado también sobre la imagen real.

2) LA IMAGEN TRAÍA EL BINARIO, LA CUENTA… Y NADIE LO ARRANCABA

Primera imagen: `gitea` instalado, `gitea:x:916` en `/etc/passwd`, y en el `genesis` de la seed sólo
`sshd`, `console-getty`, `hammerd`, `hammer-product`. `servidor-image.sh` no inyectaba las Cards —
eso sólo estaba en el camino de las imágenes de escritorio. Y ninguna métrica lo dice: `--services`
responde que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva esa
declaración a la imagen. Ahora llama a `service-cards` + `inyectar-cards.py` (idempotente por label).

3) ⚡ EL BINARIO MORÍA CON `trap invalid opcode` — Y EL BUG ESTABA EN EL BUILDER

Con la card en el genesis y la config puesta, gitea arrancaba y moría al instante:

    traps: gitea[91] trap invalid opcode ip:79ea992 ... in gitea[...]

El sandbox exporta `CC` apuntando a un wrapper que pone `-mcpu=baseline` (`sandbox.rs` ya avisaba:
«de paso cierra el SIGILL de AVX en qemu64»), y la fase Go del propio builder lo PISABA con
`CC="zig cc"` a secas — que por defecto es `-mcpu=native` y hornea la ISA del que compila. El binario
corría perfecto en el worker y moría en QEMU-TCG.

No falla al compilar ni al sellar: falla al EJECUTAR en otra CPU. Y el `ArtifactHash` no lo puede
cazar, porque la CPU del builder no entra en `hash_inputs` — dos workers distintos sellan bytes
distintos bajo la misma dirección. Arreglado en el builder y en la receta; re-hashea las CINCO
recetas `cgo = true` (gitea, usql, sq, gocryptfs, naabu), que es correcto: lo que había sellado no
es portable. gitea: `b3:391a613e…` → `b3:5edf9c16…`.

Lo verificado en la VM: PID 1 = arje-zero · la cuenta de `[[user]]` en `/etc/passwd` y `/etc/group`
de la imagen · la card de gitea en el `genesis` · y arje encarnándola, con la guarda saliendo 78 y
`/var/log/arje/ente-gitea.log` diciendo exactamente «falta /etc/gitea/app.ini — es config del SITIO».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn

467a87cdb82c4738332f23feeb9f95319c3f857f · ruwaq Sergio · (1 killa ñawpaq) · tayta 44b3624de · kay commitpi sach'ata qhaway

Semántico sayk'uy · 4

crates/takana-build/src/lib.rs

crates/takana-cli/src/main.rs

  • ukhunpi t'ikrakurqan fn main

crates/takana-core/src/user.rs

5 qillqakuna · +96 −3

diff --git a/crates/takana-build/src/lib.rs b/crates/takana-build/src/lib.rs
index afb8c080..8802c535 100644
--- a/crates/takana-build/src/lib.rs
+++ b/crates/takana-build/src/lib.rs
@@ -970,7 +970,15 @@ fn resolve_phases(recipe: &Recipe, src: &Path) -> takana_core::Result<Phases> {
                 // (Go puro estático). Validado: usql (sqlite, estático y corre).
                 let (cgo_env, ldflags) = if recipe.build.cgo {
                     (
-                        "CGO_ENABLED=1 CC=\"zig cc\" CXX=\"zig c++\"",
+                        // ⚠ `-mcpu=baseline` NO es opcional y este sitio lo perdía: el sandbox
+                        // exporta `CC` apuntando a un wrapper que ya lo pone (ver `sandbox.rs`), y
+                        // esta línea lo PISABA con `zig cc` a secas — que por defecto es
+                        // `-mcpu=native` y hornea la ISA del builder. El síntoma no aparece al
+                        // compilar ni al sellar: aparece al EJECUTAR en otra CPU, como
+                        // `trap invalid opcode` (SIGILL). Medido 2026-09-14 con `gitea` dentro de
+                        // QEMU-TCG: el binario construido en el worker moría al arrancar, y el
+                        // mismo con baseline corre. Afecta a toda receta `cgo = true`.
+                        "CGO_ENABLED=1 CC=\"zig cc -mcpu=baseline\" CXX=\"zig c++ -mcpu=baseline\"",
                         "-ldflags=\"-buildid= -linkmode=external -extldflags=-static\"",
                     )
                 } else {
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index da418808..cf7da82e 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -1046,7 +1046,7 @@ fn main() -> anyhow::Result<()> {
                 }
                 for (rel, p, nuevo, anadidas) in plan {
                     if !anadidas.is_empty() {
-                        std::fs::write(&p, &nuevo)?;
+                        takana_core::user::escribir_rompiendo_hardlink(&p, &nuevo)?;
                     }
                     eprintln!("{}: +{} cuenta(s)", rel, anadidas.len());
                     for a in &anadidas {
diff --git a/crates/takana-core/src/user.rs b/crates/takana-core/src/user.rs
index 3a46fa19..6008a087 100644
--- a/crates/takana-core/src/user.rs
+++ b/crates/takana-core/src/user.rs
@@ -213,6 +213,35 @@ pub fn fusionar(contenido: &str, lineas: &[String]) -> (String, Vec<String>, Vec
     (out, anadidas, conflictos)
 }
 
+/// Escribe `contenido` en `p` **rompiendo el hardlink**: temporal en el mismo directorio + `rename`.
+///
+/// ⚠ Esto no es higiene, es lo único correcto, y se descubrió al armar la primera imagen: un rootfs
+/// hidratado se arma con **hardlinks contra el store**, así que su `/etc/passwd` y el del artefacto
+/// `product-rootfs` **son el mismo inode** (medido: mismo número de inode, `2 links`, modo `444`).
+/// Un `fs::write` sobre él abre ESE inode en truncate y **modifica el artefacto sellado** — todas
+/// las imágenes futuras saldrían con la cuenta metida dentro del producto, y el store mentiría
+/// sobre lo que contiene su propio hash. Acá se salvó porque el store es de sólo lectura y el
+/// primer intento murió con `Permission denied`; confiar en eso sería confiar en un permiso.
+///
+/// `rename` sobre el mismo directorio es atómico y deja el fichero del store intacto con un link
+/// menos. El modo sale 0644: el del store es 0444 y un `passwd` de sólo lectura rompe a cualquiera
+/// que después añada una cuenta.
+pub fn escribir_rompiendo_hardlink(p: &std::path::Path, contenido: &str) -> crate::Result<()> {
+    let dir = p.parent().unwrap_or(std::path::Path::new("."));
+    let tmp = dir.join(format!(
+        ".{}.users-tmp",
+        p.file_name().and_then(|s| s.to_str()).unwrap_or("fichero")
+    ));
+    std::fs::write(&tmp, contenido)?;
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))?;
+    }
+    std::fs::rename(&tmp, p)?;
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -272,6 +301,42 @@ mod tests {
         assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("name duplicado"));
     }
 
+    /// El control del fallo que casi envenena el store: escribir el `passwd` de un rootfs
+    /// hidratado NO puede tocar el fichero del artefacto con el que comparte inode.
+    #[test]
+    fn escribir_no_toca_el_fichero_hardlinkeado() {
+        use std::os::unix::fs::MetadataExt;
+        let dir = std::env::temp_dir().join(format!("takana-user-test-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        std::fs::create_dir_all(dir.join("store")).unwrap();
+        std::fs::create_dir_all(dir.join("rootfs")).unwrap();
+        let en_store = dir.join("store/passwd");
+        let en_rootfs = dir.join("rootfs/passwd");
+        std::fs::write(&en_store, "root:x:0:0:root:/root:/bin/sh\n").unwrap();
+        std::fs::hard_link(&en_store, &en_rootfs).unwrap();
+        assert_eq!(
+            std::fs::metadata(&en_store).unwrap().ino(),
+            std::fs::metadata(&en_rootfs).unwrap().ino(),
+            "el montaje del test tiene que hardlinkear para que el control valga"
+        );
+
+        escribir_rompiendo_hardlink(&en_rootfs, "root:x:0:0:root:/root:/bin/sh\ngitea:x:916:916:gitea:/var/lib/gitea:/bin/false\n").unwrap();
+
+        assert_eq!(
+            std::fs::read_to_string(&en_store).unwrap(),
+            "root:x:0:0:root:/root:/bin/sh\n",
+            "SE MODIFICÓ EL ARTEFACTO DEL STORE"
+        );
+        assert!(std::fs::read_to_string(&en_rootfs).unwrap().contains("gitea:"));
+        assert_ne!(
+            std::fs::metadata(&en_store).unwrap().ino(),
+            std::fs::metadata(&en_rootfs).unwrap().ino(),
+            "el hardlink no se rompió"
+        );
+        assert_eq!(std::fs::metadata(&en_store).unwrap().nlink(), 1);
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
     #[test]
     fn fusionar_es_idempotente_y_no_pisa() {
         let base = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n";
diff --git a/recipes/gitea.toml b/recipes/gitea.toml
index f8f7c578..aa1962ad 100644
--- a/recipes/gitea.toml
+++ b/recipes/gitea.toml
@@ -51,7 +51,7 @@ build = ["go"]
 configure = "true"
 compile   = """
 export GOCACHE=/src/.gocache GOTMPDIR=/src/.gotmp GOPATH=/tmp/gopath GOTOOLCHAIN=local \
-  CGO_ENABLED=1 CC="zig cc" CXX="zig c++" GOFLAGS=-mod=vendor GOPROXY=off
+  CGO_ENABLED=1 CC="zig cc -mcpu=baseline" CXX="zig c++ -mcpu=baseline" GOFLAGS=-mod=vendor GOPROXY=off
 mkdir -p /src/.gotmp /out/usr/bin
 go build -trimpath -tags 'bindata sqlite sqlite_unlock_notify' \
   -ldflags '-buildid= -linkmode=external -extldflags=-static -X "main.Version=1.27.0" -X "main.Tags=bindata sqlite sqlite_unlock_notify"' \
diff --git a/scripts/servidor-image.sh b/scripts/servidor-image.sh
index 87d7c829..b44cfa2c 100755
--- a/scripts/servidor-image.sh
+++ b/scripts/servidor-image.sh
@@ -99,6 +99,26 @@ else
   echo "==> cuentas : ninguna receta del perfil declara [[user]]"
 fi
 
+# ── 2 ter. las CARDS de los servicios que el perfil arranca (SDD 30 §4c) ─────────────────────────
+# Sin esto la imagen trae el binario, trae la cuenta… y NADIE lo arranca: la seed del producto sólo
+# conoce las cards que `takana-bootstrap` hornea (`sshd`, `console-getty`, `hammerd`). Medido en la
+# primera imagen de este perfil: `gitea` instalado, su usuario en `/etc/passwd`, y en el `genesis`
+# NADA — un servidor que arranca perfecto sin el servicio por el que existe. La métrica no lo ve:
+# `--services` dice que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva
+# esa declaración a la imagen.
+#
+# `service-cards` es la ÚNICA traducción `[[service]]` → Card, y `inyectar-cards.py` sólo compone la
+# seed (idempotente por label, y con `os.replace` porque la seed es un hardlink al store).
+SVCPATHS=$(python3 scripts/targets.py --service-paths "$PERFIL")
+if [ -n "$SVCPATHS" ]; then
+  # shellcheck disable=SC2086
+  "$TAKANA" --store "$STORE" service-cards $SVCPATHS > "$WORKDIR/cards.json"
+  echo "==> cards   : $(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$WORKDIR/cards.json") card(s) de $(echo "$SVCPATHS" | wc -l) receta(s)"
+  python3 scripts/gnome/inyectar-cards.py "$RFS/ente/seed.card.json" "$WORKDIR/cards.json"
+else
+  echo "==> cards   : el perfil no arranca ningún servicio de paquete"
+fi
+
 # ── 3. reparar el init que la hidratación pisó (ver cabecera, punto 2) ───────────────────────────
 antes=$(readlink "$RFS/sbin/init" 2>/dev/null || echo "(fichero)")
 ln -sf /usr/bin/arje-zero "$RFS/sbin/init"