diff --git a/coderd/database/gen/dump/main.go b/coderd/database/gen/dump/main.go index 1f87c94f0e..35a769284b 100644 --- a/coderd/database/gen/dump/main.go +++ b/coderd/database/gen/dump/main.go @@ -3,10 +3,18 @@ package main import ( "database/sql" "fmt" + "net" "os" + "os/exec" + "os/signal" "path/filepath" "runtime" + "strconv" + "strings" + "sync" + "syscall" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -42,10 +50,26 @@ func (*mockTB) TempDir() string { func main() { t := &mockTB{} - defer func() { - for _, f := range t.cleanup { - f() - } + + // Ensure cleanups run on both normal exit and SIGINT/SIGTERM. + // Go's default signal handlers call os.Exit, which skips deferred + // funcs and would leave an embedded-postgres daemon orphaned. + var cleanupOnce sync.Once + runCleanup := func() { + cleanupOnce.Do(func() { + for _, f := range t.cleanup { + f() + } + }) + } + defer runCleanup() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + runCleanup() + os.Exit(130) }() connection := os.Getenv("DB_DUMP_CONNECTION_URL") @@ -54,10 +78,13 @@ func main() { var err error connection, cleanup, err = dbtestutil.OpenContainerized(t, dbtestutil.DBContainerOptions{}) if err != nil { - err = xerrors.Errorf("open containerized database failed: %w", err) - panic(err) + _, _ = fmt.Fprintf(os.Stderr, "containerized postgres unavailable (%s); falling back to embedded postgres\n", err) + connection, cleanup, err = openEmbeddedPostgres() + if err != nil { + panic(err) + } } - defer cleanup() + t.Cleanup(cleanup) } db, err := sql.Open("postgres", connection) @@ -75,6 +102,14 @@ func main() { dumpBytes, err := dbtestutil.PGDumpSchemaOnly(connection) if err != nil { + if !pgDumpUsable() { + _, _ = fmt.Fprintf(os.Stderr, + "\nThis step needs pg_dump (PostgreSQL v13 or later) on PATH OR a Docker-compatible daemon.\n"+ + "Install pg_dump locally to avoid Docker:\n"+ + " mise: mise use -g postgres@13\n"+ + " brew: brew install libpq && brew link --force libpq\n"+ + " apt: sudo apt-get install -y postgresql-client\n\n") + } err = xerrors.Errorf("dump schema failed: %w", err) panic(err) } @@ -89,3 +124,98 @@ func main() { panic(err) } } + +// pgDumpUsable mirrors PGDumpSchemaOnly's requirement (pg_dump on PATH at +// v13 or later). PGDumpSchemaOnly silently falls back to `docker run` when +// either condition fails, so we only show the install hint here when the +// local pg_dump is genuinely unusable. Otherwise an old pg_dump would +// produce a misleading Docker-not-found message. +func pgDumpUsable() bool { + path, err := exec.LookPath("pg_dump") + if err != nil { + return false + } + out, err := exec.Command(path, "--version").Output() + if err != nil { + return false + } + // Output format: "pg_dump (PostgreSQL) 14.5 ..." + parts := strings.Fields(string(out)) + if len(parts) < 3 { + return false + } + major, err := strconv.Atoi(strings.SplitN(parts[2], ".", 2)[0]) + if err != nil { + return false + } + return major >= 13 +} + +func openEmbeddedPostgres() (string, func(), error) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return "", nil, xerrors.Errorf("find ephemeral port: %w", err) + } + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + _ = listener.Close() + return "", nil, xerrors.New("listener returned non-TCP addr") + } + port := tcpAddr.Port + _ = listener.Close() + + cacheRoot, err := os.UserCacheDir() + if err != nil { + cacheRoot = os.TempDir() + } + cacheDir := filepath.Join(cacheRoot, "coder", "dbdump-postgres") + + runtimeDir, err := os.MkdirTemp("", "coder-dbdump-postgres-") + if err != nil { + return "", nil, xerrors.Errorf("create runtime dir: %w", err) + } + + const password = "postgres" + ep := embeddedpostgres.NewDatabase( + embeddedpostgres.DefaultConfig(). + Version(embeddedpostgres.V13). + // repo1.maven.org is flaky; matches cli/server.go and scripts/embedded-pg/main.go. + BinaryRepositoryURL("https://repo.maven.apache.org/maven2"). + BinariesPath(filepath.Join(cacheDir, "bin")). + CachePath(filepath.Join(cacheDir, "cache")). + DataPath(filepath.Join(runtimeDir, "data")). + RuntimePath(filepath.Join(runtimeDir, "runtime")). + Port(uint32(port)). //nolint:gosec // port from listener, fits uint32. + Username("postgres"). + Password(password). + Database("postgres"). + // Postgres canonicalizes timestamptz DEFAULT expressions at + // parse time using the server timezone GUC, then stores the + // canonical form in pg_attrdef. Without UTC, the host's TZ + // leaks into dump.sql as values like '0001-12-31 23:06:32+00 BC'. + StartParameters(map[string]string{"timezone": "UTC"}). + Logger(nil), + ) + + _, _ = fmt.Fprintln(os.Stderr, "starting embedded postgres (first run may download binaries)...") + if err := ep.Start(); err != nil { + _ = os.RemoveAll(runtimeDir) + return "", nil, xerrors.Errorf("start embedded postgres: %w", err) + } + + dsn := dbtestutil.ConnectionParams{ + Username: "postgres", + Password: password, + Host: "127.0.0.1", + Port: strconv.Itoa(port), + DBName: "postgres", + }.DSN() + + cleanup := func() { + if stopErr := ep.Stop(); stopErr != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to stop embedded postgres: %s\n", stopErr) + } + _ = os.RemoveAll(runtimeDir) + } + return dsn, cleanup, nil +}