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 1531Shell 194Rust 108Markdown 983678 Definitions

respaldo: escribir sin poder borrar no es un permiso, es un alcance

Los permisos de subcuenta del Storage Box son BINARIOS: --readonly sí o no.
No hay append-only ni write-sin-delete (verificado en la API, no recordado).

Así que la contención se hace por alcance: el worker escribe en un BUZÓN
cuyo home es incoming/, y hammer/store sencillamente no existe para él. Lo
máximo que puede destruir es lo que él mismo depositó y aún no se promovió,
que sigue estando en el volumen. Un permiso puede estar mal puesto; un
directorio fuera de tu home, no.

La promoción es un mv DENTRO del mismo filesystem: un rename, instantáneo,
cero bytes por la red. Eso es lo que la hace viable con un uplink de
440 kB/s — el laptop manda órdenes, los datos van worker→box por dentro de
Hetzner (122 MB/s, 280×).

Medido de la shell del box, que NO es un bash:
  · no hay `for`   ⇒ los lotes se arman en el hub
  · `a; b` no encadena de fiar ⇒ una orden por conexión
  · `mv a b c dest/` sí acepta varios orígenes ⇒ cientos por conexión
  · `find` no existe y devuelve 0 EN SILENCIO ⇒ parece «no hay respaldo»

Y la trampa que motivó partir el trabajo en dos conjuntos: `mv A dest/` con
dest/A ya existente NO falla, mete A DENTRO y deja dest/A/A. Como el store
es CAS, un artefacto ya respaldado es idéntico ⇒ no se mueve, se borra del
buzón. Los dos caminos probados de punta a punta con un artefacto falso,
recuento verificado y sin anidar.

Defensa en profundidad aparte: plan de snapshots diario (03:17 UTC, retiene
7 de 10) y la carpeta ZFS visible para recuperar ficheros sueltos sin
rollback. La subcuenta no las alcanza: viven en la cuenta, con el token que
el worker no tiene.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

400f7171e0225201f28c619906ef64cdfaac85a0 · by sergio · (2 months ago) · parent 759401b61 · browse the tree at this commit

Semantic delta

No definition changed: this change is text, documentation or data.

3 files · +205 −3

