// 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 + `"` }