3b88857999
Agrega documentación de schema rápido en CLAUDE.md, regla sources en INDEX. CLI fn index sincroniza registry.db a directorio Metabase si existe. fn show muestra campos source_*. Fix import en metabase_registry/main.py. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
412 lines
14 KiB
Python
412 lines
14 KiB
Python
"""
|
|
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()
|