la caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano
`planear.py --revisar` sobre el censo ordena lo que queda, y lo primero no eran los servicios del usuario sino TRES CAPACIDADES que ninguna metrica reclama: el disparador periodico, la hora y la rotacion de logs. Los tres paquetes ya estaban sellados y declarados en `perfil.servidor` desde que ese perfil nacio — lo que faltaba era el eslabon que los ARRANCA, igual que con squid. · `chronyd`: la caja iba **15 s atrasada** y nadie la corregia. Ahora stratum 3 contra los NTP de Hetzner, 0,000005 s de NTP. Sin hora no hay TLS ni firmas, y el sintoma no se parece a la causa. · `crond`: no existia el latido. Verificado con una entrada `* * * * *` en el crontab REAL. · `logrotate`: `/var/log/squid` sin rotar. Diario, con `squid -k rotate` (squid mantiene los ficheros abiertos: un rename a secas lo deja escribiendo en un inode que nadie puede leer). · `scripts/respaldo-gitea.sh` + `17 3 * * *`: cierra el hueco del §6.15 — los 44 repos vivian en UNA copia y el respaldo se hacia a mano. Sube 23+21 repos (1,5 G) y un `gitea.db` de 332 M tomado con `.backup`, consistente con el servidor vivo. El control no es que el script salga 0: es CONTAR los repos de los dos lados. `chrony` y `cronie` NO declaraban `[[service]]` — el paquete llegaba a la imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de `hash_inputs`: los hashes no se movieron) y el perfil los habilita. TRES MEDICIONES que valen mas que el resultado: 1. **La deriva era de gioser, no de la caja**: despues de sincronizar, el HUB quedo 3 s adelantado. Medir «contra el otro» sin un tercero no dice quien esta mal. 2. **El spool de cronie es `/var/spool/cron/<usuario>`**, no `…/crontabs/<usuario>`: `crontab -l` mostraba las entradas y `crontabs/` estaba vacio. Un crontab restaurado en el sitio equivocado es un fichero perfecto que nadie lee. 3. 🧨 **`sqlite3` seguia INERTE en la caja** (`compressBound: symbol not found`): el ultimo de los 23 del §6.4. Le faltaba `zlib-shared`, sellado y declarado en `perfil.base` desde el 11-09 — pero la caja se instalo antes. Sin el no habia snapshot consistente. Un paquete declarado no es un paquete instalado: en una caja viva vale lo que el `upgrade` proyecto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Semantic delta
No definition changed: this change is text, documentation or data.
5 files · +169 −1
- docs/28-servidor-de-produccion.md +42 −0
- docs/state/targets.toml +5 −1
- recipes/chrony.toml +26 −0
- recipes/cronie.toml +31 −0
- scripts/respaldo-gitea.sh +65 −0
diff --git a/docs/28-servidor-de-produccion.md b/docs/28-servidor-de-produccion.md
index f5767210..07bd7032 100644
--- a/docs/28-servidor-de-produccion.md
+++ b/docs/28-servidor-de-produccion.md
@@ -1691,6 +1691,48 @@ escrito por el log daemon, y el `basic_ncsa_auth` leyendo el `passwd` de verdad
con una clave mala contesta `ERR Wrong password`). Los dos primeros intentos de esta receta habrían
llegado a producción sin ese control: el binario estaba sellado y el hash era estable las dos veces.
+### 6.29 ⏱️ La caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano *(2026-09-15)*
+
+`planear.py --revisar` sobre el censo del día ordena lo que queda, y lo primero de la lista no eran
+los servicios del usuario sino **tres capacidades que ninguna métrica reclama**: el disparador
+periódico, la hora y la rotación de logs. Los tres paquetes ya estaban sellados y declarados en
+`perfil.servidor` desde que ese perfil nació — **lo que faltaba era el eslabón que los ARRANCA**.
+
+| | antes | ahora |
+|---|---|---|
+| `chronyd` | la caja iba **15 s atrasada** y nadie la corregía | stratum 3 contra `ntp{1,2,3}.hetzner.de`, **0,000005 s** de NTP |
+| `crond` | no existía el latido | ejecuta el crontab real, verificado con una entrada `* * * * *` |
+| `logrotate` | `/var/log/squid` sin rotar | diario con `squid -k rotate`, 14 copias |
+| respaldo del gitea | **a mano, una vez** (§6.15) | `17 3 * * *`, con el snapshot consistente de la sqlite |
+
+**El respaldo, medido:** `scripts/respaldo-gitea.sh` sube **44 repos (23 `sergio` + 21 `tawasuyu`,
+1,5 G)** y un `gitea.db` de **332 M** tomado con `.backup` —consistente con el servidor vivo— al
+Storage Box. El control no es que el script salga 0: es **contar los repos de los dos lados**, que
+es lo que se hizo.
+
+⚠ **`chrony` y `cronie` NO declaraban `[[service]]`**, como `squid` antes: el paquete llegaba a la
+imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de `hash_inputs`: los hashes no se movieron)
+y `perfil.servidor` los habilita en `servicios`.
+
+⚠ **La config va aparte y el servicio sale 78 si falta**, igual que gitea: a qué NTP se pregunta y
+qué logs se guardan son decisiones del SITIO. Una caja que se sincroniza contra un pool que nadie
+eligió es peor que una sin hora, porque nadie lo mira.
+
+#### Tres cosas medidas que valen más que el resultado
+
+1. **La deriva era de gioser, no de la caja.** Antes de chrony la caja iba 15 s atrás; después, la
+ caja quedó en hora de NTP y **el hub pasó a estar 3 s adelantado**. La máquina de referencia era
+ la equivocada — medir «contra el otro» sin un tercero no dice quién está mal.
+2. **El spool de cronie es `/var/spool/cron/<usuario>`, no `…/crontabs/<usuario>`.** `crontab -l`
+ mostraba las entradas y `crontabs/` estaba vacío. El día que alguien restaure un crontab a mano
+ en el sitio equivocado va a tener un fichero perfecto que nadie lee. Lo decide un control y no la
+ intuición: meter un `* * * * *` en el crontab REAL y ver si dispara.
+3. 🧨 **`sqlite3` seguía inerte en la caja** (`Error relocating: compressBound: symbol not found`):
+ es el último de los 23 binarios del §6.4, y le faltaba `zlib-shared` — **sellado y declarado en
+ `perfil.base` desde el 2026-09-11**, pero la caja se instaló antes. Sin él no había snapshot
+ consistente, o sea que el respaldo habría copiado la sqlite a lo bruto. *Un paquete declarado no
+ es un paquete instalado: en una caja viva, lo que vale es lo que el `upgrade` proyectó.*
+
## 7. Reusar los scripts que ya existen, y no escribir de nuevo
Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index c6d170ff..2253dfcc 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -1409,4 +1409,8 @@ paquetes = [
# `gitea` en `/etc/passwd`— y sale con 78 si falta alguna. Es a propósito: las dos vienen con los
# datos de la mudanza, y un gitea que arranca sin su config ofrece el asistente de «crear
# administrador» a quien pase. Hasta que la mudanza traiga ambas, este servicio NO levanta y lo dice.
-servicios = ["sshd", "gitea", "caddy", "minga", "squid"]
+# ⚠ `crond` y `chronyd` (2026-09-15) son las dos CAPACIDADES que un servidor necesita y que ninguna
+# métrica reclama: el latido y la hora. Sus paquetes ya estaban declarados arriba y sellados —lo que
+# faltaba era el eslabón que los ARRANCA, que es este. Una caja con `cronie` instalado y sin `crond`
+# corriendo se ve idéntica a una con latido, y no rota un log ni respalda nada.
+servicios = ["sshd", "gitea", "caddy", "minga", "squid", "crond", "chronyd"]
diff --git a/recipes/chrony.toml b/recipes/chrony.toml
index d2846f55..e520f6a1 100644
--- a/recipes/chrony.toml
+++ b/recipes/chrony.toml
@@ -68,3 +68,29 @@ install = "make install DESTDIR=/out"
[deps]
build = ["binutils", "busybox", "make", "pkgconf", "libcap"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# Fuera de `hash_inputs`: declarar esto NO re-hashea chrony.
+#
+# ⚠ SIN HORA NO HAY TLS NI FIRMAS, y el síntoma no se parece a la causa: los certificados salen
+# «not yet valid» o «expired», el ACME falla, y todo apunta al servidor web. Una caja de Hetzner
+# arranca con la hora del reloj virtual y sin nadie que la corrija: `netup` pone la red y ahí
+# termina su trabajo. Por eso `perfil.servidor` lo declara desde que nació (§6.2).
+#
+# `-n` (foreground, para arje) y `-u chrony`… NO: el binario ajusta el reloj del sistema, que es
+# capability de root, y bajar de privilegio necesita una cuenta y un `driftfile` suyos. Corre como
+# root, que es lo que hace en la práctica cualquier distro sin un `chrony` user preparado.
+#
+# ⚠ La CONFIG es del sitio: a qué servidores NTP se pregunta es una decisión de quien administra la
+# caja (pool público, el del proveedor, uno propio). El servicio sale 78 nombrando el fichero en vez
+# de inventarse un pool — una caja que se sincroniza contra un servidor que nadie eligió es peor que
+# una sin hora, porque nadie lo mira.
+[[service]]
+label = "chronyd"
+id = "01M2EKDA00CHR0NYD7K2X4M9V3"
+exec = "/bin/busybox"
+argv = ["sh", "-c", "test -f /etc/chrony.conf || { echo 'chronyd: falta /etc/chrony.conf — a qué servidores NTP se pregunta es decisión del sitio, no del paquete' >&2; exit 78; }; mkdir -p /var/lib/chrony; exec /usr/sbin/chronyd -n -f /etc/chrony.conf"]
+envp = [["PATH", "/usr/sbin:/usr/bin:/sbin:/bin"], ["HOME", "/root"], ["USER", "root"]]
+networking = "full"
+cgroup = "arje.slice/chronyd"
+restart = { initial_ms = 1000, max_ms = 30000 }
diff --git a/recipes/cronie.toml b/recipes/cronie.toml
index f7f4b679..3b946569 100644
--- a/recipes/cronie.toml
+++ b/recipes/cronie.toml
@@ -52,3 +52,34 @@ install = "make install DESTDIR=/out && find /out -name '*.la' -delete"
[deps]
build = ["binutils", "busybox", "make", "pkgconf"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# Fuera de `hash_inputs`: declarar esto NO re-hashea cronie.
+#
+# ⚠ SIN DISPARADOR PERIÓDICO UN SERVIDOR NO TIENE LATIDO, y eso no lo canta ninguna métrica: la
+# caja se ve perfecta —todo sellado, todo sirviendo— y no rota un log, no renueva nada por su
+# cuenta y no respalda nada. En la mudanza de gioser se descubrió al revés: el `crond` del origen
+# era el que disparaba la cosecha, el respaldo y la poda, y el destino no tenía NADA equivalente.
+# Es la misma familia que la lección de `foot`, pero de una CAPACIDAD, no de un paquete.
+#
+# `-n` (foreground) porque el supervisor es arje: un daemon que se va a segundo plano deja al init
+# cuidando un proceso que ya murió. `-P` deja el PATH del entorno en vez de reescribirlo.
+#
+# ⚠ La guarda mira el DIRECTORIO de spool, no un fichero: un `crond` sin `/var/spool/cron` arranca
+# igual y no ejecuta nada — un latido que late en el vacío es peor que uno ausente, porque parece
+# que está.
+#
+# ⚠ Y el spool de ESTA cronie es `/var/spool/cron/<usuario>`, NO `/var/spool/cron/crontabs/<usuario>`
+# (medido en la caja: `crontab -l` mostraba las entradas y `crontabs/` estaba vacío). La diferencia
+# importa el día que alguien restaure un crontab a mano en el sitio equivocado: quedaría un fichero
+# perfecto que nadie lee. El control que lo decide es el único que vale — meter una entrada
+# `* * * * *` en el crontab REAL y ver si dispara.
+[[service]]
+label = "crond"
+id = "01M2EKDA00CR0N1EDAEM0N5QZ8"
+exec = "/bin/busybox"
+argv = ["sh", "-c", "mkdir -p /var/spool/cron /etc/cron.d; test -d /var/spool/cron || { echo 'crond: no pude crear /var/spool/cron — sin spool no ejecuta NADA y arranca igual' >&2; exit 78; }; exec /usr/sbin/crond -n -P"]
+envp = [["PATH", "/usr/sbin:/usr/bin:/sbin:/bin"], ["HOME", "/root"], ["USER", "root"], ["SHELL", "/bin/sh"]]
+networking = "full"
+cgroup = "arje.slice/crond"
+restart = { initial_ms = 1000, max_ms = 30000 }
diff --git a/scripts/respaldo-gitea.sh b/scripts/respaldo-gitea.sh
new file mode 100755
index 00000000..49ef4a9c
--- /dev/null
+++ b/scripts/respaldo-gitea.sh
@@ -0,0 +1,65 @@
+#!/bin/sh
+# respaldo-gitea.sh — el respaldo PERIÓDICO del gitea: la sqlite y los repos.
+#
+# ── POR QUÉ EXISTE (SDD 28 §6.15) ───────────────────────────────────────────────────────────────
+# `respaldo-storagebox.sh` respalda el STORE de artefactos, que es lo reconstruible. **Los datos del
+# gitea —44 repos, 26 de ellos sin copia en ningún otro lado— no los cubría nadie**, y estuvieron en
+# UNA sola copia hasta el 2026-09-14, cuando se hizo a mano. Un respaldo que depende de que alguien
+# se acuerde es un respaldo que no existe: esto es lo mismo, con `crond` disparándolo.
+#
+# ── LAS DOS PARTES, Y POR QUÉ LA SQLITE NO SE COPIA A LO BRUTO ──────────────────────────────────
+# Un `cp` de una sqlite viva puede quedar a medias de una transacción y el fichero resultante abre
+# —parece bueno— y le faltan las últimas escrituras. `.backup` del propio sqlite3 toma una copia
+# CONSISTENTE con el servidor corriendo (1,5 s medidos sobre los 2 G de gioser). Los repos en cambio
+# son ficheros que git sólo AÑADE, así que `rsync -aH` alcanza.
+#
+# ⚠ Al Storage Box se entra por el PUERTO 23: el 22 da un SFTP restringido y contesta
+# `Permission denied (publickey,password)` teniendo la clave buena — se lee como «no tengo acceso».
+#
+# Uso: respaldo-gitea.sh (desde la caja que sirve el gitea)
+# DRY=1 respaldo-gitea.sh (dice qué haría y no sube nada)
+set -eu
+
+GITEA_DATA="${GITEA_DATA:-/var/lib/gitea}"
+GITEA_ETC="${GITEA_ETC:-/etc/gitea}"
+SB_USER="${SB_USER:-u647150}"
+SB_HOST="${SB_HOST:-u647150.your-storagebox.de}"
+SB_PORT="${SB_PORT:-23}"
+KEY="${KEY:-/root/.ssh/github5}"
+DESTINO="${DESTINO:-gitea}"
+TMP="${TMP:-/work/respaldo-gitea}"
+
+log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
+
+[ -d "$GITEA_DATA" ] || { log "no existe $GITEA_DATA — ¿esta caja sirve el gitea?"; exit 78; }
+command -v sqlite3 >/dev/null || { log "falta sqlite3: la DB se copiaría a lo bruto y eso NO es consistente"; exit 78; }
+[ -f "$KEY" ] || { log "falta la clave $KEY — sin ella no hay Storage Box"; exit 78; }
+
+mkdir -p "$TMP"
+DB="$GITEA_DATA/gitea.db"
+if [ -f "$DB" ]; then
+ log "snapshot consistente de la sqlite…"
+ rm -f "$TMP/gitea.db"
+ sqlite3 "$DB" ".backup '$TMP/gitea.db'"
+ # Un `.backup` que deja un fichero de 0 bytes es el modo de fallo que más engaña: sube, ocupa
+ # lugar y no restaura nada (`CLAUDE.md` regla 3).
+ [ -s "$TMP/gitea.db" ] || { log "el snapshot salió VACÍO — no se sube nada"; exit 1; }
+ log " $(du -h "$TMP/gitea.db" | cut -f1)"
+else
+ log "no hay $DB (¿DB_TYPE distinto de sqlite3?) — sigo con los repos"
+fi
+
+SSH_CMD="ssh -4 -p $SB_PORT -i $KEY -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30"
+RS="rsync -aH --partial -e '$SSH_CMD'"
+[ "${DRY:-0}" = "1" ] && RS="$RS --dry-run" && log "DRY=1: no se sube nada"
+
+log "subiendo repos y config…"
+eval rsync -aH --partial -e "'$SSH_CMD'" ${DRY:+--dry-run} \
+ --exclude 'gitea.db' --exclude 'gitea.db-wal' --exclude 'gitea.db-shm' \
+ "$GITEA_DATA/" "$SB_USER@$SB_HOST:$DESTINO/data/"
+eval rsync -aH --partial -e "'$SSH_CMD'" ${DRY:+--dry-run} \
+ "$GITEA_ETC/" "$SB_USER@$SB_HOST:$DESTINO/etc/"
+[ -s "$TMP/gitea.db" ] && eval rsync -aH --partial -e "'$SSH_CMD'" ${DRY:+--dry-run} \
+ "$TMP/gitea.db" "$SB_USER@$SB_HOST:$DESTINO/gitea.db"
+
+log "listo."