feat(backup-system): cobertura completa del homelab + sync diario a Mega
- backup.sh: fix openclaw (tar-eaba un hostPath stub vacio, 104 bytes/dia desde hace meses); ahora resuelve el PVC real via local-path. - Nuevos servicios: ghost-en/zona-exclusion (VACUUM INTO consistente via node del pod + tar del content), gitea (sqlite .backup + repos + conf, sin packages 6.4G reconstruibles), researchowl (sqlite backup API via python3), roswell-pg, umami-pg, infisical-pg, trilium, vaultwarden, uptime-kuma (sqlite3 .backup), filebrowser-db, home-master (sin ~/.ssh ni caches), k3s token + /etc/rancher/k3s (cadena de restauracion). - Validacion por artefacto: gzip -t + tamano minimo; el job sale 1 si falta algo -> alerta Grafana->Telegram existente. - rclone-mega: de semanal a diario (03:30); --exclude reddit-intel/. - Nuevo CronJob reddit-intel-backup en n97 (unico dato del worker fuera de k3s; su cron local escribia en el mismo disco). - verify.sh: lista completa (20 servicios + reddit-intel), diario, ventana de frescura 3 dias (BusyBox-safe). - backoffLimit 1 (retry recrearia artefactos y romperia la retencion). - RESTORE.md: runbook con la cadena token->state.db->infisical-secrets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -2,69 +2,222 @@ apiVersion: v1
|
|||||||
data:
|
data:
|
||||||
backup.sh: |
|
backup.sh: |
|
||||||
#!/bin/bash
|
#!/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"
|
BACKUP_DIR="/data/backups/backups"
|
||||||
DATE=$(date +%Y-%m-%d_%H-%M-%S)
|
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"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
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..."
|
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..."
|
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 ===
|
# === homarr ===
|
||||||
log "Backing up homarr..."
|
log "Backing up homarr..."
|
||||||
HOMARR_DB_PV=$(kubectl get pvc -n homarr homarr-db-pvc -o jsonpath='{.spec.volumeName}' 2>/dev/null)
|
HOMARR_DB=$(pvpath homarr homarr-db-pvc)
|
||||||
HOMARR_REDIS_PV=$(kubectl get pvc -n homarr homarr-redis-pvc -o jsonpath='{.spec.volumeName}' 2>/dev/null)
|
HOMARR_REDIS=$(pvpath homarr homarr-redis-pvc)
|
||||||
mkdir -p /tmp/backup-homarr/db /tmp/backup-homarr/redis
|
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 "$HOMARR_DB/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
|
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 && log "homarr OK"
|
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
|
rm -rf /tmp/backup-homarr
|
||||||
|
|
||||||
# === authentik PostgreSQL ===
|
# === authentik PostgreSQL ===
|
||||||
log "Backing up authentik postgres..."
|
log "Backing up authentik postgres..."
|
||||||
kubectl exec -n authentik postgresql-0 -- pg_dump -U authentik authentik 2>/dev/null | \
|
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"
|
gzip > "$BACKUP_DIR/authentik-pg_${DATE}.sql.gz"
|
||||||
|
check "$BACKUP_DIR/authentik-pg_${DATE}.sql.gz" 10000 authentik-pg
|
||||||
# === 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).
|
|
||||||
|
|
||||||
# === grafana ===
|
# === grafana ===
|
||||||
log "Backing up 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
|
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
|
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 && log "grafana OK"
|
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
|
rm -rf /tmp/backup-grafana
|
||||||
|
|
||||||
# === k3s SQLite ===
|
# === k3s: state.db + token del server + /etc/rancher/k3s ===
|
||||||
log "Backing up k3s SQLite..."
|
# 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" \
|
tar -czf "$BACKUP_DIR/k3s-db_${DATE}.tar.gz" \
|
||||||
/data/k3s-db/state.db \
|
/data/k3s-db/state.db* /host/k3s-token /host/etc-rancher-k3s 2>/dev/null
|
||||||
/data/k3s-db/state.db-shm \
|
check "$BACKUP_DIR/k3s-db_${DATE}.tar.gz" 1000000 k3s-db
|
||||||
/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)"
|
|
||||||
|
|
||||||
# === 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..."
|
log "Cleaning old backups..."
|
||||||
for prefix in n8n openclaw homarr authentik-pg grafana k3s-db; do
|
retain() { # $1=prefijo $2=copias
|
||||||
find "$BACKUP_DIR" \( -name "${prefix}_*.tar.gz" -o -name "${prefix}_*.sql.gz" \) | \
|
find "$BACKUP_DIR" -maxdepth 1 \
|
||||||
sort | head -n -7 | \
|
\( -name "${1}_*.tar.gz" -o -name "${1}_*.sql.gz" -o -name "${1}_*.db.gz" \) | \
|
||||||
xargs -r rm -f
|
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
|
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!"
|
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: |
|
rclone-mega.sh: |
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
@@ -75,7 +228,10 @@ data:
|
|||||||
log "Origen: /data/backups/backups"
|
log "Origen: /data/backups/backups"
|
||||||
log "Destino: mega:k3s-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/ \
|
rclone sync /data/backups/backups mega:k3s-backups/ \
|
||||||
|
--exclude "/reddit-intel/**" \
|
||||||
--config /rclone/rclone.conf \
|
--config /rclone/rclone.conf \
|
||||||
--log-level INFO \
|
--log-level INFO \
|
||||||
--stats 30s
|
--stats 30s
|
||||||
@@ -83,33 +239,48 @@ data:
|
|||||||
log "Sync completado."
|
log "Sync completado."
|
||||||
log "Archivos en Mega:"
|
log "Archivos en Mega:"
|
||||||
rclone ls mega:k3s-backups/ --config /rclone/rclone.conf
|
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: |
|
verify.sh: |
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Imagen BusyBox (rclone) — evitar GNU-ismos (date -d 'yesterday' NO funciona).
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
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 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"
|
||||||
EXPECTED_SERVICES="n8n openclaw homarr authentik-pg grafana k3s-db"
|
|
||||||
ERRORS=0
|
ERRORS=0
|
||||||
|
|
||||||
# Ventana de frescura: el sync a Mega es semanal (domingos 03:00), así que
|
# Ventana de frescura: backup y sync son diarios → un backup es "reciente"
|
||||||
# un backup es "reciente" si su fecha está en los últimos 8 días (0..7 atrás).
|
# si su fecha está en los últimos 3 días (margen para jobs nocturnos largos).
|
||||||
# BusyBox date no soporta -d 'yesterday'; se calcula con epoch (-d @segundos).
|
|
||||||
NOW=$(date +%s)
|
NOW=$(date +%s)
|
||||||
ACCEPTED_DATES=""
|
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)"
|
ACCEPTED_DATES="$ACCEPTED_DATES $(date -d @$(( NOW - i * 86400 )) +%Y-%m-%d)"
|
||||||
done
|
done
|
||||||
|
|
||||||
log "=== Verificación de backups en Mega ==="
|
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)
|
FILES=$(rclone ls mega:k3s-backups/ --config /rclone/rclone.conf 2>&1)
|
||||||
log "$FILES"
|
|
||||||
|
|
||||||
log "---"
|
log "Fechas aceptadas (ventana de 3 días):$ACCEPTED_DATES"
|
||||||
log "Fechas aceptadas (ventana de 8 días):$ACCEPTED_DATES"
|
|
||||||
|
|
||||||
for svc in $EXPECTED_SERVICES; do
|
for svc in $EXPECTED_SERVICES; do
|
||||||
FOUND=0
|
FOUND=0
|
||||||
@@ -120,15 +291,30 @@ data:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ "$FOUND" -eq 1 ]; then
|
if [ "$FOUND" -eq 1 ]; then
|
||||||
log "[OK] $svc — backup reciente encontrado"
|
log "[OK] $svc"
|
||||||
else
|
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))
|
ERRORS=$((ERRORS + 1))
|
||||||
fi
|
fi
|
||||||
done
|
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 "---"
|
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"
|
log "Total archivos en Mega: $TOTAL"
|
||||||
|
|
||||||
if [ "$ERRORS" -gt 0 ]; then
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ metadata:
|
|||||||
name: backup-verify
|
name: backup-verify
|
||||||
namespace: backup-system
|
namespace: backup-system
|
||||||
spec:
|
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
|
concurrencyPolicy: Forbid
|
||||||
failedJobsHistoryLimit: 3
|
failedJobsHistoryLimit: 3
|
||||||
successfulJobsHistoryLimit: 3
|
successfulJobsHistoryLimit: 3
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ spec:
|
|||||||
failedJobsHistoryLimit: 1
|
failedJobsHistoryLimit: 1
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
spec:
|
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:
|
template:
|
||||||
spec:
|
spec:
|
||||||
restartPolicy: OnFailure
|
restartPolicy: Never
|
||||||
serviceAccountName: backup-sa
|
serviceAccountName: backup-sa
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsUser: 0
|
runAsUser: 0
|
||||||
@@ -29,11 +33,11 @@ spec:
|
|||||||
value: /kubeconfig/k3s.yaml
|
value: /kubeconfig/k3s.yaml
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 200m
|
||||||
memory: 128Mi
|
memory: 256Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: "1"
|
||||||
memory: 512Mi
|
memory: 1Gi
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: backup-dir
|
- name: backup-dir
|
||||||
mountPath: /data/backups/backups
|
mountPath: /data/backups/backups
|
||||||
@@ -50,6 +54,15 @@ spec:
|
|||||||
- name: k3s-db
|
- name: k3s-db
|
||||||
mountPath: /data/k3s-db
|
mountPath: /data/k3s-db
|
||||||
readOnly: true
|
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:
|
volumes:
|
||||||
- name: backup-dir
|
- name: backup-dir
|
||||||
hostPath:
|
hostPath:
|
||||||
@@ -79,3 +92,15 @@ spec:
|
|||||||
hostPath:
|
hostPath:
|
||||||
path: /var/lib/rancher/k3s/server/db
|
path: /var/lib/rancher/k3s/server/db
|
||||||
type: Directory
|
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
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ metadata:
|
|||||||
name: rclone-mega-backup
|
name: rclone-mega-backup
|
||||||
namespace: backup-system
|
namespace: backup-system
|
||||||
spec:
|
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
|
concurrencyPolicy: Forbid
|
||||||
failedJobsHistoryLimit: 2
|
failedJobsHistoryLimit: 2
|
||||||
successfulJobsHistoryLimit: 3
|
successfulJobsHistoryLimit: 3
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user