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 --updatecron diario. - Caddyfile: 488 → 218 líneas.
- Snapshots de seguridad:
pre_quickwins_20260428_2027.
Decision Drivers¶
- Estabilidad sobre todo: hoy hubo 4 incidentes en producción; no aceptamos un día más de churn por refactor mal escalonado.
- Reversibilidad: cada fase debe tener snapshot Proxmox antes y después;
rollback en <1 minuto vía
qm rollback. - Smoke testing E2E: tras cada fase, verificar 32/32 hostnames responden antes de avanzar.
- Independencia entre fases: si la fase 1 falla, fases 2 y 3 siguen siendo ejecutables otro día.
- 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:
-
Las regex de parsing del Caddyfile (
[^}]*?con un solo nivel de nesting) son la causa raíz de los problemas de hoy con bloquessablier{}anidados. Caddy 2.6+ soportacaddy adapt --config <file> --prettyque emite JSON estructurado; manipular ese JSON elimina la fragilidad. -
Quedan 3 hostnames que no se pueden expresar como labels Docker en la VM 208 (no son containers locales):
-
caddy.monxas.casa→ SÍ migrable como label normal (es container local). n8n.monxas.casa → 192.168.0.200:5678(LXC distinto, otro host).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 cargatunnel-static.yamly 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 /loadal 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:
Validación:
- Pre: snapshot, smoke test 32/32.
- Post:
caddy adaptse ejecuta sin errores.docker exec caddy curl -s :2019/configdebe 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:
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
--helppor 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¶
-
~~Migrar los 3 manuales irreductibles~~: RESUELTO.
caddyse migra con label normal;n8nyjob-huntervíatunnel-static.yaml. Todo declarativo tras fase 1. -
Containers
jobhunter-appyjobhunter-db: jobhunter-db(postgres en :5433): probablemente la DB que usa el systemd nativejobhunter(port 3002, sirvehttps://job-hunter.monxas.casa). Antes de tocarlo: confirmar consudo lsof -i :5433o leer el EnvironmentFile del service systemd para verDATABASE_URL. No borrar hasta confirmar.-
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. -
Repo separado para
homelab-ctl/: tras la consolidación serán ~600 LOC con tests posibles. Podría justificarmonxas/homelab-ctlgit repo. Defer: decidir tras fase 3 ver tamaño real. -
Tests unitarios: ¿pytest sobre
labels.pyycaddy.py? ROI bajo en homelab single-user pero alto encaddy.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 Caddyfilese 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/Caddyfilevacío (las IPs literales viven sólo entunnel-static.yaml) -
tunnel-static.yamlcubre n8n + job-hunter; ambos hostnames en smoke -
homelab-ctl statusproduce output equivalente atunnel-sync status - Crons migrados; logs de
homelab-ctlcon el mismo nivel de detalle que el watchdog actual -
~/scripts/tunnel-sync.pyy~/scripts/sablier-watchdog.pymovidos a~/scripts/legacy/ - ADR actualizado con
Status: Implementedy 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 Caddyheader +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_ypost_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 Caddyfilesin warnings - 0 manual blocks en main Caddyfile (todo en
managed.caddyauto-gen) -
grep 192.168.0.208 Caddyfilevacío (sólo el static fallback de tunnel-static.yaml) -
tunnel-static.yamlcubre n8n + job-hunter; ambos hostnames smoke OK -
homelab-ctl statusproduce output equivalente altunnel-sync statusprevio - Crons migrados; logs en
~/scripts/homelab-ctl.log -
tunnel-sync.py+sablier-watchdog.pyen~/scripts/legacy/(7 días gracia) - Memoria + docs canónicas actualizadas (
services_routing.md)
Open questions resueltas¶
- ~~Migrar 3 manuales irreductibles~~ — resuelto en Fase 1 vía
tunnel-static.yaml(n8n, job-hunter) + label en caddy service. - ~~Containers
jobhunter-app/jobhunter-db~~ — confirmado zombies (la app systemd usa SQLite local, no Postgres). Stopped esta sesión, pendientedocker rmdefinitivo 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 concaddy 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-ctlsi crece >1000 LOC o añadimos tests. oap-nginx(~/oap/) fuera de~/stacks/: actualmente usa target IP legacy. Migrarlo astacks_sharedo documentar que vive aparte.