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>
105 lines
6.3 KiB
Bash
Executable File
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
|