Skip to content

ADR-0001: Consolidación de routing y tooling del homelab

Campo Valor
Status Implemented (2026-04-28)
Date 2026-04-28
Decision by ramon
Reviewers system-architect (subagent review)
Supersedes

Contexto

Tras la sesión de saneamiento del 2026-04-28 quedan 3 refactors arquitectónicos identificados por el arquitecto. Son independientes entre sí pero tienen ROI alto y arrastran deuda que dispara incidentes (hoy hubo varios: 16 containers borrados invisibles al watchdog inicial, regex de tunnel-sync que no captura nesting profundo, IPs hardcodeadas en 40 sitios).

Estado tras los quick wins de hoy:

  • 32 routes públicas (29 managed via labels + 3 manuales irreductibles).
  • 2 mecanismos de gestión de containers: sablier-watchdog.py + deploy-hook.
  • Watchtower eliminado, reemplazado por watchdog --update cron diario.
  • Caddyfile: 488 → 218 líneas.
  • Snapshots de seguridad: pre_quickwins_20260428_2027.

Decision Drivers

  1. Estabilidad sobre todo: hoy hubo 4 incidentes en producción; no aceptamos un día más de churn por refactor mal escalonado.
  2. Reversibilidad: cada fase debe tener snapshot Proxmox antes y después; rollback en <1 minuto vía qm rollback.
  3. Smoke testing E2E: tras cada fase, verificar 32/32 hostnames responden antes de avanzar.
  4. Independencia entre fases: si la fase 1 falla, fases 2 y 3 siguen siendo ejecutables otro día.
  5. Atacar causa raíz, no síntoma: los 3 refactors apuntan a problemas estructurales (parsing frágil, acoplamiento de IPs, lógica duplicada), no a añadir guardarraíles.

Opciones consideradas

Opción A: Hacer los 3 refactors en paralelo

  • ✅ Más rápido si todo va bien
  • ❌ Si rompe algo, difícil aislar la causa
  • ❌ Contención mental excesiva (estoy solo, no equipo)
  • Rechazada por riesgo

Opción B: Sólo el refactor JSON adapter (más urgente)

  • ✅ Mínimo de cambio
  • ❌ Deja deuda de IPs hardcoded y duplicación CLI
  • ❌ Habrá que volver a esto en otra sesión
  • Rechazada por completitud

Opción C: Los 3 secuencial, ordenados por dependencia y riesgo (ELEGIDA)

  • ✅ Cada fase valida la siguiente
  • ✅ Rollback granular por fase
  • ✅ Smoke test entre fases detecta regresión inmediatamente
  • ❌ ~6h continuas de trabajo
  • Elegida

Decisión

Ejecutar Fase 1 → Fase 2 → Fase 3 secuencialmente, con snapshot Proxmox entre cada fase. Cualquier fase que falle el smoke test se revierte y se abortan las siguientes.

Fase 1 — tunnel-sync regex → JSON adapter + tunnel-static.yaml (~2.5h)

Motivación dual:

  1. Las regex de parsing del Caddyfile ([^}]*? con un solo nivel de nesting) son la causa raíz de los problemas de hoy con bloques sablier{} anidados. Caddy 2.6+ soporta caddy adapt --config <file> --pretty que emite JSON estructurado; manipular ese JSON elimina la fragilidad.

  2. Quedan 3 hostnames que no se pueden expresar como labels Docker en la VM 208 (no son containers locales):

  3. caddy.monxas.casa → SÍ migrable como label normal (es container local).

  4. n8n.monxas.casa → 192.168.0.200:5678 (LXC distinto, otro host).
  5. job-hunter.monxas.casa → 192.168.0.208:3002 (systemd native, no Docker).

Para los 2 últimos creamos un archivo ~/scripts/tunnel-static.yaml que declara rutas estáticas. tunnel-sync une (labels Docker) ∪ (static.yaml) en la lista "desired" → ambas fuentes van al mismo JSON managed. Resultado: 0 manual blocks en el Caddyfile, 100% declarativo.

