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 1181Shell 82Rust 62Markdown 521515 Sutichaykuna

instalador: el censo sale en JSON, mirar deja de costar lo que escribir, y una candidata que no entra deja de ofrecerse

El censo es el contrato con cualquier cara gráfica (ADR 0021): `--survey --json` emite
dev/partuuid/mib/fs/etiqueta/montaje/veredicto/usable/explicacion. Existe para que NO
haya un segundo censo: el censo es lo que decide qué no se toca, y dos censos que
divergen no muestran de menos — formatean de más. El campo `usable` es la lectura de una
sola pregunta, para que una cara no reimplemente el case de los veredictos y se equivoque
en el default.

Y al ponerle la cara aparecieron tres defectos del motor, los tres sólo visibles desde
afuera:

- `--survey` exigía mke2fs, los tres mtools y el bzImage. En una máquina ajena —sin store
  y sin kernel del corpus— moría con «no encuentro el kernel EFI-stub» ANTES de imprimir
  una línea del terreno, que es justo lo primero que se corre en una máquina desconocida.
  Mirar necesita sfdisk y blkid, nada más.

- `--survey` salía con 1 habiendo funcionado. Un `a && b && c` como ÚLTIMO comando del
  bucle de impresión decide el estado de salida del script entero: con set -e,
  mostrar_censo moría si la última fila no tenía PARTUUID (un disco sin tabla, un hueco
  libre). En este hub la última fila es /dev/sdc. Un if no propaga; el && sí.

- el censo ofrecía /dev/sda1 —2 MiB, el hueco de arranque— como «se puede usar», porque
  el veredicto miraba QUÉ hay dentro y nunca CUÁNTO cabe. Elegirla formatea algo real y
  muere a mitad de la copia. El piso es ahora el mismo número con el que se talla un
  hueco, así que las dos puertas no divergen; con --root-part explícito AVISA y no aborta
  (el banco instala en 1200 MiB y funciona).

Un censo vacío tampoco se calla: dentro de esta jaula /dev no tiene un solo dispositivo
de bloque y la tabla salía vacía sin decir por qué.

Banco de pruebas en verde (25 comprobaciones, tres escenarios).

251405e72e14be46d5502f6b832db11980c12a1b · ruwaq Sergio · (29 p'unchaw ñawpaq) · tayta fadeaa035 · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

2 qillqakuna · +114 −17

diff --git a/scripts/lib/herramientas-desde-store.sh b/scripts/lib/herramientas-desde-store.sh
index 33a00350..2cbc07b6 100644
--- a/scripts/lib/herramientas-desde-store.sh
+++ b/scripts/lib/herramientas-desde-store.sh
@@ -75,7 +75,11 @@ herramienta() {
         fi
     fi
     if [ -n "$_hr_host" ] && command -v "$_hr_host" >/dev/null 2>&1; then
-        echo "   ⚠ ${_hr_rel##*/} ← $(command -v "$_hr_host") DEL HOST, sin hash: por acá la imagen no es reproducible" >&2
+        # El aviso se lee también donde no se está armando ninguna imagen —`takana metal survey`
+        # en una máquina ajena cae acá siempre, porque ahí NO HAY store y no puede haberlo—, así
+        # que dice qué pasó y no qué significa: «sin hash» vale en los dos casos; «la imagen no es
+        # reproducible» sonaba a error para el que sólo está mirando el disco.
+        echo "   ⚠ ${_hr_rel##*/} ← $(command -v "$_hr_host") del HOST, sin hash (si estás armando una imagen, por acá deja de ser reproducible)" >&2
         command -v "$_hr_host"; return 0
     fi
     echo "   ✗ falta ${_hr_rel##*/}: ni sellado ($_hr_store/$_hr_hash-$_hr_receta/$_hr_rel) ni en el PATH ($_hr_host)" >&2
diff --git a/scripts/takana-install-partition.sh b/scripts/takana-install-partition.sh
index 5f3806f8..bd5256fc 100755
--- a/scripts/takana-install-partition.sh
+++ b/scripts/takana-install-partition.sh
@@ -32,6 +32,8 @@
 #                              estables entre arranques (medido) y el kexec cambia quién enumera.
 #   --survey                   mirar y NO tocar: el censo del terreno de esta máquina, con el
 #                              PARTUUID de cada partición y qué se puede usar. Empezá por acá.
+#   --json                     (con --survey) el mismo censo en JSON. Es el CONTRATO que consume
+#                              una cara gráfica: `takana metal survey --json` lo envuelve.
 #   (sin destino)              modo INTERACTIVO: muestra el censo y te deja elegir. Es lo que hace
 #                              que un mismo medio sirva en cualquier máquina.
 #   --use-free-space           no formatear nada existente: crear la partición en el hueco sin
@@ -48,7 +50,7 @@ set -eu
 ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
 
 DEST=""; ESP=""; DISK=""; ESP_PART=""; CLAIM_FALLBACK=""; NO_NVRAM=""; ASSUME_YES=""
-USE_FREE=""; ON_ACTIVE="${ON_ACTIVE:-auto}"; SOLO_CENSO=""
+USE_FREE=""; ON_ACTIVE="${ON_ACTIVE:-auto}"; SOLO_CENSO=""; CENSO_JSON=""
 while [ $# -gt 0 ]; do
     case "$1" in
         --root-part) DEST="${2:?}"; shift 2 ;;
@@ -58,6 +60,7 @@ while [ $# -gt 0 ]; do
         --claim-fallback) CLAIM_FALLBACK=1; shift ;;
         --no-nvram)  NO_NVRAM=1; shift ;;
         --survey)    SOLO_CENSO=1; shift ;;
