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>
This commit is contained in:
Executable
+104
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+51
@@ -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"
|
||||
Reference in New Issue
Block a user