CUT-02: Trockenlauf 2 - Generalprobe (synthetisch simuliert) #280

Merged
patrick merged 7 commits from feature/cut-02-trockenlauf-2-generalprobe into main 2026-10-15 01:12:02 +02:00
14 changed files with 1002 additions and 7 deletions

No files matched your search

+4
View File
@@ -169,3 +169,7 @@ func readJSON(path string, v any) error {
}
return json.Unmarshal(data, v)
}
func writeMarkdown(runDir, name, content string) error {
return os.WriteFile(filepath.Join(runDir, name), []byte(content), 0o644)
}
+59
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -16,6 +17,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcilereportmigration"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retention"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionmigration"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionverify"
)
const (
@@ -51,6 +53,8 @@ func Run(ctx context.Context, e *Env, stage string) (Result, error) {
err = e.forTenants(ctx, &res, e.mai04)
case "tar02":
err = e.forTenants(ctx, &res, e.tar02)
case "val03":
err = e.forTenants(ctx, &res, e.val03)
default:
err = fmt.Errorf("unbekannte archive-stufe %q", stage)
}
@@ -166,6 +170,61 @@ func (e *Env) tar01(ctx context.Context, t Tenant, res *Result) error {
return nil
}
// val03SampleSize: Produktionsvorgabe aus VAL-03 (50 Objekte je Tenant mit
// Zukunftsfrist, siehe archive/internal/retentionverify); die Testkopie hat
// je Tenant weniger Objekte mit Zukunftsfrist, val03 nimmt dann alle.
const val03SampleSize = 50
func (e *Env) val03(ctx context.Context, t Tenant, res *Result) error {
docs, err := e.handoffDocuments(t.Slug)
if err != nil {
return err
}
msgs, err := e.handoffMessages(t.Slug)
if err != nil {
return err
}
pool, err := e.Pool(ctx, t.ArchiveDB)
if err != nil {
return err
}
now := time.Now()
var samples []retentionverify.SourceSample
for _, dh := range docs {
if dh.Migrated && dh.RetainUntil != nil && dh.RetainUntil.After(now) {
samples = append(samples, retentionverify.SourceSample{ObjectType: objectTypeDocument, ObjectReference: dh.ObjectReference, RetainUntil: *dh.RetainUntil})
}
}
for _, mh := range msgs {
if mh.RetainUntil != nil && mh.RetainUntil.After(now) {
samples = append(samples, retentionverify.SourceSample{ObjectType: objectTypeMessage, ObjectReference: mh.ObjectReference, RetainUntil: *mh.RetainUntil})
}
}
if len(samples) > val03SampleSize {
samples = samples[:val03SampleSize]
}
results, err := retentionverify.CompareSamples(ctx, pool, t.Slug, samples)
if err != nil {
return err
}
if err := writeMarkdown(e.Ctx.RunDir, "val03-retention-"+t.Slug+".md", retentionverify.RenderBericht(results)); err != nil {
return err
}
offen := retentionverify.OffeneBefunde(results)
res.add("fristen_stichprobe", len(results))
res.add("offene_befunde", len(offen))
if len(offen) > 0 {
var lines []string
for _, r := range offen {
lines = append(lines, fmt.Sprintf("%s/%s", r.ObjectType, r.ObjectReference))
}
return fmt.Errorf("retention-stichprobe: %d offene befunde: %s", len(offen), strings.Join(lines, ", "))
}
return nil
}
type dsgvoSource struct {
legacy *pgxpool.Pool
tenantID int64
+84
View File
@@ -0,0 +1,84 @@
// cutover-generalprobe fuehrt CUT-02 aus: einen einzigen, vollautomatischen
// Lauf aller Migrationsschritte (gleicher Orchestrator wie CUT-01,
// cmd/cutover-dryrun) gegen eine groessere/vielfaeltigere synthetische
// Testkopie, mit allen Verifikationen VAL-01 bis VAL-05 sowie dem
// Volltextvergleich TXT-01. Die Generalprobe ist Ersatz fuer einen Lauf
// gegen eine reale Produktivkopie, der ausserhalb dieser Automatisierung
// liegt (siehe docs/CUT-02-PRUEFPROTOKOLL.md, Abschnitt Abweichung).
package main
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator"
)
func main() {
repoFlag := os.Getenv("NEXARCH_GENERALPROBE_REPO")
if repoFlag == "" {
repoFlag = "."
}
workRoot := os.Getenv("NEXARCH_GENERALPROBE_ARBEITSVERZEICHNIS")
if workRoot == "" {
workRoot = filepath.Join(os.TempDir(), "nexarch-cut02-generalprobe")
}
keep := os.Getenv("NEXARCH_GENERALPROBE_BEHALTEN") == "1"
adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if adminDSN == "" {
adminDSN = os.Getenv("TEST_ADMIN_DSN")
}
if adminDSN == "" {
fmt.Fprintln(os.Stderr, "NEXARCH_CUTOVER_ADMIN_DSN (oder TEST_ADMIN_DSN) nicht gesetzt")
os.Exit(2)
}
repoRoot, err := filepath.Abs(repoFlag)
if err != nil {
fmt.Fprintln(os.Stderr, "repo-pfad:", err)
os.Exit(2)
}
dr, err := cutoverorchestrator.NewDryrun(cutoverorchestrator.Config{
RepoRoot: repoRoot, WorkRoot: workRoot, Seed: 20261015, BcryptCost: 12, Generalprobe: true,
}, adminDSN)
if err != nil {
fmt.Fprintln(os.Stderr, "vorbereitung:", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
runner := cutoverorchestrator.Runner{Log: os.Stdout, Redactor: dr.Redactor}
fmt.Printf("CUT-02 Generalprobe %s, Arbeitsverzeichnis %s, ein einziger vollautomatischer Durchlauf\n", dr.RunTag, dr.RunDir)
rep := runner.Run(ctx, dr.RunTag, "Generalprobe", dr.Plan())
if err := os.MkdirAll(dr.RunDir, 0o700); err == nil {
if err := cutoverorchestrator.WriteJSON(filepath.Join(dr.RunDir, "generalprobe-protokoll.json"), rep); err != nil {
fmt.Fprintln(os.Stderr, dr.Redactor.Redact(err.Error()))
}
if err := os.WriteFile(filepath.Join(dr.RunDir, "generalprobe-protokoll.txt"), []byte(cutoverorchestrator.RenderText(rep)), 0o644); err != nil {
fmt.Fprintln(os.Stderr, dr.Redactor.Redact(err.Error()))
}
}
fmt.Print("\n" + cutoverorchestrator.RenderText(rep))
if keep {
dr.Close()
fmt.Printf("\nDatenbanken und Arbeitsdateien behalten (Laufkennung %s)\n", dr.RunTag)
} else {
dropped, err := dr.Cleanup(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, "aufraeumen:", dr.Redactor.Redact(err.Error()))
}
fmt.Printf("\nAufgeraeumt: %d Lauf-Datenbanken entfernt, Protokolle unter %s\n", len(dropped), dr.RunDir)
}
if !rep.Success {
os.Exit(1)
}
}
+272
View File
@@ -0,0 +1,272 @@
# CUT-02 – Prüfprotokoll: Trockenlauf 2 – Generalprobe
## Abweichung vom Tickettext (vom Nutzer vorab freigegeben)
**Diese Kachel simuliert die Generalprobe mit einer zweiten synthetischen
Testkopie. Der eigentliche Lauf gegen eine reale Produktivkopie ist ein
separater, späterer operativer Schritt, der nur von Menschen mit Zugriff
auf die echten Altsysteme und deren Freigabe durchgeführt werden kann —
NICHT Teil dieser Automatisierung.**
Der Tickettext verlangt wörtlich einen Lauf „gegen eine reale, aktuelle
Kopie beider Produktivdatenbanken“. Das ist in dieser Session nicht
erlaubt: echte archivmail-/archivdms-Produktivdaten dürfen niemals
angefasst werden (DSGVO-Leitplanke dieses Repositoriums, Host 131
Vorstufe-Produktion ist tabu). Umsetzung stattdessen: eine zweite,
eigenständige und realistischere synthetische Testkopie
(`GenerateGeneralprobeDataset`) — doppelt so viele Organisationen wie
CUT-01, mehr Randfälle, realistischere Datenmengen (siehe Abschnitt
„Testkopie“) —, gegen die derselbe, um VAL-03/VAL-04/TXT-01 erweiterte
Orchestrator in einem einzigen vollautomatischen Lauf alle in CUT-02
verlangten Prüfungen durchführt.
**Akzeptanzkriterium 3 („keine Verbindung zu echten Produktivsystemen“)
ist durch diese Umsetzung automatisch und per Konstruktion erfüllt:**
`GenerateGeneralprobeDataset(seed, now, bcryptCost)` hat — wie
`GenerateDataset` in CUT-01 — keinen Parameter, über den Daten aus einem
Altsystem, einem Export oder einer Datei einfließen könnten; die
Quelldatenbanken werden in jedem Lauf per `CREATE DATABASE` neu angelegt
und ausschließlich aus diesem Generator befüllt. Es existiert kein
Codepfad, der eine Verbindung zu archivmail/archivdms aufbaut. Synthetische
Daten haben keine Verbindung zu echten Systemen — der Nachweis
(`VerifyAnonymization`, Schritt PREP-03) läuft bei jedem Lauf real mit.
## Umfang
CUT-02 erweitert den bestehenden CUT-01-Orchestrator
(`internal/cutoverorchestrator`, Modulstufen
`{dms,mail,archive,workflow}/internal/cutoverstage`) — **nicht neu
erfunden, sondern erweitert**:
| Neu/geändert | Inhalt |
|---|---|
| `internal/cutoverorchestrator/dataset_generalprobe.go` | `GenerateGeneralprobeDataset`: dieselbe Generatorlogik wie CUT-01 (`dataset.go`, jetzt als `generateDataset(orgs, ...)` parametrisiert), über acht statt vier synthetische Organisationen |
| `internal/cutoverorchestrator/val04.go` | VAL-04 (`internal/migrationauditcontinuity`) direkt in den Ablaufplan eingebunden (lag in CUT-01 nur als Paket vor, war nicht verdrahtet) |
| `archive/internal/cutoverstage/stages.go` (`val03`) | VAL-03 (`archive/internal/retentionverify`) als neue Modulstufe `val03` (gleiches gilt: Paket existierte, war in CUT-01 nicht verdrahtet) |
| `internal/cutoverorchestrator/txtverify.go` | TXT-01: neuer Volltextvergleich-Schritt (Dokumenttitel, Nachrichtenbetreff/-volltext Zeichen für Zeichen, kein Hash) zusätzlich zu VAL-01 (Zeilenzahl) und VAL-02 (SHA-256-Hash) |
| `internal/cutoverorchestrator/dryrun.go` (`Plan()`) | VAL-03, VAL-04, TXT-01 in den gemeinsamen Ablaufplan eingehängt — gilt jetzt auch für CUT-01 (`cmd/cutover-dryrun`), da derselbe Orchestrator |
| `cmd/cutover-generalprobe` | neuer Einstiegspunkt für CUT-02: ein einziger `Runner.Run`-Aufruf, `Config.Generalprobe=true` |
| `internal/cutoverorchestrator/generalprobe_test.go`, `dataset_generalprobe_test.go`, `txtverify_test.go` | automatisierte, real gegen Postgres ausgeführte Tests je Akzeptanzkriterium |
**Warum VAL-03/VAL-04 vorher nicht eingebunden waren:** Beide Pakete
(`archive/internal/retentionverify`, `internal/migrationauditcontinuity`)
wurden in eigenen Tickets gebaut, aber nie in den Orchestrator verdrahtet
(`grep` über `internal/cutoverorchestrator` und alle `cutoverstage`-Pakete
vor dieser Kachel: keine Treffer). VAL-04 liegt im Root-Modul (Quelle
`legacy_audit_log_staging`, Ziel `audit_events`, beide in derselben
Registry-DB) und ist direkt verdrahtet. VAL-03 liegt im Archive-Modul
(eigenes Go-Modul, `internal`-Sichtbarkeit verbietet den Import aus dem
Root-Modul, gleiches Muster wie VAL-02/dms) und läuft deshalb als neue
Modulstufe `val03` im Archive-Stufen-Binary.
## Testkopie: größer und vielfältiger als CUT-01
`GenerateGeneralprobeDataset` nutzt dieselbe, unveränderte
Generator-Maschinerie aus CUT-01 (`archivmailTenant`/`archivdmsTenant` in
`dataset.go`, jetzt über `generateDataset(orgs []orgSpec, ...)`
parametrisiert) über acht statt vier `orgSpec`-Einträge
(`dataset_generalprobe.go`): die vier CUT-01-Grundmuster (beide Systeme
gleicher Slug, beide Systeme mit abweichendem Slug/manueller Zuordnung,
nur archivmail, nur archivdms) kommen je ein zweites Mal mit eigenen
IDs/Slugs/Domains vor, ohne die bestehenden, indexbasierten Querverweise
innerhalb der beiden Generatorfunktionen zu verändern (Risiko: hartkodierte
Bezüge wie `docs[6]`, `docs[7]` für Löschanfragen/Freigaben).
Mengen je Lauf (realer Testlauf, Seed `20261015`): 8 logische
Organisationen/NEXARCH-Mandanten (12 Altsystem-Mandanten, 6 archivmail +
6 archivdms), 60 Konten (30 je Altsystem), 48 archivdms-Dokumente,
36 archivmail-Nachrichten, 18 Anhänge, 96 Binärobjekte, 861 geprüfte
Tabellenzeilen (PREP-03) — durchgehend doppelt so viel wie CUT-01
(6 Mandanten, 30 Konten, 24 Dokumente, 18 Mails, 9 Anhänge,
48 Binärobjekte, 431 Zeilen). Automatisiert belegt:
`TestGenerateGeneralprobeDataset_IstGroesserUndVielfaeltigerAlsCUT01`
(mehr Tenants/Dokumente/Nachrichten, jedes der vier Grundmuster mindestens
zweimal vertreten).
## Ablaufplan (Auszug der Änderung gegenüber CUT-01)
Der gemeinsame Plan (`Dryrun.Plan()`) ist jetzt 34 statt 31 Schritte lang;
`ValidatePlan` prüft weiterhin vor dem ersten Datenbankzugriff, dass jede
Abhängigkeit vorher im Plan steht:
| # | Schritt | hängt ab von | neu in CUT-02 |
|---|---|---|---|
| 28 | VAL-04 Audit-Log-Kontinuitätsprüfung | COR-05 | ja (Verdrahtung) |
| 29 | VAL-03 Retention-Fristen-Stichprobenvergleich | TAR-01 | ja (Verdrahtung + neue Modulstufe) |
| 30 | VAL-01 Zeilenzahl-Abgleich | COR-02, DOK-01, MAI-02 | nein (aus CUT-01) |
| 31 | VAL-02 Hash-Vergleich | DOK-01, MAI-02 | nein (aus CUT-01) |
| 32 | TXT-01 Volltextvergleich | DOK-01, MAI-02, VAL-02 | ja (neuer Schritt) |
| 33 | VAL-05 Login-Test | COR-02 | nein (aus CUT-01) |
| 34 | CHK-01 Kettenprüfung | DOK-03, DOK-05, TAR-02, MAI-03, MAI-04, WFL-01 | nein (aus CUT-01) |
TXT-01 vergleicht lesbaren Volltext Zeichen für Zeichen (nicht über einen
Hash): Dokumenttitel (immer Klartext in der Zieldatenbank, auch wenn der
Dateiinhalt verschlüsselt liegt) gegen `archivdms.documents.title`, sowie
Nachrichtenbetreff und entschlüsselter Nachrichtenvolltext aus dem
dateibasierten Suchindex-Ersatz von MAI-02 (`ziel-objekte/mail-index/`,
vertritt die einzige, mit archivdms geteilte Manticore-Instanz, siehe
CUT-01-Befund B-6) gegen `archivmail.emails.subject` und den rohen
Nachrichteninhalt.
## Generalprobe-Protokoll: Lauf ohne manuellen Eingriff (Akzeptanzkriterium 1 und 2)
Realer Lauf, Host `root@192.168.1.204`, Wegwerf-Cluster `coredev`
(PostgreSQL, Port 5433, `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`,
nie im Klartext ausgegeben), Testfunktion
`TestGeneralprobe_VollstaendigerLaufOhneManuellenEingriff`, **ein einziger
`Runner.Run`-Aufruf, keine Zwischenstopps, keine Korrekturrunde** (anders
als CUT-01 mit zwei iterativen Läufen) — die Fehler, die CUT-01 fand, sind
bereits behoben und bestätigt, die Generalprobe bestätigt den
fehlerfreien Durchlauf ein weiteres Mal gegen die größere Testkopie.
Gesamtdauer: 36,22 s. Alle 34 Schritte `erfolgreich`:
| # | Schritt | Dauer | Zähler (Auszug) |
|---|---|---|---|
| 1 | PREP-01 Arbeitsverzeichnis, Modulstufen bauen | 8502 ms | modulstufen=4 |
| 2 | PREP-02 Testkopie erzeugen | 587 ms | archivdms_tenants=6 archivmail_tenants=6 archivdms_documents=48 archivmail_emails=36 binaerobjekte=96 |
| 3 | PREP-03 Anonymisierungsnachweis | 31 ms | tabellen=30 zeilen=861 verstoesse=0 |
| 4 | PREP-04 Core-Registry | 541 ms | core_migrationen=43 |
| 5 | EXP-01 Export-Sichten | 19 ms | export_sichten=4 |
| 6 | MAP-01 Tenant-Export/Abgleich | 16 ms | kandidaten=8 konflikte=0 manuelle_zuordnungen=2 |
| 7 | MAP-02 Tenant-Map-Freigabe | 133 ms | eintraege_angelegt=8 freigegeben=8 |
| 8 | MAP-03 User-Export/Abgleich | 2 ms | kandidaten=48 zusammengefuehrt=12 |
| 9 | MAP-04\* Benutzer-/Rollen-Map | 475 ms | benutzer_freigegeben=48 mit_ldap_bezug=12 |
| 10 | COR-01 Tenant-Migration | 924 ms | tenants_migriert=8 |
| 11 | SCHEMA-01 Core-Tenant-Schema | 2382 ms | tenants=8 migrationen=168 |
| 12 | SCHEMA-02 Modul-Schemata | 8169 ms | dms=296 archive=144 workflow=144 |
| 13 | COR-02 Benutzer-Migration | 319 ms | benutzer_migriert=48 |
| 14 | COR-04 LDAP-Feld-Prüfung | 48 ms | benutzer_mit_ldap_bezug_geprueft=12 |
| 15 | COR-03 Rollen-/Rechte-Migration | 347 ms | rollen_zugewiesen=48 |
| 16 | COR-05 Audit-Log-Zusammenführung | 557 ms | audit_ereignisse_migriert=48 staging_zeilen=48 |
| 17 | DOK-01 Dokument-/Revisions-Migration | 860 ms | dokumente=42 revisionen=42 |
| 18 | DOK-02 Akten-/Ordnerstruktur | 332 ms | akten=18 dokumente_verknuepft=30 |
| 19 | DOK-04 Custom-Fields/Vorlagen | 861 ms | felder=30 vorlagen=18 |
| 20 | DOK-03 ACL-Migration | 779 ms | objektrechte=96 |
| 21 | DOK-05 Freigabelinks | 154 ms | freigabelinks=12 |
| 22 | MAI-02 Nachrichten-/Anhang-Migration | 50 ms | nachrichten=37 anhaenge=18 |
| 23 | TAR-01 Retention-Fristen-Migration | 831 ms | objekte_registriert=79 fristen_uebernommen=54 |
| 24 | MAI-03 DSGVO-Löschanfragen | 219 ms | einzelentscheidungen=18 |
| 25 | MAI-04 Reconciliation-Reports | 155 ms | berichte=18 |
| 26 | TAR-02 Vier-Augen-Löschworkflow | 246 ms | offene_loeschanfragen=6 vollzogene_loeschungen_audit=6 |
| 27 | WFL-01 Workflow-Historie | 434 ms | vorgaenge_migriert=18 |
| 28 | **VAL-04 Audit-Kontinuität** | 13 ms | tenants=8, **0 offene Befunde** |
| 29 | **VAL-03 Retention-Stichprobe** | 89 ms | fristen_stichprobe=42, **0 offene Befunde** |
| 30 | VAL-01 Zeilenzahl-Abgleich | 55 ms | 24 Vergleiche, quelle_documents=48/ziel=42 (6 erklärt: vollzogene Löschungen, nur Audit) |
| 31 | VAL-02 Hash-Vergleich | 74 ms | dms_dateien=30 mail_anhaenge=18, **0 Abweichungen** |
| 32 | **TXT-01 Volltextvergleich** | 24 ms | dokumenttitel_geprueft=42 nachrichtentext_geprueft=74, **0 Abweichungen** |
| 33 | VAL-05 Login-Test | 66 ms | logins_erfolgreich=24 falsche_passwoerter_abgewiesen=24 |
| 34 | CHK-01 Kettenprüfung | 181 ms | **0 Befunde** |
Nach dem Lauf: `d.Cleanup` entfernt alle Lauf-Datenbanken (per
Laufkennung), `pg_database` enthält danach keine `cut01%`-Datenbank mehr
(geprüft).
## Prüfung vor Abnahme 2: eingebauter Testfehler wird erkannt
Eigener Testlauf (`TestGeneralprobe_EingebauterRetentionsfehlerWirdErkannt`,
**nicht** Teil des fehlerfreien Generalprobe-Laufs oben): derselbe Plan,
unmittelbar vor VAL-03 ein zusätzlicher Schritt `FEHLERTEST-01`
eingefügt, der nach TAR-01 bewusst eine migrierte Retention-Frist um 48h
verschiebt (`UPDATE retention_objects SET retain_until = retain_until +
interval '48 hours' ...`).
Ergebnis (reale Postgres-Ausführung, 38,20 s bis zum Abbruch):
```
[FEHLERTEST-01] erfolgreich (12 ms)
[VAL-03] FEHLGESCHLAGEN nach 11 ms: modulstufe archive/val03: exit status 1:
fehler: mandant cut01f558d3_kanzlei_musterhausen: retention-stichprobe:
1 offene befunde: mail_message/mig-741e7cd1c5fe9f4c57ffc866c0ddbeb9
```
VAL-03 erkennt die verschobene Frist, meldet sie als offenen Befund
(`retentionverify.SampleResult.OffenerBefund()`) und bricht den Lauf ab —
genau wie bei einem echten Migrationsfehler. Bei einem Wiederholungslauf
(zweite Testausführung) fiel die zufällig gewählte Stichprobe auf ein
Dokument statt eine Nachricht (`dms_document/...`) — die Erkennung ist
nicht von der Objektart abhängig.
## Akzeptanzkriterien
| # | Kriterium (angepasst, siehe Abweichung) | Ergebnis |
|---|---|---|
| 1 | Vollständiger Lauf ohne manuellen Eingriff | **erfüllt** – ein `Runner.Run`-Aufruf, 34/34 Schritte erfolgreich, 36,22 s |
| 2 | VAL-01 bis VAL-05 erfolgreich durchgeführt | **erfüllt** – alle fünf plus TXT-01, 0 offene Befunde |
| 3 | Keine Verbindung zu echten Produktivsystemen | **erfüllt per Konstruktion** – `GenerateGeneralprobeDataset` hat keinen Fremddaten-Parameter, Nachweis PREP-03 (0 Verstöße), siehe Abweichungsabschnitt |
## Prüfungen vor Abnahme
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Generalprobe-Protokoll mit Zeitstempel/Dauer/Ergebnis je Schritt | **erfüllt** – `generalprobe-protokoll.json`/`.txt` (Laufausgabe), Tabelle oben, `StepRecord.StartedAt`/`DurationMS` je Schritt |
| 2 | Mindestens ein eingebauter Testfehler wird erkannt | **erfüllt** – `TestGeneralprobe_EingebauterRetentionsfehlerWirdErkannt`, Abbruch bei VAL-03 mit benanntem Objekt |
## Tests (real ausgeführt, Host 204, Cluster `coredev`)
```
set -a && . /etc/nexarch-dev/core-dev.env && set +a
go test ./internal/cutoverorchestrator/... -v -timeout 9m
```
| Test | Ergebnis |
|---|---|
| `TestGeneralprobe_VollstaendigerLaufOhneManuellenEingriff` | PASS (36,22 s) – AC1, AC2 |
| `TestGeneralprobe_EingebauterRetentionsfehlerWirdErkannt` | PASS (38,20 s) – Prüfung vor Abnahme 2 |
| `TestGenerateGeneralprobeDataset_ContainsOnlySyntheticPersonalData` | PASS – AC3 |
| `TestGenerateGeneralprobeDataset_IstGroesserUndVielfaeltigerAlsCUT01` | PASS – Testkopie-Umfang |
| `TestFirstDiff`, `TestRenderTxtBericht_MarkiertAbweichungen` | PASS – TXT-01-Hilfsfunktionen |
| alle bestehenden CUT-01-Tests (`TestRunner_*`, `TestDryrunPlan_RespectsMigrationDependencies`, `TestGenerateDataset_*`, `TestAnonymizationCheck_*`, `TestTargetSlug_*`, `TestClusterTemplateAndDSN`, `TestRedactor`) | PASS – keine Regression |
Gesamtpaketlauf `go test ./internal/cutoverorchestrator/... -v`: **PASS**,
64,4 s. Regression `go build ./...`, `go vet ./...` im Root-Modul sowie
`go build ./...` in `dms`, `mail`, `archive`, `workflow`: sauber.
`go test ./... -p 1` im Archive-Modul (inkl. `retentionverify`,
`retentionmigration`): **PASS**, keine Regression durch die neue
Modulstufe `val03`.
`go test ./... -p 1` im Root-Modul: zwei vorbestehende, von dieser Kachel
unabhängige Fehlschläge außerhalb des CUT-02-Diffs (kein Treffer in
`git status` für diese Pakete): `internal/archtest`
(`TestAllHTTPServersUseHTTPHarden`, zwei a11y-Test-Fixtures unter
`ai/cmd/` nutzen rohes `http.ListenAndServe`) und `internal/tenant`
(mehrere Tests scheitern mit „slug bereits vergeben“ — Altlast auf dem
geteilten `TEST_TENANT_DSN`-Cluster, vermutlich ein vorheriger,
nicht aufgeräumter Testlauf; CUT-01-Protokoll dokumentiert bereits ein
verwandtes Aufräum-Problem bei `internal/migrationtenant`). Beides liegt
außerhalb des CUT-02-Tickets und wurde nicht angefasst.
## Sicherheit
Keine Zugangsdaten im Code; `NEXARCH_CUTOVER_ADMIN_DSN`/`TEST_ADMIN_DSN`
ausschließlich über Umgebungsvariablen, Redigierung (`Redactor`) wie in
CUT-01 unverändert aktiv in Runner-Log, Protokoll und Fehlermeldungen.
Keine DSN-haltige Fehlermeldung ist in dieses Protokoll gelangt (geprüft).
## Offene Punkte (Übergabe, nicht Teil von CUT-02)
- B-5 aus CUT-01 (MAI-01/Postfach-Migration mit Test-IMAP) bleibt offen,
CUT-02 erweitert nur den bestehenden Orchestrator-Lauf, kein neuer
IMAP-Testserver im Rahmen dieser Kachel.
- Produktiv-Adapter für `retentionverify.SampleSource` (Folgepunkt aus
VAL-03) wird mit dieser Kachel erstmals konkret implementiert
(Handoff-Dateien aus DOK-01/TAR-01), aber weiterhin gegen die
synthetische Testkopie, nicht gegen einen echten archivdms/archivmail-
Export.
- Der reale Generalprobe-Lauf gegen eine Kopie der echten Produktivdaten
(Tickettext wörtlich) ist der separate, spätere operative Schritt aus
dem Abweichungsabschnitt oben.
## Bedienung
```bash
set -a && . /etc/nexarch-dev/core-dev.env && set +a # liefert TEST_ADMIN_DSN, nie ausgeben
go run ./cmd/cutover-generalprobe
```
Umgebungsvariablen (alle optional): `NEXARCH_GENERALPROBE_REPO`
(Repository-Wurzel, Default `.`), `NEXARCH_GENERALPROBE_ARBEITSVERZEICHNIS`,
`NEXARCH_GENERALPROBE_BEHALTEN=1` (Datenbanken/Arbeitsdateien nach dem Lauf
behalten). Ergebnis: `generalprobe-protokoll.json`/`.txt` sowie
VAL-01/-02/-03/-04-Berichte und `txt01-volltextvergleich.md` im
Laufverzeichnis, Exitcode 0 nur bei vollständigem, fehlerfreiem Durchlauf.
+5 -1
View File
@@ -354,6 +354,10 @@ func strPtr(v string) *string { return &v }
// diesem Code: es gibt keinen Parameter, ueber den Daten aus einem
// Altsystem, einem Export oder einer Datei einfliessen koennten.
func GenerateDataset(seed int64, now time.Time, bcryptCost int) (*Dataset, error) {
return generateDataset(syntheticOrganisations, seed, now, bcryptCost)
}
func generateDataset(orgs []orgSpec, seed int64, now time.Time, bcryptCost int) (*Dataset, error) {
g := &generator{
rnd: rand.New(rand.NewSource(seed)),
now: now.UTC().Truncate(time.Second),
@@ -365,7 +369,7 @@ func GenerateDataset(seed int64, now time.Time, bcryptCost int) (*Dataset, error
var manual strings.Builder
manual.WriteString("archivmail_slug,archivdms_slug,match,note\n")
for _, org := range syntheticOrganisations {
for _, org := range orgs {
if org.archivmailID != 0 {
if err := g.archivmailTenant(org); err != nil {
return nil, err
@@ -0,0 +1,34 @@
package cutoverorchestrator
import "time"
// generalprobeOrganisations erweitert syntheticOrganisations (CUT-01) um
// vier weitere Mandanten fuer CUT-02: jedes der vier Grundmuster aus
// syntheticOrganisations (beide Systeme gleicher Slug, beide Systeme
// unterschiedlicher Slug mit manueller Zuordnung, nur archivmail, nur
// archivdms) kommt ein zweites Mal mit eigenen IDs/Slugs/Domains vor -
// insgesamt eine groessere und vielfaeltigere Testkopie als CUT-01, ohne
// die bestehenden, indexbasierten Quervervweise innerhalb von
// archivmailTenant/archivdmsTenant zu veraendern.
var generalprobeExtraOrganisations = []orgSpec{
{key: "grosskanzlei", name: "Grosskanzlei Vielfalt", mailDomain: "grosskanzlei-vielfalt.example",
archivmailID: 104, archivmailSlug: "grosskanzlei-vielfalt", archivdmsID: 10, archivdmsSlug: "grosskanzlei-vielfalt"},
{key: "altkunde", name: "Altkunde Sonderfall", mailDomain: "altkunde-sonderfall.example",
extraDomains: []string{"altkunde-sonderfall-zweigstelle.example"},
archivmailID: 105, archivmailSlug: "altkunde-sonderfall", archivdmsID: 11, archivdmsSlug: "altkunde-sonderfall-dms"},
{key: "einzelpraxis", name: "Einzelpraxis Randfall", mailDomain: "einzelpraxis-randfall.example",
archivmailID: 106, archivmailSlug: "einzelpraxis-randfall"},
{key: "verwaltung", name: "Verwaltungsbuero Nebenfall", mailDomain: "verwaltungsbuero-nebenfall.example",
archivdmsID: 12, archivdmsSlug: "verwaltungsbuero-nebenfall"},
}
var generalprobeOrganisations = append(append([]orgSpec{}, syntheticOrganisations...), generalprobeExtraOrganisations...)
// GenerateGeneralprobeDataset erzeugt die CUT-02-Testkopie: dieselbe
// Generatorlogik wie GenerateDataset (dataset.go), aber ueber doppelt so
// viele synthetische Mandanten (acht statt vier Organisationen) - mehr
// Tenants, mehr Kombinationen aus Slug-Treffer/manueller Zuordnung/
// einseitiger Systemnutzung, kein Parameter fuer Fremddaten.
func GenerateGeneralprobeDataset(seed int64, now time.Time, bcryptCost int) (*Dataset, error) {
return generateDataset(generalprobeOrganisations, seed, now, bcryptCost)
}
@@ -0,0 +1,106 @@
package cutoverorchestrator
import (
"bytes"
"strings"
"testing"
"time"
)
func testGeneralprobeDataset(t *testing.T) *Dataset {
t.Helper()
ds, err := GenerateGeneralprobeDataset(7, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC), 4)
if err != nil {
t.Fatal(err)
}
return ds
}
// Prüfung vor Abnahme (Akzeptanzkriterium 3, Abweichung siehe
// docs/CUT-02-PRUEFPROTOKOLL.md): die Generalprobe-Testkopie ist eine
// zweite, groessere/vielfaeltigere synthetische Testkopie - kein Zugriff auf
// echte Produktivsysteme ist durch Konstruktion ausgeschlossen, derselbe
// Nachweis wie in CUT-01 (dataset_test.go).
func TestGenerateGeneralprobeDataset_ContainsOnlySyntheticPersonalData(t *testing.T) {
ds := testGeneralprobeDataset(t)
var rep AnonymizationReport
for _, u := range ds.Archivmail.Users {
rep.checkText("archivmail.users", u.Email+" "+u.Username)
if !strings.Contains(u.Username, "testperson") {
t.Errorf("archivmail-benutzer %q ohne synthetisches praefix", u.Username)
}
}
for _, u := range ds.Archivdms.Users {
rep.checkText("archivdms.users", u.Email+" "+u.Username)
if !strings.Contains(strings.ToLower(u.Email), "testperson") {
t.Errorf("archivdms-benutzer %q ohne synthetisches praefix", u.Email)
}
}
for _, e := range ds.Archivmail.Emails {
rep.checkText("emails", e.From+" "+e.To+" "+e.Subject)
}
for _, o := range ds.Archivmail.StorageObjects {
if !bytes.HasPrefix(o.Content, []byte(SyntheticMarker)) {
t.Errorf("mail-objekt %s ohne synthetik-marker", o.Path)
}
}
for _, d := range ds.Archivdms.Documents {
if !bytes.HasPrefix(d.Content, []byte(SyntheticMarker)) {
t.Errorf("dokument %d ohne synthetik-marker", d.ID)
}
}
if len(rep.Violations) > 0 {
t.Fatalf("nicht-synthetische angaben: %v", rep.Violations)
}
}
// TestGenerateGeneralprobeDataset_IstGroesserUndVielfaeltigerAlsCUT01 belegt
// die geforderte Erweiterung ("groessere/vielfaeltigere synthetische
// Testkopie"): mehr Tenants, mehr Dokumente/Nachrichten als CUT-01s
// GenerateDataset auf demselben Seed.
func TestGenerateGeneralprobeDataset_IstGroesserUndVielfaeltigerAlsCUT01(t *testing.T) {
cut01, err := GenerateDataset(7, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC), 4)
if err != nil {
t.Fatal(err)
}
generalprobe := testGeneralprobeDataset(t)
cut01Tenants := len(cut01.Archivmail.Tenants) + len(cut01.Archivdms.Tenants)
gpTenants := len(generalprobe.Archivmail.Tenants) + len(generalprobe.Archivdms.Tenants)
if gpTenants <= cut01Tenants {
t.Fatalf("generalprobe muss mehr mandanten haben: cut01=%d generalprobe=%d", cut01Tenants, gpTenants)
}
if len(generalprobe.Archivdms.Documents) <= len(cut01.Archivdms.Documents) {
t.Fatalf("generalprobe muss mehr dokumente haben: cut01=%d generalprobe=%d", len(cut01.Archivdms.Documents), len(generalprobe.Archivdms.Documents))
}
if len(generalprobe.Archivmail.Emails) <= len(cut01.Archivmail.Emails) {
t.Fatalf("generalprobe muss mehr nachrichten haben: cut01=%d generalprobe=%d", len(cut01.Archivmail.Emails), len(generalprobe.Archivmail.Emails))
}
onlyMail, onlyDMS, both := 0, 0, 0
amSlugs, dmsSlugs := map[string]bool{}, map[string]bool{}
for _, tn := range generalprobe.Archivmail.Tenants {
amSlugs[tn.Slug] = true
}
for _, tn := range generalprobe.Archivdms.Tenants {
dmsSlugs[tn.Slug] = true
}
for s := range amSlugs {
if dmsSlugs[s] {
both++
} else {
onlyMail++
}
}
for s := range dmsSlugs {
if !amSlugs[s] {
onlyDMS++
}
}
if both < 2 || onlyMail < 2 || onlyDMS < 2 {
t.Fatalf("generalprobe muss jedes grundmuster mehrfach abdecken: both=%d onlyMail=%d onlyDMS=%d", both, onlyMail, onlyDMS)
}
if !strings.Contains(generalprobe.ManualDecisionsCSV, "true") {
t.Fatal("generalprobe muss weiterhin manuelle kundenzuordnungen enthalten")
}
}
+8 -4
View File
@@ -17,10 +17,11 @@ import (
)
type Config struct {
RepoRoot string
WorkRoot string
Seed int64
BcryptCost int
RepoRoot string
WorkRoot string
Seed int64
BcryptCost int
Generalprobe bool
}
type Dryrun struct {
@@ -178,8 +179,11 @@ func (d *Dryrun) Plan() []Step {
{ID: "MAI-04", Title: "Reconciliation-Reports-Archivierung", DependsOn: []string{"SCHEMA-02"}, Run: d.stage("archive", "mai04")},
{ID: "TAR-02", Title: "Vier-Augen-Loeschworkflow-Migration", DependsOn: []string{"TAR-01", "COR-02"}, Run: d.stage("archive", "tar02")},
{ID: "WFL-01", Title: "Workflow-Historie-Migration", DependsOn: []string{"COR-02", "SCHEMA-02"}, Run: d.stage("workflow", "wfl01")},
{ID: "VAL-04", Title: "Audit-Log-Kontinuitaetspruefung", DependsOn: []string{"COR-05"}, Run: d.val04},
{ID: "VAL-03", Title: "Retention-Fristen-Stichprobenvergleich", DependsOn: []string{"TAR-01"}, Run: d.stage("archive", "val03")},
{ID: "VAL-01", Title: "Zeilenzahl-Abgleich je Kerntabelle", DependsOn: []string{"COR-02", "DOK-01", "MAI-02"}, Run: d.val01},
{ID: "VAL-02", Title: "Hash-Vergleich migrierter Binaerdateien", DependsOn: []string{"DOK-01", "MAI-02"}, Run: d.val02},
{ID: "TXT-01", Title: "Volltextvergleich migrierter Dokumenttitel und Nachrichten", DependsOn: []string{"DOK-01", "MAI-02", "VAL-02"}, Run: d.txt01},
{ID: "VAL-05", Title: "Login-Test mit migrierten Zugangsdaten", DependsOn: []string{"COR-02"}, Run: d.val05},
{ID: "CHK-01", Title: "Modulgrenzen-uebergreifende Kettenpruefung", DependsOn: []string{"DOK-03", "DOK-05", "TAR-02", "MAI-03", "MAI-04", "WFL-01"}, Run: d.chk01},
}
@@ -0,0 +1,137 @@
package cutoverorchestrator
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func requireGeneralprobeAdminDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if dsn == "" {
dsn = os.Getenv("TEST_ADMIN_DSN")
}
if dsn == "" {
t.Skip("NEXARCH_CUTOVER_ADMIN_DSN/TEST_ADMIN_DSN nicht gesetzt, generalprobe-integrationstest uebersprungen")
}
return dsn
}
func newGeneralprobeDryrun(t *testing.T, dsn string) *Dryrun {
t.Helper()
d, err := NewDryrun(Config{
RepoRoot: "../..", WorkRoot: t.TempDir(), Seed: 20261015, BcryptCost: 4, Generalprobe: true,
}, dsn)
if err != nil {
t.Fatalf("vorbereitung: %v", err)
}
t.Cleanup(func() {
if _, err := d.Cleanup(context.Background()); err != nil {
t.Logf("aufraeumen: %v", err)
}
})
return d
}
// TestGeneralprobe_VollstaendigerLaufOhneManuellenEingriff ist die
// Generalprobe selbst: ein einziger Runner.Run-Aufruf (kein Zwischenstopp,
// keine Korrekturrunde wie bei CUT-01) gegen die groessere synthetische
// Testkopie, alle Schritte inkl. VAL-01 bis VAL-05 und TXT-01 erfolgreich
// (Akzeptanzkriterium 1 und 2).
func TestGeneralprobe_VollstaendigerLaufOhneManuellenEingriff(t *testing.T) {
dsn := requireGeneralprobeAdminDSN(t)
d := newGeneralprobeDryrun(t, dsn)
rep := Runner{Log: os.Stdout, Redactor: d.Redactor}.Run(context.Background(), d.RunTag, "Generalprobe-Test", d.Plan())
if err := os.MkdirAll(d.RunDir, 0o700); err == nil {
if err := WriteJSON(filepath.Join(d.RunDir, "generalprobe-test-protokoll.json"), rep); err != nil {
t.Logf("protokoll schreiben: %v", err)
}
}
if !rep.Success {
t.Fatalf("generalprobe muss ohne abbruch durchlaufen, abbruch bei %q: %s", rep.AbortedAt, rep.PlanError)
}
required := []string{"VAL-01", "VAL-02", "VAL-03", "VAL-04", "VAL-05", "TXT-01", "CHK-01"}
seen := map[string]Status{}
for _, s := range rep.Steps {
seen[s.ID] = s.Status
}
for _, id := range required {
if seen[id] != StatusSucceeded {
t.Errorf("verifikationsschritt %s muss erfolgreich sein, status=%s", id, seen[id])
}
}
}
// TestGeneralprobe_EingebauterRetentionsfehlerWirdErkannt ist die in CUT-02
// geforderte Pruefung vor Abnahme 2: ein bewusst eingebauter Testfehler
// (verschobene Retention-Frist nach TAR-01) muss von VAL-03 erkannt und zum
// Abbruch fuehren - dieser Lauf ist bewusst nicht der Generalprobe-Lauf
// selbst (der muss fehlerfrei durchlaufen, siehe Test oben), sondern ein
// eigener Nachweis der Fehlererkennung.
func TestGeneralprobe_EingebauterRetentionsfehlerWirdErkannt(t *testing.T) {
dsn := requireGeneralprobeAdminDSN(t)
d := newGeneralprobeDryrun(t, dsn)
plan := d.Plan()
idx := -1
for i, s := range plan {
if s.ID == "VAL-03" {
idx = i
break
}
}
if idx < 0 {
t.Fatal("VAL-03 fehlt im ablaufplan")
}
fault := Step{
ID: "FEHLERTEST-01", Title: "Bewusst eingebauter Testfehler: eine Retention-Frist um 48h verschieben",
DependsOn: []string{"TAR-01"}, Run: d.injectShiftedRetentionDate,
}
plan = append(plan[:idx], append([]Step{fault}, plan[idx:]...)...)
rep := Runner{Log: os.Stdout, Redactor: d.Redactor}.Run(context.Background(), d.RunTag, "Generalprobe-Fehlertest", plan)
if rep.Success {
t.Fatal("lauf mit eingebautem fehler darf nicht erfolgreich sein")
}
if rep.AbortedAt != "VAL-03" {
t.Fatalf("erwarte abbruch bei VAL-03, habe %q", rep.AbortedAt)
}
var val03 StepRecord
for _, s := range rep.Steps {
if s.ID == "VAL-03" {
val03 = s
}
}
if !strings.Contains(val03.Error, "offene befunde") {
t.Fatalf("val-03-fehlermeldung muss den offenen befund benennen: %q", val03.Error)
}
}
// injectShiftedRetentionDate verschiebt in der Archiv-Datenbank des ersten
// Mandanten mit mindestens einem Retention-Objekt mit Frist die Frist um
// 48 Stunden - der bewusst eingebaute Testfehler fuer die Pruefung oben.
func (d *Dryrun) injectShiftedRetentionDate(ctx context.Context) (Outcome, error) {
for _, t := range d.tenants {
pool, err := d.tenantPool(ctx, t.ArchiveDB)
if err != nil {
return Outcome{}, err
}
tag, err := pool.Exec(ctx, `
UPDATE retention_objects SET retain_until = retain_until + interval '48 hours'
WHERE id = (SELECT id FROM retention_objects WHERE retain_until > now() ORDER BY id LIMIT 1)
`)
if err != nil {
return Outcome{}, err
}
if tag.RowsAffected() > 0 {
return Outcome{Notes: []string{fmt.Sprintf("tenant %s: eine retention-frist um 48h verschoben", t.Slug)}}, nil
}
}
return Outcome{}, fmt.Errorf("kein mandant mit retention-frist gefunden, testfehler nicht einbaubar")
}
+1 -1
View File
@@ -37,7 +37,7 @@ func WriteJSON(path string, rep Report) error {
func RenderText(rep Report) string {
var b strings.Builder
fmt.Fprintf(&b, "CUT-01 Trockenlauf-Protokoll\n")
fmt.Fprintf(&b, "NEXARCH Migrations-Trockenlauf-Protokoll\n")
fmt.Fprintf(&b, "Lauf: %s (%s)\n", rep.RunTag, rep.Label)
fmt.Fprintf(&b, "Beginn: %s Ende: %s Dauer: %s\n",
rep.StartedAt.Format("2006-01-02 15:04:05 MST"), rep.FinishedAt.Format("2006-01-02 15:04:05 MST"),
+8 -1
View File
@@ -26,7 +26,11 @@ func (d *Dryrun) prepWorkdir(ctx context.Context) (Outcome, error) {
}
func (d *Dryrun) prepLegacyCopy(ctx context.Context) (Outcome, error) {
ds, err := GenerateDataset(d.cfg.Seed, d.now, d.cfg.BcryptCost)
gen := GenerateDataset
if d.cfg.Generalprobe {
gen = GenerateGeneralprobeDataset
}
ds, err := gen(d.cfg.Seed, d.now, d.cfg.BcryptCost)
if err != nil {
return Outcome{}, err
}
@@ -67,6 +71,9 @@ func (d *Dryrun) prepLegacyCopy(ctx context.Context) (Outcome, error) {
return out, err
}
out.Notes = append(out.Notes, fmt.Sprintf("generator-seed %d, quelle ausschliesslich internal/cutoverorchestrator/dataset.go", d.cfg.Seed))
if d.cfg.Generalprobe {
out.Notes = append(out.Notes, "Generalprobe-Testkopie (GenerateGeneralprobeDataset): rein synthetisch, keine Verbindung zu archivmail/archivdms moeglich")
}
return out, nil
}
+207
View File
@@ -0,0 +1,207 @@
package cutoverorchestrator
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// TXT-01 vergleicht lesbaren Volltext zwischen Quelle und Ziel - zusaetzlich
// zu VAL-01 (Zeilenzahl) und VAL-02 (SHA-256-Hash des verschluesselten
// Binaerobjekts): Dokumenttitel (DMS, immer Klartext in der Zieldatenbank)
// sowie Betreff und entschluesselter Volltext migrierter Nachrichten
// (dateibasierter Suchindex-Ersatz aus MAI-02, siehe B-6 im CUT-01-Protokoll)
// werden Zeichen fuer Zeichen verglichen, nicht ueber einen Hash.
type txtFinding struct {
tenant, kind, ref, detail string
}
func (d *Dryrun) txt01(ctx context.Context) (Outcome, error) {
out := Outcome{Counts: map[string]int{}}
var findings []txtFinding
var rows []string
for _, t := range d.tenants {
if t.ArchivdmsTenantID != nil {
fs, rs, err := d.txt01Documents(ctx, t)
if err != nil {
return out, err
}
findings = append(findings, fs...)
rows = append(rows, rs...)
out.Counts["dokumenttitel_geprueft"] += len(rs)
}
if t.ArchivmailTenantID != nil {
fs, rs, err := d.txt01Messages(ctx, t)
if err != nil {
return out, err
}
findings = append(findings, fs...)
rows = append(rows, rs...)
out.Counts["nachrichtentext_geprueft"] += len(rs)
}
}
if err := os.WriteFile(filepath.Join(d.RunDir, "txt01-volltextvergleich.md"), []byte(renderTxtBericht(rows)), 0o644); err != nil {
return out, err
}
out.Counts["abweichungen"] = len(findings)
if len(findings) > 0 {
var lines []string
for _, f := range findings {
lines = append(lines, fmt.Sprintf("%s %s %s: %s", f.tenant, f.kind, f.ref, f.detail))
}
return out, fmt.Errorf("volltextvergleich: %d abweichungen: %s", len(findings), strings.Join(lines, "; "))
}
return out, nil
}
type dmsHandoffEntry struct {
SourceDocumentID string `json:"source_document_id"`
ObjectReference string `json:"object_reference"`
Migrated bool `json:"migrated"`
}
func (d *Dryrun) txt01Documents(ctx context.Context, t ContextTenant) ([]txtFinding, []string, error) {
var handoff []dmsHandoffEntry
if err := readJSONFile(filepath.Join(d.RunDir, "uebergabe", t.Slug, "dms_objekte.json"), &handoff); err != nil {
return nil, nil, fmt.Errorf("dms-uebergabe %s: %w", t.Slug, err)
}
tenantDB, err := d.tenantPool(ctx, t.TenantDB)
if err != nil {
return nil, nil, err
}
var findings []txtFinding
var rows []string
for _, h := range handoff {
if !h.Migrated {
continue
}
var legacyTitle string
if err := d.archivdms.QueryRow(ctx, `SELECT title FROM documents WHERE id::text = $1`, h.SourceDocumentID).Scan(&legacyTitle); err != nil {
return nil, nil, fmt.Errorf("dokument %s quelltitel: %w", h.SourceDocumentID, err)
}
var targetTitle string
if err := tenantDB.QueryRow(ctx, `SELECT title FROM documents WHERE id = $1`, h.ObjectReference).Scan(&targetTitle); err != nil {
return nil, nil, fmt.Errorf("dokument %s zieltitel: %w", h.ObjectReference, err)
}
match := legacyTitle == targetTitle
if !match {
findings = append(findings, txtFinding{tenant: t.Slug, kind: "dokumenttitel", ref: h.ObjectReference,
detail: fmt.Sprintf("abweichung ab zeichen %d", firstDiff(legacyTitle, targetTitle))})
}
rows = append(rows, txtRow(t.Slug, "Dokumenttitel", h.ObjectReference, legacyTitle, targetTitle, match))
}
return findings, rows, nil
}
type mailHandoffEntry struct {
SourceMessageID string `json:"source_message_id"`
ObjectReference string `json:"object_reference"`
}
type mailIndexEntry struct {
Subject string `json:"subject"`
Body string `json:"body"`
}
func (d *Dryrun) txt01Messages(ctx context.Context, t ContextTenant) ([]txtFinding, []string, error) {
var handoff []mailHandoffEntry
if err := readJSONFile(filepath.Join(d.RunDir, "uebergabe", t.Slug, "mail_objekte.json"), &handoff); err != nil {
return nil, nil, fmt.Errorf("mail-uebergabe %s: %w", t.Slug, err)
}
var index map[string]mailIndexEntry
if err := readJSONFile(filepath.Join(d.RunDir, "ziel-objekte", "mail-index", t.Slug+".json"), &index); err != nil {
return nil, nil, fmt.Errorf("mail-index %s: %w", t.Slug, err)
}
var findings []txtFinding
var rows []string
for _, h := range handoff {
var subject, path string
if err := d.archivmail.QueryRow(ctx, `
SELECT e.subject, so.path FROM emails e JOIN storage_objects so ON so.id = e.storage_id WHERE e.id = $1
`, h.SourceMessageID).Scan(&subject, &path); err != nil {
return nil, nil, fmt.Errorf("nachricht %s quelle: %w", h.SourceMessageID, err)
}
raw, err := os.ReadFile(filepath.Join(d.legacyObjectsDir(), "archivmail", path))
if err != nil {
return nil, nil, fmt.Errorf("nachricht %s rohinhalt: %w", h.SourceMessageID, err)
}
entry, ok := index[h.ObjectReference]
if !ok {
findings = append(findings, txtFinding{tenant: t.Slug, kind: "nachricht", ref: h.ObjectReference, detail: "kein eintrag im zielindex"})
rows = append(rows, txtRow(t.Slug, "Nachricht (kein Zielindex)", h.ObjectReference, subject, "-", false))
continue
}
if entry.Subject != subject {
findings = append(findings, txtFinding{tenant: t.Slug, kind: "nachricht-betreff", ref: h.ObjectReference,
detail: fmt.Sprintf("abweichung ab zeichen %d", firstDiff(subject, entry.Subject))})
}
if entry.Body != string(raw) {
findings = append(findings, txtFinding{tenant: t.Slug, kind: "nachricht-volltext", ref: h.ObjectReference,
detail: fmt.Sprintf("abweichung ab zeichen %d", firstDiff(string(raw), entry.Body))})
}
rows = append(rows, txtRow(t.Slug, "Nachrichtenbetreff", h.ObjectReference, subject, entry.Subject, entry.Subject == subject))
rows = append(rows, txtRow(t.Slug, "Nachrichtenvolltext", h.ObjectReference, string(raw), entry.Body, entry.Body == string(raw)))
}
return findings, rows, nil
}
// firstDiff liefert den Index des ersten abweichenden Byte zweier Strings,
// -1 wenn sie identisch sind - Diagnosewert, den ein reiner Hash-Vergleich
// nicht liefert.
func firstDiff(a, b string) int {
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
return i
}
}
if len(a) != len(b) {
return n
}
return -1
}
func txtRow(tenant, kind, ref, source, target string, match bool) string {
status := "OK"
if !match {
status = "ABWEICHUNG"
}
return fmt.Sprintf("| %s | %s | %s | %s | %s | %s |", tenant, kind, ref, truncateForReport(source), truncateForReport(target), status)
}
func truncateForReport(s string) string {
s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "|", "/")
const max = 60
if len(s) <= max {
return s
}
return s[:max] + "…"
}
func renderTxtBericht(rows []string) string {
sort.Strings(rows)
var b strings.Builder
b.WriteString("# Volltextvergleich migrierter Dokumenttitel und Nachrichten (TXT-01)\n\n")
b.WriteString("| Tenant | Art | Referenz | Quelle | Ziel | Status |\n|---|---|---|---|---|---|\n")
for _, r := range rows {
b.WriteString(r)
b.WriteString("\n")
}
abweichungen := 0
for _, r := range rows {
if strings.HasSuffix(r, "ABWEICHUNG |") {
abweichungen++
}
}
fmt.Fprintf(&b, "\nGeprueft: %d Zeilen, %d Abweichungen.\n", len(rows), abweichungen)
return b.String()
}
@@ -0,0 +1,37 @@
package cutoverorchestrator
import (
"strings"
"testing"
)
func TestFirstDiff(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"gleich", "gleich", -1},
{"abc", "abx", 2},
{"kurz", "kurzer", 4},
{"", "", -1},
}
for _, c := range cases {
if got := firstDiff(c.a, c.b); got != c.want {
t.Errorf("firstDiff(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
func TestRenderTxtBericht_MarkiertAbweichungen(t *testing.T) {
rows := []string{
txtRow("mandant-a", "Dokumenttitel", "doc-1", "Titel A", "Titel A", true),
txtRow("mandant-a", "Dokumenttitel", "doc-2", "Titel B", "Titel B (veraendert)", false),
}
out := renderTxtBericht(rows)
if !strings.Contains(out, "doc-2") || !strings.Contains(out, "ABWEICHUNG") {
t.Fatalf("bericht markiert abweichung nicht:\n%s", out)
}
if !strings.Contains(out, "Geprueft: 2 Zeilen, 1 Abweichungen.") {
t.Fatalf("bericht-zusammenfassung falsch:\n%s", out)
}
}
+40
View File
@@ -0,0 +1,40 @@
package cutoverorchestrator
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationauditcontinuity"
)
// val04 verwendet VAL-04 (internal/migrationauditcontinuity) direkt: Quelle
// und Ziel liegen beide in der Registry-DB (legacy_audit_log_staging bzw.
// audit_events aus COR-05), eine Modulstufe ist dafuer nicht noetig.
func (d *Dryrun) val04(ctx context.Context) (Outcome, error) {
var slugs []string
for _, t := range d.tenants {
slugs = append(slugs, t.Slug)
}
source := migrationauditcontinuity.PostgresSourceStatsProvider{RegistryPool: d.registry}
target := migrationauditcontinuity.PostgresTargetStatsProvider{AuditPool: d.registry}
reports, err := migrationauditcontinuity.Run(ctx, slugs, source, target)
if err != nil {
return Outcome{}, err
}
if err := os.WriteFile(filepath.Join(d.RunDir, "val04-audit-kontinuitaet.md"), []byte(migrationauditcontinuity.RenderBericht(reports)), 0o644); err != nil {
return Outcome{}, err
}
out := Outcome{Counts: map[string]int{"tenants": len(reports)}}
if offen := migrationauditcontinuity.OffeneBefunde(reports); len(offen) > 0 {
var lines []string
for _, r := range offen {
lines = append(lines, r.String())
}
out.Counts["offene_befunde"] = len(offen)
return out, fmt.Errorf("audit-log-kontinuitaet: %d offene befunde: %s", len(offen), strings.Join(lines, "; "))
}
return out, nil
}