32c7336bf6
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
5.9 KiB
Python
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"}
|