feat: metabase_setup Python, fix list_databases, volumen Docker en init_metabase
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) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,9 @@ registry.db-wal
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user