Files
fn_registry/python/functions/infra/resolve_pg_dsn.py
T
egutierrez 32c7336bf6 feat(infra): auto-commit con 56 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-21 14:22:55 +02:00

143 lines
5.9 KiB
Python

"""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"}