CUT-02: Trockenlauf 2 - Generalprobe (synthetisch simuliert) #280
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user