Files
takana/scripts/respaldo-storagebox.sh
T
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
250 líneas de comentario en 151 scripts. Control verificado: el diff no toca
NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa.

El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces:
las tres eran el MOTD que el script escribe DENTRO de la imagen construida —
texto del producto, no comentario del script. Se cambiaron aparte y a
propósito, que es rebranding, no limpieza.

Y el hallazgo caro:  casaba contra ,
que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate.
La etapa 4 lo movió a  y el script quedó casando NADA. No fallaba:
imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado
con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y
tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja.

Además 14 rutas de módulo  en docs, que el barrido anterior no tocó
porque  no es frontera de palabra.
2026-09-09 19:28:48 +00:00

217 lines
15 KiB
Bash
Executable File

#!/bin/sh
# Respaldo de takana al Storage Box de Hetzner (BX11, 1 TiB, hel1).
#
# ── POR QUÉ UN STORAGE BOX Y NO EL VOLUMEN ──────────────────────────────────────────────────────
# El usuario pidió respaldar «en el volumen, aunque sea montándolo aquí». **No se puede**: un Hetzner
# Cloud Volume es un dispositivo de bloque por RED que sólo se adjunta a servidores de Hetzner Cloud
# del mismo datacenter — no se monta en un laptop, y encima sólo puede estar en UN servidor a la vez.
# `harkaq-cosecha` (100 G) es el disco del worker efímero y `vvv` (260 G, en gioser) está al 78%.
# El Storage Box SÍ es el producto para esto: se monta desde acá (SSHFS/CIFS) y habla rsync/borg/
# restic por SSH. BX11 = 1 TiB por €3,20/mes, sin coste de alta. Ver [[capacidad-disco-proyeccion]].
#
# ── ES INTERRUMPIBLE Y CONTINUABLE. ESE ES EL DISEÑO, NO UN EFECTO SECUNDARIO ───────────────────
# Medido en la oficina el 2026-08-07: el enlace da **8 Mbps de subida**, idénticos por cable (eth0)
# y por wifi (wlan0) ⇒ el cuello NO está en el laptop, está en el uplink. Con 128 G de store eso son
# ~35 h sin comprimir. Por lo tanto el respaldo NO se completa en una sentada, y todo el script está
# construido alrededor de esa realidad:
# · rsync incremental: lo ya subido no se vuelve a subir (el store es CAS, los ficheros nunca
# cambian, así que la comparación por tamaño+fecha es exacta y baratísima).
# · `--partial-dir`: un fichero cortado a la mitad conserva el trozo y la próxima corrida lo
# TERMINA en vez de empezarlo de cero. Sin esto, cortar a mitad de un artefacto de 500 MB
# tiraría los 400 MB ya subidos.
# · Se puede matar con Ctrl-C o apagar el laptop en cualquier momento. Se relanza y sigue.
#
# ── EL ORDEN ES POR VALOR IRREEMPLAZABLE, NO POR TAMAÑO ─────────────────────────────────────────
# Como no cabe todo en una ventana, lo que sube primero importa. De más a menos crítico:
# 1. ESTADO Y REPO (~cientos de MB, minutos). Recetas, scripts, SDDs, el grafo. Es el CEREBRO: con
# esto solo, el store entero se reconstruye — caro en CPU, pero se reconstruye. Sin esto no hay
# proyecto. Va primero aunque ya esté espejado a GitHub, porque un respaldo que depende de otro
# respaldo no es un respaldo.
# 2. HUÉRFANOS del store (~4,5 G). Artefactos que son el ÚNICO ejemplar de su receta: su hash
# vigente no está sellado, así que perderlos es perder trabajo real, no reconstruible en dos
# minutos. Es el material genuinamente irreemplazable y es sorprendentemente barato.
# 3. EL RESTO DEL STORE (~128 G). Meses de CPU, pero todo reconstruible desde (1).
#
# ── POR QUÉ rsync PLANO Y NO borg/restic ────────────────────────────────────────────────────────
# El store es CAS: los ficheros son INMUTABLES y se nombran por hash. Nunca cambian, sólo aparecen o
# desaparecen. Para eso rsync incremental es exactamente lo correcto y no hay que mantener repos ni
# claves de cifrado. Sí se usa COMPRESIÓN zstd, que aquí no es un lujo: medido, 53 MB tardan 53 s sin
# comprimir y 27 s con `--compress-choice=zstd` — **el doble de rendimiento efectivo**, porque el 79%
# del contenido del store son secciones `.debug_*` y comprimen como texto. Con un enlace de 8 Mbps la
# CPU sobra y el ancho de banda es el recurso escaso: comprimir siempre gana.
#
# ── EL STORE NO SE BORRA EN EL DESTINO, A PROPÓSITO ─────────────────────────────────────────────
# `--delete` NO va sobre el store. Un respaldo que replica los borrados no protege del borrado por
# error, y el 2026-08-07 `store-gc.sh` demostró que puede equivocarse EN SILENCIO (reportó 364
# artefactos borrados sin haber borrado ninguno). El respaldo acumula; podarlo es una decisión
# deliberada y aparte. El repo sí va con `--delete`, que para eso está git.
#
# Uso: scripts/respaldo-storagebox.sh [--seco]
# scripts/respaldo-storagebox.sh --listar # refresca work/respaldo-sellados.txt y sale
# Relanzalo cuantas veces quieras: continúa donde quedó.
#
# ── `--listar`: el respaldo también es un REGISTRO, no sólo una copia ──────────────────────────
# Desde que el store se mudó al volumen (2026-08-09), `build-state.py` y `farm-sync.sh` responden
# «¿esto está sellado?» con la unión del disco local y los manifiestos, y el del respaldo es el más
# completo de los dos. Sin refrescarlo, recetas que existen y están respaldadas se reportan como
# deuda y el worker las rehace. Es barato (una lista de nombres) y no toca un solo byte de datos.
set -eu
SB_USER="${SB_USER:-u647150}"
SB_HOST="${SB_HOST:-u647150.your-storagebox.de}"
SB_PORT="${SB_PORT:-23}" # 23 = SSH completo (rsync). El 22 sólo da SFTP/SCP restringido.
KEY="${KEY:-$HOME/.ssh/github5}"
RAIZ="${RAIZ:-/home/sergio/hammer}"
SECO=""
[ "${1:-}" = "--seco" ] && SECO="--dry-run"
if [ "${1:-}" = "--listar" ]; then
cd "$RAIZ"; mkdir -p work
# `du -s`, NO `ls`. Un `ls` lista NOMBRES DE DIRECTORIO, y un artefacto VACÍO tiene nombre igual
# que uno bueno ⇒ el manifiesto lo avala, `build-state.py` lo da por sellado, rsync lo baja vacío
# y `takana build` hace cache-hit sobre el directorio vacío: sella sin construir y sale 0. Toda la
# cadena lee el vacío como presencia, y el único síntoma aparece al final, disfrazado de éxito.
# (2026-08-10: 7 vacíos de 1749, todos del stack gráfico, de una subida que falló callada.)
# Cuesta 8,6 s sobre 1751 artefactos frente a un `ls` instantáneo — barato para lo que evita.
# La shell del box no es bash y no tiene `find`, pero SÍ expande globs y SÍ tiene `du`.
# `du -s takana/store/[0-9a-f]*` y NO `store/*`: el glob amplio incluye `.dmerge`, la caché
# transitoria de fusión —48 GB el 2026-08-12—, y recorrerla hace que el listado tarde tanto que se
# corta por timeout. El sufijo del glob además descarta de entrada los `.bootstrap-tmp`/`.mirror-tmp`
# que nunca son artefactos. Un artefacto SIEMPRE empieza por hex: filtrar en el origen es más barato
# que traerse la basura y descartarla aquí.
ssh -4 -p "$SB_PORT" -i "$KEY" -o StrictHostKeyChecking=accept-new \
"$SB_USER@$SB_HOST" 'du -s hammer/store/[0-9a-f]*' 2>/dev/null > work/.respaldo-du.tmp
# OJO CON EL ORDEN: recortar la ruta ANTES del awk se come el contador (`sed 's#.*/##'` es codicioso
# y borra "5347<TAB>takana/store/" entero), y entonces $1 es el NOMBRE, no los bloques: el filtro
# compara basura y devuelve cualquier cosa. Primero se filtra por número, después se recorta.
# Campo 1 = bloques. Un directorio sin contenido da 1..4; con contenido, mucho más.
awk '$1<=4 {sub(/.*\//,"",$2); print $2}' work/.respaldo-du.tmp \
| grep -E '^[0-9a-f]{64}-' | sort -u > work/respaldo-vacios.txt
awk '$1>4 {sub(/.*\//,"",$2); print $2}' work/.respaldo-du.tmp \
| grep -E '^[0-9a-f]{64}-' | sort -u > work/respaldo-sellados.txt.new
rm -f work/.respaldo-du.tmp
N=$(wc -l < work/respaldo-sellados.txt.new)
V=$(wc -l < work/respaldo-vacios.txt)
# Un listado vacío es un fallo de red disfrazado de respaldo borrado. No pisa nada.
if [ "$N" -eq 0 ]; then
echo "⚠ el box devolvió 0 artefactos — no piso el manifiesto (¿enlace caído?)" >&2
rm -f work/respaldo-sellados.txt.new; exit 1
fi
mv work/respaldo-sellados.txt.new work/respaldo-sellados.txt
echo "==> manifiesto del respaldo: $N artefactos → work/respaldo-sellados.txt"
# Se DICE siempre, aunque sea 0: un guardián que sólo habla cuando hay desastre no enseña a nadie
# qué está vigilando, y el día que hable nadie sabrá interpretarlo.
if [ "$V" -gt 0 ]; then
echo "⚠ $V artefactos VACÍOS en el respaldo (excluidos del manifiesto) → work/respaldo-vacios.txt" >&2
sed 's/^/ /' work/respaldo-vacios.txt >&2
echo " curar = reconstruir la receta y volver a subirla; NO se cuentan como respaldadas" >&2
else
echo " (0 artefactos vacíos)"
fi
exit 0
fi
# `-4` a propósito: el box tiene AAAA y el laptop tiene IPv6 sólo en wlan0, así que sin esto la ruta
# por cable nunca se usaría aunque el cable estuviera enchufado.
SSH_CMD="ssh -4 -p $SB_PORT -i $KEY -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30"
# Opciones comunes. `--partial-dir` es lo que hace el respaldo continuable a mitad de fichero.
# `--exclude /.dmerge`: son directorios TRANSITORIOS de fusión del store — aparecen y desaparecen
# mientras takana sella. rsync los empieza a copiar, se esfuman a media transferencia y devuelve 23
# («some files/attrs were not transferred»). Como 23 está en la lista de reintentables, el bucle los
# reintentó **40 veces seguidas** y se rindió con el respaldo a medias (79 G de 127 G), sin que el
# mensaje dijera nunca que la culpa era de un directorio temporal. `cosecha-cron.sh` ya los excluía;
# este script no, y ésa era toda la diferencia.
# ⇒ Un error reintentable que se repite 40 veces no es un corte de red: es algo estructural. Vale la
# pena que el bucle distinga «se cayó una vez» de «falla siempre igual».
# `--info=progress2` SÓLO con terminal. Sin TTY reescribe la misma línea con `\r` y, como nadie
# interpreta el retorno de carro, el «progreso» se acumula: una subida dejó un log de **2,6 MB**
# donde lo único que importaba era la última línea. Con el respaldo corriendo desatendido (que es
# como se corre), ese log es la única forma de saber qué pasó — y a 2,6 MB no se lee.
PROG=""; [ -t 1 ] && PROG="--info=progress2"
RS="rsync -a --partial-dir=.rsync-partial --exclude /.dmerge $PROG -z --compress-choice=zstd --compress-level=3"
# ── REINTENTOS: EL ENLACE SE CAE, Y ESO NO ES EXCEPCIONAL ───────────────────────────────────────
# Primera corrida real (2026-08-07): tras subir el cerebro y 2,3 G de store, murió con
# `Connection reset by peer` / `Broken pipe` y rsync salió con 255. Con una subida de ~19 h sobre un
# enlace de oficina, que la conexión se corte no es un accidente: es lo NORMAL. Un respaldo que hay
# que relanzar a mano cada vez que se cae no se completa nunca, porque nadie está mirando.
#
# Como rsync ya es incremental y `--partial-dir` conserva los trozos, reintentar es barato y seguro:
# cada intento retoma donde quedó. El bucle sólo insiste ante fallos de RED (códigos 10/12/23/30/35/
# 255); un error de verdad —permisos, disco lleno en destino, ruta inexistente— sale a la primera en
# vez de repetirse 40 veces contra la misma pared.
reintentar() {
etiqueta="$1"; shift
intento=1
while :; do
"$@" && return 0
rc=$?
case "$rc" in
10|12|23|30|35|255) ;; # red / pipe roto / timeout: insistir
# 24 = «ficheros de origen desaparecieron durante la transferencia». NO es un fallo: pasa
# cuando `store-gc.sh` poda artefactos superados mientras el respaldo corre, que es una
# combinación NORMAL en este proyecto (el respaldo dura ~19 h y la poda es la válvula del
# disco). Sin esta línea el bucle lo tomaría por «error que no es de red» y PARARÍA el
# respaldo entero por algo inofensivo — y encima justo cuando el disco aprieta, que es cuando
# menos conviene quedarse sin copia.
24) ;;
*) echo "!! $etiqueta: error $rc que NO es de red — no reintento"; return "$rc" ;;
esac
if [ "$intento" -ge 40 ]; then
echo "!! $etiqueta: 40 intentos y sigue cayéndose. Dejo lo subido y paro."; return "$rc"
fi
espera=$(( intento < 6 ? intento * 10 : 60 ))
echo ".. $etiqueta: corte de red (rsync $rc). Intento $intento; reanudo en ${espera}s."
intento=$((intento + 1))
sleep "$espera"
done
}
# ── LA COMPROBACIÓN INICIAL TAMBIÉN REINTENTA ──────────────────────────────────────────────────
# Era un `if` de un solo intento, y el 2026-08-07 tiró la corrida al relanzar el respaldo justo al
# llegar a casa: el wifi todavía no había levantado, el `ssh` falló una vez y el script se rindió con
# «no hay SSH» — cuando medio minuto después conectaba perfecto. Es el peor momento para rendirse:
# quien relanza un respaldo interrumpido acaba de cambiar de red, así que el hipo es LO ESPERABLE.
# Mismo criterio que el bucle de transferencia: insistir es barato, rendirse cuesta una noche.
intento=1
until $SSH_CMD "$SB_USER@$SB_HOST" 'df -h .' >/dev/null 2>&1; do
if [ "$intento" -ge 20 ]; then
echo "!! No hay SSH al Storage Box ($SB_HOST:$SB_PORT) tras 20 intentos."
echo "!! Si acabás de crearlo, el DNS tarda unos minutos en propagar."
exit 1
fi
echo ".. sin SSH al box todavía (¿red recién levantada?). Intento $intento; reintento en 15s."
intento=$((intento + 1))
sleep 15
done
echo "==> destino: $SB_USER@$SB_HOST:$SB_PORT ${SECO:+(SECO)}"
$SSH_CMD "$SB_USER@$SB_HOST" 'mkdir hammer hammer/store hammer/repo hammer/estado' >/dev/null 2>&1 || true
# ── 1. EL CEREBRO: estado + repo. Minutos, y es lo que no se puede perder. ──────────────────────
echo "==> [1/3] estado (el grafo: qué había construido y con qué hash)"
reintentar estado $RS $SECO -e "$SSH_CMD" "$RAIZ/docs/state/" "$SB_USER@$SB_HOST:hammer/estado/" || true
echo "==> [2/3] repo (recetas, scripts, SDDs — el cerebro; con esto solo se reconstruye el store)"
reintentar repo $RS --delete $SECO -e "$SSH_CMD" \
--exclude /store --exclude /store-rust --exclude /store-kern \
--exclude /work --exclude /target --exclude /.dev-fs --exclude /dist --exclude /.scratch \
"$RAIZ/" "$SB_USER@$SB_HOST:hammer/repo/"
# ── 2 y 3. EL STORE, huérfanos primero. ────────────────────────────────────────────────────────
# La lista de huérfanos la produce `store-gc.sh` como subproducto; si no hay una reciente, se sube
# el store entero en un solo paso y santas pascuas (rsync es incremental: no se pierde nada).
echo "==> [3/3] store (128 G a 8 Mbps ⇒ NO cabe en una sentada; esto se corta y se continúa)"
HUER="$(ls -t "$RAIZ"/work/store-gc-huerfanos-*.txt 2>/dev/null | head -1)"
if [ -n "$HUER" ] && [ -s "$HUER" ]; then
echo " · huérfanos primero ($(wc -l < "$HUER") artefactos, únicos ejemplares) — desde $HUER"
reintentar huerfanos $RS $SECO -e "$SSH_CMD" --files-from="$HUER" "$RAIZ/store/" "$SB_USER@$SB_HOST:hammer/store/" || true
fi
echo " · resto del store"
reintentar store $RS $SECO -e "$SSH_CMD" "$RAIZ/store/" "$SB_USER@$SB_HOST:hammer/store/"
echo "==> ocupación en el Storage Box:"
$SSH_CMD "$SB_USER@$SB_HOST" 'df -h .' 2>/dev/null | tail -2