36 Commits
Author SHA1 Message Date
Gitea CI d011a4c50f ci: update roswell-corpus image to 09457a47 [skip ci] 2026-07-17 11:53:01 +00:00
Gitea CI 8a5ccbebdb ci: update roswell-corpus image to 93fe8de2 [skip ci] 2026-07-17 10:19:35 +00:00
chemavxandClaude Fable 5 09643943cb feat(portfolio): refresh de la landing — fix /ze/, fuera Open WebUI, nuevos servicios y proyectos
- fix: card ze apunta a /ze/ (el redirect de nginx a http:// caía en 404 de Traefik)
- fuera Open WebUI (chat.chemavx.xyz no existe) y card duplicada de polymarket en Services
- nuevos servicios: Uptime Kuma, Infisical, Trilium, Files, Umami, Ollama
- Projects: blogs The Exclusion Zone (EN) y Zona de Exclusión (ES) + ResearchOwl
- badges de infra bajo el header y subtítulos en las cards; Status en el footer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:44:58 +00:00
Gitea CI 2abc398a10 ci: update roswell-corpus image to 5ac38bc0 [skip ci] 2026-07-17 09:38:49 +00:00
chemavxandClaude Fable 5 376eab68b6 decommission(F4): retirar polymarket-bot — Application y manifests
Fase final de la decomisión: namespace y Application borrados del cluster,
manifests fuera de git. Dump definitivo verificado y offsite en
/data/backups/backups/polymarket-decommission/ (espejo en mega:k3s-backups/).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:29:10 +00:00
Gitea CI 5c43a59884 ci: update roswell-corpus image to 3b55d68e [skip ci] 2026-07-17 08:59:22 +00:00
chemavxandClaude Fable 5 c99d0a978e chore(backup-system): consolidar config rclone en Infisical (mega+b2)
El bloque [b2] ya vive en RCLONE_CONF (Infisical /backup-system); los 3
CronJobs vuelven a rclone-conf-infisical y se elimina el secret
out-of-band rclone-conf-b2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:18:32 +00:00
chemavxandClaude Fable 5 c364d46ac6 feat(backup-system): segundo offsite Backblaze B2 (crown jewels)
- b2-crown-jewels.sh: sync diario 04:00 de las 2 copias mas recientes de
  cada servicio irremplazable (todo menos uptime-kuma) a
  b2:chemavx-k3s-backups/crown-jewels/ (~2GB, tier gratuito).
- reddit-intel.sh sube tambien a B2 (retencion 3d).
- verify.sh comprueba Mega (completo) + B2 (crown jewels).
- Config via Secret out-of-band rclone-conf-b2 (mega+b2, kubectl, NO git);
  consolidar en Infisical RCLONE_CONF cuando se anada [b2] por UI (la
  identity del operator es read-only por API).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 17:24:02 +00:00
chemavxandClaude Fable 5 39ea16fe91 fix(backup-system): excluir binarios reproducibles del tar de home (.warp, .local/share/claude, .local/lib)
1.5G -> ~500M por dia de churn en Mega; .local/bin y .local/state se conservan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:44:10 +00:00
chemavxandClaude Fable 5 e9eb8c4ae2 fix(backup-system): kubectl via ServiceAccount in-cluster, no kubeconfig del host
El k3s.yaml del host apunta a 127.0.0.1:6443 (inalcanzable desde el pod).
El script antiguo solo funcionaba porque su KUBECONFIG apuntaba a un
fichero inexistente y kubectl caia en fallback al SA. Se hace explicito:
unset KUBECONFIG + RBAC con apps/deployments,statefulsets get (para
kubectl exec deploy/...) y se retira el mount del kubeconfig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:39:25 +00:00
chemavxandClaude Fable 5 ed3d886090 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>
2026-07-15 16:12:27 +00:00
Gitea CI 4c0b2d4fa4 ci: update roswell-corpus image to f4c04d72 [skip ci] 2026-07-13 17:39:49 +00:00
Gitea CI a85c82a903 ci: update roswell-corpus image to c48a5f73 [skip ci] 2026-07-13 17:39:32 +00:00
chemavxandClaude Fable 5 005b13bd91 feat(roswell): corte a secretos Infisical — refs *-infisical en deployment, postgres y backup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:37:14 +00:00
chemavxandClaude Fable 5 1b9a7bdddc fix(roswell): un InfisicalStaticSecret por target — el operator ignora targets extra
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:31:04 +00:00
Gitea CI 871af5df11 ci: update roswell-corpus image to 9efbb53b [skip ci] 2026-07-13 17:21:30 +00:00
chemavxandClaude Fable 5 d903fd4631 feat(roswell): postgres a ClusterIP y secretos Infisical preparados (alta manual pendiente en /roswell)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:20:25 +00:00
Gitea CI 43e607629f ci: update roswell-corpus image to 6d492bc9 [skip ci] 2026-07-13 17:12:03 +00:00
chemavxandClaude Fable 5 7e60b5e8d5 fix(roswell): pin a chemavx-k8 y límite 6Gi — Whisper large-v3 OOMKilled con 4Gi en el nodo pequeño
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:05:03 +00:00
Gitea CI 831d686b5a ci: update roswell-corpus image to 79a0a396 [skip ci] 2026-07-13 16:34:37 +00:00
chemavxandClaude Fable 5 6416c6c3af feat(roswell): despliegue GitOps del corpus — bot+scheduler, backup pg_dump diario, postgres adoptado
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:29:19 +00:00
Gitea CI ad3716bf80 ci: update researchowl image to dadc030d [skip ci] 2026-07-10 11:17:43 +00:00
Gitea CI 29b3fb3e5a ci: update researchowl image to 0e9a2e5b [skip ci] 2026-07-10 11:10:43 +00:00
Gitea CI 989064d332 ci: update researchowl image to 4ee9ad06 [skip ci] 2026-07-10 09:42:12 +00:00
chemavxandClaude Fable 5 91bf7d2909 fix(researchowl): memory limit 1Gi → 2Gi
El pod fue OOMKilled el 2026-07-10 durante un research: 20 fuentes
concurrentes con PDFs grandes y pdfplumber superaron 1Gi. El scraper
se endurece en paralelo (cap PDF 15MB, contenido 300k chars), pero el
margen extra evita que un pico legítimo mate la tarea en memoria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:38:53 +00:00
chemavxandClaude Fable 5 cbfb1ba039 feat(n8n): inyectar TELEGRAM_BOT_TOKEN/CHAT_ID desde telegram-notify-infisical
Para que el workflow 'Health Check General' lea el token del bot de notificaciones
via process.env en vez de hardcodearlo (rotación 2026-07-09).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:54:21 +00:00
chemavxandClaude Fable 5 43c78246a8 security: eliminar token de Telegram en claro de ficheros versionados
- openclaw/golden/openclaw.json: botToken → placeholder (referencia Infisical)
- n8n/health-check-code-node.js: literal → process.env.TELEGRAM_BOT_TOKEN

Token del bot @chemavx_bot rotado 2026-07-09 (BotFather). El valor vivo se
gestiona en Infisical homelab/prod/telegram. NOTA: el token viejo sigue en el
HISTORIAL de git; la revocación en BotFather es lo que lo neutraliza.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:46:04 +00:00
chemavxandClaude Fable 5 395102a85e feat(n8n): auto-reload del deployment al cambiar n8n-secret-infisical (operator Infisical)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:16:33 +00:00
chemavxandClaude Fable 5 9dbcb0896e fix(n8n): N8N_BLOCK_ENV_ACCESS_IN_NODE=false — n8n bloquea $env por defecto y el autopost no podía leer GETXAPI_TOKEN
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:38:32 +00:00
chemavxandClaude Fable 5 f29256ca55 feat(n8n): inyectar GETXAPI_TOKEN desde n8n-secret-infisical (rotación token X autopost)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:30:20 +00:00
chemavxandClaude Fable 5 e1a25329d4 feat(monitoring): alerta Telegram para jobs fallidos de backup-system
Regla homelab-backup-job-failed (kube_job_status_failed{namespace="backup-system"} > 0)
en el grupo homelab-infra; cubre backup, rclone-mega-backup y backup-verify.
Aplicado con kubectl replace + rollout restart de Grafana (namespace manual).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:37:28 +00:00
chemavxandClaude Fable 5 d1d2fac287 feat(backup-system): migración completa a GitOps
- CronJob rclone-mega-backup entra en git (solo existía imperativo)
- Scripts backup.sh/verify.sh/rclone-mega.sh como ConfigMap backup-scripts
  (antes hostPath /data/backups/scripts — cambios ahora pasan por git+ArgoCD)
- rclone.conf vía InfisicalStaticSecret (homelab/prod//backup-system → RCLONE_CONF),
  sustituye al hostPath de ~/.config/rclone/rclone.conf
- Imágenes pinneadas: rclone 1.74.3, bitnami/kubectl por digest (v1.36.2)
- ClusterRole/CRB de backup-sa entran en git
- Application ArgoCD backup-system (auto-sync + prune + selfHeal)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:33:35 +00:00
chemavxandClaude Fable 5 ae0b5f9492 feat(reddit-intel): manifiestos preparados, INACTIVOS hasta promoción
App corre en docker compose en n97 (semana de calibración). El app-of-apps
no es recursivo: nada de este directorio se sincroniza hasta aplicar a mano
argocd-app.yaml. Checklist de activación en PROMOTION.md (imagen construida,
secrets en Infisical /reddit-intel, copia de la BD al PVC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:14:06 +00:00
chemavx f059470fde decommission(F3): activar tarjeta Archived del bot en el portfolio
Merge de feat/portfolio-polymarket-archived con las cifras finales de
cierre (12 trades, +$247.78 realized, 239k evaluaciones, 81 días).
El widget deja de consultar la API (apagada) y pasa a tarjeta estática
con enlace al case study.
2026-07-06 12:20:48 +00:00
chemavx 8a89d0bd46 feat(portfolio): cifras finales del widget al cierre real (239k evaluaciones)
Snapshot del apagado 2026-07-06 con el bot ya detenido: 12 trades,
realized +$247.78, 239.462 evaluaciones, 81 días observados.
2026-07-06 12:16:23 +00:00
chemavxandClaude Fable 5 59bc269923 feat(portfolio): tarjeta Polymarket Bot pasa a Archived / case study
Sustituye el widget live (fetch a polymarket.chemavx.xyz/api/summary) por
una tarjeta estática con las cifras finales del paper trading y enlace al
case study en el repo. La tarjeta de servicio apunta al repo de Gitea en
lugar del dominio, que desaparecerá con el apagado.

NO MERGEAR HASTA F2/F3: mientras el bot siga vivo, main mantiene el
widget live. Aplicar esta rama cuando se apague el bot (Fase 3 del plan
de decomisión).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:40:09 +00:00
44 changed files with 1902 additions and 706 deletions
+95
View File
@@ -0,0 +1,95 @@
# 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:chemavx-k3s-backups/crown-jewels/` + `reddit-intel/`):
copia reducida "crown jewels" (últimas 2 de cada servicio irremplazable, todo
menos uptime-kuma). 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.
+23
View File
@@ -0,0 +1,23 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: backup-system
namespace: argocd
annotations:
notifications.argoproj.io/subscribe.on-sync-failed.telegram: "5138407666"
notifications.argoproj.io/subscribe.on-health-degraded.telegram: "5138407666"
spec:
project: default
source:
repoURL: https://git.chemavx.xyz/chemavx/k8s-manifests
targetRevision: HEAD
path: backup-system
destination:
server: https://kubernetes.default.svc
namespace: backup-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+23
View File
@@ -0,0 +1,23 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: backup-role
rules:
- apiGroups: [""]
resources:
- pods
- pods/exec
- persistentvolumeclaims
- persistentvolumes
verbs:
- get
- list
- create
# kubectl exec deploy/<nombre> resuelve el Deployment antes de elegir pod
- apiGroups: ["apps"]
resources:
- deployments
- statefulsets
verbs:
- get
- list
@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: backup-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: backup-role
subjects:
- kind: ServiceAccount
name: backup-sa
namespace: backup-system
+386
View File
@@ -0,0 +1,386 @@
apiVersion: v1
data:
backup.sh: |
#!/bin/bash
# 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
# kubectl usa el ServiceAccount in-cluster (backup-sa + ClusterRole backup-role).
# NO usar el k3s.yaml del host: su server es https://127.0.0.1:6443, inalcanzable
# desde la red del pod (el script antiguo "funcionaba" porque apuntaba KUBECONFIG
# a un fichero inexistente y kubectl caía en fallback al ServiceAccount).
unset KUBECONFIG
BACKUP_DIR="/data/backups/backups"
DATE=$(date +%Y-%m-%d_%H-%M-%S)
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)); }
# 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
check "$BACKUP_DIR/n8n_${DATE}.tar.gz" 10000000 n8n
# === openclaw (PVC local-path; el hostPath /data/openclaw era un stub vacío) ===
log "Backing up openclaw..."
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=$(pvpath homarr homarr-db-pvc)
HOMARR_REDIS=$(pvpath homarr homarr-redis-pvc)
mkdir -p /tmp/backup-homarr/db /tmp/backup-homarr/redis
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"
check "$BACKUP_DIR/authentik-pg_${DATE}.sql.gz" 10000 authentik-pg
# === grafana ===
log "Backing up grafana..."
GRAFANA=$(pvpath monitoring kube-prometheus-stack-grafana)
mkdir -p /tmp/backup-grafana
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: 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* /host/k3s-token /host/etc-rancher-k3s 2>/dev/null
check "$BACKUP_DIR/k3s-db_${DATE}.tar.gz" 1000000 k3s-db
# === 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' \
--exclude='./.warp' --exclude='./.local/share/claude' --exclude='./.local/lib' \
. 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..."
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
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
log "Iniciando sync a Mega..."
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
log "Sync completado."
log "Archivos en Mega:"
rclone ls mega:k3s-backups/ --config /rclone/rclone.conf
b2-crown-jewels.sh: |
#!/bin/sh
# Segunda copia offsite (Backblaze B2): "crown jewels" — las 2 copias más
# recientes de cada servicio irremplazable (~2GB, cabe en los 10GB gratis).
# Todo menos uptime-kuma (622M de historial de heartbeats, prescindible).
# Imagen BusyBox — sin GNU-ismos. Config: RCLONE_CONF de Infisical (mega+b2).
set -e
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
BACKUP_DIR=/data/backups/backups
CROWN="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 home-master"
KEEP=/tmp/crown-jewels
rm -rf "$KEEP"; mkdir -p "$KEEP"
for p in $CROWN; do
for f in $(ls -1 "$BACKUP_DIR/${p}"_* 2>/dev/null | sort | tail -2); do
ln -s "$f" "$KEEP/$(basename "$f")"
done
done
N=$(ls -1 "$KEEP" | wc -l)
log "Sync de $N artefactos crown-jewels a B2..."
# -L: resuelve los symlinks; sync = espejo → la retención remota (2/servicio) es automática
rclone sync "$KEEP" b2:chemavx-k3s-backups/crown-jewels/ -L \
--config /rclone/rclone.conf --log-level INFO --stats 60s
rm -rf "$KEEP"
log "B2 crown-jewels completado ($N artefactos)."
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
log "Subiendo a B2 (crown jewels, retención 3 días)..."
rclone copy "$F" b2:chemavx-k3s-backups/reddit-intel/ --config /rclone/rclone.conf --log-level INFO
rclone delete b2:chemavx-k3s-backups/reddit-intel/ --min-age 3d --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')] $*"; }
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: 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 2); do
ACCEPTED_DATES="$ACCEPTED_DATES $(date -d @$(( NOW - i * 86400 )) +%Y-%m-%d)"
done
log "=== Verificación de backups en Mega ==="
FILES=$(rclone ls mega:k3s-backups/ --config /rclone/rclone.conf 2>&1)
log "Fechas aceptadas (ventana de 3 días):$ACCEPTED_DATES"
for svc in $EXPECTED_SERVICES; do
FOUND=0
for d in $ACCEPTED_DATES; do
if echo "$FILES" | grep -q "${svc}_${d}"; then
FOUND=1
break
fi
done
if [ "$FOUND" -eq 1 ]; then
log "[OK] $svc"
else
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\|db.gz" || true)
log "Total archivos en Mega: $TOTAL"
# === Segundo offsite: B2 crown-jewels (todo menos uptime-kuma) ===
log "=== Verificación de crown-jewels en B2 ==="
CROWN="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 home-master"
B2FILES=$(rclone ls b2:chemavx-k3s-backups/ --config /rclone/rclone.conf 2>&1)
for svc in $CROWN; do
FOUND=0
for d in $ACCEPTED_DATES; do
if echo "$B2FILES" | grep -q "${svc}_${d}"; then
FOUND=1
break
fi
done
if [ "$FOUND" -eq 1 ]; then
log "[OK] b2/$svc"
else
log "[WARN] b2/$svc — sin backup en los últimos 3 días"
ERRORS=$((ERRORS + 1))
fi
done
if [ "$ERRORS" -gt 0 ]; then
log "RESULTADO: $ERRORS servicio(s) sin backup reciente. Revisar."
exit 1
fi
log "RESULTADO: Todos los backups verificados correctamente (Mega + B2)."
kind: ConfigMap
metadata:
name: backup-scripts
namespace: backup-system
+14 -9
View File
@@ -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
@@ -19,7 +20,8 @@ spec:
runAsUser: 0
containers:
- name: verify
image: rclone/rclone:latest
# OJO: imagen BusyBox — verify.sh debe evitar GNU-ismos (date -d 'yesterday')
image: docker.io/rclone/rclone:1.74.3
command: ["/bin/sh", "/scripts/verify.sh"]
resources:
limits:
@@ -29,16 +31,19 @@ spec:
cpu: 50m
memory: 64Mi
volumeMounts:
- mountPath: /rclone/rclone.conf
- mountPath: /rclone
name: rclone-conf
- mountPath: /scripts
name: scripts
volumes:
# RCLONE_CONF (Infisical /backup-system) contiene mega+b2 desde 2026-07-15
- name: rclone-conf
hostPath:
path: /home/chemavx/.config/rclone/rclone.conf
type: File
secret:
secretName: rclone-conf-infisical
items:
- key: RCLONE_CONF
path: rclone.conf
- name: scripts
hostPath:
path: /data/backups/scripts
type: Directory
configMap:
name: backup-scripts
defaultMode: 0755
+36 -18
View File
@@ -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
@@ -20,18 +24,17 @@ spec:
kubernetes.io/hostname: chemavx-k8
containers:
- name: backup
image: bitnami/kubectl:latest
# bitnami/kubectl v1.36.2 — pinneado por digest: los tags de bitnami
# en Docker Hub ya no son fiables (migración Broadcom/bitnamilegacy)
image: docker.io/bitnami/kubectl@sha256:558420daf32bbc382e3e9af4537f4073085b336ddd47399a3b70e70087115978
command: ["/bin/bash", "/scripts/backup.sh"]
env:
- name: KUBECONFIG
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
@@ -43,11 +46,18 @@ spec:
mountPath: /scripts
- name: k3s-storage
mountPath: /var/lib/rancher/k3s/storage
- name: kubeconfig
mountPath: /kubeconfig/k3s.yaml
- 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:
@@ -62,18 +72,26 @@ spec:
path: /data/openclaw
type: DirectoryOrCreate
- name: scripts
hostPath:
path: /data/backups/scripts
type: Directory
configMap:
name: backup-scripts
defaultMode: 0755
- name: k3s-storage
hostPath:
path: /var/lib/rancher/k3s/storage
type: Directory
- name: kubeconfig
hostPath:
path: /etc/rancher/k3s/k3s.yaml
type: File
- name: k3s-db
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
+58
View File
@@ -0,0 +1,58 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: rclone-b2-backup
namespace: backup-system
spec:
# Segunda copia offsite (crown jewels → Backblaze B2), tras el sync a Mega
schedule: "0 4 * * *"
concurrencyPolicy: Forbid
failedJobsHistoryLimit: 2
successfulJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 3600
template:
spec:
nodeSelector:
kubernetes.io/hostname: chemavx-k8
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/b2-crown-jewels.sh"]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- mountPath: /data/backups/backups
name: backup-dir
readOnly: true
- mountPath: /rclone
name: rclone-conf
- mountPath: /scripts
name: scripts
volumes:
- name: backup-dir
hostPath:
path: /data/backups/backups
type: Directory
# RCLONE_CONF (Infisical /backup-system) contiene mega+b2 desde 2026-07-15
- name: rclone-conf
secret:
secretName: rclone-conf-infisical
items:
- key: RCLONE_CONF
path: rclone.conf
- name: scripts
configMap:
name: backup-scripts
defaultMode: 0755
+54
View File
@@ -0,0 +1,54 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: rclone-mega-backup
namespace: backup-system
spec:
# 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
jobTemplate:
spec:
template:
spec:
nodeSelector:
kubernetes.io/hostname: chemavx-k8
restartPolicy: OnFailure
securityContext:
runAsUser: 0
containers:
- name: rclone
image: docker.io/rclone/rclone:1.74.3
command: ["/bin/sh", "/scripts/rclone-mega.sh"]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- mountPath: /data/backups/backups
name: backup-dir
- mountPath: /rclone
name: rclone-conf
- mountPath: /scripts
name: scripts
volumes:
- name: backup-dir
hostPath:
path: /data/backups/backups
type: DirectoryOrCreate
- name: rclone-conf
secret:
secretName: rclone-conf-infisical
items:
- key: RCLONE_CONF
path: rclone.conf
- name: scripts
configMap:
name: backup-scripts
defaultMode: 0755
+59
View File
@@ -0,0 +1,59 @@
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
# RCLONE_CONF (Infisical /backup-system) contiene mega+b2 desde 2026-07-15
- name: rclone-conf
secret:
secretName: rclone-conf-infisical
items:
- key: RCLONE_CONF
path: rclone.conf
- name: scripts
configMap:
name: backup-scripts
defaultMode: 0755
+24
View File
@@ -0,0 +1,24 @@
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: rclone-conf
namespace: backup-system
spec:
# Identidad compartida read-only (sin write grant) — el valor RCLONE_CONF
# se gestiona desde la UI de Infisical (proyecto homelab / prod / /backup-system).
infisicalAuthRef:
name: infisical-auth
namespace: infisical-operator
sources:
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /backup-system
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: rclone-conf-infisical
namespace: backup-system
creationPolicy: Owner
# El Secret lleva la key RCLONE_CONF con el contenido íntegro de rclone.conf;
# los CronJobs la montan como fichero /rclone/rclone.conf (items en el volume).
@@ -83,6 +83,47 @@ data:
conditions:
- evaluator: {params: [0], type: gt}
- uid: homelab-backup-job-failed
title: "Backup job fallido"
condition: C
for: 1m
noDataState: OK
execErrState: Error
annotations:
summary: "🗄️ Backup FALLIDO: {{ $labels.job_name }}\nNamespace: {{ $labels.namespace }}"
description: "El job {{ $labels.job_name }} del sistema de backups ha fallado. Cubre los CronJobs backup, rclone-mega-backup y backup-verify."
labels:
severity: critical
isPaused: false
data:
- refId: A
relativeTimeRange: {from: 300, to: 0}
datasourceUid: prometheus
model:
editorMode: code
expr: "kube_job_status_failed{namespace=\"backup-system\"}"
instant: true
refId: A
- refId: B
relativeTimeRange: {from: 0, to: 0}
datasourceUid: "-100"
model:
type: reduce
refId: B
expression: A
reducer: last
settings:
mode: ""
- refId: C
relativeTimeRange: {from: 0, to: 0}
datasourceUid: "-100"
model:
type: threshold
refId: C
expression: B
conditions:
- evaluator: {params: [0], type: gt}
- uid: homelab-disk-high
title: "Disco > 80%"
condition: C
+28
View File
@@ -3,6 +3,10 @@ kind: Deployment
metadata:
name: n8n
namespace: n8n
annotations:
# El operator de Infisical redespliega este Deployment cuando cambia
# n8n-secret-infisical (p.ej. rotación de GETXAPI_TOKEN) — sin kubectl manual.
secrets.infisical.com/auto-reload: "true"
spec:
replicas: 1
selector:
@@ -34,6 +38,30 @@ spec:
secretKeyRef:
name: n8n-secret-infisical
key: encryption-key
# Token getxapi (X autopost) — rotado 2026-07-09; el workflow lo lee
# con la expresión {{ $env.GETXAPI_TOKEN }}, nunca inline en el nodo.
- name: GETXAPI_TOKEN
valueFrom:
secretKeyRef:
name: n8n-secret-infisical
key: GETXAPI_TOKEN
# Token del bot @chemavx_bot (notificaciones) — rotado 2026-07-09.
# Lo consume el workflow "Health Check General" vía process.env,
# ya no hardcodeado. Fuente: Infisical homelab/prod/telegram.
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: telegram-notify-infisical
key: TELEGRAM_BOT_TOKEN
- name: TELEGRAM_CHAT_ID
valueFrom:
secretKeyRef:
name: telegram-notify-infisical
key: TELEGRAM_CHAT_ID
# n8n ≥1.x bloquea $env en expresiones por defecto; sin esto el nodo
# HTTP del autopost no puede leer GETXAPI_TOKEN. Instancia single-user.
- name: N8N_BLOCK_ENV_ACCESS_IN_NODE
value: "false"
- name: N8N_HOST
value: n8n.chemavx.xyz
- name: N8N_PORT
+1 -1
View File
@@ -8,7 +8,7 @@ return await (async () => {
// ── Configuración ────────────────────────────────────────────────────────
const TELEGRAM_TOKEN = '8611913802:AAFlrFtc0vYISOliO_W8B4c-W1ue0hG9Fio';
const TELEGRAM_TOKEN = process.env.TELEGRAM_BOT_TOKEN /* rotado: leer de env, no hardcodear */;
const TELEGRAM_CHAT = '5138407666';
const RESTART_WEBHOOK = 'https://n8n.chemavx.xyz/webhook/uptime-kuma-restart';
+1 -1
View File
@@ -158,7 +158,7 @@
"telegram": {
"enabled": true,
"dmPolicy": "pairing",
"botToken": "8611913802:AAFlrFtc0vYISOliO_W8B4c-W1ue0hG9Fio",
"botToken": "<set-via-infisical: homelab/prod/telegram TELEGRAM_BOT_TOKEN>",
"groupPolicy": "allowlist",
"streaming": {
"mode": "partial"
-14
View File
@@ -1,14 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: bot-config
namespace: polymarket-bot
# Non-secret config split out of the old bot-secrets Secret. PAPER_* are plain
# operating params; WALLET_PRIVATE_KEY and COINGECKO_API_KEY are kept here as
# empty placeholders (preserving the "present-but-empty" env semantics the bot
# was started with — avoids a KeyError if the code reads them unconditionally).
data:
PAPER_BANKROLL: "10000"
PAPER_MODE: "true"
WALLET_PRIVATE_KEY: ""
COINGECKO_API_KEY: ""
@@ -1,55 +0,0 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: metrics-retention
namespace: polymarket-bot
spec:
# Daily at 00:10 UTC: collapse every past UTC day in metrics_daily to its
# last snapshot (the "daily close"). The bot writes one snapshot per cycle
# (~1,300/day); only the close of past days is read back (DISTINCT ON in
# get_metrics_history / get_daily_pnl_closes), so intraday rows of past
# days are dead weight. Today's rows are never touched: the latest one
# feeds /api/summary and the close is still evolving.
schedule: "10 0 * * *"
suspend: true
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: prune
image: postgres:16-alpine
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: bot-secrets-infisical
key: DATABASE_URL
command:
- /bin/sh
- -c
- |
set -e
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "
DELETE FROM metrics_daily m
USING (
SELECT timestamp::date AS day, MAX(timestamp) AS keep_ts
FROM metrics_daily
WHERE timestamp::date < CURRENT_DATE
GROUP BY 1
) k
WHERE m.timestamp::date = k.day
AND m.timestamp < k.keep_ts;
"
psql "$DATABASE_URL" -c "VACUUM ANALYZE metrics_daily;"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
-47
View File
@@ -1,47 +0,0 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: outcomes-joiner
namespace: polymarket-bot
spec:
# Daily at 00:30 UTC (after metrics-retention at 00:10, no overlap): fetch
# UMA-final resolutions for archived markets from the Gamma API into
# market_outcomes, then print the calibration report (Brier/log-loss of the
# model vs the market price). Replay R2 joiner — analysis only: reads the
# signals archive, writes only market_outcomes, never touches trading
# tables. Safe to also run by hand any time (kubectl create job --from=...):
# the upsert is idempotent per market_id (ON CONFLICT DO UPDATE) and the
# fetch only queries markets that still lack an outcome.
schedule: "30 0 * * *"
suspend: true
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
# A hung Gamma call must not block tomorrow's run through Forbid
activeDeadlineSeconds: 900
template:
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: gitea-registry-infisical
containers:
- name: outcomes
# Same image as the bot deployment; CI bumps this tag on every
# bot image build (see polymarket-bot repo .gitea/workflows/ci.yml)
image: git.chemavx.xyz/chemavx/polymarket-bot:2b326ad5
imagePullPolicy: Always
command: ["python3", "-m", "bot.outcomes"]
envFrom:
- secretRef:
name: bot-secrets-infisical
- configMapRef:
name: bot-config
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
memory: 256Mi
-57
View File
@@ -1,57 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: polymarket-bot
spec:
progressDeadlineSeconds: 600
replicas: 0
revisionHistoryLimit: 10
selector:
matchLabels:
app: api
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
labels:
app: api
spec:
imagePullSecrets:
- name: gitea-registry-infisical
containers:
- command:
- uvicorn
- api.main:app
- --host
- 0.0.0.0
- --port
- "8000"
envFrom:
- secretRef:
name: bot-secrets-infisical
- configMapRef:
name: bot-config
image: git.chemavx.xyz/chemavx/polymarket-bot-api:7804d25c
imagePullPolicy: Always
name: api
ports:
- containerPort: 8000
protocol: TCP
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
-51
View File
@@ -1,51 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bot
namespace: polymarket-bot
spec:
progressDeadlineSeconds: 600
replicas: 0
revisionHistoryLimit: 10
selector:
matchLabels:
app: bot
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
labels:
app: bot
spec:
imagePullSecrets:
- name: gitea-registry-infisical
containers:
- command:
- python3
- -m
- bot.main
envFrom:
- secretRef:
name: bot-secrets-infisical
- configMapRef:
name: bot-config
image: git.chemavx.xyz/chemavx/polymarket-bot:7804d25c
imagePullPolicy: Always
name: bot
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
-45
View File
@@ -1,45 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: dashboard
namespace: polymarket-bot
spec:
progressDeadlineSeconds: 600
replicas: 0
revisionHistoryLimit: 10
selector:
matchLabels:
app: dashboard
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
labels:
app: dashboard
spec:
imagePullSecrets:
- name: gitea-registry-infisical
containers:
- image: git.chemavx.xyz/chemavx/polymarket-bot-dashboard:7804d25c
imagePullPolicy: Always
name: dashboard
ports:
- containerPort: 80
protocol: TCP
resources:
limits:
cpu: 200m
memory: 128Mi
requests:
cpu: 50m
memory: 64Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
-30
View File
@@ -1,30 +0,0 @@
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: bot-secrets
namespace: polymarket-bot
spec:
# Shared, read-only machine identity (no write grant, no PushSecret).
infisicalAuthRef:
name: infisical-auth
namespace: infisical-operator
sources:
# App-specific secrets (4): DATABASE_URL, POLYMARKET_API_KEY/SECRET/PASSPHRASE
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /polymarket-bot
# Telegram (2): TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID — reused from the shared
# /telegram path (deduplicated; same token as the other 5 namespaces).
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0
environmentSlug: prod
secretPath: /telegram
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: bot-secrets-infisical # parallel name — old bot-secrets stays live until 3c
namespace: polymarket-bot
creationPolicy: Owner
# key passthrough: synced Secret carries all 6 keys verbatim.
# PAPER_* and the empty WALLET_PRIVATE_KEY/COINGECKO_API_KEY are config,
# not secrets — they live in the bot-config ConfigMap, not here.
@@ -1,24 +0,0 @@
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: telegram-notify
namespace: polymarket-bot
spec:
# Shared, read-only machine identity (no write grant, no PushSecret).
# NOTE: this migrates only the STANDALONE telegram-notify secret. The same
# Telegram token also embedded in bot-secrets is migration #3 — not touched here.
infisicalAuthRef:
name: infisical-auth
namespace: infisical-operator
sources:
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /telegram # shared across all 5 namespaces
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: telegram-notify-infisical # parallel name — old telegram-notify stays live until 3c
namespace: polymarket-bot
creationPolicy: Owner
# key passthrough: synced Secret carries TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID verbatim
@@ -1,33 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: polymarket-bot
namespace: polymarket-bot
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
rules:
- host: polymarket.chemavx.xyz
http:
paths:
- backend:
service:
name: api
port:
number: 8000
path: /api
pathType: Prefix
- backend:
service:
name: dashboard
port:
number: 80
path: /
pathType: Prefix
tls:
- hosts:
- polymarket.chemavx.xyz
secretName: polymarket-tls
-25
View File
@@ -1,25 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
# Never let ArgoCD prune this PVC: it holds the paper-trading postgres data.
argocd.argoproj.io/sync-options: Prune=false
pv.kubernetes.io/bind-completed: 'yes'
pv.kubernetes.io/bound-by-controller: 'yes'
volume.beta.kubernetes.io/storage-provisioner: rancher.io/local-path
volume.kubernetes.io/selected-node: chemavx-k8
volume.kubernetes.io/storage-provisioner: rancher.io/local-path
labels:
app: postgres
name: data-postgres-0
namespace: polymarket-bot
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: local-path
volumeMode: Filesystem
volumeName: pvc-327bbc6d-dcc6-46b4-9d78-db950f71eb98
-22
View File
@@ -1,22 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: api
namespace: polymarket-bot
spec:
clusterIP: 10.43.201.4
clusterIPs:
- 10.43.201.4
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- port: 8000
protocol: TCP
targetPort: 8000
selector:
app: api
sessionAffinity: None
type: ClusterIP
-22
View File
@@ -1,22 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: dashboard
namespace: polymarket-bot
spec:
clusterIP: 10.43.62.6
clusterIPs:
- 10.43.62.6
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- port: 80
protocol: TCP
targetPort: 80
selector:
app: dashboard
sessionAffinity: None
type: ClusterIP
-22
View File
@@ -1,22 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: polymarket-bot
spec:
clusterIP: 10.43.238.53
clusterIPs:
- 10.43.238.53
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- port: 5432
protocol: TCP
targetPort: 5432
selector:
app: postgres
sessionAffinity: None
type: ClusterIP
-76
View File
@@ -1,76 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: polymarket-bot
spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
podManagementPolicy: OrderedReady
replicas: 0
revisionHistoryLimit: 10
selector:
matchLabels:
app: postgres
serviceName: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- env:
- name: POSTGRES_USER
value: bot
- name: POSTGRES_PASSWORD
value: bot
- name: POSTGRES_DB
value: polymarket
image: postgres:16-alpine
imagePullPolicy: IfNotPresent
name: postgres
ports:
- containerPort: 5432
protocol: TCP
readinessProbe:
exec:
command:
- pg_isready
- -U
- bot
failureThreshold: 3
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /var/lib/postgresql/data
name: data
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
updateStrategy:
rollingUpdate:
partition: 0
type: RollingUpdate
volumeClaimTemplates:
- apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
volumeMode: Filesystem
status:
phase: Pending
+320 -142
View File
@@ -1,147 +1,325 @@
apiVersion: v1
data:
index.html: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\"
/>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"
/>\n <title>ChemaVX</title>\n <style>\n *, *::before, *::after { box-sizing:
border-box; margin: 0; padding: 0; }\n\n :root {\n --bg: #0d1117;\n
\ --surface: #161b22;\n --border: #30363d;\n --text: #e6edf3;\n
\ --muted: #8b949e;\n --accent: #58a6ff;\n --green: #3fb950;\n
\ --red: #f85149;\n --yellow: #d29922;\n --purple: #bc8cff;\n
\ }\n\n body {\n background: var(--bg);\n color: var(--text);\n
\ font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica,
Arial, sans-serif;\n min-height: 100vh;\n display: flex;\n flex-direction:
column;\n align-items: center;\n padding: 3rem 1.5rem 4rem;\n }\n\n
\ /* ── Header ─────────────────────────────────── */\n header {\n text-align:
center;\n margin-bottom: 3.5rem;\n }\n .avatar {\n width: 80px;
height: 80px;\n border-radius: 50%;\n background: linear-gradient(135deg,
#1f6feb 0%, #bc8cff 100%);\n display: flex; align-items: center; justify-content:
center;\n font-size: 2rem; font-weight: 700; color: #fff;\n margin:
0 auto 1.25rem;\n box-shadow: 0 0 0 3px var(--border);\n }\n h1 { font-size:
2rem; font-weight: 700; letter-spacing: -0.5px; }\n .tagline {\n color:
var(--muted); font-size: 0.95rem; margin-top: 0.4rem;\n }\n .dot { display:
inline-block; width: 8px; height: 8px;\n border-radius: 50%; background:
var(--green);\n margin-right: 6px; animation: pulse 2s infinite; }\n
\ @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity:
0.4; }\n }\n\n /* ── Section title ───────────────────────────── */\n .section-title
{\n font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em;\n text-transform:
uppercase; color: var(--muted);\n margin-bottom: 0.875rem;\n }\n\n /*
── Services grid ───────────────────────────── */\n .services-grid {\n display:
grid;\n grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));\n gap:
0.75rem;\n width: 100%; max-width: 720px;\n margin-bottom: 2.5rem;\n
\ }\n .service-card {\n background: var(--surface);\n border: 1px
solid var(--border);\n border-radius: 10px;\n padding: 1rem 1.1rem;\n
\ text-decoration: none;\n color: var(--text);\n transition: border-color
0.15s, transform 0.15s, box-shadow 0.15s;\n display: flex; align-items: center;
gap: 0.65rem;\n }\n .service-card:hover {\n border-color: var(--accent);\n
\ transform: translateY(-2px);\n box-shadow: 0 4px 16px rgba(88,166,255,0.1);\n
\ }\n .service-icon { font-size: 1.3rem; flex-shrink: 0; }\n .service-name
{ font-size: 0.9rem; font-weight: 500; }\n\n /* ── Polymarket card ───────────────────────────
*/\n .poly-wrapper { width: 100%; max-width: 720px; }\n .poly-card {\n background:
var(--surface);\n border: 1px solid var(--border);\n border-radius:
12px;\n padding: 1.5rem;\n margin-bottom: 2.5rem;\n }\n .poly-header
{\n display: flex; align-items: center; justify-content: space-between;\n
\ margin-bottom: 1.25rem;\n }\n .poly-title {\n display: flex;
align-items: center; gap: 0.5rem;\n font-size: 0.95rem; font-weight: 600;\n
\ }\n .poly-badge {\n font-size: 0.65rem; font-weight: 600; letter-spacing:
0.04em;\n padding: 2px 7px; border-radius: 20px;\n background: rgba(188,140,255,0.15);
color: var(--purple);\n border: 1px solid rgba(188,140,255,0.3);\n }\n
\ #poly-updated {\n font-size: 0.72rem; color: var(--muted);\n }\n\n
\ .metrics-grid {\n display: grid;\n grid-template-columns: repeat(2,
1fr);\n gap: 0.75rem;\n }\n @media (min-width: 480px) {\n .metrics-grid
{ grid-template-columns: repeat(4, 1fr); }\n }\n .metric {\n background:
var(--bg);\n border: 1px solid var(--border);\n border-radius: 8px;\n
\ padding: 0.875rem 1rem;\n }\n .metric-label {\n font-size: 0.68rem;
color: var(--muted);\n text-transform: uppercase; letter-spacing: 0.06em;\n
\ margin-bottom: 0.35rem;\n }\n .metric-value {\n font-size: 1.35rem;
font-weight: 700; font-variant-numeric: tabular-nums;\n transition: color
0.3s;\n }\n .metric-value.positive { color: var(--green); }\n .metric-value.negative
{ color: var(--red); }\n .metric-value.neutral { color: var(--text); }\n .metric-value.loading
\ { color: var(--muted); font-size: 1rem; }\n\n /* ── Footer ────────────────────────────────────
*/\n footer {\n color: var(--muted); font-size: 0.75rem; text-align: center;\n
\ width: 100%; max-width: 720px;\n border-top: 1px solid var(--border);\n
\ padding-top: 1.25rem;\n }\n footer a { color: var(--accent); text-decoration:
none; }\n footer a:hover { text-decoration: underline; }\n </style>\n</head>\n<body>\n\n
\ <header>\n <div class=\"avatar\">C</div>\n <h1>ChemaVX</h1>\n <p class=\"tagline\">\n
\ <span class=\"dot\"></span>Homelab · k3s · Self-hosted\n </p>\n </header>\n\n
\ <!-- Services -->\n <div style=\"width:100%;max-width:720px;margin-bottom:2.5rem;\">\n
\ <p class=\"section-title\">Services</p>\n <div class=\"services-grid\">\n
\ <a class=\"service-card\" href=\"https://grafana.chemavx.xyz\" target=\"_blank\"
rel=\"noopener\">\n <span class=\"service-icon\">\U0001F4CA</span>\n <span
class=\"service-name\">Grafana</span>\n </a>\n <a class=\"service-card\"
href=\"https://n8n.chemavx.xyz\" target=\"_blank\" rel=\"noopener\">\n <span
class=\"service-icon\">⚙️</span>\n <span class=\"service-name\">n8n</span>\n
\ </a>\n <a class=\"service-card\" href=\"https://home.chemavx.xyz\"
target=\"_blank\" rel=\"noopener\">\n <span class=\"service-icon\">\U0001F3E0</span>\n
\ <span class=\"service-name\">Homarr</span>\n </a>\n <a class=\"service-card\"
href=\"https://polymarket.chemavx.xyz\" target=\"_blank\" rel=\"noopener\">\n
\ <span class=\"service-icon\">\U0001F4C8</span>\n <span class=\"service-name\">Polymarket
Bot</span>\n </a>\n <a class=\"service-card\" href=\"https://chat.chemavx.xyz\"
target=\"_blank\" rel=\"noopener\">\n <span class=\"service-icon\">\U0001F916</span>\n
\ <span class=\"service-name\">Open WebUI</span>\n </a>\n <a class=\"service-card\"
href=\"https://git.chemavx.xyz\" target=\"_blank\" rel=\"noopener\">\n <span
class=\"service-icon\">\U0001F419</span>\n <span class=\"service-name\">Gitea</span>\n
\ </a>\n <a class=\"service-card\" href=\"https://argocd.chemavx.xyz\"
target=\"_blank\" rel=\"noopener\">\n <span class=\"service-icon\">\U0001F504</span>\n
\ <span class=\"service-name\">ArgoCD</span>\n </a>\n <a class=\"service-card\"
href=\"https://vaultwarden.chemavx.xyz\" target=\"_blank\" rel=\"noopener\">\n
\ <span class=\"service-icon\">\U0001F510</span>\n <span class=\"service-name\">Vaultwarden</span>\n
\ </a>\n </div>\n </div>\n\n <!-- Projects -->\n <div style=\"width:100%;max-width:720px;margin-bottom:2.5rem;\">\n
\ <p class=\"section-title\">Projects</p>\n <div class=\"services-grid\">\n
\ <a class=\"service-card\" href=\"/ze\">\n <span class=\"service-icon\">\U0001F6F8</span>\n
\ <span class=\"service-name\">ze</span>\n </a>\n </div>\n </div>\n\n
\ <!-- Polymarket live metrics -->\n <div class=\"poly-wrapper\">\n <p class=\"section-title\">Polymarket
Bot — Live</p>\n <div class=\"poly-card\">\n <div class=\"poly-header\">\n
\ <div class=\"poly-title\">\n <span>\U0001F4C8</span>\n <span>Portfolio
Summary</span>\n <span class=\"poly-badge\" id=\"poly-mode\">PAPER</span>\n
\ </div>\n <span id=\"poly-updated\">Loading…</span>\n </div>\n
\ <div class=\"metrics-grid\">\n <div class=\"metric\">\n <div
class=\"metric-label\">P&amp;L</div>\n <div class=\"metric-value loading\"
id=\"m-pnl\">—</div>\n </div>\n <div class=\"metric\">\n <div
class=\"metric-label\">Win Rate</div>\n <div class=\"metric-value loading\"
id=\"m-winrate\">—</div>\n </div>\n <div class=\"metric\">\n <div
class=\"metric-label\">Deployed</div>\n <div class=\"metric-value loading\"
id=\"m-deployed\">—</div>\n </div>\n <div class=\"metric\">\n <div
class=\"metric-label\">Trades</div>\n <div class=\"metric-value loading\"
id=\"m-trades\">—</div>\n </div>\n <div class=\"metric\">\n <div
class=\"metric-label\">Bankroll</div>\n <div class=\"metric-value loading\"
id=\"m-bankroll\">—</div>\n </div>\n <div class=\"metric\">\n <div
class=\"metric-label\">Sharpe</div>\n <div class=\"metric-value loading\"
id=\"m-sharpe\">—</div>\n </div>\n <div class=\"metric\">\n <div
class=\"metric-label\">Calibration</div>\n <div class=\"metric-value
loading\" id=\"m-calibration\">—</div>\n </div>\n <div class=\"metric\">\n
\ <div class=\"metric-label\">Status</div>\n <div class=\"metric-value
loading\" id=\"m-status\">—</div>\n </div>\n </div>\n </div>\n
\ </div>\n\n <footer>\n <p>\n Hosted on <strong>k3s</strong> &mdash;\n
\ <a href=\"https://grafana.chemavx.xyz/d/chemavx-homelab-v1/chemavx-homelab-overview\"
target=\"_blank\">Cluster Dashboard</a>\n &mdash; <a href=\"https://git.chemavx.xyz\"
target=\"_blank\">Gitea</a>\n </p>\n </footer>\n\n <script>\n const API
= 'https://polymarket.chemavx.xyz/api/summary';\n const fmt$ = v => v == null
? '—' : '$' + v.toFixed(2);\n const fmtPct = v => v == null ? '—' : (v * 100).toFixed(1)
+ '%';\n const fmtN = v => v == null ? '—' : String(v);\n const fmtF =
(v, d=3) => v == null ? '—' : v.toFixed(d);\n\n function colorClass(id, val)
{\n const el = document.getElementById(id);\n el.classList.remove('positive','negative','neutral','loading');\n
\ if (val > 0) el.classList.add('positive');\n else if (val < 0) el.classList.add('negative');\n
\ else el.classList.add('neutral');\n }\n\n async function fetchMetrics()
{\n try {\n const r = await fetch(API, { cache: 'no-store' });\n if
(!r.ok) throw new Error(r.status);\n const d = await r.json();\n\n document.getElementById('m-pnl').textContent
\ = fmt$(d.total_pnl);\n document.getElementById('m-winrate').textContent
\ = fmtPct(d.win_rate);\n document.getElementById('m-deployed').textContent
\ = fmt$(d.total_deployed);\n document.getElementById('m-trades').textContent
\ = fmtN(d.total_trades);\n document.getElementById('m-bankroll').textContent
\ = fmt$(d.paper_bankroll);\n document.getElementById('m-sharpe').textContent
\ = fmtF(d.sharpe_ratio, 2);\n document.getElementById('m-calibration').textContent
= fmtF(d.calibration_score, 3);\n document.getElementById('m-status').textContent
\ = d.promotion_ready ? '\U0001F680 Ready' : '⏳ Training';\n\n colorClass('m-pnl',
d.total_pnl);\n colorClass('m-sharpe', d.sharpe_ratio);\n\n const
mode = d.paper_mode ? 'PAPER' : 'LIVE';\n const badge = document.getElementById('poly-mode');\n
\ badge.textContent = mode;\n badge.style.background = d.paper_mode\n
\ ? 'rgba(188,140,255,0.15)' : 'rgba(63,185,80,0.15)';\n badge.style.color
= d.paper_mode ? 'var(--purple)' : 'var(--green)';\n badge.style.borderColor
= d.paper_mode\n ? 'rgba(188,140,255,0.3)' : 'rgba(63,185,80,0.3)';\n\n
\ const now = new Date();\n document.getElementById('poly-updated').textContent
=\n 'Updated ' + now.toLocaleTimeString();\n } catch (e) {\n document.getElementById('poly-updated').textContent
= 'Error: ' + e.message;\n }\n }\n\n fetchMetrics();\n setInterval(fetchMetrics,
30000);\n </script>\n</body>\n</html>\n"
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ChemaVX — homelab k3s self-hosted: GitOps con ArgoCD, secrets con Infisical, backups offsite duales y publicación bilingüe." />
<title>ChemaVX</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--purple: #bc8cff;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1.5rem 4rem;
}
/* ── Header ─────────────────────────────────── */
header {
text-align: center;
margin-bottom: 3rem;
}
.avatar {
width: 80px; height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #1f6feb 0%, #bc8cff 100%);
display: flex; align-items: center; justify-content: center;
font-size: 2rem; font-weight: 700; color: #fff;
margin: 0 auto 1.25rem;
box-shadow: 0 0 0 3px var(--border);
}
h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.5px; }
.tagline {
color: var(--muted); font-size: 0.95rem; margin-top: 0.4rem;
}
.dot { display: inline-block; width: 8px; height: 8px;
border-radius: 50%; background: var(--green);
margin-right: 6px; animation: pulse 2s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.badges {
display: flex; flex-wrap: wrap; gap: 0.5rem;
justify-content: center; margin-top: 1.1rem;
max-width: 560px;
}
.badge {
font-size: 0.7rem; color: var(--muted);
border: 1px solid var(--border); border-radius: 20px;
padding: 3px 11px; background: var(--surface);
white-space: nowrap;
}
.badge b { color: var(--text); font-weight: 600; }
/* ── Section title ───────────────────────────── */
.section-title {
font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--muted);
margin-bottom: 0.875rem;
}
/* ── Services grid ───────────────────────────── */
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
width: 100%; max-width: 720px;
margin-bottom: 2.5rem;
}
.service-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem 1.1rem;
text-decoration: none;
color: var(--text);
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
display: flex; align-items: center; gap: 0.65rem;
}
.service-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(88,166,255,0.1);
}
.service-icon { font-size: 1.3rem; flex-shrink: 0; }
.service-name { font-size: 0.9rem; font-weight: 500; line-height: 1.25; }
.service-sub { font-size: 0.68rem; color: var(--muted); margin-top: 1px; }
/* ── Polymarket card ─────────────────────────── */
.poly-wrapper { width: 100%; max-width: 720px; }
.poly-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2.5rem;
}
.poly-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.25rem;
}
.poly-title {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.95rem; font-weight: 600;
}
.poly-badge {
font-size: 0.65rem; font-weight: 600; letter-spacing: 0.04em;
padding: 2px 7px; border-radius: 20px;
background: rgba(188,140,255,0.15); color: var(--purple);
border: 1px solid rgba(188,140,255,0.3);
}
#poly-updated {
font-size: 0.72rem; color: var(--muted);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
@media (min-width: 480px) {
.metrics-grid { grid-template-columns: repeat(4, 1fr); }
}
.metric {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.875rem 1rem;
}
.metric-label {
font-size: 0.68rem; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 0.35rem;
}
.metric-value {
font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums;
transition: color 0.3s;
}
.metric-value.positive { color: var(--green); }
.metric-value.negative { color: var(--red); }
.metric-value.neutral { color: var(--text); }
.metric-value.loading { color: var(--muted); font-size: 1rem; }
/* ── Footer ──────────────────────────────────── */
footer {
color: var(--muted); font-size: 0.75rem; text-align: center;
width: 100%; max-width: 720px;
border-top: 1px solid var(--border);
padding-top: 1.25rem;
}
footer a { color: var(--accent); text-decoration: none; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header>
<div class="avatar">C</div>
<h1>ChemaVX</h1>
<p class="tagline">
<span class="dot"></span>Homelab · k3s · Self-hosted
</p>
<div class="badges">
<span class="badge"><b>k3s</b> · 2 nodos</span>
<span class="badge"><b>GitOps</b> · ArgoCD</span>
<span class="badge"><b>Secrets</b> · Infisical</span>
<span class="badge"><b>Backups</b> · Mega + B2</span>
<span class="badge"><b>Observabilidad</b> · Grafana + Kuma</span>
</div>
</header>
<!-- Services -->
<div style="width:100%;max-width:720px;margin-bottom:2.5rem;">
<p class="section-title">Services</p>
<div class="services-grid">
<a class="service-card" href="https://grafana.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">📊</span>
<span><span class="service-name">Grafana</span><div class="service-sub">Dashboards y alertas</div></span>
</a>
<a class="service-card" href="https://status.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">💓</span>
<span><span class="service-name">Uptime Kuma</span><div class="service-sub">Estado de servicios</div></span>
</a>
<a class="service-card" href="https://n8n.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">⚙️</span>
<span><span class="service-name">n8n</span><div class="service-sub">Automatización</div></span>
</a>
<a class="service-card" href="https://home.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">🏠</span>
<span><span class="service-name">Homarr</span><div class="service-sub">Panel del homelab</div></span>
</a>
<a class="service-card" href="https://git.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">🐙</span>
<span><span class="service-name">Gitea</span><div class="service-sub">Git + CI + registry</div></span>
</a>
<a class="service-card" href="https://argocd.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">🔄</span>
<span><span class="service-name">ArgoCD</span><div class="service-sub">GitOps del cluster</div></span>
</a>
<a class="service-card" href="https://infisical.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">🗝️</span>
<span><span class="service-name">Infisical</span><div class="service-sub">Gestor de secretos</div></span>
</a>
<a class="service-card" href="https://vaultwarden.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">🔐</span>
<span><span class="service-name">Vaultwarden</span><div class="service-sub">Contraseñas</div></span>
</a>
<a class="service-card" href="https://trilium.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">📝</span>
<span><span class="service-name">Trilium</span><div class="service-sub">Notas</div></span>
</a>
<a class="service-card" href="https://files.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">📁</span>
<span><span class="service-name">Files</span><div class="service-sub">Filebrowser</div></span>
</a>
<a class="service-card" href="https://umami.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">📈</span>
<span><span class="service-name">Umami</span><div class="service-sub">Analytics sin cookies</div></span>
</a>
<a class="service-card" href="https://ollama.chemavx.xyz" target="_blank" rel="noopener">
<span class="service-icon">🦙</span>
<span><span class="service-name">Ollama</span><div class="service-sub">LLM local</div></span>
</a>
</div>
</div>
<!-- Projects -->
<div style="width:100%;max-width:720px;margin-bottom:2.5rem;">
<p class="section-title">Projects</p>
<div class="services-grid">
<a class="service-card" href="https://theexclusionzone.com" target="_blank" rel="noopener">
<span class="service-icon">🛸</span>
<span><span class="service-name">The Exclusion Zone</span><div class="service-sub">UAP research · EN</div></span>
</a>
<a class="service-card" href="https://zonadeexclusion.com" target="_blank" rel="noopener">
<span class="service-icon">📡</span>
<span><span class="service-name">Zona de Exclusión</span><div class="service-sub">Investigación UAP · ES</div></span>
</a>
<a class="service-card" href="https://git.chemavx.xyz/chemavx/researchowl" target="_blank" rel="noopener">
<span class="service-icon">🦉</span>
<span><span class="service-name">ResearchOwl</span><div class="service-sub">Bot de investigación</div></span>
</a>
<a class="service-card" href="/ze/">
<span class="service-icon">⌨️</span>
<span><span class="service-name">ze</span><div class="service-sub">CLI · manual</div></span>
</a>
</div>
</div>
<!-- Polymarket bot: archived case study (final figures, 2026-07-06) -->
<div class="poly-wrapper">
<p class="section-title">Polymarket Bot — Archived · Case study</p>
<div class="poly-card">
<div class="poly-header">
<div class="poly-title">
<span>📉</span>
<span>Paper Trading — Final Results</span>
<span class="poly-badge">ARCHIVED</span>
</div>
<span id="poly-updated">Final snapshot · 2026-07-06</span>
</div>
<div class="metrics-grid">
<div class="metric">
<div class="metric-label">Realized P&amp;L</div>
<div class="metric-value positive">+$247.78</div>
</div>
<div class="metric">
<div class="metric-label">Paper Trades</div>
<div class="metric-value neutral">12</div>
</div>
<div class="metric">
<div class="metric-label">Evaluations</div>
<div class="metric-value neutral">239k</div>
</div>
<div class="metric">
<div class="metric-label">Days Observed</div>
<div class="metric-value neutral">81</div>
</div>
</div>
<p style="font-size:0.8rem;color:var(--muted);margin-top:1rem;line-height:1.5;">
Bot bayesiano de paper trading sobre mercados de predicción, retirado en
julio de 2026 tras el bloqueo regulatorio de Polymarket en España.
Motor de replay determinista, ~239k evaluaciones archivadas y
observabilidad completa en k3s.
<a href="https://git.chemavx.xyz/chemavx/polymarket-bot" target="_blank" rel="noopener"
style="color:var(--accent);text-decoration:none;">Case study completo →</a>
</p>
</div>
</div>
<footer>
<p>
Hosted on <strong>k3s</strong> &mdash;
<a href="https://grafana.chemavx.xyz/d/chemavx-homelab-v1/chemavx-homelab-overview" target="_blank">Cluster Dashboard</a>
&mdash; <a href="https://status.chemavx.xyz" target="_blank">Status</a>
&mdash; <a href="https://git.chemavx.xyz" target="_blank">Gitea</a>
</p>
</footer>
</body>
</html>
kind: ConfigMap
metadata:
name: portfolio-html
+61
View File
@@ -0,0 +1,61 @@
# reddit-intel — promoción de docker compose (n97) a k3s
Estado: **INACTIVO**. Los manifiestos de este directorio existen solo en git;
el app-of-apps `k8s-manifests` no es recursivo, así que ArgoCD no los
sincroniza. La app corre en docker compose en chemavx-n97
(`/opt/reddit-intel`, semana de calibración desde 2026-07-06).
## Checklist de activación (en este orden)
1. **Imagen en el registro.** Primer build manual, o `workflow_dispatch` del
workflow "Build reddit-intel image" en el repo
`git.chemavx.xyz/chemavx/reddit-intel` (requiere secrets `CI_TOKEN` y
`TELEGRAM_BOT_TOKEN` configurados en ese repo). El workflow actualiza el
tag de `deployment.yaml` aquí; si el build es manual, sustituir el
placeholder `:00000000` a mano por el sha8 real.
2. **Secrets en Infisical** (proyecto "homelab", env `prod`, path
`/reddit-intel`) — mismos valores que el `.env` del compose:
`reddit-client-id`, `reddit-client-secret`, `llm-api-key`,
`dashboard-auth-token`, `telegram-bot-token`, `telegram-chat-id`
(los dos de telegram pueden ir vacíos).
3. **Congelar el compose y copiar la BD al PVC:**
```bash
# en n97
cd /opt/reddit-intel && docker compose down # para la app (checkpoint WAL limpio)
/opt/reddit-intel/scripts/backup.sh # backup final pre-migración
# activar la app (crea PVCs y arranca el pod con BD vacía)
kubectl apply -f reddit-intel/argocd-app.yaml
kubectl -n reddit-intel rollout status deploy/reddit-intel
# parar el pod y volcar la BD del host al PVC
kubectl -n reddit-intel scale deploy/reddit-intel --replicas=0
kubectl -n reddit-intel run pvc-loader --image=busybox --restart=Never \
--overrides='{"spec":{"nodeSelector":{"kubernetes.io/hostname":"chemavx-n97"},"containers":[{"name":"pvc-loader","image":"busybox","command":["sleep","600"],"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"reddit-intel-data"}}]}}'
kubectl -n reddit-intel wait --for=condition=Ready pod/pvc-loader
kubectl -n reddit-intel cp /opt/reddit-intel/data/reddit-intel.db pvc-loader:/data/reddit-intel.db
kubectl -n reddit-intel delete pod pvc-loader
kubectl -n reddit-intel scale deploy/reddit-intel --replicas=1
```
(El `docker compose down` previo garantiza que no hay `-wal`/`-shm`
pendientes: basta copiar el `.db`.)
4. **Verificar:** `kubectl -n reddit-intel logs deploy/reddit-intel` debe
mostrar `scheduler_start jobs=monitor,detector,analyzer,karma,learning`;
el dashboard responde vía `svc/reddit-intel` (port-forward o el ingress
que se decida — pendiente de elegir exposición).
5. **Desmantelar la fase compose:** quitar la línea del crontab de chemavx en
n97 (el CronJob `reddit-intel-db-backup` de aquí la reemplaza) y archivar
`/opt/reddit-intel` cuando la semana de convivencia confirme que todo va bien.
## Notas
- El CronJob de backup (`backup-cronjob.yaml`) replica el patrón researchowl:
`sqlite3 .backup` + `integrity_check` + retención 7, contra el PVC.
- Un solo replica siempre (`strategy: Recreate`): SQLite WAL + APScheduler
in-process no admiten dos pods.
- El namespace `reddit-intel` ya existe en el clúster (creado durante la
preparación); `CreateNamespace=true` lo cubre igualmente.
+26
View File
@@ -0,0 +1,26 @@
# INACTIVO hasta la promoción: el app-of-apps k8s-manifests NO es recursivo,
# así que este fichero solo existe en git — nadie lo aplica automáticamente.
# La activación es manual: ver PROMOTION.md en este directorio.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: reddit-intel
namespace: argocd
annotations:
notifications.argoproj.io/subscribe.on-sync-failed.telegram: "5138407666"
notifications.argoproj.io/subscribe.on-health-degraded.telegram: "5138407666"
spec:
project: default
source:
repoURL: https://git.chemavx.xyz/chemavx/k8s-manifests
targetRevision: HEAD
path: reddit-intel
destination:
server: https://kubernetes.default.svc
namespace: reddit-intel
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+80
View File
@@ -0,0 +1,80 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
# Never let ArgoCD prune this PVC: it holds the DB backups.
argocd.argoproj.io/sync-options: Prune=false
name: reddit-intel-backups
namespace: reddit-intel
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: reddit-intel-db-backup
namespace: reddit-intel
labels:
app: reddit-intel
spec:
schedule: "0 3 * * *"
timeZone: "Europe/Madrid"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 600
template:
metadata:
labels:
app: reddit-intel-db-backup
spec:
restartPolicy: Never
containers:
- name: backup
image: keinos/sqlite3:latest
command:
- /bin/sh
- -c
- |
set -eu
STAMP=$(date +%Y%m%d)
DEST="/backups/reddit-intel-${STAMP}.db"
echo "Backing up /data/reddit-intel.db -> ${DEST}"
sqlite3 -cmd ".timeout 30000" /data/reddit-intel.db ".backup '${DEST}'"
RESULT=$(sqlite3 "${DEST}" "PRAGMA integrity_check;")
if [ "${RESULT}" != "ok" ]; then
echo "Integrity check FAILED: ${RESULT}"
rm -f "${DEST}"
exit 1
fi
echo "Integrity check ok"
# Retention: keep the 7 most recent backups
for f in $(ls -1t /backups/reddit-intel-*.db | tail -n +8); do
echo "Pruning old backup ${f}"
rm -f "${f}"
done
ls -lh /backups/
volumeMounts:
# NOT readOnly: the source DB runs in WAL mode and sqlite readers
# must be able to touch the -shm/-wal sidecar files. The job only
# ever reads the DB (.backup); it never modifies application data.
- name: data
mountPath: /data
- name: backups
mountPath: /backups
volumes:
- name: data
persistentVolumeClaim:
claimName: reddit-intel-data
- name: backups
persistentVolumeClaim:
claimName: reddit-intel-backups
+124
View File
@@ -0,0 +1,124 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: reddit-intel
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
# Never let ArgoCD prune this PVC: it holds the SQLite DB.
argocd.argoproj.io/sync-options: Prune=false
name: reddit-intel-data
namespace: reddit-intel
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: reddit-intel
namespace: reddit-intel
labels:
app: reddit-intel
spec:
replicas: 1
# Single-replica app on a RWO local-path SQLite DB (WAL) with an in-process
# APScheduler: Recreate avoids two pods briefly sharing /data/reddit-intel.db.
strategy:
type: Recreate
selector:
matchLabels:
app: reddit-intel
template:
metadata:
labels:
app: reddit-intel
spec:
containers:
- name: reddit-intel
# Placeholder tag: el workflow manual (repo reddit-intel) lo sustituye
# por el sha8 real en cada build. Ver PROMOTION.md.
image: git.chemavx.xyz/chemavx/reddit-intel:00000000
imagePullPolicy: Always
ports:
- containerPort: 8080
env:
- name: DB_PATH
value: "/data/reddit-intel.db"
- name: REDDIT_USERNAME
value: "ExclusionZoneUAP"
- name: REDDIT_USER_AGENT
value: "linux:reddit-intel:v1.0 (by /u/ExclusionZoneUAP)"
- name: LLM_PROVIDER
value: "anthropic"
- name: LLM_MODEL_SCORING
value: "claude-haiku-4-5"
- name: LLM_MODEL_DRAFTING
value: "claude-sonnet-5"
- name: DAILY_LLM_BUDGET
value: "60"
- name: REDDIT_CLIENT_ID
valueFrom:
secretKeyRef:
name: reddit-intel-secrets
key: reddit-client-id
- name: REDDIT_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: reddit-intel-secrets
key: reddit-client-secret
- name: LLM_API_KEY
valueFrom:
secretKeyRef:
name: reddit-intel-secrets
key: llm-api-key
- name: DASHBOARD_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: reddit-intel-secrets
key: dashboard-auth-token
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: reddit-intel-secrets
key: telegram-bot-token
- name: TELEGRAM_CHAT_ID
valueFrom:
secretKeyRef:
name: reddit-intel-secrets
key: telegram-chat-id
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 20
periodSeconds: 60
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: data
persistentVolumeClaim:
claimName: reddit-intel-data
imagePullSecrets:
- name: gitea-registry
@@ -2,7 +2,7 @@ apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: gitea-registry
namespace: polymarket-bot
namespace: reddit-intel
spec:
# References the SHARED auth layer in infisical-operator (cross-namespace).
infisicalAuthRef:
@@ -11,13 +11,13 @@ spec:
sources:
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /registry # shared /registry v3 fields (same as researchowl)
secretPath: /registry
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: gitea-registry-infisical # parallel name — old gitea-registry stays live until cutover
namespace: polymarket-bot
name: gitea-registry
namespace: reddit-intel
creationPolicy: Owner
secretType: kubernetes.io/dockerconfigjson
template:
@@ -0,0 +1,26 @@
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: reddit-intel-secrets
namespace: reddit-intel
spec:
# Shared, read-only machine identity (no write grant, no PushSecret).
# Source is the /reddit-intel folder. Expected keys (lowercase-hyphenated,
# straight passthrough to the deployment's secretKeyRef values):
# reddit-client-id, reddit-client-secret, llm-api-key,
# dashboard-auth-token, telegram-bot-token, telegram-chat-id
# telegram-* pueden crearse vacíos (las notificaciones son opcionales).
infisicalAuthRef:
name: infisical-auth
namespace: infisical-operator
sources:
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /reddit-intel
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: reddit-intel-secrets
namespace: reddit-intel
creationPolicy: Owner
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: reddit-intel
namespace: reddit-intel
labels:
app: reddit-intel
spec:
selector:
app: reddit-intel
ports:
- name: http
port: 80
targetPort: 8080
+2 -2
View File
@@ -44,7 +44,7 @@ spec:
spec:
containers:
- name: researchowl
image: git.chemavx.xyz/chemavx/researchowl:d31badeb
image: git.chemavx.xyz/chemavx/researchowl:dadc030d
imagePullPolicy: Always
env:
- name: TELEGRAM_BOT_TOKEN
@@ -104,7 +104,7 @@ spec:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
memory: "2Gi"
cpu: "500m"
volumes:
- name: data
@@ -1,18 +1,22 @@
# Se aplica una vez imperativamente (kubectl apply -f); después ArgoCD
# se autogestiona porque este fichero también vive en k8s-manifests/roswell/.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: polymarket-bot
name: roswell
namespace: argocd
spec:
destination:
namespace: polymarket-bot
server: https://kubernetes.default.svc
project: default
source:
path: polymarket-bot
repoURL: https://git.chemavx.xyz/chemavx/k8s-manifests
targetRevision: main
targetRevision: HEAD
path: roswell
destination:
server: https://kubernetes.default.svc
namespace: roswell
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+78
View File
@@ -0,0 +1,78 @@
# Backup diario de la BD del corpus con retención 7 (PLAN §Fase 6).
# pg_dump en formato custom (-Fc, comprimido) verificado con pg_restore --list.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
# ArgoCD no debe podar este PVC: contiene los backups de la BD.
argocd.argoproj.io/sync-options: Prune=false
name: roswell-backups
namespace: roswell
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: roswell-db-backup
namespace: roswell
labels:
app: roswell
spec:
schedule: "30 3 * * *"
timeZone: "Europe/Madrid"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 900
template:
metadata:
labels:
app: roswell-db-backup
spec:
restartPolicy: Never
containers:
- name: backup
# Misma imagen que el postgres del corpus: pg_dump 16 garantizado
# y ya está en la caché del nodo.
image: pgvector/pgvector:pg16
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: roswell-pg-secret-infisical
key: password
command:
- /bin/sh
- -c
- |
set -eu
STAMP=$(date +%Y%m%d)
DEST="/backups/roswell-${STAMP}.dump"
echo "pg_dump roswell -> ${DEST}"
pg_dump -h postgres.roswell.svc.cluster.local -U roswell \
-d roswell -Fc -f "${DEST}"
# Verificación: un dump corrupto no lista su TOC.
pg_restore --list "${DEST}" > /dev/null
echo "Dump verificado ($(du -h ${DEST} | cut -f1))"
# Retención: conservar los 7 más recientes
for f in $(ls -1t /backups/roswell-*.dump | tail -n +8); do
echo "Podando backup antiguo ${f}"
rm -f "${f}"
done
ls -lh /backups/
volumeMounts:
- name: backups
mountPath: /backups
volumes:
- name: backups
persistentVolumeClaim:
claimName: roswell-backups
+116
View File
@@ -0,0 +1,116 @@
# Bot + scheduler en un pod (PLAN §Fase 6). Los secretos vienen de Infisical
# (carpeta /roswell del proyecto homelab) vía infisical-roswell-secrets.yaml.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
# ArgoCD no debe podar este PVC: guarda la caché del modelo Whisper (~3GB)
# y los PDFs subidos por Telegram.
argocd.argoproj.io/sync-options: Prune=false
name: roswell-data
namespace: roswell
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: roswell
namespace: roswell
labels:
app: roswell
spec:
replicas: 1
# Dos pods haciendo polling del mismo token de Telegram entran en conflicto:
# Recreate garantiza que el viejo muere antes de arrancar el nuevo.
strategy:
type: Recreate
selector:
matchLabels:
app: roswell
template:
metadata:
labels:
app: roswell
spec:
containers:
- name: roswell
# Tag placeholder: el workflow de Gitea Actions lo sustituye por el
# sha8 real en cada build (ver .gitea/workflows/build.yml del repo).
image: git.chemavx.xyz/chemavx/roswell-corpus:09457a47
imagePullPolicy: Always
env:
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: roswell-secrets-infisical
key: telegram-bot-token
- name: TELEGRAM_ALLOWED_USERS
valueFrom:
secretKeyRef:
name: roswell-secrets-infisical
key: telegram-allowed-users
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: roswell-secrets-infisical
key: anthropic-api-key
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: roswell-secrets-infisical
key: database-url
- name: PODCASTINDEX_KEY
valueFrom:
secretKeyRef:
name: roswell-secrets-infisical
key: podcastindex-key
- name: PODCASTINDEX_SECRET
valueFrom:
secretKeyRef:
name: roswell-secrets-infisical
key: podcastindex-secret
- name: OLLAMA_URL
value: "http://ollama.ollama.svc.cluster.local:11434"
- name: SEARXNG_URL
value: "http://searxng-svc.researchowl.svc.cluster.local:8080"
- name: WHISPER_MODEL
value: "large-v3"
- name: WHISPER_DEVICE
value: "cpu"
- name: DATA_DIR
value: "/data"
- name: FEED_CHECK_HOURS
value: "24"
# Caché de modelos Whisper en el PVC: la descarga (~3GB) sobrevive
# a los redeploys.
- name: HF_HOME
value: "/data/hf-cache"
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
# Whisper large-v3 INT8 en CPU: el pico real supera los 4Gi que
# estimaba el PLAN §10 (OOMKilled verificado el 2026-07-13).
# Sin límite de CPU: la transcripción usa los cores libres.
memory: "6Gi"
# Nodo grande (16 cores / 29Gi): transcribe a ~1x tiempo real y comparte
# nodo con los PVC de datos y backups. El nodo pequeño (4 cores/12Gi)
# se saturaba con Whisper.
nodeSelector:
kubernetes.io/hostname: chemavx-k8
volumes:
- name: data
persistentVolumeClaim:
claimName: roswell-data
imagePullSecrets:
- name: gitea-registry
+27
View File
@@ -0,0 +1,27 @@
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: gitea-registry
namespace: roswell
spec:
# Referencia la capa de auth COMPARTIDA en infisical-operator (cross-namespace).
infisicalAuthRef:
name: infisical-auth
namespace: infisical-operator
sources:
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /registry
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: gitea-registry
namespace: roswell
creationPolicy: Owner
secretType: kubernetes.io/dockerconfigjson
template:
engineVersion: v1
data:
# Reconstruye el pull-secret docker desde los dos campos de /registry.
.dockerconfigjson: '{"auths":{"git.chemavx.xyz":{"username":"{{ .GITEA_REGISTRY_USERNAME.Value }}","password":"{{ .GITEA_REGISTRY_TOKEN.Value }}","auth":"{{ printf "%s:%s" .GITEA_REGISTRY_USERNAME.Value .GITEA_REGISTRY_TOKEN.Value | encodeBase64 }}"}}}'
+69
View File
@@ -0,0 +1,69 @@
# Secretos de la app respaldados por Infisical (patrón researchowl).
#
# Claves esperadas en /roswell (proyecto homelab, entorno prod):
# TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS, ANTHROPIC_API_KEY,
# PODCASTINDEX_KEY, PODCASTINDEX_SECRET, POSTGRES_PASSWORD
#
# Un InfisicalStaticSecret por target: el operator (versión actual) solo
# procesa el primer elemento de `targets`, comprobado el 2026-07-13.
#
# CORTE (cuando los valores estén verificados): cambiar las referencias de
# deployment.yaml, postgres.yaml y backup-cronjob.yaml a los nombres
# *-infisical y borrar los planos:
# kubectl delete secret roswell-secrets roswell-pg-secret -n roswell
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: roswell-secrets
namespace: roswell
spec:
# Identidad compartida de solo lectura (sin write, sin PushSecret).
infisicalAuthRef:
name: infisical-auth
namespace: infisical-operator
sources:
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /roswell
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: roswell-secrets-infisical
namespace: roswell
creationPolicy: Owner
template:
engineVersion: v1
data:
telegram-bot-token: '{{ .TELEGRAM_BOT_TOKEN.Value }}'
telegram-allowed-users: '{{ .TELEGRAM_ALLOWED_USERS.Value }}'
anthropic-api-key: '{{ .ANTHROPIC_API_KEY.Value }}'
podcastindex-key: '{{ .PODCASTINDEX_KEY.Value }}'
podcastindex-secret: '{{ .PODCASTINDEX_SECRET.Value }}'
# La URL completa se reconstruye: un solo sitio para la password.
database-url: 'postgresql://roswell:{{ .POSTGRES_PASSWORD.Value }}@postgres.roswell.svc.cluster.local:5432/roswell'
---
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
name: roswell-pg-secret
namespace: roswell
spec:
infisicalAuthRef:
name: infisical-auth
namespace: infisical-operator
sources:
- projectId: 17e98e9d-70f5-43d1-8382-7da818dfcdd0 # project "homelab"
environmentSlug: prod
secretPath: /roswell
syncOptions:
refreshInterval: 60s
targets:
- kind: Secret
name: roswell-pg-secret-infisical
namespace: roswell
creationPolicy: Owner
template:
engineVersion: v1
data:
password: '{{ .POSTGRES_PASSWORD.Value }}'
+90
View File
@@ -0,0 +1,90 @@
# Postgres 16 + pgvector para el corpus (namespace roswell).
# El Secret roswell-pg-secret se crea imperativamente (PLAN Fase 6);
# migración a Infisical preparada en infisical-roswell-secrets.yaml.
# ClusterIP (cierre Fase 6): con el bot ya en el clúster el NodePort 30432
# sobraba. Acceso local puntual: desde un nodo del clúster la ClusterIP
# responde directa; desde fuera, kubectl port-forward -n roswell svc/postgres 15432:5432.
apiVersion: v1
kind: Namespace
metadata:
name: roswell
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
# ArgoCD no debe podar nunca este PVC: contiene la BD del corpus.
argocd.argoproj.io/sync-options: Prune=false
name: roswell-pgdata
namespace: roswell
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: roswell
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: roswell-postgres
template:
metadata:
labels:
app: roswell-postgres
spec:
containers:
- name: postgres
image: pgvector/pgvector:pg16
env:
- name: POSTGRES_DB
value: roswell
- name: POSTGRES_USER
value: roswell
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: roswell-pg-secret-infisical
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
readinessProbe:
exec:
command: ["pg_isready", "-U", "roswell", "-d", "roswell"]
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: pgdata
persistentVolumeClaim:
claimName: roswell-pgdata
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: roswell
spec:
type: ClusterIP
selector:
app: roswell-postgres
ports:
- port: 5432
targetPort: 5432