tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1181Shell 83Rust 62Markdown 521515 Definitions

⎇ 4b1f19d8d85eda4740af94810b184b1d192427f6 scripts/respaldo-storagebox.sh Shell · 279 lines · 19.6 KB History raw

respaldo: un parcial de SÓLO LECTURA envenena esa ruta para siempre — y el bucle lo llamaba «red» b98557078 · Sergio ·

1#!/bin/sh
2# Respaldo de takana al Storage Box de Hetzner (BX11, 1 TiB, hel1).
3#
4# ── POR QUÉ UN STORAGE BOX Y NO EL VOLUMEN ──────────────────────────────────────────────────────
5# El usuario pidió respaldar «en el volumen, aunque sea montándolo aquí». **No se puede**: un Hetzner
6# Cloud Volume es un dispositivo de bloque por RED que sólo se adjunta a servidores de Hetzner Cloud
7# del mismo datacenter — no se monta en un laptop, y encima sólo puede estar en UN servidor a la vez.
8# `harkaq-cosecha` (100 G) es el disco del worker efímero y `vvv` (260 G, en gioser) está al 78%.
9# El Storage Box SÍ es el producto para esto: se monta desde acá (SSHFS/CIFS) y habla rsync/borg/
10# restic por SSH. BX11 = 1 TiB por €3,20/mes, sin coste de alta. Ver [[capacidad-disco-proyeccion]].
11#
12# ── ES INTERRUMPIBLE Y CONTINUABLE. ESE ES EL DISEÑO, NO UN EFECTO SECUNDARIO ───────────────────
13# Medido en la oficina el 2026-08-07: el enlace da **8 Mbps de subida**, idénticos por cable (eth0)
14# y por wifi (wlan0) ⇒ el cuello NO está en el laptop, está en el uplink. Con 128 G de store eso son
15# ~35 h sin comprimir. Por lo tanto el respaldo NO se completa en una sentada, y todo el script está
16# construido alrededor de esa realidad:
17#   · rsync incremental: lo ya subido no se vuelve a subir (el store es CAS, los ficheros nunca
18#     cambian, así que la comparación por tamaño+fecha es exacta y baratísima).
19#   · `--partial-dir`: un fichero cortado a la mitad conserva el trozo y la próxima corrida lo
20#     TERMINA en vez de empezarlo de cero. Sin esto, cortar a mitad de un artefacto de 500 MB
21#     tiraría los 400 MB ya subidos.
22#   · Se puede matar con Ctrl-C o apagar el laptop en cualquier momento. Se relanza y sigue.
23#
24# ── EL ORDEN ES POR VALOR IRREEMPLAZABLE, NO POR TAMAÑO ─────────────────────────────────────────
25# Como no cabe todo en una ventana, lo que sube primero importa. De más a menos crítico:
26#   1. ESTADO Y REPO (~cientos de MB, minutos). Recetas, scripts, SDDs, el grafo. Es el CEREBRO: con
27#      esto solo, el store entero se reconstruye — caro en CPU, pero se reconstruye. Sin esto no hay
28#      proyecto. Va primero aunque ya esté espejado a GitHub, porque un respaldo que depende de otro
29#      respaldo no es un respaldo.
30#   2. HUÉRFANOS del store (~4,5 G). Artefactos que son el ÚNICO ejemplar de su receta: su hash
31#      vigente no está sellado, así que perderlos es perder trabajo real, no reconstruible en dos
32#      minutos. Es el material genuinamente irreemplazable y es sorprendentemente barato.
33#   3. EL RESTO DEL STORE (~128 G). Meses de CPU, pero todo reconstruible desde (1).
34#
35# ── POR QUÉ rsync PLANO Y NO borg/restic ────────────────────────────────────────────────────────
36# El store es CAS: los ficheros son INMUTABLES y se nombran por hash. Nunca cambian, sólo aparecen o
37# desaparecen. Para eso rsync incremental es exactamente lo correcto y no hay que mantener repos ni
38# claves de cifrado. Sí se usa COMPRESIÓN zstd, que aquí no es un lujo: medido, 53 MB tardan 53 s sin
39# comprimir y 27 s con `--compress-choice=zstd` — **el doble de rendimiento efectivo**, porque el 79%
40# del contenido del store son secciones `.debug_*` y comprimen como texto. Con un enlace de 8 Mbps la
41# CPU sobra y el ancho de banda es el recurso escaso: comprimir siempre gana.
42#
43# ── EL STORE NO SE BORRA EN EL DESTINO, A PROPÓSITO ─────────────────────────────────────────────
44# `--delete` NO va sobre el store. Un respaldo que replica los borrados no protege del borrado por
45# error, y el 2026-08-07 `store-gc.sh` demostró que puede equivocarse EN SILENCIO (reportó 364
46# artefactos borrados sin haber borrado ninguno). El respaldo acumula; podarlo es una decisión
47# deliberada y aparte. El repo sí va con `--delete`, que para eso está git.
48#
49# Uso:  scripts/respaldo-storagebox.sh [--seco]
50#       scripts/respaldo-storagebox.sh --listar   # refresca work/respaldo-sellados.txt y sale
51#       Relanzalo cuantas veces quieras: continúa donde quedó.
52#
53# ── `--listar`: el respaldo también es un REGISTRO, no sólo una copia ──────────────────────────
54# Desde que el store se mudó al volumen (2026-08-09), `build-state.py` y `farm-sync.sh` responden
55# «¿esto está sellado?» con la unión del disco local y los manifiestos, y el del respaldo es el más
56# completo de los dos. Sin refrescarlo, recetas que existen y están respaldadas se reportan como
57# deuda y el worker las rehace. Es barato (una lista de nombres) y no toca un solo byte de datos.
58set -eu
59
60SB_USER="${SB_USER:-u647150}"
61SB_HOST="${SB_HOST:-u647150.your-storagebox.de}"
62SB_PORT="${SB_PORT:-23}"          # 23 = SSH completo (rsync). El 22 sólo da SFTP/SCP restringido.
63KEY="${KEY:-$HOME/.ssh/github5}"
64# La raíz se DERIVA de dónde está el script, como el resto de `scripts/`. Antes el default era
65# `/mnt/vvv/takana` cableado —la ruta de gioser— así que el script no corría en ningún otro hub:
66# desde la caja de producción moría con `cd: /mnt/vvv/takana: No such file or directory` (SDD 28
67# §6.9). El override por env se conserva.
68RAIZ="${RAIZ:-$(cd "$(dirname "$0")/.." && pwd)}"
69# El store NO siempre vive dentro del repo. En gioser `store/` es un bind-mount del volumen y queda
70# bajo la raíz; en una caja takana INSTALADA es una partición propia montada en `/store`, y el script
71# moría con `change_dir "/opt/takana/store" failed`. Peor: rsync devuelve 23, que está en la lista de
72# reintentables, así que el bucle lo reintentaba una y otra vez — el mismo cuadro que esta cabecera ya
73# documenta («un error reintentable que se repite 40 veces no es un corte de red: es algo
74# estructural»). Ahora es env, y si no existe se ABORTA antes de entrar al bucle.
75STORE="${STORE:-$RAIZ/store}"
76
77SECO=""
78[ "${1:-}" = "--seco" ] && SECO="--dry-run"
79
80if [ "${1:-}" = "--listar" ]; then
81  cd "$RAIZ"; mkdir -p work
82  # `du -s`, NO `ls`. Un `ls` lista NOMBRES DE DIRECTORIO, y un artefacto VACÍO tiene nombre igual
83  # que uno bueno ⇒ el manifiesto lo avala, `build-state.py` lo da por sellado, rsync lo baja vacío
84  # y `takana build` hace cache-hit sobre el directorio vacío: sella sin construir y sale 0. Toda la
85  # cadena lee el vacío como presencia, y el único síntoma aparece al final, disfrazado de éxito.
86  # (2026-08-10: 7 vacíos de 1749, todos del stack gráfico, de una subida que falló callada.)
87  # Cuesta 8,6 s sobre 1751 artefactos frente a un `ls` instantáneo — barato para lo que evita.
88  # La shell del box no es bash y no tiene `find`, pero SÍ expande globs y SÍ tiene `du`.
89  # `du -s takana/store/[0-9a-f]*` y NO `store/*`: el glob amplio incluye `.dmerge`, la caché
90  # transitoria de fusión —48 GB el 2026-08-12—, y recorrerla hace que el listado tarde tanto que se
91  # corta por timeout. El sufijo del glob además descarta de entrada los `.bootstrap-tmp`/`.mirror-tmp`
92  # que nunca son artefactos. Un artefacto SIEMPRE empieza por hex: filtrar en el origen es más barato
93  # que traerse la basura y descartarla aquí.
94  ssh -4 -p "$SB_PORT" -i "$KEY" -o StrictHostKeyChecking=accept-new \
95      "$SB_USER@$SB_HOST" 'du -s hammer/store/[0-9a-f]*' 2>/dev/null > work/.respaldo-du.tmp
96  # OJO CON EL ORDEN: recortar la ruta ANTES del awk se come el contador (`sed 's#.*/##'` es codicioso
97  # y borra "5347<TAB>takana/store/" entero), y entonces $1 es el NOMBRE, no los bloques: el filtro
98  # compara basura y devuelve cualquier cosa. Primero se filtra por número, después se recorta.
99  # Campo 1 = bloques. Un directorio sin contenido da 1..4; con contenido, mucho más.
100  awk '$1<=4 {sub(/.*\//,"",$2); print $2}' work/.respaldo-du.tmp \
101    | grep -E '^[0-9a-f]{64}-' | sort -u > work/respaldo-vacios.txt
102  awk '$1>4  {sub(/.*\//,"",$2); print $2}' work/.respaldo-du.tmp \
103    | grep -E '^[0-9a-f]{64}-' | sort -u > work/respaldo-sellados.txt.new
104  rm -f work/.respaldo-du.tmp
105  N=$(wc -l < work/respaldo-sellados.txt.new)
106  V=$(wc -l < work/respaldo-vacios.txt)
107  # Un listado vacío es un fallo de red disfrazado de respaldo borrado. No pisa nada.
108  if [ "$N" -eq 0 ]; then
109    echo "⚠ el box devolvió 0 artefactos — no piso el manifiesto (¿enlace caído?)" >&2
110    rm -f work/respaldo-sellados.txt.new; exit 1
111  fi
112  mv work/respaldo-sellados.txt.new work/respaldo-sellados.txt
113  echo "==> manifiesto del respaldo: $N artefactos → work/respaldo-sellados.txt"
114  # Se DICE siempre, aunque sea 0: un guardián que sólo habla cuando hay desastre no enseña a nadie
115  # qué está vigilando, y el día que hable nadie sabrá interpretarlo.
116  if [ "$V" -gt 0 ]; then
117    echo "⚠ $V artefactos VACÍOS en el respaldo (excluidos del manifiesto) → work/respaldo-vacios.txt" >&2
118    sed 's/^/    /' work/respaldo-vacios.txt >&2
119    echo "    curar = reconstruir la receta y volver a subirla; NO se cuentan como respaldadas" >&2
120  else
121    echo "    (0 artefactos vacíos)"
122  fi
123  exit 0
124fi
125
126# `-4` a propósito: el box tiene AAAA y el laptop tiene IPv6 sólo en wlan0, así que sin esto la ruta
127# por cable nunca se usaría aunque el cable estuviera enchufado.
128SSH_CMD="ssh -4 -p $SB_PORT -i $KEY -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30"
129
130# Opciones comunes. `--partial-dir` es lo que hace el respaldo continuable a mitad de fichero.
131# `--exclude /.dmerge`: son directorios TRANSITORIOS de fusión del store — aparecen y desaparecen
132# mientras takana sella. rsync los empieza a copiar, se esfuman a media transferencia y devuelve 23
133# («some files/attrs were not transferred»). Como 23 está en la lista de reintentables, el bucle los
134# reintentó **40 veces seguidas** y se rindió con el respaldo a medias (79 G de 127 G), sin que el
135# mensaje dijera nunca que la culpa era de un directorio temporal. `cosecha-cron.sh` ya los excluía;
136# este script no, y ésa era toda la diferencia.
137# ⇒ Un error reintentable que se repite 40 veces no es un corte de red: es algo estructural. Vale la
138#   pena que el bucle distinga «se cayó una vez» de «falla siempre igual».
139# `--info=progress2` SÓLO con terminal. Sin TTY reescribe la misma línea con `\r` y, como nadie
140# interpreta el retorno de carro, el «progreso» se acumula: una subida dejó un log de **2,6 MB**
141# donde lo único que importaba era la última línea. Con el respaldo corriendo desatendido (que es
142# como se corre), ese log es la única forma de saber qué pasó — y a 2,6 MB no se lee.
143PROG=""; [ -t 1 ] && PROG="--info=progress2"
144# zstd duplica el rendimiento efectivo (medido, ver cabecera) pero NO todo rsync lo trae: el del
145# PROPIO CORPUS se construye con `--disable-zstd` (`recipes/rsync.toml`), así que desde una caja
146# takana el respaldo moría en el primer paso con `unknown compress name: zstd` y un error 4 que el
147# script clasifica como "no de red" y no reintenta. Un respaldo que no corre por un algoritmo de
148# compresión es peor que un respaldo lento: se degrada a zlib y se avisa.
149if rsync --version 2>/dev/null | grep -A1 -i "compress list" | grep -qw zstd; then
150    COMPRESION="-z --compress-choice=zstd --compress-level=3"
151else
152    COMPRESION="-z"
153    echo "⚠ este rsync no trae zstd (compress list sin él) ⇒ comprimo con zlib."
154    echo "  Es ~la mitad de rendimiento efectivo. Se arregla en recipes/rsync.toml (dep zstd)."
155fi
156# ⚠ `--chmod=Fu+w` NO es cosmético: sin él, un parcial interrumpido queda en el destino con el modo
157# del origen —el store es `-r--r--r--` por diseño— y **ningún intento posterior puede reescribirlo**.
158# Eso envenena esa ruta PARA SIEMPRE (medido el 2026-09-17: 40 reintentos contra el mismo .ttf). Con
159# los ficheros del respaldo escribibles por su dueño, un corte a mitad se retoma en vez de trabarse.
160# El precio es que la copia no conserva el bit de sólo-lectura; es un precio bajo: el contenido es lo
161# que se respalda, y los permisos del store los reconstruye el propio store.
162RS="rsync -a --chmod=Fu+w --partial-dir=.rsync-partial --exclude /.dmerge $PROG $COMPRESION"
163
164# ── REINTENTOS: EL ENLACE SE CAE, Y ESO NO ES EXCEPCIONAL ───────────────────────────────────────
165# Primera corrida real (2026-08-07): tras subir el cerebro y 2,3 G de store, murió con
166# `Connection reset by peer` / `Broken pipe` y rsync salió con 255. Con una subida de ~19 h sobre un
167# enlace de oficina, que la conexión se corte no es un accidente: es lo NORMAL. Un respaldo que hay
168# que relanzar a mano cada vez que se cae no se completa nunca, porque nadie está mirando.
169#
170# Como rsync ya es incremental y `--partial-dir` conserva los trozos, reintentar es barato y seguro:
171# cada intento retoma donde quedó. El bucle sólo insiste ante fallos de RED (códigos 10/12/23/30/35/
172# 255); un error de verdad —permisos, disco lleno en destino, ruta inexistente— sale a la primera en
173# vez de repetirse 40 veces contra la misma pared.
174reintentar() {
175  etiqueta="$1"; shift
176  intento=1
177  while :; do
178    "$@" && return 0
179    rc=$?
180    case "$rc" in
181      10|12|30|35|255) ;;                          # red / pipe roto / timeout: insistir
182      # ⚠ 23 NO ES NECESARIAMENTE RED, y creerlo costó 40 reintentos inútiles (2026-09-17). El
183      # respaldo desde la caja se cayó 40 veces seguidas con el MISMO fichero:
184      #
185      #   open ".../fonts/dejavu/.rsync-partial/DejaVuSans-Oblique.ttf" failed: No such file… (2)
186      #
187      # La causa: una transferencia interrumpida el 11-sep dejó ese PARCIAL en el destino con modo
188      # `-r--r--r--` (el del origen: el store es de sólo lectura por diseño). Ningún intento
189      # posterior puede reescribirlo, así que el fallo es ETERNO — y el bucle lo llamaba «corte de
190      # red» y volvía a intentar cada 60 s. Es exactamente la pared que la cabecera de esta función
191      # dice que no hay que golpear 40 veces.
192      #
193      # ⇒ al 23 se le dan TRES intentos, no cuarenta, y al tercero se nombra la causa probable.
194      23)
195        if [ "$intento" -ge 3 ]; then
196          echo "!!  $etiqueta: rsync 23 tres veces seguidas. NO parece red: mirá si quedó un" >&2
197          echo "!!  parcial de sólo lectura en el destino (\`.rsync-partial/\` dentro del árbol)." >&2
198          echo "!!  Se borra a mano en el Storage Box y la corrida siguiente pasa." >&2
199          return "$rc"
200        fi
201        ;;
202      # 24 = «ficheros de origen desaparecieron durante la transferencia». NO es un fallo: pasa
203      # cuando `store-gc.sh` poda artefactos superados mientras el respaldo corre, que es una
204      # combinación NORMAL en este proyecto (el respaldo dura ~19 h y la poda es la válvula del
205      # disco). Sin esta línea el bucle lo tomaría por «error que no es de red» y PARARÍA el
206      # respaldo entero por algo inofensivo — y encima justo cuando el disco aprieta, que es cuando
207      # menos conviene quedarse sin copia.
208      24) ;;
209      *) echo "!!  $etiqueta: error $rc que NO es de red — no reintento"; return "$rc" ;;
210    esac
211    if [ "$intento" -ge 40 ]; then
212      echo "!!  $etiqueta: 40 intentos y sigue cayéndose. Dejo lo subido y paro."; return "$rc"
213    fi
214    espera=$(( intento < 6 ? intento * 10 : 60 ))
215    echo "..  $etiqueta: corte de red (rsync $rc). Intento $intento; reanudo en ${espera}s."
216    intento=$((intento + 1))
217    sleep "$espera"
218  done
219}
220
221# ── LA COMPROBACIÓN INICIAL TAMBIÉN REINTENTA ──────────────────────────────────────────────────
222# Era un `if` de un solo intento, y el 2026-08-07 tiró la corrida al relanzar el respaldo justo al
223# llegar a casa: el wifi todavía no había levantado, el `ssh` falló una vez y el script se rindió con
224# «no hay SSH» — cuando medio minuto después conectaba perfecto. Es el peor momento para rendirse:
225# quien relanza un respaldo interrumpido acaba de cambiar de red, así que el hipo es LO ESPERABLE.
226# Mismo criterio que el bucle de transferencia: insistir es barato, rendirse cuesta una noche.
227intento=1
228until $SSH_CMD "$SB_USER@$SB_HOST" 'df -h .' >/dev/null 2>&1; do
229  if [ "$intento" -ge 20 ]; then
230    echo "!!  No hay SSH al Storage Box ($SB_HOST:$SB_PORT) tras 20 intentos."
231    echo "!!  Si acabás de crearlo, el DNS tarda unos minutos en propagar."
232    exit 1
233  fi
234  echo "..  sin SSH al box todavía (¿red recién levantada?). Intento $intento; reintento en 15s."
235  intento=$((intento + 1))
236  sleep 15
237done
238
239echo "==> destino: $SB_USER@$SB_HOST:$SB_PORT  ${SECO:+(SECO)}"
240$SSH_CMD "$SB_USER@$SB_HOST" 'mkdir hammer hammer/store hammer/repo hammer/estado' >/dev/null 2>&1 || true
241
242# ── 1. EL CEREBRO: estado + repo. Minutos, y es lo que no se puede perder. ──────────────────────
243echo "==> [1/3] estado (el grafo: qué había construido y con qué hash)"
244reintentar estado $RS $SECO -e "$SSH_CMD" "$RAIZ/docs/state/" "$SB_USER@$SB_HOST:hammer/estado/" || true
245
246# ⚠ GUARDA: el repo va con `--delete`, así que respaldar desde un árbol INCOMPLETO no sube menos
247# cosas: BORRA las que faltan del respaldo. El caso real (2026-09-11, SDD 28 §6.10): la caja de
248# producción tiene el repo por `rsync --exclude=.git` —no es un clon—, y una corrida real desde ahí
249# habría borrado `.git` del Storage Box, o sea el historial entero. Falla ruidosamente en vez de
250# podar en silencio. `REPO_INCOMPLETO=1` es la escotilla para el caso deliberado.
251if [ ! -d "$RAIZ/.git" ] && [ "${REPO_INCOMPLETO:-0}" != 1 ]; then
252  echo "!! '$RAIZ' no tiene .git — parece una copia, no un clon." >&2
253  echo "   El paso [2/3] va con --delete: subir desde acá BORRARÍA del respaldo lo que falte," >&2
254  echo "   empezando por el historial de git. Respaldá desde un hub completo, o REPO_INCOMPLETO=1." >&2
255  exit 1
256fi
257echo "==> [2/3] repo (recetas, scripts, SDDs — el cerebro; con esto solo se reconstruye el store)"
258reintentar repo $RS --delete $SECO -e "$SSH_CMD" \
259  --exclude /store --exclude /store-rust --exclude /store-kern \
260  --exclude /work --exclude /target --exclude /.dev-fs --exclude /dist --exclude /.scratch \
261  "$RAIZ/" "$SB_USER@$SB_HOST:hammer/repo/"
262
263# ── 2 y 3. EL STORE, huérfanos primero. ────────────────────────────────────────────────────────
264# Guarda ANTES del bucle: un store ausente da rsync 23, que es reintentable, y se convierte en 40
265# intentos y un respaldo a medias sin que nada diga «esa ruta no existe».
266[ -d "$STORE" ] || { echo "!! el store no está en '$STORE' — pasá STORE=<ruta> (en una caja instalada suele ser /store)" >&2; exit 1; }
267# La lista de huérfanos la produce `store-gc.sh` como subproducto; si no hay una reciente, se sube
268# el store entero en un solo paso y santas pascuas (rsync es incremental: no se pierde nada).
269echo "==> [3/3] store (128 G a 8 Mbps ⇒ NO cabe en una sentada; esto se corta y se continúa)"
270HUER="$(ls -t "$RAIZ"/work/store-gc-huerfanos-*.txt 2>/dev/null | head -1)"
271if [ -n "$HUER" ] && [ -s "$HUER" ]; then
272  echo "    · huérfanos primero ($(wc -l < "$HUER") artefactos, únicos ejemplares) — desde $HUER"
273  reintentar huerfanos $RS $SECO -e "$SSH_CMD" --files-from="$HUER" "$STORE/" "$SB_USER@$SB_HOST:hammer/store/" || true
274fi
275echo "    · resto del store"
276reintentar store $RS $SECO -e "$SSH_CMD" "$STORE/" "$SB_USER@$SB_HOST:hammer/store/"
277
278echo "==> ocupación en el Storage Box:"
279$SSH_CMD "$SB_USER@$SB_HOST" 'df -h .' 2>/dev/null | tail -2