Cambios:

  • tunnel-sync.py:
  • Reemplazar caddy_replace_managed_in_text() y similares por construcción de JSON via dict/list Python.
  • Nueva función collect_static_routes() que carga tunnel-static.yaml y devuelve entries con la misma forma que las labels.
  • collect_desired() une ambas fuentes (labels + static), deduplica por hostname, prioriza labels en caso de colisión.
  • Reload de Caddy con POST /load al admin :2019 con el JSON resultante.
  • Eliminar ~80 LOC de regex.
  • Mantener Caddyfile como input legible (la parte estática no managed); sólo el managed muta vía JSON.

  • ~/scripts/tunnel-static.yaml (nuevo):

    # Rutas estáticas para hostnames que no son containers Docker en VM 208.
    # Formato idéntico al desired de labels (hostname/target/lazy opcional).
    routes:
      - hostname: n8n.monxas.casa
        target: 192.168.0.200:5678
        note: n8n vive en LXC 200 del cluster Proxmox
    
      - hostname: job-hunter.monxas.casa
        target: 192.168.0.208:3002
        note: systemd native (Next.js prod), no Docker
    

  • ~/stacks/reverse-proxy/compose.yaml: añadir labels al service caddy:

    labels:
      - tunnel.hostname=caddy.monxas.casa
      - tunnel.port=80
    

Validación:

  • Pre: snapshot, smoke test 32/32.
  • Post:
  • caddy adapt se ejecuta sin errores.
  • docker exec caddy curl -s :2019/config debe ser idéntico al estado anterior salvo whitespace.
  • smoke test 32/32 incluyendo n8n y job-hunter.
  • 0 bloques manuales fuera de la sección managed: awk '/BEGIN tunnel-sync/{flag=1; next} /END tunnel-sync/{flag=0; next} !flag' Caddyfile | grep -c '@\w\+ host' debe devolver 0.

Rollback: qm rollback 208 pre_phase1_jsonadapter.

Fase 2 — Docker shared network stacks_shared (~1h)

Motivación: 40+ instancias de 192.168.0.208:<port> en Caddyfile + composes. Migrar host requiere tocar 40 archivos. Una network Docker compartida permite usar nombres de container resolvibles via Docker DNS.

Cambios: - docker network create stacks_shared (bridge externa). - Cada compose: networks: [stacks_shared, default] para los servicios expuestos (los que tienen label tunnel.hostname). - Caddy también en stacks_shared. - Caddyfile / managed JSON: reverse_proxy http://<container_name>:<port_interno> en vez de reverse_proxy 192.168.0.208:<port_publicado>. - Beneficio adicional: ya no necesitamos publicar ports: para los servicios públicos lazy (Sablier puede arrancarlos sin conflicto de puertos).

Estrategia incremental: 1. Crear network. 2. Piloto con deploy-hook (servicio nuevo, bajo riesgo). 3. Verificar docker exec caddy nslookup deploy-hook resuelve. 4. Migrar el resto en lote, smoke test después de cada stack.

Validación: - Pre: snapshot, smoke 32/32. - Post: smoke 32/32; verificar que ningún hostname queda apuntando a IP literal (grep '192\.168\.0\.208' Caddyfile debe estar vacío excepto los 3 manuales irreductibles).

Rollback: qm rollback 208 pre_phase2_sharednetwork.

Fase 3 — Consolidación a homelab-ctl CLI (~3h)

Motivación: tunnel-sync.py y sablier-watchdog.py parsean labels prácticamente con la misma lógica. La duplicación causa drift (hoy detecté que el watchdog no manejaba <project>-<service>-1 y tuve que parchearlo mientras tunnel-sync sí lo hace).

Estructura propuesta:

~/scripts/homelab-ctl/
├── homelab-ctl.py        # entry point con argparse
├── labels.py             # parse compose + docker labels (compartido)
├── caddy.py              # JSON manipulation (resultado fase 1)
├── tunnel.py             # CF API operations
├── docker_ops.py         # create/up/pull/recreate
└── smoke.py              # E2E hostname checks

Subcomandos:

homelab-ctl status              # diff esperado vs real
homelab-ctl sync [--yes]        # tunnel + caddy reconcile (era tunnel-sync)
homelab-ctl watch               # recreate missing (era watchdog --check)
homelab-ctl update              # pull + recreate stale (era watchdog --update)
homelab-ctl smoke               # E2E test sólo

Crons migrados:

*/5 * * * * /usr/bin/python3 -m homelab-ctl watch
0   4 * * * /usr/bin/python3 -m homelab-ctl update

Validación: - homelab-ctl status produce diff equivalente a tunnel-sync status. - homelab-ctl watch --dry-run lista las mismas acciones que watchdog hubiera hecho. - 24h en producción sin incidentes antes de archivar tunnel-sync/watchdog.

Rollback: scripts viejos quedan en ~/scripts/legacy/ durante 7 días; si hay regresión, restaurar y revertir crons.

Consecuencias

