#!/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 `takana/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 `takana/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>-`: 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