feat(infra): auto-commit con 56 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 14:22:55 +02:00
parent c1071a82b3
commit 32c7336bf6
56 changed files with 5307 additions and 100 deletions
@@ -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"]
+69
View File
@@ -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']`.
+142
View File
@@ -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"