+        --json)      CENSO_JSON=1; shift ;;
         --use-free-space) USE_FREE=1; shift ;;
         --on-active-root) ON_ACTIVE="${2:?}"; shift 2 ;;
         --yes|-y)    ASSUME_YES=1; shift ;;
@@ -76,7 +79,6 @@ case "$ON_ACTIVE" in auto|kexec|free-space|abort) ;; *) echo "--on-active-root:
 # las GUARDAS, o sea antes de tocar nada. Salen del store por hash vigente; `TOOL_<binario>=` las
 # inyecta cuando no hay store (dentro del initramfs de un instalador, p. ej.).
 . "$(dirname "$0")/lib/herramientas-desde-store.sh"
-MKE2FS=$(herramienta e2fsprogs sbin/mke2fs mke2fs) || exit 1
 SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk) || exit 1
 # Releer la tabla es su propio problema: tras añadir una entrada, el kernel NO se entera solo y el
 # device nuevo no aparece. `partx -a` se lo dice; `blockdev --rereadpt` es el ioctl crudo, que falla
@@ -84,11 +86,20 @@ SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk) || exit 1
 # salen del `util-linux` sellado: en el anfitrión musl no existe ninguno (ni `partprobe`).
 PARTX=$(herramienta util-linux usr/sbin/partx partx 2>/dev/null || echo "")
 BLOCKDEV=$(herramienta util-linux sbin/blockdev blockdev 2>/dev/null || echo "")
-MMD=$(herramienta    mtools usr/bin/mmd   mmd)     || exit 1
-MCOPY=$(herramienta  mtools usr/bin/mcopy mcopy)   || exit 1
-MDIR=$(herramienta   mtools usr/bin/mdir  mdir)    || exit 1
-KERNEL="${KERNEL:-$(ls -dt store/*-linux-metal/boot/bzImage /store/*-linux-metal/boot/bzImage 2>/dev/null | head -1)}"
-[ -n "${KERNEL:-}" ] && [ -r "$KERNEL" ] || { echo "no encuentro el kernel EFI-stub (set KERNEL=)" >&2; exit 1; }
+# ⚠ **MIRAR no puede costar lo mismo que ESCRIBIR.** Todo lo de abajo —los tres binarios de mtools,
+# mke2fs y el bzImage— sólo hace falta para INSTALAR, y hasta el 2026-09-23 se exigía también para
+# `--survey`: en una máquina ajena, sin store y sin kernel del corpus, el censo moría con «no
+# encuentro el kernel EFI-stub» ANTES de imprimir una sola línea del terreno. Y el censo es
+# justamente lo primero que se corre en una máquina desconocida, donde nada de eso existe todavía.
+# El censo necesita `sfdisk` y `blkid`, nada más.
+if [ -z "$SOLO_CENSO" ]; then
+    MKE2FS=$(herramienta e2fsprogs sbin/mke2fs mke2fs) || exit 1
+    MMD=$(herramienta    mtools usr/bin/mmd   mmd)     || exit 1
+    MCOPY=$(herramienta  mtools usr/bin/mcopy mcopy)   || exit 1
+    MDIR=$(herramienta   mtools usr/bin/mdir  mdir)    || exit 1
+    KERNEL="${KERNEL:-$(ls -dt store/*-linux-metal/boot/bzImage /store/*-linux-metal/boot/bzImage 2>/dev/null | head -1)}"
+    [ -n "${KERNEL:-}" ] && [ -r "$KERNEL" ] || { echo "no encuentro el kernel EFI-stub (set KERNEL=)" >&2; exit 1; }
+fi
 HAMMER="${HAMMER:-$ROOT/target/release/takana}"
 
 # ── LAS GUARDAS VAN PRIMERO, Y SON EL PUNTO DE ESTE SCRIPT ─────────────────────────────────────
