diff --git a/README.md b/README.md index 13afc7bd..5b2e476c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # backup-tools This repo contains the PostgreSQL database backup tool. + +Use this through the [pgbackup Helm chart](https://github.com/sapcc/helm-charts/tree/master/common/pgbackup). diff --git a/backup-tools.sh b/backup-tools.sh index 102d9c34..84391de4 100755 --- a/backup-tools.sh +++ b/backup-tools.sh @@ -1,5 +1,4 @@ -#!/usr/bin/env sh -#shellcheck disable=SC3040 # sh in Alpine does support pipefail +#!/usr/bin/env ash set -euo pipefail usage() { @@ -55,7 +54,33 @@ cmd_restore() { if [ "$BACKUP_ID" = "unset" ]; then usage && exit 1 fi - do_curl POST "/v1/restore/${BACKUP_ID}" + if [ "${PGSQL_USER:-unset}" = backup ]; then + # when using the postgresql-ng chart, this container has a restricted user + # with read-only privileges that are enough for creating backups, but not + # for restoring them + echo "Please enter credentials for the PostgreSQL superuser to proceed." + echo -n "Username [postgres]: " + read -r PG_SUPERUSER_NAME + if [ "$PG_SUPERUSER_NAME" = "" ]; then + PG_SUPERUSER_NAME=postgres + fi + echo -n "Password (get this from the respective Kubernetes Secret): " + read -r PG_SUPERUSER_PASSWORD + if [ "$PG_SUPERUSER_PASSWORD" = "" ]; then + echo "ERROR: No password given." >&2 + exit 1 + fi + # to ensure that special characters in the input do not break the JSON payload, + # use jq to convert the raw input into string literals + PG_SUPERUSER_NAME_JSON="$(echo -n "$PG_SUPERUSER_NAME" | jq --raw-input --slurp .)" + PG_SUPERUSER_PASSWORD_JSON="$(echo -n "$PG_SUPERUSER_PASSWORD" | jq --raw-input --slurp .)" + do_curl POST "/v1/restore/${BACKUP_ID}" -d "{\"superuser\":{\"username\":$PG_SUPERUSER_NAME_JSON,\"password\":$PG_SUPERUSER_PASSWORD_JSON}}" + else + # when using the legacy postgres chart, this container uses the superuser + # credentials, so restore works directly + # TODO: remove this case once everyone has been migrated to postgresql-ng + do_curl POST "/v1/restore/${BACKUP_ID}" -d "{\"superuser\":null}" + fi } case "${1:-unset}" in diff --git a/internal/api/api.go b/internal/api/api.go index 42b10400..5cd125e6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -20,6 +20,8 @@ package api import ( + "encoding/json" + "io" "net/http" "github.com/gorilla/mux" @@ -112,6 +114,21 @@ func (a API) handleGetBackups(w http.ResponseWriter, r *http.Request) { func (a API) handlePostRestore(w http.ResponseWriter, r *http.Request) { httpapi.IdentifyEndpoint(r, "/v1/restore/:id") + //superuser credentials may be supplied in the request body + buf, err := io.ReadAll(r.Body) + if respondwith.ErrorText(w, err) { + return + } + var req struct { + SuperUser *restore.SuperUserCredentials `json:"superuser"` + } + if len(buf) > 0 { + err = json.Unmarshal(buf, &req) + if respondwith.ErrorText(w, err) { + return + } + } + //find backup backups, err := restore.ListRestorableBackups(a.Config) if respondwith.ErrorText(w, err) { @@ -124,7 +141,7 @@ func (a API) handlePostRestore(w http.ResponseWriter, r *http.Request) { } //run restore - err = bkp.Restore(a.Config) + err = bkp.Restore(a.Config, req.SuperUser) if err == nil { http.Error(w, "backup restored successfully", http.StatusOK) } else { diff --git a/internal/restore/restore.go b/internal/restore/restore.go index c88fad9d..5842f097 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -23,6 +23,7 @@ import ( "fmt" "os" "os/exec" + "slices" "github.com/kballard/go-shellquote" "github.com/sapcc/go-bits/logg" @@ -30,8 +31,14 @@ import ( "github.com/sapcc/backup-tools/internal/core" ) +// SuperUserCredentials can be used to override the default credentials during Restore(). +type SuperUserCredentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + // Restore downloads and restores this backup into the Postgres. -func (bkp RestorableBackup) Restore(cfg *core.Configuration) error { +func (bkp RestorableBackup) Restore(cfg *core.Configuration, suCreds *SuperUserCredentials) error { //download dumps dirPath := fmt.Sprintf("/tmp/restore-%s", bkp.ID) err := os.MkdirAll(dirPath, 0777) @@ -43,12 +50,22 @@ func (bkp RestorableBackup) Restore(cfg *core.Configuration) error { return err } + //override config if necessary + if suCreds != nil { + cloned := *cfg + cloned.PgUsername = suCreds.Username + cfg = &cloned + } + //playback dumps for _, filePath := range filePaths { cmd := exec.Command("psql", cfg.ArgsForPsql("-a", "-f", filePath)...) //nolint:gosec // input is user supplied and self executed logg.Info(">> " + shellquote.Join(cmd.Args...)) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + if suCreds != nil { + cmd.Env = append(slices.Clone(os.Environ()), "PGPASSWORD="+suCreds.Password) + } err := cmd.Run() if err != nil { return fmt.Errorf("could not import %s with psql: %w", filePath, err)