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 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 == "" { fmt.Fprintln(os.Stderr, "error: --project requerido") flag.Usage() os.Exit(1) } 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) } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") enc.Encode(result) } func initMetabase(project, mbPort, pgPort, pgUser, pgPass, pgDB, pgVolume string, mbExtraVolumes []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, } 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) 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) }