diff --git a/backup-system/RESTORE.md b/backup-system/RESTORE.md new file mode 100644 index 0000000..e715090 --- /dev/null +++ b/backup-system/RESTORE.md @@ -0,0 +1,94 @@ +# RESTORE.md — Runbook de restauración del homelab + +> Última revisión: 2026-07-15. Si cambias `backup.sh`, actualiza este runbook. + +## Dónde están las copias + +- **Mega** (`mega:k3s-backups/`): espejo diario de `/data/backups/backups` del master + (retención 7 por servicio; 3 para `uptime-kuma` y `home-master`), más el subdir + `reddit-intel/` que sube el CronJob de n97. +- **Backblaze B2** (`b2:.../`): copia reducida "crown jewels" (últimas 2 de cada + servicio irremplazable). Segundo proveedor por si Mega falla. +- Acceso: credenciales rclone en Infisical (`/backup-system`, key `RCLONE_CONF`). + **Si el cluster está muerto, Infisical también** → usa el login de Mega/B2 de la + cuenta personal (Vaultwarden... que también está en el backup: guarda estas dos + contraseñas TAMBIÉN en un gestor externo al homelab, p. ej. el llavero del móvil). + +## ⚠️ La cadena de dependencias (leer antes de restaurar nada) + +``` +token k3s ──> state.db (todos los Secrets k8s) ──> infisical-secrets (ENCRYPTION_KEY) + │ + v + dump infisical-pg (secrets de apps) + │ + v + resto de apps (n8n, ghost smtp, ddns, runner…) +``` + +1. `k3s-db_*.tar.gz` contiene `state.db` **+ `/var/lib/rancher/k3s/server/token` + + `/etc/rancher/k3s/`**. Sin ese token, un k3s nuevo NO puede leer el state.db + (los datos de bootstrap van cifrados con él). +2. El Secret `infisical-secrets` (ns `infisical`: `ENCRYPTION_KEY`, `AUTH_SECRET`) + está DENTRO de state.db. Sin él, el dump `infisical-pg` es indescifrable. +3. n8n es autosuficiente: su `encryptionKey` va dentro de `n8n_*.tar.gz` + (`.n8n/config`). + +## Escenario A — Perder un PVC / una app (lo habitual) + +Restaurar solo el artefacto de esa app (ver tabla). El cluster sigue vivo. + +## Escenario B — Muere el disco del master (chemavx-k8) + +1. Reinstalar Ubuntu + k3s server (misma versión mayor; ver skill k8s-infra). +2. Restaurar del `k3s-db_*.tar.gz`: `/etc/rancher/k3s/`, el fichero `token` en + `/var/lib/rancher/k3s/server/token` y `state.db*` en + `/var/lib/rancher/k3s/server/db/` (con k3s parado). Arrancar k3s. +3. Con el API server vivo: los manifests los trae ArgoCD desde Gitea → pero Gitea + está en el master: restaurar primero Gitea (tabla) para que ArgoCD sincronice. +4. Restaurar los datos de apps del master (tabla). Reinstalar Tailscale a mano + (está fuera de k8s). + +## Escenario C — Muere el disco del worker (n97) + +Reinstalar Ubuntu + k3s agent (token del master), dejar que los pods se +reprogramen y restaurar: ghost-en, zona-exclusion, researchowl, trilium, +umami-pg, roswell-pg (la BD corre en n97) y `/opt/reddit-intel` + Docker. + +## Tabla de restauración por artefacto + +| Artefacto | Cómo restaurar | +|---|---| +| `n8n_*.tar.gz` | Extraer en `/data/n8n` (hostPath master), reiniciar deploy. Incluye encryptionKey. | +| `openclaw_*.tar.gz` | Extraer en el PVC `openclaw-pvc` (pod parado). | +| `homarr_*.tar.gz` | `db/db.sqlite` → PVC db; `redis/dump.rdb` → PVC redis. | +| `authentik-pg_*.sql.gz` | `gunzip -c \| kubectl exec -i postgresql-0 -n authentik -- psql -U authentik authentik` (BD vacía primero). | +| `grafana_*.tar.gz` | `grafana.db` → PVC de grafana, reiniciar pod. | +| `k3s-db_*.tar.gz` | Ver Escenario B. **No** restaurar sobre un cluster sano. | +| `gitea_*.tar.gz` | Extraer en el PVC de gitea; **renombrar `gitea/gitea-backup.db` → `gitea/gitea.db`**. El registry (packages) NO está: re-push de imágenes vía CI (re-run de Gitea Actions por app). | +| `ghost-*-db_*.db.gz` | `gunzip` → `content/data/ghost.db` (pod parado). | +| `ghost-*-content_*.tar.gz` | Extraer en `/var/lib/ghost/content` (incluye imágenes, temas, `data/redirects.yaml`; el `data/ghost.db` del tar es copia en caliente — usa el `-db_*.db.gz`, que es la consistente). | +| `researchowl_*.db.gz` | `gunzip` → `/data/researchowl.db` en el PVC (pod parado; borrar `-wal`/`-shm` viejos). | +| `roswell-pg_*.sql.gz` / `umami-pg_*.sql.gz` | Como authentik: `psql` contra el deploy/postgres de su namespace. | +| `infisical-pg_*.sql.gz` | **Primero** debe existir el Secret `infisical-secrets` original (viene en state.db; si no, exportarlo ANTES de perderlo). Luego psql contra `infisicalDB` en `postgresql-0`. | +| `trilium_*.tar.gz` | Extraer en `/home/node/trilium-data` (PVC, pod parado). | +| `vaultwarden_*.tar.gz` | Extraer en el PVC (pod parado). Incluye db, attachments y claves RSA. | +| `uptime-kuma_*.db.gz` | `gunzip` → `/app/data/kuma.db` (pod parado, borrar `-wal`/`-shm`). | +| `filebrowser-db_*.tar.gz` | Extraer en el PVC `filebrowser-db`. | +| `home-master_*.tar.gz` | Extraer en `/home/chemavx`. **No incluye `~/.ssh`** (regenerar claves) ni `~/bin` ni venvs/caches (reinstalar). | +| `reddit-intel/reddit-intel_*.tar.gz` | Extraer en `/opt/reddit-intel` de n97, `docker compose up -d`. | + +## Qué NO está en el backup (reconstruible, asumido) + +- Registry de imágenes de Gitea (re-build por CI), modelos Ollama (re-pull), + hf-cache de roswell, Prometheus TSDB (histórico de métricas se pierde), + registry-cache, SO de los nodos, Tailscale (re-join), certificados TLS + (cert-manager los re-emite), DNS (favonia lo reconstruye). +- `~/.ssh` del master: excluido a propósito — regenerar par ed25519 y + re-autorizar en n97/Gitea. + +## Simulacro + +Tras cualquier cambio gordo en `backup.sh`, repetir el simulacro: bajar de Mega +el `ghost-en-db` más reciente, `PRAGMA integrity_check` + `SELECT count(*) FROM posts`, +y extraer un `home-master` en /tmp comprobando que `~/.claude/` está dentro. diff --git a/backup-system/configmap-backup-scripts.yaml b/backup-system/configmap-backup-scripts.yaml index 12d5cf1..6d1d789 100644 --- a/backup-system/configmap-backup-scripts.yaml +++ b/backup-system/configmap-backup-scripts.yaml @@ -2,69 +2,222 @@ apiVersion: v1 data: backup.sh: | #!/bin/bash - set -e + # Backup diario del homelab. Imagen: bitnami/kubectl (bash GNU, corre en chemavx-k8). + # Regla: cada artefacto se valida (gzip -t + tamaño mínimo) — un tar vacío + # como el bug histórico de openclaw (104 bytes) cuenta como FALLO y el job sale 1 + # (→ alerta Grafana homelab-backup-job-failed → Telegram). + set -u BACKUP_DIR="/data/backups/backups" DATE=$(date +%Y-%m-%d_%H-%M-%S) - KUBECONFIG=/etc/rancher/k3s/k3s.yaml + STORAGE=/var/lib/rancher/k3s/storage + FAIL=0 mkdir -p "$BACKUP_DIR" - log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + fail() { log "ERROR: $1 FAILED"; FAIL=$((FAIL + 1)); } - # === n8n === + # Valida artefacto: gzip íntegro y tamaño >= mínimo esperado + check() { # $1=fichero $2=bytes_minimos $3=servicio + if gzip -t "$1" 2>/dev/null && [ "$(stat -c%s "$1" 2>/dev/null || echo 0)" -ge "$2" ]; then + log "$3 OK ($(du -h "$1" | cut -f1))" + else + rm -f "$1" + fail "$3 (artefacto corrupto o menor de $2 bytes)" + fi + } + + # Resuelve la ruta local-path de un PVC en el host + pvpath() { # $1=ns $2=pvc + local pv + pv=$(kubectl get pvc -n "$1" "$2" -o jsonpath='{.spec.volumeName}' 2>/dev/null) || return 1 + echo "$STORAGE/${pv}_$1_$2" + } + + # Copia consistente de la ghost.db (VACUUM INTO vía node del pod) + tar del content + backup_ghost() { # $1=ns $2=deploy $3=prefijo + local f="$BACKUP_DIR/${3}-db_${DATE}.db.gz" + kubectl exec -i -n "$1" "deploy/$2" -- sh -c \ + 'rm -f /tmp/ghost-backup.db && NODE_PATH=/var/lib/ghost/current/node_modules node -' \ + < /scripts/ghost-db-backup.js >/dev/null 2>&1 \ + && kubectl exec -n "$1" "deploy/$2" -- cat /tmp/ghost-backup.db 2>/dev/null | gzip > "$f" + kubectl exec -n "$1" "deploy/$2" -- rm -f /tmp/ghost-backup.db 2>/dev/null || true + check "$f" 200000 "${3}-db" + local c="$BACKUP_DIR/${3}-content_${DATE}.tar.gz" + kubectl exec -n "$1" "deploy/$2" -- tar czf - -C /var/lib/ghost/content . > "$c" 2>/dev/null + check "$c" 5000000 "${3}-content" + } + + # === n8n (hostPath /data/n8n — incluye .n8n/config con la encryptionKey) === log "Backing up n8n..." - tar -czf "$BACKUP_DIR/n8n_${DATE}.tar.gz" -C /data/n8n . 2>/dev/null && log "n8n OK" + tar -czf "$BACKUP_DIR/n8n_${DATE}.tar.gz" -C /data/n8n . 2>/dev/null + check "$BACKUP_DIR/n8n_${DATE}.tar.gz" 10000000 n8n - # === openclaw === + # === openclaw (PVC local-path; el hostPath /data/openclaw era un stub vacío) === log "Backing up openclaw..." - tar -czf "$BACKUP_DIR/openclaw_${DATE}.tar.gz" -C /data/openclaw . 2>/dev/null && log "openclaw OK" + OC=$(pvpath openclaw openclaw-pvc) + tar -czf "$BACKUP_DIR/openclaw_${DATE}.tar.gz" -C "$OC" . 2>/dev/null + check "$BACKUP_DIR/openclaw_${DATE}.tar.gz" 50000 openclaw # === homarr === log "Backing up homarr..." - HOMARR_DB_PV=$(kubectl get pvc -n homarr homarr-db-pvc -o jsonpath='{.spec.volumeName}' 2>/dev/null) - HOMARR_REDIS_PV=$(kubectl get pvc -n homarr homarr-redis-pvc -o jsonpath='{.spec.volumeName}' 2>/dev/null) + HOMARR_DB=$(pvpath homarr homarr-db-pvc) + HOMARR_REDIS=$(pvpath homarr homarr-redis-pvc) mkdir -p /tmp/backup-homarr/db /tmp/backup-homarr/redis - cp /var/lib/rancher/k3s/storage/${HOMARR_DB_PV}_homarr_homarr-db-pvc/db.sqlite /tmp/backup-homarr/db/ 2>/dev/null || true - cp /var/lib/rancher/k3s/storage/${HOMARR_REDIS_PV}_homarr_homarr-redis-pvc/dump.rdb /tmp/backup-homarr/redis/ 2>/dev/null || true - tar -czf "$BACKUP_DIR/homarr_${DATE}.tar.gz" -C /tmp/backup-homarr . 2>/dev/null && log "homarr OK" + cp "$HOMARR_DB/db.sqlite" /tmp/backup-homarr/db/ 2>/dev/null || true + cp "$HOMARR_REDIS/dump.rdb" /tmp/backup-homarr/redis/ 2>/dev/null || true + tar -czf "$BACKUP_DIR/homarr_${DATE}.tar.gz" -C /tmp/backup-homarr . 2>/dev/null + check "$BACKUP_DIR/homarr_${DATE}.tar.gz" 10000 homarr rm -rf /tmp/backup-homarr # === authentik PostgreSQL === log "Backing up authentik postgres..." kubectl exec -n authentik postgresql-0 -- pg_dump -U authentik authentik 2>/dev/null | \ - gzip > "$BACKUP_DIR/authentik-pg_${DATE}.sql.gz" && log "authentik-pg OK" - - # === polymarket PostgreSQL === (retirado 2026-07-06, decomisión del bot) - # postgres escalado a 0; dump definitivo verificado en - # $BACKUP_DIR/polymarket-decommission/ (+ último nocturno .last-nightly). + gzip > "$BACKUP_DIR/authentik-pg_${DATE}.sql.gz" + check "$BACKUP_DIR/authentik-pg_${DATE}.sql.gz" 10000 authentik-pg # === grafana === log "Backing up grafana..." - GRAFANA_PV=$(kubectl get pvc -n monitoring kube-prometheus-stack-grafana -o jsonpath='{.spec.volumeName}' 2>/dev/null) + GRAFANA=$(pvpath monitoring kube-prometheus-stack-grafana) mkdir -p /tmp/backup-grafana - cp /var/lib/rancher/k3s/storage/${GRAFANA_PV}_monitoring_kube-prometheus-stack-grafana/grafana.db /tmp/backup-grafana/ 2>/dev/null || true - tar -czf "$BACKUP_DIR/grafana_${DATE}.tar.gz" -C /tmp/backup-grafana . 2>/dev/null && log "grafana OK" + cp "$GRAFANA/grafana.db" /tmp/backup-grafana/ 2>/dev/null || true + tar -czf "$BACKUP_DIR/grafana_${DATE}.tar.gz" -C /tmp/backup-grafana . 2>/dev/null + check "$BACKUP_DIR/grafana_${DATE}.tar.gz" 100000 grafana rm -rf /tmp/backup-grafana - # === k3s SQLite === - log "Backing up k3s SQLite..." + # === k3s: state.db + token del server + /etc/rancher/k3s === + # Sin el token, state.db NO arranca en un k3s nuevo (cadena de restauración). + log "Backing up k3s (state.db + token + /etc/rancher/k3s)..." tar -czf "$BACKUP_DIR/k3s-db_${DATE}.tar.gz" \ - /data/k3s-db/state.db \ - /data/k3s-db/state.db-shm \ - /data/k3s-db/state.db-wal 2>/dev/null \ - && log "k3s-db OK" \ - || log "k3s-db WARNING: backup parcial (WAL/SHM pueden estar ausentes si DB está idle)" + /data/k3s-db/state.db* /host/k3s-token /host/etc-rancher-k3s 2>/dev/null + check "$BACKUP_DIR/k3s-db_${DATE}.tar.gz" 1000000 k3s-db - # === Cleanup old backups (keep last 7 per service) === + # === Gitea: sqlite .backup consistente + repos + conf (SIN gitea/packages, 6.4G de registry reconstruible por CI) === + log "Backing up gitea..." + kubectl exec -n gitea gitea-0 -- sh -c \ + 'rm -f /data/gitea/gitea-backup.db && sqlite3 /data/gitea/gitea.db ".backup /data/gitea/gitea-backup.db"' 2>/dev/null \ + && kubectl exec -n gitea gitea-0 -- tar czf - -C /data \ + git ssh gitea/conf gitea/jwt gitea/avatars gitea/repo-avatars gitea/gitea-backup.db \ + > "$BACKUP_DIR/gitea_${DATE}.tar.gz" 2>/dev/null + kubectl exec -n gitea gitea-0 -- rm -f /data/gitea/gitea-backup.db 2>/dev/null || true + check "$BACKUP_DIR/gitea_${DATE}.tar.gz" 5000000 gitea + + # === Ghost EN + ES (db consistente + content con imágenes/temas/redirects) === + log "Backing up ghost-en..." + backup_ghost ghost-en ghost-en ghost-en + log "Backing up zona-exclusion..." + backup_ghost zona-exclusion zona-exclusion zona-exclusion + + # === ResearchOwl (sqlite .backup vía python3 del pod) === + log "Backing up researchowl..." + kubectl exec -i -n researchowl deploy/researchowl -- sh -c \ + 'rm -f /tmp/ro-backup.db && python3 -' < /scripts/sqlite-stdin-backup.py >/dev/null 2>&1 \ + && kubectl exec -n researchowl deploy/researchowl -- cat /tmp/ro-backup.db 2>/dev/null | \ + gzip > "$BACKUP_DIR/researchowl_${DATE}.db.gz" + kubectl exec -n researchowl deploy/researchowl -- rm -f /tmp/ro-backup.db 2>/dev/null || true + check "$BACKUP_DIR/researchowl_${DATE}.db.gz" 5000000 researchowl + + # === Roswell (corpus) PostgreSQL === + log "Backing up roswell postgres..." + kubectl exec -n roswell deploy/postgres -- sh -c \ + 'PGPASSWORD="${POSTGRES_PASSWORD:-}" pg_dump -U "$POSTGRES_USER" "${POSTGRES_DB:-$POSTGRES_USER}"' 2>/dev/null | \ + gzip > "$BACKUP_DIR/roswell-pg_${DATE}.sql.gz" + check "$BACKUP_DIR/roswell-pg_${DATE}.sql.gz" 5000 roswell-pg + + # === Umami PostgreSQL === + log "Backing up umami postgres..." + kubectl exec -n umami deploy/postgres -- sh -c \ + 'PGPASSWORD="${POSTGRES_PASSWORD:-}" pg_dump -U "$POSTGRES_USER" "${POSTGRES_DB:-$POSTGRES_USER}"' 2>/dev/null | \ + gzip > "$BACKUP_DIR/umami-pg_${DATE}.sql.gz" + check "$BACKUP_DIR/umami-pg_${DATE}.sql.gz" 5000 umami-pg + + # === Infisical PostgreSQL (RESTAURAR REQUIERE infisical-secrets — está dentro de k3s-db/state.db, ver RESTORE.md) === + log "Backing up infisical postgres..." + kubectl exec -n infisical postgresql-0 -- sh -c \ + 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -U "$POSTGRES_USER" infisicalDB' 2>/dev/null | \ + gzip > "$BACKUP_DIR/infisical-pg_${DATE}.sql.gz" + check "$BACKUP_DIR/infisical-pg_${DATE}.sql.gz" 10000 infisical-pg + + # === Trilium === + log "Backing up trilium..." + kubectl exec -n trilium deploy/trilium -- tar czf - -C /home/node/trilium-data . \ + > "$BACKUP_DIR/trilium_${DATE}.tar.gz" 2>/dev/null + check "$BACKUP_DIR/trilium_${DATE}.tar.gz" 500000 trilium + + # === Vaultwarden (db + attachments + claves RSA; copia en frío ~02:00) === + log "Backing up vaultwarden..." + VW=$(pvpath vaultwarden vaultwarden-pvc) + tar -czf "$BACKUP_DIR/vaultwarden_${DATE}.tar.gz" -C "$VW" . 2>/dev/null + check "$BACKUP_DIR/vaultwarden_${DATE}.tar.gz" 100000 vaultwarden + + # === Uptime-Kuma (sqlite .backup vía CLI del pod; retención corta, es el artefacto gordo) === + log "Backing up uptime-kuma..." + kubectl exec -n monitoring deploy/uptime-kuma -- sh -c \ + 'rm -f /tmp/kuma-backup.db && sqlite3 /app/data/kuma.db ".backup /tmp/kuma-backup.db"' 2>/dev/null \ + && kubectl exec -n monitoring deploy/uptime-kuma -- cat /tmp/kuma-backup.db 2>/dev/null | \ + gzip > "$BACKUP_DIR/uptime-kuma_${DATE}.db.gz" + kubectl exec -n monitoring deploy/uptime-kuma -- rm -f /tmp/kuma-backup.db 2>/dev/null || true + check "$BACKUP_DIR/uptime-kuma_${DATE}.db.gz" 1000000 uptime-kuma + + # === Filebrowser (solo su BD; los ficheros servidos van en home-master/imagefinder) === + log "Backing up filebrowser db..." + FB=$(pvpath filebrowser filebrowser-db) + tar -czf "$BACKUP_DIR/filebrowser-db_${DATE}.tar.gz" -C "$FB" . 2>/dev/null + check "$BACKUP_DIR/filebrowser-db_${DATE}.tar.gz" 1000 filebrowser-db + + # === Home del master (~/.claude memoria+skills, imagefinder, proyectos sueltos, RUNBOOK...) === + # Excluye ~/.ssh (claves privadas, decisión explícita), caches y binarios reproducibles. + log "Backing up home-master..." + tar -czf "$BACKUP_DIR/home-master_${DATE}.tar.gz" -C /host/home-chemavx \ + --exclude='./.ssh' --exclude='./.cache' --exclude='./.venvs' --exclude='./.npm' \ + --exclude='./.nvm' --exclude='./bin' --exclude='./.docker' --exclude='./snap' \ + --exclude='*/node_modules' --exclude='./.local/share/pnpm' . 2>/dev/null + check "$BACKUP_DIR/home-master_${DATE}.tar.gz" 50000000 home-master + + # === Retención (7 por defecto; 3 para los artefactos gordos) === log "Cleaning old backups..." - for prefix in n8n openclaw homarr authentik-pg grafana k3s-db; do - find "$BACKUP_DIR" \( -name "${prefix}_*.tar.gz" -o -name "${prefix}_*.sql.gz" \) | \ - sort | head -n -7 | \ - xargs -r rm -f + retain() { # $1=prefijo $2=copias + find "$BACKUP_DIR" -maxdepth 1 \ + \( -name "${1}_*.tar.gz" -o -name "${1}_*.sql.gz" -o -name "${1}_*.db.gz" \) | \ + sort | head -n -"$2" | xargs -r rm -f + } + for p in n8n openclaw homarr authentik-pg grafana k3s-db gitea \ + ghost-en-db ghost-en-content zona-exclusion-db zona-exclusion-content \ + researchowl roswell-pg umami-pg infisical-pg trilium vaultwarden filebrowser-db; do + retain "$p" 7 done + retain uptime-kuma 3 + retain home-master 3 + if [ "$FAIL" -gt 0 ]; then + log "Backup terminado con $FAIL fallo(s) — el job sale con error para disparar la alerta." + exit 1 + fi log "Backup complete!" + ghost-db-backup.js: | + // Copia consistente de la ghost.db usando el driver sqlite3 del propio Ghost. + // Se ejecuta DENTRO del pod vía: kubectl exec -i ... node - (script por stdin) + const sqlite3 = require('sqlite3'); + const src = '/var/lib/ghost/content/data/ghost.db'; + const dst = '/tmp/ghost-backup.db'; + const db = new sqlite3.Database(src, sqlite3.OPEN_READONLY, (err) => { + if (err) { console.error(err.message); process.exit(1); } + }); + db.run("VACUUM INTO '" + dst + "'", (err) => { + if (err) { console.error(err.message); process.exit(1); } + db.close(() => console.log('ghost db backup ok')); + }); + sqlite-stdin-backup.py: | + # Copia consistente de researchowl.db (API backup de sqlite3, stdlib). + # Se ejecuta DENTRO del pod vía: kubectl exec -i ... python3 - + import sqlite3 + src = sqlite3.connect('file:/data/researchowl.db?mode=ro', uri=True) + dst = sqlite3.connect('/tmp/ro-backup.db') + with dst: + src.backup(dst) + dst.close() + src.close() + print('researchowl db backup ok') rclone-mega.sh: | #!/bin/bash set -e @@ -75,7 +228,10 @@ data: log "Origen: /data/backups/backups" log "Destino: mega:k3s-backups/" + # --exclude reddit-intel: ese subdir remoto lo escribe el CronJob de n97 + # directamente; un sync sin exclude lo borraría (sync = espejo exacto). rclone sync /data/backups/backups mega:k3s-backups/ \ + --exclude "/reddit-intel/**" \ --config /rclone/rclone.conf \ --log-level INFO \ --stats 30s @@ -83,33 +239,48 @@ data: log "Sync completado." log "Archivos en Mega:" rclone ls mega:k3s-backups/ --config /rclone/rclone.conf + reddit-intel.sh: | + #!/bin/sh + # Corre en chemavx-n97 (imagen rclone, BusyBox — sin GNU-ismos). + # Sube /opt/reddit-intel a Mega directamente: es el único dato del host n97 + # y su cron local escribe en el MISMO disco (inútil contra fallo de disco). + set -e + + log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + DATE=$(date +%Y-%m-%d_%H-%M-%S) + F="/tmp/reddit-intel_${DATE}.tar.gz" + + log "Empaquetando /opt/reddit-intel..." + tar -czf "$F" -C /opt/reddit-intel . + gzip -t "$F" + log "Subiendo a mega:k3s-backups/reddit-intel/..." + rclone copy "$F" mega:k3s-backups/reddit-intel/ --config /rclone/rclone.conf --log-level INFO + log "Podando copias remotas de más de 8 días..." + rclone delete mega:k3s-backups/reddit-intel/ --min-age 8d --config /rclone/rclone.conf + rm -f "$F" + log "reddit-intel OK" verify.sh: | #!/bin/sh + # Imagen BusyBox (rclone) — evitar GNU-ismos (date -d 'yesterday' NO funciona). set -e log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } - # polymarket-pg retirado el 2026-07-06 (decomisión del bot) - EXPECTED_SERVICES="n8n openclaw homarr authentik-pg grafana k3s-db" + EXPECTED_SERVICES="n8n openclaw homarr authentik-pg grafana k3s-db gitea ghost-en-db ghost-en-content zona-exclusion-db zona-exclusion-content researchowl roswell-pg umami-pg infisical-pg trilium vaultwarden uptime-kuma filebrowser-db home-master" ERRORS=0 - # Ventana de frescura: el sync a Mega es semanal (domingos 03:00), así que - # un backup es "reciente" si su fecha está en los últimos 8 días (0..7 atrás). - # BusyBox date no soporta -d 'yesterday'; se calcula con epoch (-d @segundos). + # Ventana de frescura: backup y sync son diarios → un backup es "reciente" + # si su fecha está en los últimos 3 días (margen para jobs nocturnos largos). NOW=$(date +%s) ACCEPTED_DATES="" - for i in $(seq 0 7); do + for i in $(seq 0 2); do ACCEPTED_DATES="$ACCEPTED_DATES $(date -d @$(( NOW - i * 86400 )) +%Y-%m-%d)" done log "=== Verificación de backups en Mega ===" - log "Listando archivos en mega:k3s-backups/..." - FILES=$(rclone ls mega:k3s-backups/ --config /rclone/rclone.conf 2>&1) - log "$FILES" - log "---" - log "Fechas aceptadas (ventana de 8 días):$ACCEPTED_DATES" + log "Fechas aceptadas (ventana de 3 días):$ACCEPTED_DATES" for svc in $EXPECTED_SERVICES; do FOUND=0 @@ -120,15 +291,30 @@ data: fi done if [ "$FOUND" -eq 1 ]; then - log "[OK] $svc — backup reciente encontrado" + log "[OK] $svc" else - log "[WARN] $svc — sin backup en los últimos 8 días" + log "[WARN] $svc — sin backup en los últimos 3 días" ERRORS=$((ERRORS + 1)) fi done + # reddit-intel vive en su subdir (lo sube el CronJob de n97) + FOUND=0 + for d in $ACCEPTED_DATES; do + if echo "$FILES" | grep -q "reddit-intel/reddit-intel_${d}"; then + FOUND=1 + break + fi + done + if [ "$FOUND" -eq 1 ]; then + log "[OK] reddit-intel" + else + log "[WARN] reddit-intel — sin backup en los últimos 3 días" + ERRORS=$((ERRORS + 1)) + fi + log "---" - TOTAL=$(echo "$FILES" | grep -c "tar.gz\|sql.gz" || true) + TOTAL=$(echo "$FILES" | grep -c "tar.gz\|sql.gz\|db.gz" || true) log "Total archivos en Mega: $TOTAL" if [ "$ERRORS" -gt 0 ]; then diff --git a/backup-system/cronjob-backup-verify.yaml b/backup-system/cronjob-backup-verify.yaml index ec1d177..a88e15e 100644 --- a/backup-system/cronjob-backup-verify.yaml +++ b/backup-system/cronjob-backup-verify.yaml @@ -4,7 +4,8 @@ metadata: name: backup-verify namespace: backup-system spec: - schedule: "0 4 * * 1" + # Diario desde 2026-07-15 (el sync a Mega ya es diario; ventana de frescura 3 días) + schedule: "0 5 * * *" concurrencyPolicy: Forbid failedJobsHistoryLimit: 3 successfulJobsHistoryLimit: 3 diff --git a/backup-system/cronjob-backup.yaml b/backup-system/cronjob-backup.yaml index 831d24c..4ed4723 100644 --- a/backup-system/cronjob-backup.yaml +++ b/backup-system/cronjob-backup.yaml @@ -10,9 +10,13 @@ spec: failedJobsHistoryLimit: 1 jobTemplate: spec: + # Sin reintentos automáticos: un reintento re-crea TODOS los artefactos con + # otro timestamp y la retención por número podría expulsar copias buenas. + backoffLimit: 1 + activeDeadlineSeconds: 3600 template: spec: - restartPolicy: OnFailure + restartPolicy: Never serviceAccountName: backup-sa securityContext: runAsUser: 0 @@ -29,11 +33,11 @@ spec: value: /kubeconfig/k3s.yaml resources: requests: - cpu: 100m - memory: 128Mi + cpu: 200m + memory: 256Mi limits: - cpu: 500m - memory: 512Mi + cpu: "1" + memory: 1Gi volumeMounts: - name: backup-dir mountPath: /data/backups/backups @@ -50,6 +54,15 @@ spec: - name: k3s-db mountPath: /data/k3s-db readOnly: true + - name: k3s-token + mountPath: /host/k3s-token + readOnly: true + - name: etc-rancher-k3s + mountPath: /host/etc-rancher-k3s + readOnly: true + - name: home-chemavx + mountPath: /host/home-chemavx + readOnly: true volumes: - name: backup-dir hostPath: @@ -79,3 +92,15 @@ spec: hostPath: path: /var/lib/rancher/k3s/server/db type: Directory + - name: k3s-token + hostPath: + path: /var/lib/rancher/k3s/server/token + type: File + - name: etc-rancher-k3s + hostPath: + path: /etc/rancher/k3s + type: Directory + - name: home-chemavx + hostPath: + path: /home/chemavx + type: Directory diff --git a/backup-system/cronjob-rclone-mega.yaml b/backup-system/cronjob-rclone-mega.yaml index aa3597a..e4830d7 100644 --- a/backup-system/cronjob-rclone-mega.yaml +++ b/backup-system/cronjob-rclone-mega.yaml @@ -4,7 +4,9 @@ metadata: name: rclone-mega-backup namespace: backup-system spec: - schedule: "0 3 * * 0" + # Diario desde 2026-07-15 (antes semanal: un fallo de disco entre domingos + # perdía hasta 6 días). Corre tras el backup de las 02:00. + schedule: "30 3 * * *" concurrencyPolicy: Forbid failedJobsHistoryLimit: 2 successfulJobsHistoryLimit: 3 diff --git a/backup-system/cronjob-reddit-intel.yaml b/backup-system/cronjob-reddit-intel.yaml new file mode 100644 index 0000000..cbe8965 --- /dev/null +++ b/backup-system/cronjob-reddit-intel.yaml @@ -0,0 +1,58 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: reddit-intel-backup + namespace: backup-system +spec: + # Único CronJob de backup que corre en n97: /opt/reddit-intel vive fuera de k3s + # (Docker en el host) y su cron local escribe en el mismo disco. + schedule: "15 3 * * *" + concurrencyPolicy: Forbid + failedJobsHistoryLimit: 2 + successfulJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + activeDeadlineSeconds: 1800 + template: + spec: + nodeSelector: + kubernetes.io/hostname: chemavx-n97 + restartPolicy: Never + securityContext: + runAsUser: 0 + containers: + - name: rclone + # OJO: imagen BusyBox — el script debe evitar GNU-ismos + image: docker.io/rclone/rclone:1.74.3 + command: ["/bin/sh", "/scripts/reddit-intel.sh"] + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + volumeMounts: + - mountPath: /opt/reddit-intel + name: reddit-intel + readOnly: true + - mountPath: /rclone + name: rclone-conf + - mountPath: /scripts + name: scripts + volumes: + - name: reddit-intel + hostPath: + path: /opt/reddit-intel + type: Directory + - name: rclone-conf + secret: + secretName: rclone-conf-infisical + items: + - key: RCLONE_CONF + path: rclone.conf + - name: scripts + configMap: + name: backup-scripts + defaultMode: 0755