Positivas

  • 1 fuente de verdad para labels parsing → cero drift.
  • Migrar host (e.g., a VM 209) = cambio en 1 .env en vez de 40 archivos.
  • Smoke test E2E built-in tras cada operación.
  • ~150 LOC menos entre regex eliminadas y duplicación.
  • Caddy JSON manipulation soporta nesting arbitrario; sin clase de bugs de parsing futuros.

Negativas

  • ~6h continuas de trabajo (1 sesión completa).
  • Curva de aprendizaje del nuevo CLI (mitigada con --help por subcomando).
  • Si falla el smoke test E2E, una fase puede no acabar y dejar estado inconsistente parcial (mitigado con snapshots).

Riesgos y mitigaciones

Riesgo Probabilidad Mitigación
Caddy JSON adapter no soporta directiva sablier en config JSON Baja Pre-test con un solo bloque sablier antes de migrar todos; fallback: mantener regex sólo para esa subsección
Docker DNS no resuelve container_name en network shared Baja Pre-test piloto con deploy-hook; fallback: usar IPs internas de la network (172.x.x.x estables)
CLI consolidation introduce bug sutil que watchdog tenía resuelto Media Mantener scripts viejos en ~/scripts/legacy/ 7 días antes de archivar; ejecución paralela primer día (ambos crons activos, comparar logs)
Smoke test no detecta regresión semántica (200 a página de error) Media Smoke test mejorado: verificar Via: Caddy header presente, status NOT in [502,503,504]

Plan de ejecución (timeline)

T+0:00  Snapshot pmx-50 "pre_phase1_jsonadapter"
T+0:05  Fase 1a: añadir label a caddy + crear tunnel-static.yaml
T+0:20  Fase 1b: refactor tunnel-sync (regex → JSON + collect_static_routes)
T+2:15  Smoke test 32/32 + 0 manual blocks + diff /config/ pre/post
T+2:30  GO: snapshot "post_phase1"; NO-GO: rollback + abort

T+2:35  Snapshot "pre_phase2_sharednetwork"
T+2:40  docker network create + piloto deploy-hook
T+3:00  Migrar 19 stacks en lote
T+3:30  Smoke test 32/32 + verificar 0 IPs literales (excepto static.yaml)
T+3:45  GO: snapshot "post_phase2"; NO-GO: rollback + parar aquí