diff --git a/scripts/farm/respaldo-promover.sh b/scripts/farm/respaldo-promover.sh
new file mode 100755
index 00000000..ca049763
--- /dev/null
+++ b/scripts/farm/respaldo-promover.sh
@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+# respaldo-promover.sh — mueve lo que el worker depositó en el BUZÓN al respaldo consolidado.
+#
+# ── POR QUÉ HAY UN BUZÓN Y NO ESCRITURA DIRECTA ────────────────────────────────────────────────
+# El worker es EFÍMERO y se borra solo; darle escritura sobre `hammer/store` sería darle permiso de
+# borrado sobre el único respaldo que existe. Y los permisos de una subcuenta del Storage Box son
+# BINARIOS: `--readonly` sí o no. No hay append-only, no hay write-sin-delete (verificado en la API
+# el 2026-08-09, no de memoria).
+#
+# Así que la contención no se hace con un permiso sino con el ALCANCE: la subcuenta de escritura
+# tiene su home en `incoming/` y `hammer/store` sencillamente NO EXISTE para ella. Lo máximo que
+# puede destruir es lo que ella misma depositó y todavía no se promovió — que sigue estando en el
+# volumen de la granja. Un permiso puede estar mal puesto; un directorio que no está en tu home no.
+#
+# La promoción es un `mv` DENTRO del mismo filesystem: un rename, instantáneo, cero bytes por la red.
+# Eso es lo que hace viable que el laptop dirija la operación con un uplink de 440 kB/s — manda
+# órdenes, no datos.
+#
+# ── LO QUE LA SHELL DEL BOX NO TIENE (medido) ──────────────────────────────────────────────────
+# Es una shell RESTRINGIDA, no un bash. Medido el 2026-08-09:
+#   · `for … do … done`  → «Command not found». No hay bucles: el batch se arma ACÁ.
+#   · `a; b`             → no encadena de fiar (la segunda orden no corrió). Una orden por conexión.
+#   · `mv a b c destino/`→ SÍ acepta varios orígenes ⇒ así se mueven cientos en una sola conexión.
+#   · `find`             → NO existe, y devuelve 0 EN SILENCIO. Un recuento con `find` acá parece
+#                          «no hay nada respaldado» y es mentira. Comparar con `ls` + `comm`.
+#
+# ── LA TRAMPA DEL `mv` SOBRE UN DESTINO QUE YA EXISTE ──────────────────────────────────────────
+# `mv A destino/` con `destino/A` ya existente NO falla: mete A DENTRO, y queda `destino/A/A`. Como
+# el store es CAS, un artefacto que ya está en el respaldo es idéntico al del buzón ⇒ no se mueve,
+# se BORRA del buzón. Por eso se parte en dos conjuntos antes de tocar nada.
+#
+# Uso:  scripts/farm/respaldo-promover.sh [--seco]
+set -uo pipefail
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
+KEY="${KEY:-$HOME/.ssh/github5}"
+MAIN="${MAIN:-u647150@u647150.your-storagebox.de}"
+BUZON="${BUZON:-incoming/store}"
+RESP="${RESP:-hammer/store}"
+LOTE="${LOTE:-80}"          # orígenes por conexión; el límite real es la longitud de la orden
+SECO=""; [ "${1:-}" = "--seco" ] && SECO=1
+
+sb() { ssh -4 -p 23 -i "$KEY" -o StrictHostKeyChecking=no -o ServerAliveInterval=30 "$MAIN" "$@"; }
+ART='^[0-9a-f]{64}-'         # sólo artefactos: `bootstrap.json` y demás metadatos NO se promueven
+
+TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+sb "ls $BUZON" 2>/dev/null | grep -E "$ART" | sort -u > "$TMP/buzon.txt"
+sb "ls $RESP"  2>/dev/null | grep -E "$ART" | sort -u > "$TMP/resp.txt"
+N_BUZON=$(wc -l < "$TMP/buzon.txt"); N_RESP=$(wc -l < "$TMP/resp.txt")
+
+# Un listado vacío del RESPALDO es un fallo de red disfrazado de respaldo borrado. Si lo tomáramos
+# por bueno, todo el buzón parecería «nuevo» y lo moveríamos sobre un destino que quizá sí tiene
+# contenido. Ante la duda, no se toca nada.
+if [ "$N_RESP" -eq 0 ]; then
+  echo "⚠ el respaldo devolvió 0 artefactos — no promuevo (¿enlace caído?)" >&2; exit 1
+fi
+comm -23 "$TMP/buzon.txt" "$TMP/resp.txt" > "$TMP/nuevos.txt"
+comm -12 "$TMP/buzon.txt" "$TMP/resp.txt" > "$TMP/duplicados.txt"
+N_NUEVOS=$(wc -l < "$TMP/nuevos.txt"); N_DUP=$(wc -l < "$TMP/duplicados.txt")
+
+echo "==> buzón $N_BUZON · respaldo $N_RESP  ⇒  nuevos $N_NUEVOS · ya estaban $N_DUP"
+[ "$N_BUZON" -eq 0 ] && { echo "    buzón vacío, nada que hacer"; exit 0; }
+[ -n "$SECO" ] && { echo "    (--seco: no toco nada)"; exit 0; }
+
+# Los lotes se arman ACÁ (el box no sabe iterar) y viajan como argumentos de UNA orden por conexión.
+# Cada nombre es `<64 hex>-<paquete>`: sin espacios ni metacaracteres por construcción, así que
+# concatenarlos es seguro — y aun así se filtra por `$ART` antes de llegar hasta acá.
+en_lotes() {  # $1 = fichero de nombres, $2 = orden ("mv"|"rm")
+  lote=""; n=0; fallos=0
+  while read -r d; do
+    lote="$lote $BUZON/$d"; n=$((n + 1))
+    if [ "$n" -ge "$LOTE" ]; then
+      case "$2" in
+        mv) sb "mv$lote $RESP/" >/dev/null 2>&1 || fallos=$((fallos + 1)) ;;
+        rm) sb "rm -rf$lote"    >/dev/null 2>&1 || fallos=$((fallos + 1)) ;;
+      esac
+      lote=""; n=0; printf '.'
+    fi
+  done < "$1"
+  if [ -n "$lote" ]; then
+    case "$2" in
+      mv) sb "mv$lote $RESP/" >/dev/null 2>&1 || fallos=$((fallos + 1)) ;;
+      rm) sb "rm -rf$lote"    >/dev/null 2>&1 || fallos=$((fallos + 1)) ;;
+    esac
+    printf '.'
+  fi
+  echo " ($2: $fallos lote(s) con error)"
+}
+[ "$N_NUEVOS" -gt 0 ] && { printf '    promoviendo '; en_lotes "$TMP/nuevos.txt" mv; }
+[ "$N_DUP"    -gt 0 ] && { printf '    limpiando duplicados '; en_lotes "$TMP/duplicados.txt" rm; }
+
+# ── EL GUARDIÁN: RECONTAR, NO CREER ────────────────────────────────────────────────────────────
+# «moví N» no es lo mismo que «hay N más». La misma regla que salvó la poda del store.
+sb "ls $RESP"  2>/dev/null | grep -cE "$ART" > "$TMP/n2" || true
+sb "ls $BUZON" 2>/dev/null | grep -cE "$ART" > "$TMP/b2" || true
+N_RESP2=$(cat "$TMP/n2"); N_BUZON2=$(cat "$TMP/b2")
+echo "==> respaldo $N_RESP → $N_RESP2 (esperado $((N_RESP + N_NUEVOS)))  ·  buzón $N_BUZON → $N_BUZON2 (esperado 0)"
+RC=0
+[ "$N_RESP2" -eq "$((N_RESP + N_NUEVOS))" ] || { echo "⚠ el respaldo NO cuadra"; RC=1; }
+[ "$N_BUZON2" -eq 0 ] || { echo "⚠ el buzón no quedó vacío ($N_BUZON2)"; RC=1; }
+[ "$RC" -eq 0 ] && echo "GUARDIÁN: el recuento CASA ✓"
+
+# El manifiesto que hace honesto al grafo de estado (ver la cabecera de build-state.py).
+scripts/respaldo-storagebox.sh --listar || true
+exit $RC
diff --git a/scripts/farm/respaldo-subcuenta.sh b/scripts/farm/respaldo-subcuenta.sh
index 81ccb997..9eb6f178 100755
--- a/scripts/farm/respaldo-subcuenta.sh
+++ b/scripts/farm/respaldo-subcuenta.sh
@@ -32,9 +32,21 @@
 # ⚠ La shell del Storage Box es RESTRINGIDA: acepta `ls`/`mkdir`/`rm` pero **no redirección**.
 #   `echo k > authorized_keys` falla EN SILENCIO (crea el directorio y no el fichero). Va con `scp`.
 #
