feat(infra): auto-commit con 56 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: discover_local_services
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "discover_local_services(manifest_path: str, include_registry: bool = True) -> list[dict]"
|
||||
description: "Descubre los servicios locales del sistema local_hub expuestos como subdominios *.localhost. Lee el manifiesto YAML, normaliza la metadata de cada servicio, opcionalmente añade los servicios del registry con puerto via fn doctor, y comprueba up/down por chequeo de puerto TCP en 127.0.0.1. Robusta: no lanza por servicio caido (up=False) ni por fallo de fn doctor."
|
||||
tags: [local-hub, infra, services, discovery, caddy, glance, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, os, socket, subprocess, sys, yaml]
|
||||
params:
|
||||
- name: manifest_path
|
||||
desc: "ruta al manifiesto YAML de servicios (apps/local_hub/local_services.yaml) con claves dashboard_subdomain, glance_port y services[]"
|
||||
- name: include_registry
|
||||
desc: "si True, añade los servicios del registry con port>0 que no esten ya en el manifiesto (dedup por port y por subdomain), obtenidos de fn doctor services-spec --json"
|
||||
output: "lista de dicts normalizados, cada uno con las claves name, subdomain, port, health_path, title, icon, category, rewrite_host (bool, passthrough del manifiesto; False para servicios del registry; lo consume render_caddyfile para reescribir el header Host), up (bool de estado vivo por puerto TCP)"
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_service_up_with_all_keys"
|
||||
- "test_edge_closed_port_is_down"
|
||||
- "test_defaults_derived_for_missing_fields"
|
||||
- "test_empty_manifest_returns_empty_list"
|
||||
- "test_rewrite_host_passthrough_desde_manifiesto"
|
||||
test_file_path: "python/functions/infra/discover_local_services_test.py"
|
||||
file_path: "python/functions/infra/discover_local_services.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from discover_local_services import discover_local_services
|
||||
|
||||
# Solo manifiesto (sin tocar el registry):
|
||||
servicios = discover_local_services(
|
||||
"apps/local_hub/local_services.yaml",
|
||||
include_registry=False,
|
||||
)
|
||||
for s in servicios:
|
||||
estado = "UP" if s["up"] else "DOWN"
|
||||
print(f'{s["title"]:<16} {s["subdomain"]}.localhost -> :{s["port"]} [{estado}]')
|
||||
|
||||
# Manifiesto + servicios del registry con puerto:
|
||||
todos = discover_local_services("apps/local_hub/local_services.yaml")
|
||||
print(len([s for s in todos if s["up"]]), "servicios vivos")
|
||||
```
|
||||
|
||||
Como script (imprime JSON a stdout):
|
||||
|
||||
```bash
|
||||
python/.venv/bin/python3 python/functions/infra/discover_local_services.py apps/local_hub/local_services.yaml
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala como fase de descubrimiento del sistema `local_hub` antes de renderizar el
|
||||
Caddyfile o la config de Glance: cuando necesites la lista normalizada de servicios
|
||||
locales (`*.localhost`) con su estado up/down resuelto. También cuando quieras un
|
||||
inventario unificado de servicios manuales (contenedores, daemons de terceros) más
|
||||
los servicios del registry con puerto, deduplicados.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `up` se decide por **conexión TCP** a `127.0.0.1:<port>` con timeout 0.5s, NO por
|
||||
GET HTTP. Un servicio puede aceptar la conexión y devolver 404/500 en `/` y aun
|
||||
así marcar `up=True`. Es intencional: solo valida que el puerto esté escuchando.
|
||||
- Solo comprueba `127.0.0.1` (loopback). Servicios que bindean únicamente a otra
|
||||
interfaz se reportan como `down`.
|
||||
- `include_registry=True` ejecuta `fn doctor services-spec --json` (fallback a
|
||||
`services --json`) como subproceso desde la raíz del repo. Si `fn` no está, falla,
|
||||
tarda más de 20s o devuelve JSON inválido, la función **no lanza**: sigue solo con
|
||||
el manifiesto. Por eso el resultado puede variar según el entorno.
|
||||
- La raíz del repo se resuelve por `FN_REGISTRY_ROOT` o subiendo directorios hasta
|
||||
encontrar `registry.db`. Si no la encuentra, usa el cwd.
|
||||
- El dedup del registry es por `port` Y por `subdomain`: un servicio del registry
|
||||
cuyo puerto o subdominio derivado ya esté en el manifiesto se omite.
|
||||
- El subdominio de un servicio del registry se deriva por una tabla de alias
|
||||
(`dag_engine`->`dag`, `registry_api`->`registry`, `sqlite_api`->`sqlite`,
|
||||
`osint_db`->`osint`, ...) y, para el resto, el primer token antes de `_`.
|
||||
- Lanza `RuntimeError` solo si el manifiesto no se puede leer o parsear (path
|
||||
inexistente, YAML inválido). Eso sí es un error duro.
|
||||
- La clave `rewrite_host` es passthrough del manifiesto (default `False`); para
|
||||
los servicios añadidos desde el registry siempre es `False`. La consume
|
||||
`render_caddyfile` para emitir `header_up Host` en el bloque del servicio.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-20) — añade clave rewrite_host (passthrough del manifiesto) para que render_caddyfile reescriba el Host
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Descubre servicios locales expuestos como subdominios ``*.localhost``.
|
||||
|
||||
Fase de descubrimiento del sistema ``local_hub`` (reverse proxy Caddy +
|
||||
dashboard Glance). Lee el manifiesto YAML de servicios, normaliza la metadata
|
||||
de cada uno, opcionalmente añade los servicios del registry con puerto, y
|
||||
comprueba si cada servicio está vivo (puerto TCP aceptando conexiones).
|
||||
|
||||
La función es robusta: nunca lanza por un servicio caído (lo marca ``up=False``)
|
||||
ni por un fallo de ``fn doctor`` (captura y continúa solo con el manifiesto).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
# Alias para derivar el subdominio de un servicio del registry a partir de su
|
||||
# nombre. Para los nombres no listados se usa el primer token antes de "_".
|
||||
_SUBDOMAIN_ALIAS = {
|
||||
"dag_engine": "dag",
|
||||
"registry_api": "registry",
|
||||
"sqlite_api": "sqlite",
|
||||
"osint_db": "osint",
|
||||
"services_api": "services",
|
||||
"web_proxy": "proxy",
|
||||
}
|
||||
|
||||
|
||||
def _is_port_up(port: int) -> bool:
|
||||
"""Devuelve True si 127.0.0.1:<port> acepta conexiones TCP.
|
||||
|
||||
Comprobación pura de puerto (no HTTP): algunos servicios no responden 200
|
||||
en ``/`` pero sí aceptan la conexión. Timeout corto para no bloquear.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", int(port)), timeout=0.5):
|
||||
return True
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_manifest_service(svc: dict) -> dict:
|
||||
"""Normaliza un servicio del manifiesto a todas las claves esperadas."""
|
||||
name = str(svc.get("name", "")).strip()
|
||||
subdomain = str(svc.get("subdomain", "") or name).strip()
|
||||
try:
|
||||
port = int(svc.get("port", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
port = 0
|
||||
return {
|
||||
"name": name,
|
||||
"subdomain": subdomain,
|
||||
"port": port,
|
||||
"health_path": str(svc.get("health_path") or "/"),
|
||||
"title": str(svc.get("title") or name),
|
||||
"icon": str(svc.get("icon") or ""),
|
||||
"category": str(svc.get("category") or "Otros"),
|
||||
"rewrite_host": bool(svc.get("rewrite_host", False)),
|
||||
"up": _is_port_up(port) if port > 0 else False,
|
||||
}
|
||||
|
||||
|
||||
def _is_registry_root(path: str) -> bool:
|
||||
"""Una raíz válida del registry tiene registry.db Y el paquete cmd/fn.
|
||||
|
||||
El doble marcador evita falsos positivos como un registry.db vacío y
|
||||
espurio dentro de python/ (la regla db_locations exige que registry.db
|
||||
solo viva en la raíz, pero protegemos por si acaso).
|
||||
"""
|
||||
return os.path.isfile(os.path.join(path, "registry.db")) and os.path.isdir(
|
||||
os.path.join(path, "cmd", "fn")
|
||||
)
|
||||
|
||||
|
||||
def _find_registry_root() -> str:
|
||||
"""Localiza la raíz del registry (FN_REGISTRY_ROOT o subiendo hasta la raíz real)."""
|
||||
root = os.environ.get("FN_REGISTRY_ROOT")
|
||||
if root and _is_registry_root(root):
|
||||
return root
|
||||
cur = os.path.abspath(os.path.dirname(__file__))
|
||||
while True:
|
||||
if _is_registry_root(cur):
|
||||
return cur
|
||||
parent = os.path.dirname(cur)
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _derive_subdomain(name: str) -> str:
|
||||
"""Deriva un subdominio del nombre de un servicio del registry."""
|
||||
name = (name or "").strip()
|
||||
if name in _SUBDOMAIN_ALIAS:
|
||||
return _SUBDOMAIN_ALIAS[name]
|
||||
return name.split("_", 1)[0] if name else name
|
||||
|
||||
|
||||
def _fetch_registry_services(root: str) -> list[dict]:
|
||||
"""Obtiene los servicios del registry con puerto via ``fn doctor``.
|
||||
|
||||
Intenta ``fn doctor services-spec --json`` y cae a ``services --json``.
|
||||
Devuelve lista vacía si ambos fallan (la función sigue con el manifiesto).
|
||||
"""
|
||||
fn_bin = os.path.join(root, "fn")
|
||||
cmd_base = [fn_bin] if os.path.isfile(fn_bin) else ["fn"]
|
||||
for sub in ("services-spec", "services"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd_base + ["doctor", sub, "--json"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
for key in ("services", "items", "results"):
|
||||
if isinstance(data.get(key), list):
|
||||
data = data[key]
|
||||
break
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def discover_local_services(manifest_path: str, include_registry: bool = True) -> list[dict]:
|
||||
"""Descubre y normaliza los servicios locales del manifiesto local_hub.
|
||||
|
||||
Args:
|
||||
manifest_path: ruta al manifiesto YAML (``apps/local_hub/local_services.yaml``).
|
||||
include_registry: si True añade los servicios del registry con puerto>0 que
|
||||
no estén ya en el manifiesto (dedup por port y por subdomain).
|
||||
|
||||
Returns:
|
||||
Lista de dicts normalizados, cada uno con las claves: name, subdomain, port,
|
||||
health_path, title, icon, category, rewrite_host, up. La clave
|
||||
``rewrite_host`` (bool) es passthrough del manifiesto (default ``False``;
|
||||
siempre ``False`` para los servicios añadidos desde el registry) y la
|
||||
consume ``render_caddyfile`` para reescribir el header ``Host`` del
|
||||
upstream en servicios que lo validan (ej. Jupyter).
|
||||
"""
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as fh:
|
||||
manifest = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise RuntimeError(f"discover_local_services: cannot read manifest {manifest_path}: {exc}") from exc
|
||||
|
||||
raw_services = manifest.get("services") or []
|
||||
result: list[dict] = []
|
||||
seen_ports: set[int] = set()
|
||||
seen_subdomains: set[str] = set()
|
||||
|
||||
for svc in raw_services:
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
norm = _normalize_manifest_service(svc)
|
||||
result.append(norm)
|
||||
if norm["port"] > 0:
|
||||
seen_ports.add(norm["port"])
|
||||
if norm["subdomain"]:
|
||||
seen_subdomains.add(norm["subdomain"])
|
||||
|
||||
if include_registry:
|
||||
try:
|
||||
root = _find_registry_root()
|
||||
for svc in _fetch_registry_services(root):
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
try:
|
||||
port = int(svc.get("port", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
port = 0
|
||||
if port <= 0 or port in seen_ports:
|
||||
continue
|
||||
name = str(svc.get("name", "")).strip()
|
||||
subdomain = _derive_subdomain(name)
|
||||
if subdomain in seen_subdomains:
|
||||
continue
|
||||
result.append({
|
||||
"name": name,
|
||||
"subdomain": subdomain,
|
||||
"port": port,
|
||||
"health_path": str(svc.get("health_endpoint") or "/"),
|
||||
"title": name,
|
||||
"icon": "",
|
||||
"category": "Registry",
|
||||
"rewrite_host": False,
|
||||
"up": _is_port_up(port),
|
||||
})
|
||||
seen_ports.add(port)
|
||||
seen_subdomains.add(subdomain)
|
||||
except Exception:
|
||||
# fn doctor opcional: si algo falla, seguimos solo con el manifiesto.
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "apps/local_hub/local_services.yaml"
|
||||
services = discover_local_services(path)
|
||||
print(json.dumps(services, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests para discover_local_services."""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
# El modulo hoja se importa por su nombre directo; aseguramos que su directorio
|
||||
# esta en sys.path para poder correr el test desde cualquier cwd.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from discover_local_services import discover_local_services
|
||||
|
||||
NORMALIZED_KEYS = {
|
||||
"name", "subdomain", "port", "health_path", "title", "icon", "category",
|
||||
"rewrite_host", "up",
|
||||
}
|
||||
|
||||
|
||||
def _write_manifest(tmp_path, services):
|
||||
manifest = {
|
||||
"dashboard_subdomain": "home",
|
||||
"glance_port": 8585,
|
||||
"services": services,
|
||||
}
|
||||
path = tmp_path / "local_services.yaml"
|
||||
path.write_text(yaml.safe_dump(manifest), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_golden_service_up_with_all_keys(tmp_path):
|
||||
# Abrimos un socket real en un puerto efímero para simular un servicio vivo.
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
port = listener.getsockname()[1]
|
||||
try:
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{
|
||||
"name": "metabase",
|
||||
"subdomain": "metabase",
|
||||
"port": port,
|
||||
"health_path": "/api/health",
|
||||
"title": "Metabase",
|
||||
"icon": "si:metabase",
|
||||
"category": "Datos",
|
||||
},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
assert len(result) == 1
|
||||
svc = result[0]
|
||||
# Todas las claves normalizadas presentes.
|
||||
assert set(svc.keys()) == NORMALIZED_KEYS
|
||||
assert svc["up"] is True
|
||||
assert svc["name"] == "metabase"
|
||||
assert svc["port"] == port
|
||||
assert svc["health_path"] == "/api/health"
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
|
||||
def test_edge_closed_port_is_down(tmp_path):
|
||||
# Tomamos un puerto efímero y lo cerramos inmediatamente -> debe estar down.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
closed_port = s.getsockname()[1]
|
||||
s.close()
|
||||
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{
|
||||
"name": "ghost",
|
||||
"subdomain": "ghost",
|
||||
"port": closed_port,
|
||||
"health_path": "/",
|
||||
"title": "Ghost",
|
||||
"icon": "",
|
||||
"category": "Otros",
|
||||
},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
assert len(result) == 1
|
||||
assert result[0]["up"] is False
|
||||
|
||||
|
||||
def test_defaults_derived_for_missing_fields(tmp_path):
|
||||
# Servicio mínimo: solo name + port. El resto debe derivarse con defaults.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
closed_port = s.getsockname()[1]
|
||||
s.close()
|
||||
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{"name": "barebones", "port": closed_port},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
svc = result[0]
|
||||
assert set(svc.keys()) == NORMALIZED_KEYS
|
||||
assert svc["title"] == "barebones" # derivado de name
|
||||
assert svc["icon"] == "" # default
|
||||
assert svc["category"] == "Otros" # default
|
||||
assert svc["health_path"] == "/" # default
|
||||
assert svc["subdomain"] == "barebones" # derivado de name
|
||||
assert svc["up"] is False
|
||||
|
||||
|
||||
def test_empty_manifest_returns_empty_list(tmp_path):
|
||||
manifest = _write_manifest(tmp_path, [])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_rewrite_host_passthrough_desde_manifiesto(tmp_path):
|
||||
# Un servicio con rewrite_host: true en el manifiesto debe propagar
|
||||
# rewrite_host == True; uno sin la clave debe dar rewrite_host == False.
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
port = listener.getsockname()[1]
|
||||
try:
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{
|
||||
"name": "jupyter",
|
||||
"subdomain": "jupyter",
|
||||
"port": port,
|
||||
"rewrite_host": True,
|
||||
},
|
||||
{
|
||||
"name": "metabase",
|
||||
"subdomain": "metabase",
|
||||
"port": port,
|
||||
# sin clave rewrite_host -> default False
|
||||
},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
by_name = {s["name"]: s for s in result}
|
||||
assert by_name["jupyter"]["rewrite_host"] is True
|
||||
assert by_name["metabase"]["rewrite_host"] is False
|
||||
finally:
|
||||
listener.close()
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: render_caddyfile
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def render_caddyfile(services: list[dict], dashboard: dict | None = None) -> str"
|
||||
description: "Parte del sistema local_hub: transforma una lista de servicios normalizados en el texto de un fragmento de Caddyfile que mapea cada subdominio *.localhost a su puerto local via reverse_proxy HTTP plano (loopback, sin TLS). Cada servicio es un dict con subdomain (str) y port (int); el resto de claves se ignoran. Los bloques de servicio se ordenan por subdominio alfabetico para que la salida sea estable y reproducible (clave para diffs y tests). Un dashboard opcional emite su bloque PRIMERO porque es la pagina principal. Ignora servicios sin subdomain o sin port (los salta, no lanza) y no deduplica. Pura: solo stdlib, sin I/O ni red, determinista."
|
||||
tags: [local-hub, caddy, caddyfile, reverse-proxy, infra, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: services
|
||||
desc: "lista de dicts de servicio normalizados. Cada uno debe tener al menos subdomain (str, sin el sufijo .localhost) y port (int, puerto local). Otras claves se ignoran. Los servicios sin subdomain o sin port se saltan silenciosamente. No se deduplica: eso es trabajo del discover."
|
||||
- name: dashboard
|
||||
desc: "dict opcional {subdomain, port} para la pagina principal del hub (ej. {\"subdomain\": \"home\", \"port\": 8585}). Si se pasa, su bloque va el primero de la salida. None = no se emite bloque de dashboard. Si le falta subdomain o port, se ignora igual que un servicio invalido."
|
||||
output: "string con el Caddyfile completo: empieza por una cabecera de comentario (# Generado por render_caddyfile_py_infra ...), luego el bloque del dashboard si aplica, y despues los bloques de servicio ordenados alfabeticamente por subdominio. Cada bloque es 'http://<subdomain>.localhost {\\n reverse_proxy 127.0.0.1:<port>\\n}\\n' con 4 espacios de indentacion. La salida termina con un unico \\n."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_dos_servicios_ordenados"
|
||||
- "test_dashboard_va_primero"
|
||||
- "test_lista_vacia_solo_cabecera"
|
||||
- "test_servicio_sin_port_se_ignora"
|
||||
- "test_servicio_sin_subdomain_se_ignora"
|
||||
- "test_rewrite_host_emite_header_up"
|
||||
- "test_rewrite_host_ausente_o_falso_no_reescribe"
|
||||
test_file_path: "python/functions/infra/render_caddyfile_test.py"
|
||||
file_path: "python/functions/infra/render_caddyfile.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions", "infra"))
|
||||
from render_caddyfile import render_caddyfile
|
||||
|
||||
services = [
|
||||
{"subdomain": "metabase", "port": 3030},
|
||||
{"subdomain": "grafana", "port": 3000},
|
||||
]
|
||||
dashboard = {"subdomain": "home", "port": 8585}
|
||||
|
||||
print(render_caddyfile(services, dashboard=dashboard))
|
||||
# # Generado por render_caddyfile_py_infra — NO editar a mano. Fuente: apps/local_hub/local_services.yaml
|
||||
# http://home.localhost {
|
||||
# reverse_proxy 127.0.0.1:8585
|
||||
# }
|
||||
# http://grafana.localhost {
|
||||
# reverse_proxy 127.0.0.1:3000
|
||||
# }
|
||||
# http://metabase.localhost {
|
||||
# reverse_proxy 127.0.0.1:3030
|
||||
# }
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando, dentro del sistema local_hub, ya tienes la lista de servicios locales
|
||||
normalizada (cada uno con su `subdomain` y `port`) y necesitas materializar el
|
||||
fragmento de Caddyfile que enruta `*.localhost` a sus puertos. Usala justo
|
||||
antes de escribir el archivo a disco y recargar Caddy: esta funcion solo
|
||||
produce el texto (pura), el I/O y el reload van en una funcion impura o pipeline
|
||||
aparte. Tambien util para tests/diffs porque la salida es determinista (bloques
|
||||
ordenados por subdominio).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El formato es HTTP plano a proposito (`http://...`, sin TLS): todo el trafico
|
||||
es loopback (`127.0.0.1`), no hay nada que cifrar y `*.localhost` no necesita
|
||||
certificado. No es un bug.
|
||||
- No deduplica subdominios: si dos servicios comparten `subdomain`, ambos
|
||||
bloques se emiten y Caddy se quedara con el ultimo. La deduplicacion es
|
||||
responsabilidad del discover que produce `services`.
|
||||
- `rewrite_host` solo cambia la cabecera `Host` que ve el upstream, no la URL
|
||||
que abre el usuario. Actívalo unicamente para servicios que validan el header
|
||||
y rechazan el subdominio (Jupyter devuelve 400, algunos FastAPI/uvicorn con
|
||||
`--forwarded-allow-ips` estricto). Para el resto dejalo ausente/False: añadir
|
||||
`header_up Host` sin necesidad puede romper virtual-hosting del upstream.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-20) — añade soporte rewrite_host (header_up Host) para servicios que validan el header Host
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Renderiza un fragmento de Caddyfile para el sistema local_hub.
|
||||
|
||||
Funcion pura: transforma una lista de servicios normalizados en el texto de un
|
||||
Caddyfile que mapea cada subdominio `*.localhost` a su puerto local via
|
||||
reverse_proxy HTTP plano (loopback, sin TLS). Sin I/O, sin red, determinista.
|
||||
"""
|
||||
|
||||
_HEADER = (
|
||||
"# Generado por render_caddyfile_py_infra — NO editar a mano. "
|
||||
"Fuente: apps/local_hub/local_services.yaml\n"
|
||||
)
|
||||
|
||||
|
||||
def _render_block(subdomain: str, port: int, rewrite_host: bool = False) -> str:
|
||||
"""Construye un bloque reverse_proxy para un subdominio y puerto dados.
|
||||
|
||||
Args:
|
||||
subdomain: subdominio sin el sufijo `.localhost` (ej. "metabase").
|
||||
port: puerto local al que redirigir (ej. 3030).
|
||||
rewrite_host: si es True, reescribe la cabecera `Host` enviada al
|
||||
upstream a `127.0.0.1:<port>`. Necesario para servicios que validan
|
||||
el header Host y rechazan el subdominio (ej. Jupyter devuelve 400
|
||||
"Bad Request" si recibe `Host: jupyter.localhost`).
|
||||
|
||||
Returns:
|
||||
el bloque Caddyfile como string, con indentacion de 4 espacios y
|
||||
terminado en `\n`.
|
||||
"""
|
||||
if rewrite_host:
|
||||
return (
|
||||
f"http://{subdomain}.localhost {{\n"
|
||||
f" reverse_proxy 127.0.0.1:{port} {{\n"
|
||||
f" header_up Host 127.0.0.1:{port}\n"
|
||||
f" }}\n"
|
||||
f"}}\n"
|
||||
)
|
||||
return (
|
||||
f"http://{subdomain}.localhost {{\n"
|
||||
f" reverse_proxy 127.0.0.1:{port}\n"
|
||||
f"}}\n"
|
||||
)
|
||||
|
||||
|
||||
def render_caddyfile(services: list[dict], dashboard: dict | None = None) -> str:
|
||||
"""Renderiza el texto de un fragmento de Caddyfile para local_hub.
|
||||
|
||||
Cada servicio se mapea a un bloque `http://<subdomain>.localhost` con un
|
||||
`reverse_proxy 127.0.0.1:<port>`. Los bloques de servicio se ordenan por
|
||||
subdominio alfabetico para que la salida sea estable y reproducible. El
|
||||
bloque del dashboard, si se pasa, va siempre primero (es la pagina
|
||||
principal). Se usa HTTP plano a proposito: todo es loopback, no hay TLS.
|
||||
|
||||
Args:
|
||||
services: lista de dicts de servicio. Cada uno debe tener al menos
|
||||
`subdomain` (str) y `port` (int); otras claves se ignoran salvo
|
||||
`rewrite_host` (bool opcional): si es truthy, el bloque reescribe la
|
||||
cabecera `Host` enviada al upstream a `127.0.0.1:<port>` (para
|
||||
servicios que validan Host, ej. Jupyter). Los servicios sin
|
||||
`subdomain` o sin `port` se saltan (no lanzan error). No se
|
||||
deduplica (eso es trabajo del discover).
|
||||
dashboard: dict opcional con `subdomain` y `port` para la pagina
|
||||
principal. Si es None, no se emite bloque de dashboard. Si le falta
|
||||
`subdomain` o `port`, se ignora igual que un servicio invalido.
|
||||
|
||||
Returns:
|
||||
el Caddyfile completo como string: empieza por una cabecera de
|
||||
comentario, luego (si aplica) el bloque del dashboard, y despues los
|
||||
bloques de servicio ordenados. Termina con un unico `\n`.
|
||||
"""
|
||||
parts: list[str] = [_HEADER]
|
||||
|
||||
if dashboard is not None:
|
||||
d_sub = dashboard.get("subdomain")
|
||||
d_port = dashboard.get("port")
|
||||
if d_sub is not None and d_port is not None:
|
||||
parts.append(_render_block(d_sub, d_port))
|
||||
|
||||
valid = [
|
||||
svc
|
||||
for svc in services
|
||||
if svc.get("subdomain") is not None and svc.get("port") is not None
|
||||
]
|
||||
for svc in sorted(valid, key=lambda s: s["subdomain"]):
|
||||
parts.append(
|
||||
_render_block(svc["subdomain"], svc["port"], bool(svc.get("rewrite_host")))
|
||||
)
|
||||
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests para render_caddyfile."""
|
||||
|
||||
from render_caddyfile import render_caddyfile
|
||||
|
||||
HEADER = (
|
||||
"# Generado por render_caddyfile_py_infra — NO editar a mano. "
|
||||
"Fuente: apps/local_hub/local_services.yaml\n"
|
||||
)
|
||||
|
||||
|
||||
def test_golden_dos_servicios_ordenados():
|
||||
services = [
|
||||
{"subdomain": "metabase", "port": 3030},
|
||||
{"subdomain": "grafana", "port": 3000},
|
||||
]
|
||||
result = render_caddyfile(services)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://grafana.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:3000\n"
|
||||
+ "}\n"
|
||||
+ "http://metabase.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:3030\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
# Orden alfabetico: grafana antes que metabase pese al orden de entrada.
|
||||
assert result.index("grafana.localhost") < result.index("metabase.localhost")
|
||||
# Termina con un unico newline.
|
||||
assert result.endswith("}\n")
|
||||
assert not result.endswith("\n\n")
|
||||
|
||||
|
||||
def test_dashboard_va_primero():
|
||||
services = [{"subdomain": "metabase", "port": 3030}]
|
||||
dashboard = {"subdomain": "home", "port": 8585}
|
||||
result = render_caddyfile(services, dashboard=dashboard)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://home.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:8585\n"
|
||||
+ "}\n"
|
||||
+ "http://metabase.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:3030\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
# El dashboard aparece antes que cualquier servicio.
|
||||
assert result.index("home.localhost") < result.index("metabase.localhost")
|
||||
|
||||
|
||||
def test_lista_vacia_solo_cabecera():
|
||||
result = render_caddyfile([])
|
||||
assert result == HEADER
|
||||
|
||||
|
||||
def test_servicio_sin_port_se_ignora():
|
||||
services = [
|
||||
{"subdomain": "valido", "port": 9000},
|
||||
{"subdomain": "sin_port"},
|
||||
]
|
||||
result = render_caddyfile(services)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://valido.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:9000\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
assert "sin_port" not in result
|
||||
|
||||
|
||||
def test_servicio_sin_subdomain_se_ignora():
|
||||
services = [
|
||||
{"subdomain": "valido", "port": 9000},
|
||||
{"port": 1234},
|
||||
]
|
||||
result = render_caddyfile(services)
|
||||
assert "1234" not in result
|
||||
assert result.count("reverse_proxy") == 1
|
||||
|
||||
|
||||
def test_rewrite_host_emite_header_up():
|
||||
services = [{"subdomain": "jupyter", "port": 8888, "rewrite_host": True}]
|
||||
result = render_caddyfile(services)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://jupyter.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:8888 {\n"
|
||||
+ " header_up Host 127.0.0.1:8888\n"
|
||||
+ " }\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rewrite_host_ausente_o_falso_no_reescribe():
|
||||
# Sin la clave -> bloque simple.
|
||||
assert "header_up" not in render_caddyfile([{"subdomain": "a", "port": 1}])
|
||||
# Con rewrite_host falsy -> bloque simple.
|
||||
assert "header_up" not in render_caddyfile(
|
||||
[{"subdomain": "a", "port": 1, "rewrite_host": False}]
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: render_glance_config
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "render_glance_config(services: list[dict], title: str = \"Procesos locales\", host_suffix: str = \"localhost\") -> str"
|
||||
description: "Transforma una lista de servicios normalizados en el YAML de configuración de Glance (dashboard self-hosted). Genera una página con un widget monitor por categoría que hace health-check de cada servicio y lo pinta verde/rojo. Función pura y determinista. Parte del sistema local_hub."
|
||||
tags: [local-hub, infra, glance, dashboard, yaml, config]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [pyyaml]
|
||||
params:
|
||||
- name: services
|
||||
desc: "Lista de dicts de servicio normalizados. Cada uno requiere 'subdomain' (si falta, el servicio se ignora sin lanzar) y 'title'; opcional 'category' (default 'General'), 'icon' (se omite del site si está vacío o ausente) y 'health_path' (ruta de salud del servicio: si es distinta de '/', el site emite 'check-url' = url+health_path para que Glance haga el health-check ahí; si es '/' o falta, no se emite check-url)."
|
||||
- name: title
|
||||
desc: "Nombre de la página de Glance (campo 'name' de la página). Default 'Procesos locales'."
|
||||
- name: host_suffix
|
||||
desc: "Sufijo de host para las URLs de los sites. Default 'localhost' -> 'http://<subdomain>.localhost'."
|
||||
output: "String con el YAML completo de configuración de Glance (cabecera de comentario + pages/columns/widgets), terminado en '\\n'. Parseable con yaml.safe_load. Cada site lleva 'title' y 'url' (raíz del subdominio); además 'check-url' (url+health_path) cuando el servicio trae un health_path distinto de '/', e 'icon' cuando no está vacío."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_dos_categorias_dos_widgets"
|
||||
- "test_yaml_parseable_y_estructura"
|
||||
- "test_icon_omitido_cuando_vacio"
|
||||
- "test_host_suffix_custom"
|
||||
- "test_title_es_name_de_pagina"
|
||||
- "test_servicios_sin_subdomain_se_ignoran"
|
||||
- "test_determinismo"
|
||||
- "test_orden_sites_por_title"
|
||||
- "test_categoria_default_general"
|
||||
- "test_check_url_se_emite_cuando_health_path_no_es_raiz"
|
||||
test_file_path: "python/functions/infra/render_glance_config_test.py"
|
||||
file_path: "python/functions/infra/render_glance_config.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.render_glance_config import render_glance_config
|
||||
|
||||
services = [
|
||||
{"subdomain": "metabase", "title": "Metabase", "icon": "si:metabase", "category": "Datos"},
|
||||
{"subdomain": "jupyter", "title": "Jupyter Lab", "icon": "si:jupyter", "category": "Datos"},
|
||||
{"subdomain": "portainer","title": "Portainer", "icon": "si:portainer", "category": "Infra"},
|
||||
]
|
||||
|
||||
yaml_text = render_glance_config(services, title="Inicio")
|
||||
print(yaml_text)
|
||||
# pages:
|
||||
# - name: Inicio
|
||||
# columns:
|
||||
# - size: full
|
||||
# widgets:
|
||||
# - type: monitor
|
||||
# title: Datos
|
||||
# cache: 1m
|
||||
# sites:
|
||||
# - title: Jupyter Lab # ordenado por title dentro de la categoría
|
||||
# url: http://jupyter.localhost
|
||||
# icon: si:jupyter
|
||||
# - title: Metabase
|
||||
# url: http://metabase.localhost
|
||||
# icon: si:metabase
|
||||
# - type: monitor
|
||||
# title: Infra
|
||||
# cache: 1m
|
||||
# sites:
|
||||
# - title: Portainer
|
||||
# url: http://portainer.localhost
|
||||
# icon: si:portainer
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites regenerar el `glance.yml` del dashboard local a partir de
|
||||
`apps/local_hub/local_services.yaml`: tras añadir/quitar un servicio local, o
|
||||
en el pipeline `refresh_local_hub` que corre diario via dag_engine. La salida se
|
||||
escribe al archivo de config de Glance (el borde impuro: I/O lo hace el caller).
|
||||
|
||||
## Notas
|
||||
|
||||
- **Decisión `title` -> `name`:** el parámetro `title` se usa como el campo `name`
|
||||
de la (única) página de Glance. No es un comentario de cabecera ni se ignora.
|
||||
Default `"Procesos locales"`. Así la firma queda útil sin añadir un parámetro
|
||||
extra para el nombre de página.
|
||||
- **Determinismo:** las categorías se ordenan alfabéticamente y los servicios de
|
||||
cada categoría por `title` (desempate por `subdomain`). Se serializa con
|
||||
`yaml.safe_dump(sort_keys=False)` sobre estructuras ya ordenadas, por lo que la
|
||||
misma entrada (en cualquier orden) produce siempre la misma salida byte a byte.
|
||||
- **Robustez:** los servicios sin `subdomain` se ignoran silenciosamente (no se
|
||||
lanza). El `icon` se omite del site cuando está vacío o ausente. Cada categoría
|
||||
produce un widget `type: monitor` con `cache: 1m`; todos los widgets van en una
|
||||
sola columna `size: full`.
|
||||
- **Función pura:** sin I/O, sin estado, determinista. El health-check real lo
|
||||
hace Glance en runtime (GET a `check-url` si existe, si no a `url`); esta
|
||||
función solo genera el texto.
|
||||
- **`check-url` vs `url`:** `url` es siempre la raíz del subdominio (lo que abre
|
||||
el usuario al clicar). `check-url` solo aparece cuando el servicio trae un
|
||||
`health_path` distinto de `/`, y vale `url + health_path`. Sirve para APIs que
|
||||
devuelven 404 en `/` pero 200 en su ruta de salud (ej. `/api/health`), de modo
|
||||
que Glance las pinta verde sin cambiar el enlace navegable.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-20) — añade check-url (health_path) por site para health-check preciso de APIs sin ruta raíz
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Renderiza la configuración YAML de Glance a partir de servicios normalizados.
|
||||
|
||||
Glance (https://github.com/glanceapp/glance) es un dashboard self-hosted. Este
|
||||
módulo transforma una lista de servicios en el YAML que Glance espera: una página
|
||||
con un widget `monitor` por categoría que hace health-check de cada servicio y lo
|
||||
pinta verde/rojo. Parte del sistema `local_hub`.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
|
||||
_HEADER = (
|
||||
"# Generado por render_glance_config_py_infra — NO editar a mano. "
|
||||
"Fuente: apps/local_hub/local_services.yaml\n"
|
||||
)
|
||||
|
||||
|
||||
def render_glance_config(
|
||||
services: list[dict],
|
||||
title: str = "Procesos locales",
|
||||
host_suffix: str = "localhost",
|
||||
) -> str:
|
||||
"""Construye el YAML de configuración de Glance para una lista de servicios.
|
||||
|
||||
Función pura y determinista: agrupa los servicios por su clave ``category``,
|
||||
crea un widget ``type: monitor`` por categoría (ordenadas alfabéticamente) y
|
||||
dentro de cada uno un site por servicio (ordenados por ``title``). Cada site
|
||||
apunta a ``http://<subdomain>.<host_suffix>`` (campo ``url``, lo que abre el
|
||||
usuario al clicar). Si el servicio trae un ``health_path`` distinto de
|
||||
``"/"``, el site añade además ``check-url`` = ``url + health_path``: es la
|
||||
ruta que Glance usa para el health-check (muchas APIs dan 404 en ``/`` pero
|
||||
200 en ``/api/health``), sin cambiar el enlace que ve el usuario.
|
||||
|
||||
Args:
|
||||
services: lista de dicts de servicio normalizados. Cada uno debe traer al
|
||||
menos ``subdomain`` (si falta, el servicio se ignora sin lanzar) y
|
||||
``title``; opcionalmente ``category`` (default ``"General"``),
|
||||
``icon`` (se omite del site si está vacío o ausente) y ``health_path``
|
||||
(si es distinto de ``"/"``, el site emite ``check-url`` = url +
|
||||
health_path; si es ``"/"`` o falta, no se emite ``check-url``).
|
||||
title: nombre de la página de Glance (campo ``name`` de la página).
|
||||
Default ``"Procesos locales"``.
|
||||
host_suffix: sufijo de host para las URLs de los sites. Default
|
||||
``"localhost"`` -> ``http://<subdomain>.localhost``.
|
||||
|
||||
Returns:
|
||||
String con el YAML completo de Glance, terminado en ``\\n``.
|
||||
"""
|
||||
# Agrupa por categoría, ignorando servicios sin subdomain.
|
||||
by_category: dict[str, list[dict]] = {}
|
||||
for svc in services:
|
||||
subdomain = svc.get("subdomain")
|
||||
if not subdomain:
|
||||
continue
|
||||
category = svc.get("category") or "General"
|
||||
by_category.setdefault(category, []).append(svc)
|
||||
|
||||
widgets: list[dict] = []
|
||||
for category in sorted(by_category.keys()):
|
||||
svcs = sorted(
|
||||
by_category[category],
|
||||
key=lambda s: (s.get("title") or "", s.get("subdomain") or ""),
|
||||
)
|
||||
sites: list[dict] = []
|
||||
for svc in svcs:
|
||||
url = f"http://{svc['subdomain']}.{host_suffix}"
|
||||
site: dict = {
|
||||
"title": svc.get("title") or svc["subdomain"],
|
||||
"url": url,
|
||||
}
|
||||
# El health-check apunta al health_path del servicio (no a "/").
|
||||
# Muchas APIs devuelven 404 en la raiz pero 200 en su ruta de salud
|
||||
# (ej. /api/health), asi Glance las pinta verde correctamente. El
|
||||
# campo `url` (lo que abre el usuario al clicar) sigue siendo la raiz.
|
||||
health = svc.get("health_path") or "/"
|
||||
if health and health != "/":
|
||||
site["check-url"] = url + health
|
||||
icon = svc.get("icon")
|
||||
if icon:
|
||||
site["icon"] = icon
|
||||
sites.append(site)
|
||||
widgets.append(
|
||||
{
|
||||
"type": "monitor",
|
||||
"title": category,
|
||||
"cache": "1m",
|
||||
"sites": sites,
|
||||
}
|
||||
)
|
||||
|
||||
config = {
|
||||
"pages": [
|
||||
{
|
||||
"name": title,
|
||||
"columns": [
|
||||
{
|
||||
"size": "full",
|
||||
"widgets": widgets,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
body = yaml.safe_dump(
|
||||
config,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
return _HEADER + body
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests para render_glance_config."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from functions.infra.render_glance_config import render_glance_config
|
||||
|
||||
SERVICES = [
|
||||
{
|
||||
"name": "metabase",
|
||||
"subdomain": "metabase",
|
||||
"title": "Metabase",
|
||||
"icon": "si:metabase",
|
||||
"category": "Datos",
|
||||
},
|
||||
{
|
||||
"name": "portainer",
|
||||
"subdomain": "portainer",
|
||||
"title": "Portainer",
|
||||
"icon": "", # icon vacío -> debe omitirse
|
||||
"category": "Infra",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_golden_dos_categorias_dos_widgets():
|
||||
out = render_glance_config(SERVICES)
|
||||
cfg = yaml.safe_load(out)
|
||||
|
||||
widgets = cfg["pages"][0]["columns"][0]["widgets"]
|
||||
assert len(widgets) == 2
|
||||
assert all(w["type"] == "monitor" for w in widgets)
|
||||
|
||||
# Categorías ordenadas alfabéticamente: Datos antes que Infra.
|
||||
assert [w["title"] for w in widgets] == ["Datos", "Infra"]
|
||||
|
||||
datos_site = widgets[0]["sites"][0]
|
||||
assert datos_site["title"] == "Metabase"
|
||||
assert datos_site["url"] == "http://metabase.localhost"
|
||||
assert datos_site["icon"] == "si:metabase"
|
||||
|
||||
|
||||
def test_yaml_parseable_y_estructura():
|
||||
out = render_glance_config(SERVICES)
|
||||
cfg = yaml.safe_load(out) # no debe lanzar
|
||||
page = cfg["pages"][0]
|
||||
assert page["name"] == "Procesos locales"
|
||||
col = page["columns"][0]
|
||||
assert col["size"] == "full"
|
||||
assert isinstance(col["widgets"], list)
|
||||
assert out.endswith("\n")
|
||||
|
||||
|
||||
def test_icon_omitido_cuando_vacio():
|
||||
out = render_glance_config(SERVICES)
|
||||
cfg = yaml.safe_load(out)
|
||||
infra_site = cfg["pages"][0]["columns"][0]["widgets"][1]["sites"][0]
|
||||
assert infra_site["title"] == "Portainer"
|
||||
assert "icon" not in infra_site
|
||||
|
||||
|
||||
def test_host_suffix_custom():
|
||||
out = render_glance_config(SERVICES, host_suffix="home.lan")
|
||||
cfg = yaml.safe_load(out)
|
||||
site = cfg["pages"][0]["columns"][0]["widgets"][0]["sites"][0]
|
||||
assert site["url"] == "http://metabase.home.lan"
|
||||
|
||||
|
||||
def test_title_es_name_de_pagina():
|
||||
out = render_glance_config(SERVICES, title="Mi Hub")
|
||||
cfg = yaml.safe_load(out)
|
||||
assert cfg["pages"][0]["name"] == "Mi Hub"
|
||||
|
||||
|
||||
def test_servicios_sin_subdomain_se_ignoran():
|
||||
services = SERVICES + [{"name": "roto", "title": "Roto", "category": "Datos"}]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out)
|
||||
datos = cfg["pages"][0]["columns"][0]["widgets"][0]
|
||||
# Solo Metabase en Datos; el servicio sin subdomain se descarta.
|
||||
assert len(datos["sites"]) == 1
|
||||
assert datos["sites"][0]["title"] == "Metabase"
|
||||
|
||||
|
||||
def test_determinismo():
|
||||
a = render_glance_config(SERVICES)
|
||||
b = render_glance_config(list(reversed(SERVICES)))
|
||||
# El orden de entrada no afecta: categorías y sites se ordenan internamente.
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_orden_sites_por_title():
|
||||
services = [
|
||||
{"subdomain": "z-svc", "title": "Zeta", "category": "Datos"},
|
||||
{"subdomain": "a-svc", "title": "Alfa", "category": "Datos"},
|
||||
]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out)
|
||||
sites = cfg["pages"][0]["columns"][0]["widgets"][0]["sites"]
|
||||
assert [s["title"] for s in sites] == ["Alfa", "Zeta"]
|
||||
|
||||
|
||||
def test_categoria_default_general():
|
||||
services = [{"subdomain": "x", "title": "X"}]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out)
|
||||
assert cfg["pages"][0]["columns"][0]["widgets"][0]["title"] == "General"
|
||||
|
||||
|
||||
def test_check_url_se_emite_cuando_health_path_no_es_raiz():
|
||||
services = [
|
||||
{
|
||||
"subdomain": "api",
|
||||
"title": "API",
|
||||
"category": "Datos",
|
||||
"health_path": "/api/health",
|
||||
},
|
||||
{
|
||||
"subdomain": "web",
|
||||
"title": "Web",
|
||||
"category": "Datos",
|
||||
"health_path": "/", # raiz -> no debe emitir check-url
|
||||
},
|
||||
{
|
||||
"subdomain": "raw",
|
||||
"title": "Raw",
|
||||
"category": "Datos",
|
||||
# sin health_path -> tampoco debe emitir check-url
|
||||
},
|
||||
]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out) # sigue siendo YAML parseable
|
||||
sites = {
|
||||
s["title"]: s
|
||||
for s in cfg["pages"][0]["columns"][0]["widgets"][0]["sites"]
|
||||
}
|
||||
|
||||
# health_path no-raiz -> check-url = url + health_path; url sigue siendo la raiz.
|
||||
assert sites["API"]["url"] == "http://api.localhost"
|
||||
assert sites["API"]["check-url"] == "http://api.localhost/api/health"
|
||||
|
||||
# health_path == "/" -> sin check-url.
|
||||
assert sites["Web"]["url"] == "http://web.localhost"
|
||||
assert "check-url" not in sites["Web"]
|
||||
|
||||
# sin health_path -> sin check-url.
|
||||
assert sites["Raw"]["url"] == "http://raw.localhost"
|
||||
assert "check-url" not in sites["Raw"]
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: resolve_pg_dsn
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def resolve_pg_dsn(project: str) -> dict"
|
||||
description: "Resuelve el DSN de PostgreSQL de un proyecto conocido del ecosistema (captacion_clientes, seo_analytics) sin lanzar. Centraliza el patron inline repetido por el agente: leer el DSN desde la variable de entorno del proyecto (CAPTACION_DSN, SEO_DSN), caer a la linea <ENV_VAR>= del .env del proyecto, y como ultimo recurso construirlo desde el secreto de pass (password en runtime, user/host/port/db fijos por proyecto). Cada proyecto declara su politica de resolucion en un mapa interno explicito (_PROJECTS) con alias para el nombre largo. Orden de resolucion: (1) env var, (2) .env, (3) pass. Devuelve {status:'ok', project, dsn, source} con source='env'|'dotenv'|'pass', o {status:'error', error} si el proyecto es desconocido o no se pudo construir el DSN. NUNCA hardcodea el password: lo lee de pass via pass_get_secret en runtime."
|
||||
tags: [postgres, postgresql, dsn, credential, infra]
|
||||
uses_functions: [pass_get_secret_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [os]
|
||||
tested: true
|
||||
tests: ["env var seteada gana y source es env", "proyecto desconocido devuelve error sin lanzar", "alias largo resuelve a la clave canonica", "fallback a .env cuando no hay env var"]
|
||||
test_file_path: "python/functions/infra/resolve_pg_dsn_test.py"
|
||||
file_path: "python/functions/infra/resolve_pg_dsn.py"
|
||||
params:
|
||||
- name: project
|
||||
desc: "Nombre del proyecto. Acepta la clave canonica ('captacion', 'seo') o el alias largo ('captacion_clientes', 'seo_analytics'). Un nombre no registrado devuelve {status:'error'} con la lista de proyectos conocidos."
|
||||
output: "dict. En exito: {status:'ok', project:str (clave canonica), dsn:str (cadena postgresql://...), source:str ('env'|'dotenv'|'pass')}. En error (sin lanzar): {status:'error', error:str} para proyecto desconocido o DSN no resoluble."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.resolve_pg_dsn import resolve_pg_dsn
|
||||
|
||||
# Por nombre corto o largo, da igual.
|
||||
res = resolve_pg_dsn("captacion")
|
||||
print(res["status"]) # ok
|
||||
print(res["source"]) # 'dotenv' (lee CAPTACION_DSN del .env del proyecto)
|
||||
# res["dsn"] -> "postgresql://captacion:***@localhost:5433/trends"
|
||||
|
||||
# La env var, si esta seteada, gana sobre el .env y sobre pass.
|
||||
os.environ["SEO_DSN"] = "postgresql://captacion:x@localhost:5433/seo"
|
||||
print(resolve_pg_dsn("seo_analytics")["source"]) # env
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala antes de cualquier `psql`/`psycopg2`/`pg_query` contra el Postgres de un
|
||||
proyecto del ecosistema, en vez de reescribir a mano la resolucion del DSN
|
||||
(grep al .env + fallback a pass). Es el unico sitio que sabe como se llama la
|
||||
env var de cada proyecto, donde vive su .env y de que entry de pass sale el
|
||||
password. Si vas a lanzar varias queries seguidas, resuelve el DSN una vez y
|
||||
reusalo; para el caso comun de "una query a un proyecto" usa el pipeline
|
||||
`query_project_pg_py_pipelines` que ya compone esta resolucion con `pg_query`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: lee variables de entorno, el `.env` del proyecto en disco y ejecuta
|
||||
`pass show` como subproceso. El resultado depende del entorno de la maquina.
|
||||
- El `dsn` devuelto **contiene el password en claro**. NO lo logees ni lo
|
||||
imprimas en produccion (el `## Ejemplo` lo redacta a proposito).
|
||||
- La ruta del `.env` se resuelve relativa a `FN_REGISTRY_ROOT` si esa env var
|
||||
esta seteada; si no, relativa al cwd. Lanza desde la raiz del registry o
|
||||
exporta `FN_REGISTRY_ROOT` para que el paso (2) `.env` funcione.
|
||||
- Solo conoce los proyectos del mapa `_PROJECTS`. Anadir uno nuevo = una entrada
|
||||
de diccionario (env_var + dotenv_path + pass_path + pg fijos), no otro bloque
|
||||
de bash inline.
|
||||
- El fallback de `seo` apunta hoy al mismo entry de pass que `captacion`
|
||||
(mismo contenedor Postgres, distinta db `seo`). Si seo_analytics pasa a tener
|
||||
credenciales propias, actualiza `_PROJECTS['seo']`.
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Resuelve el DSN de PostgreSQL de un proyecto conocido del ecosistema.
|
||||
|
||||
Centraliza el patrón que el agente reescribía inline una y otra vez: leer el
|
||||
DSN de un proyecto desde su variable de entorno, caer al fichero ``.env`` del
|
||||
proyecto, y como último recurso construirlo desde el secreto guardado en
|
||||
``pass``. Cada proyecto declara su política de resolución en un mapa interno
|
||||
explícito (``_PROJECTS``), de modo que añadir un proyecto nuevo es una sola
|
||||
entrada de diccionario, no otra copia del bloque de bash.
|
||||
|
||||
Es una función impura (lee env, ficheros y ``pass``) que NUNCA lanza: devuelve
|
||||
un dict ``{status:'ok', ...}`` en éxito y ``{status:'error', error}`` en fallo,
|
||||
siguiendo el estilo del resto de funciones I/O del registry. El password sale
|
||||
de ``pass`` en runtime — jamás está hardcodeado en este módulo.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
|
||||
|
||||
# Mapa EXPLÍCITO de proyectos conocidos -> cómo resolver su DSN.
|
||||
#
|
||||
# Cada entrada declara:
|
||||
# env_var: variable de entorno que (si está seteada) gana sobre todo.
|
||||
# dotenv_path: ruta (relativa a la raíz del registry) del .env del proyecto.
|
||||
# La línea buscada dentro del .env es "<env_var>=<dsn>".
|
||||
# pass_path: ruta del secreto en `pass` desde la que construir el fallback.
|
||||
# pg: parámetros fijos para construir el DSN desde el secreto de pass.
|
||||
# user/host/port/db son estables por proyecto; el password es la
|
||||
# primera línea del secreto de pass y se lee en runtime.
|
||||
#
|
||||
# Los alias (claves múltiples que apuntan a la misma config) permiten llamar a
|
||||
# la función con el nombre corto ("captacion") o el largo ("captacion_clientes").
|
||||
_PROJECTS = {
|
||||
"captacion": {
|
||||
"env_var": "CAPTACION_DSN",
|
||||
"dotenv_path": "projects/captacion_clientes/.env",
|
||||
"pass_path": "captacion/postgres",
|
||||
"pg": {"user": "captacion", "host": "localhost", "port": "5433", "db": "trends"},
|
||||
},
|
||||
"seo": {
|
||||
"env_var": "SEO_DSN",
|
||||
# seo_analytics no fija un .env canónico hoy; se resuelve por env var
|
||||
# (la convención que ya usa ingest_gsc_search_analytics) o por pass.
|
||||
"dotenv_path": "projects/seo_analytics/.env",
|
||||
"pass_path": "captacion/postgres",
|
||||
"pg": {"user": "captacion", "host": "localhost", "port": "5433", "db": "seo"},
|
||||
},
|
||||
}
|
||||
|
||||
# Alias: nombre largo del proyecto -> clave canónica en _PROJECTS.
|
||||
_ALIASES = {
|
||||
"captacion_clientes": "captacion",
|
||||
"seo_analytics": "seo",
|
||||
}
|
||||
|
||||
|
||||
def _canonical(project: str) -> str:
|
||||
"""Normaliza el nombre del proyecto a su clave canónica en _PROJECTS."""
|
||||
key = (project or "").strip().lower()
|
||||
return _ALIASES.get(key, key)
|
||||
|
||||
|
||||
def _read_dotenv_line(dotenv_path: str, env_var: str) -> str:
|
||||
"""Devuelve el valor de la línea ``<env_var>=...`` del .env, o "" si no está.
|
||||
|
||||
Resuelve la ruta relativa a la raíz del registry usando FN_REGISTRY_ROOT si
|
||||
está disponible; en su defecto asume el cwd actual. Quita comillas dobles o
|
||||
simples envolventes del valor.
|
||||
"""
|
||||
root = os.environ.get("FN_REGISTRY_ROOT", "").strip()
|
||||
full = os.path.join(root, dotenv_path) if root else dotenv_path
|
||||
try:
|
||||
with open(full, "r", encoding="utf-8") as fh:
|
||||
prefix = env_var + "="
|
||||
for raw in fh:
|
||||
line = raw.strip()
|
||||
if line.startswith(prefix):
|
||||
value = line[len(prefix):].strip()
|
||||
if len(value) >= 2 and value[0] in "\"'" and value[-1] == value[0]:
|
||||
value = value[1:-1]
|
||||
return value
|
||||
except OSError:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_pg_dsn(project: str) -> dict:
|
||||
"""Resuelve el DSN PostgreSQL de un proyecto conocido sin lanzar.
|
||||
|
||||
Orden de resolución (gana el primero que tenga valor):
|
||||
1. La variable de entorno del proyecto (``env``).
|
||||
2. La línea ``<ENV_VAR>=<dsn>`` del ``.env`` del proyecto (``dotenv``).
|
||||
3. Un DSN construido a partir del secreto de ``pass`` (``pass``): el
|
||||
password es la primera línea del secreto; user/host/port/db son fijos
|
||||
por proyecto. El password NO se hardcodea: se lee en runtime.
|
||||
|
||||
Args:
|
||||
project: nombre del proyecto. Acepta la clave canónica ("captacion",
|
||||
"seo") o el alias largo ("captacion_clientes", "seo_analytics").
|
||||
|
||||
Returns:
|
||||
dict. En éxito: ``{status:'ok', project, dsn, source}`` donde ``source``
|
||||
es ``'env'`` | ``'dotenv'`` | ``'pass'`` según de dónde salió el DSN.
|
||||
En error (sin lanzar): ``{status:'error', error}`` (proyecto desconocido
|
||||
o no se pudo construir el DSN por ningún medio).
|
||||
"""
|
||||
canonical = _canonical(project)
|
||||
cfg = _PROJECTS.get(canonical)
|
||||
if cfg is None:
|
||||
known = ", ".join(sorted(set(_PROJECTS) | set(_ALIASES)))
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"unknown project '{project}'. Known: {known}",
|
||||
}
|
||||
|
||||
env_var = cfg["env_var"]
|
||||
|
||||
# 1. Variable de entorno (gana sobre todo).
|
||||
env_dsn = os.environ.get(env_var, "").strip()
|
||||
if env_dsn:
|
||||
return {"status": "ok", "project": canonical, "dsn": env_dsn, "source": "env"}
|
||||
|
||||
# 2. Línea del .env del proyecto.
|
||||
dotenv_dsn = _read_dotenv_line(cfg["dotenv_path"], env_var)
|
||||
if dotenv_dsn:
|
||||
return {"status": "ok", "project": canonical, "dsn": dotenv_dsn, "source": "dotenv"}
|
||||
|
||||
# 3. Fallback: construir desde el secreto de pass (password en runtime).
|
||||
secret = pass_get_secret(cfg["pass_path"], line=1)
|
||||
if secret.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"could not resolve DSN for '{canonical}': env var {env_var} unset, "
|
||||
f"no line in .env, and pass failed: {secret.get('error')}"
|
||||
),
|
||||
}
|
||||
password = secret["value"]
|
||||
pg = cfg["pg"]
|
||||
dsn = f"postgresql://{pg['user']}:{password}@{pg['host']}:{pg['port']}/{pg['db']}"
|
||||
return {"status": "ok", "project": canonical, "dsn": dsn, "source": "pass"}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests para resolve_pg_dsn.
|
||||
|
||||
No tocan pass ni el disco salvo via monkeypatch sobre os.environ y un .env
|
||||
temporal. El fallback a pass se valida indirectamente (proyecto desconocido,
|
||||
prioridad del env var) sin invocar el subproceso real.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "functions"))
|
||||
|
||||
from infra.resolve_pg_dsn import resolve_pg_dsn
|
||||
|
||||
|
||||
def test_env_var_seteada_gana_y_source_es_env(monkeypatch):
|
||||
"""La env var del proyecto gana sobre .env y pass; source == 'env'."""
|
||||
expected = "postgresql://captacion:secret@localhost:5433/trends"
|
||||
monkeypatch.setenv("CAPTACION_DSN", expected)
|
||||
res = resolve_pg_dsn("captacion")
|
||||
assert res["status"] == "ok"
|
||||
assert res["dsn"] == expected
|
||||
assert res["source"] == "env"
|
||||
assert res["project"] == "captacion"
|
||||
|
||||
|
||||
def test_proyecto_desconocido_devuelve_error_sin_lanzar():
|
||||
"""Un proyecto no registrado devuelve {status:'error'} sin excepcion."""
|
||||
res = resolve_pg_dsn("no_existe_este_proyecto")
|
||||
assert res["status"] == "error"
|
||||
assert "unknown project" in res["error"]
|
||||
|
||||
|
||||
def test_alias_largo_resuelve_a_la_clave_canonica(monkeypatch):
|
||||
"""El alias largo 'seo_analytics' resuelve a la clave canonica 'seo'."""
|
||||
monkeypatch.setenv("SEO_DSN", "postgresql://captacion:x@localhost:5433/seo")
|
||||
res = resolve_pg_dsn("seo_analytics")
|
||||
assert res["status"] == "ok"
|
||||
assert res["project"] == "seo"
|
||||
assert res["source"] == "env"
|
||||
|
||||
|
||||
def test_fallback_a_dotenv_cuando_no_hay_env_var(monkeypatch, tmp_path):
|
||||
"""Sin env var, lee la linea <ENV_VAR>= del .env del proyecto; source == 'dotenv'."""
|
||||
monkeypatch.delenv("CAPTACION_DSN", raising=False)
|
||||
# Monta una raiz falsa con el .env del proyecto en la ruta esperada.
|
||||
proj_dir = tmp_path / "projects" / "captacion_clientes"
|
||||
proj_dir.mkdir(parents=True)
|
||||
dsn = "postgresql://captacion:fromdotenv@localhost:5433/trends"
|
||||
(proj_dir / ".env").write_text(f'CAPTACION_DSN="{dsn}"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("FN_REGISTRY_ROOT", str(tmp_path))
|
||||
res = resolve_pg_dsn("captacion_clientes")
|
||||
assert res["status"] == "ok"
|
||||
assert res["dsn"] == dsn # comillas envolventes quitadas
|
||||
assert res["source"] == "dotenv"
|
||||
Reference in New Issue
Block a user