@@ -228,11 +239,19 @@ discos_del_sistema() {
 
 mib_de_sectores() { echo $(( ${1:-0} / 2048 )); }
 
-# clasificar <guid-tipo> <fstype> <montaje> → VEREDICTO|ETIQUETA
+# ── mib_minimos — el piso, calculado UNA vez ──────────────────────────────────────────────────
+# `mib_necesarios` mide el árbol y no es gratis (`du -sm` sobre el rootfs); el censo la llamaría una
+# vez por partición. Se cachea en una global, que es la única forma de memoizar en sh.
+mib_minimos() {
+    [ -n "${MIB_MINIMOS:-}" ] || MIB_MINIMOS=$(mib_necesarios)
+    echo "$MIB_MINIMOS"
+}
+
+# clasificar <guid-tipo> <fstype> <montaje> [mib] → VEREDICTO|ETIQUETA
 # El veredicto es lo que decide el menú: SOLO_ESP, NO_TOCAR, CANDIDATA, RAIZ_VIVA, EN_USO.
 clasificar() {
     _cl_guid=$(printf '%s' "${1:-}" | tr 'A-Z' 'a-z')
-    _cl_fs="${2:-}"; _cl_mnt="${3:-}"
+    _cl_fs="${2:-}"; _cl_mnt="${3:-}"; _cl_mib="${4:-0}"
     case "$_cl_guid" in
         "$GUID_ESP") echo "SOLO_ESP|ESP (partición de arranque EFI)"; return ;;
         "$GUID_MSR") echo "NO_TOCAR|reservada de Microsoft"; return ;;
@@ -263,10 +282,20 @@ clasificar() {
         echo "EN_USO|montada en $_cl_mnt"; return
     fi
     case "$_cl_fs" in
-        ext2|ext3|ext4|xfs|btrfs|f2fs) echo "CANDIDATA|Linux ($_cl_fs) sin montar"; return ;;
-        "")  echo "CANDIDATA|sin filesystem reconocible"; return ;;
+        ext2|ext3|ext4|xfs|btrfs|f2fs) _cl_q="Linux ($_cl_fs) sin montar" ;;
+        "")  _cl_q="sin filesystem reconocible" ;;
+        *)   echo "NO_TOCAR|$_cl_fs (desconocido para este instalador)"; return ;;
     esac
-    echo "NO_TOCAR|$_cl_fs (desconocido para este instalador)"
+    # ⚠ **Una candidata que no entra no es una candidata.** Sin esta puerta el censo de este mismo
+    # hub ofrecía `/dev/sda1` —2 MiB, sin filesystem, el hueco de arranque— como «✓ se puede usar»:
+    # el veredicto sólo miraba QUÉ hay dentro y nunca CUÁNTO cabe. Elegirla formatea algo real y
+    # muere a mitad de la copia, que es el peor momento para enterarse. El piso es el mismo número
+    # con el que se talla un hueco (`mib_necesarios`), así que las dos puertas no pueden divergir.
+    _cl_min=$(mib_minimos)
+    if [ "${_cl_mib:-0}" -lt "$_cl_min" ] 2>/dev/null; then
+        echo "NO_TOCAR|$_cl_q, pero son ${_cl_mib} M y hacen falta al menos ${_cl_min} M"; return
+    fi
+    echo "CANDIDATA|$_cl_q"
 }
 
 # censo [--solo-candidatas] → una línea por partición:
