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
114 changed files with 3557 additions and 3734 deletions
+2
View File
@@ -1,2 +1,4 @@
*.log
.env
web/*/node_modules/
web/*/.next/
+115
View File
@@ -44,3 +44,118 @@ Keine Commits in dieser Session.
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
```
-107
View File
@@ -1,107 +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 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":
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])
}
}
-75
View File
@@ -1,75 +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 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":
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)
}
}
-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)
}
}
-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.
-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.
-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.
-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))
}
}
-56
View File
@@ -1,56 +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
}
-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
}
-167
View File
@@ -1,167 +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
}
// 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)
}
}
-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])
}
}
-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)
}
}
@@ -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;
+18 -3
View File
@@ -7,6 +7,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func main() {
@@ -15,16 +16,30 @@ func main() {
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 {
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.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
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)
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-08 (dauerhaft, Pull-Modell fuer OPS-03)
After=network.target postgresql.service
[Service]
Type=simple
User=nexarch
EnvironmentFile=/etc/nexarch/archive-scrub-metrics.env
ExecStart=__INSTALL_DIR__/bin/scrub-metrics
Restart=on-failure
StandardOutput=journal
[Install]
WantedBy=multi-user.target
@@ -1,10 +0,0 @@
[Unit]
Description=NEXARCH Archive - Checksummen-Integritaetspruefung Stichprobe (BAK-08)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-scrub.env
ExecStart=__INSTALL_DIR__/bin/scrub-cli
StandardOutput=journal
@@ -1,9 +0,0 @@
[Unit]
Description=Zeitplan fuer NEXARCH Archive Checksummen-Stichprobe (BAK-08)
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
[Install]
WantedBy=timers.target
+9
View File
@@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch
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
)
View File
+24 -2
View File
@@ -10,8 +10,15 @@ import (
// connection info, superadmin accounts) — see nexarch-state.json
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
type Config struct {
ListenAddr string
ListenAddr string
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
RegistryDSN string
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
AdminDSN string
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
}
func Load() (Config, error) {
@@ -20,10 +27,25 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
}
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
if adminDSN == "" {
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
}
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
if dsnTemplate == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
+179
View File
@@ -0,0 +1,179 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0,
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
+25
View File
@@ -0,0 +1,25 @@
package health
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
// (Ping) — nicht nur, ob der Pool existiert.
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
return pool.Ping(ctx)
}
}
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
_, err := pool.Exec(ctx, `SELECT 1`)
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package health
import (
"encoding/json"
"net/http"
)
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
}
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Registry) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ready, results := r.CheckAll(req.Context())
body := map[string]any{
"status": statusText(ready),
"checks": results,
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
writeStatus(w, status, body)
}
}
func statusText(ready bool) string {
if ready {
return "ready"
}
return "not_ready"
}
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+86
View File
@@ -0,0 +1,86 @@
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
// nicht nur von Core selbst.
package health
import (
"context"
"time"
)
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
type Checker interface {
Check(ctx context.Context) error
}
type CheckerFunc func(ctx context.Context) error
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
// haengender Check den gesamten Readiness-Endpunkt blockiert
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
const DefaultCheckTimeout = 2 * time.Second
// Registry haelt alle benannten Checks eines Dienstes.
type Registry struct {
checks map[string]Checker
timeout time.Duration
}
func NewRegistry() *Registry {
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
}
func (r *Registry) WithTimeout(d time.Duration) *Registry {
return &Registry{checks: r.checks, timeout: d}
}
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
func (r *Registry) Register(name string, c Checker) {
r.checks[name] = c
}
// Result ist der Ausgang eines einzelnen Checks.
type Result struct {
OK bool
Error string
}
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
type namedResult struct {
name string
result Result
}
ch := make(chan namedResult, len(r.checks))
for name, checker := range r.checks {
go func(name string, checker Checker) {
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
err := checker.Check(checkCtx)
if err != nil {
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
return
}
ch <- namedResult{name, Result{OK: true}}
}(name, checker)
}
results = make(map[string]Result, len(r.checks))
ready = true
for i := 0; i < len(r.checks); i++ {
nr := <-ch
results[nr.name] = nr.result
if !nr.result.OK {
ready = false
}
}
return ready, results
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
// "nicht bereit".
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]interface{} `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body parsen: %v", err)
}
if body.Status != "not_ready" {
t.Fatalf("status-feld = %q, want not_ready", body.Status)
}
if _, ok := body.Checks["database"]; !ok {
t.Fatal("erwartet 'database' im checks-ergebnis")
}
}
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
reg.Register("queue", QueueChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
// sich nachweislich im Fehlerfall.
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
pool.Close() // db-ausfall simulieren
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
livenessRec := httptest.NewRecorder()
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
if livenessRec.Code != http.StatusOK {
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
}
readinessRec := httptest.NewRecorder()
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readinessRec.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
}
if livenessRec.Code == readinessRec.Code {
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
elapsed := time.Since(start)
if elapsed > time.Second {
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
}
}
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
reg := NewRegistry().WithTimeout(time.Second)
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
ready, results := reg.CheckAll(context.Background())
if ready {
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
}
if !results["a"].OK {
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
}
if results["b"].OK || results["b"].Error == "" {
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
}
}
+99
View File
@@ -0,0 +1,99 @@
package moduleregistry
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var (
ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein")
ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential")
)
// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine
// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim
// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich
// dessen SHA-256-Hash.
func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) {
if _, err := r.Get(ctx, moduleName); err != nil {
if errors.Is(err, ErrModuleNotFound) {
return "", "", ErrModuleNotRegistered
}
return "", "", err
}
clientID, err = randomToken(16)
if err != nil {
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
}
secret, err = randomToken(32)
if err != nil {
return "", "", fmt.Errorf("secret erzeugen: %w", err)
}
hash := hashSecret(secret)
_, err = r.pool.Exec(ctx, `
INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now()
`, moduleName, clientID, hash)
if err != nil {
return "", "", fmt.Errorf("credential speichern: %w", err)
}
return clientID, secret, nil
}
// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster
// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt
// (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) {
if clientID == "" || secret == "" {
return "", false, nil
}
var storedHash []byte
err = r.pool.QueryRow(ctx, `
SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1
`, clientID).Scan(&moduleName, &storedHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("credential lesen: %w", err)
}
if !timingSafeEqual(hashSecret(secret), storedHash) {
return "", false, nil
}
return moduleName, true, nil
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02
// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden
// sicherheitsrelevanten Vergleich.
func timingSafeEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
+46
View File
@@ -0,0 +1,46 @@
package moduleregistry
import "net/http"
// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL
// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 /
// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter
// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber
// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist
// API-05/TEN-06, nicht Teil dieser Kachel).
func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tenantSlug := req.URL.Query().Get("tenant")
active, err := r.IsActive(req.Context(), tenantSlug, moduleName)
if err != nil {
http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !active {
http.Error(w, "modul nicht aktiviert", http.StatusForbidden)
return
}
next(w, req)
}
}
// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr
// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der
// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
clientID := req.Header.Get("X-Client-Id")
secret := req.Header.Get("X-Client-Secret")
_, ok, err := r.Authenticate(req.Context(), clientID, secret)
if err != nil {
http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized)
return
}
next(w, req)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package moduleregistry implementiert Core API-02: die Registry, in der
// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an
// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich
// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei
// Provisionierung ausgestelltes Service-Credential.
package moduleregistry
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
var (
ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein")
ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein")
ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert")
)
type Module struct {
Name string
Version string
RequiredFlags []string
}
type Registry struct {
pool *pgxpool.Pool
flags *flag.Service
}
func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry {
return &Registry{pool: pool, flags: flags}
}
// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags
// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen
// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens
// aktualisiert Version/Flags (Redeploy-Fall).
func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) {
if name == "" {
return Module{}, ErrMissingName
}
if version == "" {
return Module{}, ErrMissingVersion
}
if requiredFlags == nil {
requiredFlags = []string{}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO modules (name, version, required_flags, registered_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now()
`, name, version, requiredFlags)
if err != nil {
return Module{}, fmt.Errorf("modul registrieren: %w", err)
}
return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil
}
func (r *Registry) Get(ctx context.Context, name string) (Module, error) {
var m Module
m.Name = name
err := r.pool.QueryRow(ctx, `
SELECT version, required_flags FROM modules WHERE name = $1
`, name).Scan(&m.Version, &m.RequiredFlags)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Module{}, ErrModuleNotFound
}
return Module{}, fmt.Errorf("modul lesen: %w", err)
}
return m, nil
}
// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API
// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche).
func (r *Registry) List(ctx context.Context) ([]Module, error) {
rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("module auflisten: %w", err)
}
defer rows.Close()
var out []Module
for rows.Next() {
var m Module
if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil {
return nil, fmt.Errorf("modul lesen: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert
// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen
// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt
// immer als nicht aktiv.
func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) {
m, err := r.Get(ctx, moduleName)
if err != nil {
if errors.Is(err, ErrModuleNotFound) {
return false, nil
}
return false, err
}
for _, flagKey := range m.RequiredFlags {
if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) {
return false, nil
}
}
return true, nil
}
+304
View File
@@ -0,0 +1,304 @@
package moduleregistry
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
func setupTest(t *testing.T) (*Registry, *flag.Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
flagStore := flag.NewStore(pool)
// Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an
// Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate
// zuverlaessig sehen.
flagService := flag.NewService(flagStore, 10*time.Millisecond)
registry := NewRegistry(pool, flagService)
cleanup := func() { pool.Close() }
return registry, flagStore, cleanup
}
func uniqueModuleName(t *testing.T) string {
return fmt.Sprintf("dms_%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen.
func TestRegister_RejectsMissingFields(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) {
t.Fatalf("erwartet ErrMissingName, habe %v", err)
}
if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) {
t.Fatalf("erwartet ErrMissingVersion, habe %v", err)
}
}
func TestRegister_AndGet(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"})
if err != nil {
t.Fatalf("register: %v", err)
}
if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 {
t.Fatalf("unerwartet: %+v", m)
}
got, err := registry.Get(ctx, name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != "1.2.0" {
t.Fatalf("get version = %q", got.Version)
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach
// Aktivierung/Deaktivierung eines Moduls.
func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
active, err := registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (vor flag): %v", err)
}
if active {
t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag an): %v", err)
}
if !active {
t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil {
t.Fatalf("flag zuruecksetzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag aus): %v", err)
}
if active {
t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde")
}
}
func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
active, err := registry.IsActive(ctx, "acme", "nie-registriert")
if err != nil {
t.Fatalf("is active: %v", err)
}
if active {
t.Fatal("unregistriertes modul darf nie aktiv sein")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird
// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird.
func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
handlerReached := false
handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) {
handlerReached = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if handlerReached {
t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code)
}
if !handlerReached {
t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen")
}
}
// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential.
func TestProvisionAndAuthenticate(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
if clientID == "" || secret == "" {
t.Fatal("erwartet nicht-leere client-id/secret")
}
moduleName, ok, err := registry.Authenticate(ctx, clientID, secret)
if err != nil {
t.Fatalf("authenticate (korrekt): %v", err)
}
if !ok || moduleName != name {
t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName)
}
_, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret")
if err != nil {
t.Fatalf("authenticate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei falschem secret")
}
_, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret)
if err != nil {
t.Fatalf("authenticate (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei unbekannter client-id")
}
}
func TestProvision_RequiresRegisteredModule(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) {
t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err)
}
}
func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Fehlendes Credential.
req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne credential: status = %d, want 401", rec.Code)
}
// Falsches Secret.
req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req2.Header.Set("X-Client-Id", clientID)
req2.Header.Set("X-Client-Secret", "falsch")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("falsches secret: status = %d, want 401", rec2.Code)
}
// Gueltiges Credential.
req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req3.Header.Set("X-Client-Id", clientID)
req3.Header.Set("X-Client-Secret", secret)
rec3 := httptest.NewRecorder()
handler(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code)
}
}
+273
View File
@@ -0,0 +1,273 @@
// Package statuspage implementiert Core OPS-02: eine zentrale Statusseite,
// die den Health-Zustand aller registrierten Module aggregiert und den
// Verlauf vergangener Statusaenderungen speichert. Baut auf OPS-01
// (internal/health) auf, indem es GENAU die dort etablierten
// Readiness-Endpunkte je Modul abfragt — dieses Paket dupliziert keine
// Health-Check-Logik, es aggregiert nur deren Ergebnisse ueber die Zeit.
package statuspage
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Status ist der aggregierte Zustand EINES Moduls zu einem Zeitpunkt.
type Status string
const (
StatusUp Status = "up"
StatusDown Status = "down"
)
// Target ist ein zu ueberwachendes Modul mit seiner Readiness-URL
// (OPS-01-Endpunkt, z.B. "http://dms:8080/readyz"). Eigenstaendige
// Konfiguration statt Erweiterung von internal/moduleregistry.Module, um
// API-02 nicht anzufassen (Kein Umbau angrenzender Bereiche).
type Target struct {
Name string
HealthURL string
}
// Store persistiert Ueberwachungsziele und den Verlauf ihrer
// Statusaenderungen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// RegisterTarget traegt ein zu ueberwachendes Modul ein oder aktualisiert
// dessen URL (Akzeptanzkriterium 1: "aller registrierten Module").
func (s *Store) RegisterTarget(ctx context.Context, t Target) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO status_targets (name, health_url)
VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET health_url = $2
`, t.Name, t.HealthURL)
if err != nil {
return fmt.Errorf("ueberwachungsziel speichern: %w", err)
}
return nil
}
func (s *Store) ListTargets(ctx context.Context) ([]Target, error) {
rows, err := s.pool.Query(ctx, `SELECT name, health_url FROM status_targets ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("ueberwachungsziele auflisten: %w", err)
}
defer rows.Close()
var out []Target
for rows.Next() {
var t Target
if err := rows.Scan(&t.Name, &t.HealthURL); err != nil {
return nil, fmt.Errorf("ueberwachungsziel lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// recordIfChanged schreibt NUR dann einen neuen Verlaufseintrag, wenn sich
// der Status seit dem letzten Eintrag geaendert hat (oder es der erste
// Eintrag ist) — der Verlauf zeigt Statusaenderungen (Akzeptanzkriterium 3),
// nicht jede einzelne Abfrage.
func (s *Store) recordIfChanged(ctx context.Context, name string, status Status) error {
var lastStatus string
err := s.pool.QueryRow(ctx, `
SELECT status FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, name).Scan(&lastStatus)
if err != nil && err != pgx.ErrNoRows {
return fmt.Errorf("letzten status lesen: %w", err)
}
if err == nil && lastStatus == string(status) {
return nil // keine Aenderung, kein neuer Eintrag
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO status_history (name, status, changed_at) VALUES ($1, $2, now())
`, name, string(status)); err != nil {
return fmt.Errorf("statuseintrag schreiben: %w", err)
}
return nil
}
// ModuleStatus ist der aktuelle Zustand EINES Moduls fuer die Uebersicht.
type ModuleStatus struct {
Name string `json:"name"`
Status Status `json:"status"`
LastChecked time.Time `json:"last_checked"`
}
// Overview liefert den aktuellen (letzten bekannten) Status jedes
// registrierten Ziels (Akzeptanzkriterium 1). Ziele ohne jemals erfolgte
// Pruefung erscheinen mit Status "down" — ein Modul, ueber das nichts
// bekannt ist, gilt als nicht verfuegbar (Fail-Safe-Default), nicht als
// stillschweigend "ok".
func (s *Store) Overview(ctx context.Context) ([]ModuleStatus, error) {
targets, err := s.ListTargets(ctx)
if err != nil {
return nil, err
}
out := make([]ModuleStatus, 0, len(targets))
for _, t := range targets {
var status string
var changedAt time.Time
err := s.pool.QueryRow(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, t.Name).Scan(&status, &changedAt)
if err == pgx.ErrNoRows {
out = append(out, ModuleStatus{Name: t.Name, Status: StatusDown})
continue
}
if err != nil {
return nil, fmt.Errorf("aktuellen status lesen (%s): %w", t.Name, err)
}
out = append(out, ModuleStatus{Name: t.Name, Status: Status(status), LastChecked: changedAt})
}
return out, nil
}
// HistoryEntry ist EIN Verlaufseintrag (Akzeptanzkriterium 3).
type HistoryEntry struct {
Status Status `json:"status"`
ChangedAt time.Time `json:"changed_at"`
}
func (s *Store) History(ctx context.Context, name string) ([]HistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC
`, name)
if err != nil {
return nil, fmt.Errorf("verlauf abfragen: %w", err)
}
defer rows.Close()
var out []HistoryEntry
for rows.Next() {
var e HistoryEntry
var status string
if err := rows.Scan(&status, &e.ChangedAt); err != nil {
return nil, fmt.Errorf("verlaufseintrag lesen: %w", err)
}
e.Status = Status(status)
out = append(out, e)
}
return out, rows.Err()
}
// HTTPChecker fragt die Readiness-URL eines Moduls ab (OPS-01-Endpunkt) und
// liefert StatusUp NUR bei HTTP 200 — jeder andere Statuscode ODER ein
// Netzwerkfehler/Timeout gilt als StatusDown. Ein einzelnes nicht
// erreichbares Modul liefert einen FEHLERFREIEN StatusDown-Wert statt eines
// Go-Errors, damit Poller.Run ein fehlerhaftes Modul niemals mit einem
// anderen verwechseln oder den gesamten Zyklus abbrechen kann
// (Akzeptanzkriterium 2).
type HTTPChecker struct {
Client *http.Client
Timeout time.Duration
}
func NewHTTPChecker(timeout time.Duration) *HTTPChecker {
return &HTTPChecker{Client: &http.Client{}, Timeout: timeout}
}
func (c *HTTPChecker) Check(ctx context.Context, url string) Status {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return StatusDown
}
resp, err := c.Client.Do(req)
if err != nil {
return StatusDown
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return StatusUp
}
return StatusDown
}
// Poller fragt periodisch alle Ziele ab und schreibt Statusaenderungen fort.
type Poller struct {
store *Store
checker *HTTPChecker
}
func NewPoller(store *Store, checker *HTTPChecker) *Poller {
return &Poller{store: store, checker: checker}
}
// PollOnce prueft ALLE Ziele in einem Durchlauf. Ein fehlschlagendes Ziel
// (Netzwerkfehler, Timeout, Nicht-200) wird als StatusDown vermerkt und
// haelt die Pruefung der UEBRIGEN Ziele nicht auf — die Schleife laeuft
// sequenziell weiter, kein Ziel kann ein anderes blockieren
// (Akzeptanzkriterium 2 / Pruefung 2).
func (p *Poller) PollOnce(ctx context.Context) error {
targets, err := p.store.ListTargets(ctx)
if err != nil {
return err
}
for _, t := range targets {
status := p.checker.Check(ctx, t.HealthURL)
if err := p.store.recordIfChanged(ctx, t.Name, status); err != nil {
// Ein Schreibfehler fuer EIN Ziel darf die Pruefung der anderen
// nicht verhindern — dieselbe Fail-Isolation wie bei einem
// unerreichbaren Modul.
continue
}
}
return nil
}
// Run ruft PollOnce in festen Abstaenden auf, bis ctx beendet wird —
// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper.
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = p.PollOnce(ctx)
}
}
}
// --- HTTP-Bindung fuer die Oberflaeche ---
func (s *Store) OverviewHandler(w http.ResponseWriter, r *http.Request) {
overview, err := s.Overview(r.Context())
writeJSONResult(w, overview, err)
}
func (s *Store) HistoryHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
history, err := s.History(r.Context(), name)
writeJSONResult(w, history, err)
}
func writeJSONResult(w http.ResponseWriter, body any, err error) {
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(body)
}
+198
View File
@@ -0,0 +1,198 @@
package statuspage
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), cleanup
}
func uniqueName(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten
// Module.
func TestOverview_ShowsAllRegisteredModules(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b")
if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil {
t.Fatalf("target a: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil {
t.Fatalf("target b: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
// Overview() listet ALLE jemals registrierten Ziele (auch aus fruehreren
// Testlaeufen auf derselben geteilten Test-DB) — hier wird deshalb nur
// der Status von nameA/nameB geprueft, nicht jeder Eintrag der Tabelle.
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[nameA] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameA, statusByName[nameA])
}
if statusByName[nameB] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameB, statusByName[nameB])
}
}
// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls
// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung
// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall).
func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall)
healthyName, downName := uniqueName("healthy"), uniqueName("down")
if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil {
t.Fatalf("target healthy: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil {
t.Fatalf("target down: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[healthyName] != StatusUp {
t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName])
}
if statusByName[downName] != StatusDown {
t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName])
}
}
// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar.
func TestHistory_RecordsStatusTransitions(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("flaky")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 1: %v", err)
}
// Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits
// geschlossenen Server) -> naechster Poll erkennt den Statuswechsel.
closedURL := up.URL
up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil {
t.Fatalf("target aktualisieren: %v", err)
}
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 2: %v", err)
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history)
}
if history[0].Status != StatusDown || history[1].Status != StatusUp {
t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history)
}
}
// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit
// identischen Eintraegen fluten (siehe recordIfChanged).
func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("stable")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
for i := 0; i < 3; i++ {
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll %d: %v", i, err)
}
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 1 {
t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history))
}
}
+45
View File
@@ -0,0 +1,45 @@
package tenant
import (
"encoding/json"
"net/http"
)
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding).
// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von
// TEN-01 und werden dort nachgezogen.
type Handler struct {
provisioner *Provisioner
}
func NewHandler(p *Provisioner) *Handler {
return &Handler{provisioner: p}
}
type createTenantRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name)
if err != nil {
if err == ErrInvalidSlug {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(t)
}
+78
View File
@@ -0,0 +1,78 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
type Provisioner struct {
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
adminPool *pgxpool.Pool
registry *Registry
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
dsnTemplate string
}
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
}
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
if err := ValidateSlug(slug); err != nil {
return Tenant{}, err
}
dbName := dbNameForSlug(slug)
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
}
t := Tenant{
Slug: slug,
Name: name,
DBName: dbName,
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
Status: StatusActive,
}
tx, err := p.registry.pool.Begin(ctx)
if err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
}
created, err := p.registry.insertTx(ctx, tx, t)
if err != nil {
_ = tx.Rollback(ctx)
p.rollbackDatabase(ctx, dbName)
return Tenant{}, err
}
if err := tx.Commit(ctx); err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
}
return created, nil
}
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
}
+105
View File
@@ -0,0 +1,105 @@
package tenant
import (
"context"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
//
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
//
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
defer registryPool.Close()
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := NewRegistry(registryPool)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
})
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
if err != nil {
t.Fatalf("provision acme: %v", err)
}
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
if err != nil {
t.Fatalf("provision globex: %v", err)
}
if tenantA.DBName == tenantB.DBName {
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
}
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
if err != nil {
t.Fatalf("connect tenant a: %v", err)
}
defer poolA.Close()
var globexVisible bool
err = poolA.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
)
`, tenantB.DBName).Scan(&globexVisible)
if err != nil {
t.Fatalf("pruefung tenant-trennung: %v", err)
}
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
var currentDB string
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(&currentDB); err != nil {
t.Fatalf("current_database: %v", err)
}
if currentDB != tenantA.DBName {
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
}
if currentDB == tenantB.DBName {
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
}
}
+69
View File
@@ -0,0 +1,69 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank.
// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) —
// niemals Geschaeftsdaten eines Mandanten.
type Registry struct {
pool *pgxpool.Pool
}
func NewRegistry(pool *pgxpool.Pool) *Registry {
return &Registry{pool: pool}
}
// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion,
// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann.
func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
row := tx.QueryRow(ctx, `
INSERT INTO tenants (slug, name, db_name, db_dsn, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
}
return t, nil
}
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
var t Tenant
row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants WHERE slug = $1
`, slug)
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
}
return t, nil
}
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("tenants auflisten: %w", err)
}
defer rows.Close()
var out []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
+42
View File
@@ -0,0 +1,42 @@
// Package tenant implements Core TEN-01: die Control-Plane-Registry und die
// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C).
package tenant
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
)
type Tenant struct {
ID string
Slug string
Name string
DBName string
DBDSN string
Status Status
CreatedAt time.Time
}
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird.
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`)
var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)")
func ValidateSlug(slug string) error {
if !slugPattern.MatchString(slug) {
return ErrInvalidSlug
}
return nil
}
func dbNameForSlug(slug string) string {
return "tenant_" + slug
}
+35
View File
@@ -0,0 +1,35 @@
package tenant
import "testing"
func TestValidateSlug(t *testing.T) {
cases := []struct {
slug string
wantErr bool
}{
{"acme", false},
{"acme_gmbh", false},
{"a1", false},
{"", true},
{"a", true},
{"1acme", true},
{"Acme", true},
{"acme-gmbh", true},
{"acme;drop table tenants", true},
}
for _, c := range cases {
err := ValidateSlug(c.slug)
if (err != nil) != c.wantErr {
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr)
}
}
}
func TestDBNameForSlug(t *testing.T) {
got := dbNameForSlug("acme")
want := "tenant_acme"
if got != want {
t.Errorf("dbNameForSlug() = %q, want %q", got, want)
}
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS tenants;
-10
View File
@@ -1,10 +0,0 @@
-- Control-plane registry: tenant list + connection info (Modell C).
-- Core TEN-01 (siehe core-kanban).
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+14
View File
@@ -0,0 +1,14 @@
-- Control-plane registry: Tenant-Liste + Verbindungsinformationen (Modell C).
-- Enthaelt AUSSCHLIESSLICH Tenant-Metadaten, keine Geschaeftsdaten eines Mandanten.
-- Core TEN-01 (siehe core-kanban/tickets/TEN-01.md).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS feature_flags;
+10
View File
@@ -0,0 +1,10 @@
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS module_credentials;
DROP TABLE IF EXISTS modules;
+18
View File
@@ -0,0 +1,18 @@
-- Modul-Registry & Aktivierungspruefung (API-02, siehe core-kanban/tickets/API-02.md).
CREATE TABLE modules (
name TEXT PRIMARY KEY,
version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}',
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Service-Credential je Modul-Instanz, bei Provisionierung ausgestellt
-- (Akzeptanzkriterium 4). secret_hash enthaelt NIEMALS das Secret im
-- Klartext, nur dessen SHA-256-Hash (Timing-safe-Vergleich beim Login,
-- Referenzmuster siehe AUD-02).
CREATE TABLE module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE status_history;
DROP TABLE status_targets;
+15
View File
@@ -0,0 +1,15 @@
-- Zentrale Statusseite (OPS-02, siehe core-kanban/tickets/OPS-02.md) —
-- ueberwachte Module und der Verlauf ihrer Statusaenderungen.
CREATE TABLE status_targets (
name TEXT PRIMARY KEY,
health_url TEXT NOT NULL
);
CREATE TABLE status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX status_history_name_idx ON status_history (name, changed_at DESC);
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1
+45
View File
@@ -0,0 +1,45 @@
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
## Enthält
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
## Verwendung in einem Modul-Frontend
```tsx
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<Shell modules={[]} tenantLabel="Mandant XY">
{children}
</Shell>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
```
## Bekannter offener Punkt
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
## Tests
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
```bash
npm install
npm test
npm run typecheck
```
+38
View File
@@ -0,0 +1,38 @@
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Dialog } from "../components/Dialog";
import { I18nProvider } from "../i18n/i18n";
function renderDialog(onClose: () => void) {
return render(
<I18nProvider>
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
<button type="button">Erste Aktion</button>
<button type="button">Zweite Aktion</button>
</Dialog>
</I18nProvider>,
);
}
describe("Dialog: Tastaturbedienung", () => {
it("schließt sich bei ESC", () => {
const onClose = vi.fn();
renderDialog(onClose);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
renderDialog(vi.fn());
const closeButton = screen.getByRole("button", { name: /schließen/i });
expect(document.activeElement).toBe(closeButton);
});
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
renderDialog(vi.fn());
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
});
});
+39
View File
@@ -0,0 +1,39 @@
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
import { describe, expect, it } from "vitest";
import { colorTokens } from "../tokens/tokens";
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
function relLuminance(hex: string): number {
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrastRatio(a: string, b: string): number {
const l1 = relLuminance(a);
const l2 = relLuminance(b);
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
return (lighter + 0.05) / (darker + 0.05);
}
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
for (const scheme of ["light", "dark"] as const) {
const c = colorTokens[scheme];
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
});
}
});
+88
View File
@@ -0,0 +1,88 @@
"use client";
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface DialogProps {
open: boolean;
onClose: () => void;
titleId: string;
title: string;
children: ReactNode;
}
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const node = dialogRef.current;
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
focusables?.[0]?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab" || !node) return;
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="shl-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className="shl-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="shl-dialog-header">
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
×
</button>
</div>
<div className="shl-dialog-body">{children}</div>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
import { useId } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
interface FieldWrapperProps {
label: string;
error?: string;
hint?: string;
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
}
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
const inputId = useId();
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="shl-field">
<label htmlFor={inputId}>{label}</label>
{children({ inputId, describedBy })}
{hint && (
<p id={hintId} className="shl-field-hint">
{hint}
</p>
)}
{error && (
<p id={errorId} className="shl-field-error" role="alert">
{error}
</p>
)}
</div>
);
}
export interface TextFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
}
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...inputProps}
/>
)}
</FieldWrapper>
);
}
export interface SelectFieldProps
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
children: ReactNode;
}
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<select
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...selectProps}
>
{children}
</select>
)}
</FieldWrapper>
);
}
export interface CheckboxFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
label: string;
}
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
const inputId = useId();
return (
<div className="shl-field shl-field-checkbox">
<input id={inputId} type="checkbox" {...inputProps} />
<label htmlFor={inputId}>{label}</label>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
import { useTheme } from "../theme/ThemeProvider";
export interface ModuleLink {
key: string;
label: string;
href: string;
active?: boolean;
}
export interface ShellProps {
modules: ModuleLink[];
tenantLabel: string;
children: ReactNode;
}
export function Shell({ modules, tenantLabel, children }: ShellProps) {
const { scheme, toggle } = useTheme();
const { t } = useI18n();
return (
<div className="shl-shell">
<a className="shl-skip-link" href="#shl-main-content">
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
</a>
<header className="shl-shell-header">
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
<ul>
{modules.map((mod) => (
<li key={mod.key}>
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
{mod.label}
</a>
</li>
))}
</ul>
</nav>
<div className="shl-shell-context">
<span className="shl-tenant-context">{tenantLabel}</span>
<button
type="button"
onClick={toggle}
aria-label={
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
}
>
{scheme === "light" ? "🌙" : "☀️"}
</button>
</div>
</header>
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
{children}
</main>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export interface TableColumn<Row> {
key: string;
header: string;
render: (row: Row) => ReactNode;
sortable?: boolean;
}
export interface TableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
rowKey: (row: Row) => string;
sortKey?: string;
sortDirection?: "asc" | "desc";
onSort?: (key: string) => void;
caption?: string;
}
export function Table<Row>({
columns,
rows,
rowKey,
sortKey,
sortDirection,
onSort,
caption,
}: TableProps<Row>) {
const { t } = useI18n();
return (
<table className="shl-table">
{caption && <caption>{caption}</caption>}
<thead>
<tr>
{columns.map((column) => {
const isSorted = column.key === sortKey;
const ariaSort = column.sortable
? isSorted
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
: undefined;
return (
<th key={column.key} scope="col" aria-sort={ariaSort}>
{column.sortable ? (
<button
type="button"
onClick={() => onSort?.(column.key)}
className="shl-table-sort-button"
>
{column.header}
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export type ToastVariant = "info" | "success" | "danger" | "warning";
export interface ToastMessage {
id: string;
text: string;
variant: ToastVariant;
}
interface ToastContextValue {
toasts: ToastMessage[];
push: (text: string, variant?: ToastVariant) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const { t } = useI18n();
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback((text: string, variant: ToastVariant = "info") => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, text, variant }]);
}, []);
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
<span>{toast.text}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label={t("shl.toast.dismiss")}
>
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
}
return ctx;
}

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