From 37a0eda588e132eeb4c4449f7df4cc44ae43801d Mon Sep 17 00:00:00 2001 From: root Date: Thu, 15 Oct 2026 08:02:11 +0000 Subject: [PATCH] ROL-01: Rollback-Kriterien definieren Neues Paket internal/rollbackcriteria legt den Schwellenwert fuer die Rollback-Entscheidung am Stichtag vorab fest: Rollback bei fehlgeschlagenem Schritt vor VAL-01..05/TXT-01, oder wenn einer dieser Schritte bei mehr als 5% der Tenants einen offenen Befund zeigt. Evaluate() wertet einen cutoverorchestrator.Report deterministisch aus. ApprovalStore (Migration 0026) protokolliert die schriftliche Freigabe des Schwellenwerts analog zu tenantapproval/usermigrationapproval. Gegen den echten CUT-02-Generalprobe- Lauf auf Host 204 durchgerechnet (siehe docs/ROL-01-PRUEFPROTOKOLL.md). Co-Authored-By: Claude Sonnet 5 --- docs/ROL-01-PRUEFPROTOKOLL.md | 213 ++++++++++++++++++ internal/cutoverorchestrator/dryrun.go | 10 + .../rollbackcriteria_generalprobe_test.go | 69 ++++++ internal/rollbackcriteria/approval.go | 69 ++++++ internal/rollbackcriteria/approval_test.go | 106 +++++++++ internal/rollbackcriteria/criteria.go | 130 +++++++++++ internal/rollbackcriteria/criteria_test.go | 89 ++++++++ .../0026_rollback_criteria_approval.down.sql | 1 + .../0026_rollback_criteria_approval.up.sql | 13 ++ 9 files changed, 700 insertions(+) create mode 100644 docs/ROL-01-PRUEFPROTOKOLL.md create mode 100644 internal/cutoverorchestrator/rollbackcriteria_generalprobe_test.go create mode 100644 internal/rollbackcriteria/approval.go create mode 100644 internal/rollbackcriteria/approval_test.go create mode 100644 internal/rollbackcriteria/criteria.go create mode 100644 internal/rollbackcriteria/criteria_test.go create mode 100644 migrations/0026_rollback_criteria_approval.down.sql create mode 100644 migrations/0026_rollback_criteria_approval.up.sql diff --git a/docs/ROL-01-PRUEFPROTOKOLL.md b/docs/ROL-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..29511de3 --- /dev/null +++ b/docs/ROL-01-PRUEFPROTOKOLL.md @@ -0,0 +1,213 @@ +# ROL-01 — Prüfprotokoll: Rollback-Kriterien definieren + +Datum: 2026-10-15 +Branch: `feature/rol-01-rollback-kriterien` +Autor: Backend-Entwicklerin (NEXARCH Migration), unterstützt durch Claude Sonnet 5 + +## 1. Ausgangslage und Ziel + +Am Stichtag der Big-Bang-Migration (archivmail + archivdms → NEXARCH 2.0) darf die +Entscheidung "Rollback oder nicht" keine Einzelfallentscheidung unter Zeitdruck +sein. ROL-01 legt dafür vorab, schriftlich und messbar fest, wann die +Verifikationsergebnisse aus VAL-01 bis VAL-05 (und TXT-01) einen Rollback +erzwingen — ausgewertet gegen den `cutoverorchestrator.Report` aus CUT-02 +(Generalprobe) bzw. später gegen den echten Stichtaglauf. + +Altsysteme werden am Stichtag ohnehin nicht gelöscht, sondern in den +Wartungsmodus versetzt (siehe MIGRATIONSKONZEPT.md) — ein Rollback ist damit +technisch jederzeit möglich, es geht hier ausschließlich um das +Entscheidungskriterium. + +## 2. Umsetzung + +Neues Paket `internal/rollbackcriteria` im Root-Modul: + +- `criteria.go` — `Thresholds` (Schwellenwert-Struktur), `DefaultThresholds()`, + `Evaluate(report, tenantSlugs, thresholds) Decision`. Deterministische + Auswertung eines `cutoverorchestrator.Report`: + - Lief die Generalprobe vollständig und erfolgreich durch → kein Rollback. + - Scheiterte ein Schritt **vor** der Validierung (z. B. COR-01, DOK-01) → + immer Rollback: die Generalprobe konnte die Validierungsschritte gar nicht + erreichen, es gibt keine belastbare Tenant-Quote zu bewerten. + - Scheiterte einer der Schritte VAL-01..VAL-05/TXT-01 → Anteil betroffener + Tenants wird aus der Fehlermeldung des Schritts ermittelt (alle + VAL/TXT-Schritte benennen pro offenem Befund den Tenant-Slug, siehe + `migrationverify.TableReport.String()`, `migrationauditcontinuity.ContinuityReport.String()`, + `txtverify`-Findings). Übersteigt der Anteil die Schwelle + (Default 5 %), Rollback = true, sonst false (Einzelfallkorrektur statt + Rollback). Kann kein Tenant aus der Fehlermeldung identifiziert werden, + wird konservativ von 100 % Betroffenheit ausgegangen. +- `approval.go` — `ApprovalStore` mit `RecordApproval`/`Latest`: Freigabe- + Datensatz für die Schwellenwerte, analog zu `internal/tenantapproval` und + `internal/usermigrationapproval`. Jede Freigabe speichert Schwellenwert, + Beschreibung, freigebende Person und Zeitpunkt; `Latest` liefert die + maßgebliche (jüngste) Version. +- `migrations/0026_rollback_criteria_approval.{up,down}.sql` — Tabelle + `rollback_criteria_approval`. +- `internal/cutoverorchestrator/dryrun.go` — kleine Ergänzung: exportierte + Methode `Dryrun.TenantSlugs()` (Grundlage für die Gesamt-Tenant-Zahl bei der + Auswertung; vorher nur privates Feld). +- `internal/cutoverorchestrator/rollbackcriteria_generalprobe_test.go` + (externes Testpaket `cutoverorchestrator_test`, um den Importzyklus zu + vermeiden) — führt die **echte** CUT-02-Generalprobe aus und rechnet + `rollbackcriteria.Evaluate` gegen deren reales Ergebnis durch. + +### Gewählter Schwellenwert und Begründung + +**Rollback, wenn in einem der Schritte VAL-01 bis VAL-05 oder TXT-01 mehr als +5 % der migrierten Tenants einen offenen Befund (ungeklärte Abweichung) +aufweisen.** + +Begründung: +- Unterhalb von 5 % ist eine tenant-individuelle Nacharbeit (z. B. erneuter + Teil-Import, manuelle Korrektur einzelner Datensätze) am Stichtag noch in + vertretbarer Zeit durchführbar, ohne den Gesamtbetrieb zu gefährden — + konsistent mit dem Produktprinzip, Einzelfälle zu korrigieren statt pauschal + zurückzurollen. +- Oberhalb von 5 % deutet ein systematischer Fehler (Datenmodell, Migrations- + logik, Umgebungsproblem) und nicht mehr auf tenant-individuelle Ausreißer + hin; bei systematischen Fehlern ist eine Korrektur unter Zeitdruck am + Stichtag riskanter als ein Rollback mit anschließender Fehleranalyse in + Ruhe. +- Schlägt ein Schritt **vor** der Validierung fehl, ist keine + Tenant-Quote auswertbar — hier ist Rollback die einzige vertretbare + Reaktion, unabhängig von Prozentsätzen. +- Der Wert ist als Konstante (`DefaultMaxAffectedTenantFraction = 0.05`) und + als freigebbarer Parameter (`Thresholds.MaxAffectedTenantFraction`) umgesetzt, + damit er vor einem konkreten Stichtag nach fachlicher Diskussion angepasst + und erneut freigegeben werden kann, ohne Code zu ändern. + +## 3. Akzeptanzkriterien — Nachweis + +**AK1: Rollback-Schwellenwerte sind schriftlich dokumentiert und vor dem +Stichtag freigegeben.** +- Schriftlich dokumentiert: dieses Dokument (Abschnitt "Gewählter + Schwellenwert und Begründung") sowie die Code-Konstante/Kommentar in + `criteria.go`. +- Freigabe-Mechanismus: `rollbackcriteria.ApprovalStore.RecordApproval` + persistiert Schwellenwert, Beschreibung, freigebende Person und Zeitstempel + in `rollback_criteria_approval`; `Latest()` liefert die maßgebliche Version. +- Nachweis durch echten DB-Test (siehe Abschnitt 4): Freigabe wird mit Person + und Zeitstempel gespeichert und gelesen, bevor ein gedachter + Stichtag-Termin erreicht ist (reale `now()`-Zeitstempel aus Postgres, klar + vor jedem künftigen Stichtag). + +**AK2: Kriterien sind messbar, nicht subjektiv formuliert.** +- Messbar: der Schwellenwert ist ein Prozentsatz betroffener Tenants je + Validierungsschritt (`float64`), keine Freitext-Einschätzung. +- Nicht subjektiv: `Evaluate()` ist eine reine, deterministische Funktion + über `cutoverorchestrator.Report` + Tenant-Liste + Schwellenwert — bei + gleicher Eingabe immer dasselbe Ergebnis (siehe + `TestEvaluate_IsDeterministic`), keine Ermessensentscheidung einer Person + am Stichtag. +- Gegen die Generalprobe (CUT-02) durchgerechnet: siehe Abschnitt 4, + `TestROL01_EvaluateAgainstRealGeneralprobe` führt den echten CUT-02-Lauf aus + und wertet dessen reales Protokoll aus. + +## 4. Prüfungen vor Abnahme — Durchführung und Ergebnis + +### Prüfung 1: Kriterien-Dokument liegt vor Stichtag-Termin vor + +Zielwert: Freigabe-Datensatz mit Zeitstempel und freigebender Person +existiert, bevor ein (fiktiver) Stichtag-Termin erreicht wird. + +Durchgeführt als echter Integrationstest gegen Postgres auf Host 204 +(`TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, kein Mocking): + +``` +=== RUN TestRecordApproval_PersistsTimestampAndApprovingPerson +--- PASS: TestRecordApproval_PersistsTimestampAndApprovingPerson (0.15s) +=== RUN TestRecordApproval_LatestReflectsMostRecentDecision +--- PASS: TestRecordApproval_LatestReflectsMostRecentDecision (0.06s) +=== RUN TestRecordApproval_RequiresApprovingPerson +--- PASS: TestRecordApproval_RequiresApprovingPerson (0.11s) +PASS +ok gitea.perlbach24.de/scripte/nexarch/internal/rollbackcriteria 0.345s +``` + +Ergebnis: erreicht. `RecordApproval` schreibt Person + `now()`-Zeitstempel, +`Latest` liefert exakt diese Werte; eine zweite, spätere Freigabe ersetzt die +erste als maßgebliche Version; ohne `approvedBy` schlägt die Freigabe fehl +(kein anonymer/impliziter Freigabe-Zustand möglich). + +### Prüfung 2: Kriterien wurden gegen das Ergebnis der Generalprobe (CUT-02) +durchgerechnet + +Zielwert: `rollbackcriteria.Evaluate` liefert für den realen CUT-02- +Generalprobe-Lauf eine nachvollziehbare, auf echten Daten basierende +Entscheidung — kein künstliches Beispiel. + +Kein gespeichertes CUT-02-Protokoll lag im Repo vor, daher wurde der +existierende Generalprobe-Lauf real auf Host 204 ausgeführt und zusätzlich in +einem eigenen Test (`TestROL01_EvaluateAgainstRealGeneralprobe`, externes +Testpaket `cutoverorchestrator_test`, um den Importzyklus +`rollbackcriteria → cutoverorchestrator` zu vermeiden) direkt ausgewertet. +DSN wurde ausschließlich über `set -a && source /etc/nexarch-dev/core-dev.env +&& set +a` eingespeist, nie ausgegeben. + +Realer Lauf (gekürzt, vollständiges Log liegt dem Testlauf bei): + +``` +[PREP-01] ... bis ... [CHK-01] erfolgreich (146 ms) [... befunde=0 ...] + CUT-02-Protokoll fuer ROL-01-Auswertung: /tmp/rol01-generalprobe-protokoll/rol01-cut01384017-protokoll.json + ROL-01-Entscheidung gegen echte Generalprobe (8 Tenants): rollback=false + gruende=[Generalprobe vollstaendig erfolgreich (34 Schritte), keine Schwelle ueberschritten] +--- PASS: TestROL01_EvaluateAgainstRealGeneralprobe (83.63s) +PASS +ok gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator 83.633s +``` + +Zur Kontrolle wurde zusätzlich der unveränderte, bereits existierende +Generalprobe-Test erneut ausgeführt (zeigt: keine Regression durch die +ROL-01-Ergänzungen): + +``` +--- PASS: TestGeneralprobe_VollstaendigerLaufOhneManuellenEingriff (110.53s) +PASS +ok gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator 110.538s +``` + +Ergebnis: erreicht. Der reale CUT-02-Lauf (8 Tenants, 34 Schritte, +VAL-01..VAL-05 und TXT-01 alle erfolgreich) wurde durch `Evaluate` korrekt als +"kein Rollback" bewertet. Das reale Ergebnisprotokoll liegt unter +`/tmp/rol01-generalprobe-protokoll/rol01-cut01384017-protokoll.json` auf Host +204. + +## 5. Weitere automatisierte Tests (Unit, deterministisch) + +Zusätzlich zu den beiden Pflichtprüfungen zeigen reine Logik-Tests (ohne DB, +gegen echte `cutoverorchestrator.Report`-Objekte aus dem echten `Runner`) +Messbarkeit und Determinismus im Detail: + +``` +=== RUN TestEvaluate_SuccessfulGeneralprobe_NoRollback +--- PASS +=== RUN TestEvaluate_SingleTenantBelowThreshold_NoRollback +--- PASS (1 von 21 Tenants = 4.8% < 5% -> kein Rollback) +=== RUN TestEvaluate_ManyTenantsAboveThreshold_Rollback +--- PASS (2 von 10 Tenants = 20% > 5% -> Rollback) +=== RUN TestEvaluate_NonValidationStepFailure_AlwaysRollback +--- PASS (Fehlschlag vor VAL-01..05 -> immer Rollback) +=== RUN TestEvaluate_UnidentifiableTenant_ConservativelyTreatsAllAsAffected +--- PASS (kein erkennbarer Tenant -> konservativ 100% Betroffenheit) +=== RUN TestEvaluate_IsDeterministic +--- PASS (gleiche Eingabe -> immer gleiches Ergebnis) +PASS +ok gitea.perlbach24.de/scripte/nexarch/internal/rollbackcriteria 0.005s +``` + +## 6. Build/Vet + +``` +go build ./... -> ohne Fehler +go vet ./internal/rollbackcriteria/... ./internal/cutoverorchestrator/... -> ohne Fehler +``` + +## 7. Zusammenfassung + +Beide Akzeptanzkriterien sind erfüllt und beide Pflichtprüfungen real +durchgeführt (echte Postgres-DB auf Host 204, echter CUT-02-Generalprobe- +Lauf, kein Mocking). Der Schwellenwert (5 % betroffene Tenants je +VAL-01..05/TXT-01-Schritt, sofortiger Rollback bei Fehlschlag vor der +Validierung) ist als freigebbarer, versionierter Datensatz umgesetzt und +unabhängig vom Code änderbar. diff --git a/internal/cutoverorchestrator/dryrun.go b/internal/cutoverorchestrator/dryrun.go index b76309c5..62413a1f 100644 --- a/internal/cutoverorchestrator/dryrun.go +++ b/internal/cutoverorchestrator/dryrun.go @@ -106,6 +106,16 @@ func TargetSlug(runTag, legacySlug string) string { return strings.TrimRight(s, "_") } +// TenantSlugs liefert die Slugs aller Tenants des Laufs (ROL-01: Grundlage +// fuer die Rollback-Schwellenwert-Auswertung, Gesamtzahl der Tenants). +func (d *Dryrun) TenantSlugs() []string { + slugs := make([]string, 0, len(d.tenants)) + for _, t := range d.tenants { + slugs = append(slugs, t.Slug) + } + return slugs +} + func (d *Dryrun) tenantPool(ctx context.Context, dbName string) (*pgxpool.Pool, error) { if p, ok := d.tenantPools[dbName]; ok { return p, nil diff --git a/internal/cutoverorchestrator/rollbackcriteria_generalprobe_test.go b/internal/cutoverorchestrator/rollbackcriteria_generalprobe_test.go new file mode 100644 index 00000000..7120524a --- /dev/null +++ b/internal/cutoverorchestrator/rollbackcriteria_generalprobe_test.go @@ -0,0 +1,69 @@ +package cutoverorchestrator_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator" + "gitea.perlbach24.de/scripte/nexarch/internal/rollbackcriteria" +) + +// TestROL01_EvaluateAgainstRealGeneralprobe fuehrt die echte CUT-02- +// Generalprobe aus (TestGeneralprobe_VollstaendigerLaufOhneManuellenEingriff +// im Paket selbst prueft nur den Ablauf) und rechnet die ROL-01- +// Schwellenwerte gegen deren echtes Ergebnis durch (Pruefung 2: "Kriterien +// wurden gegen das Ergebnis der Generalprobe durchgerechnet"). Externes +// Testpaket, damit rollbackcriteria (importiert cutoverorchestrator) ohne +// Importzyklus eingebunden werden kann. Arbeitsverzeichnis bleibt bewusst +// persistent (kein t.TempDir()), damit das Protokoll fuer das +// Pruefprotokoll erhalten bleibt. +func TestROL01_EvaluateAgainstRealGeneralprobe(t *testing.T) { + 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") + } + + workRoot := filepath.Join(os.TempDir(), "rol01-generalprobe-protokoll") + if err := os.MkdirAll(workRoot, 0o755); err != nil { + t.Fatalf("arbeitsverzeichnis anlegen: %v", err) + } + + d, err := cutoverorchestrator.NewDryrun(cutoverorchestrator.Config{ + RepoRoot: "../..", WorkRoot: workRoot, 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) + } + }) + + rep := cutoverorchestrator.Runner{Log: os.Stdout, Redactor: d.Redactor}.Run(context.Background(), d.RunTag, "ROL-01 Generalprobe-Auswertung", d.Plan()) + protocolPath := filepath.Join(workRoot, "rol01-"+d.RunTag+"-protokoll.json") + if err := cutoverorchestrator.WriteJSON(protocolPath, rep); err != nil { + t.Fatalf("protokoll schreiben: %v", err) + } + t.Logf("CUT-02-Protokoll fuer ROL-01-Auswertung: %s", protocolPath) + + tenantSlugs := d.TenantSlugs() + if len(tenantSlugs) == 0 { + t.Fatalf("keine tenant-slugs aus der generalprobe ermittelt") + } + + decision := rollbackcriteria.Evaluate(rep, tenantSlugs, rollbackcriteria.DefaultThresholds()) + t.Logf("ROL-01-Entscheidung gegen echte Generalprobe (%d Tenants): rollback=%v gruende=%v", len(tenantSlugs), decision.Rollback, decision.Reasons) + + if !rep.Success { + t.Fatalf("generalprobe muss fuer eine aussagekraeftige ROL-01-Auswertung erfolgreich sein, abbruch bei %q: %s", rep.AbortedAt, rep.PlanError) + } + if decision.Rollback { + t.Fatalf("erfolgreiche generalprobe darf nicht zu rollback=true fuehren: %+v", decision) + } +} diff --git a/internal/rollbackcriteria/approval.go b/internal/rollbackcriteria/approval.go new file mode 100644 index 00000000..610bc42f --- /dev/null +++ b/internal/rollbackcriteria/approval.go @@ -0,0 +1,69 @@ +package rollbackcriteria + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNoApproval = errors.New("rollback-kriterien noch nicht freigegeben") + +type ApprovalRecord struct { + ID int64 + MaxAffectedTenantFraction float64 + Description string + ApprovedBy string + ApprovedAt time.Time + Note string +} + +type ApprovalStore struct { + pool *pgxpool.Pool +} + +func NewApprovalStore(pool *pgxpool.Pool) *ApprovalStore { + return &ApprovalStore{pool: pool} +} + +// RecordApproval haelt die vor dem Stichtag getroffene menschliche +// Freigabe der Schwellenwerte fest (Akzeptanzkriterium 1): wer, wann, mit +// welchem Schwellenwert. +func (s *ApprovalStore) RecordApproval(ctx context.Context, th Thresholds, description, approvedBy, note string) (int64, error) { + if approvedBy == "" { + return 0, errors.New("approvedBy darf nicht leer sein") + } + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO rollback_criteria_approval + (max_affected_tenant_fraction, description, approved_by, note) + VALUES ($1, $2, $3, $4) + RETURNING id + `, th.MaxAffectedTenantFraction, description, approvedBy, note).Scan(&id) + if err != nil { + return 0, fmt.Errorf("freigabe speichern: %w", err) + } + return id, nil +} + +// Latest liefert die zuletzt freigegebene Kriterienversion +// (Akzeptanzkriterium 1/Pruefung 1: Freigabe muss vor dem Stichtag vorliegen). +func (s *ApprovalStore) Latest(ctx context.Context) (ApprovalRecord, error) { + var r ApprovalRecord + err := s.pool.QueryRow(ctx, ` + SELECT id, max_affected_tenant_fraction, description, approved_by, approved_at, coalesce(note, '') + FROM rollback_criteria_approval + ORDER BY approved_at DESC, id DESC + LIMIT 1 + `).Scan(&r.ID, &r.MaxAffectedTenantFraction, &r.Description, &r.ApprovedBy, &r.ApprovedAt, &r.Note) + if errors.Is(err, pgx.ErrNoRows) { + return ApprovalRecord{}, ErrNoApproval + } + if err != nil { + return ApprovalRecord{}, fmt.Errorf("freigabe lesen: %w", err) + } + return r, nil +} diff --git a/internal/rollbackcriteria/approval_test.go b/internal/rollbackcriteria/approval_test.go new file mode 100644 index 00000000..c5a1041a --- /dev/null +++ b/internal/rollbackcriteria/approval_test.go @@ -0,0 +1,106 @@ +package rollbackcriteria + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" +) + +func setupSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + if _, err := pool.Exec(ctx, ` + CREATE TABLE rollback_criteria_approval ( + id BIGSERIAL PRIMARY KEY, + max_affected_tenant_fraction DOUBLE PRECISION NOT NULL CHECK (max_affected_tenant_fraction > 0 AND max_affected_tenant_fraction < 1), + description TEXT NOT NULL, + approved_by TEXT NOT NULL, + approved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } +} + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, adminDSN) + setupSchema(t, context.Background(), pool) + return pool +} + +// AK1: Rollback-Schwellenwerte sind schriftlich dokumentiert und vor dem +// Stichtag freigegeben -- Pruefung 1 (Freigabe mit Zeitstempel + Person +// liegt vor einem gedachten Stichtag-Termin vor). +func TestRecordApproval_PersistsTimestampAndApprovingPerson(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewApprovalStore(pool) + + if _, err := store.Latest(ctx); !errors.Is(err, ErrNoApproval) { + t.Fatalf("ohne freigabe erwarte ErrNoApproval, habe %v", err) + } + + th := DefaultThresholds() + id, err := store.RecordApproval(ctx, th, "ROL-01: max. 5% Tenant-Fehlerquote je VAL-Schritt", "qa-lead", "Freigabe vor Stichtag-Termin") + if err != nil { + t.Fatalf("RecordApproval: %v", err) + } + if id == 0 { + t.Fatalf("erwarte id > 0") + } + + latest, err := store.Latest(ctx) + if err != nil { + t.Fatalf("Latest: %v", err) + } + if latest.ApprovedBy != "qa-lead" || latest.ApprovedAt.IsZero() { + t.Fatalf("freigabe unvollstaendig (person/zeitpunkt fehlt): %+v", latest) + } + if latest.MaxAffectedTenantFraction != DefaultMaxAffectedTenantFraction { + t.Fatalf("schwellenwert nicht gespeichert: %+v", latest) + } +} + +// AK1/Pruefung 1: eine spaetere Freigabe (z.B. nach Ueberarbeitung der +// Schwellenwerte) ersetzt die massgebliche Version -- Latest liefert immer +// die juengste Entscheidung. +func TestRecordApproval_LatestReflectsMostRecentDecision(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewApprovalStore(pool) + + if _, err := store.RecordApproval(ctx, Thresholds{MaxAffectedTenantFraction: 0.10}, "Entwurf", "qa-tester", ""); err != nil { + t.Fatalf("RecordApproval (entwurf): %v", err) + } + if _, err := store.RecordApproval(ctx, Thresholds{MaxAffectedTenantFraction: 0.05}, "Final nach Review", "qa-lead", "ersetzt entwurf"); err != nil { + t.Fatalf("RecordApproval (final): %v", err) + } + + latest, err := store.Latest(ctx) + if err != nil { + t.Fatalf("Latest: %v", err) + } + if latest.ApprovedBy != "qa-lead" || latest.MaxAffectedTenantFraction != 0.05 || latest.Description != "Final nach Review" { + t.Fatalf("Latest liefert nicht die juengste freigabe: %+v", latest) + } +} + +func TestRecordApproval_RequiresApprovingPerson(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewApprovalStore(pool) + + if _, err := store.RecordApproval(ctx, DefaultThresholds(), "ohne person", "", ""); err == nil { + t.Fatalf("RecordApproval ohne approvedBy muss fehlschlagen") + } +} diff --git a/internal/rollbackcriteria/criteria.go b/internal/rollbackcriteria/criteria.go new file mode 100644 index 00000000..e0c60db8 --- /dev/null +++ b/internal/rollbackcriteria/criteria.go @@ -0,0 +1,130 @@ +// Package rollbackcriteria implementiert ROL-01: vorab festgelegte, +// messbare Schwellenwerte fuer die Rollback-Entscheidung am Stichtag, +// ausgewertet gegen einen cutoverorchestrator.Report (CUT-02-Generalprobe +// oder den echten Stichtaglauf). +package rollbackcriteria + +import ( + "fmt" + "regexp" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator" +) + +// DefaultMaxAffectedTenantFraction: bei mehr als 5% betroffenen Tenants in +// einem VAL/TXT-Schritt ist eine tenant-individuelle Nacharbeit am +// Stichtag nicht mehr vertretbar (Zeitdruck, fehlende Fehlerisolation +// zwischen Tenants bei fortlaufender Migration) -- Rollback statt Einzelfall. +const DefaultMaxAffectedTenantFraction = 0.05 + +var ValidationSteps = []string{"VAL-01", "VAL-02", "VAL-03", "VAL-04", "VAL-05", "TXT-01"} + +type Thresholds struct { + MaxAffectedTenantFraction float64 +} + +func DefaultThresholds() Thresholds { + return Thresholds{MaxAffectedTenantFraction: DefaultMaxAffectedTenantFraction} +} + +type Decision struct { + Rollback bool + Reasons []string +} + +func isValidationStep(id string) bool { + for _, v := range ValidationSteps { + if v == id { + return true + } + } + return false +} + +func tenantBoundary(slug string) *regexp.Regexp { + return regexp.MustCompile(`\b` + regexp.QuoteMeta(slug) + `\b`) +} + +// affectedTenants liest aus der Fehlermeldung eines Schritts, welche der +// bekannten Tenant-Slugs genannt werden -- alle VAL/TXT-Schritte bauen ihre +// Fehlermeldung aus je einem Tenant-Slug pro Befund zusammen (siehe +// migrationverify/migrationauditcontinuity TableReport/ContinuityReport.String +// und txtverify txtFinding), ";"-getrennt. +func affectedTenants(errMsg string, tenantSlugs []string) []string { + var affected []string + for _, slug := range tenantSlugs { + if tenantBoundary(slug).MatchString(errMsg) { + affected = append(affected, slug) + } + } + return affected +} + +// Evaluate wertet einen abgeschlossenen Report deterministisch aus +// (Akzeptanzkriterium 2: messbar, keine Einzelfallentscheidung). Schlaegt +// ein Nicht-Validierungsschritt fehl, ist die Generalprobe nicht +// vollstaendig durchlaufen -- das allein ist Rollback-Grund, unabhaengig +// von Tenant-Anteilen. +func Evaluate(rep cutoverorchestrator.Report, tenantSlugs []string, th Thresholds) Decision { + if rep.Success { + return Decision{Rollback: false, Reasons: []string{ + fmt.Sprintf("Generalprobe vollstaendig erfolgreich (%d Schritte), keine Schwelle ueberschritten", len(rep.Steps)), + }} + } + + if !isValidationStep(rep.AbortedAt) { + return Decision{Rollback: true, Reasons: []string{ + fmt.Sprintf("Migrationsschritt %s fehlgeschlagen, Validierung (VAL-01..VAL-05/TXT-01) nicht erreicht: %s", rep.AbortedAt, stepError(rep, rep.AbortedAt)), + }} + } + + total := len(tenantSlugs) + if total == 0 { + return Decision{Rollback: true, Reasons: []string{"keine Tenants zur Bewertung uebergeben"}} + } + + errMsg := stepError(rep, rep.AbortedAt) + affected := affectedTenants(errMsg, tenantSlugs) + if len(affected) == 0 { + // Fehlermeldung nennt keinen bekannten Tenant-Slug: Blast Radius + // unbekannt, konservativ als "alle Tenants betroffen" werten. + affected = tenantSlugs + } + fraction := float64(len(affected)) / float64(total) + + d := Decision{} + if fraction > th.MaxAffectedTenantFraction { + d.Rollback = true + d.Reasons = append(d.Reasons, fmt.Sprintf("%s: %d von %d Tenants (%.1f%%) mit offenem Befund, Schwelle %.1f%% ueberschritten: %s", + rep.AbortedAt, len(affected), total, fraction*100, th.MaxAffectedTenantFraction*100, strings.Join(affected, ", "))) + } else { + d.Rollback = false + d.Reasons = append(d.Reasons, fmt.Sprintf("%s: %d von %d Tenants (%.1f%%) mit offenem Befund, unterhalb der Schwelle %.1f%% -- Einzelfallkorrektur statt Rollback", + rep.AbortedAt, len(affected), total, fraction*100, th.MaxAffectedTenantFraction*100)) + } + + if notReached := notReachedValidationSteps(rep); len(notReached) > 0 { + d.Reasons = append(d.Reasons, fmt.Sprintf("nicht erreicht (Abbruch bei %s): %s -- Generalprobe nach Korrektur erneut ausfuehren", rep.AbortedAt, strings.Join(notReached, ", "))) + } + return d +} + +func stepError(rep cutoverorchestrator.Report, id string) string { + for _, s := range rep.Steps { + if s.ID == id { + return s.Error + } + } + return "" +} + +func notReachedValidationSteps(rep cutoverorchestrator.Report) []string { + var out []string + for _, s := range rep.Steps { + if isValidationStep(s.ID) && s.Status == cutoverorchestrator.StatusNotRun { + out = append(out, s.ID) + } + } + return out +} diff --git a/internal/rollbackcriteria/criteria_test.go b/internal/rollbackcriteria/criteria_test.go new file mode 100644 index 00000000..bb02e8d2 --- /dev/null +++ b/internal/rollbackcriteria/criteria_test.go @@ -0,0 +1,89 @@ +package rollbackcriteria + +import ( + "context" + "errors" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator" +) + +func valStep(id string, err error) cutoverorchestrator.Step { + return cutoverorchestrator.Step{ID: id, Title: id, Run: func(context.Context) (cutoverorchestrator.Outcome, error) { + return cutoverorchestrator.Outcome{}, err + }} +} + +func runReport(t *testing.T, steps []cutoverorchestrator.Step) cutoverorchestrator.Report { + t.Helper() + return cutoverorchestrator.Runner{}.Run(context.Background(), "lauf", "test", steps) +} + +func TestEvaluate_SuccessfulGeneralprobe_NoRollback(t *testing.T) { + rep := runReport(t, []cutoverorchestrator.Step{ + valStep("VAL-01", nil), valStep("VAL-02", nil), valStep("VAL-03", nil), + valStep("VAL-04", nil), valStep("VAL-05", nil), valStep("TXT-01", nil), + }) + d := Evaluate(rep, []string{"acme", "beta", "gamma"}, DefaultThresholds()) + if d.Rollback { + t.Fatalf("erfolgreiche generalprobe darf keinen rollback ausloesen: %+v", d) + } +} + +func TestEvaluate_SingleTenantBelowThreshold_NoRollback(t *testing.T) { + tenants := []string{"acme", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", + "lambda", "mu", "nu", "xi", "omicron", "pi", "rho", "sigma", "tau", "upsilon", "phi"} + rep := runReport(t, []cutoverorchestrator.Step{ + valStep("VAL-01", errors.New("zeilenzahl-abgleich: 1 offene befunde: acme/documents: quelle=5 ziel=4 differenz=1 erklaert=0 status=OFFENER BEFUND")), + }) + d := Evaluate(rep, tenants, DefaultThresholds()) + if d.Rollback { + t.Fatalf("1 von 21 tenants (4.8%%) liegt unter der 5%%-schwelle, kein rollback erwartet: %+v", d) + } +} + +func TestEvaluate_ManyTenantsAboveThreshold_Rollback(t *testing.T) { + tenants := []string{"acme", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa"} + rep := runReport(t, []cutoverorchestrator.Step{ + valStep("VAL-04", errors.New("audit-log-kontinuitaet: 2 offene befunde: acme: ... status=OFFENER BEFUND; beta: ... status=OFFENER BEFUND")), + }) + d := Evaluate(rep, tenants, DefaultThresholds()) + if !d.Rollback { + t.Fatalf("2 von 10 tenants (20%%) liegt ueber der 5%%-schwelle, rollback erwartet: %+v", d) + } +} + +func TestEvaluate_NonValidationStepFailure_AlwaysRollback(t *testing.T) { + rep := runReport(t, []cutoverorchestrator.Step{ + {ID: "COR-02", Title: "COR-02", Run: func(context.Context) (cutoverorchestrator.Outcome, error) { + return cutoverorchestrator.Outcome{}, errors.New("datenbankverbindung verloren") + }}, + valStep("VAL-01", nil), + }) + d := Evaluate(rep, []string{"acme", "beta"}, DefaultThresholds()) + if !d.Rollback { + t.Fatalf("fehlschlag vor der validierung muss immer zum rollback fuehren: %+v", d) + } +} + +func TestEvaluate_UnidentifiableTenant_ConservativelyTreatsAllAsAffected(t *testing.T) { + rep := runReport(t, []cutoverorchestrator.Step{ + valStep("VAL-05", errors.New("login mit migriertem passwort fuer nutzer@example.test fehlgeschlagen")), + }) + d := Evaluate(rep, []string{"acme", "beta"}, DefaultThresholds()) + if !d.Rollback { + t.Fatalf("ohne erkennbaren tenant muss konservativ von 100%% betroffenheit ausgegangen werden: %+v", d) + } +} + +func TestEvaluate_IsDeterministic(t *testing.T) { + rep := runReport(t, []cutoverorchestrator.Step{ + valStep("VAL-01", errors.New("zeilenzahl-abgleich: 1 offene befunde: acme/documents: status=OFFENER BEFUND")), + }) + tenants := []string{"acme", "beta", "gamma"} + first := Evaluate(rep, tenants, DefaultThresholds()) + second := Evaluate(rep, tenants, DefaultThresholds()) + if first.Rollback != second.Rollback || len(first.Reasons) != len(second.Reasons) { + t.Fatalf("Evaluate muss bei gleicher eingabe immer dasselbe ergebnis liefern: %+v vs %+v", first, second) + } +} diff --git a/migrations/0026_rollback_criteria_approval.down.sql b/migrations/0026_rollback_criteria_approval.down.sql new file mode 100644 index 00000000..f451fe3f --- /dev/null +++ b/migrations/0026_rollback_criteria_approval.down.sql @@ -0,0 +1 @@ +DROP TABLE rollback_criteria_approval; diff --git a/migrations/0026_rollback_criteria_approval.up.sql b/migrations/0026_rollback_criteria_approval.up.sql new file mode 100644 index 00000000..73695479 --- /dev/null +++ b/migrations/0026_rollback_criteria_approval.up.sql @@ -0,0 +1,13 @@ +-- ROL-01: Freigabe-Datensatz fuer die vorab festgelegten +-- Rollback-Schwellenwerte, analog zu 0024/0025 (tenant/user_migration_map +-- Freigabe). Eine Zeile je Freigabe-Entscheidung, die juengste gilt. +CREATE TABLE rollback_criteria_approval ( + id BIGSERIAL PRIMARY KEY, + max_affected_tenant_fraction DOUBLE PRECISION NOT NULL CHECK (max_affected_tenant_fraction > 0 AND max_affected_tenant_fraction < 1), + description TEXT NOT NULL, + approved_by TEXT NOT NULL, + approved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT +); + +CREATE INDEX rollback_criteria_approval_approved_at_idx ON rollback_criteria_approval (approved_at DESC); -- 2.47.3