@@ -301,13 +330,16 @@ censo() {
             _ce_mn=$(awk -v d="$_ce_dev" '$1==d {print $2; exit}' /proc/mounts 2>/dev/null || true)
             # La raíz viva se decide por número de dispositivo, no por el nombre de /proc/mounts.
             if es_la_raiz_viva "$_ce_dev"; then _ce_mn="/"; fi
-            _ce_cl=$(clasificar "$_ce_ty" "$_ce_fs" "$_ce_mn")
+            _ce_cl=$(clasificar "$_ce_ty" "$_ce_fs" "$_ce_mn" "$(mib_de_sectores "$_ce_sz")")
             _ce_ver="${_ce_cl%%|*}"; _ce_exp="${_ce_cl#*|}"
             [ "$_ce_solo" = "--solo-candidatas" ] && [ "$_ce_ver" != "CANDIDATA" ] && continue
             echo "$_ce_dev|$_ce_uu|$(mib_de_sectores "$_ce_sz")|$_ce_fs|$_ce_lb|$_ce_mn|$_ce_ver|$_ce_exp"
         done
         # El espacio SIN ASIGNAR es un candidato más, y el único que no destruye nada.
-        "$SFDISK" -F "$_ce_d" 2>/dev/null | awk -v d="$_ce_d" 'NF==4 && $1 ~ /^[0-9]+$/ {print d"|-|"int($3/2048)"|-|-|-|LIBRE|espacio sin asignar en "d}'
+        "$SFDISK" -F "$_ce_d" 2>/dev/null | awk -v d="$_ce_d" -v min="$(mib_minimos)" \
+            'NF==4 && $1 ~ /^[0-9]+$/ { m=int($3/2048);
+               if (m >= min) print d"|-|"m"|-|-|-|LIBRE|espacio sin asignar en "d;
+               else print d"|-|"m"|-|-|-|NO_TOCAR|hueco sin asignar en "d", pero son "m" M y hacen falta al menos "min" M" }'
     done
 }
 
@@ -330,9 +362,58 @@ mostrar_censo() {
         esac
         printf "  %-3s %-16s %6s M  %-11s %s\n" "$_mc_n" "$_d" "$_m" "${_fs:-—}" "$_mc_marca"
         printf "      %s%s\n" "$_exp" "$( [ -n "$_lb" ] && [ "$_lb" != "-" ] && printf ' · etiqueta «%s»' "$_lb")"
-        [ -n "$_u" ] && [ "$_u" != "-" ] && printf "      PARTUUID=%s\n" "$_u"
+        # ⚠ **Un `a && b && c` como ÚLTIMO comando de un bucle decide el estado de salida del
+        # script entero.** Con `set -e`, si la última fila del censo no tiene PARTUUID —un disco sin
+        # tabla, un hueco libre— esta línea daba 1, el `while` daba 1, la tubería daba 1 y
+        # `mostrar_censo` MORÍA ahí: el separador de cierre no se imprimía y `--survey` salía con 1
+        # habiendo funcionado. Medido en este hub el 2026-09-23, donde la última fila es `/dev/sdc`.
+        # Un `if` no propaga nada; el `&&` sí.
+        if [ -n "$_u" ] && [ "$_u" != "-" ]; then printf "      PARTUUID=%s\n" "$_u"; fi
     done
     echo "───────────────────────────────────────────────────────────────────────────────"
+    # Un censo VACÍO no es un error, pero callarlo se lee como «no hay nada que hacer acá». Pasa de
+    # verdad: dentro de esta jaula `/dev` no tiene un solo dispositivo de bloque.
+    if [ -z "$({ if [ -n "$_mc_f" ]; then cat "$_mc_f"; else censo; fi; } | grep -c . 2>/dev/null | grep -v '^0$')" ]; then
+        echo "  (no veo NINGÚN disco desde acá — ¿estás dentro de un contenedor, o sin privilegios?)"
+    fi
+}
+
+# ── censo_json — el MISMO censo, en JSON. Es el CONTRATO con cualquier cara gráfica ───────────
+#
+# ⚠ **Existe para que no haya un segundo censo.** La tentación al poner una interfaz encima es que
+# la interfaz lea `sfdisk`/`blkid` por su cuenta y clasifique ella; ahí es donde los dos divergen y
+# uno de los dos empieza a ofrecer lo que el otro protege — y el que se equivoca formatea. El SDD 28
+# §8 ya nombra ese riesgo («un motor, tres frentes»). Entonces: UN censo, UNA clasificación, y las
+# caras consumen esto. Si `veredicto` cambia acá, cambia para todas a la vez.
+#
+# El campo `usable` es la lectura de una sola pregunta —¿se puede instalar acá?— para que una cara
+# no tenga que reimplementar el `case` de los veredictos y equivocarse en el default: lo que no
+# reconozca NO es usable.
+jstr() {
+    printf '"%s"' "$(printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/ /g' | tr -d '\000-\010\013\014\016-\037')"
+}
+censo_json() {
+    _cj_f="${1:-}"
+    printf '{\n'
+    printf '  "version": 1,\n'
+    printf '  "raiz_viva": %s,\n' "$( [ -n "${RAIZ_MAJMIN:-}" ] && jstr "$RAIZ_MAJMIN" || printf 'null' )"
+    printf '  "particiones": [\n'
+    _cj_pri=1
+    { if [ -n "$_cj_f" ]; then cat "$_cj_f"; else censo; fi; } | while IFS='|' read -r _d _u _m _fs _lb _mn _ver _exp; do
+        [ -n "$_d" ] || continue
+        case "$_ver" in CANDIDATA|LIBRE|RAIZ_VIVA) _cj_us=true ;; *) _cj_us=false ;; esac
+        [ "$_cj_pri" = 1 ] || printf ',\n'
+        _cj_pri=0
+        printf '    {"dev": %s, "partuuid": %s, "mib": %s, "fs": %s, "etiqueta": %s, "montaje": %s, "veredicto": %s, "usable": %s, "explicacion": %s}' \
+            "$(jstr "$_d")" \
+            "$( [ "${_u:--}" = "-" ] && printf 'null' || jstr "$_u" )" \
+            "${_m:-0}" \
+            "$( [ "${_fs:--}" = "-" ] || [ -z "${_fs:-}" ] && printf 'null' || jstr "$_fs" )" \
+            "$( [ "${_lb:--}" = "-" ] || [ -z "${_lb:-}" ] && printf 'null' || jstr "$_lb" )" \
+            "$( [ "${_mn:--}" = "-" ] || [ -z "${_mn:-}" ] && printf 'null' || jstr "$_mn" )" \
+            "$(jstr "$_ver")" "$_cj_us" "$(jstr "$_exp")"
+    done
+    printf '\n  ]\n}\n'
 }
 
 # ── partuuid_de <dev> — la identidad estable de una partición, leída de la TABLA ──────────────
