"""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 "=". # 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 ``=...`` 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 ``=`` 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"}