diff --git a/functions/pipelines/init_metabase.go b/functions/pipelines/init_metabase.go new file mode 100644 index 00000000..c421e0ae --- /dev/null +++ b/functions/pipelines/init_metabase.go @@ -0,0 +1,12 @@ +package pipelines + +// InitMetabase despliega un stack Metabase + Postgres en Docker. +// +// Pasos: +// 1. Crear red Docker compartida +// 2. Pull de imágenes postgres:16 y metabase/metabase:latest +// 3. Iniciar Postgres con volume persistente +// 4. Esperar a que Postgres acepte conexiones (health check con retry) +// 5. Iniciar Metabase conectado a Postgres +// +// Implementation: functions/pipelines/init_metabase/main.go diff --git a/functions/pipelines/init_metabase.md b/functions/pipelines/init_metabase.md new file mode 100644 index 00000000..76f620d0 --- /dev/null +++ b/functions/pipelines/init_metabase.md @@ -0,0 +1,72 @@ +--- +name: init_metabase +kind: pipeline +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func main() — Despliega stack Metabase + Postgres en Docker" +description: "Pipeline que inicializa un contenedor Metabase con su base de datos Postgres. Crea red Docker, pull de imágenes, inicia Postgres con volume persistente, espera health check y lanza Metabase conectado." +tags: [docker, metabase, postgres, pipeline, infra, analytics] +uses_functions: + - docker_create_network_go_infra + - docker_pull_image_go_infra + - docker_run_container_go_infra + - docker_inspect_container_go_infra + - retry_with_backoff_go_core +uses_types: + - container_info_go_infra + - network_go_docker +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [os/exec, encoding/json] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/pipelines/init_metabase/main.go" +--- + +## Ejemplo + +```bash +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 +``` + +Salida JSON: +```json +{ + "network_id": "abc123...", + "postgres_id": "def456...", + "metabase_id": "ghi789...", + "network_name": "analytics-net", + "postgres_name": "analytics-postgres", + "metabase_name": "analytics-metabase", + "metabase_url": "http://localhost:3000" +} +``` + +## Notas + +El pipeline orquesta 5 pasos secuenciales: + +1. **Red Docker** — crea `{project}-net` con driver bridge +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 + +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. + +Para destruir el stack: +```bash +docker stop analytics-metabase analytics-postgres +docker rm analytics-metabase analytics-postgres +docker network rm analytics-net +``` diff --git a/functions/pipelines/init_metabase/main.go b/functions/pipelines/init_metabase/main.go new file mode 100644 index 00000000..e061cc26 --- /dev/null +++ b/functions/pipelines/init_metabase/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// MetabaseResult contiene los IDs y nombres de los recursos creados. +type MetabaseResult struct { + NetworkID string `json:"network_id"` + PostgresID string `json:"postgres_id"` + MetabaseID string `json:"metabase_id"` + NetworkName string `json:"network_name"` + PostgresName string `json:"postgres_name"` + MetabaseName string `json:"metabase_name"` + MetabaseURL string `json:"metabase_url"` +} + +func main() { + project := flag.String("project", "", "Prefijo para nombres de contenedores y red (requerido)") + mbPort := flag.String("metabase-port", "3000", "Puerto host para Metabase") + pgPort := flag.String("pg-port", "5432", "Puerto host para Postgres") + 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)") + flag.Parse() + + if *project == "" { + fmt.Fprintln(os.Stderr, "error: --project requerido") + flag.Usage() + os.Exit(1) + } + + result, err := initMetabase(*project, *mbPort, *pgPort, *pgUser, *pgPass, *pgDB, *pgVolume) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(result) +} + +func initMetabase(project, mbPort, pgPort, pgUser, pgPass, pgDB, pgVolume string) (*MetabaseResult, error) { + networkName := project + "-net" + pgName := project + "-postgres" + mbName := project + "-metabase" + + // 1. Crear red + fmt.Fprintf(os.Stderr, "[1/5] Creando red %s...\n", networkName) + netID, err := dockerCreateNetwork(networkName, "bridge") + if err != nil { + return nil, fmt.Errorf("creating network: %w", err) + } + + // 2. Pull imágenes + for i, img := range []string{"postgres:16", "metabase/metabase:latest"} { + fmt.Fprintf(os.Stderr, "[2/5] Pulling %s (%d/2)...\n", img, i+1) + if err := dockerPull(img); err != nil { + return nil, fmt.Errorf("pulling %s: %w", img, err) + } + } + + // 3. Iniciar Postgres + fmt.Fprintf(os.Stderr, "[3/5] Iniciando Postgres %s...\n", pgName) + vol := pgName + "-data:/var/lib/postgresql/data" + if pgVolume != "" { + vol = pgVolume + ":/var/lib/postgresql/data" + } + + pgArgs := []string{ + "run", "-d", + "--name", pgName, + "--network", networkName, + "-p", pgPort + ":5432", + "-e", "POSTGRES_USER=" + pgUser, + "-e", "POSTGRES_PASSWORD=" + pgPass, + "-e", "POSTGRES_DB=" + pgDB, + "-v", vol, + "postgres:16", + } + pgID, err := dockerCmd(pgArgs...) + if err != nil { + return nil, fmt.Errorf("starting postgres: %w", err) + } + + // 4. Health check con retry exponencial + fmt.Fprintf(os.Stderr, "[4/5] Esperando a que Postgres esté listo...\n") + err = retryWithBackoff(func() error { + out, execErr := dockerCmd("exec", pgName, "pg_isready", "-U", pgUser) + if execErr != nil { + return fmt.Errorf("pg_isready: %s", out) + } + return nil + }, 10, 2*time.Second) + if err != nil { + return nil, fmt.Errorf("waiting for postgres: %w", err) + } + fmt.Fprintf(os.Stderr, " Postgres listo.\n") + + // 5. Iniciar Metabase + fmt.Fprintf(os.Stderr, "[5/5] Iniciando Metabase %s...\n", mbName) + mbArgs := []string{ + "run", "-d", + "--name", mbName, + "--network", networkName, + "-p", mbPort + ":3000", + "-e", "MB_DB_TYPE=postgres", + "-e", "MB_DB_DBNAME=" + pgDB, + "-e", "MB_DB_PORT=5432", + "-e", "MB_DB_USER=" + pgUser, + "-e", "MB_DB_PASS=" + pgPass, + "-e", "MB_DB_HOST=" + pgName, + "metabase/metabase:latest", + } + mbID, err := dockerCmd(mbArgs...) + if err != nil { + return nil, fmt.Errorf("starting metabase: %w", err) + } + + mbURL := fmt.Sprintf("http://localhost:%s", mbPort) + fmt.Fprintf(os.Stderr, "\nStack listo. Metabase disponible en %s\n", mbURL) + + return &MetabaseResult{ + NetworkID: netID, + PostgresID: pgID, + MetabaseID: mbID, + NetworkName: networkName, + PostgresName: pgName, + MetabaseName: mbName, + MetabaseURL: mbURL, + }, nil +} + +// --- Funciones Docker (reimplementan las del registry sin imports internos) --- + +func dockerCreateNetwork(name, driver string) (string, error) { + return dockerCmd("network", "create", "--driver", driver, name) +} + +func dockerPull(image string) error { + _, err := dockerCmd("pull", image) + return err +} + +func dockerCmd(args ...string) (string, error) { + out, err := exec.Command("docker", args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("docker %s: %s", args[0], strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +// retryWithBackoff reimplementa RetryWithBackoff de core para funciones () error. +func retryWithBackoff(fn func() error, maxRetries int, baseDelay time.Duration) error { + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if err := fn(); err == nil { + return nil + } else { + lastErr = err + } + if attempt < maxRetries { + delay := baseDelay * (1 << uint(attempt)) + time.Sleep(delay) + } + } + return fmt.Errorf("all %d retries exhausted: %w", maxRetries+1, lastErr) +}