@@ -495,7 +576,7 @@ crear_en_hueco() {
 
 # `--survey`: mirar y no tocar. Es el comando con el que se empieza en una máquina desconocida.
 if [ -n "$SOLO_CENSO" ]; then
-    mostrar_censo
+    if [ -n "$CENSO_JSON" ]; then censo_json; else mostrar_censo; fi
     exit 0
 fi
 
@@ -557,6 +638,18 @@ if es_bloque "$DEST"; then
     # 4) NO PUEDE SER LA ESP que vamos a reusar.
     [ -n "$ESP" ] && [ "$(readlink -f "$DEST")" = "$(readlink -f "$ESP")" ] && {
         echo "takana-install-partition: --root-part y --esp son la MISMA partición" >&2; exit 1; }
+    # 5) ¿ENTRA? Esto AVISA y no aborta, a propósito. El censo esconde del menú lo que no llega al
+    #    piso de `mib_necesarios` —que es el mismo con el que se talla un hueco, generoso a
+    #    propósito—, pero quien nombra una partición con `--root-part` la está nombrando a mano y
+    #    puede saber que su destino justo entra: el banco de pruebas instala en 1200 MiB y funciona.
+    #    Abortar acá rechazaría configuraciones que andan; callarse dejaría el fallo para la mitad
+    #    de la copia, con el destino ya formateado. Avisar es lo único que no miente.
+    _mib_dest=$(mib_de_sectores "$(cat "/sys/class/block/$_n/size" 2>/dev/null || echo 0)")
+    _mib_pide=$(mib_minimos)
+    if [ "${_mib_dest:-0}" -gt 0 ] && [ "$_mib_dest" -lt "$_mib_pide" ] 2>/dev/null; then
+        echo "    ⚠ $DEST son ${_mib_dest} MiB y el sistema pide ~${_mib_pide} MiB con holgura." >&2
+        echo "      Sigo porque lo nombraste vos; si no entra, la copia falla con el destino ya formateado." >&2
+    fi
 else
     [ -f "$DEST" ] || { echo "takana-install-partition: $DEST no existe (ni bloque ni fichero)" >&2; exit 1; }
 fi