From 2e5bdacdcf28ae1750027dba7e529fc89ba4e961 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 23:23:20 +0100 Subject: [PATCH] feat: metabase_setup Python, fix list_databases, volumen Docker en init_metabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nueva función metabase_setup para setup inicial via API. Fix list_databases que no extraía data del response wrapper. Pipeline init_metabase soporta --mb-volumes para montar SQLite como volumen con fix de permisos automático. Añadido .env a gitignore. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 + apps/metabase_registry/main.py | 411 ++++++++++++++++++ apps/metabase_registry/requirements.txt | 13 + functions/pipelines/init_metabase.md | 13 +- functions/pipelines/init_metabase/main.go | 37 +- python/functions/metabase/__init__.py | 4 + python/functions/metabase/databases.py | 105 +++++ .../metabase/metabase_add_database.md | 44 ++ .../metabase/metabase_get_database.md | 33 ++ .../metabase/metabase_list_databases.md | 39 ++ python/functions/metabase/metabase_setup.md | 44 ++ python/functions/metabase/setup.py | 83 ++++ 12 files changed, 821 insertions(+), 8 deletions(-) create mode 100644 apps/metabase_registry/main.py create mode 100644 apps/metabase_registry/requirements.txt create mode 100644 python/functions/metabase/databases.py create mode 100644 python/functions/metabase/metabase_add_database.md create mode 100644 python/functions/metabase/metabase_get_database.md create mode 100644 python/functions/metabase/metabase_list_databases.md create mode 100644 python/functions/metabase/metabase_setup.md create mode 100644 python/functions/metabase/setup.py diff --git a/.gitignore b/.gitignore index 84ae434f..ba7c706f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ registry.db-wal *.swo *~ +# Secrets +.env + # OS .DS_Store Thumbs.db diff --git a/apps/metabase_registry/main.py b/apps/metabase_registry/main.py new file mode 100644 index 00000000..2e5bd827 --- /dev/null +++ b/apps/metabase_registry/main.py @@ -0,0 +1,411 @@ +""" +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=/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", "/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() diff --git a/apps/metabase_registry/requirements.txt b/apps/metabase_registry/requirements.txt new file mode 100644 index 00000000..79865d30 --- /dev/null +++ b/apps/metabase_registry/requirements.txt @@ -0,0 +1,13 @@ +# Dependencias de apps/metabase_registry +# +# Instalar con pip: +# pip install -r requirements.txt +# +# O con uv (recomendado, desde la raiz del repo): +# cd /home/lucas/fn_registry/python && uv pip install -r ../apps/metabase_registry/requirements.txt +# +# O directamente desde el directorio python (que ya tiene httpx): +# cd /home/lucas/fn_registry/python && uv run python ../apps/metabase_registry/main.py + +# HTTP client — mismo que usa el paquete python/functions/metabase +httpx>=0.27.0 diff --git a/functions/pipelines/init_metabase.md b/functions/pipelines/init_metabase.md index a7cbc232..54f2a14f 100644 --- a/functions/pipelines/init_metabase.md +++ b/functions/pipelines/init_metabase.md @@ -30,13 +30,17 @@ file_path: "functions/pipelines/init_metabase/main.go" ## Ejemplo ```bash +# Básico go run functions/pipelines/init_metabase/main.go \ --project analytics \ --metabase-port 3000 \ - --pg-port 5432 \ --pg-user metabase \ - --pg-password metabase \ - --pg-database metabase + --pg-password metabase + +# Con volume para registry.db (detecta cambios en vivo) +go run functions/pipelines/init_metabase/main.go \ + --project fn_registry \ + --mb-volumes "/home/lucas/fn_registry/registry.db:/data/registry.db" ``` Salida JSON: @@ -60,7 +64,8 @@ El pipeline orquesta 5 pasos secuenciales: 2. **Pull** — descarga `postgres:16` y `metabase/metabase:latest` 3. **Postgres** — inicia con volume persistente (named volume por defecto o bind mount con `--pg-volume`) 4. **Health check** — retry exponencial (hasta ~34 min) con `pg_isready` dentro del contenedor -5. **Metabase** — conecta a Postgres via red interna, expone en puerto configurable +5. **Metabase** — conecta a Postgres via red interna, expone en puerto configurable. Con `--mb-volumes` monta volumes adicionales (ej: registry.db para SQLite) +6. **Permisos** — ajusta ownership de directorios montados para el usuario `metabase` (UID 2000) dentro del contenedor Reutiliza conceptualmente `docker_create_network`, `docker_pull_image`, `docker_run_container`, `docker_inspect_container` y `retry_with_backoff`, reimplementadas inline por ser un ejecutable independiente. diff --git a/functions/pipelines/init_metabase/main.go b/functions/pipelines/init_metabase/main.go index e061cc26..ba40b535 100644 --- a/functions/pipelines/init_metabase/main.go +++ b/functions/pipelines/init_metabase/main.go @@ -28,7 +28,8 @@ func main() { pgUser := flag.String("pg-user", "metabase", "Usuario Postgres") pgPass := flag.String("pg-password", "metabase", "Password Postgres") pgDB := flag.String("pg-database", "metabase", "Base de datos Postgres") - pgVolume := flag.String("pg-volume", "", "Path host para persistencia (default: docker named volume)") + pgVolume := flag.String("pg-volume", "", "Path host para persistencia Postgres (default: docker named volume)") + mbVolumes := flag.String("mb-volumes", "", "Volumes adicionales para Metabase, separados por coma (ej: /host/path:/container/path,/otro:/dest)") flag.Parse() if *project == "" { @@ -37,7 +38,12 @@ func main() { os.Exit(1) } - result, err := initMetabase(*project, *mbPort, *pgPort, *pgUser, *pgPass, *pgDB, *pgVolume) + var extraVolumes []string + if *mbVolumes != "" { + extraVolumes = strings.Split(*mbVolumes, ",") + } + + result, err := initMetabase(*project, *mbPort, *pgPort, *pgUser, *pgPass, *pgDB, *pgVolume, extraVolumes) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) @@ -48,7 +54,7 @@ func main() { enc.Encode(result) } -func initMetabase(project, mbPort, pgPort, pgUser, pgPass, pgDB, pgVolume string) (*MetabaseResult, error) { +func initMetabase(project, mbPort, pgPort, pgUser, pgPass, pgDB, pgVolume string, mbExtraVolumes []string) (*MetabaseResult, error) { networkName := project + "-net" pgName := project + "-postgres" mbName := project + "-metabase" @@ -118,13 +124,36 @@ func initMetabase(project, mbPort, pgPort, pgUser, pgPass, pgDB, pgVolume string "-e", "MB_DB_USER=" + pgUser, "-e", "MB_DB_PASS=" + pgPass, "-e", "MB_DB_HOST=" + pgName, - "metabase/metabase:latest", } + for _, v := range mbExtraVolumes { + mbArgs = append(mbArgs, "-v", strings.TrimSpace(v)) + } + mbArgs = append(mbArgs, "metabase/metabase:latest") mbID, err := dockerCmd(mbArgs...) if err != nil { return nil, fmt.Errorf("starting metabase: %w", err) } + // 6. Fix permisos para volumes SQLite — Metabase corre como UID 2000 + for _, v := range mbExtraVolumes { + parts := strings.SplitN(strings.TrimSpace(v), ":", 2) + if len(parts) < 2 { + continue + } + destPath := parts[1] + // Si es un archivo, fix el directorio padre; si es directorio, fix directo + dir := destPath + if strings.Contains(destPath, ".") { + // Probablemente un archivo, usar dirname + idx := strings.LastIndex(destPath, "/") + if idx > 0 { + dir = destPath[:idx] + } + } + fmt.Fprintf(os.Stderr, " Fijando permisos de %s para usuario metabase...\n", dir) + dockerCmd("exec", "-u", "root", mbName, "chown", "metabase:metabase", dir) + } + mbURL := fmt.Sprintf("http://localhost:%s", mbPort) fmt.Fprintf(os.Stderr, "\nStack listo. Metabase disponible en %s\n", mbURL) diff --git a/python/functions/metabase/__init__.py b/python/functions/metabase/__init__.py index bccd53c3..a7744901 100644 --- a/python/functions/metabase/__init__.py +++ b/python/functions/metabase/__init__.py @@ -2,10 +2,14 @@ from .client import MetabaseClient from .users import metabase_list_users, metabase_get_user, metabase_create_user, metabase_update_user, metabase_deactivate_user from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard +from .databases import metabase_list_databases, metabase_add_database, metabase_get_database +from .setup import metabase_setup __all__ = [ "MetabaseClient", "metabase_list_users", "metabase_get_user", "metabase_create_user", "metabase_update_user", "metabase_deactivate_user", "metabase_list_cards", "metabase_get_card", "metabase_create_card", "metabase_update_card", "metabase_delete_card", "metabase_execute_card", "metabase_execute_query", "metabase_list_dashboards", "metabase_get_dashboard", "metabase_create_dashboard", "metabase_update_dashboard", "metabase_delete_dashboard", + "metabase_list_databases", "metabase_add_database", "metabase_get_database", + "metabase_setup", ] diff --git a/python/functions/metabase/databases.py b/python/functions/metabase/databases.py new file mode 100644 index 00000000..2bb1cb0f --- /dev/null +++ b/python/functions/metabase/databases.py @@ -0,0 +1,105 @@ +"""CRUD de databases de Metabase.""" + +from .client import MetabaseClient + + +def metabase_list_databases( + client: MetabaseClient, + include_tables: bool = False, +) -> list: + """Lista las databases configuradas en Metabase. + + Endpoint: GET /api/database. + + Args: + client: Cliente autenticado. + include_tables: Si True, incluye las tablas de cada database en la respuesta. + + Returns: + Lista de dicts, cada uno con campos de la database: + - id: ID numerico de la database + - name: Nombre dado en Metabase + - engine: Motor de BD (sqlite, postgres, mysql, etc.) + - details: Dict con parametros de conexion + - is_full_sync: Si sincroniza el schema automaticamente + - tables: Lista de tablas (solo si include_tables=True) + + Example: + >>> dbs = metabase_list_databases(client) + >>> for db in dbs: + ... print(db["id"], db["name"], db["engine"]) + + >>> dbs = metabase_list_databases(client, include_tables=True) + >>> for db in dbs: + ... print(db["name"], [t["name"] for t in db.get("tables", [])]) + """ + params = {} + if include_tables: + params["include"] = "tables" + result = client.request("GET", "/api/database", params=params) + if isinstance(result, dict) and "data" in result: + return result["data"] + return result + + +def metabase_add_database( + client: MetabaseClient, + name: str, + engine: str, + details: dict, +) -> dict: + """Agrega una nueva database a Metabase. + + Endpoint: POST /api/database. Requiere permisos de superusuario. + + Args: + client: Cliente autenticado con permisos admin. + name: Nombre descriptivo para la database en Metabase. + engine: Motor de base de datos. Ejemplos: "sqlite", "postgres", + "mysql", "h2", "mongo", "bigquery". + details: Dict con parametros de conexion especificos del engine. + Para SQLite: {"db": "/ruta/al/archivo.db"} + Para Postgres: {"host": "...", "port": 5432, "dbname": "...", + "user": "...", "password": "..."} + + Returns: + Dict con la database creada, incluyendo el campo "id" asignado + por Metabase y todos los campos de configuracion. + + Raises: + httpx.HTTPStatusError: 400 si los datos de conexion son invalidos. + + Example: + >>> db = metabase_add_database(client, "Mi SQLite", "sqlite", {"db": "/data/ops.db"}) + >>> print(db["id"], db["name"]) + """ + body = { + "name": name, + "engine": engine, + "details": details, + } + return client.request("POST", "/api/database", json=body) + + +def metabase_get_database(client: MetabaseClient, database_id: int) -> dict: + """Obtiene los detalles de una database de Metabase por su ID. + + Endpoint: GET /api/database/:id. + + Args: + client: Cliente autenticado. + database_id: ID numerico de la database. + + Returns: + Dict con campos de la database: id, name, engine, details, + is_full_sync, is_on_demand, auto_run_queries, created_at, + updated_at, features, etc. + + Raises: + httpx.HTTPStatusError: 404 si la database no existe. + + Example: + >>> db = metabase_get_database(client, 2) + >>> print(db["name"], db["engine"]) + """ + return client.request("GET", f"/api/database/{database_id}") diff --git a/python/functions/metabase/metabase_add_database.md b/python/functions/metabase/metabase_add_database.md new file mode 100644 index 00000000..4c5e77be --- /dev/null +++ b/python/functions/metabase/metabase_add_database.md @@ -0,0 +1,44 @@ +--- +name: metabase_add_database +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_add_database(client: MetabaseClient, name: str, engine: str, details: dict) -> dict" +description: "Agrega una nueva database a Metabase via POST /api/database. Soporta cualquier engine (sqlite, postgres, mysql, etc.)." +tags: [metabase, database, add, create, api, python] +uses_functions: [] +uses_types: [MetabaseClient_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/databases.py" +--- + +## Ejemplo + +```python +# SQLite +db = metabase_add_database(client, "Ops DB", "sqlite", {"db": "/data/operations.db"}) +print(db["id"]) + +# Postgres +db = metabase_add_database(client, "Prod PG", "postgres", { + "host": "localhost", + "port": 5432, + "dbname": "myapp", + "user": "reader", + "password": "secret", +}) +``` + +## Notas + +Requiere permisos de superusuario. El campo `details` depende del engine: +para SQLite solo necesita `{"db": "/ruta/archivo.db"}`. +Retorna la database creada con su `id` asignado por Metabase. diff --git a/python/functions/metabase/metabase_get_database.md b/python/functions/metabase/metabase_get_database.md new file mode 100644 index 00000000..0048a0aa --- /dev/null +++ b/python/functions/metabase/metabase_get_database.md @@ -0,0 +1,33 @@ +--- +name: metabase_get_database +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_database(client: MetabaseClient, database_id: int) -> dict" +description: "Obtiene los detalles de una database de Metabase por su ID. Endpoint: GET /api/database/:id." +tags: [metabase, database, get, api, python] +uses_functions: [] +uses_types: [MetabaseClient_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/databases.py" +--- + +## Ejemplo + +```python +db = metabase_get_database(client, 2) +print(db["name"], db["engine"]) +``` + +## Notas + +Error 404 si la database no existe. Retorna campos completos incluyendo +id, name, engine, details, is_full_sync, auto_run_queries, created_at, features. diff --git a/python/functions/metabase/metabase_list_databases.md b/python/functions/metabase/metabase_list_databases.md new file mode 100644 index 00000000..fccd3ce4 --- /dev/null +++ b/python/functions/metabase/metabase_list_databases.md @@ -0,0 +1,39 @@ +--- +name: metabase_list_databases +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_databases(client: MetabaseClient, include_tables: bool = False) -> list" +description: "Lista las databases configuradas en Metabase. Endpoint: GET /api/database. Soporta incluir tablas con include_tables=True." +tags: [metabase, database, list, api, python] +uses_functions: [] +uses_types: [MetabaseClient_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/databases.py" +--- + +## Ejemplo + +```python +dbs = metabase_list_databases(client) +for db in dbs: + print(db["id"], db["name"], db["engine"]) + +# Con tablas incluidas +dbs = metabase_list_databases(client, include_tables=True) +for db in dbs: + print(db["name"], [t["name"] for t in db.get("tables", [])]) +``` + +## Notas + +Si include_tables=True agrega el query param `?include=tables` al request. +Retorna lista directa (no paginada) de todas las databases accesibles. diff --git a/python/functions/metabase/metabase_setup.md b/python/functions/metabase/metabase_setup.md new file mode 100644 index 00000000..33521160 --- /dev/null +++ b/python/functions/metabase/metabase_setup.md @@ -0,0 +1,44 @@ +--- +name: metabase_setup +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "metabase_setup(base_url: str, admin_email: str, admin_password: str, admin_first_name: str, admin_last_name: str, site_name: str, site_locale: str) -> dict" +description: "Ejecuta el setup inicial de una instancia Metabase nueva via POST /api/setup. Obtiene el setup-token automaticamente y crea el usuario admin con preferencias del sitio." +tags: [metabase, setup, api, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/setup.py" +--- + +## Ejemplo + +```python +from metabase.setup import metabase_setup + +result = metabase_setup( + base_url="http://localhost:3000", + admin_email="admin@fnregistry.local", + admin_password="FnRegistry2024!", + admin_first_name="Lucas", + admin_last_name="Admin", + site_name="fn-registry", + site_locale="es", +) +print(result["id"]) # session token +``` + +## Notas + +Solo funciona en instancias sin configurar (setup-token disponible). Si Metabase ya tiene un usuario, lanza RuntimeError. + +El setup-token se obtiene automaticamente de GET /api/session/properties. Una vez usado, Metabase invalida el token. diff --git a/python/functions/metabase/setup.py b/python/functions/metabase/setup.py new file mode 100644 index 00000000..c8eaa4b5 --- /dev/null +++ b/python/functions/metabase/setup.py @@ -0,0 +1,83 @@ +"""Setup inicial de Metabase via API.""" + +import httpx + + +def metabase_setup( + base_url: str, + admin_email: str, + admin_password: str, + admin_first_name: str = "Admin", + admin_last_name: str = "User", + site_name: str = "Metabase", + site_locale: str = "en", +) -> dict: + """Ejecuta el setup inicial de una instancia Metabase nueva. + + Usa el setup-token de una instancia sin configurar para crear el + usuario admin y configurar preferencias del sitio. Solo funciona + una vez — si Metabase ya tiene un usuario, retorna error. + + Endpoint: POST /api/setup (requiere setup-token valido). + + Args: + base_url: URL base de la instancia (ej: "http://localhost:3000"). + admin_email: Email del usuario admin a crear. + admin_password: Password del admin (min 8 chars con complejidad). + admin_first_name: Nombre del admin. + admin_last_name: Apellido del admin. + site_name: Nombre del sitio Metabase. + site_locale: Locale del sitio (ej: "es", "en"). + + Returns: + Dict con el session id del admin recien creado. + + Raises: + httpx.HTTPStatusError: 400 si el setup-token es invalido o + Metabase ya fue configurado. + RuntimeError: Si no se puede obtener el setup-token. + + Example: + >>> result = metabase_setup( + ... "http://localhost:3000", + ... "admin@example.com", + ... "SecurePass123!", + ... site_name="fn-registry", + ... site_locale="es", + ... ) + >>> print(result["id"]) # session token + """ + url = base_url.rstrip("/") + + # Obtener setup-token + resp = httpx.get(f"{url}/api/session/properties") + resp.raise_for_status() + props = resp.json() + setup_token = props.get("setup-token") + if not setup_token: + raise RuntimeError( + "No setup-token disponible. Metabase ya fue configurado " + "o has-user-setup es True." + ) + + # Ejecutar setup + resp = httpx.post( + f"{url}/api/setup", + json={ + "token": setup_token, + "user": { + "first_name": admin_first_name, + "last_name": admin_last_name, + "email": admin_email, + "password": admin_password, + }, + "prefs": { + "site_name": site_name, + "site_locale": site_locale, + "allow_tracking": False, + }, + }, + timeout=30.0, + ) + resp.raise_for_status() + return resp.json()