ROL-01: Rollback-Kriterien definieren #281
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user