Files
takana/scripts/farm/respaldo-promover.sh
T
sergioandClaude Opus 5 400f7171e0 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>
2026-08-09 21:39:30 -04:00

105 lines
6.3 KiB
Bash
Executable File

#!/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