""" apps/metabase_registry/main.py ============================== Setup completo de fn-registry en Metabase: datasource, cards y dashboard. USO --- Via variables de entorno: METABASE_URL=http://localhost:3000 \ METABASE_ADMIN_EMAIL=admin@example.com \ METABASE_ADMIN_PASSWORD=secret \ REGISTRY_DB_PATH=/data/registry/registry.db \ python main.py Via argumentos CLI: python main.py \ --url http://localhost:3000 \ --admin-email admin@example.com \ --admin-password secret \ --registry-db-path /registry.db Para crear un usuario nuevo (opcional): python main.py ... \ --new-user-email dev@example.com \ --new-user-first-name Dev \ --new-user-last-name User \ --new-user-password devpass NOTA SOBRE registry.db EN DOCKER ---------------------------------- Metabase corre en Docker y necesita acceder a registry.db. El path que se configura en Metabase (--registry-db-path) debe ser la ruta DENTRO del contenedor. Usa setup_volume.sh para copiar el archivo al contenedor antes de ejecutar este script. ./setup_volume.sh /home/lucas/fn_registry/registry.db Tras copiar, el archivo queda en /registry.db dentro del contenedor, que es el valor por defecto de --registry-db-path. DEPENDENCIAS ------------ Instalar con: pip install -r requirements.txt O con uv: uv pip install -r requirements.txt Las funciones metabase_add_database y metabase_list_databases deben existir en python/functions/metabase/databases.py (creadas por el fn-constructor). """ import argparse import os import sys import json # Agregar el directorio de funciones Python al path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions")) from metabase import ( MetabaseClient, metabase_create_user, metabase_create_card, metabase_create_dashboard, metabase_update_dashboard, ) from metabase.client import metabase_auth from metabase.databases import metabase_add_database, metabase_list_databases # --------------------------------------------------------------------------- # Configuracion de cards de ejemplo # --------------------------------------------------------------------------- CARDS_CONFIG = [ { "name": "Funciones por dominio", "description": "Cuenta de funciones agrupadas por dominio del registry", "sql": "SELECT domain, count(*) AS total FROM functions GROUP BY domain ORDER BY total DESC", "display": "bar", }, { "name": "Funciones puras vs impuras", "description": "Distribucion de pureza funcional en el registry", "sql": "SELECT purity, count(*) AS total FROM functions GROUP BY purity ORDER BY total DESC", "display": "pie", }, { "name": "Funciones por kind", "description": "Distribucion por tipo: function, pipeline, component", "sql": "SELECT kind, count(*) AS total FROM functions GROUP BY kind ORDER BY total DESC", "display": "bar", }, { "name": "Buscar funciones", "description": "Lista completa de funciones con id, dominio, pureza y descripcion", "sql": ( "SELECT id, domain, kind, purity, signature, description " "FROM functions ORDER BY domain, name" ), "display": "table", }, { "name": "Tipos por dominio", "description": "Cuenta de tipos registrados por dominio", "sql": "SELECT domain, count(*) AS total FROM types GROUP BY domain ORDER BY total DESC", "display": "bar", }, { "name": "Proposals pendientes", "description": "Proposals del registry que estan pendientes de revision", "sql": ( "SELECT id, kind, title, created_by, created_at " "FROM proposals WHERE status = 'pending' ORDER BY created_at DESC" ), "display": "table", }, ] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def log(msg: str) -> None: print(f"[metabase_registry] {msg}", flush=True) def log_ok(msg: str) -> None: print(f"[metabase_registry] OK {msg}", flush=True) def log_err(msg: str) -> None: print(f"[metabase_registry] ERR {msg}", file=sys.stderr, flush=True) def find_existing_database(client: MetabaseClient, name: str) -> dict | None: """Busca una database por nombre en Metabase. Retorna None si no existe.""" try: databases = metabase_list_databases(client) for db in databases: if db.get("name") == name: return db except Exception as e: log(f"No se pudo listar databases: {e}") return None def build_dataset_query(database_id: int, sql: str) -> dict: """Construye el dataset_query para una card SQL nativa.""" return { "type": "native", "database": database_id, "native": { "query": sql, "template-tags": {}, }, } def create_cards(client: MetabaseClient, database_id: int) -> list[dict]: """Crea todas las cards de ejemplo. Retorna lista de cards creadas.""" created = [] for cfg in CARDS_CONFIG: log(f"Creando card: {cfg['name']!r} ...") try: card = metabase_create_card( client=client, name=cfg["name"], dataset_query=build_dataset_query(database_id, cfg["sql"]), display=cfg["display"], description=cfg["description"], ) log_ok(f"Card creada: id={card['id']} nombre={card['name']!r}") created.append(card) except Exception as e: log_err(f"No se pudo crear card {cfg['name']!r}: {e}") return created def create_overview_dashboard(client: MetabaseClient, cards: list[dict]) -> dict | None: """Crea el dashboard 'fn-registry Overview' con todas las cards.""" log("Creando dashboard 'fn-registry Overview' ...") try: dashboard = metabase_create_dashboard( client=client, name="fn-registry Overview", description="Vista general del function registry: funciones, tipos, proposals y metricas.", ) log_ok(f"Dashboard creado: id={dashboard['id']}") except Exception as e: log_err(f"No se pudo crear dashboard: {e}") return None if not cards: log("Sin cards para agregar al dashboard.") return dashboard # Posicionar cards en una grilla de 3 columnas, 24 unidades de ancho total. # Cada card ocupa 8 columnas x 6 filas. cols = 3 card_w = 8 card_h = 6 dashcards = [] for idx, card in enumerate(cards): col = (idx % cols) * card_w row = (idx // cols) * card_h dashcards.append({ "id": -(idx + 1), # ID negativo = nueva dashcard "card_id": card["id"], "size_x": card_w, "size_y": card_h, "col": col, "row": row, "parameter_mappings": [], "visualization_settings": {}, }) try: metabase_update_dashboard( client=client, dashboard_id=dashboard["id"], dashcards=dashcards, ) log_ok(f"Dashboard actualizado con {len(dashcards)} cards.") except Exception as e: log_err(f"No se pudo poblar el dashboard con cards: {e}") return dashboard # --------------------------------------------------------------------------- # Flujo principal # --------------------------------------------------------------------------- def run(args: argparse.Namespace) -> int: """Ejecuta el setup completo. Retorna 0 en exito, 1 en fallo critico.""" # 1. Autenticar como admin log(f"Autenticando en {args.url} como {args.admin_email} ...") try: client = metabase_auth(args.url, args.admin_email, args.admin_password) log_ok("Autenticacion exitosa.") except Exception as e: log_err(f"Fallo de autenticacion: {e}") return 1 with client: # 2. Crear usuario nuevo (opcional) if args.new_user_email: if not args.new_user_first_name or not args.new_user_last_name: log_err("Para crear usuario se requieren --new-user-first-name y --new-user-last-name.") return 1 log(f"Creando usuario {args.new_user_email} ...") try: user = metabase_create_user( client=client, first_name=args.new_user_first_name, last_name=args.new_user_last_name, email=args.new_user_email, password=args.new_user_password or "", ) log_ok(f"Usuario creado: id={user['id']} email={user['email']}") except Exception as e: log_err(f"No se pudo crear usuario: {e}") # No es critico, continuar # 3. Agregar registry.db como datasource SQLite db_name = "fn-registry" log(f"Verificando si ya existe la database {db_name!r} en Metabase ...") existing_db = find_existing_database(client, db_name) if existing_db: db_id = existing_db["id"] log_ok(f"Database ya existe: id={db_id} nombre={db_name!r}. Se reutiliza.") else: log(f"Registrando registry.db ({args.registry_db_path}) como datasource SQLite ...") try: new_db = metabase_add_database( client=client, name=db_name, engine="sqlite", details={"db": args.registry_db_path}, ) db_id = new_db["id"] log_ok(f"Database registrada: id={db_id} path={args.registry_db_path}") except Exception as e: log_err(f"No se pudo registrar la database: {e}") log_err( "Verifica que registry.db este accesible desde el contenedor Docker. " "Usa setup_volume.sh para copiar el archivo." ) return 1 # 4. Crear cards de ejemplo log(f"Creando {len(CARDS_CONFIG)} cards con database_id={db_id} ...") cards = create_cards(client, db_id) log(f"Cards creadas: {len(cards)}/{len(CARDS_CONFIG)}") # 5. Crear dashboard dashboard = create_overview_dashboard(client, cards) if dashboard: log_ok( f"Dashboard disponible en: {args.url}/dashboard/{dashboard['id']}" ) else: log_err("El dashboard no pudo crearse.") summary = { "url": args.url, "database_id": db_id, "cards_created": len(cards), "dashboard_id": dashboard["id"] if dashboard else None, "dashboard_url": f"{args.url}/dashboard/{dashboard['id']}" if dashboard else None, } print("\n--- Resumen ---") print(json.dumps(summary, indent=2)) return 0 # --------------------------------------------------------------------------- # CLI / env vars # --------------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="metabase_registry", description="Setup de fn-registry como datasource en Metabase.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) # Conexion Metabase p.add_argument( "--url", default=os.environ.get("METABASE_URL", "http://localhost:3000"), help="URL base de Metabase (env: METABASE_URL). Default: http://localhost:3000", ) p.add_argument( "--admin-email", default=os.environ.get("METABASE_ADMIN_EMAIL", "admin@example.com"), dest="admin_email", help="Email del admin (env: METABASE_ADMIN_EMAIL).", ) p.add_argument( "--admin-password", default=os.environ.get("METABASE_ADMIN_PASSWORD", ""), dest="admin_password", help="Password del admin (env: METABASE_ADMIN_PASSWORD).", ) # Registry DB path (ruta dentro del contenedor Docker) p.add_argument( "--registry-db-path", default=os.environ.get("REGISTRY_DB_PATH", "/data/registry/registry.db"), dest="registry_db_path", help=( "Ruta al registry.db DENTRO del contenedor Docker " "(env: REGISTRY_DB_PATH). Default: /registry.db" ), ) # Nuevo usuario (todos opcionales) p.add_argument( "--new-user-email", default=os.environ.get("NEW_USER_EMAIL", ""), dest="new_user_email", help="Email del nuevo usuario a crear (env: NEW_USER_EMAIL). Opcional.", ) p.add_argument( "--new-user-first-name", default=os.environ.get("NEW_USER_FIRST_NAME", ""), dest="new_user_first_name", help="Nombre del nuevo usuario (env: NEW_USER_FIRST_NAME).", ) p.add_argument( "--new-user-last-name", default=os.environ.get("NEW_USER_LAST_NAME", ""), dest="new_user_last_name", help="Apellido del nuevo usuario (env: NEW_USER_LAST_NAME).", ) p.add_argument( "--new-user-password", default=os.environ.get("NEW_USER_PASSWORD", ""), dest="new_user_password", help="Password del nuevo usuario (env: NEW_USER_PASSWORD). Opcional.", ) return p def main() -> None: parser = build_parser() args = parser.parse_args() if not args.admin_password: parser.error( "Se requiere la password del admin. " "Usa --admin-password o la variable de entorno METABASE_ADMIN_PASSWORD." ) sys.exit(run(args)) if __name__ == "__main__": main()