diff --git a/archive/internal/cutoverstage/env.go b/archive/internal/cutoverstage/env.go index a4ab8638..7a7067bf 100644 --- a/archive/internal/cutoverstage/env.go +++ b/archive/internal/cutoverstage/env.go @@ -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) +} diff --git a/archive/internal/cutoverstage/stages.go b/archive/internal/cutoverstage/stages.go index 29dbb8ab..af1f5538 100644 --- a/archive/internal/cutoverstage/stages.go +++ b/archive/internal/cutoverstage/stages.go @@ -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 diff --git a/cmd/cutover-generalprobe/main.go b/cmd/cutover-generalprobe/main.go new file mode 100644 index 00000000..85dfd9eb --- /dev/null +++ b/cmd/cutover-generalprobe/main.go @@ -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) + } +} diff --git a/docs/CUT-02-PRUEFPROTOKOLL.md b/docs/CUT-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..53a29bb1 --- /dev/null +++ b/docs/CUT-02-PRUEFPROTOKOLL.md @@ -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. diff --git a/internal/cutoverorchestrator/dataset.go b/internal/cutoverorchestrator/dataset.go index 16f06c27..ab0ea26f 100644 --- a/internal/cutoverorchestrator/dataset.go +++ b/internal/cutoverorchestrator/dataset.go @@ -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 diff --git a/internal/cutoverorchestrator/dataset_generalprobe.go b/internal/cutoverorchestrator/dataset_generalprobe.go new file mode 100644 index 00000000..a9cfd5cf --- /dev/null +++ b/internal/cutoverorchestrator/dataset_generalprobe.go @@ -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) +} diff --git a/internal/cutoverorchestrator/dataset_generalprobe_test.go b/internal/cutoverorchestrator/dataset_generalprobe_test.go new file mode 100644 index 00000000..18361a17 --- /dev/null +++ b/internal/cutoverorchestrator/dataset_generalprobe_test.go @@ -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") + } +} diff --git a/internal/cutoverorchestrator/dryrun.go b/internal/cutoverorchestrator/dryrun.go index fa96fbc9..b76309c5 100644 --- a/internal/cutoverorchestrator/dryrun.go +++ b/internal/cutoverorchestrator/dryrun.go @@ -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}, } diff --git a/internal/cutoverorchestrator/generalprobe_test.go b/internal/cutoverorchestrator/generalprobe_test.go new file mode 100644 index 00000000..6fbac6c4 --- /dev/null +++ b/internal/cutoverorchestrator/generalprobe_test.go @@ -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") +} diff --git a/internal/cutoverorchestrator/protocol.go b/internal/cutoverorchestrator/protocol.go index 4137769a..83d20cfa 100644 --- a/internal/cutoverorchestrator/protocol.go +++ b/internal/cutoverorchestrator/protocol.go @@ -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"), diff --git a/internal/cutoverorchestrator/steps_prep.go b/internal/cutoverorchestrator/steps_prep.go index dc9edb3b..30f469e4 100644 --- a/internal/cutoverorchestrator/steps_prep.go +++ b/internal/cutoverorchestrator/steps_prep.go @@ -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 } diff --git a/internal/cutoverorchestrator/txtverify.go b/internal/cutoverorchestrator/txtverify.go new file mode 100644 index 00000000..d41479b4 --- /dev/null +++ b/internal/cutoverorchestrator/txtverify.go @@ -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() +} diff --git a/internal/cutoverorchestrator/txtverify_test.go b/internal/cutoverorchestrator/txtverify_test.go new file mode 100644 index 00000000..b8c8da26 --- /dev/null +++ b/internal/cutoverorchestrator/txtverify_test.go @@ -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) + } +} diff --git a/internal/cutoverorchestrator/val04.go b/internal/cutoverorchestrator/val04.go new file mode 100644 index 00000000..8ea2eba8 --- /dev/null +++ b/internal/cutoverorchestrator/val04.go @@ -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 +}