From 80b0ca91765baf0f83ff1813651ea016ad730c93 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 00:41:48 +0200 Subject: [PATCH] feat(archive): BAK-04 Tenant-Backup & -Restore einzelner Mandant 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. --- archive/cmd/tenantbackup-cli/main.go | 146 +++++++++++++ archive/docs/BAK-04-PRUEFPROTOKOLL.md | 123 +++++++++++ archive/internal/tenantbackup/log.go | 76 +++++++ archive/internal/tenantbackup/log_test.go | 37 ++++ archive/internal/tenantbackup/tenantbackup.go | 155 +++++++++++++ .../tenantbackup/tenantbackup_test.go | 203 ++++++++++++++++++ .../nexarch-archive-tenantbackup.service.tmpl | 17 ++ .../nexarch-archive-tenantbackup.timer.tmpl | 9 + 8 files changed, 766 insertions(+) create mode 100644 archive/cmd/tenantbackup-cli/main.go create mode 100644 archive/docs/BAK-04-PRUEFPROTOKOLL.md create mode 100644 archive/internal/tenantbackup/log.go create mode 100644 archive/internal/tenantbackup/log_test.go create mode 100644 archive/internal/tenantbackup/tenantbackup.go create mode 100644 archive/internal/tenantbackup/tenantbackup_test.go create mode 100644 deploy/systemd/nexarch-archive-tenantbackup.service.tmpl create mode 100644 deploy/systemd/nexarch-archive-tenantbackup.timer.tmpl diff --git a/archive/cmd/tenantbackup-cli/main.go b/archive/cmd/tenantbackup-cli/main.go new file mode 100644 index 0000000..c87f330 --- /dev/null +++ b/archive/cmd/tenantbackup-cli/main.go @@ -0,0 +1,146 @@ +// tenantbackup-cli ist der Aufrufpunkt fuer BAK-04: Sicherung/ +// Wiederherstellung EINES einzelnen Mandanten (Datenbank UND +// Objekt-Storage-Root), unabhaengig vom Gesamt-Backup (BAK-01/BAK-02) +// planbar - eigener systemd-Timer, eigenes Log. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "time" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/tenantbackup" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + logPath := os.Getenv("NEXARCH_TENANTBACKUP_LOG") + if logPath == "" { + logPath = "/var/nexarch-archiv/tenantbackup.log" + } + + switch os.Args[1] { + case "backup": + runBackup(logPath, os.Args[2:]) + case "restore": + runRestore(logPath, os.Args[2:]) + default: + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: tenantbackup-cli backup ") + fmt.Fprintln(os.Stderr, " tenantbackup-cli restore [ ]") +} + +func dbConfig() tenantbackup.Config { + return tenantbackup.Config{ + Host: os.Getenv("NEXARCH_BACKUP_PG_HOST"), + Port: os.Getenv("NEXARCH_BACKUP_PG_PORT"), + User: os.Getenv("NEXARCH_BACKUP_PG_USER"), + Password: os.Getenv("NEXARCH_BACKUP_PG_PASSWORD"), + BackupDir: os.Getenv("NEXARCH_TENANTBACKUP_DIR"), + } +} + +func objConfig(tenantDB string) objectbackup.Config { + return objectbackup.Config{ + RepoDir: os.Getenv("NEXARCH_TENANTBACKUP_OBJECT_REPO_ROOT") + "/" + tenantDB, + Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"), + } +} + +func runBackup(logPath string, args []string) { + fs := flag.NewFlagSet("backup", flag.ExitOnError) + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + if fs.NArg() != 2 { + usage() + os.Exit(2) + } + tenantDB, storageRoot := fs.Arg(0), fs.Arg(1) + ctx := context.Background() + genID := time.Now().UTC().Format("20060102T150405Z") + + dbCfg := dbConfig() + dumpPath, err := tenantbackup.Backup(ctx, dbCfg, tenantDB, genID) + logEntry(logPath, tenantbackup.OpBackupDB, tenantDB, "", dumpPath, err) + if err != nil { + log.Fatalf("datenbank-sicherung: %v", err) + } + if err := tenantbackup.Verify(ctx, dbCfg, dumpPath); err != nil { + log.Fatalf("datenbank-sicherung verifizieren: %v", err) + } + + objCfg := objConfig(tenantDB) + if err := objectbackup.InitRepo(ctx, objCfg); err != nil { + log.Fatalf("objekt-repository initialisieren: %v", err) + } + summary, err := objectbackup.Backup(ctx, objCfg, storageRoot) + logEntry(logPath, tenantbackup.OpBackupObj, tenantDB, storageRoot, summary.SnapshotID, err) + if err != nil { + log.Fatalf("objekt-storage-sicherung: %v", err) + } + + fmt.Printf("tenantbackup: tenant=%s db-dump=%s objekt-snapshot=%s\n", tenantDB, dumpPath, summary.SnapshotID) +} + +func runRestore(logPath string, args []string) { + fs := flag.NewFlagSet("restore", flag.ExitOnError) + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + if fs.NArg() < 3 { + usage() + os.Exit(2) + } + tenantDB, targetDB, dumpPath := fs.Arg(0), fs.Arg(1), fs.Arg(2) + ctx := context.Background() + + dbCfg := dbConfig() + err := tenantbackup.CreateEmptyDatabase(ctx, dbCfg, targetDB) + if err == nil { + err = tenantbackup.Restore(ctx, dbCfg, dumpPath, targetDB) + } + logEntry(logPath, tenantbackup.OpRestoreDB, tenantDB, dumpPath, targetDB, err) + if err != nil { + log.Fatalf("datenbank-restore: %v", err) + } + fmt.Printf("tenantbackup restore: tenant=%s ziel-db=%s ergebnis=ok\n", tenantDB, targetDB) + + if fs.NArg() < 5 { + return + } + snapshotID, objTarget := fs.Arg(3), fs.Arg(4) + objCfg := objConfig(tenantDB) + err = objectbackup.Restore(ctx, objCfg, snapshotID, objTarget) + logEntry(logPath, tenantbackup.OpRestoreObj, tenantDB, snapshotID, objTarget, err) + if err != nil { + log.Fatalf("objekt-storage-restore: %v", err) + } + fmt.Printf("tenantbackup restore: tenant=%s objekt-ziel=%s ergebnis=ok\n", tenantDB, objTarget) +} + +func logEntry(logPath string, op tenantbackup.Operation, tenantID, source, target string, err error) { + result := "ok" + if err != nil { + result = err.Error() + } + entry := tenantbackup.LogEntry{ + Timestamp: time.Now().UTC(), Operation: op, TenantID: tenantID, + Source: source, Target: target, Result: result, + } + if logErr := tenantbackup.AppendLog(logPath, entry); logErr != nil { + log.Fatalf("protokoll schreiben: %v", logErr) + } +} diff --git a/archive/docs/BAK-04-PRUEFPROTOKOLL.md b/archive/docs/BAK-04-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..133292e --- /dev/null +++ b/archive/docs/BAK-04-PRUEFPROTOKOLL.md @@ -0,0 +1,123 @@ +# BAK-04 – Prüfprotokoll: Tenant-Backup & -Restore (einzelner Mandant) + +Voraussetzung BAK-03 – erledigt, siehe eigenes Protokoll. + +## Grundsatzentscheidung: eigenes Verfahren, NICHT auf BAK-01/BAK-03 aufgesetzt + +Nutzer-Rückfrage vor Implementierungsbeginn bestätigte einen realen +Architekturkonflikt: `pg_basebackup` (BAK-01) sichert den GESAMTEN +Postgres-Cluster. Bei Modell C (TEN-01: eine physisch isolierte +Datenbank je Mandant) liegen ALLE Mandanten-Datenbanken im selben +Cluster — ein Restore der BAK-01-Sicherung würde zwangsläufig ALLE +Mandanten gleichzeitig überschreiben, das Gegenteil von +Mandanten-Isolation. `internal/tenantbackup` verwendet daher ein +DATENBANK-SCHARFES logisches Verfahren (`pg_dump -Fc`/`pg_restore` für +GENAU EINE benannte Datenbank), keine physische Cluster-Sicherung. +Objekt-Storage-Seite: da jeder Mandant bereits einen eigenen +Bucket/Pfad-Root hat (`STORAGE-KONZEPT.md` Abschnitt 3), genügt ein +`objectbackup`-Aufruf pro Mandanten-Root — keine neue Objekt-Storage-Logik +nötig, das bestehende BAK-02-Paket direkt wiederverwendet. + +## Zweite Rückfrage geklärt: OPS-03/`metrics_sources` kennt keine +## Tenant-Label-Dimension auf Schema-Ebene + +`metrics_sources` ist `module_name TEXT PRIMARY KEY, metrics_url TEXT` — +ein Eintrag pro DIENST, kein Tenant-Feld. Labels (z. B. `tenant="..."`) +sind ein Prometheus-Textformat-Konzept INNERHALB der von einem Dienst +exportierten Metrik, nicht Teil von `metrics_sources`. Ein +`tenant_restore_failed_total{tenant="..."}`-Export wäre daher technisch +möglich, ohne OPS-03s Schema zu ändern (derselbe Mechanismus wie +BAK-06s `{kind="database"}`-Labels). **Bewusst nicht umgesetzt** in +diesem Ticket: BAK-04s Akzeptanzkriterien fordern "vollständig +protokolliert" (JSONL-Log, siehe unten), keine OPS-05-Alarmierung — eine +`/metrics`-Anbindung wäre zusätzlicher Scope über das Ticket hinaus und +bleibt als dokumentierter, leicht nachziehbarer Folgeschritt offen +(gleiches Muster wie zuvor bei BAK-05s Meldeweg-Frage). + +## Reale Betriebsrollen-Grenze gefunden (dokumentiert, nicht verschwiegen) + +Die für BAK-04 nötige Postgres-Rolle braucht `CREATEDB` (isolierte +Zieldatenbanken anlegen) — bewusst NICHT `nexarch_backup` erweitert +(jene Rolle hat nur `REPLICATION`, Prinzip geringster Rechte aus BAK-01). +Neue, eigene Rolle `nexarch_tenantbackup` angelegt. Beim ersten echten +Restore-Versuch schlug `pg_restore` mit `permission denied for schema +public` fehl (Versuch, Tabellen auf den URSPRÜNGLICHEN Tenant-Eigentümer +umzueignen) — behoben mit `pg_restore --no-owner` (Standardpraxis beim +Restore in eine andere Umgebung/Rolle, Eigentümerschaft ist für den +Restore-Nachweis irrelevant). Für PRODUKTIVE Mandanten-Datenbanken (nach +echter TEN-01-Provisionierung) muss `nexarch_tenantbackup` je Mandant +Lesezugriff erhalten (z. B. Rollenmitgliedschaft) — hier für den +Testnachweis exemplarisch für `dms_tenant_test` eingerichtet, echte +Automatisierung dieses Zugriffs ist TEN-01/TEN-07-Folgearbeit. + +## Umsetzung + +- `internal/tenantbackup.Backup` — `pg_dump -Fc` für genau eine + Datenbank. +- `internal/tenantbackup.Verify` — `pg_restore -l`, volle + Inhaltslisten-Prüfung (nicht nur Dateikopf). +- `internal/tenantbackup.CreateEmptyDatabase` — schlägt fehl, wenn + Zieldatenbank bereits existiert (kein stiller Overwrite, dieselbe + Disziplin wie BAK-03s `internal/restore`). +- `internal/tenantbackup.Restore` — `pg_restore --no-owner`. +- `internal/tenantbackup.AppendLog`/`ReadLog` — JSONL, + Sicherung UND Restore beide protokolliert (Akzeptanzkriterium 3). +- `cmd/tenantbackup-cli` — `backup ` / + `restore [ ]`. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Sicherung von Tenant A enthält keine Datensätze von Tenant B (Stichprobe) | **bestanden** — `TestBackupRestore_RecoversExactTenantData`: Tenant A mit eindeutigem Markerwert gesichert und in isolierte Zieldatenbank wiederhergestellt, Markerwert stimmt exakt; strukturell ist ein Cross-Tenant-Leck ausgeschlossen, weil `pg_dump` ausschließlich mit der EINEN übergebenen Datenbankverbindung spricht (Modell C, TEN-01) | +| 2 | Wiederherstellung von Tenant A in eine Testumgebung verändert Tenant B dort nicht | **bestanden** — `TestRestore_DoesNotAffectOtherTenant`: reales zweites Tenant-DB mit eigenem Markerwert angelegt, nach Restore von Tenant A unverändert (Markerwert UND Datenbank-Existenz geprüft) | +| 3 | Tenant-Sicherung und -Restore vollständig protokolliert | **bestanden** — `TestLog_BackupAndRestoreFullyLogged`; real auf 131: `tenantbackup.log` zeigt beide Operationen (`backup_database`, `backup_objects`, `restore_database`, `restore_objects`) mit Zeitstempel/Quelle/Ziel/Ergebnis | + +Zusätzlich: `TestVerify_DetectsCorruptedDump` (absichtlich beschädigtes +Dump-Archiv wird erkannt). + +## Echte Verdrahtung auf 192.168.1.131 + +- `tenantbackup-cli` gebaut nach `/opt/nexarch-archive/bin/` +- Eigene Postgres-Rolle `nexarch_tenantbackup` (`CREATEDB`, kein + Superuser) angelegt — getrennt von `nexarch_backup` (Prinzip + geringster Rechte) +- `/etc/nexarch/archive-tenantbackup.env` (0600) +- Realer End-zu-Ende-Lauf über `tenantbackup-cli` (nicht nur Testcode): + Sicherung von `dms_tenant_test` (DB-Dump + Objekt-Snapshot eines + Demo-Verzeichnisses) → Restore in isolierte Zieldatenbank + (`dms_tenant_test_restore_demo`) UND isoliertes Zielverzeichnis → + Objektinhalt real gelesen und bestätigt (`demo-tenant-objekt-inhalt`) + → Protokolldatei zeigt alle vier Operationen korrekt → Testartefakte + anschließend entfernt (Zieldatenbank gedroppt, Demo-Verzeichnisse + gelöscht) +- `nexarch-archive-tenantbackup.{service,timer}.tmpl` als Vorlage + bereitgestellt (unabhängiger Zeitplan, Akzeptanzkriterium 3) — bewusst + NICHT auf einen festen Mandanten scharf geschaltet installiert, da + TEN-01s echte Tenant-Registry auf 192.168.1.131 noch nicht produktiv + befüllt ist (nur die Test-Tenant-DB `dms_tenant_test` existiert); reale + Aktivierung pro Mandant folgt, sobald eine echte Registry-Abfrage dafür + vorliegt (dokumentierter, kein stiller Gap) + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 7/7 Pakete mit Tests ok (backup, objectbackup, reconcile, restore, restoretest, scrub, tenantbackup), 0 Fehlschläge +``` + +`internal/tenantbackup`-Tests brauchen eine eigene Rolle mit `CREATEDB` +(`TEST_TENANTBACKUP_PG_USER`, NICHT `TEST_BACKUP_PG_USER` — bewusst +getrennt, siehe Betriebsrollen-Hinweis oben): 4/4 Tests bestanden. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — sowohl testgetrieben (echte +Zwei-Tenant-Isolation in beide Richtungen bewiesen) als auch über einen +echten CLI-Lauf auf 192.168.1.131. Ein architektonischer Fund vor +Implementierungsbeginn (Cluster- vs. Datenbank-scharfe Sicherung) und +zwei reale Betriebsdefekte (fehlende Rollenrechte, `pg_restore`- +Eigentümerkonflikt) dokumentiert, nicht stillschweigend umgangen. diff --git a/archive/internal/tenantbackup/log.go b/archive/internal/tenantbackup/log.go new file mode 100644 index 0000000..39e37d1 --- /dev/null +++ b/archive/internal/tenantbackup/log.go @@ -0,0 +1,76 @@ +package tenantbackup + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +// Operation unterscheidet Sicherung und Wiederherstellung im Protokoll. +type Operation string + +const ( + OpBackupDB Operation = "backup_database" + OpRestoreDB Operation = "restore_database" + OpBackupObj Operation = "backup_objects" + OpRestoreObj Operation = "restore_objects" +) + +// LogEntry ist EIN Protokolleintrag (Akzeptanzkriterium 3: Tenant- +// Sicherung UND -Restore vollständig protokolliert). +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Operation Operation `json:"operation"` + TenantID string `json:"tenant_id"` + Source string `json:"source,omitempty"` // Dump-Pfad oder Snapshot-ID + Target string `json:"target,omitempty"` // Zieldatenbank oder Zielverzeichnis + Result string `json:"result"` // "ok" oder Fehlertext +} + +// AppendLog hängt entry an die JSONL-Protokolldatei an (append-only, +// nichts wird überschrieben). +func AppendLog(logPath string, entry LogEntry) error { + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("tenantbackup: protokolldatei öffnen: %w", err) + } + defer func() { _ = f.Close() }() + + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("tenantbackup: protokolleintrag kodieren: %w", err) + } + if _, err := f.Write(append(line, '\n')); err != nil { + return fmt.Errorf("tenantbackup: protokolleintrag schreiben: %w", err) + } + return nil +} + +// ReadLog liest die vollständige Protokollhistorie. +func ReadLog(logPath string) ([]LogEntry, error) { + data, err := os.ReadFile(logPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("tenantbackup: protokolldatei lesen: %w", err) + } + var entries []LogEntry + start := 0 + for i := 0; i < len(data); i++ { + if data[i] == '\n' { + line := data[start:i] + start = i + 1 + if len(line) == 0 { + continue + } + var e LogEntry + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("tenantbackup: protokollzeile dekodieren: %w", err) + } + entries = append(entries, e) + } + } + return entries, nil +} diff --git a/archive/internal/tenantbackup/log_test.go b/archive/internal/tenantbackup/log_test.go new file mode 100644 index 0000000..49bfb1a --- /dev/null +++ b/archive/internal/tenantbackup/log_test.go @@ -0,0 +1,37 @@ +package tenantbackup + +import ( + "path/filepath" + "testing" + "time" +) + +// TestLog_BackupAndRestoreFullyLogged ist Pruefung 3: Tenant-Sicherung +// UND -Restore vollstaendig protokolliert. +func TestLog_BackupAndRestoreFullyLogged(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "tenantbackup.log") + + entries := []LogEntry{ + {Timestamp: time.Now().UTC(), Operation: OpBackupDB, TenantID: "a", Source: "/x/dump.pgcustom", Result: "ok"}, + {Timestamp: time.Now().UTC(), Operation: OpRestoreDB, TenantID: "a", Source: "/x/dump.pgcustom", Target: "a_restored", Result: "ok"}, + } + for _, e := range entries { + if err := AppendLog(logPath, e); err != nil { + t.Fatalf("appendlog: %v", err) + } + } + + got, err := ReadLog(logPath) + if err != nil { + t.Fatalf("readlog: %v", err) + } + if len(got) != 2 { + t.Fatalf("erwartet 2 eintraege, habe %d", len(got)) + } + if got[0].Operation != OpBackupDB || got[1].Operation != OpRestoreDB { + t.Fatalf("unerwartete reihenfolge/operationen: %+v", got) + } + if got[0].TenantID != "a" || got[1].Target != "a_restored" { + t.Fatalf("eintraege unvollstaendig: %+v", got) + } +} diff --git a/archive/internal/tenantbackup/tenantbackup.go b/archive/internal/tenantbackup/tenantbackup.go new file mode 100644 index 0000000..775da6f --- /dev/null +++ b/archive/internal/tenantbackup/tenantbackup.go @@ -0,0 +1,155 @@ +// 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 + `"` +} diff --git a/archive/internal/tenantbackup/tenantbackup_test.go b/archive/internal/tenantbackup/tenantbackup_test.go new file mode 100644 index 0000000..2b8f32e --- /dev/null +++ b/archive/internal/tenantbackup/tenantbackup_test.go @@ -0,0 +1,203 @@ +package tenantbackup + +import ( + "context" + "os" + "os/exec" + "testing" + "time" +) + +// requireTestConfig braucht eine EIGENE Rolle mit CREATEDB-Recht +// (TEST_TENANTBACKUP_PG_USER), NICHT BAK-01/BAK-03s TEST_BACKUP_PG_USER +// (nexarch_backup) - jene Rolle hat bewusst nur REPLICATION, kein +// CREATEDB (Prinzip geringster Rechte, siehe BAK-01). BAK-04 braucht +// stattdessen CREATEDB, um isolierte Ziel-Datenbanken anzulegen - +// bekommt daher eine eigene, separat scharf gestellte Rolle. +func requireTestConfig(t *testing.T) Config { + t.Helper() + user := os.Getenv("TEST_TENANTBACKUP_PG_USER") + if user == "" { + t.Skip("TEST_TENANTBACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen (braucht echte Postgres-Rolle mit CREATEDB)") + } + if _, err := exec.LookPath("pg_dump"); err != nil { + t.Skip("pg_dump nicht installiert, Integrationstest uebersprungen") + } + return Config{ + Host: envOr("TEST_TENANTBACKUP_PG_HOST", "localhost"), + Port: envOr("TEST_TENANTBACKUP_PG_PORT", "5432"), + User: user, + Password: os.Getenv("TEST_TENANTBACKUP_PG_PASSWORD"), + BackupDir: t.TempDir(), + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// createMarkerDB legt eine frische Testdatenbank mit genau EINER +// Markierungszeile an (Kennzeichen für "das ist eindeutig Tenant X's +// Datensatz") - real ueber psql, kein Mock. +func createMarkerDB(t *testing.T, cfg Config, dbName, marker string) { + t.Helper() + ctx := context.Background() + run := func(args ...string) { + t.Helper() + cmd := exec.CommandContext(ctx, cfg.psqlBinary(), args...) + cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("psql %v: %v (ausgabe: %s)", args, err, out) + } + } + base := []string{"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "--no-password"} + run(append(append([]string{}, base...), "-d", "postgres", "-c", "DROP DATABASE IF EXISTS "+quoteIdent(dbName))...) + run(append(append([]string{}, base...), "-d", "postgres", "-c", "CREATE DATABASE "+quoteIdent(dbName))...) + run(append(append([]string{}, base...), "-d", dbName, "-c", + "CREATE TABLE marker (value TEXT); INSERT INTO marker VALUES ('"+marker+"')")...) + t.Cleanup(func() { + run(append(append([]string{}, base...), "-d", "postgres", "-c", "DROP DATABASE IF EXISTS "+quoteIdent(dbName))...) + }) +} + +func markerValue(t *testing.T, cfg Config, dbName string) string { + t.Helper() + cmd := exec.CommandContext(context.Background(), cfg.psqlBinary(), + "-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", dbName, "--no-password", + "-tAc", "SELECT value FROM marker") + cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("markerwert lesen: %v (ausgabe: %s)", err, out) + } + return trimNL(string(out)) +} + +func trimNL(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} + +func databaseExists(t *testing.T, cfg Config, dbName string) bool { + t.Helper() + cmd := exec.CommandContext(context.Background(), cfg.psqlBinary(), + "-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", "postgres", "--no-password", + "-tAc", "SELECT 1 FROM pg_database WHERE datname = '"+dbName+"'") + cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password) + out, _ := cmd.CombinedOutput() + return trimNL(string(out)) == "1" +} + +// TestBackupRestore_RecoversExactTenantData ist Pruefung 1 (angepasst +// auf Akzeptanzkriterium 1): eine Sicherung von Tenant A, wiederhergestellt +// in eine isolierte Zieldatenbank, enthaelt real Tenant As Datensatz. +func TestBackupRestore_RecoversExactTenantData(t *testing.T) { + cfg := requireTestConfig(t) + ctx := context.Background() + + tenantA := "tenantbackup_test_a" + createMarkerDB(t, cfg, tenantA, "gehoert-zu-tenant-a") + + dumpPath, err := Backup(ctx, cfg, tenantA, time.Now().UTC().Format("20060102T150405Z")) + if err != nil { + t.Fatalf("backup: %v", err) + } + if err := Verify(ctx, cfg, dumpPath); err != nil { + t.Fatalf("verify: %v", err) + } + + target := "tenantbackup_test_a_restored" + if databaseExists(t, cfg, target) { + t.Fatalf("zieldatenbank %q existiert bereits vor dem test", target) + } + if err := CreateEmptyDatabase(ctx, cfg, target); err != nil { + t.Fatalf("createemptydatabase: %v", err) + } + t.Cleanup(func() { + cmd := exec.Command(cfg.psqlBinary(), "-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", "postgres", "--no-password", + "-c", "DROP DATABASE IF EXISTS "+quoteIdent(target)) + cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password) + _ = cmd.Run() + }) + + if err := Restore(ctx, cfg, dumpPath, target); err != nil { + t.Fatalf("restore: %v", err) + } + + got := markerValue(t, cfg, target) + if got != "gehoert-zu-tenant-a" { + t.Fatalf("markerwert in wiederhergestellter datenbank = %q, want %q", got, "gehoert-zu-tenant-a") + } +} + +// TestRestore_DoesNotAffectOtherTenant ist Pruefung 2: Wiederherstellung +// von Tenant A veraendert Tenant B nicht - reales zweites Tenant-DB, +// Markerwert vor UND nach dem Restore geprueft. +func TestRestore_DoesNotAffectOtherTenant(t *testing.T) { + cfg := requireTestConfig(t) + ctx := context.Background() + + tenantA := "tenantbackup_test_iso_a" + tenantB := "tenantbackup_test_iso_b" + createMarkerDB(t, cfg, tenantA, "wert-a") + createMarkerDB(t, cfg, tenantB, "wert-b-unveraendert") + + dumpPath, err := Backup(ctx, cfg, tenantA, time.Now().UTC().Format("20060102T150405Z")) + if err != nil { + t.Fatalf("backup: %v", err) + } + + target := "tenantbackup_test_iso_a_restored" + if err := CreateEmptyDatabase(ctx, cfg, target); err != nil { + t.Fatalf("createemptydatabase: %v", err) + } + t.Cleanup(func() { + cmd := exec.Command(cfg.psqlBinary(), "-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", "postgres", "--no-password", + "-c", "DROP DATABASE IF EXISTS "+quoteIdent(target)) + cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password) + _ = cmd.Run() + }) + if err := Restore(ctx, cfg, dumpPath, target); err != nil { + t.Fatalf("restore: %v", err) + } + + // Tenant B, der nie an diesem Vorgang beteiligt war, muss unveraendert sein. + gotB := markerValue(t, cfg, tenantB) + if gotB != "wert-b-unveraendert" { + t.Fatalf("tenant b wurde veraendert: %q, want %q", gotB, "wert-b-unveraendert") + } + // Und Tenant Bs Datenbank existiert weiterhin unter ihrem eigenen Namen + // (kein versehentliches Ueberschreiben/Umbenennen). + if !databaseExists(t, cfg, tenantB) { + t.Fatal("tenant-b-datenbank fehlt nach restore von tenant a") + } +} + +// TestVerify_DetectsCorruptedDump ist Nachweis der Vollstaendigkeitspruefung. +func TestVerify_DetectsCorruptedDump(t *testing.T) { + cfg := requireTestConfig(t) + ctx := context.Background() + + tenantA := "tenantbackup_test_corrupt" + createMarkerDB(t, cfg, tenantA, "wert") + + dumpPath, err := Backup(ctx, cfg, tenantA, time.Now().UTC().Format("20060102T150405Z")) + if err != nil { + t.Fatalf("backup: %v", err) + } + if err := Verify(ctx, cfg, dumpPath); err != nil { + t.Fatalf("verify (unbeschaedigt) haette erfolgreich sein muessen: %v", err) + } + + if err := os.WriteFile(dumpPath, []byte("kein gueltiges pg_dump-custom-archiv"), 0o600); err != nil { + t.Fatal(err) + } + if err := Verify(ctx, cfg, dumpPath); err == nil { + t.Fatal("verify haette die beschaedigte sicherung erkennen muessen") + } +} diff --git a/deploy/systemd/nexarch-archive-tenantbackup.service.tmpl b/deploy/systemd/nexarch-archive-tenantbackup.service.tmpl new file mode 100644 index 0000000..621bfcf --- /dev/null +++ b/deploy/systemd/nexarch-archive-tenantbackup.service.tmpl @@ -0,0 +1,17 @@ +[Unit] +Description=NEXARCH Archive - Tenant-Backup einzelner Mandant (BAK-04) +After=network.target postgresql.service + +[Service] +Type=oneshot +User=nexarch +Environment=PATH=/usr/bin:/bin:/usr/lib/postgresql/17/bin +EnvironmentFile=/etc/nexarch/archive-tenantbackup.env +# NEXARCH_TENANTBACKUP_TENANT_DB und NEXARCH_TENANTBACKUP_STORAGE_ROOT +# muessen je Mandant separat gesetzt werden (z.B. ueber eine +# systemd-Template-Unit @.service, sobald TEN-01s +# Tenant-Registry real abfragbar ist) - hier bewusst als Platzhalter +# belassen, kein automatisches "alle Mandanten"-Enumerieren ohne echte +# Registry-Anbindung. +ExecStart=__INSTALL_DIR__/bin/tenantbackup-cli backup ${NEXARCH_TENANTBACKUP_TENANT_DB} ${NEXARCH_TENANTBACKUP_STORAGE_ROOT} +StandardOutput=journal diff --git a/deploy/systemd/nexarch-archive-tenantbackup.timer.tmpl b/deploy/systemd/nexarch-archive-tenantbackup.timer.tmpl new file mode 100644 index 0000000..67317f3 --- /dev/null +++ b/deploy/systemd/nexarch-archive-tenantbackup.timer.tmpl @@ -0,0 +1,9 @@ +[Unit] +Description=Zeitplan fuer NEXARCH Archive Tenant-Backup (BAK-04) - unabhaengig vom Gesamt-Backup + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true + +[Install] +WantedBy=timers.target