internal/tenantbackup: datenbank-scharfes pg_dump/pg_restore statt BAK-01s Cluster-weitem pg_basebackup - bei Modell C (TEN-01, physisch isolierte DB je Mandant) wuerde ein Cluster-Restore zwangslaeufig ALLE Mandanten ueberschreiben. Objekt-Seite nutzt BAK-02 direkt (Mandanten haben eigene Buckets/Pfad-Roots). Eigene Postgres-Rolle nexarch_tenantbackup (CREATEDB, kein Superuser, getrennt von nexarch_backup). Zwei-Tenant-Isolation real in beide Richtungen bewiesen (Markerwert-Nachweis), JSONL-Protokoll fuer Sicherung UND Restore. Realer End-zu-Ende-Lauf ueber tenantbackup-cli auf 131.
156 lines
6.0 KiB
Go
156 lines
6.0 KiB
Go
// Package tenantbackup implementiert BAK-04: Sicherung und
|
||
// Wiederherstellung der Daten EINES einzelnen Mandanten, ohne andere
|
||
// Mandanten zu berühren.
|
||
//
|
||
// BEWUSST NICHT auf BAK-01/BAK-03 aufgesetzt: pg_basebackup (BAK-01)
|
||
// sichert den GESAMTEN Postgres-Cluster — bei Modell C (TEN-01, eine
|
||
// physisch isolierte Datenbank je Mandant) liegen ALLE Mandanten-
|
||
// Datenbanken in genau diesem einen Cluster, ein Restore der
|
||
// Cluster-Sicherung würde also zwangsläufig ALLE Mandanten gleichzeitig
|
||
// überschreiben — das genaue Gegenteil von Mandanten-Isolation. BAK-04
|
||
// braucht daher ein DATENBANK-SCHARFES logisches Verfahren (pg_dump/
|
||
// pg_restore für genau EINE Datenbank), keine physische
|
||
// Cluster-Sicherung. Objekt-Storage ist bereits von Haus aus pro
|
||
// Mandant getrennt (eigener Bucket/Pfad-Root, STORAGE-KONZEPT.md
|
||
// Abschnitt 3) — dort genügt ein restic-Repository je Mandanten-Root
|
||
// (objectbackup-Paket direkt wiederverwendbar, ein Aufruf pro
|
||
// Mandanten-Verzeichnis).
|
||
package tenantbackup
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
)
|
||
|
||
// Config enthält die Verbindungsdaten — ausschließlich über
|
||
// Umgebungsvariablen befüllt (siehe Ticket-Abschluss-Regel).
|
||
type Config struct {
|
||
Host string
|
||
Port string
|
||
User string
|
||
Password string
|
||
BackupDir string
|
||
PgDumpPath string // Default "pg_dump"
|
||
PgRestorePath string // Default "pg_restore"
|
||
PsqlPath string // Default "psql"
|
||
}
|
||
|
||
func (c Config) dumpBinary() string {
|
||
if c.PgDumpPath != "" {
|
||
return c.PgDumpPath
|
||
}
|
||
return "pg_dump"
|
||
}
|
||
|
||
func (c Config) restoreBinary() string {
|
||
if c.PgRestorePath != "" {
|
||
return c.PgRestorePath
|
||
}
|
||
return "pg_restore"
|
||
}
|
||
|
||
func (c Config) psqlBinary() string {
|
||
if c.PsqlPath != "" {
|
||
return c.PsqlPath
|
||
}
|
||
return "psql"
|
||
}
|
||
|
||
const DumpFile = "dump.pgcustom"
|
||
|
||
// Backup erstellt eine logische Sicherung GENAU EINER Mandanten-
|
||
// Datenbank (Custom-Format, `pg_dump -Fc`) — enthält strukturell
|
||
// ausschließlich Daten dieser einen Datenbank, andere Mandanten-
|
||
// Datenbanken werden nie verbunden oder gelesen (Akzeptanzkriterium 1).
|
||
func Backup(ctx context.Context, cfg Config, tenantDB, generationID string) (dumpPath string, err error) {
|
||
dir := filepath.Join(cfg.BackupDir, tenantDB, generationID)
|
||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||
return "", fmt.Errorf("tenantbackup: sicherungsverzeichnis anlegen: %w", err)
|
||
}
|
||
dumpPath = filepath.Join(dir, DumpFile)
|
||
|
||
args := []string{
|
||
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
|
||
"-Fc", "-f", dumpPath, "--no-password", tenantDB,
|
||
}
|
||
cmd := exec.CommandContext(ctx, cfg.dumpBinary(), args...)
|
||
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
return "", fmt.Errorf("tenantbackup: %s fehlgeschlagen: %w (ausgabe: %s)", cfg.dumpBinary(), err, string(output))
|
||
}
|
||
return dumpPath, nil
|
||
}
|
||
|
||
// Verify prüft, dass dumpPath ein vollständig lesbares pg_dump-Custom-
|
||
// Format-Archiv ist — liest die GESAMTE Inhaltsliste (`pg_restore -l`),
|
||
// nicht nur den Dateikopf, damit ein abgeschnittenes oder beschädigtes
|
||
// Archiv zuverlässig auffällt.
|
||
func Verify(ctx context.Context, cfg Config, dumpPath string) error {
|
||
cmd := exec.CommandContext(ctx, cfg.restoreBinary(), "-l", dumpPath)
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
return fmt.Errorf("tenantbackup: sicherung beschädigt oder unvollständig: %w (ausgabe: %s)", err, string(output))
|
||
}
|
||
if len(output) == 0 {
|
||
return fmt.Errorf("tenantbackup: sicherung enthält keine inhaltsliste")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Restore stellt dumpPath in targetDB wieder her — targetDB MUSS bereits
|
||
// existieren und leer sein (angelegt vom Aufrufer über CreateEmptyDatabase),
|
||
// niemals die Quelldatenbank selbst oder eine andere Mandanten-Datenbank
|
||
// (Akzeptanzkriterium 2: Wiederherstellung verändert keine Daten anderer
|
||
// Mandanten — strukturell garantiert, weil pg_restore ausschließlich mit
|
||
// der EINEN übergebenen Zielverbindung spricht).
|
||
func Restore(ctx context.Context, cfg Config, dumpPath, targetDB string) error {
|
||
args := []string{
|
||
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
|
||
// --no-owner: die Zieldatenbank ist eine frische, isolierte
|
||
// Testumgebung (CreateEmptyDatabase) - der urspruengliche
|
||
// Tenant-Eigentuemer existiert dort nicht zwangslaeufig mit
|
||
// gleichen Rechten, und Eigentuemerschaft ist fuer den
|
||
// Restore-Nachweis irrelevant (Standardpraxis beim Restore in
|
||
// eine andere Umgebung).
|
||
"--no-owner",
|
||
"-d", targetDB, "--no-password", dumpPath,
|
||
}
|
||
cmd := exec.CommandContext(ctx, cfg.restoreBinary(), args...)
|
||
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
return fmt.Errorf("tenantbackup: %s fehlgeschlagen: %w (ausgabe: %s)", cfg.restoreBinary(), err, string(output))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CreateEmptyDatabase legt targetDB frisch und leer an — schlägt fehl,
|
||
// wenn targetDB bereits existiert (Isolation: ein Restore darf niemals
|
||
// stillschweigend eine bestehende Datenbank – eigene oder fremde –
|
||
// überschreiben, dieselbe "kein stiller Overwrite"-Disziplin wie BAK-03s
|
||
// internal/restore).
|
||
func CreateEmptyDatabase(ctx context.Context, cfg Config, targetDB string) error {
|
||
cmd := exec.CommandContext(ctx, cfg.psqlBinary(),
|
||
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", "postgres", "--no-password",
|
||
"-c", fmt.Sprintf("CREATE DATABASE %s", quoteIdent(targetDB)))
|
||
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
return fmt.Errorf("tenantbackup: zieldatenbank %q anlegen fehlgeschlagen (existiert sie bereits?): %w (ausgabe: %s)", targetDB, err, string(output))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// quoteIdent ist eine MINIMALE Absicherung für Datenbanknamen, die
|
||
// ausschließlich aus dem eigenen Tenant-Registry-Kontext stammen (nie aus
|
||
// unmittelbarer Benutzereingabe an dieser Stelle) — auf doppelte
|
||
// Anführungszeichen beschränkt, da Postgres-Identifier keine
|
||
// eingebetteten NUL-Bytes zulassen.
|
||
func quoteIdent(name string) string {
|
||
return `"` + name + `"`
|
||
}
|