Files
goclaw/internal/backup/db_dump.go
T
Duc Nguyen 52f48e5ea6 fix(backup): detect pg server major for version-aware pg_dump hints (#829) (#830)
* fix(backup): detect pg server major for version-aware pg_dump hints

pg_dump aborts when its major version is older than the server's, so
backup preflight now runs SHOW server_version_num against the live PG
server and uses the detected major to drive:

- the missing-pg_dump hint (names the exact postgresqlNN-client)
- a new compat check that flags an installed-but-too-old pg_dump as
  not-ready, instead of letting backup fail partway through the dump

Adds ParsePgDumpMajor helper for Debian/Homebrew/EDB output shapes,
covered by table-driven unit tests. Normalizes nil ctx once at
RunPreflight boundary so downstream exec.CommandContext calls are safe.
Stubs detectPGServerMajor + checkPgDumpServerCompat for the sqliteonly
build.

UI: removes the duplicate static hint from the preflight alert box so
the dynamic, actionable warning is the single source of truth. Drops
the obsolete pgDumpHint i18n key from en/vi/zh locale files.

Refs #829

* fix(docker): bump runtime base alpine 3.22 → 3.23

Alpine 3.23 main ships postgresql16-client, postgresql17-client, and
postgresql18-client simultaneously. Alpine 3.22 only shipped up to
postgresql17-client, so the backup preflight's dynamic hint to install
postgresql18-client previously failed with "no such package" on PG 18
deployments.

No client package is pre-bundled: the on-demand install via the
Packages page now resolves for any supported PG major.

Refs #829
2026-04-11 21:22:04 +07:00

105 lines
2.3 KiB
Go

//go:build !sqliteonly
package backup
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
)
// DumpDatabase runs pg_dump and streams plain-SQL output to w.
// Uses a temporary .pgpass file (0600) to pass the password securely.
// The child process receives only PGPASSFILE, PATH, HOME, LC_ALL=C.
func DumpDatabase(ctx context.Context, dsn string, w io.Writer) error {
creds, err := ParseDSN(dsn)
if err != nil {
return fmt.Errorf("parse DSN: %w", err)
}
pgDump, err := exec.LookPath("pg_dump")
if err != nil {
return fmt.Errorf("pg_dump not found on PATH: %w", err)
}
tempDir, pgpassPath, err := WritePgpass(creds)
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
args := []string{
"--host", creds.Host,
"--port", creds.Port,
"--username", creds.User,
"--dbname", creds.DBName,
"--format=plain",
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
}
cmd := exec.CommandContext(ctx, pgDump, args...)
cmd.Env = CleanEnv(pgpassPath)
cmd.Stdout = w
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return fmt.Errorf("pg_dump failed: %s", errMsg)
}
return nil
}
// PgDumpVersion returns the version string from pg_dump --version.
func PgDumpVersion(ctx context.Context) (string, error) {
pgDump, err := exec.LookPath("pg_dump")
if err != nil {
return "", fmt.Errorf("pg_dump not found: %w", err)
}
out, err := exec.CommandContext(ctx, pgDump, "--version").Output()
if err != nil {
return "", fmt.Errorf("pg_dump --version: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// ParsePgDumpMajor extracts the PostgreSQL major version number from a
// pg_dump --version string. Returns 0 if parsing fails.
// Example inputs:
//
// "pg_dump (PostgreSQL) 17.9 (Debian 17.9-1.pgdg12+1)" -> 17
// "pg_dump (PostgreSQL) 18.3" -> 18
func ParsePgDumpMajor(version string) int {
const marker = "(PostgreSQL) "
idx := strings.Index(version, marker)
if idx < 0 {
return 0
}
rest := version[idx+len(marker):]
end := 0
for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' {
end++
}
if end == 0 {
return 0
}
major, err := strconv.Atoi(rest[:end])
if err != nil {
return 0
}
return major
}