+# ── DOS SUBCUENTAS, DOS VERBOS (2026-08-09) ────────────────────────────────────────────────────
+# `sub1` (home `hammer`, --readonly) es para MIRAR qué hay ya respaldado y no volver a subirlo.
+# `sub2` (home `incoming`, escritura) es el BUZÓN donde el worker DEPOSITA lo nuevo.
+#
+# No hay una sola subcuenta que escriba sin poder borrar: los permisos de la API son binarios
+# (`--readonly` sí o no; verificado, no recordado). La contención se hace por ALCANCE — el buzón no
+# tiene a `hammer/store` dentro de su home, así que el respaldo consolidado no existe para él. Lo que
+# el worker puede destruir se reduce a lo que él mismo depositó y aún no se promovió, que sigue
+# estando en el volumen. Promoción: `scripts/farm/respaldo-promover.sh` (un `mv`, cero bytes de red).
+#
 # Uso:  scripts/farm/respaldo-subcuenta.sh crear      # crea y guarda la clave en ~/.config/hammer
-#       scripts/farm/respaldo-subcuenta.sh autorizar <ip-worker>
-#       scripts/farm/respaldo-subcuenta.sh verificar <ip-worker>   # la prueba NEGATIVA
+#       scripts/farm/respaldo-subcuenta.sh autorizar <ip-worker>          # sub1 (lectura)
+#       scripts/farm/respaldo-subcuenta.sh verificar <ip-worker>          # la prueba NEGATIVA
+#       scripts/farm/respaldo-subcuenta.sh autorizar-buzon <ip-worker>    # sub2 (escritura)
+#       scripts/farm/respaldo-subcuenta.sh verificar-buzon <ip-worker>    # escribe SÍ, respaldo NO
 #
 # ── ⚠ AL TIRAR DEL RESPALDO, `rsync -aH` — LA H NO ES OPCIONAL ─────────────────────────────────
 # El store comparte ficheros entre artefactos por ENLACE DURO. `rsync -a` sin `-H` no los preserva:
