Files
pgbackweb/internal/integration/postgres/postgres.go
Luis Eduardo Jeréz Girón fb97464444 Include support for PostgreSQL 17
2024-10-13 23:09:15 -06:00

271 lines
7.2 KiB
Go

package postgres
import (
"archive/zip"
"bytes"
"fmt"
"io"
"os"
"os/exec"
"github.com/eduardolat/pgbackweb/internal/util/strutil"
"github.com/orsinium-labs/enum"
)
/*
Important:
Versions supported by PG Back Web must be supported in PostgreSQL Version Policy
https://www.postgresql.org/support/versioning/
Backing up a database from an old unsupported version should not be allowed.
*/
type version struct {
Version string
PGDump string
PSQL string
}
type PGVersion enum.Member[version]
var (
PG13 = PGVersion{version{
Version: "13",
PGDump: "/usr/lib/postgresql/13/bin/pg_dump",
PSQL: "/usr/lib/postgresql/13/bin/psql",
}}
PG14 = PGVersion{version{
Version: "14",
PGDump: "/usr/lib/postgresql/14/bin/pg_dump",
PSQL: "/usr/lib/postgresql/14/bin/psql",
}}
PG15 = PGVersion{version{
Version: "15",
PGDump: "/usr/lib/postgresql/15/bin/pg_dump",
PSQL: "/usr/lib/postgresql/15/bin/psql",
}}
PG16 = PGVersion{version{
Version: "16",
PGDump: "/usr/lib/postgresql/16/bin/pg_dump",
PSQL: "/usr/lib/postgresql/16/bin/psql",
}}
PG17 = PGVersion{version{
Version: "17",
PGDump: "/usr/lib/postgresql/17/bin/pg_dump",
PSQL: "/usr/lib/postgresql/17/bin/psql",
}}
PGVersions = []PGVersion{PG13, PG14, PG15, PG16, PG17}
)
type Client struct{}
func New() *Client {
return &Client{}
}
// ParseVersion returns the PGVersion enum member for the given PostgreSQL
// version as a string.
func (Client) ParseVersion(version string) (PGVersion, error) {
switch version {
case "13":
return PG13, nil
case "14":
return PG14, nil
case "15":
return PG15, nil
case "16":
return PG16, nil
case "17":
return PG17, nil
default:
return PGVersion{}, fmt.Errorf("pg version not allowed: %s", version)
}
}
// Test tests the connection to the PostgreSQL database
func (Client) Test(version PGVersion, connString string) error {
cmd := exec.Command(version.Value.PSQL, connString, "-c", "SELECT 1;")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf(
"error running psql test v%s: %s",
version.Value.Version, output,
)
}
return nil
}
// DumpParams contains the parameters for the pg_dump command
type DumpParams struct {
// DataOnly (--data-only): Dump only the data, not the schema (data definitions).
// Table data, large objects, and sequence values are dumped.
DataOnly bool
// SchemaOnly (--schema-only): Dump only the object definitions (schema), not data.
SchemaOnly bool
// Clean (--clean): Output commands to DROP all the dumped database objects
// prior to outputting the commands for creating them. This option is useful
// when the restore is to overwrite an existing database. If any of the
// objects do not exist in the destination database, ignorable error messages
// will be reported during restore, unless --if-exists is also specified.
Clean bool
// IfExists (--if-exists): Use DROP ... IF EXISTS commands to drop objects in
// --clean mode. This suppresses “does not exist” errors that might otherwise
// be reported. This option is not valid unless --clean is also specified.
IfExists bool
// Create (--create): Begin the output with a command to create the database
// itself and reconnect to the created database. (With a script of this form,
// it doesn't matter which database in the destination installation you
// connect to before running the script.) If --clean is also specified, the
// script drops and recreates the target database before reconnecting to it.
Create bool
// NoComments (--no-comments): Do not dump comments.
NoComments bool
}
// Dump runs the pg_dump command with the given parameters. It returns the SQL
// dump as an io.Reader.
func (Client) Dump(
version PGVersion, connString string, params ...DumpParams,
) io.Reader {
pickedParams := DumpParams{}
if len(params) > 0 {
pickedParams = params[0]
}
args := []string{connString}
if pickedParams.DataOnly {
args = append(args, "--data-only")
}
if pickedParams.SchemaOnly {
args = append(args, "--schema-only")
}
if pickedParams.Clean {
args = append(args, "--clean")
}
if pickedParams.IfExists {
args = append(args, "--if-exists")
}
if pickedParams.Create {
args = append(args, "--create")
}
if pickedParams.NoComments {
args = append(args, "--no-comments")
}
errorBuffer := &bytes.Buffer{}
reader, writer := io.Pipe()
cmd := exec.Command(version.Value.PGDump, args...)
cmd.Stdout = writer
cmd.Stderr = errorBuffer
go func() {
defer writer.Close()
if err := cmd.Run(); err != nil {
writer.CloseWithError(fmt.Errorf(
"error running pg_dump v%s: %s",
version.Value.Version, errorBuffer.String(),
))
}
}()
return reader
}
// DumpZip runs the pg_dump command with the given parameters and returns the
// ZIP-compressed SQL dump as an io.Reader.
func (c *Client) DumpZip(
version PGVersion, connString string, params ...DumpParams,
) io.Reader {
dumpReader := c.Dump(version, connString, params...)
reader, writer := io.Pipe()
go func() {
defer writer.Close()
zipWriter := zip.NewWriter(writer)
defer zipWriter.Close()
fileWriter, err := zipWriter.Create("dump.sql")
if err != nil {
writer.CloseWithError(fmt.Errorf("error creating zip file: %w", err))
return
}
if _, err := io.Copy(fileWriter, dumpReader); err != nil {
writer.CloseWithError(fmt.Errorf("error writing to zip file: %w", err))
return
}
}()
return reader
}
// RestoreZip downloads or copies the ZIP from the given url or path, unzips it,
// and runs the psql command to restore the database.
//
// The ZIP file must contain a dump.sql file with the SQL dump to restore.
//
// - version: PostgreSQL version to use for the restore
// - connString: connection string to the database
// - isLocal: whether the ZIP file is local or a URL
// - zipURLOrPath: URL or path to the ZIP file
func (Client) RestoreZip(
version PGVersion, connString string, isLocal bool, zipURLOrPath string,
) error {
workDir, err := os.MkdirTemp("", "pbw-restore-*")
if err != nil {
return fmt.Errorf("error creating temp dir: %w", err)
}
defer os.RemoveAll(workDir)
zipPath := strutil.CreatePath(true, workDir, "dump.zip")
dumpPath := strutil.CreatePath(true, workDir, "dump.sql")
if isLocal {
cmd := exec.Command("cp", zipURLOrPath, zipPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("error copying ZIP file to temp dir: %s", output)
}
}
if !isLocal {
cmd := exec.Command("wget", "--no-verbose", "-O", zipPath, zipURLOrPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("error downloading ZIP file: %s", output)
}
}
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
return fmt.Errorf("zip file not found: %s", zipPath)
}
cmd := exec.Command("unzip", "-o", zipPath, "dump.sql", "-d", workDir)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("error unzipping ZIP file: %s", output)
}
if _, err := os.Stat(dumpPath); os.IsNotExist(err) {
return fmt.Errorf("dump.sql file not found in ZIP file: %s", zipPath)
}
cmd = exec.Command(version.Value.PSQL, connString, "-f", dumpPath)
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf(
"error running psql v%s command: %s",
version.Value.Version, output,
)
}
return nil
}