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>-`: 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 -# scripts/farm/respaldo-subcuenta.sh verificar # la prueba NEGATIVA +# scripts/farm/respaldo-subcuenta.sh autorizar # sub1 (lectura) +# scripts/farm/respaldo-subcuenta.sh verificar # la prueba NEGATIVA +# scripts/farm/respaldo-subcuenta.sh autorizar-buzon # sub2 (escritura) +# scripts/farm/respaldo-subcuenta.sh verificar-buzon # 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"