@@ -54,6 +66,8 @@ BOX="${BOX:-hammer-respaldo}"
 SUB_HOST="${SUB_HOST:-u647150-sub1.your-storagebox.de}"
 SUB_USER="${SUB_USER:-u647150-sub1}"
 MAIN="${MAIN:-u647150@u647150.your-storagebox.de}"
+BUZ_HOST="${BUZ_HOST:-u647150-sub2.your-storagebox.de}"
+BUZ_USER="${BUZ_USER:-u647150-sub2}"
 KEY="${KEY:-$HOME/.ssh/github5}"
 CRED="$HOME/.config/hammer/storagebox-worker.env"
 
@@ -95,5 +109,38 @@ verificar)
     case \"\$borra\$esc\" in *[Rr]ead-only*|*Failure*) echo 'VEREDICTO: acotada ✓' ;;
       *) echo 'VEREDICTO: ⚠ ESCRIBE — no usar'; exit 1 ;; esac"
   ;;
-*) sed -n '/^# Uso:/,/verificar/p' "$0"; exit 2 ;;
+autorizar-buzon)
+  IP="${2:?falta la IP del worker}"
+  # La MISMA llave del worker sirve para las dos subcuentas: son dos autorizaciones distintas del
+  # box, no dos identidades del worker. Se genera allá y nunca viaja una privada desde el laptop.
+  PUB=$(ssh -i "$KEY" "root@$IP" 'mkdir -p /root/.ssh
+    [ -f /root/.ssh/sb_sub ] || ssh-keygen -q -t ed25519 -N "" -C "hworker→respaldo" -f /root/.ssh/sb_sub
+    cat /root/.ssh/sb_sub.pub' 2>/dev/null | tail -1)
+  [ -z "$PUB" ] && { echo "!! no obtuve la pública del worker"; exit 1; }
+  T=$(mktemp); printf '%s\n' "$PUB" > "$T"
+  ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p incoming/.ssh' >/dev/null 2>&1
+  ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p incoming/store' >/dev/null 2>&1
+  # scp y NO `echo >`: la shell restringida del box no hace redirección y falla sin decirlo.
+  scp -4 -P 23 -i "$KEY" "$T" "$MAIN:incoming/.ssh/authorized_keys" || { rm -f "$T"; exit 1; }
+  rm -f "$T"; echo "==> pública del worker autorizada en el BUZÓN"
+  ;;
+verificar-buzon)
+  IP="${2:?falta la IP del worker}"
+  # La verificación tiene DOS mitades y las dos son necesarias: que SÍ escriba (si no, el respaldo
+  # no se alimenta y nos enteramos el día que haga falta) y que NO alcance el respaldo consolidado
+  # (si lo alcanza, el aislamiento es decorativo). Una sola mitad no prueba nada útil.
+  ssh -i "$KEY" "root@$IP" "SB=\"ssh -4 -p 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no $BUZ_USER@$BUZ_HOST\"
+    echo hola > /tmp/.btest
+    esc=\$(scp -4 -P 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no /tmp/.btest $BUZ_USER@$BUZ_HOST:store/.btest 2>&1 | head -1)
+    ve=\$(\$SB 'ls store' 2>&1 | head -3 | tr '\n' ' ')
+    fuera=\$(\$SB 'ls ../hammer/store' 2>&1 | head -1)
+    \$SB 'rm -f store/.btest' >/dev/null 2>&1
+    echo \"escribir en buzón : \${esc:-(sin error = OK)}\"
+    echo \"ve su buzón       : \$ve\"
+    echo \"alcanza respaldo  : \$fuera\"
+    case \"\$fuera\" in *[Nn]o\ such*|*denied*|*[Ff]ailure*|'')
+        echo 'VEREDICTO: buzón aislado ✓' ;;
+      *) echo 'VEREDICTO: ⚠ ALCANZA EL RESPALDO — no usar'; exit 1 ;; esac"
+  ;;
+*) sed -n '/^# Uso:/,/verificar-buzon/p' "$0"; exit 2 ;;
 esac
