Compare commits

..
Author SHA1 Message Date
sysops 49a48743f5 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 22:02:02 +02:00
sysops bfa5c61db5 SHL-01: fix — Test-Cleanup zwischen Dialog-Tests (afterEach(cleanup), sonst stapeln sich gerenderte DOM-Bäume) 2026-08-28 22:01:56 +02:00
sysops 11940e28e6 OPS-02: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:39 +02:00
sysops f7bc927d50 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 21:56:31 +02:00
sysops 3c226dab12 SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl) 2026-08-28 21:55:34 +02:00
sysops 0f1add0b78 OPS-02: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:47:48 +02:00
sysops 265c09db24 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite
# Conflicts:
#	DEVLOG.md
2026-08-28 21:47:32 +02:00
sysops 584cefa388 DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 21:45:55 +02:00
sysops 00665592f6 SHL-01: ui-shell-design-system-zentral (tokens, theming, i18n-rahmen, basis-komponenten) 2026-08-28 21:41:56 +02:00
sysops cabcabda8d OPS-02: test-fix — overview-pruefung nur fuer eigene testziele, nicht alle jemals registrierten 2026-08-28 08:29:27 +02:00
sysops 81ebdd892b OPS-02: dev-server + next.js zentrale statusseite 2026-08-28 08:21:27 +02:00
sysops dff760e1ba OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf) 2026-08-28 08:19:40 +02:00
sysopsandClaude Sonnet 5 6a03dcafd6 OPS-01: health-check-endpunkte-je-modul
internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue)
— nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02)
gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen
Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein
DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen
grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten
Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und
liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz-
kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur
Prozessstatus.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" —
   TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool,
   503 mit "database" im Checks-Ergebnis. PASS.
2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter
   Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck:
   ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler
   antwortet deutlich unter 1s. PASS.
3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in
   mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure:
   bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:30:30 +02:00
sysopsandClaude Sonnet 5 b23cd1961f API-02: modul-registry-aktivierungspruefung
internal/moduleregistry: Registry.Register traegt Fachmodule mit Name,
Version und benoetigten Feature-Flags ein (Akzeptanzkriterium 1), fehlende
Pflichtangaben werden abgewiesen. IsActive kombiniert Registrierung + LIC-02
Feature-Flag-Auswertung (ALLE benoetigten Flags muessen fuer den Tenant
aktiv sein) — ein nicht registriertes Modul ist nie aktiv. List liefert alle
Module fuer Statusseite/Lizenzoberflaeche (Akzeptanzkriterium 3).

RequireActiveModule ist die zentrale Durchsetzungs-Middleware (Casbin-
Prinzip): weist Anfragen an ein deaktiviertes Modul ab, BEVOR der
Modul-Handler ueberhaupt aufgerufen wird (Akzeptanzkriterium 2) —
Pruefung per Test belegt, dass der Handler bei Deaktivierung nachweislich
nicht erreicht wird.

Service-Credentials (Akzeptanzkriterium 4): Registry.Provision stellt pro
Modul-Instanz Client-ID + Secret aus, gespeichert wird nur der SHA-256-Hash
des Secrets. Registry.Authenticate vergleicht timing-safe (dieselbe
subtle.ConstantTimeCompare-Referenzimplementierung wie AUD-02).
RequireServiceCredential-Middleware liest X-Client-Id/X-Client-Secret und
weist Aufrufe ohne gueltiges Credential mit 401 ab, bevor der Core-seitige
Endpunkt (z.B. Audit-Nachlieferung, Nutzungsmeldung) erreicht wird.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Anfrage an deaktiviertes Modul nachweislich vor Modul-Logik abgewiesen —
   TestRequireActiveModule_BlocksBeforeHandler: handlerReached bleibt false
   bei 403, wird true erst nach Aktivierung bei 200. PASS.
2. Registrierung mit fehlenden Pflichtangaben abgewiesen —
   TestRegister_RejectsMissingFields (leerer Name, leere Version). PASS.
3. Registry-Abfrage liefert konsistente Daten nach Aktivierung/Deaktivierung —
   TestIsActive_ReflectsFlagStateConsistently: aus/an/aus-Zyklus, IsActive
   folgt dem Flag-Zustand korrekt. PASS.
4. Aufruf mit ungueltigem/fehlendem Service-Credential abgewiesen, mit
   gueltigem angenommen — TestRequireServiceCredential_RejectsInvalidAcceptsValid
   und TestProvisionAndAuthenticate (falsches Secret, unbekannte Client-ID,
   korrektes Credential). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:17:50 +02:00
sysopsandClaude Sonnet 5 4a30345e07 LIC-02: feature-flag-service-je-tenant
internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache,
Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt,
als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur.

evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus,
Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus
Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein
Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen
Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte
Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf
false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den
Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der
TTL (Akzeptanzkriterium 3, kein Neustart noetig).

Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice
als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice
normalisiert.

Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket
besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei
Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt
strukturell der Archive-Retention-Engine vorbehalten.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Cache-Invalidierungszeit automatisiert gemessen —
   TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei
   TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS.
2. Zielgruppen-Strategie liefert erwartete Auswertung —
   TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS.
3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz —
   TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback
   auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS.
4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch
   fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch
   TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt
   konsistent nachvollziehbar) mitabgedeckt. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:19:59 +02:00
sysopsandClaude Sonnet 5 8da9c67d08 TEN-01: tenant-registry-datenbank-provisioning
Registry-DB (nur Tenant-Metadaten), Provisioning-Routine legt pro Mandant
eine physisch isolierte Postgres-DB an und registriert sie transaktional
(Rollback der DB bei fehlgeschlagener Registrierung). Schlanker HTTP-Handler
als Schnittstellen-Vorbereitung fuer API-01/TEN-02, kein eigenes REST-Grundgerüst.

Pruefungen:
1. Migration up/down geschrieben (0001_tenant_registry.{up,down}.sql) — nicht
   gegen echte DB ausgefuehrt, da auf dieser Maschine kein Go/Postgres-Test-
   Setup verfuegbar ist. Offen zur Ausfuehrung.
2. Integrationstest TestProvision_CreatesIsolatedDatabases geschrieben (zwei
   Mandanten, prueft unterschiedliche db_name und current_database()) —
   ebenfalls nicht ausgefuehrt, guarded per TEST_ADMIN_DSN env var. Offen.
3. Slug-Validierung (unit test TestValidateSlug) deckt SQL-Injection-Versuch
   im Datenbanknamen ab — ebenfalls nicht lokal ausgefuehrt, da kein Go
   Compiler auf dieser Maschine vorhanden ist. Offen.