T+3:50  Snapshot "pre_phase3_cli"
T+3:55  Implementar homelab-ctl/* (labels, caddy, tunnel, docker_ops, smoke)
T+5:55  Probar subcomandos en dry-run vs scripts viejos
T+6:25  Migrar crons + dejar viejos en legacy/
T+6:40  Smoke test 32/32
T+6:55  GO: snapshot "post_phase3" + actualizar docs/memory
T+7:00  Done

Total: 7h con buffer realista. Si una fase tarda más, abortar las siguientes y continuar otro día.

Open questions

  1. ~~Migrar los 3 manuales irreductibles~~: RESUELTO. caddy se migra con label normal; n8n y job-hunter vía tunnel-static.yaml. Todo declarativo tras fase 1.

  2. Containers jobhunter-app y jobhunter-db:

  3. jobhunter-db (postgres en :5433): probablemente la DB que usa el systemd native jobhunter (port 3002, sirve https://job-hunter.monxas.casa). Antes de tocarlo: confirmar con sudo lsof -i :5433 o leer el EnvironmentFile del service systemd para ver DATABASE_URL. No borrar hasta confirmar.
  4. jobhunter-app (next.js container en :3087): NO es lo que sirve el dominio público — el systemd native escucha en :3002 y eso es lo que apunta el Caddyfile. Probable container zombie de versión vieja, pero sin urgencia. Defer.

  5. Repo separado para homelab-ctl/: tras la consolidación serán ~600 LOC con tests posibles. Podría justificar monxas/homelab-ctl git repo. Defer: decidir tras fase 3 ver tamaño real.

  6. Tests unitarios: ¿pytest sobre labels.py y caddy.py? ROI bajo en homelab single-user pero alto en caddy.py (manipulación JSON) por cobertura de edge cases. Defer a sesión post-fase-3.

Validation criteria (definition of done)

Al final de la jornada, todos verdaderos:

  • 32/32 routes responden correctamente (smoke test E2E)
  • caddy adapt --config Caddyfile se ejecuta sin warnings (excepto el formatting cosmético existente)
  • 0 manual blocks en el Caddyfile (todo el routing en sección managed)
  • grep '192\.168\.0\.208' ~/stacks/reverse-proxy/Caddyfile vacío (las IPs literales viven sólo en tunnel-static.yaml)
  • tunnel-static.yaml cubre n8n + job-hunter; ambos hostnames en smoke
  • homelab-ctl status produce output equivalente a tunnel-sync status
  • Crons migrados; logs de homelab-ctl con el mismo nivel de detalle que el watchdog actual
  • ~/scripts/tunnel-sync.py y ~/scripts/sablier-watchdog.py movidos a ~/scripts/legacy/
  • ADR actualizado con Status: Implemented y enlaces a snapshots final
  • Memoria actualizada (services_routing.md)
  • Doc canónica actualizada (../services-routing.md (hub))

Notas de implementación

  • Smoke test mejorado (a integrar en fase 1): verificar Via: 1.1 Caddy header + status NOT IN [502,503,504] + body length > 0. Detecta regresión semántica (e.g., Caddy sirve fallback "Not found" 404 cuando el matcher se rompe).
  • Snapshots: nombrar con prefijo pre_ y post_ para cada fase. No acumular más de 10 snapshots por VM (limpiar los anteriores al éxito).
  • No tocar nada los 3 manuales irreductibles sin comentarlo aquí primero.

Próxima revisión: tras ejecución completa o aborto. Actualizar Status a Implemented, Partial, o Superseded según resultado.


Implementation outcome

Status: Implemented — 2026-04-28, single session, ~1h30min total (estimación original: 7h — el approach import managed.caddy simplificó sustancialmente la fase 1 vs el JSON adapter previsto).

Snapshots Proxmox (rollback path)

Phase Pre-snapshot Post-snapshot
Fase 1 pre_phase1_20260428_2110 post_phase1_20260428_2113
Fase 2 pre_phase2_20260428_2114 post_phase2_20260428_2120
Fase 3 pre_phase3_20260428_2122 post_phase3_20260428_2128

Métricas finales

Métrica Antes (mañana) Después
Caddyfile principal 488 líneas 30 líneas
Manual blocks 13 0
IPs hardcoded en routing 40+ 3 (legítimas: n8n LXC, job-hunter systemd, oap-nginx legacy)
Scripts en ~/scripts/ (routing+watchdog) 2 (714 LOC) 1 homelab-ctl.py (671 LOC)
Crons separados 2 2 (mismo CLI)
Smoke test E2E manual integrado en sync

Validation criteria — 9/9 ✅

  • 34/34 routes responden (smoke E2E integrado)
  • caddy adapt --config Caddyfile sin warnings
  • 0 manual blocks en main Caddyfile (todo en managed.caddy auto-gen)
  • grep 192.168.0.208 Caddyfile vacío (sólo el static fallback de tunnel-static.yaml)
  • tunnel-static.yaml cubre n8n + job-hunter; ambos hostnames smoke OK
  • homelab-ctl status produce output equivalente al tunnel-sync status previo
  • Crons migrados; logs en ~/scripts/homelab-ctl.log
  • tunnel-sync.py + sablier-watchdog.py en ~/scripts/legacy/ (7 días gracia)
  • Memoria + docs canónicas actualizadas (services_routing.md)

Open questions resueltas

  1. ~~Migrar 3 manuales irreductibles~~ — resuelto en Fase 1 vía tunnel-static.yaml (n8n, job-hunter) + label en caddy service.
  2. ~~Containers jobhunter-app/jobhunter-db~~ — confirmado zombies (la app systemd usa SQLite local, no Postgres). Stopped esta sesión, pendiente docker rm definitivo en próxima sesión.

Cambios respecto al plan original

  • Fase 1: usé import managed.caddy (Caddyfile DSL) en lugar de JSON adapter. Mismo objetivo (sin regex parsing del Caddyfile), mucha menos complejidad. Caddyfile se valida natively con caddy validate.
  • Fase 3: archivo único homelab-ctl.py (671 LOC) en vez de la estructura modular planeada (labels.py, caddy.py, etc.). Para 1 usuario y este scope, single-file es más mantenible.

Deferred (no críticos)

  • Borrar jobhunter-app + jobhunter-db (zombies confirmados, stopped).
  • Eliminar ~/scripts/legacy/ tras 7 días sin incidentes.
  • Considerar repo git separado para homelab-ctl si crece >1000 LOC o añadimos tests.
  • oap-nginx (~/oap/) fuera de ~/stacks/: actualmente usa target IP legacy. Migrarlo a stacks_shared o documentar que vive aparte.