diff --git a/scripts/farm/worker-depositar.sh b/scripts/farm/worker-depositar.sh
new file mode 100755
index 00000000..d67fafcd
--- /dev/null
+++ b/scripts/farm/worker-depositar.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# worker-depositar.sh — CORRE EN EL WORKER. Sube al buzón del Storage Box lo que selló y todavía no
+# está respaldado. El laptop no participa: la ruta es worker→box, por dentro de Hetzner.
+#
+# ── POR QUÉ NO LO HACE EL LAPTOP ───────────────────────────────────────────────────────────────
+# Medido el 2026-08-09: el uplink de la oficina da 440 kB/s y el tramo interno de Hetzner 122 MB/s
+# — 280 veces más. Cualquier diseño que haga pasar los bytes por el laptop no se completa nunca.
+# El laptop manda ÓRDENES (la promoción es un `mv`, cero bytes); los DATOS van por acá.
+#
+# ── LAS DOS CREDENCIALES, Y POR QUÉ HACEN FALTA LAS DOS ────────────────────────────────────────
+# `sub1` (sólo lectura, home `hammer`) sirve para PREGUNTAR qué hay ya respaldado. Sin eso el worker
+# resubiría el corpus entero en cada ciclo.
+# `sub2` (escritura, home `incoming`) es el BUZÓN donde deposita. No alcanza a `hammer/store`: el
+# respaldo consolidado no está dentro de su home. Ver `respaldo-subcuenta.sh`.
+#
+# ── UN SOLO rsync CON --files-from, NO UNO POR ARTEFACTO ───────────────────────────────────────
+# El store comparte ficheros entre artefactos por ENLACE DURO. `-H` sólo preserva los enlaces que ve
+# DENTRO de una misma corrida: con un rsync por directorio, cada artefacto viaja como si fuera único
+# y el buzón llega inflado. Una sola invocación con la lista completa los ve todos.
+set -uo pipefail
+STORE="${STORE:-/opt/hammer/store}"
+KEY="${KEY:-/root/.ssh/sb_sub}"
+RO_HOST="${RO_HOST:-u647150-sub1.your-storagebox.de}"; RO_USER="${RO_USER:-u647150-sub1}"
+BZ_HOST="${BZ_HOST:-u647150-sub2.your-storagebox.de}"; BZ_USER="${BZ_USER:-u647150-sub2}"
+SSH_SB="ssh -4 -p 23 -i $KEY -o StrictHostKeyChecking=no -o ServerAliveInterval=30"
+ART='^[0-9a-f]{64}-'
+
+TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+ls "$STORE" 2>/dev/null | grep -E "$ART" | sort -u > "$TMP/local.txt"
+$SSH_SB "$RO_USER@$RO_HOST" 'ls store' 2>/dev/null | grep -E "$ART" | sort -u > "$TMP/resp.txt"
+N_L=$(wc -l < "$TMP/local.txt"); N_R=$(wc -l < "$TMP/resp.txt")
+
+# Un listado vacío del respaldo es un fallo de red disfrazado de «no hay nada respaldado». Tomarlo
+# por bueno significaría resubir el corpus entero por gusto.
+if [ "$N_R" -eq 0 ]; then
+  echo "⚠ el respaldo devolvió 0 artefactos — no deposito (¿enlace caído?)" >&2; exit 1
+fi
+comm -23 "$TMP/local.txt" "$TMP/resp.txt" > "$TMP/nuevos.txt"
+N_N=$(wc -l < "$TMP/nuevos.txt")
+echo "==> store $N_L · respaldo $N_R  ⇒  a depositar $N_N"
+[ "$N_N" -eq 0 ] && { echo "    nada nuevo"; exit 0; }
+
+# `--files-from` con los nombres a secas (son directorios del store) + `-r` para bajar en cada uno.
+rsync -aHr --files-from="$TMP/nuevos.txt" --partial-dir=.rsync-partial --info=progress2 \
+  -e "$SSH_SB" "$STORE/" "$BZ_USER@$BZ_HOST:store/" || { echo "⚠ el depósito falló"; exit 1; }
+
+# Guardián: recontar el buzón. «rsync salió 0» no es «llegaron N».
+EN_BUZON=$($SSH_SB "$BZ_USER@$BZ_HOST" 'ls store' 2>/dev/null | grep -cE "$ART")
+echo "==> buzón: $EN_BUZON artefactos (depositados esperados: $N_N)"
+[ "$EN_BUZON" -ge "$N_N" ] || { echo "⚠ el buzón tiene MENOS de lo depositado"; exit 1; }
+echo "    ahora el hub promueve con scripts/farm/respaldo-promover.sh"