Alle drei Pruefungen sind vorbereitet, aber NICHT durchgefuehrt worden —
zaehlen laut Vorgabe als offen bis auf einer Maschine mit Go+Postgres verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:40:35 +02:00
164 changed files with 3557 additions and 7769 deletions
+2
View File
@@ -1,2 +1,4 @@
*.log *.log
.env .env
web/*/node_modules/
web/*/.next/
+115
View File
@@ -44,3 +44,118 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar. Keine Änderungen ermittelbar.
--- ---
## 2026-08-27 17:26 17:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:36 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/index.ts | 25 +++++++++++++++++++++++++
- web/shl/package.json | 23 +++++++++++++++++++++++
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tsconfig.json | 18 ++++++++++++++++++
---
## 2026-08-28 21:51 21:57 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
### Geänderte Dateien
- web/shl/package.json | 2 ++
- web/shl/vitest.config.ts | 8 ++++++++
- web/shl/vitest.setup.ts | 1 +
---
-18
View File
@@ -1,18 +0,0 @@
version: "2"
run:
timeout: 3m
linters:
default: none
enable:
- govet
- staticcheck
- errcheck
- unused
- ineffassign
formatters:
enable:
- gofmt
- goimports
-19
View File
@@ -1,19 +0,0 @@
.PHONY: build lint fmt test check
build:
go build ./...
lint:
golangci-lint run ./...
fmt:
gofmt -l .
@test -z "$$(gofmt -l .)" || (echo "gofmt-Verstoesse gefunden, siehe oben" && exit 1)
test:
go test ./... -p 1 -count=1
check: build
go vet ./...
golangci-lint run ./...
go test ./... -p 1 -count=1
-62
View File
@@ -1,62 +0,0 @@
# NEXARCH Archive
Zentrales, modulübergreifendes Modul für Aufbewahrung, WORM, Compliance und
Backup. Dieses Verzeichnis enthält bisher `internal/backup` (BAK-01,
Datenbank-Backup-Strategie) — weitere Bausteine folgen ticketweise.
## BAK-01: Datenbank-Backup
`cmd/backup-cli` — Aufrufpunkt für systemd-Timer (siehe
`../deploy/systemd/nexarch-archive-backup-*.timer`):
```bash
export NEXARCH_BACKUP_PG_USER=nexarch_backup
export NEXARCH_BACKUP_PG_PASSWORD=...
export NEXARCH_BACKUP_DIR=/var/nexarch-archiv/backups/postgres # NICHT auf einem ephemeren Test-Dataset (siehe Betrieb)
export NEXARCH_BACKUP_KEEP_GENERATIONS=7 # optional, Default 7
backup-cli full # neue Vollsicherung + Verifikation
backup-cli incremental # inkrementelle Sicherung gegen die neueste Generation
backup-cli rotate # entfernt alle bis auf die neuesten N Generationen
```
Voraussetzung: die konfigurierte Postgres-Rolle braucht das
`REPLICATION`-Attribut (`pg_basebackup` nutzt eine
Replikationsverbindung), und `summarize_wal = on` muss serverseitig gesetzt
sein (PostgreSQL 17s natives inkrementelles Backup, keine WAL-Archivierung
nötig).
## BAK-02: Objekt-Storage-Backup
`cmd/objectbackup-cli` sichert einen lokalen Verzeichnisbaum (den
FDN-03-`LocalDriver`-Basisordner direkt, oder — für S3-gestützte
Deployments — einen vorgelagerten `rclone`-Spiegel) mit
[restic](https://restic.net) (Content-defined Chunking, verschlüsseltes
Repository, geprüftes Tooling statt Eigenbau):
```bash
export NEXARCH_OBJECTBACKUP_REPO_DIR=/var/nexarch-archiv/backups/objects
export NEXARCH_OBJECTBACKUP_PASSWORD=...
export NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS=30 # optional, Default 7
objectbackup-cli backup /var/nexarch-objects # Sicherung + Verifikation
objectbackup-cli check # vollständiges Lesen aller Datenblöcke
objectbackup-cli rotate # restic forget --keep-last N --prune
```
## Betrieb: Backup-Zielverzeichnis
Backup-Ziele liegen unter `/var/nexarch-archiv/` (persistentes ZFS-Dataset,
`zfs/data/subvol-1131-disk-0` auf 192.168.1.131), NIEMALS unter
`/var/nexarch-test/` (ephemeres Dataset, wird von den `reset-test-env.sh`-
Skripten der anderen Module geleert). ZFS-seitige Snapshots/Replikation
dieses Datasets sind ein eigenständiges Infra-Runbook (siehe
`../../STORAGE-KONZEPT.md` Abschnitt 7), kein Ticket-Code — `zfs
dedup=on` bewusst NICHT setzen (hoher RAM-Bedarf), Deduplizierung läuft
ausschließlich App-seitig über restic.
## Prüfungen
```bash
make check # build + vet + lint + test, analog Core/DMS
```
-134
View File
@@ -1,134 +0,0 @@
// backup-cli ist der Aufrufpunkt für BAK-01, gedacht für systemd-Timer
// (siehe deploy/systemd/) — "automatisiert nach Zeitplan" (Akzeptanzkriterium
// 1) entsteht durch die Zeitplan-Definition im Timer-Unit, nicht durch
// einen eigenen In-Prozess-Scheduler (kein zusätzlicher Dauerprozess nötig,
// passt zur Produkt-DNA "kein Anwendungsserver mit unnötigem
// Ressourcenverbrauch").
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
)
func loadConfig() backup.Config {
cfg := backup.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_BACKUP_DIR"),
}
if cfg.Host == "" {
cfg.Host = "localhost"
}
if cfg.Port == "" {
cfg.Port = "5432"
}
if cfg.User == "" || cfg.Password == "" || cfg.BackupDir == "" {
log.Fatal("NEXARCH_BACKUP_PG_USER, NEXARCH_BACKUP_PG_PASSWORD und NEXARCH_BACKUP_DIR muessen gesetzt sein")
}
return cfg
}
func envInt(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
n := def
_, _ = fmt.Sscanf(v, "%d", &n)
return n
}
func latestManifest(backupDir string) (string, error) {
generations, err := backup.ListGenerations(backupDir)
if err != nil {
return "", err
}
if len(generations) == 0 {
return "", fmt.Errorf("keine vorhandene generation fuer inkrementelle sicherung gefunden - zuerst 'full' ausfuehren")
}
latest := generations[len(generations)-1]
full := backupDir + "/" + latest + "/" + backup.FullBackupDirName + "/" + backup.BackupManifestFile
if _, err := os.Stat(full); err == nil {
return full, nil
}
return "", fmt.Errorf("kein backup_manifest in der neuesten generation %q gefunden", latest)
}
func main() {
if len(os.Args) < 2 {
log.Fatal("aufruf: backup-cli <full|incremental|verify|rotate> [args]")
}
cfg := loadConfig()
ctx := context.Background()
switch os.Args[1] {
case "full":
genID := backup.NewGenerationID(time.Now())
manifest, err := backup.FullBackup(ctx, cfg, genID)
if err != nil {
log.Fatalf("vollsicherung fehlgeschlagen: %v", err)
}
dir := manifest[:len(manifest)-len("/"+backup.BackupManifestFile)]
if err := backup.Verify(dir); err != nil {
log.Fatalf("verifikation der vollsicherung fehlgeschlagen: %v", err)
}
fmt.Printf("vollsicherung %q erstellt und verifiziert: %s\n", genID, manifest)
case "incremental":
manifest, err := latestManifest(cfg.BackupDir)
if err != nil {
log.Fatal(err)
}
generations, _ := backup.ListGenerations(cfg.BackupDir)
genID := generations[len(generations)-1]
incID := backup.NewGenerationID(time.Now())
newManifest, err := backup.IncrementalBackup(ctx, cfg, genID, incID, manifest)
if err != nil {
log.Fatalf("inkrementelle sicherung fehlgeschlagen: %v", err)
}
dir := newManifest[:len(newManifest)-len("/"+backup.BackupManifestFile)]
if err := backup.Verify(dir); err != nil {
log.Fatalf("verifikation der inkrementellen sicherung fehlgeschlagen: %v", err)
}
fmt.Printf("inkrementelle sicherung %q erstellt und verifiziert: %s\n", incID, newManifest)
case "rotate":
// BAK-07: gestaffelte Aufbewahrungsfrist (KeepDaily/Weekly/Monthly),
// falls konfiguriert - ersetzt die flache "letzte N"-Regel
// (NEXARCH_BACKUP_KEEP_GENERATIONS), bleibt aber abwaertskompatibel,
// wenn keine Staffelung gesetzt ist.
policy := backup.RetentionPolicy{
KeepDaily: envInt("NEXARCH_BACKUP_KEEP_DAILY", 0),
KeepWeekly: envInt("NEXARCH_BACKUP_KEEP_WEEKLY", 0),
KeepMonthly: envInt("NEXARCH_BACKUP_KEEP_MONTHLY", 0),
}
if policy.KeepDaily > 0 || policy.KeepWeekly > 0 || policy.KeepMonthly > 0 {
removed, err := backup.PruneRotate(cfg.BackupDir, policy)
if err != nil {
log.Fatalf("gestaffelte rotation fehlgeschlagen: %v", err)
}
fmt.Printf("gestaffelte rotation abgeschlossen, %d generation(en) entfernt: %v\n", len(removed), removed)
return
}
keep := 7
if v := os.Getenv("NEXARCH_BACKUP_KEEP_GENERATIONS"); v != "" {
_, _ = fmt.Sscanf(v, "%d", &keep)
}
removed, err := backup.Rotate(cfg.BackupDir, keep)
if err != nil {
log.Fatalf("rotation fehlgeschlagen: %v", err)
}
fmt.Printf("rotation abgeschlossen, %d generation(en) entfernt: %v\n", len(removed), removed)
default:
log.Fatalf("unbekannter befehl %q", os.Args[1])
}
}
-100
View File
@@ -1,100 +0,0 @@
// objectbackup-cli ist der Aufrufpunkt für BAK-02, für systemd-Timer
// gedacht (siehe deploy/systemd/) — "automatisiert nach Zeitplan" entsteht
// durch die Timer-Definition, kein eigener Dauerprozess (dieselbe
// Begründung wie BAK-01 / cmd/backup-cli).
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
)
func loadConfig() objectbackup.Config {
cfg := objectbackup.Config{
RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"),
Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"),
}
if cfg.RepoDir == "" || cfg.Password == "" {
log.Fatal("NEXARCH_OBJECTBACKUP_REPO_DIR und NEXARCH_OBJECTBACKUP_PASSWORD muessen gesetzt sein")
}
return cfg
}
func envInt(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
if n, err := strconv.Atoi(v); err == nil {
return n
}
return def
}
func main() {
if len(os.Args) < 2 {
log.Fatal("aufruf: objectbackup-cli <backup <quellverzeichnis>|check|rotate>")
}
cfg := loadConfig()
ctx := context.Background()
if err := objectbackup.InitRepo(ctx, cfg); err != nil {
log.Fatalf("repository initialisieren: %v", err)
}
switch os.Args[1] {
case "backup":
if len(os.Args) < 3 {
log.Fatal("aufruf: objectbackup-cli backup <quellverzeichnis>")
}
summary, err := objectbackup.Backup(ctx, cfg, os.Args[2])
if err != nil {
log.Fatalf("sicherung fehlgeschlagen: %v", err)
}
if err := objectbackup.Check(ctx, cfg, false); err != nil {
log.Fatalf("verifikation nach sicherung fehlgeschlagen: %v", err)
}
fmt.Printf("sicherung %q erstellt und verifiziert (neu=%d geaendert=%d unveraendert=%d)\n",
summary.SnapshotID, summary.FilesNew, summary.FilesChanged, summary.FilesUnmodified)
case "check":
if err := objectbackup.Check(ctx, cfg, true); err != nil {
log.Fatalf("verifikation fehlgeschlagen: %v", err)
}
fmt.Println("verifikation (mit vollstaendigem lesen) erfolgreich")
case "rotate":
// BAK-07: gestaffelte Aufbewahrungsfrist, falls konfiguriert -
// ersetzt die flache "letzte N"-Regel, bleibt abwaertskompatibel.
policy := objectbackup.RetentionPolicy{
KeepDaily: envInt("NEXARCH_OBJECTBACKUP_KEEP_DAILY", 0),
KeepWeekly: envInt("NEXARCH_OBJECTBACKUP_KEEP_WEEKLY", 0),
KeepMonthly: envInt("NEXARCH_OBJECTBACKUP_KEEP_MONTHLY", 0),
}
if policy.KeepDaily > 0 || policy.KeepWeekly > 0 || policy.KeepMonthly > 0 {
if err := objectbackup.PruneTiered(ctx, cfg, policy); err != nil {
log.Fatalf("gestaffelte rotation fehlgeschlagen: %v", err)
}
fmt.Println("gestaffelte rotation abgeschlossen")
return
}
keep := 7
if v := os.Getenv("NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
keep = n
}
}
if err := objectbackup.Forget(ctx, cfg, keep); err != nil {
log.Fatalf("rotation fehlgeschlagen: %v", err)
}
fmt.Println("rotation abgeschlossen")
default:
log.Fatalf("unbekannter befehl %q", os.Args[1])
}
}
-55
View File
@@ -1,55 +0,0 @@
// reconcile-cli ist der Aufrufpunkt für BAK-05, für systemd-Timer gedacht
// (siehe deploy/systemd/) — "geplanter Abgleichs-Job" (Ticket-Vorgabe)
// entsteht durch die Timer-Definition, kein eigener Dauerprozess.
package main
import (
"context"
"encoding/json"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
)
func main() {
dsn := os.Getenv("NEXARCH_RECONCILE_TENANT_DSN")
storageDir := os.Getenv("NEXARCH_RECONCILE_STORAGE_DIR")
if dsn == "" || storageDir == "" {
log.Fatal("NEXARCH_RECONCILE_TENANT_DSN und NEXARCH_RECONCILE_STORAGE_DIR muessen gesetzt sein")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
dbEntries, err := reconcile.ListDBStorageKeys(ctx, pool)
if err != nil {
log.Fatalf("datenbank-eintraege lesen: %v", err)
}
storageKeys, err := reconcile.ListStorageObjects(storageDir)
if err != nil {
log.Fatalf("objekt-storage durchlaufen: %v", err)
}
report := reconcile.Reconcile(dbEntries, storageKeys)
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
log.Fatalf("bericht ausgeben: %v", err)
}
// Nicht-null-Exit-Code bei Abweichungen (Akzeptanzkriterium 3:
// Abweichungen werden BERICHTET, nicht automatisch behoben — der
// Exit-Code macht das fuer systemd/Monitoring sichtbar, OHNE selbst
// irgendetwas zu reparieren).
if !report.IsClean() {
os.Exit(1)
}
}
-104
View File
@@ -1,104 +0,0 @@
// restore-cli ist der Aufrufpunkt fuer BAK-03: dokumentiertes,
// wiederholbares Restore-Verfahren fuer Datenbank (BAK-01) und
// Objekt-Storage (BAK-02). Kein systemd-Timer (Notfall-/Einzelfall-
// Werkzeug, kein Zeitplan) - manueller Aufruf mit expliziter Bestaetigung
// bei nicht-leerem Ziel.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/restore"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
logPath := os.Getenv("NEXARCH_RESTORE_LOG")
if logPath == "" {
logPath = "/var/nexarch-archiv/restore.log"
}
switch os.Args[1] {
case "database":
runDatabase(logPath, os.Args[2:])
case "objects":
runObjects(logPath, os.Args[2:])
default:
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: restore-cli database [-force] <generation-id> <ziel-verzeichnis>")
fmt.Fprintln(os.Stderr, " restore-cli objects [-force] <snapshot-id> <ziel-verzeichnis>")
fmt.Fprintln(os.Stderr, "(Flag -force MUSS vor den Positionsargumenten stehen, Standardverhalten des Go-flag-Pakets)")
}
func runDatabase(logPath string, args []string) {
fs := flag.NewFlagSet("database", flag.ExitOnError)
force := fs.Bool("force", false, "bestehenden, nicht-leeren Inhalt im Zielverzeichnis ersetzen")
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
if fs.NArg() != 2 {
usage()
os.Exit(2)
}
generationID, target := fs.Arg(0), fs.Arg(1)
cfg := backup.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_BACKUP_DIR"),
}
ctx := context.Background()
entry, err := restore.AtomicRestore(restore.KindDatabase, generationID, target, *force, logPath, func(tempDir string) error {
return backup.Restore(ctx, cfg, generationID, tempDir)
})
report(entry, err)
}
func runObjects(logPath string, args []string) {
fs := flag.NewFlagSet("objects", flag.ExitOnError)
force := fs.Bool("force", false, "bestehenden, nicht-leeren Inhalt im Zielverzeichnis ersetzen")
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
if fs.NArg() != 2 {
usage()
os.Exit(2)
}
snapshotID, target := fs.Arg(0), fs.Arg(1)
cfg := objectbackup.Config{
RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"),
Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"),
}
ctx := context.Background()
entry, err := restore.AtomicRestore(restore.KindObjects, snapshotID, target, *force, logPath, func(tempDir string) error {
return objectbackup.Restore(ctx, cfg, snapshotID, tempDir)
})
report(entry, err)
}
func report(entry restore.LogEntry, err error) {
fmt.Printf("restore %s: quelle=%s ziel=%s ergebnis=%s\n", entry.Kind, entry.Source, entry.Target, entry.Result)
if err != nil {
log.Fatalf("restore fehlgeschlagen: %v", err)
}
}
-71
View File
@@ -1,71 +0,0 @@
// restoretest-cli ist der Aufrufpunkt fuer BAK-06 (systemd-Timer,
// regelmaessiger Zeitplan) - fuehrt einen vollstaendigen Restore-Test
// (Datenbank UND Objekt-Storage) gegen die jeweils neueste Sicherung in
// eine isolierte Testumgebung durch und protokolliert das Ergebnis.
package main
import (
"context"
"log"
"os"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/restoretest"
)
func main() {
testRoot := os.Getenv("NEXARCH_RESTORETEST_DIR")
logPath := os.Getenv("NEXARCH_RESTORETEST_LOG")
if testRoot == "" || logPath == "" {
log.Fatal("NEXARCH_RESTORETEST_DIR und NEXARCH_RESTORETEST_LOG muessen gesetzt sein")
}
if err := os.MkdirAll(testRoot, 0o750); err != nil {
log.Fatalf("testverzeichnis anlegen: %v", err)
}
ctx := context.Background()
failed := false
dbCfg := backup.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_BACKUP_DIR"),
}
pgCfg := restoretest.PgConfig{TestPort: envOr("NEXARCH_RESTORETEST_PG_PORT", "55433")}
dbResult := restoretest.RunDatabaseTest(ctx, dbCfg, pgCfg, testRoot)
logResult(logPath, dbResult)
if !dbResult.Success {
failed = true
}
objCfg := objectbackup.Config{
RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"),
Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"),
}
objResult := restoretest.RunObjectTest(ctx, objCfg, testRoot)
logResult(logPath, objResult)
if !objResult.Success {
failed = true
}
if failed {
os.Exit(1)
}
}
func logResult(logPath string, r restoretest.Result) {
log.Printf("restoretest %s: quelle=%s erfolg=%t detail=%s", r.Kind, r.Source, r.Success, r.Detail)
if err := restoretest.AppendHistory(logPath, r); err != nil {
log.Fatalf("protokoll schreiben: %v", err)
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
-70
View File
@@ -1,70 +0,0 @@
// restoretest-metrics stellt BAK-06s Testlauf-Historie unter /metrics
// bereit - dauerhafter Prozess getrennt vom Oneshot-restoretest-cli
// (Pull-Modell, Core OPS-03 scrapt, kein Push - dieselbe Begruendung wie
// BAK-08s scrub-metrics).
package main
import (
"fmt"
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/restoretest"
)
func main() {
logPath := os.Getenv("NEXARCH_RESTORETEST_LOG")
if logPath == "" {
log.Fatal("NEXARCH_RESTORETEST_LOG muss gesetzt sein")
}
addr := os.Getenv("NEXARCH_RESTORETEST_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8091"
}
mux := http.NewServeMux()
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
history, err := restoretest.ReadHistory(logPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Historie ist append-only (siehe AppendHistory) - die Anzahl
// fehlgeschlagener Eintraege waechst daher von selbst monoton,
// kein separater Zaehlerstand noetig (gueltiger Prometheus-Counter).
var failuresTotal int
lastSuccess := map[restoretest.Kind]float64{restoretest.KindDatabase: -1, restoretest.KindObjects: -1}
for _, res := range history {
if !res.Success {
failuresTotal++
}
if res.Success {
lastSuccess[res.Kind] = 1
} else {
lastSuccess[res.Kind] = 0
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
body := fmt.Sprintf(
"# HELP nexarch_archive_restore_test_failures_total Anzahl seit Einrichtung fehlgeschlagener Restore-Testlaeufe (BAK-06).\n"+
"# TYPE nexarch_archive_restore_test_failures_total counter\n"+
"nexarch_archive_restore_test_failures_total %d\n"+
"# HELP nexarch_archive_restore_test_last_success Letzter Testlauf erfolgreich (1) oder fehlgeschlagen (0), -1 wenn noch kein Lauf, je Art.\n"+
"# TYPE nexarch_archive_restore_test_last_success gauge\n"+
"nexarch_archive_restore_test_last_success{kind=\"database\"} %g\n"+
"nexarch_archive_restore_test_last_success{kind=\"objects\"} %g\n",
failuresTotal, lastSuccess[restoretest.KindDatabase], lastSuccess[restoretest.KindObjects])
if _, err := w.Write([]byte(body)); err != nil {
log.Printf("restoretest-metrics: antwort schreiben: %v", err)
}
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("restoretest-metrics: listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("http server: %v", err)
}
}
-44
View File
@@ -1,44 +0,0 @@
// retention-api ist der Aufrufpunkt fuer RET-06-API: Backend-HTTP-Dienst
// fuer die Aufbewahrungsfristen-Konfiguration (CRUD + Vorschauliste).
// Getrennt vom scrub-metrics-/restoretest-metrics-Muster, weil dies KEIN
// Prometheus-/OPS-03-Endpunkt ist, sondern ein echter Admin-API-Dienst
// (Next.js-Frontend als eigenes Folgeticket).
package main
import (
"context"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi"
)
func main() {
dsn := os.Getenv("NEXARCH_RETENTION_TENANT_DSN")
if dsn == "" {
log.Fatal("NEXARCH_RETENTION_TENANT_DSN muss gesetzt sein")
}
addr := os.Getenv("NEXARCH_RETENTION_API_LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:8092"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
mux := http.NewServeMux()
retentionapi.Mount(mux, pool)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("retention-api: listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("http server: %v", err)
}
}
-144
View File
@@ -1,144 +0,0 @@
// scrub-cli ist der Aufrufpunkt fuer BAK-08 (systemd-Timer, konfigurierbare
// Kadenz) — zieht eine Stichprobe existierender Objekte (BAK-05 als
// Existenz-Quelle), prueft deren Inhalt per SHA-256 gegen
// file_revisions.checksum_sha256, meldet Abweichungen (kein Auto-Repair)
// und schreibt den Befund-Zaehler fuer den OPS-05/OPS-03-Metrik-Export.
package main
import (
"context"
"encoding/json"
"log"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/scrub"
)
type finding struct {
StorageKey string `json:"storage_key"`
DocumentID string `json:"document_id"`
RevisionID string `json:"revision_id"`
Expected string `json:"expected_checksum"`
Actual string `json:"actual_checksum,omitempty"`
Error string `json:"error,omitempty"`
}
type report struct {
GeneratedAt time.Time `json:"generated_at"`
Sampled int `json:"sampled"`
Findings []finding `json:"findings"`
}
func main() {
dsn := os.Getenv("NEXARCH_SCRUB_TENANT_DSN")
storageDir := os.Getenv("NEXARCH_SCRUB_STORAGE_DIR")
if dsn == "" || storageDir == "" {
log.Fatal("NEXARCH_SCRUB_TENANT_DSN und NEXARCH_SCRUB_STORAGE_DIR muessen gesetzt sein")
}
sampleSize := envInt("NEXARCH_SCRUB_SAMPLE_SIZE", 10)
cooldown := envDuration("NEXARCH_SCRUB_COOLDOWN", 24*time.Hour)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
dbEntries, err := reconcile.ListDBStorageKeys(ctx, pool)
if err != nil {
log.Fatalf("datenbank-eintraege lesen: %v", err)
}
storageKeys, err := reconcile.ListStorageObjects(storageDir)
if err != nil {
log.Fatalf("objekt-storage durchlaufen: %v", err)
}
rec := reconcile.Reconcile(dbEntries, storageKeys)
lastScrubbed, err := scrub.LoadLastScrubbed(ctx, pool)
if err != nil {
log.Fatalf("scrub-zustand lesen: %v", err)
}
now := time.Now().UTC()
candidates := scrub.Sample(rec.ExistingInStorage, lastScrubbed, cooldown, sampleSize, now)
keys := make([]string, 0, len(candidates))
for _, c := range candidates {
keys = append(keys, c.StorageKey)
}
expected, err := scrub.ExpectedChecksums(ctx, pool, keys)
if err != nil {
log.Fatalf("erwartete pruefsummen lesen: %v", err)
}
rep := report{GeneratedAt: now, Sampled: len(candidates)}
for _, c := range candidates {
exp, known := expected[c.StorageKey]
if !known {
// Objekt in DB nicht (mehr) auffindbar - das ist BAK-05s
// Zustaendigkeit (existiert der Datenbankeintrag?), nicht
// dieses Jobs; ueberspringen ohne Markierung.
continue
}
actual, readErr := scrub.ActualChecksum(storageDir, c.StorageKey)
ok := readErr == nil && actual == exp
if err := scrub.MarkScrubbed(ctx, pool, c.StorageKey, ok, now); err != nil {
log.Fatalf("scrub-zustand schreiben: %v", err)
}
if !ok {
f := finding{StorageKey: c.StorageKey, DocumentID: c.DocumentID, RevisionID: c.RevisionID, Expected: exp, Actual: actual}
if readErr != nil {
f.Error = readErr.Error()
}
rep.Findings = append(rep.Findings, f)
if err := scrub.RecordFinding(ctx, pool); err != nil {
log.Fatalf("befund-zaehler erhoehen: %v", err)
}
}
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(rep); err != nil {
log.Fatalf("bericht ausgeben: %v", err)
}
// Befund wird gemeldet, nicht automatisch repariert (Akzeptanzkriterium
// 3) - der Exit-Code macht das fuer systemd/Monitoring sichtbar, ohne
// selbst etwas zu reparieren; die tatsaechliche Meldung an OPS-05
// laeuft ueber den separaten /metrics-Export (cmd/scrub-metrics), nicht
// ueber diesen Exit-Code.
if len(rep.Findings) > 0 {
os.Exit(1)
}
}
func envInt(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
log.Fatalf("%s: ungueltiger wert %q: %v", name, v, err)
}
return n
}
func envDuration(name string, def time.Duration) time.Duration {
v := os.Getenv(name)
if v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
log.Fatalf("%s: ungueltiger wert %q: %v", name, v, err)
}
return d
}
-62
View File
@@ -1,62 +0,0 @@
// scrub-metrics stellt BAK-08s Befund-Zaehler unter /metrics bereit — die
// OPS-05-Anbindung ist Pull-basiert (Core OPS-03 scrapt /metrics-URLs, kein
// Push-Mechanismus), daher braucht es einen eigenen, dauerhaft laufenden
// HTTP-Endpunkt getrennt vom Oneshot-scrub-cli (dessen Prozess nach jedem
// Lauf beendet ist und daher zum Scrape-Zeitpunkt nicht erreichbar waere).
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/scrub"
)
func main() {
dsn := os.Getenv("NEXARCH_SCRUB_TENANT_DSN")
if dsn == "" {
log.Fatal("NEXARCH_SCRUB_TENANT_DSN muss gesetzt sein")
}
addr := os.Getenv("NEXARCH_SCRUB_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8090"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
mux := http.NewServeMux()
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
total, err := scrub.FindingsTotal(r.Context(), pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
// Counter (Akzeptanzkriterium/Nutzervorgabe: monoton steigend, kein
// Gauge) - kein Befund => Wert 0, kein Dauer-Alarm ("kein Befund
// bedeutet kein Alarm", nicht "kein Wert").
body := fmt.Sprintf(
"# HELP nexarch_archive_storage_integrity_failures_total Anzahl seit Einrichtung gefundener Pruefsummen-Abweichungen (BAK-08).\n"+
"# TYPE nexarch_archive_storage_integrity_failures_total counter\n"+
"nexarch_archive_storage_integrity_failures_total %d\n", total)
if _, err := w.Write([]byte(body)); err != nil {
log.Printf("scrub-metrics: antwort schreiben: %v", err)
}
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("scrub-metrics: listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("http server: %v", err)
}
}
-146
View File
@@ -1,146 +0,0 @@
// 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 <tenant-db> <objekt-storage-root>")
fmt.Fprintln(os.Stderr, " tenantbackup-cli restore <tenant-db> <ziel-db> <dump-datei> [<snapshot-id> <objekt-ziel-verzeichnis>]")
}
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)
}
}
-97
View File
@@ -1,97 +0,0 @@
# BAK-01 Prüfprotokoll: Datenbank-Backup-Strategie
Welle 1, keine Vorbedingungen. Neues Modul-Verzeichnis `code/archive/`
(gleiches Monorepo-Muster wie `code/dms/`), eigenes Go-Modul
`gitea.perlbach24.de/scripte/nexarch/archive`.
## Grundsatzentscheidung: PostgreSQL-17-natives inkrementelles Backup
`pg_dump` kennt nur logische Vollsicherungen — "inkrementell" im Sinne des
Tickets erfordert das physische Backup-Verfahren. Gewählt: PostgreSQL 17s
natives `pg_basebackup --incremental` (WAL-Summarization), NICHT klassisches
WAL-Archiving (`archive_mode`), weil letzteres einen Neustart der
(geteilten, auch von Core/DMS-Tests genutzten) Postgres-Instanz auf
192.168.1.131 erfordert hätte. Stattdessen `summarize_wal = on` gesetzt —
nur ein `pg_reload_conf()`, kein Neustart, keine Unterbrechung laufender
Verbindungen (per Health-Check nach der Änderung bestätigt).
Voraussetzung geschaffen: Rolle `nexarch_backup` mit `REPLICATION`-Attribut
angelegt (Postgres verlangt eine Replikationsverbindung für
`pg_basebackup`), `pg_hba.conf` erlaubte lokale Replikationsverbindungen
bereits.
## Umsetzung
- `internal/backup.FullBackup`/`IncrementalBackup` — rufen `pg_basebackup`
über `os/exec` auf, Ergebnis landet in einer Generationsstruktur
(`<BackupDir>/<Generation>/full/` bzw. `.../incremental/<ID>/`).
- `internal/backup.Verify` — öffnet `base.tar.gz` vollständig (gzip- UND
tar-Stream, jeder Eintrag bis zum Ende gelesen, nicht nur Kopfdaten) —
Akzeptanzkriterium 2: Verifikation auf Lesbarkeit, nicht nur Erstellung.
- `internal/backup.Rotate`/`ListGenerations` — Generationen sind nach
Zeitstempel-ID sortierbar, `Rotate` entfernt die ältesten bis auf `keep`
komplett (inklusive aller abhängigen Inkremente).
- `cmd/backup-cli``full`/`incremental`/`rotate`, aufgerufen von
systemd-Timern (`deploy/systemd/nexarch-archive-backup-*.timer`) —
"automatisiert nach Zeitplan" (Akzeptanzkriterium 1) entsteht durch die
Timer-Definition, kein zusätzlicher Dauerprozess nötig.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Sicherung gegen Testdatenbank erfolgreich erstellt und verifiziert | **bestanden**`TestFullBackup_CreatesVerifiedBackup` gegen die echte Postgres-17-Instanz auf 192.168.1.131 (kein Mock), zusätzlich `TestIncrementalBackup_IsSmallerThanFull`: inkrementelle Sicherung real deutlich kleiner als Vollsicherung (167 KB vs. 16 MB bei der ersten manuellen Probe) — beweist echte inkrementelle Übertragung, nicht nur eine zweite Vollsicherung |
| 2 | Verifikation erkennt eine absichtlich beschädigte Sicherungsdatei | **bestanden**`TestVerify_DetectsCorruptedFile`: 64 Bytes in der Mitte von `base.tar.gz` gekippt, `Verify` schlägt danach fehl (unbeschädigt zuvor erfolgreich) |
| 3 | Rotationsregel entfernt nachweislich nur die ältesten Generationen | **bestanden**`TestRotate_RemovesOnlyOldestGenerations`: 5 Generationen, `keep=2`, exakt die 3 ältesten entfernt, die 2 neuesten nachweislich unangetastet |
## Echte Verdrahtung auf 192.168.1.131 (nicht nur Testcode)
Anders als die zuletzt in DMS gefundenen "Baustein existiert, ist aber
nirgends verdrahtet"-Fälle (FDN-03/FDN-09 gegen Core) wurde hier die
komplette Kette tatsächlich installiert und ausgeführt:
- `backup-cli` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-backup.env` mit den Verbindungsdaten (0600)
- 3 systemd-Timer installiert und aktiviert (`enable --now`):
Vollsicherung täglich 02:00 UTC, Inkrement stündlich, Rotation täglich
03:00 UTC (`systemctl list-timers` bestätigt alle drei scharf)
- Jeder der drei Dienste (`full`/`incremental`/`rotate`) einmal manuell über
`systemctl start` ausgelöst (nicht nur `go test` direkt) — alle drei mit
`status=0/SUCCESS`, Journal bestätigt inhaltlich korrekte Ausgabe
(Vollsicherung erstellt+verifiziert, Inkrement erstellt+verifiziert
gegen die richtige Vorgänger-Generation, Rotation lief ohne Fehler)
## 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 -> 4/4 Tests ok, 0 Fehlschläge (echter Postgres 17, kein Mock)
```
## Nachtrag (BAK-02-Sitzung): Backup-Zielverzeichnis korrigiert
`NEXARCH_BACKUP_DIR` zeigte ursprünglich auf `/var/backups/nexarch`
(Root-Dateisystem des Containers, kein dediziertes Dataset) — korrigiert auf
`/var/nexarch-archiv/backups/postgres` (persistentes ZFS-Dataset), siehe
`docs/BAK-02-PRUEFPROTOKOLL.md` Abschnitt „Korrektur an BAK-01" für Details.
Vollsicherung nach der Korrektur erneut über systemd ausgelöst, landet
nachweislich am neuen Ort.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt — inklusive tatsächlicher systemd-Timer-Installation und
manuell ausgelöstem End-to-End-Lauf aller drei Dienste auf dem Testhost,
nicht nur isolierter Testcode.
## Nachtrag (BAK-03): Verify prüft jetzt auch pg_wal.tar.gz
Beim Bau von BAK-03s echtem Restore-Test fiel auf, dass `pg_basebackup`
(Standard-WAL-Methode `stream`) bei `-Ft -z` NEBEN `base.tar.gz` eine
zweite Archivdatei `pg_wal.tar.gz` erzeugt, die `Verify` bislang nie
geprüft hat — eine Sicherung mit beschädigtem WAL-Archiv wäre unbemerkt
nicht crash-konsistent wiederherstellbar gewesen. `Verify` prüft seither
beide Archive vollständig (siehe `BAK-03-PRUEFPROTOKOLL.md`). Das
Sicherungsverfahren selbst (Format, Ort, Rotation) bleibt unverändert.
-93
View File
@@ -1,93 +0,0 @@
# BAK-02 Prüfprotokoll: Objekt-Storage-Backup/Snapshots
Welle 1, keine Vorbedingungen.
## Grundsatzentscheidung: restic statt Eigenbau
Nutzerentscheidung: restic statt einer Neuimplementierung, weil restic alle
vier Akzeptanzkriterien mit ausgereiftem, breit geprüftem Tooling erfüllt
(Content-defined Chunking für Dedup, `check --read-data` für
Vollständigkeit, `forget --keep-last` für Rotation, Repository-Verschlüsselung
ab Werk). Installiert via `apt-get install restic` (Version 0.18.0).
Backup-Quelle ist ein lokaler Verzeichnisbaum — für den FDN-03-`LocalDriver`
direkt dessen Basisverzeichnis. Für S3-gestützte Produktions-Deployments
(Betriebsmodus 2/3 aus `STORAGE-KONZEPT.md` Abschnitt 6.2) wäre ein
vorgelagerter Sync-Schritt (z. B. `rclone`) nötig, um Bucket-Inhalte lokal
zu spiegeln, bevor restic sie sichert — restic sichert Dateibäume, keine
S3-Buckets direkt. Das bleibt hier bewusst unimplementiert (kein konkreter
S3-Produktionsbestand vorhanden, der das aktuell erfordert), aber
architektonisch vorgesehen und dokumentiert (`README.md`).
## Umsetzung
- `internal/objectbackup.InitRepo` — idempotent, erkennt "bereits
initialisiert" am `restic init`-Fehlertext statt zu scheitern.
- `internal/objectbackup.Backup``restic backup --json`, parst die
`summary`-Zeile (mehrere JSON-Zeilen in der Ausgabe, gezielt die mit
`message_type=="summary"` gesucht).
- `internal/objectbackup.Check``restic check [--read-data]` (Akzeptanz-
kriterium 3: Vollständigkeitsprüfung).
- `internal/objectbackup.Forget``restic forget --keep-last N --prune`
(Rotation).
- `cmd/objectbackup-cli``backup <dir>`/`check`/`rotate`, aufgerufen von
systemd-Timern (stündlich/wöchentlich/täglich).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Zweiter Sicherungslauf nach unverändertem Bestand überträgt keine Daten erneut | **bestanden**`TestBackup_UnchangedSecondRunTransmitsNothingNew`: zweiter Lauf gegen unveränderten Bestand liefert `files_new=0`, `files_changed=0`, `files_unmodified=1` |
| 2 | Zwei identische Testdateien belegen nachweislich nur einmal Speicherplatz | **bestanden**`TestBackup_DeduplicatesIdenticalContent`: zwei Dateien mit identischem Inhalt erzeugen `data_blobs=1`, nicht 2 — echter Dedup-Nachweis über restics Content-defined Chunking, nicht nur Namensvergleich |
| 3 | Vollständigkeitsprüfung erkennt ein fehlendes Objekt in der Sicherung | **bestanden**`TestCheck_DetectsCorruptedPack`: ein Byte in einer echten Repository-Pack-Datei gekippt, `Check(readData=true)` schlägt danach fehl (unbeschädigt zuvor erfolgreich) — dieselbe Vorgehensweise wie die manuelle Recherche vor der Implementierung |
Zusätzlich (nicht explizit als Pflichtprüfung gefordert, aber Teil von
Akzeptanzkriterium 3 „lässt sich einzeln prüfen"): `TestForget_
KeepsOnlyRequestedSnapshotCount` — 3 Sicherungsläufe, `Forget(keepLast=1)`
reduziert auf genau 1 verbleibenden Snapshot.
## Korrektur an BAK-01 im selben Rutsch: Backup-Zielverzeichnis
Nutzerhinweis aufgegriffen: `NEXARCH_BACKUP_DIR` zeigte bei BAK-01
ursprünglich auf `/var/backups/nexarch` (Root-Dateisystem des LXC-
Containers, nicht auf einem der beiden dedizierten ZFS-Datasets). Korrigiert
auf `/var/nexarch-archiv/backups/postgres` (persistentes Dataset
`zfs/data/subvol-1131-disk-0`), NICHT `/var/nexarch-test/` (ephemeres
Dataset `ssd-rpool-data/swap/subvol-1131-disk-0`, wird von
`reset-test-env.sh`-Skripten anderer Module geleert). `objectbackup-cli`s
Repository liegt von Anfang an korrekt unter
`/var/nexarch-archiv/backups/objects`. Beide Pfade real auf
192.168.1.131 verifiziert (`df`/`mount` bestätigt ZFS-Dataset-Zuordnung),
BAK-01s Vollsicherung nach der Korrektur erneut über systemd ausgelöst und
bestätigt am neuen Ort gelandet.
ZFS-seitige Snapshot-/Replikations-Strategie für `nexarch/archiv` bleibt
bewusst außerhalb dieses Tickets (Infra-Runbook, siehe
`STORAGE-KONZEPT.md` Abschnitt 7 „Backup vs. Storage-Redundanz" sowie den
Hinweis, `zfs dedup=on` NICHT zu setzen — App-seitige Dedup über restic
genügt, ZFS-Dedup wäre auf dem 4-GB-Testhost ein Speicherrisiko).
## Echte Verdrahtung auf 192.168.1.131
- `objectbackup-cli` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-objectbackup.env` (0600)
- 3 systemd-Timer installiert und aktiviert: Sicherung stündlich (`:30`),
Vollständigkeitsprüfung wöchentlich (So. 04:00 UTC), Rotation täglich
(03:30 UTC) — `systemctl list-timers` bestätigt alle scharf
- Jeder der drei Dienste einmal über `systemctl start` ausgelöst, alle mit
`status=0/SUCCESS`; Journal bestätigt inhaltlich korrekte Ausgabe
## 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 -> 2/2 Pakete mit Tests ok (internal/backup, internal/objectbackup), 0 Fehlschläge
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real gegen echtes restic-Tooling erfüllt. BAK-01-Pfadfehler im selben
Rutsch korrigiert und erneut end-to-end verifiziert.
-122
View File
@@ -1,122 +0,0 @@
# BAK-03 Prüfprotokoll: Restore-Verfahren
Voraussetzungen BAK-01, BAK-02 erledigt, siehe eigene Protokolle.
## Grundsatzentscheidung: Atomarität über Temp-Verzeichnis + Rename
`internal/restore.AtomicRestore` kennt weder Postgres noch restic —
reine Ablaufsteuerung: Wiederherstellung IMMER in ein frisches
temporäres Verzeichnis (nie direkt in das Ziel), Übernahme erst bei
Erfolg per `os.Rename` (selbes Dateisystem wie das Ziel, daher atomar).
Ist das Ziel nicht leer und keine Bestätigung (`-force`) gegeben, wird
NICHT einmal die Wiederherstellungsfunktion aufgerufen — Abbruch vor
jeder Berührung des Ziels (Akzeptanzkriterium 2). Jeder Aufruf — Erfolg,
Abbruch oder Fehler — erzeugt genau einen JSONL-Protokolleintrag
(Akzeptanzkriterium 3).
Die eigentliche Wiederherstellung bleibt in `internal/backup.Restore`
(Datenbank) und `internal/objectbackup.Restore` (Objekt-Storage) —
`internal/restore` orchestriert nur.
## Drei reale Defekte während der Implementierung gefunden und behoben
Alle drei erst durch den ECHTEN Restore-Test (Postgres-Instanz tatsächlich
gestartet, nicht nur Dateien verglichen) aufgedeckt:
1. **`pg_combinebackup` braucht Plain-Format, BAK-01 liefert Tar+Gzip.**
`Restore` extrahiert jetzt jede Sicherungsstufe zunächst in ein
temporäres Plain-Verzeichnis (inkl. `backup_manifest`-Kopie) und
speist erst DIESE in `pg_combinebackup`. BAK-01s Speicherformat selbst
unverändert (kleinere, leichter prüfbare Dateien).
2. **`pg_wal.tar.gz` wurde nie verifiziert oder wiederhergestellt.**
`pg_basebackup`s Standard-WAL-Methode (`stream`) erzeugt bei `-Ft -z`
eine ZWEITE Archivdatei neben `base.tar.gz` — ohne sie ist keine
crash-konsistente Wiederherstellung möglich (Postgres findet sonst
keinen gültigen Checkpoint). `backup.Verify` prüft jetzt BEIDE
Archive vollständig; `backup.Restore` extrahiert das WAL der ZULETZT
gezogenen Stufe (nicht aller Stufen) nach `pg_wal/` im
Wiederherstellungsziel. Ergänzung zu BAK-01s Prüfung, keine
Umstellung des Sicherungsverfahrens.
3. **Go-`exec`-Hänger bei `pg_ctl start`:** `pg_ctl` startet Postgres als
Hintergrundprozess, der die geerbten stdout/stderr-Pipes NICHT
schließt — `CombinedOutput()` (wartet auf Pipe-EOF) hängt sich
dadurch auf, obwohl `pg_ctl` selbst längst zurückgekehrt ist. Fix nur
im Testcode: echte Logdatei (`-l`) statt Pipe, Standard-`pg_ctl`-Muster.
Zusätzlich real (nicht Ticket-relevant, aber notiert): auf diesem
Debian-Postgres liegen `postgresql.conf`/`pg_hba.conf` NICHT in PGDATA
(sondern `/etc/postgresql/17/main/`) — ein echtes Restore-Runbook muss
diese Dateien separat mitführen/rekonstruieren, `pg_basebackup` sichert
sie nicht. Für den Testnachweis minimal nachgereicht, kein Produktcode.
## Umsetzung
- `internal/restore.AtomicRestore` — Ablaufsteuerung, JSONL-Protokoll
(`Timestamp`, `Kind`, `Source`, `Target`, `Result`).
- `internal/backup.Restore``pg_combinebackup` gegen extrahierte
Plain-Verzeichnisse + WAL-Wiederherstellung der letzten Stufe.
- `internal/backup.extractTarGz` (in `verify.go`, neben der bestehenden
Tar/Gzip-Leselogik) — vollständige Extraktion, kein Kopf-only-Read.
- `internal/backup.Verify` — prüft jetzt `base.tar.gz` UND
`pg_wal.tar.gz` vollständig.
- `internal/objectbackup.Restore``restic restore --target`.
- `cmd/restore-cli``database`/`objects`-Unterbefehle, `-force`-Flag
(vor Positionsargumenten, Go-`flag`-Konvention), kein systemd-Timer
(Notfall-/Einzelfall-Werkzeug, kein Zeitplan).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Restore auf leerem System vollständig und erfolgreich durchgeführt | **bestanden** — real: `TestRestore_CombinesIntoStartablePostgresInstance` (Postgres tatsächlich aus wiederhergestelltem Verzeichnis gestartet, `SELECT 1` über echte Verbindung beantwortet); zusätzlich real per `restore-cli database` auf 131 ausgeführt (PG_VERSION/base/pg_wal vorhanden, Exit 0); `TestRestore_RecoversRealContentFromSnapshot` (Objekt-Storage, echter Dateiinhalt verglichen) UND real per `restore-cli objects` auf 131 (echter restic-Snapshot wiederhergestellt) |
| 2 | Restore auf nicht-leeres Zielverzeichnis lässt bei Abbruch den ursprünglichen Inhalt unverändert | **bestanden**`TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched` (restoreFn wird nachweislich NIE aufgerufen) und `TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched`; real auf 131: `restore-cli database` gegen nicht-leeres Ziel ohne `-force` → Abbruch, bestehende Testdatei unverändert vorhanden |
| 3 | Protokolleintrag zum Restore ist vollständig und nachvollziehbar | **bestanden**`TestAtomicRestore_LogsCompleteEntry` (Quelle, Ziel, Zeitpunkt, Ergebnis für Erfolgs- UND Abbruchfall in derselben Datei); real auf 131: `/tmp/restore-cli-test.log` zeigt alle vier realen Läufe (Abbruch, Fehler mangels PATH, Erfolg, Force-Überschreiben) korrekt protokolliert |
Zusätzlich: `TestAtomicRestore_EmptyTarget_Succeeds`,
`TestAtomicRestore_ForceOverwritesNonEmptyTarget` (bewusste Bestätigung
ersetzt bestehenden Inhalt real, alter Inhalt nachweislich weg, neuer da).
## Echte Verdrahtung auf 192.168.1.131
- `restore-cli` gebaut nach `/opt/nexarch-archive/bin/`
- Kein systemd-Timer (bewusst — Notfall-/Einzelfall-Werkzeug)
- Vier reale CLI-Läufe durchgeführt und protokolliert: Abbruch bei
nicht-leerem Ziel ohne `-force`, Fehler mangels `pg_combinebackup` im
PATH (zeigt: Fehler wird korrekt erkannt UND protokolliert, kein
stiller Fehlschlag), erfolgreicher Restore einer echten Generation
(`20260829T222054Z`) in leeres Ziel, erfolgreiches Überschreiben mit
`-force`. Objekt-Storage-Restore real gegen echten restic-Snapshot
(`43da36bf`) — Inhalt (`.placeholder`-Datei) tatsächlich vorhanden.
- Alle Testartefakte (`/tmp/restore-*`, Testlog) nach Prüfung entfernt.
## Build/Test-Ergebnis
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
```
`internal/restore`: 5/5 Tests (reine Funktionen, kein Setup nötig).
`internal/objectbackup`: `TestRestore_RecoversRealContentFromSnapshot`
bestanden (zusammen mit den bestehenden BAK-02-Tests).
`internal/backup`: `TestRestore_CombinesIntoStartablePostgresInstance`
bestanden — **Hinweis**: dieser eine Test läuft NICHT als root
(`pg_ctl: cannot be run as root`) und braucht `pg_combinebackup`/
`pg_ctl` im PATH (`/usr/lib/postgresql/17/bin` auf Debian, dort nicht
standardmäßig verlinkt) — daher separat als `postgres`-Systemnutzer mit
entsprechendem PATH ausgeführt, nicht Teil des root-`make check`-Laufs;
dort wird er mit klarer Meldung übersprungen (`pg_combinebackup nicht
installiert`), kein stiller Fehlschlag. Alle übrigen BAK-01-Tests
(inkl. der um `pg_wal.tar.gz` erweiterten `Verify`) liefen unverändert
grün im normalen `make check`-Lauf.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — beide Restore-Pfade (Datenbank,
Objekt-Storage) sowohl testgetrieben als auch manuell über `restore-cli`
auf echter Infrastruktur nachgewiesen. Drei während der Implementierung
gefundene reale Defekte (Tar-vs-Plain-Format, fehlende WAL-Sicherung/
-Verifikation, Go-exec-Pipe-Hänger) behoben und dokumentiert, nicht
stillschweigend umgangen.
-123
View File
@@ -1,123 +0,0 @@
# 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 <tenant-db> <storage-root>` /
`restore <tenant-db> <ziel-db> <dump> [<snapshot-id> <objekt-ziel>]`.
## 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.
-119
View File
@@ -1,119 +0,0 @@
# BAK-05 Prüfprotokoll: Reconciliation / Konsistenzprüfung Storage vs. DB
Voraussetzung BAK-01, BAK-02 (Welle 1) erledigt, siehe eigene Protokolle.
## Grundsatzentscheidung: reine Funktion + zwei Quell-Adapter
`internal/reconcile.Reconcile` ist eine reine Funktion ohne DB-/Storage-
Zugriff (leicht ohne echte Infrastruktur testbar), die Ein- und
Auslesen echter Systeme ist strikt in `sources.go` getrennt
(`ListDBStorageKeys` gegen echtes Postgres, `ListStorageObjects` gegen
echtes Dateisystem). Beide Seiten liefern nur SCHLÜSSEL niemals Inhalt
dadurch bleibt BAK-05 sauber getrennt von BAK-08 (Inhalts-/Prüfsummen-
verifikation, eigene Fehlerklasse, eigenes Ticket).
Report-Format bewusst deterministisch: alle drei Ergebnislisten
(`missing_in_storage`, `orphaned_in_storage`, `existing_in_storage`)
nach `storage_key` aufsteigend sortiert.
**Nachtrag (nach Rückfrage vor BAK-08-Start):** Der ursprüngliche Report
enthielt nur die beiden Abweichungslisten keine Liste der bestätigt
existierenden Objekte. Für BAK-08 als Stichprobengrundlage reicht
"keine Abweichung" nicht, es braucht die tatsächliche, deterministisch
sortierte Liste. Ergänzt: `Report.ExistingInStorage` DB-Eintrag UND
Storage-Objekt beide vorhanden, reine Existenzbestätigung (keine
Inhaltsprüfung, Scope-Trennung zu BAK-08 bleibt gewahrt), aufsteigend
nach `storage_key` sortiert. BAK-08 zieht seine Stichprobe daraus, ohne
selbst zu sortieren/filtern. Neuer Test
`TestReconcile_ExistingInStorageIsStableSamplingBasis` beweist Inhalt
und Sortierung. Real neu gebaut, getestet (9/9) und auf 131 erneut
ausgelöst Journal zeigt das Feld `existing_in_storage` im Report.
Meldeweg über OPS-05 (wie später BAK-08) wurde als offene Design-Frage
aufgeworfen, aber nicht zur Vorbedingung gemacht hier bewusst noch
nicht umgesetzt (kein OPS-05-Abhängigkeitseintrag im Board für BAK-05);
Report wird aktuell nur als JSON auf stdout ausgegeben und per
Exit-Code (1 bei Abweichungen) für systemd/Monitoring sichtbar gemacht.
Anbindung an OPS-05 kann bei Bedarf nachgezogen werden, ohne
`Reconcile` selbst zu ändern.
## Umsetzung
- `internal/reconcile.Reconcile(dbEntries, storageKeys) Report` reine
Vergleichsfunktion, liefert `MissingInStorage`/`OrphanedInStorage`,
`Report.IsClean()` als eindeutiges Sauber-Merkmal.
- `internal/reconcile.ListDBStorageKeys` liest `file_revisions`
(DMS FDN-02) per direktem SQL aus derselben physischen Tenant-DB
(Modell C, Core TEN-01) kein Import von DMS-Go-Paketen möglich
(eigenes Go-Modul), daher reiner SQL-Zugriff gegen das dokumentierte
Schema.
- `internal/reconcile.ListStorageObjects` durchläuft den lokalen
FDN-03-`LocalDriver`-Basisordner (`filepath.WalkDir`), liefert `nil,
nil` bei fehlendem Verzeichnis statt Fehler (noch keine Objekte ist
kein Fehlerzustand).
- `cmd/reconcile-cli` liest `NEXARCH_RECONCILE_TENANT_DSN` und
`NEXARCH_RECONCILE_STORAGE_DIR`, gibt Report als JSON auf stdout aus,
Exit-Code 1 bei Abweichungen.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Datenbankeintrag ohne Storage-Objekt wird erkannt | **bestanden**`TestReconcile_DetectsMissingInStorage` |
| 2 | Storage-Objekt ohne Datenbankeintrag wird erkannt | **bestanden**`TestReconcile_DetectsOrphanedInStorage` |
| 3 | Lauf ohne Abweichungen liefert leeren, eindeutig sauberen Bericht | **bestanden**`TestReconcile_CleanRunProducesEmptyReport` (zusätzlich `IsClean()`-Konsistenzprüfung) |
Zusätzlich (Nutzervorgaben, nicht explizit im Ticket als Pflichtprüfung
benannt, aber zentral für die Abgrenzung/Weiterverwendbarkeit):
- `TestReconcile_ExistingButCorruptedObjectProducesNoFinding` Nachweis,
dass Reconcile AUSSCHLIESSLICH Existenz prüft, niemals Inhalt (Trennung
von BAK-08).
- `TestReconcile_DeterministicOrdering` zwei Läufe mit identischer
Eingabe liefern identische Reihenfolge, aufsteigend nach `storage_key`.
- `TestListDBStorageKeys_ReadsRealFileRevisions` liest echt gegen die
gemeinsame Tenant-Testdatenbank `dms_tenant_test` (reales DMS-FDN-02-
Schema, kein Mock).
- `TestListStorageObjects_WalksRealDirectory` /
`_MissingDirectoryReturnsEmpty` echtes Dateisystem, kein Mock.
## Echte Verdrahtung auf 192.168.1.131
- `reconcile-cli` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-reconcile.env` (0600): `NEXARCH_RECONCILE_TENANT_DSN`
zeigt auf die gemeinsame Tenant-Testdatenbank `dms_tenant_test`
(DMS selbst läuft auf 192.168.1.131 noch nicht als eigener systemd-
Dienst mit persistenter Konfiguration dies ist die real verfügbare
Tenant-DB mit echtem FDN-02-Schema, dokumentierter bekannter Stand,
kein stiller Mock); `NEXARCH_RECONCILE_STORAGE_DIR` zeigt auf
`/var/nexarch-archiv/dms-objects` (persistentes ZFS-Dataset, NICHT
`/var/nexarch-test/`).
- Timer `nexarch-archive-reconcile.timer` installiert und aktiviert
(täglich 05:00 UTC), `systemctl list-timers` bestätigt scharf.
- `systemctl start nexarch-archive-reconcile.service` real ausgelöst:
`status=0/SUCCESS`, Journal zeigt echten JSON-Report
(`missing_in_storage: null, orphaned_in_storage: null` Tenant-DB
aktuell leer, daher sauberer Bericht, keine synthetische Ausgabe).
## 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 -> 3/3 Pakete mit Tests ok (internal/backup, internal/objectbackup, internal/reconcile), 0 Fehlschläge
```
`internal/reconcile`-Tests separat mit gesetzter `TEST_TENANT_DSN` gegen
`dms_tenant_test` verifiziert: 9/9 Tests bestanden (6 reine
`Reconcile`-Tests + 3 `sources.go`-Integrationstests).
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle Pflicht- sowie
Nutzervorgaben-Prüfungen real erfüllt (echte Postgres-Instanz, echtes
Dateisystem, echter systemd-Lauf). Zwei Testfehler während der
Entwicklung (Schema-Abweichung `revision_number` NOT NULL in der realen
`dms_tenant_test`-Tabelle; inkonsistente Fixture-Daten in
`TestReconcile_DeterministicOrdering`) gefunden und korrigiert beide
waren Testautorenfehler, keine Fehler in `Reconcile` selbst.
-110
View File
@@ -1,110 +0,0 @@
# BAK-06 Prüfprotokoll: Restore-Testverfahren
Voraussetzung BAK-03 erledigt, siehe eigenes Protokoll.
## Grundsatzentscheidung: Produktcode statt Testcode für den echten Nachweis
Produkt-DNA: "Wiederherstellung ist Routine, nicht Ausnahmefall
regelmäßig getestet." Ein reiner Dateiexistenz-Check hätte diese
Forderung nicht erfüllt (dieselbe Disziplin wie überall sonst in diesem
Projekt: "jede Prüfung tatsächlich durchführen"). `internal/restoretest`
übernimmt daher exakt die Prüftiefe, die BAK-03s eigener Test bewiesen
hat — tatsächlicher Restore, tatsächlicher Kurzstart einer isolierten
Postgres-Instanz, tatsächliche `SELECT 1`-Abfrage — als WIEDERHOLBAREN
Produktcode statt einmaligen Testcode, damit es unbeaufsichtigt auf
Zeitplan laufen kann.
Historie: append-only JSONL-Datei (Akzeptanzkriterium 2/Pflichtprüfung
3). Sichtbare Warnung (Akzeptanzkriterium 3): derselbe OPS-05-Pull-Weg
wie BAK-08 (`nexarch_archive_restore_test_failures_total`, Counter) —
bewusst als EIGENES `/metrics`-Modul (`archive-restoretest`) registriert,
nicht in BAK-08s `scrub-metrics` verbaut (kein Umbau angrenzender,
bereits fertiger Bereiche).
## Drei reale Defekte während der Implementierung gefunden und behoben
1. **Unix-Socket-Pfadlänge:** Postgres begrenzt Socket-Pfade auf 107
Byte — ein unter `t.TempDir()` verschachtelter Pfad reißt dieses
Limit leicht. Fix: eigenes, kurzes Socket-Verzeichnis
(`os.MkdirTemp("", ...)`), nicht unter dem Testverzeichnis.
2. **`restic snapshots --json` unter dem systemd-Dienstnutzer
`nexarch`:** ohne beschreibbares `HOME` schreibt restic eine Warnung
("unable to open cache: ...") nach STDERR — `CombinedOutput()` hätte
sie vor das JSON-Array gemischt und das Parsen gebrochen. Fix: nur
`cmd.Output()` (stdout), stderr separat für Fehlermeldungen.
3. Beide Defekte wurden NICHT beim ersten laufenden Testdurchlauf
sichtbar, sondern erst beim ECHTEN systemd-Lauf unter dem
`nexarch`-Dienstnutzer (Defekt 2) bzw. bei tief verschachtelten
Go-Testpfaden (Defekt 1) — Beleg dafür, warum sowohl automatisierte
Tests als auch ein realer Deploy-Lauf nötig sind, keines allein hätte
beide gefunden.
## Umsetzung
- `internal/restoretest.RunDatabaseTest` — neueste Generation ermitteln
(`backup.ListGenerations`), `backup.Restore` in isoliertes
Testverzeichnis, Kurzstart via `pg_ctl`, `SELECT 1` über echte
Verbindung, danach `pg_ctl stop`.
- `internal/restoretest.RunObjectTest` — neuesten Snapshot ermitteln,
`objectbackup.Restore` in isoliertes Testverzeichnis, Inhalt real
geprüft (nicht nur Exit-Code).
- `internal/restoretest.AppendHistory`/`ReadHistory` — JSONL,
append-only.
- `cmd/restoretest-cli` — Oneshot, beide Testarten, Exit-Code 1 bei
Fehlschlag.
- `cmd/restoretest-metrics` — dauerhafter `/metrics`-Endpunkt, Zähler
aus der Historie abgeleitet (append-only ⇒ Fehlschlagszahl von selbst
monoton, kein separater Zählerstand nötig).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Geplanter Testlauf gegen aktuelle Sicherung erfolgreich durchgeführt und protokolliert | **bestanden**`TestRunDatabaseTest_SucceedsAgainstRealBackup`, `TestRunObjectTest_SucceedsAgainstRealSnapshot`; real auf 131: `systemctl start nexarch-archive-restoretest.service` → beide Testarten `erfolg=true`, in `history.log` protokolliert |
| 2 | Absichtlich beschädigte Sicherung lässt den Testlauf sichtbar fehlschlagen | **bestanden**`TestRunDatabaseTest_DetectsCorruptedBackup`: `base.tar.gz` durch Datenmüll ersetzt, `RunDatabaseTest` liefert `Success=false` mit aussagekräftigem Detail |
| 3 | Protokollhistorie zeigt mehrere zurückliegende Testläufe nachvollziehbar an | **bestanden**`TestHistory_ShowsMultiplePastRunsInOrder`: drei Einträge angehängt, in exakt derselben Reihenfolge gelesen |
## Echte Verdrahtung auf 192.168.1.131
- `restoretest-cli`, `restoretest-metrics` gebaut nach
`/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-restoretest.env` (0600)
- `nexarch-archive-restoretest.timer` installiert/aktiviert (wöchentlich
So. 07:00 UTC), `nexarch-archive-restoretest-metrics.service`
installiert/aktiviert (dauerhaft) — beide `systemctl status`: aktiv
- `Environment=PATH=...` im Service ergänzt um
`/usr/lib/postgresql/17/bin` (Debian verlinkt `pg_ctl`/
`pg_combinebackup` nicht ins Standard-PATH)
- **Reales `INSERT` in `metrics_sources`** (Core-Registry-DB):
`('archive-restoretest', 'http://127.0.0.1:8091/metrics')`
- **End-to-End über OPS-03 bestätigt**: `curl http://127.0.0.1:8085/metrics`
zeigt `nexarch_module_archive_restoretest_nexarch_archive_restore_test_*`
mit den realen Werten (`failures_total=0`,
`last_success{kind="database"}=1`, `last_success{kind="objects"}=1`)
- Realer Lauf via `systemctl start nexarch-archive-restoretest.service`:
beide Testarten erfolgreich, Journal zeigt Details, `/metrics` und
OPS-03-Aggregation stimmen überein
## 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 -> 6/6 Pakete mit Tests ok (backup, objectbackup, reconcile, restore, restoretest, scrub), 0 Fehlschläge
```
Hinweis wie bei BAK-03: die Datenbank-Restore-Tests
(`TestRunDatabaseTest_*`) brauchen `pg_ctl`/`pg_combinebackup` im PATH
und laufen NICHT als root — separat als `postgres`-Systemnutzer
verifiziert (7/7 `internal/restoretest`-Tests bestanden), im normalen
root-`make check`-Lauf werden sie mit klarer Meldung übersprungen.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — sowohl testgetrieben als auch über einen
echten, unbeaufsichtigten systemd-Lauf mit OPS-03/OPS-05-Sichtbarkeit
nachgewiesen. Zwei während der Implementierung gefundene reale Defekte
(Unix-Socket-Pfadlänge, restic-stderr-Vermischung unter dem
Dienstnutzer) behoben und dokumentiert.
-112
View File
@@ -1,112 +0,0 @@
# BAK-07 Prüfprotokoll: Aufbewahrungsfrist für Backup-Snapshots
Voraussetzung BAK-01, BAK-02 erledigt, siehe eigene Protokolle.
## Grundsatzentscheidung: restic-native Staffelung für Objekte, eigene GFS-Funktion für DB-Generationen
Objekt-Storage (BAK-02): restic beherrscht gestaffelte Aufbewahrung
bereits nativ (`forget --keep-daily/--keep-weekly/--keep-monthly`) —
kein Eigenbau, `internal/objectbackup.PruneTiered` ist ein dünner
Wrapper (dieselbe Disziplin wie beim ursprünglichen
restic-statt-Eigenbau-Entscheid in BAK-02).
Datenbank-Generationen (BAK-01): `pg_basebackup` kennt keine
Staffelung, daher `internal/backup.PruneTiered` als reine Funktion
(GFS-Prinzip: je Granularität Tag/Woche/Monat wird pro Zeitfenster die
NEUESTE Generation behalten, bis die konfigurierte Fensteranzahl
erreicht ist) — ohne Dateisystemzugriff testbar, `PruneRotate` führt die
tatsächliche Löschung anhand des Ergebnisses aus (analog zu BAK-01s
bestehendem `Rotate`).
Beide Policies sind über Umgebungsvariablen konfigurierbar
(`NEXARCH_BACKUP_KEEP_DAILY/WEEKLY/MONTHLY`,
`NEXARCH_OBJECTBACKUP_KEEP_DAILY/WEEKLY/MONTHLY`), nicht hart codiert
(Ticket-Vorgabe). Bestehende flache "letzte N"-Rotation
(`NEXARCH_*_KEEP_GENERATIONS`/`KEEP_SNAPSHOTS`) bleibt als Fallback
erhalten, falls keine Staffelung gesetzt ist — kein Bruch für bestehende
Deployments.
## Sicherheitsnetz für Akzeptanzkriterium 3 (Legal-Hold-Kollision)
`internal/backup.PruneTiered` behält STRUKTURELL immer die neueste
Generation, unabhängig von der konfigurierten Staffelung — ein
Dokument, das in der neuesten (immer behaltenen) Sicherung enthalten
ist, bleibt also immer wiederherstellbar, unabhängig von einer
laufenden RET-03/CMP-06-Aufbewahrungssperre (die dieses Ticket, Archive
BAK-Modul, ohnehin nicht direkt anfasst — Sperren sind DMS/RET-Eigentum,
kein Import-Ziel für Archive per "kein Umbau angrenzender Bereiche").
Für Objekt-Storage ist die äquivalente Eigenschaft restics eigene
Garantie: `forget --prune` entfernt niemals Datenblöcke, die ein
BEHALTENER Snapshot noch referenziert (Content-defined Chunking +
Referenzzählung) — real bewiesen, nicht nur behauptet (siehe Prüfung 3).
## Umsetzung
- `internal/backup.RetentionPolicy`/`PruneTiered` (`retention.go`) —
reine GFS-Funktion.
- `internal/backup.PruneRotate` (`rotate.go`) — führt `PruneTiered`
tatsächlich gegen das Sicherungsverzeichnis aus.
- `internal/objectbackup.RetentionPolicy`/`PruneTiered` (`restic.go`) —
Wrapper um restics native Staffelung.
- `cmd/backup-cli rotate` / `cmd/objectbackup-cli rotate` — nutzen die
gestaffelte Policy, wenn konfiguriert, sonst weiterhin die flache
Regel (Abwärtskompatibilität).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Prune-Test mit simulierten Snapshots über mehrere Altersstufen ergibt korrekte Staffelung | **bestanden**`TestPruneTiered_KeepsCorrectStaggering` (Datenbank, reine Funktion, simulierte Generationen über Tage/Wochen/Monate) UND `TestPruneTiered_KeepsCorrectStaggering_RealSnapshots` (Objekt-Storage, ECHTE restic-Snapshots mit erzwungenem Zeitstempel `restic backup --time`, `KeepDaily=3` reduziert 10 reale Snapshots korrekt auf 3) |
| 2 | Automatisierte Löschung abgelaufener Snapshots im Test nachgewiesen | **bestanden**`TestPruneRotate_DeletesOnlyExpiredGenerations`: reales Verzeichnis, abgelaufene Generation tatsächlich vom Dateisystem entfernt, aktuelle unangetastet; real auf 131: `backup-cli rotate` und `objectbackup-cli rotate` über systemd ausgelöst, Journal zeigt „gestaffelte rotation abgeschlossen“ |
| 3 | Legal-Hold-Test bestätigt, dass Pruning nicht mit Dokumenten-Retention kollidiert | **bestanden**`TestPruneTiered_AlwaysKeepsNewest` (Datenbank-Sicherheitsnetz: neueste Generation immer behalten, selbst bei Policy 0/0/0) UND `TestPruneTiered_KeptSnapshotStillFullyRestorable` (Objekt-Storage, real: 5 ältere Snapshots eines Dokuments weggeprunt, verbleibender Snapshot danach vollständig und inhaltlich korrekt wiederhergestellt, zusätzlich `restic check --read-data` bestätigt Datenintegrität nach dem Pruning) |
Zusätzlich: `TestPruneTiered_DeterministicForIdenticalInput`.
## Echte Verdrahtung auf 192.168.1.131
- `backup-cli`, `objectbackup-cli` neu gebaut mit der gestaffelten
Rotations-Logik
- `/etc/nexarch/archive-backup.env` und
`/etc/nexarch/archive-objectbackup.env` um
`KEEP_DAILY=14`/`KEEP_WEEKLY=12`/`KEEP_MONTHLY=12` ergänzt
- Beide `*-rotate.service` real über `systemctl start` ausgelöst —
Journal bestätigt den neuen Pfad ("gestaffelte rotation
abgeschlossen"/"... entfernt: [...]"), nicht mehr die alte flache
Regel
## 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, 0 Fehlschläge
```
## Bekannte Lücke: keine Kohärenz zwischen DB- und Objekt-Rotation
Beide Rotationswege laufen als UNABHÄNGIGE systemd-Timer mit eigener
Kadenz und eigener Policy-Auswertung — nichts stellt sicher, dass der
älteste noch erreichbare restic-Snapshot und die älteste noch
erreichbare DB-Generation denselben Zeitpunkt abdecken. Die aktuelle
Konfiguration (`KEEP_DAILY=14`/`KEEP_WEEKLY=12`/`KEEP_MONTHLY=12` auf
beiden Seiten identisch) ist eine ZUFÄLLIGE Übereinstimmung, kein
getestetes oder erzwungenes Invariant — ein restic-Snapshot ohne
zeitlich passende DB-Generation wäre ein inkonsistenter Restore-Punkt
(Objekte ohne zugehörige `file_revisions`-Metadaten oder umgekehrt).
Nicht Teil dieses Tickets (BAK-07 fordert nur je Sicherungsart eine
konfigurierbare Staffelung, kein Cross-Artefakt-Alignment) — als
dokumentierter Folgepunkt festgehalten, nicht stillschweigend
übergangen: ein künftiges Ticket müsste entweder die Policies fest
koppeln oder bei Restore-Zeitpunkt-Wahl (BAK-03) explizit warnen, wenn
kein zeitlich passendes Gegenstück mehr existiert.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — sowohl mit simulierten (Datenbank) als
auch mit tatsächlich zeitversetzt erzeugten, realen restic-Snapshots
(Objekt-Storage). Ein realer Formatfehler beim Testaufbau gefunden und
behoben (restics `--time`-Flag erwartet sein eigenes Format, nicht
RFC3339). Beide Rotations-Wege real über systemd auf 192.168.1.131
ausgelöst, nicht nur isolierter Testcode.
-140
View File
@@ -1,140 +0,0 @@
# BAK-08 Prüfprotokoll: Checksum-basierte Objekt-Integritätsprüfung
Voraussetzungen BAK-05, FDN-04, FDN-09, OPS-05 alle erledigt, siehe
eigene Protokolle. Vor Start zwei offene Rückfragen geklärt (siehe unten).
## Grundsatzentscheidung: eigener Zustand statt file_revisions.created_at
`created_at` als Alterskriterium hätte immer dieselben "ältesten" Objekte
gescrubbt und den Rest nie erreicht — kein echtes Rotationsverhalten.
Stattdessen eigene Archive-Tabelle `scrub_state` (`storage_key`
`last_scrubbed_at`, `last_result`), Migration
`migrations/0001_scrub_state.up.sql`. `internal/scrub.Sample` ist eine
reine Funktion: nimmt BAK-05s `existing_in_storage` (deterministisch
sortiert) entgegen, filtert Objekte innerhalb der konfigurierbaren
Cooldown-Frist heraus, priorisiert danach nach `last_scrubbed_at`
aufsteigend (nie geprüft = ältestmöglicher Wert), begrenzt auf die
konfigurierte Stichprobengröße — kein Voll-Sort über den gesamten
Bestand bei jedem Lauf (Nutzerhinweis zum Kostenfaktor bei 10⁵+
Objekten: die WHERE-artige Cooldown-Filterung reduziert die Kandidatenmenge
VOR der Sortierung, nur die Kandidaten selbst werden sortiert, nicht der
komplette Bestand).
## Nachtrag: zwei Rückfragen vor Implementierungsbeginn geklärt
1. **OPS-05-Anbindung ist Pull, nicht Push.** OPS-05 (`internal/alerting`,
Core) ist real implementiert, aber Core OPS-03 scrapt `/metrics`-URLs
registrierter Module (`metrics_sources`-Tabelle in der Core-Registry-
DB, `SourceStore.RegisterSource`) — kein Push-API. Für BAK-08 daher
ein eigener, DAUERHAFT laufender Endpunkt (`cmd/scrub-metrics`,
getrennt vom Oneshot-`scrub-cli`, dessen Prozess nach jedem Lauf endet
und zum Scrape-Zeitpunkt nicht erreichbar wäre). Metrik als Counter
(`nexarch_archive_storage_integrity_failures_total`), monoton
steigend — kein Gauge, kein Rücksetzen bei behobenem Befund. Kein
Befund = Wert bleibt unverändert (kein Dauer-Alarm durch andauernden
"Fehler"-Zustand). Scope-Trennung gewahrt: `scrub-cli`/`scrub-metrics`
erzeugen selbst KEIN Alert-Objekt — Schwellwert/Drosselung bleiben
OPS-05-eigene Konfiguration (Alert-Regel wird separat über
`alerting.RuleStore.CreateRule` angelegt, nicht Teil dieses Tickets).
**CFG-04 war eine Verwechslung** (das ist die
Benachrichtigungs-Einstellungen-Oberfläche, ein anderes Ticket) — die
tatsächlich nötige "Config"-Aktion ist ein `INSERT` in
`metrics_sources` (Core-Registry-DB), kein UI/Ticket-Abhängigkeit.
Real ausgeführt (siehe „Echte Verdrahtung" unten).
2. **Sampling-Kriterium.** Siehe Grundsatzentscheidung oben —
`scrub_state.last_scrubbed_at` statt `file_revisions.created_at`,
Cooldown-Filterung vor Sortierung, feste Stichprobengröße (Top-N,
deterministisch, keine Zufallsstichprobe — Nutzerpräferenz für
Reproduzierbarkeit im Protokoll).
## Umsetzung
- `migrations/0001_scrub_state.up.sql`/`.down.sql``scrub_state`,
`scrub_counters` (Einzelzeile, monotoner Zähler).
- `internal/scrub.Sample` — reine Funktion, Cooldown-Filter + Alt-
Priorisierung + Stichprobenbegrenzung.
- `internal/scrub.LoadLastScrubbed`/`MarkScrubbed`/`RecordFinding`/
`FindingsTotal` — DB-Zugriff auf `scrub_state`/`scrub_counters`,
`MarkScrubbed` idempotent (`ON CONFLICT`) für unterbrechbare Läufe.
- `internal/scrub.ExpectedChecksums` — eigene, minimale Abfrage gegen
`file_revisions` (keine Erweiterung von `reconcile.DBEntry` — BAK-05
bleibt existenz-only).
- `internal/scrub.ActualChecksum` — echtes Lesen der Datei + SHA-256,
kein Header-/Größenvergleich.
- `cmd/scrub-cli` — Oneshot: BAK-05-Reconcile → `Sample` → pro Kandidat
Checksum-Vergleich → `MarkScrubbed` + bei Abweichung `RecordFinding`
JSON-Bericht auf stdout, Exit-Code 1 bei Befunden (gemeldet, nicht
automatisch repariert).
- `cmd/scrub-metrics` — dauerhafter `/metrics`-Endpunkt, liest
`scrub_counters.findings_total`.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Absichtlich veränderter Objektinhalt wird als Abweichung erkannt | **bestanden** — real: Testobjekt mit absichtlich falscher `checksum_sha256` in `dms_tenant_test` angelegt, echte Datei ins Storage-Verzeichnis gelegt, `scrub-cli` real über systemd ausgelöst: Befund im JSON-Bericht, Exit-Code 1, `scrub_counters.findings_total` real von 0 auf 1 erhöht (siehe Journal-Auszug unten) |
| 2 | Sampling priorisiert alte/nie geprüfte Objekte, nicht neue | **bestanden**`TestSample_PrioritizesNeverScrubbedAndOldest`: nie geprüftes Objekt kommt vor einem vor 30 Tagen geprüften, dieses vor einem vor 1 Tag geprüften |
| 3 | Wiederholter Lauf ohne neue Objekte meldet nichts erneut (kein Spam) / idempotent bei Unterbrechung | **bestanden** — real: zweiter `scrub-cli`-Lauf direkt nach dem ersten liefert `sampled: 0` (Cooldown greift), `TestMarkScrubbed_IsIdempotent` beweist wiederholtes Markieren ohne Duplikat |
Zusätzlich: `TestSample_RespectsCooldown`,
`TestSample_LimitsToSampleSize`, `TestSample_DeterministicForIdenticalInput`,
`TestRecordFinding_IsMonotonicallyIncreasing`,
`TestActualChecksum_MatchesRealFileContent` (echter Dateiinhalt, echtes
SHA-256), `TestExpectedChecksums_ReadsRealFileRevisions` (echtes
Postgres, kein Mock).
## Echte Verdrahtung auf 192.168.1.131
- `scrub-cli`, `scrub-metrics` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-scrub.env`, `/etc/nexarch/archive-scrub-metrics.env`
(0600)
- Migration real gegen `dms_tenant_test` angewendet
(`psql -f migrations/0001_scrub_state.up.sql`)
- `nexarch-archive-scrub.timer` installiert/aktiviert (täglich 06:00
UTC), `nexarch-archive-scrub-metrics.service` installiert/aktiviert
(dauerhaft, `Restart=on-failure`) — beide `systemctl status`: aktiv
- **Reales `INSERT` in `metrics_sources`** (Core-Registry-DB
`nexarch_registry`): `('archive', 'http://127.0.0.1:8090/metrics')`
bestätigt über `SELECT * FROM metrics_sources`
- **End-to-End über OPS-03 bestätigt**: `curl http://127.0.0.1:8085/metrics`
(Core-Aggregator) zeigt `nexarch_module_archive_nexarch_archive_storage_integrity_failures_total`
— reale Umbenennung gemäß OPS-03-Namenskonvention, kein synthetischer
Wert
- Realer Befund-Durchlauf: Testobjekt mit absichtlich falscher Prüfsumme
angelegt → `scrub-cli` real via `systemctl start` ausgelöst → Befund im
Journal, `scrub_counters.findings_total` real 0→1, sichtbar sowohl auf
`scrub-metrics` als auch über den Core-Aggregator → Testdaten
anschließend bereinigt (`file_revisions`/`documents`/`users`-Zeilen
gelöscht, `scrub_state`/`scrub_counters` zurückgesetzt, Testdatei
entfernt)
## 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 -> 4/4 Pakete mit Tests ok (internal/backup, internal/objectbackup, internal/reconcile, internal/scrub), 0 Fehlschläge
```
`internal/scrub`-Tests separat mit gesetzter `TEST_TENANT_DSN` gegen
`dms_tenant_test` verifiziert: 8/8 Tests bestanden.
## Bekannte Grenze (aus Ticket übernommen, nicht Teil der Abnahme)
Der Job erkennt Abweichungen nur bei Objekten, die gelesen und erneut
geprüft werden können. Ersetzt keine storage-seitige WORM-/
Versionierungsstrategie und keine Zugriffs-/Audit-Logs des
Storage-Providers (`STORAGE-KONZEPT.md` Abschnitt 6.1) — bei extern
eingebundenem, nicht-kompatiblem Kunden-Storage (Betriebsmodus 3, ohne
Versioning/Object Lock/Audit-Logs) bleibt eine Lücke, die BAK-08
technisch nicht schließen kann.
## Gesamtergebnis
**Bestanden.** Alle sechs Akzeptanzkriterien und alle drei Pflicht-
prüfungen real erfüllt — inklusive echtem Ende-zu-Ende-Nachweis über
Core OPS-03/OPS-05 (kein Stub, reale `/metrics`-Registrierung und
-Aggregation). Beide vor Implementierungsbeginn gestellten Rückfragen
(OPS-05-Anbindungsmechanismus, Sampling-Kriterium) im Protokoll
dokumentiert und in der Umsetzung berücksichtigt.
-64
View File
@@ -1,64 +0,0 @@
# QA-04 Abnahmeprotokoll: Prüfgate Backup & Restore
Voraussetzung BAK-04, BAK-05, BAK-06, BAK-07 alle Fertig, siehe
jeweilige Prüfprotokolle. Gate fasst deren Ergebnisse zusammen und
fordert einen ZUSÄTZLICHEN, eigenständigen Nachweis: ein realer
Restore-Lauf plus Reconciliation, ausgeführt als Abnahme-Handlung,
nicht nur als Entwicklungs-Test.
## Prüfungen
| # | Prüfung | Zielwert | Messwert | Bewertung |
|---|---|---|---|---|
| 1 | Realer Restore-Testlauf erfolgreich und protokolliert | Beide Restore-Arten (DB + Objekt) laufen ohne Fehler, real gegen 192.168.1.131 | **Nachhol-Prüfung** (dedizierter Abnahme-Lauf für dieses Gate, NICHT identisch mit BAK-06s eigenem Entwicklungstest): `systemctl start nexarch-archive-restoretest.service` (2026-08-29 22:54:53 UTC): DB-Restore `erfolg=true` (Quelle `20260829T222054Z`), Objekt-Restore `erfolg=true` (Quelle `4af7bf18`); Ergebnis in `/var/nexarch-archiv/restoretest/history.log` protokolliert | **bestanden** |
| 2 | Reconciliation nach dem Testlauf liefert einen sauberen Bericht | `missing_in_storage`/`orphaned_in_storage` beide leer | **Nachhol-Prüfung**, im selben Abnahme-Durchgang direkt nach Prüfung 1 ausgelöst: `systemctl start nexarch-archive-reconcile.service` (2026-08-29 22:55:02 UTC): `{"missing_in_storage": null, "orphaned_in_storage": null, "existing_in_storage": null}` | **bestanden** |
| 3 | Offene Restrisiken sind schriftlich benannt | Vollständige, ehrliche Liste (siehe unten) | 5 Punkte identifiziert und dokumentiert | **bestanden** |
## Offene Restrisiken (Akzeptanzkriterium/Pflichtprüfung 3)
1. **Keine Kohärenz zwischen DB- und Objekt-Rotation** (BAK-07). Beide
Rotationswege laufen unabhängig, ohne Garantie, dass der älteste noch
erreichbare restic-Snapshot und die älteste noch erreichbare
DB-Generation zeitlich zusammenpassen. Aktuell identische
Keep-Werte sind Zufall, kein erzwungenes Invariant. **Ausdrücklich
NICHT gelöst**: ein Restore-Punkt ist nur dann belastbar, wenn zum
selben Zeitpunkt sowohl ein restic-Snapshot als auch eine
DB-Generation existieren — das ist aktuell nicht sichergestellt.
2. **BAK-04-Rollenrechte nicht automatisiert für produktive Mandanten.**
`nexarch_tenantbackup` braucht je Mandant manuell/administrativ
eingerichteten Lesezugriff (Rollenmitgliedschaft), bis TEN-01/TEN-07
dies automatisiert bereitstellen. Aktuell nur für die Test-Tenant-DB
eingerichtet.
3. **BAK-08 deckt keine Storage-Provider-Lücke.** Der Scrub-Job erkennt
Abweichungen nur bei Objekten, die gelesen und erneut geprüft werden
können — ersetzt keine storage-seitige WORM-/Versionierungsstrategie
und keine Zugriffs-/Audit-Logs des Storage-Providers. Bei extern
eingebundenem, nicht-kompatiblem Kunden-Storage (Betriebsmodus 3,
ohne Versioning/Object Lock/Audit-Logs) bleibt eine technisch nicht
schließbare Lücke (aus dem BAK-08-Ticket selbst übernommen, hier
erneut benannt statt stillschweigend vorausgesetzt).
4. **Core FDN-03/FDN-09-Wiring-Lücke** (aus früheren Prüfprotokollen
bekannt, nicht Archive-Scope): Core-seitige Handler für Speicher-
Nutzungsmeldung und Tenant-KEK-Abruf existieren, sind aber in keinem
laufenden Core-Dienst registriert. Betrifft indirekt BAK-08s
OPS-05-Anbindung (funktioniert unabhängig davon, aber der breitere
Meldeweg für Speicher-Nutzung bleibt lückenhaft).
5. **`existing_in_storage`/Reconciliation-Basis aktuell leer im
Testsystem.** Der saubere Bericht dieses Gates (Prüfung 2) beweist
Abwesenheit von Abweichungen, nicht Abdeckung eines befüllten
Bestands — `dms_tenant_test` enthält aktuell keine Testdaten (von
früheren Testläufen geleert). Ein Gate-Wiederholungslauf mit echtem
Datenbestand vor Produktivbetrieb wird empfohlen.
## Gesamtergebnis
**Bestanden.** Alle drei Prüfungen real durchgeführt und dokumentiert.
Fünf Restrisiken benannt, keines davon blockiert die Freigabe der
Backup-Funktionen, alle sind entweder bereits als Folgeticket-Kandidaten
dokumentiert (1, 4) oder liegen strukturell außerhalb des
Archive-Moduls (2, 3) bzw. sind ein Hinweis für den Produktivbetrieb (5).
## Unterschriften
- **Umsetzung:** Claude (Agent), 2026-08-30 — alle Prüfungen durchgeführt, Protokoll erstellt.
- **Gegenzeichnung geprüft:** Betreiber, 2026-08-30 — unter den drei Bedingungen (Rotations-Kohärenz als offenes Restrisiko benannt, Nachhol-Prüfung explizit gekennzeichnet, Zwei-Namen-Unterschrift) bestätigt.
-64
View File
@@ -1,64 +0,0 @@
# RET-01 Prüfprotokoll: Generisches Retention-Objektmodell
Keine Vorbedingungen (Welle 1 des RET-Epics).
## Grundsatzentscheidung: Adapter-Muster, keine Fremdschlüssel auf Modul-Tabellen
`retention_objects.object_type`/`object_reference` sind reine
Textfelder — Archive importiert weder DMS- noch Mail-Code (eigene
Go-Module, physisch getrennte Verantwortung, dieselbe Disziplin wie
BAK-05s `reconcile`-Paket gegenüber `file_revisions`). Ein neues Modul
kann retention-pflichtige Objekte einbinden, ohne dieses Paket zu
ändern — nur `RegisterObject(objectType, objectReference)` aufrufen.
Aufbewahrungsklassen-Zuordnung ist historisiert: `AssignClass` fügt
IMMER eine neue Zeile hinzu, ändert nie eine bestehende — die "aktuelle"
Klasse ist die mit dem jüngsten `assigned_at`. Damit bleibt
nachvollziehbar, wann und wie oft sich die Klasse eines Objekts
geändert hat, ohne eigene Audit-Tabelle.
## Umsetzung
- `migrations/0002_retention.up.sql`/`.down.sql``retention_objects`,
`retention_class_assignments`.
- `internal/retention.RegisterObject` — idempotent (`ON CONFLICT`).
- `internal/retention.AssignClass`/`CurrentClass`/`ClassHistory`.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Migration vorwärts und rückwärts getestet | **bestanden**`TestMigration_ForwardAndBackward`: real gegen die tatsächlichen Migrationsdateien (nicht nachgebaut), vorwärts→Tabellen vorhanden, rückwärts→Tabellen weg, erneut vorwärts→sauber (kein Rest blockiert erneuten Lauf) |
| 2 | Testobjekt aus fiktivem DMS- und Mail-Adapter beide korrekt abgebildet | **bestanden**`TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically`: `dms_document`/`mail_message` beide ohne modulspezifische Spalten abgebildet, zusätzlich Idempotenz bewiesen (erneute Registrierung liefert dieselbe ID) |
| 3 | Tenant-Isolation der Kern-Tabellen durch Negativtest belegt | **bestanden**`TestTenantIsolation_Negativtest`: reales zweites Tenant-DB (`ret01_tenant_test_b`), Objekt aus Tenant A über Verbindung zu Tenant B abgefragt, `count=0` — technisch nicht sichtbar, keine gemeinsame Tabelle mit `tenant_id`-Filter (Modell C, TEN-01) |
Zusätzlich: `TestAssignClass_IsHistoricized` — zwei Klassen-Zuordnungen,
`CurrentClass` liefert die jüngste, `ClassHistory` beide chronologisch.
## Echte Verdrahtung auf 192.168.1.131
- Migration real gegen `dms_tenant_test` angewendet (`psql -f
migrations/0002_retention.up.sql`) — `retention_objects`,
`retention_class_assignments` bestätigt vorhanden (`\dt retention*`)
- Kein systemd-Dienst/Timer nötig — RET-01 ist reines Datenmodell +
Bibliothek, kein eigenständiger Prozess (Verbraucher sind spätere
RET-Tickets, allen voran RET-05 als Adapter-Interface)
- Zweites Tenant-DB (`ret01_tenant_test_b`) nur für den Isolationstest
angelegt, danach entfernt
## 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 -> 8/8 Pakete mit Tests ok (backup, objectbackup, reconcile, restore, restoretest, retention, scrub, tenantbackup), 0 Fehlschläge
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — Migration gegen die tatsächlichen
`.sql`-Dateien (nicht nachgebaut) getestet, Mandantentrennung gegen ein
echtes zweites Tenant-DB bewiesen. Grundlage für RET-05 (Adapter-
Interface, als Nächstes) und RET-02 gelegt.
-69
View File
@@ -1,69 +0,0 @@
# RET-02 Prüfprotokoll: Aufbewahrungsfristen-Engine
Voraussetzung RET-01 erledigt, siehe eigenes Protokoll.
**Scope-Klarstellung:** Dieses Ticket ist die Fristen-BERECHNUNGS-Engine
(Frist je Klasse, Stichtagsberechnung, periodischer Ablauf-Job) —
`dependsOn: ["RET-01"]`, KEINE Abhängigkeit auf RET-05. Die DMS-/Mail-
seitige Registrierung als RET-05-Adapter-Konsument sowie
Vernichtungs-Job-Fehlerbehandlung (2xx/Requeue) sind NICHT Teil dieser
Kachel — das ist ein späteres, eigenes Ticket (vermutlich im
ARC-*/DOC-*-Umfeld). Bewusst nicht mitgebaut, um nicht über den
Ticket-Umfang hinaus zu implementieren.
## Grundsatzentscheidung: Postgres-INTERVAL statt eigener Kalenderrechnung
`retention_class_rules.duration` ist ein natives Postgres-`INTERVAL`
(z. B. `'10 years'`, `'6 months'`) — `ComputeDueDate` delegiert die
gesamte Stichtagsberechnung an Postgres selbst (`start + duration`),
statt eine eigene Schaltjahr-/Monatsende-Logik in Go nachzubauen, die
von der WHERE-Klausel des periodischen Jobs (dieselbe Arithmetik)
abweichen könnte. Referenzwerte für Akzeptanzkriterium 2 real gegen
Postgres verifiziert, nicht angenommen:
`2024-02-29 + 1 year = 2025-02-28`, `2026-01-31 + 1 month = 2026-02-28`.
## Umsetzung
- `migrations/0004_retention_class_rules.up.sql`/`.down.sql`.
- `internal/retentionengine.ConfigureClassRule` — eine Regel je Klasse
(`UPSERT`).
- `internal/retentionengine.ComputeDueDate` — delegiert an Postgres.
- `internal/retentionengine.ListExpiringObjects` — periodischer Job:
`DISTINCT ON (retention_object_id)` auf die JÜNGSTE Klassenzuordnung
beschränkt, sonst würde ein Objekt mit mehrfach geänderter Klasse
(RET-01s Historisierung) mehrfach im Ergebnis auftauchen.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Fristberechnung an Referenzdaten mit bekannten Ablaufdaten geprüft | **bestanden**`TestComputeDueDate_KnownReferenceDates`: Schaltjahr (29.02.2024 + 1 Jahr → 28.02.2025) und Monatsende (31.01.2026 + 1 Monat → 28.02.2026), beide Werte vorab real gegen Postgres verifiziert |
| 2 | Job liefert bei leerem Bestand ein leeres, nicht fehlerhaftes Ergebnis | **bestanden**`TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError` |
| 3 | Mehrfachausführung des Jobs erzeugt keine doppelten Einträge | **bestanden**`TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges`: Objekt mit ZWEI historischen Klassenzuordnungen (beide abgelaufen), zwei Job-Läufe liefern je genau 1 Eintrag — ohne die `DISTINCT ON`-Einschränkung wäre es 2 gewesen |
## Echte Verdrahtung auf 192.168.1.131
- Migration real gegen `dms_tenant_test` angewendet — `retention_class_rules`
bestätigt vorhanden neben `retention_objects`/`retention_class_assignments`
- Kein systemd-Timer in diesem Ticket — "periodischer Job" ist die
Bibliotheksfunktion `ListExpiringObjects`; ihr tatsächlicher
Aufrufer/Zeitplan (systemd-Timer + Meldeweg für abgelaufene Objekte)
ist Aufgabe eines Folgetickets, das auch die Vernichtungslogik selbst
bringt (dieses Ticket berechnet nur, wer fällig ist — vernichtet
nichts)
## 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 -> 10/10 Pakete mit Tests ok, 0 Fehlschläge
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — Stichtagsberechnung gegen real
verifizierte Postgres-Referenzwerte, Job-Idempotenz gegen ein Objekt mit
echter Mehrfach-Historie bewiesen (nicht nur behauptet).
-94
View File
@@ -1,94 +0,0 @@
# RET-05 Prüfprotokoll: Modul-Adapter-Schnittstelle
Voraussetzung RET-01 erledigt, siehe eigenes Protokoll.
## Grundsatzentscheidung: Interface-Freeze, keine Modul-Implementierung
Nutzervorgabe: RET-05 als reines INTERFACE definieren (Registrierung,
Rückruf für Löschbestätigung, Fehlerverhalten) — NICHT schon
implementieren, damit spätere DMS-/Mail-Kacheln gegen ein bereits
feststehendes, nicht nachträglich verändertes Interface bauen. Dieses
Ticket liefert daher NUR Archives eigene Seite:
- Registrierungs-API (`internal/moduleadapter.Register` +
`RegisterHandler`, REST-Schnittstelle laut Ticket-Technikvorgabe).
- Rückruf-Auslöser (`NotifyDestruction`) mit feststehendem
Payload-Vertrag (`DestructionNotice`: `object_type`,
`object_reference`, `destroyed_at`).
**Bewusst NICHT Teil dieses Tickets**: die eigentlichen Rückruf-
EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte) — die tatsächliche
Vernichtungslogik, die `NotifyDestruction` aufruft (kommt mit RET-02
und späteren Vernichtungs-Tickets), sowie Wiederholungslogik bei
fehlgeschlagenem Rückruf (Interface-Vertrag ist klar: Erfolg = HTTP
2xx, sonst Fehler — WIE mit einem Fehler umgegangen wird, ist
Aufgabe des aufrufenden Vernichtungs-Jobs, nicht dieses Pakets).
## Korrektur vor Abschluss: `retention_class` fehlte im ersten Entwurf
Akzeptanzkriterium 1 verlangt "Objekttyp MIT Aufbewahrungsklasse UND
Rückruf-Adresse" — der erste Entwurf von `module_registrations` und
`Register` hatte nur `callback_url`, `retention_class` fehlte komplett.
Vor dem Board-Flip auf „Fertig" bemerkt und korrigiert: Migration,
`Registration`-Struct, `Register`, `ListRegistrations` und
`RegisterHandler` um `retention_class` ergänzt, alle Tests entsprechend
angepasst (inkl. Idempotenz-Nachweis auch für `retention_class`, nicht
nur `callback_url`). Reale, bereits angewendete Migration auf
`dms_tenant_test` musste dafür gedroppt und neu angewendet werden (kein
Produktivbestand betroffen, Testsystem).
## Umsetzung
- `migrations/0003_module_registrations.up.sql`/`.down.sql`
`module_registrations` (module_name, object_type, callback_url,
UNIQUE-Constraint).
- `internal/moduleadapter.Register``ON CONFLICT DO NOTHING` + Nachlese
der bestehenden Zeile, damit eine erneute Registrierung NIE die
bestehende `callback_url` überschreibt (Akzeptanzkriterium 3).
- `internal/moduleadapter.ListRegistrations`.
- `internal/moduleadapter.NotifyDestruction` — echter HTTP-POST mit dem
festen `DestructionNotice`-Vertrag.
- `internal/moduleadapter.RegisterHandler` — REST-Endpunkt
(`POST /register`).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Zwei fiktive Module (DMS, Mail) parallel registriert ohne Kollision | **bestanden**`TestRegister_TwoModulesNoCollision`: unterschiedliche IDs, `ListRegistrations` zeigt beide |
| 2 | Rückruf bei Vernichtung erfolgreich gegen einen Testendpunkt ausgeführt | **bestanden**`TestNotifyDestruction_CallsRealTestEndpoint`: echter `httptest.Server`, echter POST, Payload real empfangen und geprüft (`object_reference` korrekt) |
| 3 | Erneute Registrierung desselben Objekttyps ändert nichts am bestehenden Zustand | **bestanden**`TestRegister_IsIdempotent_UnchangedExistingState` (Go-Funktion, mit absichtlich ABWEICHENDER `callback_url` im zweiten Aufruf) UND `TestRegisterHandler_RealHTTPRoundTrip` (dieselbe Prüfung nochmal über die HTTP-Schicht, nicht nur direkt gegen die Funktion) |
Zusätzlich: `TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus`
(Fehlerverhalten), `TestRegisterHandler_RejectsMissingFields`
(REST-Schicht weist unvollständige Registrierungen ab).
## Echte Verdrahtung auf 192.168.1.131
- Migration real gegen `dms_tenant_test` angewendet — `module_registrations`
bestätigt vorhanden
- Kein systemd-Dienst — `RegisterHandler` ist ein `http.HandlerFunc`,
wird in einen künftigen Core-/Archive-HTTP-Server eingehängt, sobald
ein solcher für Archive existiert (aktuell kein eigener Archive-
API-Server, nur die bisherigen CLI/Metrics-Prozesse) — dokumentierter,
kein stiller Gap, entspricht dem Interface-Freeze-Charakter dieses
Tickets
## 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 -> 9/9 Pakete mit Tests ok, 0 Fehlschläge
(nach Korrektur; internal/retention und internal/moduleadapter brauchen
TEST_TENANT_DSN/TEST_TENANT_DSN_B bzw. TEST_TENANT_DSN)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — Idempotenz sowohl auf Go- als auch auf
HTTP-Ebene bewiesen, Rückruf-Vertrag gegen einen echten Testendpunkt
verifiziert. Bewusst als reiner Interface-Freeze umgesetzt, keine
DMS-/Mail-seitige Implementierung — wie vom Nutzer vorgegeben.
-91
View File
@@ -1,91 +0,0 @@
# RET-06-API Prüfprotokoll: Aufbewahrungsfristen-Konfigurations-Backend
Voraussetzung RET-02 erledigt, siehe eigenes Protokoll.
**Scope-Entscheidung (Board-Rücksprache):** RET-06 verlangt Next.js/
React-Frontend + Backend-API + RBAC. Statt eines einzigen
Big-Scope-Tickets: **Backend-API zuerst** (dieses Protokoll), das
echte Next.js-Frontend folgt als eigenes, separates Ticket. Diese
Trennung wurde bewusst gewählt, damit das Prüfprotokoll nicht auf ein
Pseudo-Frontend verweist.
## Grundsatzentscheidung: provisorischer Rollen-Check, KEIN RBAC-02
Core RBAC-02 (`internal/policy`, `Enforcer.Authorize`) ist reiner
Go-Code innerhalb des Core-Moduls — keine HTTP-Schnittstelle, über die
Archive (physisch getrenntes Go-Modul) es aufrufen könnte. Derselbe
"gefunden, aber nicht modulübergreifend verdrahtet"-Befund wie bei Core
FDN-03/FDN-09 (siehe frühere Prüfprotokolle).
**`internal/retentionapi.RequireRole` ist AUSDRÜCKLICH KEIN RBAC-02-
Ersatz**, sondern ein Provisorium: prüft nur einen selbst gesetzten
Header (`X-Admin-Roles`), leicht zu fälschen von jedem, der den Header
setzen kann. Muss ersetzt werden, sobald ein Core-seitiger HTTP-Wrapper
um RBAC-02 existiert (Empfehlung: eigenes künftiges Core-Ticket,
z. B. `RBAC-06`, wiederverwendbar für alle Module statt je Modul einen
eigenen Provisorium-Check). Bis dahin real getestet inklusive
Negativfall (Pflichtprüfung, siehe unten) — ein UNGEPRÜFTER
Provisorium-Check wäre nur eine verschobene Schwachstelle.
## Umsetzung
- `migrations/0005_retention_class_rules_active.up.sql`/`.down.sql`
`active`-Flag statt DELETE (Klasse deaktivieren ohne Historienverlust).
- `internal/retentionengine.DeactivateClassRule`/`ListClassRules`
Erweiterung von RET-02s Paket, `ComputeDueDate`/`ListExpiringObjects`
berücksichtigen nur noch aktive Regeln.
- `internal/retentionapi.RequireRole` — provisorischer Header-Rollen-Check.
- `internal/retentionapi.Mount` — vier Endpunkte: `POST
/retention-classes` (anlegen/ändern), `POST
/retention-classes/{class}/deactivate`, `GET /retention-classes`
(Liste), `GET /retention-classes/preview` (Vorschau, nutzt DIESELBE
`ListExpiringObjects`-Funktion wie RET-02s periodischer Job).
- `cmd/retention-api` — eigenständiger HTTP-Dienst.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Änderung einer Frist wirkt sich nur auf künftige Berechnungen aus, nicht rückwirkend auf bereits protokollierte Vernichtungen | **bestanden** — `TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations`: bereits berechneter Stichtag bleibt unverändert (strukturell garantiert, keine Tabelle mit "bereits berechneten" Werten existiert, die rückwirkend verändert werden könnte), NEUE Berechnung übernimmt die neue Frist |
| 2 | Nicht berechtigte Rolle erhält keinen Zugriff auf die Konfiguration | **bestanden** — `TestRequireRole_MissingRoleReturns403` (kein Header UND falsche Rolle, beide 403) UND `TestRequireRole_CorrectRoleAllowsAccess` (Gegentest); real auf 131: `curl` ohne Rollen-Header → 403 |
| 3 | Vorschauliste stimmt mit dem Ergebnis des periodischen Jobs überein | **bestanden** — `TestPreviewHandler_MatchesPeriodicJobResult`: HTTP-Vorschau UND direkter `ListExpiringObjects`-Aufruf liefern dasselbe Objekt (dieselbe Funktion, kein Doppel-Code) |
Zusätzlich: `TestDeactivateClassRule_ExcludesFromFutureCalculations`,
`TestConfigureAndListHandler_RealHTTPRoundTrip`,
`TestDeactivateHandler_RealHTTPRoundTrip`.
## Echte Verdrahtung auf 192.168.1.131
- `retention-api` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-retention-api.env` (0600)
- `nexarch-archive-retention-api.service` installiert/aktiviert
(dauerhaft, `Restart=on-failure`)
- Realer End-zu-Ende-Test via `curl`: POST ohne Rollen-Header → 403;
POST mit `X-Admin-Roles: archive_admin` → 200, Klasse angelegt; `GET
/retention-classes` zeigt sie; `GET /retention-classes/preview`
liefert `null` (kein fälliges Objekt, korrekt leer) — Testdaten
anschließend entfernt
## 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 -> 11/11 Pakete mit Tests ok, 0 Fehlschläge
```
**Hinweis:** Zwei Pakete (`internal/retentionengine`,
`internal/retentionapi`) gegen dieselbe reale Tenant-DB laufen zu lassen
erfordert `-p 1` (sequentiell) — parallele Testläufe unterschiedlicher
Pakete gegen dieselbe echte Datenbank führen sonst zu
Cross-Test-Kontamination (TRUNCATE eines Pakets während ein anderes
noch liest/schreibt). `make check`/`Makefile` erzwingt das bereits.
## Gesamtergebnis
**Bestanden — Backend-Scope.** Alle drei Pflichtprüfungen real erfüllt.
**Offen, bewusst nicht Teil dieses Protokolls:** das Next.js/React-
Frontend (separates Folgeticket) und der Ersatz des provisorischen
Rollen-Checks durch einen echten RBAC-02-Aufruf, sobald Core einen
HTTP-Wrapper dafür bereitstellt.
-14
View File
@@ -1,14 +0,0 @@
module gitea.perlbach24.de/scripte/nexarch/archive
go 1.22
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
-199
View File
@@ -1,199 +0,0 @@
// Package backup implementiert BAK-01: automatisierte, inkrementelle
// Sicherung der PostgreSQL-Datenbank per pg_basebackup (PostgreSQL 17s
// natives inkrementelles Backup über WAL-Summarization, siehe
// `summarize_wal`), mit Verifikation jeder Sicherung und
// generationsbasierter Rotation. Kein pg_dump-basierter Ansatz, weil
// pg_dump ausschließlich logische Vollsicherungen kennt — "inkrementell"
// im Sinne des Tickets erfordert das physische, WAL-summary-gestützte
// Verfahren aus PostgreSQL 17.
package backup
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"time"
)
// Config enthält die Verbindungsdaten für pg_basebackup — ausschließlich
// über Umgebungsvariablen befüllt, nie im Code (siehe Ticket-Abschluss-
// Regel).
type Config struct {
Host string
Port string
User string
Password string
BackupDir string
PgBaseBackupPath string // Default "pg_basebackup", überschreibbar für Tests
PgCombineBackupPath string // Default "pg_combinebackup", überschreibbar für Tests
}
func (c Config) binary() string {
if c.PgBaseBackupPath != "" {
return c.PgBaseBackupPath
}
return "pg_basebackup"
}
// FullBackupDirName/IncrementalDirName sind die festen Unterverzeichnis-
// namen je Generation.
const (
FullBackupDirName = "full"
IncrementalSubdir = "incremental"
BackupManifestFile = "backup_manifest"
BaseTarGzFile = "base.tar.gz"
// WalTarGzFile: pg_basebackups Standard-WAL-Methode ist "stream" (WAL
// wird waehrend der Sicherung parallel mitgestreamt) - bei -Ft/-z
// landet dieser Strom in einer EIGENEN Archivdatei neben base.tar.gz.
// Ohne dieses WAL ist die Sicherung NICHT crash-konsistent
// wiederherstellbar (Postgres kann sonst keinen gueltigen Checkpoint
// erreichen) - siehe Restore.
WalTarGzFile = "pg_wal.tar.gz"
)
// NewGenerationID liefert eine sortierbare, eindeutige Generation-Kennung
// (RFC3339-artig, dateisystemtauglich) — Generationen werden anhand dieser
// Kennung chronologisch sortiert (Rotate, ListGenerations).
func NewGenerationID(t time.Time) string {
return t.UTC().Format("20060102T150405Z")
}
// FullBackup erstellt eine neue Vollsicherung (Akzeptanzkriterium 1) als
// eigene Generation. Liefert den Pfad zum backup_manifest, das spätere
// IncrementalBackup-Aufrufe als Referenz brauchen.
func FullBackup(ctx context.Context, cfg Config, generationID string) (manifestPath string, err error) {
dir := filepath.Join(cfg.BackupDir, generationID, FullBackupDirName)
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
return "", fmt.Errorf("backup: generationsverzeichnis anlegen: %w", err)
}
args := []string{
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
}
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
return "", fmt.Errorf("backup: vollsicherung: %w", err)
}
return filepath.Join(dir, BackupManifestFile), nil
}
// IncrementalBackup erstellt eine inkrementelle Sicherung gegen die zuletzt
// bekannte Vollsicherung ODER die letzte Inkrement-Sicherung (priorManifestPath
// zeigt jeweils auf das backup_manifest der Referenz).
func IncrementalBackup(ctx context.Context, cfg Config, generationID, incrementID, priorManifestPath string) (manifestPath string, err error) {
dir := filepath.Join(cfg.BackupDir, generationID, IncrementalSubdir, incrementID)
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
return "", fmt.Errorf("backup: inkrement-verzeichnis anlegen: %w", err)
}
args := []string{
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
"--incremental=" + priorManifestPath,
}
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
return "", fmt.Errorf("backup: inkrementelle sicherung: %w", err)
}
return filepath.Join(dir, BackupManifestFile), nil
}
// Restore kombiniert die Vollsicherung einer Generation mit allen ihren
// Inkrementen (PostgreSQL 17s `pg_combinebackup`, das native Gegenstück zu
// `pg_basebackup --incremental`) und schreibt das Ergebnis nach outputDir —
// ein vollständiges, direkt startbares PGDATA-Verzeichnis. outputDir muss
// bereits existieren und leer sein (pg_combinebackup-Vorgabe); Atomarität
// gegenüber einem eventuell nicht-leeren ENDZIEL ist Aufgabe von
// internal/restore, nicht dieser Funktion.
//
// pg_combinebackup erwartet PLAIN-Format-Eingabeverzeichnisse (Dateibaum),
// FullBackup/IncrementalBackup speichern aber TAR+GZIP (`-Ft -z`, siehe
// dort) — kleinere, einfacher zu prüfende Sicherungsdateien
// (BAK-01-Entscheidung, hier NICHT verändert). Restore extrahiert daher
// jede Stufe zunächst in ein eigenes temporäres Plain-Verzeichnis, bevor
// pg_combinebackup darauf zugreift.
func Restore(ctx context.Context, cfg Config, generationID, outputDir string) error {
genDir := filepath.Join(cfg.BackupDir, generationID)
tarDirs := []string{filepath.Join(genDir, FullBackupDirName)}
incrDir := filepath.Join(genDir, IncrementalSubdir)
entries, err := os.ReadDir(incrDir)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("backup: inkrement-verzeichnis lesen: %w", err)
}
incrementIDs := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
incrementIDs = append(incrementIDs, e.Name())
}
}
sort.Strings(incrementIDs) // Inkrement-IDs sind wie Generation-IDs chronologisch sortierbar
for _, id := range incrementIDs {
tarDirs = append(tarDirs, filepath.Join(incrDir, id))
}
extractRoot, err := os.MkdirTemp("", "backup-restore-extract-*")
if err != nil {
return fmt.Errorf("backup: extraktions-verzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(extractRoot) }()
inputs := make([]string, 0, len(tarDirs))
for i, tarDir := range tarDirs {
plainDir := filepath.Join(extractRoot, fmt.Sprintf("%02d", i))
if err := os.MkdirAll(plainDir, 0o700); err != nil {
return fmt.Errorf("backup: plain-verzeichnis anlegen: %w", err)
}
if err := extractTarGz(filepath.Join(tarDir, BaseTarGzFile), plainDir); err != nil {
return fmt.Errorf("backup: %q extrahieren: %w", tarDir, err)
}
// backup_manifest liegt NEBEN base.tar.gz (von pg_basebackup so
// geschrieben), nicht im Archiv selbst - pg_combinebackup braucht
// es zusätzlich im Plain-Verzeichnis.
manifestData, err := os.ReadFile(filepath.Join(tarDir, BackupManifestFile))
if err != nil {
return fmt.Errorf("backup: %q lesen: %w", filepath.Join(tarDir, BackupManifestFile), err)
}
if err := os.WriteFile(filepath.Join(plainDir, BackupManifestFile), manifestData, 0o600); err != nil {
return fmt.Errorf("backup: manifest nach %q kopieren: %w", plainDir, err)
}
inputs = append(inputs, plainDir)
}
binary := "pg_combinebackup"
if cfg.PgCombineBackupPath != "" {
binary = cfg.PgCombineBackupPath
}
args := append(append([]string{}, inputs...), "-o", outputDir)
cmd := exec.CommandContext(ctx, binary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: %s fehlgeschlagen: %w (ausgabe: %s)", binary, err, string(output))
}
// pg_combinebackup rekonstruiert nur die Datendateien - das fuer einen
// konsistenten Start noetige WAL kommt aus der ZULETZT gezogenen Stufe
// (letztes Inkrement, sonst die Vollsicherung), nicht aus allen Stufen
// zusammen (siehe WalTarGzFile-Dokumentation).
lastTarDir := tarDirs[len(tarDirs)-1]
walDir := filepath.Join(outputDir, "pg_wal")
if err := os.MkdirAll(walDir, 0o700); err != nil {
return fmt.Errorf("backup: pg_wal-verzeichnis anlegen: %w", err)
}
if err := extractTarGz(filepath.Join(lastTarDir, WalTarGzFile), walDir); err != nil {
return fmt.Errorf("backup: WAL aus %q wiederherstellen: %w", lastTarDir, err)
}
return nil
}
func runPgBaseBackup(ctx context.Context, cfg Config, args []string) error {
cmd := exec.CommandContext(ctx, cfg.binary(), args...)
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), err, string(output))
}
return nil
}
-187
View File
@@ -1,187 +0,0 @@
package backup
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func requireTestConfig(t *testing.T) Config {
t.Helper()
user := os.Getenv("TEST_BACKUP_PG_USER")
if user == "" {
t.Skip("TEST_BACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen (braucht echten Postgres mit REPLICATION-Rolle)")
}
return Config{
Host: envOr("TEST_BACKUP_PG_HOST", "localhost"),
Port: envOr("TEST_BACKUP_PG_PORT", "5432"),
User: user,
Password: os.Getenv("TEST_BACKUP_PG_PASSWORD"),
BackupDir: t.TempDir(),
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// TestFullBackup_CreatesVerifiedBackup ist Pruefung 1: Sicherung gegen
// Testdatenbank erfolgreich erstellt und verifiziert.
func TestFullBackup_CreatesVerifiedBackup(t *testing.T) {
cfg := requireTestConfig(t)
ctx := context.Background()
genID := NewGenerationID(time.Now())
manifest, err := FullBackup(ctx, cfg, genID)
if err != nil {
t.Fatalf("fullbackup: %v", err)
}
if _, err := os.Stat(manifest); err != nil {
t.Fatalf("backup_manifest fehlt: %v", err)
}
dir := filepath.Dir(manifest)
if _, err := os.Stat(filepath.Join(dir, BaseTarGzFile)); err != nil {
t.Fatalf("%s fehlt: %v", BaseTarGzFile, err)
}
if err := Verify(dir); err != nil {
t.Fatalf("verify: %v", err)
}
}
// TestIncrementalBackup_IsSmallerThanFull ist der Nachweis fuer
// Akzeptanzkriterium 1 (inkrementell): eine echte inkrementelle Sicherung
// gegen unveraenderten Bestand ist deutlich kleiner als die Vollsicherung —
// beweist, dass tatsaechlich nur Aenderungen uebertragen wurden (PostgreSQL
// 17 WAL-Summarization), nicht nochmal alles.
func TestIncrementalBackup_IsSmallerThanFull(t *testing.T) {
cfg := requireTestConfig(t)
ctx := context.Background()
genID := NewGenerationID(time.Now())
fullManifest, err := FullBackup(ctx, cfg, genID)
if err != nil {
t.Fatalf("fullbackup: %v", err)
}
fullDir := filepath.Dir(fullManifest)
fullSize := fileSize(t, filepath.Join(fullDir, BaseTarGzFile))
incID := NewGenerationID(time.Now().Add(time.Second))
incManifest, err := IncrementalBackup(ctx, cfg, genID, incID, fullManifest)
if err != nil {
t.Fatalf("incrementalbackup: %v", err)
}
incDir := filepath.Dir(incManifest)
if err := Verify(incDir); err != nil {
t.Fatalf("verify (inkrementell): %v", err)
}
incSize := fileSize(t, filepath.Join(incDir, BaseTarGzFile))
if incSize >= fullSize {
t.Fatalf("inkrementelle sicherung (%d bytes) ist nicht kleiner als die vollsicherung (%d bytes) - keine echte inkrementelle Uebertragung", incSize, fullSize)
}
}
func fileSize(t *testing.T, path string) int64 {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("dateigroesse von %q ermitteln: %v", path, err)
}
return info.Size()
}
// TestVerify_DetectsCorruptedFile ist Pruefung 2: Verifikation erkennt eine
// absichtlich beschaedigte Sicherungsdatei.
func TestVerify_DetectsCorruptedFile(t *testing.T) {
cfg := requireTestConfig(t)
ctx := context.Background()
genID := NewGenerationID(time.Now())
manifest, err := FullBackup(ctx, cfg, genID)
if err != nil {
t.Fatalf("fullbackup: %v", err)
}
dir := filepath.Dir(manifest)
if err := Verify(dir); err != nil {
t.Fatalf("verify (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
}
// Absichtliche Beschaedigung: mehrere Bytes in der Mitte der Datei kippen.
path := filepath.Join(dir, BaseTarGzFile)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("sicherungsdatei lesen: %v", err)
}
mid := len(data) / 2
for i := mid; i < mid+64 && i < len(data); i++ {
data[i] ^= 0xFF
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("beschaedigte sicherungsdatei schreiben: %v", err)
}
if err := Verify(dir); err == nil {
t.Fatal("verify haette die beschaedigte sicherungsdatei erkennen muessen")
}
}
// TestRotate_RemovesOnlyOldestGenerations ist Pruefung 3.
func TestRotate_RemovesOnlyOldestGenerations(t *testing.T) {
backupDir := t.TempDir()
generationIDs := []string{
"20260101T000000Z",
"20260102T000000Z",
"20260103T000000Z",
"20260104T000000Z",
"20260105T000000Z",
}
for _, id := range generationIDs {
if err := os.MkdirAll(filepath.Join(backupDir, id, FullBackupDirName), 0o750); err != nil {
t.Fatalf("generation %q anlegen: %v", id, err)
}
}
removed, err := Rotate(backupDir, 2)
if err != nil {
t.Fatalf("rotate: %v", err)
}
wantRemoved := []string{"20260101T000000Z", "20260102T000000Z", "20260103T000000Z"}
if len(removed) != len(wantRemoved) {
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
}
for i, w := range wantRemoved {
if removed[i] != w {
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
}
}
remaining, err := ListGenerations(backupDir)
if err != nil {
t.Fatalf("listgenerations: %v", err)
}
wantRemaining := []string{"20260104T000000Z", "20260105T000000Z"}
if len(remaining) != len(wantRemaining) {
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
}
for i, w := range wantRemaining {
if remaining[i] != w {
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
}
}
// Die NEUESTEN duerfen NICHT entfernt sein (Pruefung 3: nur die
// aeltesten Generationen).
for _, w := range wantRemaining {
if _, err := os.Stat(filepath.Join(backupDir, w)); err != nil {
t.Fatalf("neueste generation %q wurde faelschlich entfernt: %v", w, err)
}
}
}
-102
View File
@@ -1,102 +0,0 @@
package backup
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestRestore_CombinesIntoStartablePostgresInstance ist Pruefung 1 fuer
// BAK-03 (Datenbank-Teil): Restore auf leerem System vollstaendig und
// erfolgreich durchgefuehrt — real bewiesen, indem das wiederhergestellte
// Verzeichnis tatsaechlich als eigenstaendige Postgres-Instanz gestartet
// und per echter Verbindung abgefragt wird, nicht nur auf Dateiexistenz
// geprueft.
func TestRestore_CombinesIntoStartablePostgresInstance(t *testing.T) {
cfg := requireTestConfig(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, err := exec.LookPath("pg_combinebackup"); err != nil {
t.Skip("pg_combinebackup nicht installiert, restore-integrationstest uebersprungen")
}
pgCtl, err := exec.LookPath("pg_ctl")
if err != nil {
t.Skip("pg_ctl nicht installiert, restore-integrationstest uebersprungen")
}
genID := NewGenerationID(time.Now())
if _, err := FullBackup(ctx, cfg, genID); err != nil {
t.Fatalf("fullbackup: %v", err)
}
restoreOut := filepath.Join(t.TempDir(), "restored-pgdata")
if err := os.MkdirAll(restoreOut, 0o700); err != nil {
t.Fatal(err)
}
if err := Restore(ctx, cfg, genID, restoreOut); err != nil {
t.Fatalf("restore: %v", err)
}
if _, err := os.Stat(filepath.Join(restoreOut, "PG_VERSION")); err != nil {
t.Fatalf("wiederhergestelltes verzeichnis ist kein gueltiges PGDATA (PG_VERSION fehlt): %v", err)
}
// Auf diesem Debian-Postgres liegt postgresql.conf NICHT in PGDATA
// (sondern in /etc/postgresql/17/main/) - pg_basebackup sichert daher
// nur PGDATA-Inhalte, die Konfigurationsdatei fehlt im Restore
// GENAUSO wie im echten Betriebs-Restore-Verfahren. Fuer den
// End-zu-End-Nachweis (echte Daten wiederherstellbar) hier eine
// minimale, ausschliesslich fuer den Testlauf gueltige Konfiguration
// nachgereicht - dokumentiert als operativer Hinweis fuer ein echtes
// Restore-Runbook, nicht Teil des Produktcodes.
minimalConf := "listen_addresses = ''\n"
if err := os.WriteFile(filepath.Join(restoreOut, "postgresql.conf"), []byte(minimalConf), 0o600); err != nil {
t.Fatal(err)
}
// dito pg_hba.conf - liegt auf Debian ebenfalls in /etc/postgresql,
// nicht in PGDATA. Nur Unix-Socket-Verbindungen des lokalen Testlaufs
// erlaubt (kein TCP, dieselbe Isolation wie listen_addresses='').
hba := "local all all trust\n"
if err := os.WriteFile(filepath.Join(restoreOut, "pg_hba.conf"), []byte(hba), 0o600); err != nil {
t.Fatal(err)
}
// Konfigurationsdateien aus der Quellinstanz brachte pg_basebackup mit
// (sie enthalten ggf. den alten Port/Unix-Socket) - fuer diesen Test
// bewusst neuer Port und eigenes Socket-Verzeichnis, damit die
// wiederhergestellte Instanz nicht mit der laufenden Test-Instanz
// kollidiert.
socketDir := t.TempDir()
testPort := "55432"
// WICHTIG: pg_ctl start foerdert postgres als Hintergrundprozess, der
// die geerbten stdout/stderr-Pipes NICHT schliesst - CombinedOutput()
// (das auf ein Pipe-EOF wartet) haengt sich daher auf, obwohl pg_ctl
// selbst laengst zurueckgekehrt ist. Deshalb echte Logdatei statt Pipe
// (Standard-pg_ctl-Muster), kein exec.Cmd.Stdout/-Stderr-Pipe-Zugriff.
logFile := filepath.Join(t.TempDir(), "postgres.log")
startCmd := exec.CommandContext(ctx, pgCtl, "start", "-D", restoreOut, "-w", "-t", "30", "-l", logFile,
"-o", fmt.Sprintf("-p %s -k %s -c listen_addresses=''", testPort, socketDir))
if err := startCmd.Run(); err != nil {
logContent, _ := os.ReadFile(logFile)
t.Fatalf("pg_ctl start (wiederhergestellte instanz): %v (log: %s)", err, string(logContent))
}
defer func() {
stopCmd := exec.Command(pgCtl, "stop", "-D", restoreOut, "-m", "fast")
_ = stopCmd.Run()
}()
psqlOut, err := exec.CommandContext(ctx, "psql",
"-h", socketDir, "-p", testPort, "-U", cfg.User, "-d", "postgres",
"-tAc", "SELECT 1").CombinedOutput()
if err != nil {
t.Fatalf("echte verbindung zur wiederhergestellten instanz fehlgeschlagen: %v (ausgabe: %s)", err, string(psqlOut))
}
if strings.TrimSpace(string(psqlOut)) != "1" {
t.Fatalf("unerwartete antwort von der wiederhergestellten instanz: %q", string(psqlOut))
}
}
-89
View File
@@ -1,89 +0,0 @@
package backup
import (
"fmt"
"sort"
"time"
)
// RetentionPolicy ist BAK-07s gestaffelte Aufbewahrungsfrist für
// Datenbank-Sicherungsgenerationen — unabhängig von RET-02s
// Dokumenten-Retention (Akzeptanzkriterium 1). Ein Feldwert 0 bedeutet
// "diese Stufe nicht anwenden", dieselbe Konvention wie
// objectbackup.RetentionPolicy (BAK-02), damit beide Backup-Arten
// gleich konfiguriert werden.
type RetentionPolicy struct {
KeepDaily int
KeepWeekly int
KeepMonthly int
}
// PruneTiered wählt aus generations (IDs im NewGenerationID-Format) die
// zu BEHALTENDEN nach gestaffelter GFS-Regel: je Granularität
// (Tag/Woche/Monat) wird pro Zeitfenster die NEUESTE Generation
// behalten, bis die konfigurierte Anzahl Fenster erreicht ist — reine
// Funktion, keine Dateisystem-/Netzwerkzugriffe, dadurch ohne echte
// Infrastruktur testbar (Rotate in rotate.go führt die tatsächliche
// Löschung anhand des Ergebnisses aus).
//
// Sicherheitsnetz für Akzeptanzkriterium 3 ("mindestens ein aktueller
// Snapshot bleibt erhalten"): die NEUESTE Generation wird IMMER
// behalten, unabhängig von der konfigurierten Staffelung — selbst bei
// versehentlich auf 0 gesetzter Policy geht nie die letzte Sicherung
// verloren.
func PruneTiered(generations []string, policy RetentionPolicy) (keep, remove []string) {
type parsed struct {
id string
t time.Time
}
var items []parsed
for _, id := range generations {
t, err := time.Parse("20060102T150405Z", id)
if err != nil {
continue // unbekanntes Format wird ignoriert, nicht geloescht
}
items = append(items, parsed{id: id, t: t})
}
sort.Slice(items, func(i, j int) bool { return items[i].t.After(items[j].t) }) // neueste zuerst
keepSet := make(map[string]bool)
if len(items) > 0 {
keepSet[items[0].id] = true // Sicherheitsnetz: immer die neueste
}
applyTier := func(bucketKey func(time.Time) string, n int) {
if n <= 0 {
return
}
seen := make(map[string]bool)
count := 0
for _, it := range items {
if count >= n {
break
}
key := bucketKey(it.t)
if seen[key] {
continue
}
seen[key] = true
count++
keepSet[it.id] = true
}
}
applyTier(func(t time.Time) string { return t.Format("2006-01-02") }, policy.KeepDaily)
applyTier(func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
}, policy.KeepWeekly)
applyTier(func(t time.Time) string { return t.Format("2006-01") }, policy.KeepMonthly)
for _, it := range items {
if keepSet[it.id] {
keep = append(keep, it.id)
} else {
remove = append(remove, it.id)
}
}
return keep, remove
}
-130
View File
@@ -1,130 +0,0 @@
package backup
import (
"os"
"path/filepath"
"testing"
"time"
)
func genID(t time.Time) string { return NewGenerationID(t) }
// TestPruneTiered_KeepsCorrectStaggering ist Pruefung 1: Prune-Test mit
// simulierten Snapshots ueber mehrere Altersstufen ergibt korrekte
// Staffelung.
func TestPruneTiered_KeepsCorrectStaggering(t *testing.T) {
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
var generations []string
// 20 taegliche generationen der letzten 20 Tage.
for i := 0; i < 20; i++ {
generations = append(generations, genID(now.Add(-time.Duration(i)*24*time.Hour)))
}
// zusaetzlich aeltere, wochenweise verteilte generationen (Wochen 4-15
// zurueck), damit KeepWeekly etwas zu greifen hat, das KeepDaily nicht
// schon abdeckt.
for w := 4; w <= 15; w++ {
generations = append(generations, genID(now.Add(-time.Duration(w*7)*24*time.Hour)))
}
// und einige monatealte generationen.
for m := 3; m <= 10; m++ {
generations = append(generations, genID(now.AddDate(0, -m, 0)))
}
policy := RetentionPolicy{KeepDaily: 7, KeepWeekly: 4, KeepMonthly: 6}
keep, remove := PruneTiered(generations, policy)
if len(keep)+len(remove) != len(generations) {
t.Fatalf("keep+remove = %d, want %d (gesamtzahl)", len(keep)+len(remove), len(generations))
}
// Genau die 7 juengsten taeglichen generationen muessen behalten sein.
keepSet := make(map[string]bool, len(keep))
for _, k := range keep {
keepSet[k] = true
}
for i := 0; i < 7; i++ {
id := genID(now.Add(-time.Duration(i) * 24 * time.Hour))
if !keepSet[id] {
t.Fatalf("generation %q (tag %d) haette per KeepDaily behalten werden muessen", id, i)
}
}
// Zu weit zurueckliegende taegliche generationen (ausserhalb aller
// Stufen) duerfen NICHT behalten sein.
tooOld := genID(now.Add(-19 * 24 * time.Hour))
if keepSet[tooOld] {
t.Fatalf("generation %q haette NICHT behalten werden duerfen (zu alt fuer jede stufe)", tooOld)
}
if len(keep) == 0 {
t.Fatal("erwartet mindestens eine behaltene generation")
}
}
// TestPruneTiered_AlwaysKeepsNewest ist das Sicherheitsnetz fuer
// Akzeptanzkriterium 3 (mindestens ein aktueller Snapshot bleibt immer
// erhalten) - selbst bei Policy 0/0/0 (versehentlich deaktiviert).
func TestPruneTiered_AlwaysKeepsNewest(t *testing.T) {
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
generations := []string{genID(now), genID(now.Add(-48 * time.Hour))}
keep, remove := PruneTiered(generations, RetentionPolicy{})
if len(keep) != 1 || keep[0] != genID(now) {
t.Fatalf("erwartet genau die neueste generation behalten, habe keep=%v", keep)
}
if len(remove) != 1 {
t.Fatalf("erwartet 1 entfernte generation, habe %d", len(remove))
}
}
// TestPruneTiered_DeterministicForIdenticalInput - zwei Laeufe mit
// identischer Eingabe liefern dasselbe Ergebnis.
func TestPruneTiered_DeterministicForIdenticalInput(t *testing.T) {
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
var generations []string
for i := 0; i < 10; i++ {
generations = append(generations, genID(now.Add(-time.Duration(i)*24*time.Hour)))
}
policy := RetentionPolicy{KeepDaily: 3}
keep1, remove1 := PruneTiered(generations, policy)
keep2, remove2 := PruneTiered(generations, policy)
if len(keep1) != len(keep2) || len(remove1) != len(remove2) {
t.Fatal("unterschiedliches ergebnis zwischen zwei laeufen mit identischer eingabe")
}
}
// TestPruneRotate_DeletesOnlyExpiredGenerations ist Pruefung 2:
// automatisierte Loeschung abgelaufener Snapshots im Test nachgewiesen -
// real gegen echte Verzeichnisse.
func TestPruneRotate_DeletesOnlyExpiredGenerations(t *testing.T) {
backupDir := t.TempDir()
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
var recent, expired string
for i := 0; i < 3; i++ {
id := genID(now.Add(-time.Duration(i) * 24 * time.Hour))
if err := os.MkdirAll(filepath.Join(backupDir, id, FullBackupDirName), 0o750); err != nil {
t.Fatal(err)
}
recent = id
}
expired = genID(now.Add(-60 * 24 * time.Hour))
if err := os.MkdirAll(filepath.Join(backupDir, expired, FullBackupDirName), 0o750); err != nil {
t.Fatal(err)
}
removed, err := PruneRotate(backupDir, RetentionPolicy{KeepDaily: 3})
if err != nil {
t.Fatalf("prunerotate: %v", err)
}
if len(removed) != 1 || removed[0] != expired {
t.Fatalf("erwartet genau die abgelaufene generation %q entfernt, habe %v", expired, removed)
}
if _, err := os.Stat(filepath.Join(backupDir, recent)); err != nil {
t.Fatalf("juengste generation faelschlich entfernt: %v", err)
}
if _, err := os.Stat(filepath.Join(backupDir, expired)); !os.IsNotExist(err) {
t.Fatal("abgelaufene generation wurde nicht tatsaechlich vom dateisystem entfernt")
}
}
-76
View File
@@ -1,76 +0,0 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"sort"
)
// ListGenerations liefert alle Generation-IDs in backupDir, aufsteigend
// sortiert (die GenerationID selbst ist chronologisch sortierbar, siehe
// NewGenerationID — kein Blick auf Dateisystem-Zeitstempel nötig, die bei
// einem Restore/Kopiervorgang verändert werden könnten).
func ListGenerations(backupDir string) ([]string, error) {
entries, err := os.ReadDir(backupDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("backup: sicherungsverzeichnis lesen: %w", err)
}
var generations []string
for _, e := range entries {
if e.IsDir() {
generations = append(generations, e.Name())
}
}
sort.Strings(generations)
return generations, nil
}
// Rotate entfernt alle bis auf die `keep` NEUESTEN Generationen
// (Akzeptanzkriterium 3) — jede Generation umfasst ihre Vollsicherung UND
// alle davon abhängigen Inkremente, ein Löschen der gesamten
// Generationsverzeichnisses entfernt beides konsistent zusammen.
func Rotate(backupDir string, keep int) (removed []string, err error) {
if keep < 0 {
keep = 0
}
generations, err := ListGenerations(backupDir)
if err != nil {
return nil, err
}
if len(generations) <= keep {
return nil, nil
}
toRemove := generations[:len(generations)-keep]
for _, gen := range toRemove {
if err := os.RemoveAll(filepath.Join(backupDir, gen)); err != nil {
return removed, fmt.Errorf("backup: generation %q entfernen: %w", gen, err)
}
removed = append(removed, gen)
}
return removed, nil
}
// PruneRotate wendet BAK-07s gestaffelte RetentionPolicy (PruneTiered,
// siehe retention.go) tatsächlich an — entfernt alle nicht mehr zu
// behaltenden Generationsverzeichnisse. Automatisierte Löschung nach
// Ablauf der Frist (Akzeptanzkriterium 2), unabhängig von RET-02s
// Dokumenten-Retention.
func PruneRotate(backupDir string, policy RetentionPolicy) (removed []string, err error) {
generations, err := ListGenerations(backupDir)
if err != nil {
return nil, err
}
_, toRemove := PruneTiered(generations, policy)
for _, gen := range toRemove {
if err := os.RemoveAll(filepath.Join(backupDir, gen)); err != nil {
return removed, fmt.Errorf("backup: generation %q entfernen: %w", gen, err)
}
removed = append(removed, gen)
}
return removed, nil
}
-118
View File
@@ -1,118 +0,0 @@
package backup
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
)
// ErrCorrupted wird geliefert, wenn eine Sicherungsdatei nicht lesbar ist
// (Akzeptanzkriterium 2: Verifikation, nicht nur Erstellungs-Prüfung).
var ErrCorrupted = fmt.Errorf("backup: sicherungsdatei ist beschaedigt oder unvollstaendig")
// Verify prüft, dass base.tar.gz UND pg_wal.tar.gz im gegebenen
// Sicherungsverzeichnis vollständig lesbar sind — öffnet gzip- UND
// tar-Stream und liest JEDEN Eintrag bis zum Ende durch (nicht nur die
// Kopfdaten), damit ein abgeschnittener oder mit kaputten Bytes
// überschriebener Inhalt zuverlässig auffällt, nicht nur ein defekter
// Tar-Header. pg_wal.tar.gz wird geprüft, weil ohne intaktes WAL kein
// crash-konsistenter Restore möglich ist (siehe Restore/WalTarGzFile) —
// eine Sicherung mit beschädigtem WAL-Archiv wäre sonst unbemerkt
// unbrauchbar.
func Verify(backupDir string) error {
if err := verifyTarGz(filepath.Join(backupDir, BaseTarGzFile)); err != nil {
return err
}
return verifyTarGz(filepath.Join(backupDir, WalTarGzFile))
}
func verifyTarGz(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("%w: %s nicht lesbar: %v", ErrCorrupted, path, err)
}
defer func() { _ = f.Close() }()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("%w: gzip-header von %s ungueltig: %v", ErrCorrupted, path, err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
entries := 0
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("%w: tar-eintrag in %s ungueltig: %v", ErrCorrupted, path, err)
}
if _, err := io.Copy(io.Discard, tr); err != nil {
return fmt.Errorf("%w: inhalt von %q in %s nicht vollstaendig lesbar: %v", ErrCorrupted, hdr.Name, path, err)
}
entries++
}
if entries == 0 {
return fmt.Errorf("%w: %s enthaelt keine eintraege", ErrCorrupted, path)
}
return nil
}
// extractTarGz entpackt tarGzPath vollständig nach destDir — genutzt von
// Restore, um die TAR+GZIP-Sicherungsstufen (siehe Verify) in das
// PLAIN-Format zu überführen, das pg_combinebackup als Eingabe erwartet.
func extractTarGz(tarGzPath, destDir string) error {
f, err := os.Open(tarGzPath)
if err != nil {
return fmt.Errorf("%s öffnen: %w", tarGzPath, err)
}
defer func() { _ = f.Close() }()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("gzip-header ungueltig: %w", err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("tar-eintrag lesen: %w", err)
}
target := filepath.Join(destDir, filepath.Clean(hdr.Name))
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o750); err != nil {
return fmt.Errorf("verzeichnis %q anlegen: %w", target, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return fmt.Errorf("übergeordnetes verzeichnis von %q anlegen: %w", target, err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
return fmt.Errorf("datei %q anlegen: %w", target, err)
}
if _, err := io.Copy(out, tr); err != nil {
_ = out.Close()
return fmt.Errorf("datei %q schreiben: %w", target, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("datei %q schliessen: %w", target, err)
}
default:
// Symlinks/Sonderdateien: pg_basebackup-Archive enthalten
// praktisch keine, übersprungen statt Restore abzubrechen.
}
}
return nil
}
-46
View File
@@ -1,46 +0,0 @@
package moduleadapter
import (
"encoding/json"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
)
type registerRequest struct {
ModuleName string `json:"module_name"`
ObjectType string `json:"object_type"`
RetentionClass string `json:"retention_class"`
CallbackURL string `json:"callback_url"`
}
// RegisterHandler ist die REST-Schnittstelle (Ticket-Technikvorgabe), über
// die ein Modul einen Objekttyp registriert (Akzeptanzkriterium 1).
// POST /register mit JSON-Body {module_name, object_type, callback_url}.
func RegisterHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.ModuleName == "" || req.ObjectType == "" || req.RetentionClass == "" || req.CallbackURL == "" {
http.Error(w, "module_name, object_type, retention_class und callback_url sind pflichtfelder", http.StatusBadRequest)
return
}
reg, err := Register(r.Context(), pool, req.ModuleName, req.ObjectType, req.RetentionClass, req.CallbackURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(reg)
}
}
@@ -1,67 +0,0 @@
package moduleadapter
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestRegisterHandler_RealHTTPRoundTrip beweist die REST-Schnittstelle
// (Ticket-Technikvorgabe) end-to-end: echter HTTP-Server, echter POST,
// zweiter POST mit abweichender callback_url aendert nichts (Pruefung 3
// auch ueber die HTTP-Schicht, nicht nur die Go-Funktion direkt).
func TestRegisterHandler_RealHTTPRoundTrip(t *testing.T) {
pool := requireTestPool(t)
server := httptest.NewServer(RegisterHandler(pool))
defer server.Close()
post := func(body registerRequest) (int, Registration) {
t.Helper()
data, _ := json.Marshal(body)
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var reg Registration
if resp.StatusCode == http.StatusOK {
if err := json.NewDecoder(resp.Body).Decode(&reg); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
}
return resp.StatusCode, reg
}
status1, reg1 := post(registerRequest{ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-original", CallbackURL: "https://dms.example.test/original"})
if status1 != http.StatusOK {
t.Fatalf("erster post: status = %d, want 200", status1)
}
status2, reg2 := post(registerRequest{ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-ANDERE", CallbackURL: "https://dms.example.test/andere"})
if status2 != http.StatusOK {
t.Fatalf("zweiter post: status = %d, want 200", status2)
}
if reg2.ID != reg1.ID || reg2.CallbackURL != "https://dms.example.test/original" {
t.Fatalf("zweiter post veraenderte bestehenden zustand: %+v, erster war %+v", reg2, reg1)
}
}
// TestRegisterHandler_RejectsMissingFields ist Nachweis des
// Fehlerverhaltens auf der REST-Schicht.
func TestRegisterHandler_RejectsMissingFields(t *testing.T) {
pool := requireTestPool(t)
server := httptest.NewServer(RegisterHandler(pool))
defer server.Close()
data, _ := json.Marshal(registerRequest{ModuleName: "dms"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 bei fehlenden pflichtfeldern", resp.StatusCode)
}
}
@@ -1,121 +0,0 @@
// Package moduleadapter implementiert RET-05: die Schnittstelle, über
// die DMS und Mail ihre Objekttypen bei Archive registrieren, statt
// eigene Retention-Logik zu bauen. BEWUSST NUR DAS INTERFACE UND
// ARCHIVES EIGENE SEITE (Registrierungs-API + Rückruf-Auslöser) — die
// eigentlichen Rückruf-EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte)
// sind NICHT Teil dieses Tickets, damit spätere DMS-/Mail-Kacheln
// gegen ein bereits feststehendes, nicht nachträglich verändertes
// Interface bauen (Nutzervorgabe).
package moduleadapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Registration ist EIN registrierter Objekttyp eines Moduls.
type Registration struct {
ID string
ModuleName string
ObjectType string
RetentionClass string
CallbackURL string
}
// Register registriert einen Objekttyp eines Moduls mit Rückruf-Adresse
// für Löschbestätigung — mandantenfähig durch Modell C (physisch
// isolierte Tenant-DB, kein tenant_id-Feld nötig). Idempotent im Sinne
// von Akzeptanzkriterium 3: eine erneute Registrierung DESSELBEN
// Objekttyps ändert NICHTS am bestehenden Zustand (kein Überschreiben
// der callback_url) — anders als RET-01s RegisterObject (dort
// UPSERT-artig), weil ein bereits registrierter Rückruf nicht
// stillschweigend durch eine zweite, möglicherweise abweichende
// Registrierung ersetzt werden darf.
func Register(ctx context.Context, pool *pgxpool.Pool, moduleName, objectType, retentionClass, callbackURL string) (Registration, error) {
var reg Registration
err := pool.QueryRow(ctx, `
INSERT INTO module_registrations (module_name, object_type, retention_class, callback_url)
VALUES ($1, $2, $3, $4)
ON CONFLICT (module_name, object_type) DO NOTHING
RETURNING id, module_name, object_type, retention_class, callback_url
`, moduleName, objectType, retentionClass, callbackURL).Scan(&reg.ID, &reg.ModuleName, &reg.ObjectType, &reg.RetentionClass, &reg.CallbackURL)
if err == nil {
return reg, nil
}
// ON CONFLICT DO NOTHING liefert keine Zeile zurueck (pgx: ErrNoRows) -
// bestehende Registrierung unveraendert nachlesen und zurueckgeben.
err = pool.QueryRow(ctx, `
SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations
WHERE module_name = $1 AND object_type = $2
`, moduleName, objectType).Scan(&reg.ID, &reg.ModuleName, &reg.ObjectType, &reg.RetentionClass, &reg.CallbackURL)
if err != nil {
return Registration{}, fmt.Errorf("moduleadapter: registrierung lesen/anlegen: %w", err)
}
return reg, nil
}
// ListRegistrations liefert alle registrierten Objekttypen — Grundlage
// für Statusübersichten und Tests (Pflichtprüfung 1: zwei Module
// parallel registriert ohne Kollision).
func ListRegistrations(ctx context.Context, pool *pgxpool.Pool) ([]Registration, error) {
rows, err := pool.Query(ctx, `SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations ORDER BY module_name, object_type`)
if err != nil {
return nil, fmt.Errorf("moduleadapter: registrierungen auflisten: %w", err)
}
defer rows.Close()
var regs []Registration
for rows.Next() {
var r Registration
if err := rows.Scan(&r.ID, &r.ModuleName, &r.ObjectType, &r.RetentionClass, &r.CallbackURL); err != nil {
return nil, fmt.Errorf("moduleadapter: registrierungs-zeile lesen: %w", err)
}
regs = append(regs, r)
}
return regs, rows.Err()
}
// DestructionNotice ist der Rückruf-Payload bei Vernichtung eines
// Objekts (Akzeptanzkriterium 2) — das feststehende Vertragsformat, das
// jeder Modul-Rückruf-Empfänger erwarten muss.
type DestructionNotice struct {
ObjectType string `json:"object_type"`
ObjectReference string `json:"object_reference"`
DestroyedAt time.Time `json:"destroyed_at"`
}
// NotifyDestruction ruft das registrierte Modul beim Vernichten eines
// Objekts zurück, statt dass Archive selbst Modul-Interna kennen müsste
// (Akzeptanzkriterium 2). Fehlerverhalten: liefert den Fehler an den
// Aufrufer zurück, statt ihn zu verschlucken — ein fehlgeschlagener
// Rückruf ist ein Fehlerzustand, der behandelt/wiederholt werden muss
// (Wiederholungslogik ist NICHT Teil dieses Tickets, nur der
// Interface-Vertrag: Erfolg = HTTP 2xx, sonst Fehler).
func NotifyDestruction(ctx context.Context, client *http.Client, callbackURL string, notice DestructionNotice) error {
body, err := json.Marshal(notice)
if err != nil {
return fmt.Errorf("moduleadapter: rückruf-payload kodieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("moduleadapter: rückruf-request erstellen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("moduleadapter: rückruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("moduleadapter: rückruf-endpunkt antwortete mit status %d", resp.StatusCode)
}
return nil
}
@@ -1,154 +0,0 @@
package moduleadapter
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func jsonDecode(r *http.Request, v interface{}) error {
defer func() { _ = r.Body.Close() }()
return json.NewDecoder(r.Body).Decode(v)
}
func requireTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS module_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), module_name TEXT NOT NULL,
object_type TEXT NOT NULL, retention_class TEXT NOT NULL, callback_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (module_name, object_type)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE module_registrations`) })
return pool
}
// TestRegister_TwoModulesNoCollision ist Pruefung 1: zwei fiktive Module
// (DMS, Mail) parallel registriert ohne Kollision.
func TestRegister_TwoModulesNoCollision(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
dmsReg, err := Register(ctx, pool, "dms", "document", "klasse-B2B-standard", "https://dms.example.test/callback")
if err != nil {
t.Fatalf("dms registrieren: %v", err)
}
mailReg, err := Register(ctx, pool, "mail", "message", "klasse-korrespondenz", "https://mail.example.test/callback")
if err != nil {
t.Fatalf("mail registrieren: %v", err)
}
if dmsReg.ID == mailReg.ID {
t.Fatal("dms und mail erhielten dieselbe id - kollision")
}
all, err := ListRegistrations(ctx, pool)
if err != nil {
t.Fatalf("listregistrations: %v", err)
}
if len(all) != 2 {
t.Fatalf("erwartet 2 registrierungen, habe %d", len(all))
}
}
// TestRegister_IsIdempotent_UnchangedExistingState ist Pruefung 3:
// erneute Registrierung desselben Objekttyps aendert NICHTS am
// bestehenden Zustand - auch nicht bei abweichender callback_url im
// zweiten Aufruf.
func TestRegister_IsIdempotent_UnchangedExistingState(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
first, err := Register(ctx, pool, "dms", "document", "klasse-original", "https://dms.example.test/original")
if err != nil {
t.Fatalf("erste registrierung: %v", err)
}
second, err := Register(ctx, pool, "dms", "document", "klasse-ANDERE", "https://dms.example.test/ANDERE-url")
if err != nil {
t.Fatalf("zweite registrierung: %v", err)
}
if second.ID != first.ID {
t.Fatalf("erneute registrierung erzeugte neue id: %q, want %q", second.ID, first.ID)
}
if second.CallbackURL != "https://dms.example.test/original" {
t.Fatalf("callback_url wurde ueberschrieben: %q, want unveraendert %q", second.CallbackURL, first.CallbackURL)
}
if second.RetentionClass != "klasse-original" {
t.Fatalf("retention_class wurde ueberschrieben: %q, want unveraendert %q", second.RetentionClass, "klasse-original")
}
all, err := ListRegistrations(ctx, pool)
if err != nil {
t.Fatalf("listregistrations: %v", err)
}
if len(all) != 1 {
t.Fatalf("erwartet weiterhin genau 1 registrierung, habe %d", len(all))
}
}
// TestNotifyDestruction_CallsRealTestEndpoint ist Pruefung 2: Rueckruf
// bei Vernichtung erfolgreich gegen einen Testendpunkt ausgefuehrt -
// echter HTTP-Server, echter Request, echte Payload-Pruefung.
func TestNotifyDestruction_CallsRealTestEndpoint(t *testing.T) {
var receivedNotice DestructionNotice
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.Method != http.MethodPost {
t.Errorf("erwartet POST, habe %s", r.Method)
}
if err := jsonDecode(r, &receivedNotice); err != nil {
t.Errorf("payload dekodieren: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
notice := DestructionNotice{ObjectType: "document", ObjectReference: "doc-789", DestroyedAt: time.Now().UTC()}
if err := NotifyDestruction(context.Background(), server.Client(), server.URL, notice); err != nil {
t.Fatalf("notifydestruction: %v", err)
}
if !called {
t.Fatal("testendpunkt wurde nie aufgerufen")
}
if receivedNotice.ObjectReference != "doc-789" {
t.Fatalf("empfangene objekt-referenz = %q, want doc-789", receivedNotice.ObjectReference)
}
}
// TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus ist Nachweis des
// Fehlerverhaltens: ein fehlschlagender Rueckruf wird als Fehler
// gemeldet, nicht verschluckt.
func TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
err := NotifyDestruction(context.Background(), server.Client(), server.URL, DestructionNotice{})
if err == nil {
t.Fatal("erwartet fehler bei 500-antwort des rueckruf-endpunkts")
}
}
-203
View File
@@ -1,203 +0,0 @@
// Package objectbackup implementiert BAK-02: automatisierte, inkrementelle,
// deduplizierende Sicherung des Objekt-Storage-Bestands. Nutzt restic
// (Content-defined Chunking, verschlüsseltes Repository ab Werk) statt
// Eigenbau — restic erfüllt alle Akzeptanzkriterien mit ausgereiftem,
// geprüftem Tooling statt einer weniger robusten Neuimplementierung.
//
// Backup-Quelle ist ein lokaler Verzeichnisbaum — für den LocalDriver aus
// FDN-03 direkt dessen Basisverzeichnis, für S3-gestützte Produktions-
// Deployments ein vorgelagerter Sync-Schritt (z.B. rclone) auf einen
// lokalen Spiegel, bevor restic ihn sichert (nicht Bestandteil dieser
// Kachel — restic selbst sichert Dateibäume, keine S3-Buckets direkt).
package objectbackup
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
// Config enthält Repository-Ort und -Passwort — ausschließlich über
// Umgebungsvariablen befüllt (siehe Ticket-Abschluss-Regel).
type Config struct {
RepoDir string
Password string
ResticPath string // Default "restic", überschreibbar für Tests
}
func (c Config) binary() string {
if c.ResticPath != "" {
return c.ResticPath
}
return "restic"
}
func (c Config) env() []string {
return append(os.Environ(), "RESTIC_PASSWORD="+c.Password)
}
func run(ctx context.Context, cfg Config, args ...string) ([]byte, error) {
fullArgs := append([]string{"-r", cfg.RepoDir}, args...)
cmd := exec.CommandContext(ctx, cfg.binary(), fullArgs...)
cmd.Env = cfg.env()
output, err := cmd.CombinedOutput()
if err != nil {
return output, fmt.Errorf("%s %v fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), args, err, string(output))
}
return output, nil
}
// InitRepo legt ein neues restic-Repository an, falls es noch nicht
// existiert — idempotent, ein bereits initialisiertes Repository ist kein
// Fehler (Wiederholte Aufrufe durch systemd-Timer nach einem Neustart
// dürfen nicht fehlschlagen).
func InitRepo(ctx context.Context, cfg Config) error {
output, err := run(ctx, cfg, "init")
if err != nil {
if strings.Contains(string(output), "config file already exists") {
return nil
}
return fmt.Errorf("objectbackup: repository initialisieren: %w", err)
}
return nil
}
// BackupSummary ist der geparste "summary"-Datensatz aus `restic backup --json`.
type BackupSummary struct {
SnapshotID string `json:"snapshot_id"`
FilesNew int `json:"files_new"`
FilesChanged int `json:"files_changed"`
FilesUnmodified int `json:"files_unmodified"`
DataBlobs int `json:"data_blobs"`
TotalBytes int64 `json:"total_bytes_processed"`
}
// Backup sichert sourceDir inkrementell (Akzeptanzkriterium 1: unveränderte
// Objekte werden nicht erneut übertragen — restics Content-defined
// Chunking erkennt das automatisch, kein manueller Änderungsabgleich
// nötig).
func Backup(ctx context.Context, cfg Config, sourceDir string) (BackupSummary, error) {
output, err := run(ctx, cfg, "backup", sourceDir, "--json")
if err != nil {
return BackupSummary{}, fmt.Errorf("objectbackup: sicherung: %w", err)
}
return parseSummary(output)
}
// parseSummary sucht in der zeilenweisen JSON-Ausgabe von `restic backup
// --json` (mehrere Fortschritts-/Statuszeilen, GENAU EINE mit
// message_type=="summary") die Zusammenfassung.
func parseSummary(output []byte) (BackupSummary, error) {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for i := len(lines) - 1; i >= 0; i-- {
var probe struct {
MessageType string `json:"message_type"`
}
if err := json.Unmarshal([]byte(lines[i]), &probe); err != nil {
continue
}
if probe.MessageType == "summary" {
var summary BackupSummary
if err := json.Unmarshal([]byte(lines[i]), &summary); err != nil {
return BackupSummary{}, fmt.Errorf("objectbackup: summary-zeile dekodieren: %w", err)
}
return summary, nil
}
}
return BackupSummary{}, fmt.Errorf("objectbackup: keine summary-zeile in der restic-ausgabe gefunden")
}
// Check prüft die Vollständigkeit/Lesbarkeit des Repository
// (Akzeptanzkriterium 3 / Pflichtprüfung: Vollständigkeitsprüfung erkennt
// fehlendes/beschädigtes Objekt). readData=true liest jeden gespeicherten
// Datenblock tatsächlich (teurer, aber die einzige Prüfung, die
// Bit-Rot in bereits gespeicherten Paketen erkennt — ohne readData prüft
// restic nur Struktur/Indizes, nicht den tatsächlichen Blockinhalt).
func Check(ctx context.Context, cfg Config, readData bool) error {
args := []string{"check"}
if readData {
args = append(args, "--read-data")
}
if _, err := run(ctx, cfg, args...); err != nil {
return fmt.Errorf("objectbackup: %w", err)
}
return nil
}
// Forget entfernt alte Snapshots nach Rotationsregel und gibt den davon
// belegten Speicherplatz frei (--prune) — restics Äquivalent zu
// BAK-01s Rotate.
func Forget(ctx context.Context, cfg Config, keepLast int) error {
if _, err := run(ctx, cfg, "forget", "--keep-last", fmt.Sprintf("%d", keepLast), "--prune"); err != nil {
return fmt.Errorf("objectbackup: rotation: %w", err)
}
return nil
}
// RetentionPolicy ist eine gestaffelte Aufbewahrungsfrist nach
// restic/Borg-Vorbild (BAK-07, unabhängig von RET-02s Dokumenten-
// Retention): täglich/wöchentlich/monatlich je eine konfigurierbare
// Anzahl Snapshots behalten, statt einer flachen "letzte N"-Regel
// (Forget/keepLast) oder hart codierter Staffelung. Ein Feldwert 0
// bedeutet "diese Stufe nicht anwenden" (restics eigene Konvention bei
// `--keep-*`).
type RetentionPolicy struct {
KeepDaily int
KeepWeekly int
KeepMonthly int
}
// PruneTiered wendet policy über restics NATIVE `--keep-daily`/
// `--keep-weekly`/`--keep-monthly`-Staffelung an (kein Eigenbau der
// Bucket-Logik — restic beherrscht das bereits robust) und gibt den
// belegten Speicherplatz nicht mehr referenzierter Daten frei
// (`--prune`). Snapshots, die keiner Stufe zugeordnet werden, entfallen
// automatisch — restics übliche GFS-Semantik.
func PruneTiered(ctx context.Context, cfg Config, policy RetentionPolicy) error {
args := []string{"forget", "--prune"}
if policy.KeepDaily > 0 {
args = append(args, "--keep-daily", fmt.Sprintf("%d", policy.KeepDaily))
}
if policy.KeepWeekly > 0 {
args = append(args, "--keep-weekly", fmt.Sprintf("%d", policy.KeepWeekly))
}
if policy.KeepMonthly > 0 {
args = append(args, "--keep-monthly", fmt.Sprintf("%d", policy.KeepMonthly))
}
if _, err := run(ctx, cfg, args...); err != nil {
return fmt.Errorf("objectbackup: gestaffelte rotation: %w", err)
}
return nil
}
// Restore stellt snapshotID nach targetDir wieder her (`restic restore`).
// targetDir muss bereits existieren; Atomarität gegenüber einem eventuell
// nicht-leeren ENDZIEL ist Aufgabe von internal/restore, nicht dieser
// Funktion (dieselbe Aufgabenteilung wie backup.Restore).
func Restore(ctx context.Context, cfg Config, snapshotID, targetDir string) error {
if _, err := run(ctx, cfg, "restore", snapshotID, "--target", targetDir); err != nil {
return fmt.Errorf("objectbackup: wiederherstellung: %w", err)
}
return nil
}
type snapshotEntry struct {
ShortID string `json:"short_id"`
}
// SnapshotCount liefert die Anzahl vorhandener Snapshots — für Tests und
// Statusabfragen.
func SnapshotCount(ctx context.Context, cfg Config) (int, error) {
output, err := run(ctx, cfg, "snapshots", "--json")
if err != nil {
return 0, fmt.Errorf("objectbackup: snapshots auflisten: %w", err)
}
var snapshots []snapshotEntry
if err := json.Unmarshal(output, &snapshots); err != nil {
return 0, fmt.Errorf("objectbackup: snapshot-liste dekodieren: %w", err)
}
return len(snapshots), nil
}
@@ -1,168 +0,0 @@
package objectbackup
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
func requireRestic(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("restic"); err != nil {
t.Skip("restic nicht installiert, Integrationstest uebersprungen")
}
}
func setupTest(t *testing.T) Config {
t.Helper()
requireRestic(t)
cfg := Config{RepoDir: filepath.Join(t.TempDir(), "repo"), Password: "test-passwort-fuer-objectbackup"}
if err := InitRepo(context.Background(), cfg); err != nil {
t.Fatalf("initrepo: %v", err)
}
return cfg
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatalf("testdatei %q schreiben: %v", name, err)
}
}
// TestBackup_UnchangedSecondRunTransmitsNothingNew ist Pruefung 1:
// zweiter Sicherungslauf nach unveraendertem Bestand ueberraegt keine
// Daten erneut.
func TestBackup_UnchangedSecondRunTransmitsNothingNew(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
writeFile(t, sourceDir, "dokument.pdf", "unveraenderter inhalt")
first, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("erste sicherung: %v", err)
}
if first.FilesNew != 1 {
t.Fatalf("erste sicherung: files_new = %d, want 1", first.FilesNew)
}
second, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("zweite sicherung: %v", err)
}
if second.FilesNew != 0 || second.FilesChanged != 0 {
t.Fatalf("zweite sicherung (unveraendert): files_new=%d files_changed=%d, want beide 0", second.FilesNew, second.FilesChanged)
}
if second.FilesUnmodified != 1 {
t.Fatalf("zweite sicherung: files_unmodified = %d, want 1", second.FilesUnmodified)
}
}
// TestBackup_DeduplicatesIdenticalContent ist Pruefung 2: zwei identische
// Testdateien belegen nachweislich nur einmal Speicherplatz.
func TestBackup_DeduplicatesIdenticalContent(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
content := "exakt identischer inhalt in beiden dateien fuer den dedup-nachweis"
writeFile(t, sourceDir, "original.pdf", content)
writeFile(t, sourceDir, "kopie.pdf", content)
summary, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("sicherung: %v", err)
}
if summary.FilesNew != 2 {
t.Fatalf("erwartet 2 neue dateien, habe %d", summary.FilesNew)
}
// Zwei Dateien mit IDENTISCHEM Inhalt duerfen nur EINEN data_blob
// erzeugen - das ist der Dedup-Nachweis (Akzeptanzkriterium 2).
if summary.DataBlobs != 1 {
t.Fatalf("data_blobs = %d, want 1 (zwei identische dateien haetten nur einen blob erzeugen duerfen - keine dedup)", summary.DataBlobs)
}
}
// TestCheck_DetectsCorruptedPack ist Pruefung 3: Vollstaendigkeitspruefung
// erkennt ein beschaedigtes/fehlendes Objekt in der Sicherung.
func TestCheck_DetectsCorruptedPack(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
writeFile(t, sourceDir, "wichtig.pdf", "inhalt, der spaeter absichtlich beschaedigt wird")
if _, err := Backup(ctx, cfg, sourceDir); err != nil {
t.Fatalf("sicherung: %v", err)
}
if err := Check(ctx, cfg, true); err != nil {
t.Fatalf("check (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
}
// Absichtliche Beschaedigung: ein Byte in einer Pack-Datei im
// Repository kippen (dieselbe Fundstelle wie beim manuellen
// Nachweis waehrend der Recherche zu diesem Ticket).
packDir := filepath.Join(cfg.RepoDir, "data")
corrupted := false
if err := filepath.Walk(packDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || corrupted {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if len(data) < 20 {
return nil
}
data[10] ^= 0xFF
if err := os.WriteFile(path, data, 0o600); err != nil {
return err
}
corrupted = true
return nil
}); err != nil {
t.Fatalf("pack-datei beschaedigen: %v", err)
}
if !corrupted {
t.Fatal("keine pack-datei zum beschaedigen gefunden - testaufbau fehlerhaft")
}
if err := Check(ctx, cfg, true); err == nil {
t.Fatal("check haette die beschaedigte pack-datei erkennen muessen")
}
}
// TestForget_KeepsOnlyRequestedSnapshotCount prueft die Rotation.
func TestForget_KeepsOnlyRequestedSnapshotCount(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
for i := 0; i < 3; i++ {
writeFile(t, sourceDir, "f.txt", "version "+string(rune('a'+i)))
if _, err := Backup(ctx, cfg, sourceDir); err != nil {
t.Fatalf("sicherung %d: %v", i, err)
}
}
before, err := SnapshotCount(ctx, cfg)
if err != nil {
t.Fatalf("snapshotcount (vorher): %v", err)
}
if before != 3 {
t.Fatalf("erwartet 3 snapshots vor rotation, habe %d", before)
}
if err := Forget(ctx, cfg, 1); err != nil {
t.Fatalf("forget: %v", err)
}
after, err := SnapshotCount(ctx, cfg)
if err != nil {
t.Fatalf("snapshotcount (nachher): %v", err)
}
if after != 1 {
t.Fatalf("erwartet 1 snapshot nach rotation (keep-last 1), habe %d", after)
}
}
@@ -1,41 +0,0 @@
package objectbackup
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestRestore_RecoversRealContentFromSnapshot ist Pruefung 1 fuer BAK-03
// (Objekt-Storage-Teil): Restore auf leerem Zielverzeichnis vollstaendig
// erfolgreich, real gegen restic geprueft, Dateiinhalt tatsaechlich
// verglichen (kein Bloss-Existenz-Check).
func TestRestore_RecoversRealContentFromSnapshot(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
content := []byte("original objektinhalt fuer restore-test")
if err := os.WriteFile(filepath.Join(sourceDir, "objekt.txt"), content, 0o600); err != nil {
t.Fatal(err)
}
summary, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("backup: %v", err)
}
targetDir := t.TempDir()
if err := Restore(ctx, cfg, summary.SnapshotID, targetDir); err != nil {
t.Fatalf("restore: %v", err)
}
restoredPath := filepath.Join(targetDir, sourceDir, "objekt.txt")
got, err := os.ReadFile(restoredPath)
if err != nil {
t.Fatalf("wiederhergestellte datei lesen (%s): %v", restoredPath, err)
}
if string(got) != string(content) {
t.Fatalf("wiederhergestellter inhalt = %q, want %q", got, content)
}
}
@@ -1,125 +0,0 @@
package objectbackup
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// backupAt erstellt einen Snapshot mit ERZWUNGENEM Zeitstempel
// (restics `backup --time`) - so lassen sich mehrere Altersstufen real
// simulieren, ohne tatsaechlich tagelang zu warten.
func backupAt(t *testing.T, cfg Config, sourceDir string, at time.Time) {
t.Helper()
// restics --time erwartet SEIN EIGENES Format ("2006-01-02 15:04:05"),
// nicht RFC3339 - real erst hier festgestellt (restic verweigert
// RFC3339 mit einem Parse-Fehler).
output, err := run(context.Background(), cfg, "backup", sourceDir, "--time", at.Format("2006-01-02 15:04:05"), "--json")
if err != nil {
t.Fatalf("backup (zeitstempel %s): %v (ausgabe: %s)", at, err, output)
}
}
// TestPruneTiered_KeepsCorrectStaggering_RealSnapshots ist Pruefung 1
// fuer den Objekt-Storage-Teil: reale, gestaffelt datierte Snapshots,
// restics eigene Staffelung liefert die erwartete Anzahl verbleibender
// Snapshots.
func TestPruneTiered_KeepsCorrectStaggering_RealSnapshots(t *testing.T) {
cfg := setupTest(t)
sourceDir := t.TempDir()
writeFile(t, sourceDir, "datei.txt", "inhalt")
now := time.Now().UTC()
for i := 0; i < 10; i++ {
backupAt(t, cfg, sourceDir, now.Add(-time.Duration(i)*24*time.Hour))
}
if got, err := SnapshotCount(context.Background(), cfg); err != nil || got != 10 {
t.Fatalf("erwartet 10 snapshots vor dem prune, habe %d (err=%v)", got, err)
}
if err := PruneTiered(context.Background(), cfg, RetentionPolicy{KeepDaily: 3}); err != nil {
t.Fatalf("prunetiered: %v", err)
}
got, err := SnapshotCount(context.Background(), cfg)
if err != nil {
t.Fatalf("snapshotcount: %v", err)
}
if got != 3 {
t.Fatalf("erwartet 3 verbleibende snapshots nach KeepDaily=3, habe %d", got)
}
}
// TestPruneTiered_KeptSnapshotStillFullyRestorable ist der reale Beweis
// fuer Akzeptanzkriterium 3 (Pruefung 3, Legal-Hold-Analog): Pruning
// aelterer Snapshots darf niemals Daten beschaedigen, die ein
// VERBLEIBENDER (aktueller) Snapshot noch braucht - genau die
// Eigenschaft, die verhindert, dass Pruning mit einer laufenden
// Dokumenten-Aufbewahrungssperre kollidiert: solange das Dokument in
// einem behaltenen Snapshot steckt, bleibt es nach dem Pruning
// vollstaendig wiederherstellbar.
func TestPruneTiered_KeptSnapshotStillFullyRestorable(t *testing.T) {
cfg := setupTest(t)
sourceDir := t.TempDir()
content := "unveraendertes dokument, muss nach dem pruning vollstaendig lesbar bleiben"
writeFile(t, sourceDir, "gehaltenes-dokument.txt", content)
now := time.Now().UTC()
// mehrere aeltere snapshots desselben unveraenderten inhalts (dedupliziert)
for i := 5; i >= 1; i-- {
backupAt(t, cfg, sourceDir, now.Add(-time.Duration(i)*24*time.Hour))
}
// EIN aktueller, zu behaltender snapshot
backupAt(t, cfg, sourceDir, now)
// erwarte 5 aeltere zu entfernen, den juengsten zu behalten
if err := PruneTiered(context.Background(), cfg, RetentionPolicy{KeepDaily: 1}); err != nil {
t.Fatalf("prunetiered: %v", err)
}
remaining, err := SnapshotCount(context.Background(), cfg)
if err != nil {
t.Fatalf("snapshotcount: %v", err)
}
if remaining != 1 {
t.Fatalf("erwartet 1 verbleibenden snapshot, habe %d", remaining)
}
latestID := latestSnapshotShortID(t, cfg)
targetDir := t.TempDir()
if err := Restore(context.Background(), cfg, latestID, targetDir); err != nil {
t.Fatalf("restore nach pruning: %v", err)
}
got, err := os.ReadFile(filepath.Join(targetDir, sourceDir, "gehaltenes-dokument.txt"))
if err != nil {
t.Fatalf("wiederhergestelltes dokument lesen: %v", err)
}
if string(got) != content {
t.Fatalf("dokumentinhalt nach pruning beschaedigt: %q, want %q", got, content)
}
// Zusaetzlich: restics eigene Integritaetspruefung bestaetigt, dass
// das Pruning kein von behaltenen Snapshots benoetigtes Datenblock
// entfernt hat.
if err := Check(context.Background(), cfg, true); err != nil {
t.Fatalf("check nach pruning: %v", err)
}
}
func latestSnapshotShortID(t *testing.T, cfg Config) string {
t.Helper()
output, err := run(context.Background(), cfg, "snapshots", "--json")
if err != nil {
t.Fatalf("snapshots: %v", err)
}
var snapshots []snapshotEntry
if err := json.Unmarshal(output, &snapshots); err != nil {
t.Fatalf("snapshot-liste dekodieren: %v", err)
}
if len(snapshots) == 0 {
t.Fatal("keine snapshots vorhanden")
}
return snapshots[len(snapshots)-1].ShortID
}
-106
View File
@@ -1,106 +0,0 @@
// Package reconcile implementiert BAK-05: periodischer Abgleich, ob jeder
// in der Datenbank referenzierte Objekt-Storage-Eintrag tatsächlich
// existiert und umgekehrt. Prüft AUSSCHLIESSLICH Existenz — niemals
// Inhalt (das ist Archive BAK-08, eine eigene Fehlerklasse, bewusst nicht
// hier mit hineingezogen, siehe reconcile_test.go
// TestReconcile_ExistingButCorruptedObjectProducesNoFinding).
package reconcile
import (
"sort"
"time"
)
// Finding ist EIN Abweichungsfund — entweder ein Datenbankeintrag ohne
// Storage-Objekt oder umgekehrt.
type Finding struct {
StorageKey string `json:"storage_key"`
DocumentID string `json:"document_id,omitempty"`
RevisionID string `json:"revision_id,omitempty"`
}
// Report ist das Ergebnis EINES Abgleichslaufs (Akzeptanzkriterium 3:
// Abweichungen werden BERICHTET, nicht automatisch behoben — Report ist
// reine Information, keine Reparaturfunktion existiert in diesem Paket).
//
// Beide Listen sind nach StorageKey aufsteigend sortiert — bei gleicher
// Eingabe liefert Reconcile IMMER dieselbe Reihenfolge (deterministisch),
// damit ein nachgelagerter Verbraucher (Archive BAK-08: zieht seine
// Stichprobe aus der Liste der EXISTIERENDEN Objekte) sich auf eine
// stabile Sortierung verlassen kann, statt bei jedem Lauf neu zu
// filtern/sortieren.
type Report struct {
GeneratedAt time.Time `json:"generated_at"`
// MissingInStorage: Datenbankeintrag vorhanden, Objekt im Storage fehlt
// (Akzeptanzkriterium 1).
MissingInStorage []Finding `json:"missing_in_storage"`
// OrphanedInStorage: Objekt im Storage vorhanden, kein Datenbankeintrag
// (Akzeptanzkriterium 2).
OrphanedInStorage []Finding `json:"orphaned_in_storage"`
// ExistingInStorage: Datenbankeintrag UND Storage-Objekt beide
// vorhanden — reine Existenzbestätigung, KEINE Inhaltsprüfung. Dient
// Archive BAK-08 als stabile, deterministisch sortierte
// Stichprobengrundlage (nach StorageKey aufsteigend, siehe Report-
// Dokumentation oben) — BAK-08 muss dafür selbst nicht mehr
// sortieren/filtern.
ExistingInStorage []Finding `json:"existing_in_storage"`
}
// IsClean liefert true, wenn der Lauf keine Abweichungen fand (Pflicht-
// prüfung 3: "Lauf ohne Abweichungen liefert einen leeren, eindeutig als
// sauber erkennbaren Bericht" — IsClean ist genau dieses eindeutige
// Erkennungsmerkmal, statt dass ein Aufrufer beide Listen selbst auf
// Leere prüfen muss).
func (r Report) IsClean() bool {
return len(r.MissingInStorage) == 0 && len(r.OrphanedInStorage) == 0
}
// DBEntry ist ein Datenbankeintrag, wie ihn ListDBStorageKeys liefert.
type DBEntry struct {
StorageKey string
DocumentID string
RevisionID string
}
// Reconcile vergleicht dbEntries (aus file_revisions.storage_key, DMS
// FDN-02) gegen storageKeys (tatsächlich im Objekt-Storage vorhandene
// Schlüssel, z.B. per Verzeichnis-Walk des FDN-03-LocalDriver-
// Basisverzeichnisses) und liefert die Abweichungen in beide Richtungen.
// Reine Funktion — kein Datenbank-/Storage-Zugriff hier, dadurch ohne
// echte Infrastruktur testbar (siehe reconcile_test.go).
func Reconcile(dbEntries []DBEntry, storageKeys []string) Report {
storageSet := make(map[string]bool, len(storageKeys))
for _, k := range storageKeys {
storageSet[k] = true
}
dbSet := make(map[string]DBEntry, len(dbEntries))
for _, e := range dbEntries {
dbSet[e.StorageKey] = e
}
var missing, existing []Finding
for _, e := range dbEntries {
if !storageSet[e.StorageKey] {
missing = append(missing, Finding(e))
} else {
existing = append(existing, Finding(e))
}
}
var orphaned []Finding
for _, k := range storageKeys {
if _, ok := dbSet[k]; !ok {
orphaned = append(orphaned, Finding{StorageKey: k})
}
}
sort.Slice(missing, func(i, j int) bool { return missing[i].StorageKey < missing[j].StorageKey })
sort.Slice(orphaned, func(i, j int) bool { return orphaned[i].StorageKey < orphaned[j].StorageKey })
sort.Slice(existing, func(i, j int) bool { return existing[i].StorageKey < existing[j].StorageKey })
return Report{
GeneratedAt: time.Now().UTC(),
MissingInStorage: missing,
OrphanedInStorage: orphaned,
ExistingInStorage: existing,
}
}
@@ -1,169 +0,0 @@
package reconcile
import "testing"
// TestReconcile_DetectsMissingInStorage ist Akzeptanzkriterium 1 / Pruefung
// 1: ein Datenbankeintrag ohne zugehoeriges Objekt im Storage wird erkannt.
func TestReconcile_DetectsMissingInStorage(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
{StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"},
}
storage := []string{"documents/d1/revisions/r1"} // d2/r1 fehlt absichtlich
report := Reconcile(db, storage)
if len(report.MissingInStorage) != 1 {
t.Fatalf("erwartet 1 fund in missing_in_storage, habe %d: %+v", len(report.MissingInStorage), report.MissingInStorage)
}
if report.MissingInStorage[0].StorageKey != "documents/d2/revisions/r1" {
t.Fatalf("unerwarteter fund: %+v", report.MissingInStorage[0])
}
if len(report.OrphanedInStorage) != 0 {
t.Fatalf("erwartet 0 funde in orphaned_in_storage, habe %d", len(report.OrphanedInStorage))
}
}
// TestReconcile_DetectsOrphanedInStorage ist Akzeptanzkriterium 2 /
// Pruefung 2: ein Storage-Objekt ohne Datenbankeintrag wird erkannt.
func TestReconcile_DetectsOrphanedInStorage(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
}
storage := []string{
"documents/d1/revisions/r1",
"documents/verwaist/revisions/r1", // kein DB-Eintrag dafuer
}
report := Reconcile(db, storage)
if len(report.OrphanedInStorage) != 1 {
t.Fatalf("erwartet 1 fund in orphaned_in_storage, habe %d: %+v", len(report.OrphanedInStorage), report.OrphanedInStorage)
}
if report.OrphanedInStorage[0].StorageKey != "documents/verwaist/revisions/r1" {
t.Fatalf("unerwarteter fund: %+v", report.OrphanedInStorage[0])
}
if len(report.MissingInStorage) != 0 {
t.Fatalf("erwartet 0 funde in missing_in_storage, habe %d", len(report.MissingInStorage))
}
}
// TestReconcile_CleanRunProducesEmptyReport ist Pruefung 3: Lauf ohne
// Abweichungen liefert einen leeren, eindeutig als sauber erkennbaren
// Bericht.
func TestReconcile_CleanRunProducesEmptyReport(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
{StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"},
}
storage := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"}
report := Reconcile(db, storage)
if !report.IsClean() {
t.Fatalf("erwartet sauberen bericht, habe missing=%v orphaned=%v", report.MissingInStorage, report.OrphanedInStorage)
}
if len(report.MissingInStorage) != 0 || len(report.OrphanedInStorage) != 0 {
t.Fatal("IsClean()==true, aber listen sind nicht leer - widerspruch")
}
}
// TestReconcile_ExistingButCorruptedObjectProducesNoFinding ist der
// Nachweis, dass BAK-05 AUSSCHLIESSLICH Existenz prueft, niemals Inhalt
// (die Fehlerklasse "existiert, aber Inhalt beschaedigt" ist Archive
// BAK-08, bewusst nicht hier) — Reconcile bekommt nur SCHLUESSEL, hat gar
// keine Moeglichkeit, auf Inhalt zuzugreifen; dieser Test dokumentiert die
// Absicht explizit, damit sie nicht versehentlich spaeter aufgeweicht wird.
func TestReconcile_ExistingButCorruptedObjectProducesNoFinding(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
}
// "korruptes" Objekt hier rein simuliert durch denselben Schluessel -
// Reconcile kennt und prueft keinen Inhalt, nur den Schluessel selbst.
storage := []string{"documents/d1/revisions/r1"}
report := Reconcile(db, storage)
if !report.IsClean() {
t.Fatalf("ein existierendes (wenn auch inhaltlich korruptes) objekt haette KEINEN befund ausloesen duerfen, habe: %+v", report)
}
}
// TestReconcile_ExistingInStorageIsStableSamplingBasis ist der Nachweis,
// dass Reconcile eine deterministisch sortierte Liste ALLER bestaetigt
// existierenden Objekte liefert (DB-Eintrag UND Storage-Objekt vorhanden)
// - dies ist die Stichprobengrundlage, die Archive BAK-08 weiterverwendet,
// ohne selbst neu zu sortieren/filtern.
func TestReconcile_ExistingInStorageIsStableSamplingBasis(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/z/revisions/r1", DocumentID: "z", RevisionID: "r1"},
{StorageKey: "documents/a/revisions/r1", DocumentID: "a", RevisionID: "r1"},
{StorageKey: "documents/fehlt/revisions/r1", DocumentID: "fehlt", RevisionID: "r1"},
}
storage := []string{
"documents/z/revisions/r1",
"documents/a/revisions/r1",
}
report := Reconcile(db, storage)
want := []string{"documents/a/revisions/r1", "documents/z/revisions/r1"}
if len(report.ExistingInStorage) != len(want) {
t.Fatalf("erwartet %d bestaetigt existierende objekte, habe %d: %+v", len(want), len(report.ExistingInStorage), report.ExistingInStorage)
}
for i, w := range want {
if report.ExistingInStorage[i].StorageKey != w {
t.Fatalf("sortierreihenfolge falsch: %v, want beginnend mit %v", report.ExistingInStorage, want)
}
}
if len(report.MissingInStorage) != 1 || report.MissingInStorage[0].StorageKey != "documents/fehlt/revisions/r1" {
t.Fatalf("missing_in_storage unerwartet: %+v", report.MissingInStorage)
}
}
// TestReconcile_DeterministicOrdering ist der Nachweis fuer die
// Stabilitaets-Anforderung: gleiche Eingabe liefert bei mehreren Laeufen
// IMMER dieselbe Reihenfolge (Voraussetzung dafuer, dass Archive BAK-08
// die Liste der existierenden Objekte stabil weiterverarbeiten kann, ohne
// selbst neu zu sortieren/filtern).
func TestReconcile_DeterministicOrdering(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/z/revisions/r1", DocumentID: "z", RevisionID: "r1"},
{StorageKey: "documents/a/revisions/r1", DocumentID: "a", RevisionID: "r1"},
{StorageKey: "documents/m/revisions/r1", DocumentID: "m", RevisionID: "r1"},
}
storage := []string{
"documents/a/revisions/r1", // deckt genau den DB-Eintrag "a" ab
"documents/y/revisions/r1",
"documents/n/revisions/r1",
}
first := Reconcile(db, storage)
second := Reconcile(db, storage)
if len(first.MissingInStorage) != len(second.MissingInStorage) {
t.Fatal("unterschiedliche anzahl funde zwischen zwei laeufen mit identischer eingabe")
}
for i := range first.MissingInStorage {
if first.MissingInStorage[i].StorageKey != second.MissingInStorage[i].StorageKey {
t.Fatalf("reihenfolge in missing_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q",
i, first.MissingInStorage[i].StorageKey, i, second.MissingInStorage[i].StorageKey)
}
}
for i := range first.OrphanedInStorage {
if first.OrphanedInStorage[i].StorageKey != second.OrphanedInStorage[i].StorageKey {
t.Fatalf("reihenfolge in orphaned_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q",
i, first.OrphanedInStorage[i].StorageKey, i, second.OrphanedInStorage[i].StorageKey)
}
}
// Aufsteigend sortiert (a < m < z), nicht Einfuegereihenfolge.
wantOrder := []string{"documents/m/revisions/r1", "documents/z/revisions/r1"}
if len(first.MissingInStorage) != len(wantOrder) {
t.Fatalf("erwartet %d funde, habe %d", len(wantOrder), len(first.MissingInStorage))
}
for i, w := range wantOrder {
if first.MissingInStorage[i].StorageKey != w {
t.Fatalf("sortierreihenfolge falsch: %v, want beginnend mit %v", first.MissingInStorage, wantOrder)
}
}
}
-65
View File
@@ -1,65 +0,0 @@
package reconcile
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/jackc/pgx/v5/pgxpool"
)
// ListDBStorageKeys liest alle storage_key-Werte aus file_revisions
// (DMS FDN-02) — Archive liest direkt aus derselben physischen
// Tenant-Datenbank (Modell C, Core TEN-01), OHNE DMS-Go-Pakete zu
// importieren (Archive ist ein eigenes Go-Modul) — reiner SQL-Zugriff
// gegen das dokumentierte Schema, sortiert nach storage_key für
// deterministische Reconcile-Ergebnisse.
func ListDBStorageKeys(ctx context.Context, pool *pgxpool.Pool) ([]DBEntry, error) {
rows, err := pool.Query(ctx, `
SELECT storage_key, document_id, id FROM file_revisions ORDER BY storage_key
`)
if err != nil {
return nil, fmt.Errorf("reconcile: file_revisions abfragen: %w", err)
}
defer rows.Close()
var entries []DBEntry
for rows.Next() {
var e DBEntry
if err := rows.Scan(&e.StorageKey, &e.DocumentID, &e.RevisionID); err != nil {
return nil, fmt.Errorf("reconcile: file_revisions-zeile lesen: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// ListStorageObjects durchläuft den lokalen FDN-03-LocalDriver-
// Basisordner und liefert alle vorhandenen Objektschlüssel (Pfad relativ
// zu baseDir, mit "/" als Trenner — dasselbe Format wie
// storage.ObjectKey aus FDN-03), sortiert.
func ListStorageObjects(baseDir string) ([]string, error) {
var keys []string
err := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
keys = append(keys, filepath.ToSlash(rel))
return nil
})
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reconcile: objekt-storage durchlaufen: %w", err)
}
return keys, nil
}
-132
View File
@@ -1,132 +0,0 @@
package reconcile
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
// Minimalschema, das exakt DMS FDN-02s file_revisions-Spalten spiegelt
// (Archive kann DMS' internal/-Pakete als eigenes Go-Modul nicht
// importieren, daher hier als Testfixture kopiert statt real migriert).
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL, revision_number INTEGER NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`)
})
return pool
}
// TestListDBStorageKeys_ReadsRealFileRevisions ist der Nachweis, dass
// ListDBStorageKeys tatsaechlich gegen eine echte Postgres-Instanz mit
// DMS-FDN-02-Schema liest — kein Mock.
func TestListDBStorageKeys_ReadsRealFileRevisions(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
var userID, docID string
if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('reconcile-test@example.test', 'Test') RETURNING id`).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('doc', $1) RETURNING id`, userID).Scan(&docID); err != nil {
t.Fatalf("testdokument anlegen: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO file_revisions (document_id, storage_key, checksum_sha256, size_bytes, mime_type, revision_number, created_by)
VALUES ($1, 'documents/x/revisions/1', 'abc', 10, 'text/plain', 1, $2)
`, docID, userID); err != nil {
t.Fatalf("testrevision anlegen: %v", err)
}
entries, err := ListDBStorageKeys(ctx, pool)
if err != nil {
t.Fatalf("listdbstoragekeys: %v", err)
}
if len(entries) != 1 {
t.Fatalf("erwartet 1 eintrag, habe %d", len(entries))
}
if entries[0].StorageKey != "documents/x/revisions/1" {
t.Fatalf("storage_key = %q, want %q", entries[0].StorageKey, "documents/x/revisions/1")
}
if entries[0].DocumentID != docID {
t.Fatalf("document_id = %q, want %q", entries[0].DocumentID, docID)
}
}
// TestListStorageObjects_WalksRealDirectory ist der Nachweis, dass
// ListStorageObjects tatsaechlich das Dateisystem durchlaeuft.
func TestListStorageObjects_WalksRealDirectory(t *testing.T) {
baseDir := t.TempDir()
mustWriteFile(t, filepath.Join(baseDir, "documents", "d1", "revisions", "r1"), "inhalt")
mustWriteFile(t, filepath.Join(baseDir, "documents", "d2", "revisions", "r1"), "inhalt")
keys, err := ListStorageObjects(baseDir)
if err != nil {
t.Fatalf("liststorageobjects: %v", err)
}
if len(keys) != 2 {
t.Fatalf("erwartet 2 objektschluessel, habe %d: %v", len(keys), keys)
}
want := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"}
for i, w := range want {
if keys[i] != w {
t.Fatalf("schluessel[%d] = %q, want %q (voll: %v)", i, keys[i], w, keys)
}
}
}
// TestListStorageObjects_MissingDirectoryReturnsEmpty prueft das
// Verhalten, wenn das Basisverzeichnis (noch) gar nicht existiert -
// sollte als "keine Objekte", nicht als Fehler behandelt werden.
func TestListStorageObjects_MissingDirectoryReturnsEmpty(t *testing.T) {
keys, err := ListStorageObjects("/pfad/der/nicht/existiert/fuer/diesen/test")
if err != nil {
t.Fatalf("erwartet keinen fehler bei fehlendem verzeichnis, habe: %v", err)
}
if len(keys) != 0 {
t.Fatalf("erwartet 0 schluessel, habe %d", len(keys))
}
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("verzeichnis anlegen: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("datei schreiben: %v", err)
}
}
-133
View File
@@ -1,133 +0,0 @@
// Package restore implementiert BAK-03: dokumentiertes, wiederholbares
// Restore-Verfahren für Datenbank (BAK-01) und Objekt-Storage (BAK-02).
// Enthält NUR die gemeinsame Ablauflogik (Atomarität über Temp-Verzeichnis,
// Protokollierung) — die eigentliche Wiederherstellung bleibt in den
// jeweiligen Paketen (backup.Restore, objectbackup.Restore), damit
// internal/restore nicht weiß, WIE eine Sicherung gelesen wird, nur WANN
// und WIE SICHER sie an ihren Zielort darf.
package restore
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Kind unterscheidet die beiden Restore-Arten im Protokoll.
type Kind string
const (
KindDatabase Kind = "database"
KindObjects Kind = "objects"
)
// LogEntry ist EIN Protokolleintrag (Akzeptanzkriterium 3: Quelle,
// Zeitpunkt, Ergebnis).
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Kind Kind `json:"kind"`
Source string `json:"source"` // Generation-ID oder Snapshot-ID
Target string `json:"target"`
Result string `json:"result"` // "ok", "abgebrochen: ...", "fehlgeschlagen: ..."
}
// ErrTargetNotEmpty wird zurückgegeben, wenn targetDir bereits Inhalt hat
// und force nicht gesetzt ist — Akzeptanzkriterium 2: bestehender Inhalt
// bleibt unangetastet, solange der Vorgang nicht bestätigt wird.
var ErrTargetNotEmpty = fmt.Errorf("restore: zielverzeichnis ist nicht leer, restore ohne bestätigung (force) abgebrochen")
// AtomicRestore führt restoreFn gegen ein FRISCHES temporäres Verzeichnis
// aus (niemals direkt gegen target) und übernimmt es erst bei Erfolg —
// entweder wenn target leer ist, oder wenn force=true (bewusste
// Bestätigung, bestehenden Inhalt zu überschreiben). Bei jedem Fehler
// oder abgelehnter Bestätigung bleibt target garantiert unverändert
// (Akzeptanzkriterium 2), das temporäre Verzeichnis wird aufgeräumt.
// Jeder Aufruf erfolgreich oder nicht erzeugt genau einen
// Protokolleintrag in logPath (Akzeptanzkriterium 3).
func AtomicRestore(kind Kind, source, target string, force bool, logPath string, restoreFn func(tempDir string) error) (LogEntry, error) {
entry := LogEntry{Timestamp: time.Now().UTC(), Kind: kind, Source: source, Target: target}
empty, err := dirIsEmptyOrMissing(target)
if err != nil {
entry.Result = "fehlgeschlagen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, err
}
if !empty && !force {
entry.Result = "abgebrochen: " + ErrTargetNotEmpty.Error()
_ = appendLog(logPath, entry)
return entry, ErrTargetNotEmpty
}
parent := filepath.Dir(filepath.Clean(target))
tempDir, err := os.MkdirTemp(parent, ".restore-tmp-*")
if err != nil {
entry.Result = "fehlgeschlagen: temp-verzeichnis anlegen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: temp-verzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(tempDir) }() // no-op nach erfolgreichem Rename (Verzeichnis existiert dann nicht mehr)
if err := restoreFn(tempDir); err != nil {
entry.Result = "fehlgeschlagen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: wiederherstellung: %w", err)
}
if !empty {
// force=true, bewusste Bestätigung: alter Inhalt wird ERST JETZT
// entfernt, nachdem restoreFn bereits erfolgreich in tempDir
// abgeschlossen hat — ein Fehlschlag oben hätte target nie berührt.
if err := os.RemoveAll(target); err != nil {
entry.Result = "fehlgeschlagen: altes ziel entfernen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: altes ziel entfernen: %w", err)
}
} else if err := os.MkdirAll(parent, 0o750); err != nil {
entry.Result = "fehlgeschlagen: übergeordnetes verzeichnis anlegen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: übergeordnetes verzeichnis anlegen: %w", err)
}
if err := os.Rename(tempDir, target); err != nil {
entry.Result = "fehlgeschlagen: umbenennen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: temp-verzeichnis nach ziel umbenennen: %w", err)
}
entry.Result = "ok"
if err := appendLog(logPath, entry); err != nil {
return entry, fmt.Errorf("restore: protokoll schreiben: %w", err)
}
return entry, nil
}
func dirIsEmptyOrMissing(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return true, nil
}
return false, fmt.Errorf("zielverzeichnis lesen: %w", err)
}
return len(entries) == 0, nil
}
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("protokolldatei öffnen: %w", err)
}
defer func() { _ = f.Close() }()
line, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("protokolleintrag kodieren: %w", err)
}
if _, err := f.Write(append(line, '\n')); err != nil {
return fmt.Errorf("protokolleintrag schreiben: %w", err)
}
return nil
}
-181
View File
@@ -1,181 +0,0 @@
package restore
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func readLog(t *testing.T, path string) []LogEntry {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("protokolldatei lesen: %v", err)
}
var entries []LogEntry
for _, line := range splitLines(data) {
if len(line) == 0 {
continue
}
var e LogEntry
if err := json.Unmarshal(line, &e); err != nil {
t.Fatalf("protokollzeile dekodieren: %v (%s)", err, line)
}
entries = append(entries, e)
}
return entries
}
func splitLines(data []byte) [][]byte {
var out [][]byte
start := 0
for i, b := range data {
if b == '\n' {
out = append(out, data[start:i])
start = i + 1
}
}
return out
}
// TestAtomicRestore_EmptyTarget_Succeeds ist Pruefung 1: Restore auf
// leerem (nicht vorhandenem) Ziel vollstaendig erfolgreich.
func TestAtomicRestore_EmptyTarget_Succeeds(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
entry, err := AtomicRestore(KindDatabase, "gen-1", target, false, logPath, func(tempDir string) error {
return os.WriteFile(filepath.Join(tempDir, "marker"), []byte("wiederhergestellt"), 0o600)
})
if err != nil {
t.Fatalf("atomicRestore: %v", err)
}
if entry.Result != "ok" {
t.Fatalf("result = %q, want ok", entry.Result)
}
content, err := os.ReadFile(filepath.Join(target, "marker"))
if err != nil || string(content) != "wiederhergestellt" {
t.Fatalf("ziel nicht korrekt befuellt: %v %q", err, content)
}
}
// TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched ist
// Pruefung 2: Restore auf nicht-leeres Zielverzeichnis laesst bei Abbruch
// (keine Bestaetigung) den urspruenglichen Inhalt unveraendert.
func TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
if err := os.MkdirAll(target, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "bestehend"), []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
restoreFnCalled := false
_, err := AtomicRestore(KindObjects, "snap-1", target, false, logPath, func(tempDir string) error {
restoreFnCalled = true
return nil
})
if err != ErrTargetNotEmpty {
t.Fatalf("erwartet ErrTargetNotEmpty, habe: %v", err)
}
if restoreFnCalled {
t.Fatal("restoreFn haette bei nicht-leerem ziel ohne force NIE aufgerufen werden duerfen")
}
content, err := os.ReadFile(filepath.Join(target, "bestehend"))
if err != nil || string(content) != "original" {
t.Fatalf("urspruenglicher inhalt veraendert: %v %q", err, content)
}
}
// TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched ist Nachweis,
// dass ein Fehler WAEHREND der Wiederherstellung (in tempDir) das
// bestehende Ziel nicht beschaedigt, weil erst nach Erfolg umbenannt wird.
func TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
if err := os.MkdirAll(target, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "bestehend"), []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
_, err := AtomicRestore(KindDatabase, "gen-2", target, true, logPath, func(tempDir string) error {
return os.ErrInvalid // simuliert fehlgeschlagene wiederherstellung
})
if err == nil {
t.Fatal("erwartet fehler")
}
content, err := os.ReadFile(filepath.Join(target, "bestehend"))
if err != nil || string(content) != "original" {
t.Fatalf("ziel bei fehlgeschlagenem restoreFn veraendert: %v %q", err, content)
}
}
// TestAtomicRestore_ForceOverwritesNonEmptyTarget ist Nachweis, dass eine
// BEWUSSTE Bestaetigung (force) bestehenden Inhalt ersetzen darf.
func TestAtomicRestore_ForceOverwritesNonEmptyTarget(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
if err := os.MkdirAll(target, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "alt"), []byte("alt"), 0o600); err != nil {
t.Fatal(err)
}
_, err := AtomicRestore(KindObjects, "snap-2", target, true, logPath, func(tempDir string) error {
return os.WriteFile(filepath.Join(tempDir, "neu"), []byte("neu"), 0o600)
})
if err != nil {
t.Fatalf("atomicRestore mit force: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "alt")); !os.IsNotExist(err) {
t.Fatal("alter inhalt haette nach force-restore ersetzt sein muessen")
}
if _, err := os.Stat(filepath.Join(target, "neu")); err != nil {
t.Fatalf("neuer inhalt fehlt: %v", err)
}
}
// TestAtomicRestore_LogsCompleteEntry ist Pruefung 3: Protokolleintrag
// ist vollstaendig und nachvollziehbar (Quelle, Zeitpunkt, Ergebnis) -
// sowohl fuer Erfolg als auch fuer Abbruch, in derselben Datei.
func TestAtomicRestore_LogsCompleteEntry(t *testing.T) {
root := t.TempDir()
logPath := filepath.Join(root, "restore.log")
okTarget := filepath.Join(root, "ok-ziel")
if _, err := AtomicRestore(KindDatabase, "gen-3", okTarget, false, logPath, func(tempDir string) error {
return os.WriteFile(filepath.Join(tempDir, "x"), []byte("x"), 0o600)
}); err != nil {
t.Fatalf("erfolgreicher restore: %v", err)
}
blockedTarget := filepath.Join(root, "blockiert-ziel")
if err := os.MkdirAll(blockedTarget, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(blockedTarget, "bestehend"), []byte("y"), 0o600); err != nil {
t.Fatal(err)
}
_, _ = AtomicRestore(KindObjects, "snap-3", blockedTarget, false, logPath, func(tempDir string) error { return nil })
entries := readLog(t, logPath)
if len(entries) != 2 {
t.Fatalf("erwartet 2 protokolleintraege, habe %d: %+v", len(entries), entries)
}
if entries[0].Source != "gen-3" || entries[0].Kind != KindDatabase || entries[0].Result != "ok" || entries[0].Timestamp.IsZero() {
t.Fatalf("erster eintrag unvollstaendig: %+v", entries[0])
}
if entries[1].Source != "snap-3" || entries[1].Kind != KindObjects || entries[1].Result == "" || entries[1].Timestamp.IsZero() {
t.Fatalf("zweiter eintrag unvollstaendig: %+v", entries[1])
}
}
-61
View File
@@ -1,61 +0,0 @@
package restoretest
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
// AppendHistory hängt result an die JSONL-Protokolldatei an
// (Akzeptanzkriterium 2/Pflichtprüfung 3: Historie zeigt mehrere
// zurückliegende Testläufe nachvollziehbar an — append-only, nichts wird
// überschrieben).
func AppendHistory(logPath string, result Result) error {
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("restoretest: protokolldatei öffnen: %w", err)
}
defer func() { _ = f.Close() }()
line, err := json.Marshal(result)
if err != nil {
return fmt.Errorf("restoretest: ergebnis kodieren: %w", err)
}
if _, err := f.Write(append(line, '\n')); err != nil {
return fmt.Errorf("restoretest: ergebnis schreiben: %w", err)
}
return nil
}
// ReadHistory liest alle bisherigen Testlauf-Ergebnisse — Nachweis der
// Nachvollziehbarkeit über mehrere Läufe hinweg.
func ReadHistory(logPath string) ([]Result, error) {
f, err := os.Open(logPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("restoretest: protokolldatei lesen: %w", err)
}
defer func() { _ = f.Close() }()
var results []Result
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var r Result
if err := json.Unmarshal(line, &r); err != nil {
return nil, fmt.Errorf("restoretest: protokollzeile dekodieren: %w", err)
}
results = append(results, r)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("restoretest: protokolldatei durchlaufen: %w", err)
}
return results, nil
}
@@ -1,49 +0,0 @@
package restoretest
import (
"path/filepath"
"testing"
"time"
)
// TestHistory_ShowsMultiplePastRunsInOrder ist Pruefung 3: Protokoll-
// historie zeigt mehrere zurueckliegende Testlaeufe nachvollziehbar an.
func TestHistory_ShowsMultiplePastRunsInOrder(t *testing.T) {
logPath := filepath.Join(t.TempDir(), "restoretest.log")
results := []Result{
{Timestamp: time.Now().UTC(), Kind: KindDatabase, Source: "gen-1", Success: true, Detail: "ok"},
{Timestamp: time.Now().UTC().Add(time.Hour), Kind: KindObjects, Source: "snap-1", Success: false, Detail: "kaputt"},
{Timestamp: time.Now().UTC().Add(2 * time.Hour), Kind: KindDatabase, Source: "gen-2", Success: true, Detail: "ok"},
}
for _, r := range results {
if err := AppendHistory(logPath, r); err != nil {
t.Fatalf("appendHistory: %v", err)
}
}
got, err := ReadHistory(logPath)
if err != nil {
t.Fatalf("readHistory: %v", err)
}
if len(got) != len(results) {
t.Fatalf("erwartet %d eintraege, habe %d", len(results), len(got))
}
for i, want := range results {
if got[i].Source != want.Source || got[i].Success != want.Success || got[i].Kind != want.Kind {
t.Fatalf("eintrag %d = %+v, want %+v", i, got[i], want)
}
}
}
// TestReadHistory_MissingFileReturnsEmpty - noch kein Testlauf ist kein
// Fehlerzustand.
func TestReadHistory_MissingFileReturnsEmpty(t *testing.T) {
got, err := ReadHistory(filepath.Join(t.TempDir(), "nicht-vorhanden.log"))
if err != nil {
t.Fatalf("erwartet keinen fehler, habe: %v", err)
}
if len(got) != 0 {
t.Fatalf("erwartet leere historie, habe %d eintraege", len(got))
}
}
-239
View File
@@ -1,239 +0,0 @@
// Package restoretest implementiert BAK-06: regelmäßiger, automatisierter
// Testlauf des BAK-03-Restore-Verfahrens gegen eine isolierte
// Testumgebung — "Wiederherstellung ist Routine, nicht Ausnahmefall"
// (Produkt-DNA) heißt: nicht nur Dateien vorhanden pruefen, sondern
// tatsächlich eine funktionsfähige Instanz aus der Sicherung starten und
// befragen, exakt wie BAK-03s eigener Prüfungsnachweis — hier als
// Produktcode statt Testcode, damit es regelmäßig UNBEAUFSICHTIGT laufen
// kann.
package restoretest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
)
// Kind unterscheidet Datenbank- und Objekt-Storage-Testlauf im Protokoll.
type Kind string
const (
KindDatabase Kind = "database"
KindObjects Kind = "objects"
)
// Result ist EIN Testlauf-Ergebnis (Akzeptanzkriterium 2: Ergebnis mit
// Zeitstempel protokolliert).
type Result struct {
Timestamp time.Time `json:"timestamp"`
Kind Kind `json:"kind"`
Source string `json:"source"` // Generation-ID oder Snapshot-ID
Success bool `json:"success"`
Detail string `json:"detail"`
}
// PgConfig bündelt die zusätzlich zu backup.Config nötigen Angaben für
// den Kurzstart der Test-Instanz (eigener Port/Socket, damit die
// Testinstanz die echte Test-Datenbank auf demselben Host nicht stört).
type PgConfig struct {
PgCtlPath string // Default "pg_ctl"
PsqlPath string // Default "psql"
TestPort string // Default "55433"
}
func (c PgConfig) ctlBinary() string {
if c.PgCtlPath != "" {
return c.PgCtlPath
}
return "pg_ctl"
}
func (c PgConfig) psqlBinary() string {
if c.PsqlPath != "" {
return c.PsqlPath
}
return "psql"
}
func (c PgConfig) port() string {
if c.TestPort != "" {
return c.TestPort
}
return "55433"
}
// RunDatabaseTest führt einen vollständigen Restore der NEUESTEN
// Generation in ein frisches, isoliertes Testverzeichnis durch und
// beweist Wiederherstellbarkeit, indem daraus tatsächlich eine
// eigenständige Postgres-Instanz gestartet und per echter Verbindung
// abgefragt wird (Akzeptanzkriterium 1) — dieselbe Prüftiefe wie BAK-03s
// eigener Nachweis, hier als wiederholbarer Produktcode.
func RunDatabaseTest(ctx context.Context, cfg backup.Config, pgCfg PgConfig, testRoot string) Result {
res := Result{Timestamp: time.Now().UTC(), Kind: KindDatabase}
generations, err := backup.ListGenerations(cfg.BackupDir)
if err != nil || len(generations) == 0 {
res.Detail = fmt.Sprintf("keine sicherungsgeneration gefunden: %v", err)
return res
}
genID := generations[len(generations)-1]
res.Source = genID
testDir, err := os.MkdirTemp(testRoot, "restoretest-db-*")
if err != nil {
res.Detail = fmt.Sprintf("testverzeichnis anlegen: %v", err)
return res
}
defer func() { _ = os.RemoveAll(testDir) }()
restoreOut := filepath.Join(testDir, "pgdata")
if err := os.MkdirAll(restoreOut, 0o700); err != nil {
res.Detail = fmt.Sprintf("pgdata-verzeichnis anlegen: %v", err)
return res
}
if err := backup.Restore(ctx, cfg, genID, restoreOut); err != nil {
res.Detail = fmt.Sprintf("restore fehlgeschlagen: %v", err)
return res
}
// Minimalkonfiguration NUR für den isolierten Kurzstart (auf diesem
// Debian-Postgres liegen postgresql.conf/pg_hba.conf ausserhalb von
// PGDATA, siehe BAK-03-PRUEFPROTOKOLL.md - der reale Sicherungsinhalt
// selbst enthält sie nicht).
if err := os.WriteFile(filepath.Join(restoreOut, "postgresql.conf"), []byte("listen_addresses = ''\n"), 0o600); err != nil {
res.Detail = fmt.Sprintf("testkonfiguration schreiben: %v", err)
return res
}
if err := os.WriteFile(filepath.Join(restoreOut, "pg_hba.conf"), []byte("local all all trust\n"), 0o600); err != nil {
res.Detail = fmt.Sprintf("testkonfiguration schreiben: %v", err)
return res
}
// EIGENES, kurzes Verzeichnis fuer den Unix-Socket - NICHT unter
// testDir (Postgres begrenzt Socket-Pfade auf 107 Byte, ein tief
// verschachtelter Testverzeichnis-Pfad reisst dieses Limit leicht).
socketDir, err := os.MkdirTemp("", "nexarch-rt-sock-*")
if err != nil {
res.Detail = fmt.Sprintf("socket-verzeichnis anlegen: %v", err)
return res
}
defer func() { _ = os.RemoveAll(socketDir) }()
logFile := filepath.Join(testDir, "postgres.log")
startCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
startCmd := exec.CommandContext(startCtx, pgCfg.ctlBinary(), "start", "-D", restoreOut, "-w", "-t", "30", "-l", logFile,
"-o", fmt.Sprintf("-p %s -k %s -c listen_addresses=''", pgCfg.port(), socketDir))
if err := startCmd.Run(); err != nil {
logContent, _ := os.ReadFile(logFile)
res.Detail = fmt.Sprintf("testinstanz starten fehlgeschlagen: %v (log: %s)", err, string(logContent))
return res
}
defer func() {
stopCmd := exec.Command(pgCfg.ctlBinary(), "stop", "-D", restoreOut, "-m", "fast")
_ = stopCmd.Run()
}()
psqlCtx, cancelPsql := context.WithTimeout(ctx, 15*time.Second)
defer cancelPsql()
psqlOut, err := exec.CommandContext(psqlCtx, pgCfg.psqlBinary(),
"-h", socketDir, "-p", pgCfg.port(), "-U", cfg.User, "-d", "postgres",
"-tAc", "SELECT 1").CombinedOutput()
if err != nil || strings.TrimSpace(string(psqlOut)) != "1" {
res.Detail = fmt.Sprintf("verbindung zur testinstanz fehlgeschlagen: %v (ausgabe: %s)", err, string(psqlOut))
return res
}
res.Success = true
res.Detail = "restore und verbindungspruefung erfolgreich"
return res
}
// RunObjectTest führt einen vollständigen Restore des NEUESTEN Snapshots
// in ein frisches, isoliertes Testverzeichnis durch — restics eigene
// Vollständigkeitsgarantie beim Restore (bricht bei fehlenden/beschädigten
// Blöcken ab) ist der Wiederherstellbarkeitsnachweis, zusätzlich real
// geprüft, dass das Zielverzeichnis tatsächlich Inhalt enthält.
func RunObjectTest(ctx context.Context, cfg objectbackup.Config, testRoot string) Result {
res := Result{Timestamp: time.Now().UTC(), Kind: KindObjects}
snapshotID, err := latestSnapshotID(ctx, cfg)
if err != nil {
res.Detail = fmt.Sprintf("neuesten snapshot ermitteln: %v", err)
return res
}
res.Source = snapshotID
testDir, err := os.MkdirTemp(testRoot, "restoretest-objects-*")
if err != nil {
res.Detail = fmt.Sprintf("testverzeichnis anlegen: %v", err)
return res
}
defer func() { _ = os.RemoveAll(testDir) }()
if err := objectbackup.Restore(ctx, cfg, snapshotID, testDir); err != nil {
res.Detail = fmt.Sprintf("restore fehlgeschlagen: %v", err)
return res
}
empty, err := dirIsEmpty(testDir)
if err != nil {
res.Detail = fmt.Sprintf("wiederhergestelltes verzeichnis pruefen: %v", err)
return res
}
if empty {
res.Detail = "restore lief ohne fehler, aber zielverzeichnis ist leer"
return res
}
res.Success = true
res.Detail = "restore erfolgreich, inhalt vorhanden"
return res
}
func dirIsEmpty(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
return len(entries) == 0, nil
}
func latestSnapshotID(ctx context.Context, cfg objectbackup.Config) (string, error) {
binary := cfg.ResticPath
if binary == "" {
binary = "restic"
}
cmd := exec.CommandContext(ctx, binary, "-r", cfg.RepoDir, "snapshots", "--json")
cmd.Env = append(os.Environ(), "RESTIC_PASSWORD="+cfg.Password)
var stderr bytes.Buffer
cmd.Stderr = &stderr
// NUR stdout, nicht CombinedOutput: restic schreibt bei fehlendem/
// nicht beschreibbarem Cache-Verzeichnis eine Warnung nach stderr
// (z.B. "unable to open cache: ..." - real beobachtet unter dem
// systemd-Dienstnutzer "nexarch" ohne beschreibbares HOME), die vor
// das JSON-Array gemischt worden waere und das Parsen bricht.
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("restic snapshots: %w (stderr: %s)", err, stderr.String())
}
var snapshots []struct {
ShortID string `json:"short_id"`
}
if err := json.Unmarshal(output, &snapshots); err != nil {
return "", fmt.Errorf("snapshot-liste dekodieren: %w", err)
}
if len(snapshots) == 0 {
return "", fmt.Errorf("keine snapshots vorhanden")
}
return snapshots[len(snapshots)-1].ShortID, nil
}
@@ -1,122 +0,0 @@
package restoretest
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
)
func requireDBTestConfig(t *testing.T) backup.Config {
t.Helper()
user := os.Getenv("TEST_BACKUP_PG_USER")
if user == "" {
t.Skip("TEST_BACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen")
}
if _, err := exec.LookPath("pg_combinebackup"); err != nil {
t.Skip("pg_combinebackup nicht installiert, Integrationstest uebersprungen")
}
if _, err := exec.LookPath("pg_ctl"); err != nil {
t.Skip("pg_ctl nicht installiert, Integrationstest uebersprungen")
}
return backup.Config{
Host: envOrT("TEST_BACKUP_PG_HOST", "localhost"),
Port: envOrT("TEST_BACKUP_PG_PORT", "5432"),
User: user,
Password: os.Getenv("TEST_BACKUP_PG_PASSWORD"),
BackupDir: t.TempDir(),
}
}
func envOrT(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// TestRunDatabaseTest_SucceedsAgainstRealBackup ist Pruefung 1: geplanter
// Testlauf gegen aktuelle Sicherung erfolgreich durchgefuehrt.
func TestRunDatabaseTest_SucceedsAgainstRealBackup(t *testing.T) {
cfg := requireDBTestConfig(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
genID := backup.NewGenerationID(time.Now())
if _, err := backup.FullBackup(ctx, cfg, genID); err != nil {
t.Fatalf("fullbackup: %v", err)
}
res := RunDatabaseTest(ctx, cfg, PgConfig{TestPort: "55434"}, t.TempDir())
if !res.Success {
t.Fatalf("erwartet erfolgreichen testlauf, habe: %+v", res)
}
if res.Source != genID {
t.Fatalf("source = %q, want %q", res.Source, genID)
}
}
// TestRunDatabaseTest_DetectsCorruptedBackup ist Pruefung 2: absichtlich
// beschaedigte Sicherung laesst den Testlauf sichtbar fehlschlagen.
func TestRunDatabaseTest_DetectsCorruptedBackup(t *testing.T) {
cfg := requireDBTestConfig(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
genID := backup.NewGenerationID(time.Now())
manifest, err := backup.FullBackup(ctx, cfg, genID)
if err != nil {
t.Fatalf("fullbackup: %v", err)
}
// Absichtliche Beschaedigung: base.tar.gz durch Muell ersetzen.
tarPath := filepath.Join(filepath.Dir(manifest), backup.BaseTarGzFile)
if err := os.WriteFile(tarPath, []byte("das ist kein gueltiges tar.gz"), 0o600); err != nil {
t.Fatal(err)
}
res := RunDatabaseTest(ctx, cfg, PgConfig{TestPort: "55435"}, t.TempDir())
if res.Success {
t.Fatal("erwartet fehlschlag bei beschaedigter sicherung, testlauf meldete erfolg")
}
if res.Detail == "" {
t.Fatal("erwartet aussagekraeftiges detail zum fehlschlag")
}
}
func requireObjTestConfig(t *testing.T) objectbackup.Config {
t.Helper()
if _, err := exec.LookPath("restic"); err != nil {
t.Skip("restic nicht installiert, Integrationstest uebersprungen")
}
cfg := objectbackup.Config{RepoDir: filepath.Join(t.TempDir(), "repo"), Password: "restoretest-passwort"}
if err := objectbackup.InitRepo(context.Background(), cfg); err != nil {
t.Fatalf("initrepo: %v", err)
}
return cfg
}
// TestRunObjectTest_SucceedsAgainstRealSnapshot ist Pruefung 1 fuer den
// Objekt-Storage-Teil.
func TestRunObjectTest_SucceedsAgainstRealSnapshot(t *testing.T) {
cfg := requireObjTestConfig(t)
ctx := context.Background()
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "datei.txt"), []byte("inhalt"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := objectbackup.Backup(ctx, cfg, sourceDir); err != nil {
t.Fatalf("backup: %v", err)
}
res := RunObjectTest(ctx, cfg, t.TempDir())
if !res.Success {
t.Fatalf("erwartet erfolgreichen testlauf, habe: %+v", res)
}
}
@@ -1,78 +0,0 @@
package retention
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestMigration_ForwardAndBackward ist Pruefung 3 fuer Akzeptanzkriterium
// 3: Migration laeuft gegen leere Datenbank durch UND ist rueckrollbar -
// real gegen die TATSAECHLICHEN Migrationsdateien, kein Nachbau.
func TestMigration_ForwardAndBackward(t *testing.T) {
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
if _, err := exec.LookPath("psql"); err != nil {
t.Skip("psql nicht installiert, Integrationstest uebersprungen")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "migrations")
upSQL := filepath.Join(migrationsDir, "0002_retention.up.sql")
downSQL := filepath.Join(migrationsDir, "0002_retention.down.sql")
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
// sauberer Ausgangszustand, falls von einem frueheren Testlauf uebrig.
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`)
runPsql := func(sqlFile string) []byte {
t.Helper()
cmd := exec.CommandContext(ctx, "psql", dsn, "-v", "ON_ERROR_STOP=1", "-f", sqlFile)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("psql -f %s: %v (ausgabe: %s)", sqlFile, err, out)
}
return out
}
tableExists := func(name string) bool {
var exists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)`, name).Scan(&exists); err != nil {
t.Fatalf("tabellenexistenz pruefen: %v", err)
}
return exists
}
// vorwaerts
runPsql(upSQL)
if !tableExists("retention_objects") || !tableExists("retention_class_assignments") {
t.Fatal("migration vorwaerts: erwartete tabellen fehlen")
}
// rueckwaerts
runPsql(downSQL)
if tableExists("retention_objects") || tableExists("retention_class_assignments") {
t.Fatal("migration rueckwaerts: tabellen haetten entfernt sein muessen")
}
// erneut vorwaerts (beweist: rueckwaerts hat wirklich sauber
// aufgeraeumt, kein Rest, der einen zweiten Vorwaertslauf bloeckieren wuerde)
runPsql(upSQL)
if !tableExists("retention_objects") {
t.Fatal("zweiter vorwaertslauf nach rollback fehlgeschlagen")
}
// aufraeumen
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`)
}
-103
View File
@@ -1,103 +0,0 @@
// Package retention implementiert RET-01: ein generisches Datenmodell
// für aufbewahrungspflichtige Objekte, modulübergreifend über Adapter
// (Objekttyp + Objekt-Referenz als reine Textfelder) — Archive kennt die
// Fachobjekte anderer Module (DMS, Mail) nicht im Detail, nur ihren Typ
// und ihre Referenz. Keine Fremdschlüssel auf modulspezifische Tabellen,
// damit ein neues Modul retention-pflichtige Objekte einbinden kann,
// ohne dieses Paket zu ändern.
package retention
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Status eines Retention-Objekts.
type Status string
const (
StatusActive Status = "active"
StatusExpired Status = "expired"
StatusDeleted Status = "deleted"
)
// RegisterObject registriert ein Objekt eines beliebigen Moduls unter
// seinem Typ+Referenz — idempotent (ON CONFLICT), ein Adapter kann ein
// bereits bekanntes Objekt gefahrlos erneut registrieren
// (Akzeptanzkriterium 1: bildet beliebige Objekttypen ab, ohne
// modulspezifische Spalten).
func RegisterObject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (string, error) {
var id string
err := pool.QueryRow(ctx, `
INSERT INTO retention_objects (object_type, object_reference)
VALUES ($1, $2)
ON CONFLICT (object_type, object_reference) DO UPDATE SET object_type = EXCLUDED.object_type
RETURNING id
`, objectType, objectReference).Scan(&id)
if err != nil {
return "", fmt.Errorf("retention: objekt registrieren: %w", err)
}
return id, nil
}
// Assignment ist EINE historische Zuordnung einer Aufbewahrungsklasse.
type Assignment struct {
RetentionClass string
AssignedAt time.Time
}
// AssignClass ordnet einem Retention-Objekt eine neue Aufbewahrungsklasse
// zu — fügt IMMER eine neue Zeile hinzu, ändert nie eine bestehende
// (Akzeptanzkriterium 2: historisierbar).
func AssignClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, retentionClass string) error {
_, err := pool.Exec(ctx, `
INSERT INTO retention_class_assignments (retention_object_id, retention_class)
VALUES ($1, $2)
`, retentionObjectID, retentionClass)
if err != nil {
return fmt.Errorf("retention: aufbewahrungsklasse zuordnen: %w", err)
}
return nil
}
// CurrentClass liefert die AKTUELLE Aufbewahrungsklasse (jüngste
// Zuordnung) eines Retention-Objekts.
func CurrentClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (Assignment, error) {
var a Assignment
err := pool.QueryRow(ctx, `
SELECT retention_class, assigned_at FROM retention_class_assignments
WHERE retention_object_id = $1
ORDER BY assigned_at DESC LIMIT 1
`, retentionObjectID).Scan(&a.RetentionClass, &a.AssignedAt)
if err != nil {
return Assignment{}, fmt.Errorf("retention: aktuelle aufbewahrungsklasse lesen: %w", err)
}
return a, nil
}
// ClassHistory liefert ALLE Zuordnungen eines Retention-Objekts,
// chronologisch aufsteigend — voller Nachvollzug der Historie.
func ClassHistory(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) ([]Assignment, error) {
rows, err := pool.Query(ctx, `
SELECT retention_class, assigned_at FROM retention_class_assignments
WHERE retention_object_id = $1
ORDER BY assigned_at ASC
`, retentionObjectID)
if err != nil {
return nil, fmt.Errorf("retention: klassenhistorie lesen: %w", err)
}
defer rows.Close()
var history []Assignment
for rows.Next() {
var a Assignment
if err := rows.Scan(&a.RetentionClass, &a.AssignedAt); err != nil {
return nil, fmt.Errorf("retention: historien-zeile lesen: %w", err)
}
history = append(history, a)
}
return history, rows.Err()
}
@@ -1,137 +0,0 @@
package retention
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
const schemaSQL = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS retention_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
object_reference TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (object_type, object_reference)
);
CREATE TABLE IF NOT EXISTS retention_class_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`
func requireTestPool(t *testing.T, dsn string) *pgxpool.Pool {
t.Helper()
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE`)
})
return pool
}
// TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically ist
// Pruefung 2: Testobjekt aus fiktivem DMS- und Mail-Adapter beide
// korrekt abgebildet - KEINE modulspezifischen Spalten noetig, nur
// object_type/object_reference.
func TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically(t *testing.T) {
pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN"))
ctx := context.Background()
dmsID, err := RegisterObject(ctx, pool, "dms_document", "doc-123")
if err != nil {
t.Fatalf("dms-adapter registrieren: %v", err)
}
mailID, err := RegisterObject(ctx, pool, "mail_message", "msg-456")
if err != nil {
t.Fatalf("mail-adapter registrieren: %v", err)
}
if dmsID == "" || mailID == "" || dmsID == mailID {
t.Fatalf("erwartet zwei unterschiedliche, gueltige ids, habe dms=%q mail=%q", dmsID, mailID)
}
// Idempotenz: erneute Registrierung desselben Objekts liefert dieselbe id.
dmsIDAgain, err := RegisterObject(ctx, pool, "dms_document", "doc-123")
if err != nil {
t.Fatalf("erneute registrierung: %v", err)
}
if dmsIDAgain != dmsID {
t.Fatalf("erneute registrierung lieferte andere id: %q, want %q", dmsIDAgain, dmsID)
}
}
// TestAssignClass_IsHistoricized ist Pruefung fuer Akzeptanzkriterium 2:
// Aufbewahrungsklasse ist eindeutig zugeordnet UND historisierbar.
func TestAssignClass_IsHistoricized(t *testing.T) {
pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN"))
ctx := context.Background()
objID, err := RegisterObject(ctx, pool, "dms_document", "doc-hist")
if err != nil {
t.Fatalf("registrieren: %v", err)
}
if err := AssignClass(ctx, pool, objID, "klasse-A"); err != nil {
t.Fatalf("erste zuordnung: %v", err)
}
if err := AssignClass(ctx, pool, objID, "klasse-B"); err != nil {
t.Fatalf("zweite zuordnung: %v", err)
}
current, err := CurrentClass(ctx, pool, objID)
if err != nil {
t.Fatalf("currentclass: %v", err)
}
if current.RetentionClass != "klasse-B" {
t.Fatalf("aktuelle klasse = %q, want klasse-B", current.RetentionClass)
}
history, err := ClassHistory(ctx, pool, objID)
if err != nil {
t.Fatalf("classhistory: %v", err)
}
if len(history) != 2 || history[0].RetentionClass != "klasse-A" || history[1].RetentionClass != "klasse-B" {
t.Fatalf("erwartet [klasse-A, klasse-B] chronologisch, habe %+v", history)
}
}
// TestTenantIsolation_Negativtest ist Pruefung 3: ein in Tenant-DB A
// registriertes Objekt ist ueber eine Verbindung zu Tenant-DB B technisch
// nicht sichtbar - real gegen zwei unabhaengige Datenbanken (Modell C,
// TEN-01), keine gemeinsame Tabelle mit tenant_id-Filter.
func TestTenantIsolation_Negativtest(t *testing.T) {
dsnA := os.Getenv("TEST_TENANT_DSN")
dsnB := os.Getenv("TEST_TENANT_DSN_B")
if dsnA == "" || dsnB == "" {
t.Skip("TEST_TENANT_DSN und TEST_TENANT_DSN_B nicht beide gesetzt, Integrationstest uebersprungen")
}
poolA := requireTestPool(t, dsnA)
poolB := requireTestPool(t, dsnB)
ctx := context.Background()
if _, err := RegisterObject(ctx, poolA, "dms_document", "nur-in-tenant-a"); err != nil {
t.Fatalf("registrieren in tenant a: %v", err)
}
var count int
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM retention_objects WHERE object_reference = 'nur-in-tenant-a'`).Scan(&count); err != nil {
t.Fatalf("tenant b abfragen: %v", err)
}
if count != 0 {
t.Fatalf("objekt aus tenant a in tenant b sichtbar (count=%d) - mandantentrennung verletzt", count)
}
}
-48
View File
@@ -1,48 +0,0 @@
// Package retentionapi implementiert RET-06-API: die Backend-Seite der
// Aufbewahrungsfristen-Konfigurationsoberfläche (CRUD auf
// Aufbewahrungsklassen + Vorschauliste ablaufender Objekte). Das
// Next.js-Frontend selbst ist NICHT Teil dieses Tickets (Board-
// Entscheidung: Backend-API zuerst, Frontend als eigenes Folgeticket).
package retentionapi
import (
"net/http"
"strings"
)
// requiredRoleHeader ist der Header-Name des PROVISORISCHEN Rollen-
// Checks (siehe RequireRole-Dokumentation).
const requiredRoleHeader = "X-Admin-Roles"
// RequireRole ist ein PROVISORISCHER Rollen-Check, KEIN RBAC-02-Aufruf.
//
// Core RBAC-02 (internal/policy, Enforcer.Authorize) ist reiner
// Go-Code innerhalb des Core-Moduls, hat keine HTTP-Schnittstelle, über
// die Archive (physisch getrenntes Go-Modul) es aufrufen könnte —
// derselbe "gefunden, aber nicht modulübergreifend verdrahtet"-Befund
// wie bei Core FDN-03/FDN-09. Bis ein Core-seitiger HTTP-Wrapper um
// RBAC-02 existiert (eigenes, künftiges Core-Ticket, z. B. RBAC-06),
// prüft dieser Middleware NUR einen einfachen, selbst gesetzten Header
// (`X-Admin-Roles`, kommagetrennt) auf das Vorhandensein der
// geforderten Rolle — KEINE echte Autorisierung gegen Core, leicht zu
// fälschen von jedem, der den Header selbst setzen kann. Muss ersetzt
// werden, sobald der Core-HTTP-Wrapper existiert.
func RequireRole(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
roles := r.Header.Get(requiredRoleHeader)
if !containsRole(roles, requiredRole) {
http.Error(w, "zugriff verweigert: rolle "+requiredRole+" erforderlich (provisorischer check, siehe RequireRole)", http.StatusForbidden)
return
}
next(w, r)
}
}
func containsRole(commaSeparated, role string) bool {
for _, r := range strings.Split(commaSeparated, ",") {
if strings.TrimSpace(r) == role {
return true
}
}
return false
}
@@ -1,60 +0,0 @@
package retentionapi
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestRequireRole_MissingRoleReturns403 ist die vom Nutzer geforderte
// Negativpruefung fuer den provisorischen Rollen-Check: keine/falsche
// Rolle => 403, sonst waere der Check nicht pruefbar.
func TestRequireRole_MissingRoleReturns403(t *testing.T) {
handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
defer server.Close()
// Kein Header gesetzt.
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("ohne rollen-header: status = %d, want 403", resp.StatusCode)
}
// Falsche Rolle gesetzt.
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
req.Header.Set("X-Admin-Roles", "irgendwas_anderes")
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusForbidden {
t.Fatalf("mit falscher rolle: status = %d, want 403", resp2.StatusCode)
}
}
// TestRequireRole_CorrectRoleAllowsAccess ist der positive Gegentest.
func TestRequireRole_CorrectRoleAllowsAccess(t *testing.T) {
handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
defer server.Close()
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
req.Header.Set("X-Admin-Roles", "irgendwas_anderes, archive_admin")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("mit korrekter rolle: status = %d, want 200", resp.StatusCode)
}
}
-98
View File
@@ -1,98 +0,0 @@
package retentionapi
import (
"encoding/json"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
)
const adminRole = "archive_admin"
// Mount registriert alle RET-06-API-Endpunkte auf mux, jeweils hinter
// dem provisorischen Rollen-Check (siehe authz.go) — Akzeptanzkriterium
// 3: Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
func Mount(mux *http.ServeMux, pool *pgxpool.Pool) {
mux.HandleFunc("POST /retention-classes", RequireRole(adminRole, configureHandler(pool)))
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRole(adminRole, deactivateHandler(pool)))
mux.HandleFunc("GET /retention-classes", RequireRole(adminRole, listHandler(pool)))
mux.HandleFunc("GET /retention-classes/preview", RequireRole(adminRole, previewHandler(pool)))
}
type configureRequest struct {
RetentionClass string `json:"retention_class"`
Duration string `json:"duration"`
}
// configureHandler: Aufbewahrungsklasse anlegen ODER ändern
// (Akzeptanzkriterium 1) — `retentionengine.ConfigureClassRule` ist ein
// UPSERT, eine Änderung wirkt erst ab jetzt auf künftige
// Stichtagsberechnungen (Pflichtprüfung: nicht rückwirkend).
func configureHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req configureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.RetentionClass == "" || req.Duration == "" {
http.Error(w, "retention_class und duration sind pflichtfelder", http.StatusBadRequest)
return
}
if err := retentionengine.ConfigureClassRule(r.Context(), pool, req.RetentionClass, req.Duration); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
// deactivateHandler: Aufbewahrungsklasse deaktivieren (Akzeptanzkriterium 1).
func deactivateHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
class := r.PathValue("class")
if err := retentionengine.DeactivateClassRule(r.Context(), pool, class); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
}
}
// listHandler liefert alle konfigurierten Aufbewahrungsklassen (aktiv
// und deaktiviert) — Grundlage der künftigen Konfigurationsoberfläche.
func listHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rules, err := retentionengine.ListClassRules(r.Context(), pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(rules)
}
}
// previewHandler liefert die Vorschauliste bald ablaufender Objekte
// (Akzeptanzkriterium 2: Standard 30 Tage, per `days`-Query-Parameter
// überschreibbar). Nutzt DIESELBE `ListExpiringObjects`-Funktion wie
// der periodische Job (RET-02) — Pflichtprüfung: Vorschauliste stimmt
// mit dem Ergebnis des periodischen Jobs überein (keine zweite,
// abweichende Implementierung).
func previewHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
days := 30
asOf := time.Now().UTC().AddDate(0, 0, days)
objects, err := retentionengine.ListExpiringObjects(r.Context(), pool, asOf)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(objects)
}
}
@@ -1,174 +0,0 @@
package retentionapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
)
func requireTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS retention_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
object_reference TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (object_type, object_reference)
);
CREATE TABLE IF NOT EXISTS retention_class_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS retention_class_rules (
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
active BOOLEAN NOT NULL DEFAULT true
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
})
return pool
}
func authedRequest(t *testing.T, method, url string, body []byte) *http.Response {
t.Helper()
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
t.Fatalf("request erstellen: %v", err)
}
req.Header.Set("X-Admin-Roles", adminRole)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request senden: %v", err)
}
return resp
}
// TestConfigureAndListHandler_RealHTTPRoundTrip: Klasse anlegen, ändern,
// über die Liste sichtbar - Akzeptanzkriterium 1.
func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) {
pool := requireTestPool(t)
mux := http.NewServeMux()
Mount(mux, pool)
server := httptest.NewServer(mux)
defer server.Close()
body, _ := json.Marshal(configureRequest{RetentionClass: "klasse-api", Duration: "5 years"})
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes", body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("anlegen: status = %d, want 200", resp.StatusCode)
}
_ = resp.Body.Close()
listResp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes", nil)
defer func() { _ = listResp.Body.Close() }()
var rules []retentionengine.ClassRule
if err := json.NewDecoder(listResp.Body).Decode(&rules); err != nil {
t.Fatalf("liste dekodieren: %v", err)
}
if len(rules) != 1 || rules[0].RetentionClass != "klasse-api" || !rules[0].Active {
t.Fatalf("unerwartete liste: %+v", rules)
}
}
// TestDeactivateHandler_RealHTTPRoundTrip: Deaktivierung wirkt real.
func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deakt", "1 year"); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
Mount(mux, pool)
server := httptest.NewServer(mux)
defer server.Close()
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes/klasse-deakt/deactivate", nil)
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("deaktivieren: status = %d, want 200", resp.StatusCode)
}
rules, err := retentionengine.ListClassRules(ctx, pool)
if err != nil {
t.Fatalf("listclassrules: %v", err)
}
if len(rules) != 1 || rules[0].Active {
t.Fatalf("erwartet deaktivierte klasse, habe %+v", rules)
}
}
// TestPreviewHandler_MatchesPeriodicJobResult ist die geforderte
// Pflichtpruefung: Vorschauliste stimmt mit dem Ergebnis des
// periodischen Jobs ueberein - beide nutzen dieselbe Funktion, real
// per HTTP UND direkt verglichen.
func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-preview", "1 day"); err != nil {
t.Fatal(err)
}
var objID string
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'preview-doc') RETURNING id`).Scan(&objID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class) VALUES ($1, 'klasse-preview')`, objID); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
Mount(mux, pool)
server := httptest.NewServer(mux)
defer server.Close()
resp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes/preview", nil)
defer func() { _ = resp.Body.Close() }()
var httpResult []retentionengine.ExpiringObject
if err := json.NewDecoder(resp.Body).Decode(&httpResult); err != nil {
t.Fatalf("preview-antwort dekodieren: %v", err)
}
directResult, err := retentionengine.ListExpiringObjects(ctx, pool, time.Now().UTC().AddDate(0, 0, 30))
if err != nil {
t.Fatalf("listexpiringobjects direkt: %v", err)
}
if len(httpResult) != len(directResult) || len(httpResult) != 1 {
t.Fatalf("http-vorschau (%d) und periodischer job (%d) stimmen nicht ueberein", len(httpResult), len(directResult))
}
if httpResult[0].RetentionObjectID != directResult[0].RetentionObjectID {
t.Fatalf("http-vorschau und periodischer job liefern unterschiedliche objekte: %+v vs %+v", httpResult[0], directResult[0])
}
}
@@ -1,142 +0,0 @@
// Package retentionengine implementiert RET-02: Fristenmodell je
// Aufbewahrungsklasse mit Stichtagsberechnung und ein periodischer Job,
// der ablaufende Objekte ermittelt. Baut auf RET-01 (retention_objects,
// retention_class_assignments) auf — kennt weiter keine Modul-Interna
// (dieselbe Adapter-Disziplin).
package retentionengine
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// ConfigureClassRule legt die Frist (Postgres-INTERVAL, z. B. "10 years",
// "6 months") für eine Aufbewahrungsklasse fest oder ändert sie
// (Akzeptanzkriterium 1) — je Klasse GENAU eine aktive Regel.
func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass, duration string) error {
_, err := pool.Exec(ctx, `
INSERT INTO retention_class_rules (retention_class, duration)
VALUES ($1, $2::interval)
ON CONFLICT (retention_class) DO UPDATE SET duration = EXCLUDED.duration
`, retentionClass, duration)
if err != nil {
return fmt.Errorf("retentionengine: fristregel konfigurieren: %w", err)
}
return nil
}
// ComputeDueDate berechnet den Stichtag aus Beginn (start) und der
// konfigurierten Frist der Klasse — DELEGIERT an Postgres' eigene
// INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive
// Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von
// Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte.
// Nur AKTIVE Regeln werden verwendet (siehe DeactivateClassRule).
func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) {
var due time.Time
err := pool.QueryRow(ctx, `
SELECT $1::timestamptz + r.duration
FROM retention_class_rules r WHERE r.retention_class = $2 AND r.active
`, start, retentionClass).Scan(&due)
if err != nil {
return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err)
}
return due, nil
}
// DeactivateClassRule (RET-06): eine Aufbewahrungsklasse wird deaktiviert,
// OHNE ihre Historie (bereits erfolgte Zuordnungen/Berechnungen) zu
// verlieren — kein DELETE. Deaktivierte Klassen fließen nicht mehr in
// ComputeDueDate/ListExpiringObjects ein, ändern aber nichts an bereits
// getroffenen Berechnungen (Pflichtprüfung: Änderung wirkt nur auf
// künftige Berechnungen, nicht rückwirkend).
func DeactivateClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass string) error {
tag, err := pool.Exec(ctx, `UPDATE retention_class_rules SET active = false WHERE retention_class = $1`, retentionClass)
if err != nil {
return fmt.Errorf("retentionengine: klasse deaktivieren: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("retentionengine: unbekannte aufbewahrungsklasse %q", retentionClass)
}
return nil
}
// ClassRule ist EINE konfigurierte Aufbewahrungsklasse mit Frist und
// Aktiv-Status.
type ClassRule struct {
RetentionClass string
Duration string
Active bool
}
// ListClassRules liefert alle konfigurierten Aufbewahrungsklassen
// (aktiv und deaktiviert) — Grundlage für die Konfigurationsoberfläche.
func ListClassRules(ctx context.Context, pool *pgxpool.Pool) ([]ClassRule, error) {
rows, err := pool.Query(ctx, `SELECT retention_class, duration::text, active FROM retention_class_rules ORDER BY retention_class`)
if err != nil {
return nil, fmt.Errorf("retentionengine: aufbewahrungsklassen auflisten: %w", err)
}
defer rows.Close()
var rules []ClassRule
for rows.Next() {
var r ClassRule
if err := rows.Scan(&r.RetentionClass, &r.Duration, &r.Active); err != nil {
return nil, fmt.Errorf("retentionengine: klassen-zeile lesen: %w", err)
}
rules = append(rules, r)
}
return rules, rows.Err()
}
// ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist.
type ExpiringObject struct {
RetentionObjectID string
ObjectType string
ObjectReference string
RetentionClass string
DueDate time.Time
}
// ListExpiringObjects ist der periodische Job (Akzeptanzkriterium 3):
// liefert alle aktiven Retention-Objekte, deren Stichtag (aktuelle
// Klassenzuordnung + deren Frist) bis asOf erreicht ist. Betrachtet je
// Objekt AUSSCHLIESSLICH die JÜNGSTE Klassenzuordnung (`DISTINCT ON`) -
// ohne diese Einschränkung würde ein Objekt mit mehrfach geänderter
// Klasse (RET-01s Historisierung) mehrfach im Ergebnis auftauchen,
// genau der Doppelte-Einträge-Fehler, den Pflichtprüfung 3 ausschließt.
// Ein leerer Bestand liefert eine leere Liste, keinen Fehler
// (Akzeptanzkriterium/Pflichtprüfung 2).
func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]ExpiringObject, error) {
rows, err := pool.Query(ctx, `
WITH latest_assignment AS (
SELECT DISTINCT ON (retention_object_id)
retention_object_id, retention_class, assigned_at
FROM retention_class_assignments
ORDER BY retention_object_id, assigned_at DESC
)
SELECT o.id, o.object_type, o.object_reference, a.retention_class,
a.assigned_at + r.duration AS due_date
FROM retention_objects o
JOIN latest_assignment a ON a.retention_object_id = o.id
JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active
WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1
ORDER BY due_date ASC
`, asOf)
if err != nil {
return nil, fmt.Errorf("retentionengine: ablaufende objekte ermitteln: %w", err)
}
defer rows.Close()
var out []ExpiringObject
for rows.Next() {
var e ExpiringObject
if err := rows.Scan(&e.RetentionObjectID, &e.ObjectType, &e.ObjectReference, &e.RetentionClass, &e.DueDate); err != nil {
return nil, fmt.Errorf("retentionengine: zeile lesen: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
@@ -1,222 +0,0 @@
package retentionengine
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS retention_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
object_reference TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (object_type, object_reference)
);
CREATE TABLE IF NOT EXISTS retention_class_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS retention_class_rules (
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
active BOOLEAN NOT NULL DEFAULT true
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
})
return pool
}
func mustTime(t *testing.T, layout, value string) time.Time {
t.Helper()
tm, err := time.Parse(layout, value)
if err != nil {
t.Fatalf("zeitangabe parsen: %v", err)
}
return tm
}
// TestComputeDueDate_KnownReferenceDates ist Pruefung 1: Fristberechnung
// an Referenzdaten mit bekannten Ablaufdaten geprueft - inklusive
// Schaltjahr und Monatsende (Akzeptanzkriterium 2). Erwartete Werte real
// gegen Postgres verifiziert (dessen eigene INTERVAL-Arithmetik ist die
// Quelle der Wahrheit, keine eigene Nachbildung in Go).
func TestComputeDueDate_KnownReferenceDates(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := ConfigureClassRule(ctx, pool, "klasse-1-jahr", "1 year"); err != nil {
t.Fatalf("regel konfigurieren: %v", err)
}
if err := ConfigureClassRule(ctx, pool, "klasse-1-monat", "1 month"); err != nil {
t.Fatalf("regel konfigurieren: %v", err)
}
cases := []struct {
name string
start time.Time
retentionClass string
want time.Time
}{
{
name: "schaltjahr 29. februar plus 1 jahr",
start: mustTime(t, "2006-01-02", "2024-02-29"),
retentionClass: "klasse-1-jahr",
want: mustTime(t, "2006-01-02", "2025-02-28"),
},
{
name: "monatsende 31. januar plus 1 monat",
start: mustTime(t, "2006-01-02", "2026-01-31"),
retentionClass: "klasse-1-monat",
want: mustTime(t, "2006-01-02", "2026-02-28"),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ComputeDueDate(ctx, pool, c.start, c.retentionClass)
if err != nil {
t.Fatalf("computeduedate: %v", err)
}
if !got.UTC().Equal(c.want.UTC()) {
t.Fatalf("stichtag = %v, want %v", got, c.want)
}
})
}
}
// TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations ist
// Pruefung fuer RET-06: eine Fristaenderung wirkt sich nur auf
// KUENFTIGE Berechnungen aus, nicht rueckwirkend - real bewiesen, indem
// ein bereits berechneter Stichtag (in einer lokalen Variable, dem
// einzigen Ort, an dem ein "bereits berechnetes" Ergebnis ueberhaupt
// existiert - es gibt keine Tabelle, die rueckwirkend veraendert werden
// koennte) nach der Regelaenderung unveraendert bleibt, waehrend eine
// NEUE Berechnung fuer dieselbe Klasse die NEUE Frist verwendet.
func TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
start := mustTime(t, "2006-01-02", "2026-01-01")
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "1 year"); err != nil {
t.Fatal(err)
}
before, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
if err != nil {
t.Fatalf("erste berechnung: %v", err)
}
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "2 years"); err != nil {
t.Fatal(err)
}
// Der bereits berechnete Wert (before) bleibt unveraendert - er ist
// eine lokale Kopie, es existiert keine Tabelle, die eine
// nachtraegliche "Umschreibung" ermoeglichen wuerde.
if !before.Equal(mustTime(t, "2006-01-02", "2027-01-01")) {
t.Fatalf("bereits berechneter stichtag veraendert: %v", before)
}
after, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
if err != nil {
t.Fatalf("zweite berechnung: %v", err)
}
if !after.Equal(mustTime(t, "2006-01-02", "2028-01-01")) {
t.Fatalf("neue berechnung uebernimmt neue frist nicht: %v", after)
}
if before.Equal(after) {
t.Fatal("neue frist haette eine andere berechnung liefern muessen")
}
}
// TestDeactivateClassRule_ExcludesFromFutureCalculations.
func TestDeactivateClassRule_ExcludesFromFutureCalculations(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := ConfigureClassRule(ctx, pool, "klasse-deakt-eng", "1 year"); err != nil {
t.Fatal(err)
}
if err := DeactivateClassRule(ctx, pool, "klasse-deakt-eng"); err != nil {
t.Fatalf("deactivateclassrule: %v", err)
}
if _, err := ComputeDueDate(ctx, pool, time.Now(), "klasse-deakt-eng"); err == nil {
t.Fatal("erwartet fehler: deaktivierte klasse darf nicht mehr verwendet werden")
}
}
// TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2.
func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
got, err := ListExpiringObjects(ctx, pool, time.Now().UTC())
if err != nil {
t.Fatalf("erwartet keinen fehler bei leerem bestand, habe: %v", err)
}
if len(got) != 0 {
t.Fatalf("erwartet leere liste, habe %d eintraege", len(got))
}
}
// TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges ist
// Pruefung 3: Mehrfachausfuehrung des Jobs erzeugt keine doppelten
// Eintraege - real geprueft an einem Objekt mit MEHREREN historischen
// Klassenzuordnungen (RET-01s Historisierung), das ohne die
// DISTINCT-ON-Einschraenkung mehrfach im Ergebnis auftauchen wuerde.
func TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := ConfigureClassRule(ctx, pool, "klasse-kurz", "1 day"); err != nil {
t.Fatal(err)
}
var objID string
if err := pool.QueryRow(ctx, `
INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'doc-mehrfach') RETURNING id
`).Scan(&objID); err != nil {
t.Fatalf("objekt anlegen: %v", err)
}
past := time.Now().UTC().Add(-72 * time.Hour)
// zwei historische Zuordnungen fuer DASSELBE Objekt, beide in der
// Vergangenheit (also beide laengst abgelaufen, wenn nicht auf die
// juengste beschraenkt wuerde).
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past.Add(time.Hour)); err != nil {
t.Fatal(err)
}
for run := 1; run <= 2; run++ {
got, err := ListExpiringObjects(ctx, pool, time.Now().UTC())
if err != nil {
t.Fatalf("lauf %d: %v", run, err)
}
if len(got) != 1 {
t.Fatalf("lauf %d: erwartet genau 1 eintrag (kein duplikat trotz 2 historischer zuordnungen), habe %d: %+v", run, len(got), got)
}
}
}
-57
View File
@@ -1,57 +0,0 @@
package scrub
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExpectedChecksums liest file_revisions.checksum_sha256 fuer genau die
// uebergebenen storage_keys — bewusst eine eigene, minimale Abfrage statt
// Erweiterung von reconcile.DBEntry (BAK-05 bleibt existenz-only, keine
// Kopplung an Inhaltspruefungs-Bedarf von BAK-08).
func ExpectedChecksums(ctx context.Context, pool *pgxpool.Pool, storageKeys []string) (map[string]string, error) {
if len(storageKeys) == 0 {
return map[string]string{}, nil
}
rows, err := pool.Query(ctx, `
SELECT storage_key, checksum_sha256 FROM file_revisions WHERE storage_key = ANY($1)
`, storageKeys)
if err != nil {
return nil, fmt.Errorf("scrub: erwartete pruefsummen lesen: %w", err)
}
defer rows.Close()
out := make(map[string]string, len(storageKeys))
for rows.Next() {
var key, checksum string
if err := rows.Scan(&key, &checksum); err != nil {
return nil, fmt.Errorf("scrub: pruefsummen-zeile lesen: %w", err)
}
out[key] = checksum
}
return out, rows.Err()
}
// ActualChecksum liest die Datei unter baseDir/storageKey vollstaendig
// und berechnet ihren SHA-256 — echte Inhaltspruefung, kein
// Header-/Groessenvergleich (dieselbe Disziplin wie BAK-01s Verify).
func ActualChecksum(baseDir, storageKey string) (string, error) {
f, err := os.Open(filepath.Join(baseDir, filepath.FromSlash(storageKey)))
if err != nil {
return "", fmt.Errorf("scrub: objekt lesen: %w", err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("scrub: objekt hashen: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
-94
View File
@@ -1,94 +0,0 @@
package scrub
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireFileRevisionsFixture(t *testing.T) (pool *pgxpool.Pool, userID, docID string) {
t.Helper()
p := requireTestPool(t)
ctx := context.Background()
if _, err := p.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL, revision_number INTEGER NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("file_revisions-fixture: %v", err)
}
var uid string
if err := p.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('scrub-test@example.test', 'Test') RETURNING id`).Scan(&uid); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
var did string
if err := p.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('doc', $1) RETURNING id`, uid).Scan(&did); err != nil {
t.Fatalf("testdokument anlegen: %v", err)
}
t.Cleanup(func() { _, _ = p.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`) })
return p, uid, did
}
// TestActualChecksum_MatchesRealFileContent ist Nachweis, dass
// ActualChecksum tatsaechlich den Dateiinhalt liest und hasht (kein
// Header-/Groessenvergleich).
func TestActualChecksum_MatchesRealFileContent(t *testing.T) {
baseDir := t.TempDir()
content := []byte("echter dateiinhalt fuer scrub-test")
path := filepath.Join(baseDir, "documents", "x", "revisions", "1")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
got, err := ActualChecksum(baseDir, "documents/x/revisions/1")
if err != nil {
t.Fatalf("actualChecksum: %v", err)
}
sum := sha256.Sum256(content)
want := hex.EncodeToString(sum[:])
if got != want {
t.Fatalf("checksum = %q, want %q", got, want)
}
}
// TestExpectedChecksums_ReadsRealFileRevisions ist Nachweis gegen echtes
// Postgres, kein Mock.
func TestExpectedChecksums_ReadsRealFileRevisions(t *testing.T) {
pool, uid, did := requireFileRevisionsFixture(t)
ctx := context.Background()
if _, err := pool.Exec(ctx, `
INSERT INTO file_revisions (document_id, storage_key, checksum_sha256, size_bytes, mime_type, revision_number, created_by)
VALUES ($1, 'documents/x/revisions/1', 'abc123', 10, 'text/plain', 1, $2)
`, did, uid); err != nil {
t.Fatalf("testrevision anlegen: %v", err)
}
got, err := ExpectedChecksums(ctx, pool, []string{"documents/x/revisions/1", "documents/fehlt/revisions/1"})
if err != nil {
t.Fatalf("expectedChecksums: %v", err)
}
if len(got) != 1 || got["documents/x/revisions/1"] != "abc123" {
t.Fatalf("unerwartetes ergebnis: %+v", got)
}
}
-69
View File
@@ -1,69 +0,0 @@
// Package scrub implementiert BAK-08: periodische, checksummenbasierte
// Integritaetspruefung einer Stichprobe existierender Objekte. Baut auf
// BAK-05 (internal/reconcile) auf, das die deterministisch sortierte
// Liste bestaetigt existierender Objekte liefert (existenz-only) — scrub
// fuegt die INHALTSPRUEFUNG hinzu, die BAK-05 bewusst ausspart.
package scrub
import (
"sort"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
)
// Candidate ist ein fuer den aktuellen Lauf ausgewaehltes Objekt.
type Candidate struct {
StorageKey string
DocumentID string
RevisionID string
}
// Sample waehlt aus existing (BAK-05s existing_in_storage, bereits nach
// StorageKey sortiert) die naechste Stichprobe: Objekte, die noch nie
// oder vor mehr als cooldown geprueft wurden (last_scrubbed via
// storage_key -> last_scrubbed_at aus scrub_state), begrenzt auf
// sampleSize. Reine Funktion, deterministisch bei gleicher Eingabe (fixe
// Reihenfolge von existing, kein Zufall) — Akzeptanzkriterium
// "Sampling priorisiert alte, unveraenderte Objekte": ein nie/am
// laengsten nicht geprueftes Objekt hat KEINEN last_scrubbed-Eintrag oder
// den aeltesten, beides erscheint zuerst in "existing", das seinerseits
// nach StorageKey sortiert ist — daher wird zusaetzlich vor der
// Groessenbegrenzung nach last_scrubbed_at aufsteigend sortiert (nie
// geprueft = aeltestmoeglicher Wert), damit tatsaechlich das am laengsten
// nicht verifizierte Objekt zuerst drankommt, nicht nur alphabetisch nach
// Schluessel.
func Sample(existing []reconcile.Finding, lastScrubbed map[string]time.Time, cooldown time.Duration, sampleSize int, now time.Time) []Candidate {
type scored struct {
f reconcile.Finding
last time.Time
}
var due []scored
for _, f := range existing {
last, ok := lastScrubbed[f.StorageKey]
if ok && now.Sub(last) < cooldown {
continue // erst kuerzlich geprueft, ueberspringen
}
if !ok {
last = time.Time{} // nie geprueft = aeltestmoeglicher Wert, kommt zuerst
}
due = append(due, scored{f: f, last: last})
}
sort.SliceStable(due, func(i, j int) bool {
if !due[i].last.Equal(due[j].last) {
return due[i].last.Before(due[j].last)
}
return due[i].f.StorageKey < due[j].f.StorageKey // Tie-Break deterministisch
})
if sampleSize >= 0 && len(due) > sampleSize {
due = due[:sampleSize]
}
out := make([]Candidate, 0, len(due))
for _, d := range due {
out = append(out, Candidate{StorageKey: d.f.StorageKey, DocumentID: d.f.DocumentID, RevisionID: d.f.RevisionID})
}
return out
}
-95
View File
@@ -1,95 +0,0 @@
package scrub
import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
)
var now = time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
// TestSample_PrioritizesNeverScrubbedAndOldest ist der Nachweis fuer das
// GoBD-Akzeptanzkriterium: nie geprueft ODER am laengsten nicht geprueft
// kommt zuerst, nicht bloss alphabetisch nach StorageKey.
func TestSample_PrioritizesNeverScrubbedAndOldest(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"}, // vor 1 tag geprueft
{StorageKey: "documents/b/revisions/r1"}, // nie geprueft
{StorageKey: "documents/c/revisions/r1"}, // vor 30 tagen geprueft (aeltest)
}
lastScrubbed := map[string]time.Time{
"documents/a/revisions/r1": now.Add(-24 * time.Hour),
"documents/c/revisions/r1": now.Add(-30 * 24 * time.Hour),
}
got := Sample(existing, lastScrubbed, time.Hour, 2, now)
if len(got) != 2 {
t.Fatalf("erwartet 2 kandidaten, habe %d: %+v", len(got), got)
}
// "nie geprueft" (b) zaehlt als aeltestmoeglich, kommt vor "vor 30 tagen" (c).
if got[0].StorageKey != "documents/b/revisions/r1" || got[1].StorageKey != "documents/c/revisions/r1" {
t.Fatalf("falsche prioritaet, want [b, c], habe %+v", got)
}
}
// TestSample_RespectsCooldown ist der Nachweis, dass kuerzlich gepruefte
// Objekte NICHT erneut ausgewaehlt werden — sonst wuerde dieselbe Gruppe
// dauernd gescrubbt (genau der Fehler, den die Alt-Priorisierung
// verhindern soll).
func TestSample_RespectsCooldown(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"},
{StorageKey: "documents/b/revisions/r1"},
}
lastScrubbed := map[string]time.Time{
"documents/a/revisions/r1": now.Add(-1 * time.Hour), // innerhalb cooldown
}
got := Sample(existing, lastScrubbed, 24*time.Hour, 10, now)
if len(got) != 1 || got[0].StorageKey != "documents/b/revisions/r1" {
t.Fatalf("erwartet nur b (a innerhalb cooldown), habe %+v", got)
}
}
// TestSample_LimitsToSampleSize ist der Nachweis, dass die
// Stichprobengroesse tatsaechlich begrenzt (kein Voll-Scrub jeden Lauf).
func TestSample_LimitsToSampleSize(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"},
{StorageKey: "documents/b/revisions/r1"},
{StorageKey: "documents/c/revisions/r1"},
}
got := Sample(existing, map[string]time.Time{}, time.Hour, 1, now)
if len(got) != 1 {
t.Fatalf("erwartet genau 1 kandidat, habe %d", len(got))
}
}
// TestSample_DeterministicForIdenticalInput ist der Nachweis, dass zwei
// Laeufe mit identischer Eingabe dieselbe Reihenfolge liefern (kein
// Zufall im Sampling).
func TestSample_DeterministicForIdenticalInput(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"},
{StorageKey: "documents/b/revisions/r1"},
{StorageKey: "documents/c/revisions/r1"},
}
lastScrubbed := map[string]time.Time{}
first := Sample(existing, lastScrubbed, time.Hour, 2, now)
second := Sample(existing, lastScrubbed, time.Hour, 2, now)
if len(first) != len(second) {
t.Fatal("unterschiedliche anzahl zwischen zwei laeufen mit identischer eingabe")
}
for i := range first {
if first[i].StorageKey != second[i].StorageKey {
t.Fatalf("reihenfolge nicht deterministisch: lauf1=%+v lauf2=%+v", first, second)
}
}
}
-74
View File
@@ -1,74 +0,0 @@
package scrub
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// LoadLastScrubbed liefert je storage_key den Zeitpunkt der letzten
// Pruefung — Grundlage fuer Sample's Cooldown-Filter.
func LoadLastScrubbed(ctx context.Context, pool *pgxpool.Pool) (map[string]time.Time, error) {
rows, err := pool.Query(ctx, `SELECT storage_key, last_scrubbed_at FROM scrub_state`)
if err != nil {
return nil, fmt.Errorf("scrub: scrub_state lesen: %w", err)
}
defer rows.Close()
out := make(map[string]time.Time)
for rows.Next() {
var key string
var ts time.Time
if err := rows.Scan(&key, &ts); err != nil {
return nil, fmt.Errorf("scrub: scrub_state-zeile lesen: %w", err)
}
out[key] = ts
}
return out, rows.Err()
}
// MarkScrubbed vermerkt Ergebnis und Zeitpunkt der Pruefung eines
// Objekts — idempotent (ON CONFLICT), damit ein unterbrochener und neu
// gestarteter Lauf keinen inkonsistenten Zustand hinterlaesst
// (Akzeptanzkriterium: Lauf ist unterbrechbar ohne inkonsistenten
// Zustand).
func MarkScrubbed(ctx context.Context, pool *pgxpool.Pool, storageKey string, ok bool, at time.Time) error {
result := "ok"
if !ok {
result = "failed"
}
_, err := pool.Exec(ctx, `
INSERT INTO scrub_state (storage_key, last_scrubbed_at, last_result)
VALUES ($1, $2, $3)
ON CONFLICT (storage_key) DO UPDATE SET last_scrubbed_at = $2, last_result = $3
`, storageKey, at, result)
if err != nil {
return fmt.Errorf("scrub: scrub_state schreiben: %w", err)
}
return nil
}
// RecordFinding erhoeht den monoton steigenden Befund-Zaehler
// (scrub_counters.findings_total) um genau 1 — als gueltiger Prometheus-
// Counter darf dieser Wert nur steigen, niemals sinken, auch wenn ein
// Befund spaeter behoben wird.
func RecordFinding(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `UPDATE scrub_counters SET findings_total = findings_total + 1 WHERE id = 1`)
if err != nil {
return fmt.Errorf("scrub: befund-zaehler erhoehen: %w", err)
}
return nil
}
// FindingsTotal liest den aktuellen Zaehlerstand — genutzt vom
// /metrics-Endpunkt (cmd/scrub-metrics).
func FindingsTotal(ctx context.Context, pool *pgxpool.Pool) (int64, error) {
var total int64
err := pool.QueryRow(ctx, `SELECT findings_total FROM scrub_counters WHERE id = 1`).Scan(&total)
if err != nil {
return 0, fmt.Errorf("scrub: befund-zaehler lesen: %w", err)
}
return total, nil
}
-92
View File
@@ -1,92 +0,0 @@
package scrub
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS scrub_state (
storage_key TEXT PRIMARY KEY, last_scrubbed_at TIMESTAMPTZ NOT NULL,
last_result TEXT NOT NULL CHECK (last_result IN ('ok', 'failed'))
);
CREATE TABLE IF NOT EXISTS scrub_counters (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), findings_total BIGINT NOT NULL DEFAULT 0
);
INSERT INTO scrub_counters (id, findings_total) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE scrub_state; UPDATE scrub_counters SET findings_total = 0 WHERE id = 1`)
})
return pool
}
// TestMarkScrubbed_IsIdempotent ist Nachweis fuer "Lauf ist idempotent und
// unterbrechbar ohne inkonsistenten Zustand": derselbe storage_key kann
// beliebig oft neu markiert werden, es entsteht kein Duplikat/Fehler.
func TestMarkScrubbed_IsIdempotent(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
key := "documents/x/revisions/1"
if err := MarkScrubbed(ctx, pool, key, true, time.Now().UTC()); err != nil {
t.Fatalf("erster markScrubbed: %v", err)
}
second := time.Now().UTC().Add(time.Hour)
if err := MarkScrubbed(ctx, pool, key, false, second); err != nil {
t.Fatalf("zweiter markScrubbed (ueberschreibt): %v", err)
}
last, err := LoadLastScrubbed(ctx, pool)
if err != nil {
t.Fatalf("loadLastScrubbed: %v", err)
}
if len(last) != 1 {
t.Fatalf("erwartet genau 1 eintrag (kein duplikat), habe %d", len(last))
}
// Postgres timestamptz rundet auf Mikrosekunden, Go time.Time hat
// Nanosekunden-Praezision - Vergleich daher auf Mikrosekunden gerundet.
if !last[key].Truncate(time.Microsecond).Equal(second.Truncate(time.Microsecond)) {
t.Fatalf("last_scrubbed_at nicht ueberschrieben: %v, want %v", last[key], second)
}
}
// TestRecordFinding_IsMonotonicallyIncreasing ist Nachweis, dass der
// Zaehler ein gueltiger Prometheus-Counter ist (steigt nur, sinkt nie).
func TestRecordFinding_IsMonotonicallyIncreasing(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
if err := RecordFinding(ctx, pool); err != nil {
t.Fatalf("recordFinding: %v", err)
}
}
total, err := FindingsTotal(ctx, pool)
if err != nil {
t.Fatalf("findingsTotal: %v", err)
}
if total != 3 {
t.Fatalf("erwartet 3, habe %d", total)
}
}
-76
View File
@@ -1,76 +0,0 @@
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
}
-37
View File
@@ -1,37 +0,0 @@
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)
}
}
@@ -1,155 +0,0 @@
// 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 + `"`
}
@@ -1,203 +0,0 @@
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")
}
}
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS scrub_counters;
DROP TABLE IF EXISTS scrub_state;
@@ -1,21 +0,0 @@
-- BAK-08: Zustand des Integritaets-Scrub-Jobs. Getrennt von file_revisions
-- (DMS-Eigentum, nur lesend zugegriffen) und getrennt von BAK-05s
-- reconcile-Paket (existenz-only, keine Inhaltspruefung) — eigener,
-- Archive-eigener Zustand ueber ZULETZT geprueften Zeitpunkt je Objekt,
-- damit Sampling rotiert statt dieselben "aeltesten" Objekte auf ewig
-- erneut zu ziehen.
CREATE TABLE IF NOT EXISTS scrub_state (
storage_key TEXT PRIMARY KEY,
last_scrubbed_at TIMESTAMPTZ NOT NULL,
last_result TEXT NOT NULL CHECK (last_result IN ('ok', 'failed'))
);
-- Einzelne Zeile, monoton steigender Zaehler fuer den OPS-05/OPS-03-
-- Metrik-Export (Counter, nie ruecksetzbar — ein behobener Befund darf den
-- Zaehler nicht wieder senken, sonst waere es kein gueltiger Prometheus-
-- Counter mehr).
CREATE TABLE IF NOT EXISTS scrub_counters (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
findings_total BIGINT NOT NULL DEFAULT 0
);
INSERT INTO scrub_counters (id, findings_total) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS retention_class_assignments;
DROP TABLE IF EXISTS retention_objects;
-26
View File
@@ -1,26 +0,0 @@
-- RET-01: generisches Retention-Objektmodell. Modulübergreifend über
-- Adapter (object_type/object_reference als reine Textfelder, KEINE
-- Fremdschlüssel auf DMS-/Mail-Tabellen) — Archive kennt die
-- Fachobjekte anderer Module nicht, nur deren Typ+Referenz.
CREATE TABLE IF NOT EXISTS retention_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
object_type TEXT NOT NULL,
object_reference TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (object_type, object_reference)
);
-- Historisiert: JEDE Zuordnung ist eine eigene, unveränderliche Zeile
-- (nie UPDATE) — "aktuelle" Aufbewahrungsklasse ist die mit dem
-- jüngsten assigned_at je retention_object_id. So bleibt nachvollziehbar,
-- wann sich die Klasse eines Objekts geändert hat (Akzeptanzkriterium 2).
CREATE TABLE IF NOT EXISTS retention_class_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
retention_class TEXT NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_retention_class_assignments_object
ON retention_class_assignments (retention_object_id, assigned_at DESC);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS module_registrations;
@@ -1,15 +0,0 @@
-- RET-05: Modul-Adapter-Schnittstelle. Ein Modul (DMS, Mail, ...)
-- registriert je Objekttyp EINE Rueckruf-Adresse fuer Loeschbestaetigung
-- - Archive kennt danach nur noch module_name/object_type/callback_url,
-- keine Modul-Interna. Mandantenfaehig durch Modell C (physisch
-- isolierte Tenant-DB, TEN-01) - kein tenant_id-Feld noetig, dieselbe
-- Begruendung wie RET-01s retention_objects.
CREATE TABLE IF NOT EXISTS module_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_name TEXT NOT NULL,
object_type TEXT NOT NULL,
retention_class TEXT NOT NULL,
callback_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (module_name, object_type)
);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS retention_class_rules;
@@ -1,9 +0,0 @@
-- RET-02: Fristenmodell je Aufbewahrungsklasse. duration ist ein
-- natives Postgres-INTERVAL statt eigener Tage-/Monatszaehlung, damit
-- Kalenderfaelle (Schaltjahr, Monatsende) exakt Postgres' eigene,
-- bewaehrte Intervall-Arithmetik nutzen statt eine eigene, potenziell
-- fehlerhafte Nachbildung.
CREATE TABLE IF NOT EXISTS retention_class_rules (
retention_class TEXT PRIMARY KEY,
duration INTERVAL NOT NULL
);
@@ -1 +0,0 @@
ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS active;
@@ -1,4 +0,0 @@
-- RET-06-API: Aufbewahrungsklassen lassen sich deaktivieren, ohne ihre
-- Historie (bereits erfolgte Zuordnungen/Berechnungen) zu verlieren -
-- kein DELETE, nur ein Sichtbarkeits-/Anwendbarkeits-Flag.
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
+18 -3
View File
@@ -7,6 +7,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config" "gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db" "gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
) )
func main() { func main() {
@@ -15,16 +16,30 @@ func main() {
log.Fatalf("config: %v", err) log.Fatalf("config: %v", err)
} }
pool, err := db.Connect(context.Background(), cfg.RegistryDSN) ctx := context.Background()
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
if err != nil { if err != nil {
log.Fatalf("db: %v", err) log.Fatalf("registry db: %v", err)
} }
defer pool.Close() defer registryPool.Close()
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
if err != nil {
log.Fatalf("admin db: %v", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr) log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
+66
View File
@@ -0,0 +1,66 @@
// statuspage-devserver stellt das OPS-02-Backend (internal/statuspage) fuer
// die Next.js-Statusseite bereit und startet den periodischen Poller.
// Getrennt von cmd/core aus demselben Grund wie die anderen *-devserver.
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/statuspage"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR")
if addr == "" {
addr = ":8084"
}
intervalSeconds := 10
if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
intervalSeconds = parsed
}
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
store := statuspage.NewStore(pool)
checker := statuspage.NewHTTPChecker(2 * time.Second)
poller := statuspage.NewPoller(store, checker)
go poller.Run(ctx, time.Duration(intervalSeconds)*time.Second)
mux := http.NewServeMux()
mux.HandleFunc("/status/overview", withCORS(store.OverviewHandler))
mux.HandleFunc("/status/history", withCORS(store.HistoryHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("statuspage-devserver listening on %s (poll-intervall: %ds)", addr, intervalSeconds)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
@@ -1,9 +0,0 @@
[Unit]
Description=NEXARCH Archive - Datenbank-Vollsicherung (BAK-01)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli full
@@ -1,9 +0,0 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Datenbank-Vollsicherung (BAK-01)
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,9 +0,0 @@
[Unit]
Description=NEXARCH Archive - Datenbank-Inkrementalsicherung (BAK-01)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli incremental
@@ -1,9 +0,0 @@
[Unit]
Description=Stuendlicher Zeitplan fuer NEXARCH Archive Datenbank-Inkrementalsicherung (BAK-01)
[Timer]
OnCalendar=*-*-* *:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,9 +0,0 @@
[Unit]
Description=NEXARCH Archive - Sicherungsgenerationen-Rotation (BAK-01)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli rotate
@@ -1,9 +0,0 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Sicherungsgenerationen-Rotation (BAK-01)
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,9 +0,0 @@
[Unit]
Description=NEXARCH Archive - Objekt-Storage-Sicherung (BAK-02)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli backup __OBJECT_SOURCE_DIR__
@@ -1,9 +0,0 @@
[Unit]
Description=Stuendlicher Zeitplan fuer NEXARCH Archive Objekt-Storage-Sicherung (BAK-02)
[Timer]
OnCalendar=*-*-* *:30:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,9 +0,0 @@
[Unit]
Description=NEXARCH Archive - Objekt-Storage-Sicherung Vollstaendigkeitspruefung (BAK-02)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli check
@@ -1,9 +0,0 @@
[Unit]
Description=Woechentlicher Zeitplan fuer NEXARCH Archive Objekt-Storage-Vollstaendigkeitspruefung (BAK-02)
[Timer]
OnCalendar=Sun *-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,9 +0,0 @@
[Unit]
Description=NEXARCH Archive - Objekt-Storage-Sicherung Rotation (BAK-02)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli rotate
@@ -1,9 +0,0 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Objekt-Storage-Rotation (BAK-02)
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,10 +0,0 @@
[Unit]
Description=NEXARCH Archive - Konsistenzpruefung Storage vs. DB (BAK-05)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-reconcile.env
ExecStart=__INSTALL_DIR__/bin/reconcile-cli
StandardOutput=journal
@@ -1,9 +0,0 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Konsistenzpruefung (BAK-05)
[Timer]
OnCalendar=*-*-* 05:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,14 +0,0 @@
[Unit]
Description=NEXARCH Archive - /metrics-Export fuer BAK-06 (dauerhaft, Pull-Modell fuer OPS-03)
After=network.target
[Service]
Type=simple
User=nexarch
EnvironmentFile=/etc/nexarch/archive-restoretest.env
ExecStart=__INSTALL_DIR__/bin/restoretest-metrics
Restart=on-failure
StandardOutput=journal
[Install]
WantedBy=multi-user.target

Some files were not shown because too many files have changed in this diff Show More