CUT-01: Trockenlauf 1 - Entwicklung #276

Merged
patrick merged 5 commits from feature/cut-01-trockenlauf-1-entwicklung into main 2026-10-14 23:16:03 +02:00
68 changed files with 10709 additions and 18 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
// cutover-stage fuehrt eine archive-Stufe des CUT-01-Trockenlaufs aus; Aufruf
// ausschliesslich durch cmd/cutover-dryrun im Root-Modul.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/cutoverstage"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "aufruf: cutover-stage <stufe>")
os.Exit(2)
}
env, err := cutoverstage.LoadEnv()
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
os.Exit(1)
}
defer env.Close()
res, err := cutoverstage.Run(context.Background(), env, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
if err := json.NewEncoder(os.Stdout).Encode(res); err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
}
+171
View File
@@ -0,0 +1,171 @@
// Package cutoverstage ist die Archive-Modulstufe des CUT-01-Trockenlaufs
// (Orchestrator: internal/cutoverorchestrator im Root-Modul): Quell-Adapter
// fuer TAR-01, MAI-03, MAI-04 und TAR-02, ohne eigene Migrationslogik.
package cutoverstage
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
type Tenant struct {
Slug string `json:"slug"`
NexarchTenantID string `json:"nexarch_tenant_id"`
ArchivmailTenantID *int64 `json:"archivmail_tenant_id"`
ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"`
TenantDB string `json:"tenant_db"`
ArchiveDB string `json:"archive_db"`
WorkflowDB string `json:"workflow_db"`
}
type Context struct {
RunTag string `json:"run_tag"`
RunDir string `json:"run_dir"`
RegistryDB string `json:"registry_db"`
ArchivmailDB string `json:"archivmail_db"`
ArchivdmsDB string `json:"archivdms_db"`
LegacyObjects string `json:"legacy_objects_dir"`
MigrationsDirs map[string]string `json:"migrations_dirs"`
Tenants []Tenant `json:"tenants"`
}
type Result struct {
Stage string `json:"stage"`
Counts map[string]int `json:"counts"`
Notes []string `json:"notes"`
}
func (r *Result) add(key string, n int) {
if r.Counts == nil {
r.Counts = map[string]int{}
}
r.Counts[key] += n
}
func (r *Result) note(format string, args ...any) {
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
type Env struct {
Ctx Context
adminDSN string
pools map[string]*pgxpool.Pool
}
func LoadEnv() (*Env, error) {
adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if adminDSN == "" {
return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt")
}
ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT")
if ctxPath == "" {
return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt")
}
raw, err := os.ReadFile(ctxPath)
if err != nil {
return nil, fmt.Errorf("laufkontext lesen: %w", err)
}
e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}}
if err := json.Unmarshal(raw, &e.Ctx); err != nil {
return nil, fmt.Errorf("laufkontext parsen: %w", err)
}
return e, nil
}
func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) {
if p, ok := e.pools[database]; ok {
return p, nil
}
cfg, err := pgxpool.ParseConfig(e.adminDSN)
if err != nil {
return nil, errors.New("admin-dsn nicht lesbar")
}
cfg.ConnConfig.Database = database
cfg.MaxConns = 4
p, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("verbindung zu %q: %w", database, err)
}
e.pools[database] = p
return p, nil
}
func (e *Env) Close() {
for _, p := range e.pools {
p.Close()
}
}
func (e *Env) HandoffDir(slug string) string {
return filepath.Join(e.Ctx.RunDir, "uebergabe", slug)
}
func (e *Env) TargetDir(kind, slug string) string {
return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug)
}
type registryUserResolver struct {
registry *pgxpool.Pool
tenantID string
}
func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) {
id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64)
if err != nil {
return "", fmt.Errorf("archivdms-benutzer-id %q ungueltig", sourceUserID)
}
var out string
err = r.registry.QueryRow(ctx, `
SELECT nexarch_user_id::text FROM user_migration_map
WHERE nexarch_tenant_id = $1 AND archivdms_user_id = $2 AND approved_at IS NOT NULL
`, r.tenantID, id).Scan(&out)
if err != nil {
return "", fmt.Errorf("archivdms-benutzer %d nicht in freigegebener user_migration_map: %w", id, err)
}
return out, nil
}
func (e *Env) userResolver(ctx context.Context, t Tenant) (registryUserResolver, error) {
reg, err := e.Pool(ctx, e.Ctx.RegistryDB)
if err != nil {
return registryUserResolver{}, err
}
return registryUserResolver{registry: reg, tenantID: t.NexarchTenantID}, nil
}
// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses.
func legacyPath(root, key string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(key))
rel, err := filepath.Rel(root, full)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key)
}
return full, nil
}
func writeJSON(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
+414
View File
@@ -0,0 +1,414 @@
package cutoverstage
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionrequestmigration"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/dsgvorequestmigration"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcilereportmigration"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retention"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionmigration"
)
const (
objectTypeDocument = "dms_document"
objectTypeMessage = "mail_message"
)
type DocumentHandoff struct {
SourceDocumentID string `json:"source_document_id"`
ObjectReference string `json:"object_reference"`
RetainUntil *time.Time `json:"retain_until"`
Migrated bool `json:"migrated"`
}
type MessageHandoff struct {
SourceMessageID string `json:"source_message_id"`
ObjectReference string `json:"object_reference"`
RetainUntil *time.Time `json:"retain_until"`
DataSubjectRef string `json:"data_subject_ref"`
}
func Run(ctx context.Context, e *Env, stage string) (Result, error) {
res := Result{Stage: stage}
var err error
switch stage {
case "schema":
err = e.forTenants(ctx, &res, e.schema)
case "tar01":
err = e.forTenants(ctx, &res, e.tar01)
case "mai03":
err = e.forTenants(ctx, &res, e.mai03)
case "mai04":
err = e.forTenants(ctx, &res, e.mai04)
case "tar02":
err = e.forTenants(ctx, &res, e.tar02)
default:
err = fmt.Errorf("unbekannte archive-stufe %q", stage)
}
return res, err
}
func (e *Env) forTenants(ctx context.Context, res *Result, fn func(context.Context, Tenant, *Result) error) error {
for _, t := range e.Ctx.Tenants {
if err := fn(ctx, t, res); err != nil {
return fmt.Errorf("mandant %s: %w", t.Slug, err)
}
}
return nil
}
// schema wendet archive/migrations auf die frisch angelegte, leere
// Mandanten-Archivdatenbank an; das Archive-Modul hat keinen eigenen
// Migrationslaeufer, eine Versionstabelle ist fuer die Wegwerf-DB unnoetig.
func (e *Env) schema(ctx context.Context, t Tenant, res *Result) error {
pool, err := e.Pool(ctx, t.ArchiveDB)
if err != nil {
return err
}
files, err := filepath.Glob(filepath.Join(e.Ctx.MigrationsDirs["archive"], "*.up.sql"))
if err != nil {
return err
}
sort.Strings(files)
for _, f := range files {
sqlText, err := os.ReadFile(f)
if err != nil {
return err
}
if _, err := pool.Exec(ctx, string(sqlText)); err != nil {
return fmt.Errorf("archive-migration %s: %w", filepath.Base(f), err)
}
}
res.add("mandanten", 1)
res.add("archive_migrationen_angewendet", len(files))
return nil
}
func (e *Env) handoffDocuments(slug string) ([]DocumentHandoff, error) {
var out []DocumentHandoff
err := readJSON(filepath.Join(e.HandoffDir(slug), "dms_objekte.json"), &out)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return out, err
}
func (e *Env) handoffMessages(slug string) ([]MessageHandoff, error) {
var out []MessageHandoff
err := readJSON(filepath.Join(e.HandoffDir(slug), "mail_objekte.json"), &out)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return out, err
}
type staticRetentionSource []retentionmigration.SourceObject
func (s staticRetentionSource) Objects(context.Context) ([]retentionmigration.SourceObject, error) {
return s, nil
}
func (e *Env) tar01(ctx context.Context, t Tenant, res *Result) error {
docs, err := e.handoffDocuments(t.Slug)
if err != nil {
return err
}
msgs, err := e.handoffMessages(t.Slug)
if err != nil {
return err
}
var src staticRetentionSource
for _, d := range docs {
if d.Migrated {
src = append(src, retentionmigration.SourceObject{ObjectType: objectTypeDocument, ObjectReference: d.ObjectReference, RetainUntil: d.RetainUntil})
}
}
for _, m := range msgs {
src = append(src, retentionmigration.SourceObject{ObjectType: objectTypeMessage, ObjectReference: m.ObjectReference, RetainUntil: m.RetainUntil, DataSubjectRef: m.DataSubjectRef})
}
pool, err := e.Pool(ctx, t.ArchiveDB)
if err != nil {
return err
}
out, err := retentionmigration.NewMigrator(pool, src).MigrateAll(ctx, time.Now())
if err != nil {
return err
}
res.add("objekte_registriert", out.ObjectsRegistered)
res.add("fristen_uebernommen", out.RetentionDatesSet)
for _, o := range src {
var id string
if err := pool.QueryRow(ctx, `SELECT id FROM retention_objects WHERE object_type = $1 AND object_reference = $2`, o.ObjectType, o.ObjectReference).Scan(&id); err != nil {
return fmt.Errorf("retention-objekt %s/%s nicht registriert: %w", o.ObjectType, o.ObjectReference, err)
}
got, ok, err := retention.RetainUntil(ctx, pool, id)
if err != nil {
return err
}
switch {
case o.RetainUntil == nil && ok:
return fmt.Errorf("retention-objekt %s/%s: frist gesetzt, obwohl die quelle keine hatte", o.ObjectType, o.ObjectReference)
case o.RetainUntil != nil && (!ok || !got.Equal(*o.RetainUntil)):
return fmt.Errorf("retention-objekt %s/%s: frist weicht von der quelle ab", o.ObjectType, o.ObjectReference)
}
}
res.add("fristen_geprueft", len(src))
return nil
}
type dsgvoSource struct {
legacy *pgxpool.Pool
tenantID int64
}
// Requests: "partial" und "failed" sind in archivmail nicht abgeschlossene
// Art.-17-Antraege und bleiben deshalb aktive Vorgaenge ("open").
func (s dsgvoSource) Requests(ctx context.Context) ([]dsgvorequestmigration.SourceRequest, error) {
rows, err := s.legacy.Query(ctx, `
SELECT id::text, requested_address, requested_by, created_at, status
FROM dsgvo_requests WHERE tenant_id = $1 ORDER BY id
`, s.tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []dsgvorequestmigration.SourceRequest
for rows.Next() {
var r dsgvorequestmigration.SourceRequest
var status string
if err := rows.Scan(&r.SourceRequestID, &r.DataSubjectRef, &r.RequestedBy, &r.RequestedAt, &status); err != nil {
return nil, err
}
switch status {
case "open", "partial", "failed":
r.Status = dsgvorequestmigration.StatusOpen
case "completed":
r.Status = dsgvorequestmigration.StatusCompleted
default:
r.Status = status
}
out = append(out, r)
}
return out, rows.Err()
}
func (e *Env) mai03(ctx context.Context, t Tenant, res *Result) error {
if t.ArchivmailTenantID == nil {
res.note("%s: kein archivmail-mandant, stufe entfaellt", t.Slug)
return nil
}
legacy, err := e.Pool(ctx, e.Ctx.ArchivmailDB)
if err != nil {
return err
}
pool, err := e.Pool(ctx, t.ArchiveDB)
if err != nil {
return err
}
src := dsgvoSource{legacy: legacy, tenantID: *t.ArchivmailTenantID}
out, err := dsgvorequestmigration.NewMigrator(pool, src).MigrateAll(ctx, time.Now())
if err != nil {
return err
}
res.add("offene_anfragen_verarbeitet", out.OpenRequestsProcessed)
res.add("historische_eintraege", out.HistoricalEntriesLogged)
reqs, err := src.Requests(ctx)
if err != nil {
return err
}
msgs, err := e.handoffMessages(t.Slug)
if err != nil {
return err
}
for _, r := range reqs {
if r.Status != dsgvorequestmigration.StatusOpen {
continue
}
objects := 0
for _, m := range msgs {
if m.DataSubjectRef == r.DataSubjectRef {
objects++
}
}
var decisions int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM dsgvo_decision_log WHERE data_subject_ref = $1`, r.DataSubjectRef).Scan(&decisions); err != nil {
return err
}
if objects > 0 && decisions == 0 {
return fmt.Errorf("offene dsgvo-anfrage %s: %d migrierte objekte der betroffenen person, aber keine einzelentscheidung protokolliert", r.SourceRequestID, objects)
}
res.add("einzelentscheidungen", decisions)
}
return nil
}
type reportSource struct {
legacy *pgxpool.Pool
tenantID int64
}
func (s reportSource) Reports(ctx context.Context) ([]reconcilereportmigration.SourceReport, error) {
rows, err := s.legacy.Query(ctx, `
SELECT r.id::text, r.created_at, row_to_json(r)::text
FROM reconciliation_reports r WHERE r.tenant_id = $1 ORDER BY r.id
`, s.tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []reconcilereportmigration.SourceReport
for rows.Next() {
var r reconcilereportmigration.SourceReport
var raw string
if err := rows.Scan(&r.SourceReportID, &r.GeneratedAt, &raw); err != nil {
return nil, err
}
r.ReportJSON = []byte(raw)
out = append(out, r)
}
return out, rows.Err()
}
func (e *Env) mai04(ctx context.Context, t Tenant, res *Result) error {
if t.ArchivmailTenantID == nil {
res.note("%s: kein archivmail-mandant, stufe entfaellt", t.Slug)
return nil
}
legacy, err := e.Pool(ctx, e.Ctx.ArchivmailDB)
if err != nil {
return err
}
pool, err := e.Pool(ctx, t.ArchiveDB)
if err != nil {
return err
}
out, err := reconcilereportmigration.NewMigrator(reportSource{legacy: legacy, tenantID: *t.ArchivmailTenantID}, pool).MigrateAll(ctx)
if err != nil {
return err
}
res.add("berichte", out.ReportsMigrated)
res.add("berichte_uebersprungen", out.ReportsSkipped)
return nil
}
type deleteRequestSource struct {
legacy *pgxpool.Pool
tenantID int64
refs map[string]string
users registryUserResolver
skipped *int
}
// DeleteRequests: pending/confirmed/blocked_retention sind offene
// Vier-Augen-Vorgaenge, executed nur noch Audit, cancelled entfaellt.
// Antragsteller/Bestaetiger werden auf NEXARCH-Benutzer-IDs abgebildet,
// sonst wuerde der Vier-Augen-Vergleich nach der Migration Altsystem-IDs
// mit NEXARCH-IDs vergleichen.
func (s deleteRequestSource) DeleteRequests(ctx context.Context) ([]deletionrequestmigration.SourceDeleteRequest, error) {
rows, err := s.legacy.Query(ctx, `
SELECT document_id::text, requested_by::text, requested_at, confirmed_by::text, confirmed_at, status
FROM document_delete_requests WHERE tenant_id = $1 ORDER BY id
`, s.tenantID)
if err != nil {
return nil, err
}
type row struct {
docID, requestedBy string
requestedAt time.Time
confirmedBy *string
confirmedAt *time.Time
status string
}
var raw []row
for rows.Next() {
var r row
if err := rows.Scan(&r.docID, &r.requestedBy, &r.requestedAt, &r.confirmedBy, &r.confirmedAt, &r.status); err != nil {
rows.Close()
return nil, err
}
raw = append(raw, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
var out []deletionrequestmigration.SourceDeleteRequest
for _, r := range raw {
ref, ok := s.refs[r.docID]
if !ok {
return nil, fmt.Errorf("loeschanfrage zu dokument %s: keine objektreferenz aus dok01", r.docID)
}
req := deletionrequestmigration.SourceDeleteRequest{ObjectType: objectTypeDocument, ObjectReference: ref, RequestedAt: r.requestedAt}
if req.RequestedBy, err = s.users.ResolveUserID(ctx, r.requestedBy); err != nil {
return nil, err
}
switch r.status {
case "pending", "confirmed", "blocked_retention":
case "executed":
if r.confirmedBy == nil || r.confirmedAt == nil {
return nil, fmt.Errorf("vollzogene loeschung zu dokument %s ohne bestaetigung", r.docID)
}
req.Executed = true
req.ExecutedAt = *r.confirmedAt
if req.ExecutedBy, err = s.users.ResolveUserID(ctx, *r.confirmedBy); err != nil {
return nil, err
}
case "cancelled":
*s.skipped++
continue
default:
return nil, fmt.Errorf("loeschanfrage zu dokument %s: unbekannter status %q", r.docID, r.status)
}
out = append(out, req)
}
return out, nil
}
func (e *Env) tar02(ctx context.Context, t Tenant, res *Result) error {
if t.ArchivdmsTenantID == nil {
res.note("%s: kein archivdms-mandant, stufe entfaellt", t.Slug)
return nil
}
docs, err := e.handoffDocuments(t.Slug)
if err != nil {
return err
}
refs := map[string]string{}
for _, d := range docs {
refs[d.SourceDocumentID] = d.ObjectReference
}
legacy, err := e.Pool(ctx, e.Ctx.ArchivdmsDB)
if err != nil {
return err
}
pool, err := e.Pool(ctx, t.ArchiveDB)
if err != nil {
return err
}
users, err := e.userResolver(ctx, t)
if err != nil {
return err
}
skipped := 0
src := deleteRequestSource{legacy: legacy, tenantID: *t.ArchivdmsTenantID, refs: refs, users: users, skipped: &skipped}
out, err := deletionrequestmigration.NewMigrator(pool, src).MigrateAll(ctx)
if err != nil {
return err
}
res.add("offene_loeschanfragen", out.OpenRequestsMigrated)
res.add("vollzogene_loeschungen_audit", out.ExecutedAudited)
res.add("abgebrochene_nicht_migriert", skipped)
return nil
}
@@ -16,11 +16,15 @@ import (
// SourceObject ist EIN bereits migriertes Kernobjekt (Dokument oder
// Nachricht) mit optionaler Frist aus der Quelle. RetainUntil == nil
// bedeutet: Objekt hatte in der Quelle keine Frist.
// bedeutet: Objekt hatte in der Quelle keine Frist. DataSubjectRef
// verknuepft das Objekt mit der betroffenen Person, nach der MAI-03
// (dsgvorequest/dpreport) sucht; ohne sie liefe eine offene
// DSGVO-Loeschanfrage ins Leere (CUT-01, Befund I-4).
type SourceObject struct {
ObjectType string
ObjectReference string
RetainUntil *time.Time
DataSubjectRef string
}
// Source liefert die bereits migrierten Kernobjekte je Altsystem-Adapter
@@ -60,7 +64,12 @@ func (m *Migrator) MigrateAll(ctx context.Context, now time.Time) (Result, error
}
for _, obj := range objs {
id, err := retention.RegisterObject(ctx, m.pool, obj.ObjectType, obj.ObjectReference)
var id string
if obj.DataSubjectRef != "" {
id, err = retention.RegisterObjectForSubject(ctx, m.pool, obj.ObjectType, obj.ObjectReference, obj.DataSubjectRef)
} else {
id, err = retention.RegisterObject(ctx, m.pool, obj.ObjectType, obj.ObjectReference)
}
if err != nil {
return res, fmt.Errorf("retentionmigration: objekt %s/%s registrieren: %w", obj.ObjectType, obj.ObjectReference, err)
}
@@ -43,6 +43,9 @@ func requireTestPool(t *testing.T) *pgxpool.Pool {
if _, err := pool.Exec(ctx, `ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ`); err != nil {
t.Fatalf("schema (retain_until nachruesten): %v", err)
}
if _, err := pool.Exec(ctx, `ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT`); err != nil {
t.Fatalf("schema (data_subject_ref nachruesten): %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_objects CASCADE`)
})
@@ -185,3 +188,36 @@ func TestMigrateAll_ResultIndependentOfInjectedNow(t *testing.T) {
t.Fatalf("lauf mit now=%v lieferte andere frist als lauf mit now=%v: %v != %v", nowA, nowB, gotA, gotB)
}
}
// CUT-01 Befund I-4: die betroffene Person aus der Quelle landet an
// retention_objects, damit MAI-03 die Objekte einer offenen DSGVO-Anfrage
// findet; Objekte ohne Personenbezug bleiben ohne Referenz.
func TestMigrateAll_CarriesDataSubjectRef(t *testing.T) {
pool := requireTestPool(t)
due := time.Date(2031, time.March, 1, 0, 0, 0, 0, time.UTC)
m := NewMigrator(pool, &fixtureSource{objects: []SourceObject{
{ObjectType: "mail_message", ObjectReference: "msg-mit-person", RetainUntil: &due, DataSubjectRef: "betroffene@synth.example"},
{ObjectType: "dms_document", ObjectReference: "doc-ohne-person"},
}})
if _, err := m.MigrateAll(context.Background(), time.Now()); err != nil {
t.Fatalf("migrateall: %v", err)
}
var ref *string
if err := pool.QueryRow(context.Background(), `SELECT data_subject_ref FROM retention_objects WHERE object_reference = 'msg-mit-person'`).Scan(&ref); err != nil {
t.Fatal(err)
}
if ref == nil || *ref != "betroffene@synth.example" {
t.Fatalf("data_subject_ref = %v, erwartet betroffene@synth.example", ref)
}
if got, ok := mustRetainUntil(t, pool, objectID(t, pool, "mail_message", "msg-mit-person")); !ok || !got.Equal(due) {
t.Fatalf("frist ging bei registrierung mit person verloren: %v %v", got, ok)
}
if err := pool.QueryRow(context.Background(), `SELECT data_subject_ref FROM retention_objects WHERE object_reference = 'doc-ohne-person'`).Scan(&ref); err != nil {
t.Fatal(err)
}
if ref != nil {
t.Fatalf("objekt ohne personenbezug hat data_subject_ref %q", *ref)
}
}
+80
View File
@@ -0,0 +1,80 @@
// cutover-dryrun fuehrt CUT-01 aus: Trockenlauf aller Migrationsschritte
// gegen eine vollstaendig synthetische Testkopie auf einem Wegwerf-Cluster.
// Zugang ausschliesslich ueber NEXARCH_CUTOVER_ADMIN_DSN (ersatzweise
// TEST_ADMIN_DSN); der DSN braucht CREATEDB-Rechte.
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator"
)
func main() {
repo := flag.String("repo", ".", "Wurzel des Repository-Checkouts")
workRoot := flag.String("arbeitsverzeichnis", filepath.Join(os.TempDir(), "nexarch-cut01"), "Basisverzeichnis fuer Laufdaten und Protokolle")
seed := flag.Int64("seed", 20261014, "Seed des Testdaten-Generators")
cost := flag.Int("bcrypt-kosten", 12, "bcrypt-Kostenfaktor der synthetischen Passwort-Hashes")
label := flag.String("bezeichnung", "Trockenlauf", "Bezeichnung des Laufs im Protokoll")
keep := flag.Bool("behalten", false, "Datenbanken und Arbeitsdateien nach dem Lauf nicht entfernen")
flag.Parse()
adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if adminDSN == "" {
adminDSN = os.Getenv("TEST_ADMIN_DSN")
}
if adminDSN == "" {
fmt.Fprintln(os.Stderr, "NEXARCH_CUTOVER_ADMIN_DSN (oder TEST_ADMIN_DSN) nicht gesetzt")
os.Exit(2)
}
repoRoot, err := filepath.Abs(*repo)
if err != nil {
fmt.Fprintln(os.Stderr, "repo-pfad:", err)
os.Exit(2)
}
dr, err := cutoverorchestrator.NewDryrun(cutoverorchestrator.Config{
RepoRoot: repoRoot, WorkRoot: *workRoot, Seed: *seed, BcryptCost: *cost,
}, adminDSN)
if err != nil {
fmt.Fprintln(os.Stderr, "vorbereitung:", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
runner := cutoverorchestrator.Runner{Log: os.Stdout, Redactor: dr.Redactor}
fmt.Printf("CUT-01 Trockenlauf %s (%s), Arbeitsverzeichnis %s\n", dr.RunTag, *label, dr.RunDir)
rep := runner.Run(ctx, dr.RunTag, *label, dr.Plan())
if err := os.MkdirAll(dr.RunDir, 0o700); err == nil {
if err := cutoverorchestrator.WriteJSON(filepath.Join(dr.RunDir, "protokoll.json"), rep); err != nil {
fmt.Fprintln(os.Stderr, dr.Redactor.Redact(err.Error()))
}
if err := os.WriteFile(filepath.Join(dr.RunDir, "protokoll.txt"), []byte(cutoverorchestrator.RenderText(rep)), 0o644); err != nil {
fmt.Fprintln(os.Stderr, dr.Redactor.Redact(err.Error()))
}
}
fmt.Print("\n" + cutoverorchestrator.RenderText(rep))
if *keep {
dr.Close()
fmt.Printf("\nDatenbanken und Arbeitsdateien behalten (Laufkennung %s)\n", dr.RunTag)
} else {
dropped, err := dr.Cleanup(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, "aufraeumen:", dr.Redactor.Redact(err.Error()))
}
fmt.Printf("\nAufgeraeumt: %d Lauf-Datenbanken entfernt, Protokolle unter %s\n", len(dropped), dr.RunDir)
}
if !rep.Success {
os.Exit(1)
}
}
+37
View File
@@ -0,0 +1,37 @@
// cutover-stage fuehrt eine DMS-Stufe des CUT-01-Trockenlaufs aus; Aufruf
// ausschliesslich durch cmd/cutover-dryrun im Root-Modul.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/cutoverstage"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "aufruf: cutover-stage <stufe>")
os.Exit(2)
}
env, err := cutoverstage.LoadEnv()
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
os.Exit(1)
}
defer env.Close()
res, err := cutoverstage.Run(context.Background(), env, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
if err := json.NewEncoder(os.Stdout).Encode(res); err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
}
+194
View File
@@ -0,0 +1,194 @@
// Package cutoverstage ist die DMS-Modulstufe des CUT-01-Trockenlaufs
// (Orchestrator: internal/cutoverorchestrator im Root-Modul). Sie liefert
// die Quell-Adapter gegen die synthetische archivdms-Testkopie und ruft die
// bestehenden DMS-Migratoren auf; eigene Migrationslogik enthaelt sie nicht.
package cutoverstage
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
type Tenant struct {
Slug string `json:"slug"`
NexarchTenantID string `json:"nexarch_tenant_id"`
ArchivmailTenantID *int64 `json:"archivmail_tenant_id"`
ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"`
TenantDB string `json:"tenant_db"`
ArchiveDB string `json:"archive_db"`
WorkflowDB string `json:"workflow_db"`
}
type Context struct {
RunTag string `json:"run_tag"`
RunDir string `json:"run_dir"`
RegistryDB string `json:"registry_db"`
ArchivmailDB string `json:"archivmail_db"`
ArchivdmsDB string `json:"archivdms_db"`
LegacyObjects string `json:"legacy_objects_dir"`
MigrationsDirs map[string]string `json:"migrations_dirs"`
Tenants []Tenant `json:"tenants"`
}
type Result struct {
Stage string `json:"stage"`
Counts map[string]int `json:"counts"`
Notes []string `json:"notes"`
}
func (r *Result) add(key string, n int) {
if r.Counts == nil {
r.Counts = map[string]int{}
}
r.Counts[key] += n
}
func (r *Result) note(format string, args ...any) {
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
type Env struct {
Ctx Context
adminDSN string
kekSeed []byte
pools map[string]*pgxpool.Pool
}
func LoadEnv() (*Env, error) {
adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if adminDSN == "" {
return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt")
}
ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT")
if ctxPath == "" {
return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt")
}
raw, err := os.ReadFile(ctxPath)
if err != nil {
return nil, fmt.Errorf("laufkontext lesen: %w", err)
}
e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}}
if err := json.Unmarshal(raw, &e.Ctx); err != nil {
return nil, fmt.Errorf("laufkontext parsen: %w", err)
}
if seed := os.Getenv("NEXARCH_CUTOVER_KEK_SEED"); seed != "" {
e.kekSeed, err = hex.DecodeString(seed)
if err != nil || len(e.kekSeed) < 32 {
return nil, errors.New("NEXARCH_CUTOVER_KEK_SEED ungueltig")
}
}
return e, nil
}
func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) {
if p, ok := e.pools[database]; ok {
return p, nil
}
cfg, err := pgxpool.ParseConfig(e.adminDSN)
if err != nil {
return nil, errors.New("admin-dsn nicht lesbar")
}
cfg.ConnConfig.Database = database
cfg.MaxConns = 4
p, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("verbindung zu %q: %w", database, err)
}
e.pools[database] = p
return p, nil
}
func (e *Env) Close() {
for _, p := range e.pools {
p.Close()
}
}
// TenantKEK ersetzt im Trockenlauf die Core-KEK-API: pro Lauf zufaelliger
// Seed, pro Mandant per HMAC abgeleitet, damit DOK-01 (verschluesseln) und
// VAL-02 (entschluesseln) in getrennten Prozessen denselben Schluessel sehen.
func (e *Env) TenantKEK(_ context.Context, tenantSlug string) ([]byte, error) {
if len(e.kekSeed) == 0 {
return nil, errors.New("kein kek-seed fuer den trockenlauf gesetzt")
}
mac := hmac.New(sha256.New, e.kekSeed)
mac.Write([]byte("cut01-dryrun-kek\x00" + tenantSlug))
return mac.Sum(nil), nil
}
func (e *Env) HandoffDir(slug string) string {
return filepath.Join(e.Ctx.RunDir, "uebergabe", slug)
}
func (e *Env) TargetDir(kind, slug string) string {
return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug)
}
type registryUserResolver struct {
registry *pgxpool.Pool
tenantID string
}
func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) {
id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64)
if err != nil {
return "", fmt.Errorf("archivdms-benutzer-id %q ungueltig", sourceUserID)
}
var out string
err = r.registry.QueryRow(ctx, `
SELECT nexarch_user_id::text FROM user_migration_map
WHERE nexarch_tenant_id = $1 AND archivdms_user_id = $2 AND approved_at IS NOT NULL
`, r.tenantID, id).Scan(&out)
if err != nil {
return "", fmt.Errorf("archivdms-benutzer %d nicht in freigegebener user_migration_map: %w", id, err)
}
return out, nil
}
func (e *Env) userResolver(ctx context.Context, t Tenant) (registryUserResolver, error) {
reg, err := e.Pool(ctx, e.Ctx.RegistryDB)
if err != nil {
return registryUserResolver{}, err
}
return registryUserResolver{registry: reg, tenantID: t.NexarchTenantID}, nil
}
// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses.
func legacyPath(root, key string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(key))
rel, err := filepath.Rel(root, full)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key)
}
return full, nil
}
func writeJSON(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
+282
View File
@@ -0,0 +1,282 @@
package cutoverstage
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationdocument"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationfolder"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationmetadata"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationsharelink"
)
// documentSource: archivdms kennt keine Revisionstabelle (WORM, eine Datei
// je Dokument), daher liefert der Adapter genau eine Revision 1 je Dokument.
// Bereits vollzogen geloeschte Dokumente (deleted_at) haben keine Datei mehr
// und werden nicht als Dokument uebernommen (TAR-02 fuehrt sie als Audit).
type documentSource struct {
legacy *pgxpool.Pool
tenantID int64
objectRoot string
}
func (s documentSource) Documents(ctx context.Context) ([]migrationdocument.SourceDocument, error) {
rows, err := s.legacy.Query(ctx, `
SELECT id::text, title, COALESCE(created_by::text, '')
FROM documents WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY id
`, s.tenantID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationdocument.SourceDocument, error) {
var d migrationdocument.SourceDocument
err := r.Scan(&d.SourceDocumentID, &d.Title, &d.SourceUserID)
return d, err
})
}
func mimeForPath(p string) string {
switch strings.ToLower(path.Ext(p)) {
case ".pdf":
return "application/pdf"
case ".txt":
return "text/plain"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
}
return "application/octet-stream"
}
func (s documentSource) Revisions(ctx context.Context, sourceDocumentID string) ([]migrationdocument.SourceRevision, error) {
var storagePath, hash, createdBy string
err := s.legacy.QueryRow(ctx, `
SELECT storage_path, content_hash, COALESCE(created_by::text, '') FROM documents WHERE id = $1::bigint
`, sourceDocumentID).Scan(&storagePath, &hash, &createdBy)
if err != nil {
return nil, err
}
full, err := legacyPath(s.objectRoot, storagePath)
if err != nil {
return nil, err
}
info, err := os.Stat(full)
if err != nil {
return nil, fmt.Errorf("quelldatei zu dokument %s: %w", sourceDocumentID, err)
}
return []migrationdocument.SourceRevision{{
SourceRevisionID: sourceDocumentID + "-r1",
SourceDocumentID: sourceDocumentID,
RevisionNumber: 1,
ChecksumSHA256: hash,
SizeBytes: info.Size(),
MimeType: mimeForPath(storagePath),
SourceUserID: createdBy,
ObjectKey: storagePath,
}}, nil
}
func (s documentSource) OpenObject(_ context.Context, objectKey string) (io.ReadCloser, error) {
full, err := legacyPath(s.objectRoot, objectKey)
if err != nil {
return nil, err
}
return os.Open(full)
}
type folderSource struct {
legacy *pgxpool.Pool
tenantID int64
}
// archivdms-Akten sind flach (keine Elternspalte), jede Akte wird Wurzelordner.
func (s folderSource) Folders(ctx context.Context) ([]migrationfolder.SourceFolder, error) {
rows, err := s.legacy.Query(ctx, `
SELECT id::text, titel, COALESCE(created_by::text, '') FROM akten WHERE tenant_id = $1 ORDER BY id
`, s.tenantID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationfolder.SourceFolder, error) {
var f migrationfolder.SourceFolder
err := r.Scan(&f.SourceFolderID, &f.Name, &f.SourceUserID)
return f, err
})
}
func (s folderSource) DocumentFolders(ctx context.Context) ([]migrationfolder.SourceDocumentFolder, error) {
rows, err := s.legacy.Query(ctx, `
SELECT id::text, akte_id::text FROM documents
WHERE tenant_id = $1 AND akte_id IS NOT NULL AND deleted_at IS NULL ORDER BY id
`, s.tenantID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationfolder.SourceDocumentFolder, error) {
var df migrationfolder.SourceDocumentFolder
err := r.Scan(&df.SourceDocumentID, &df.SourceFolderID)
return df, err
})
}
type metadataSource struct {
legacy *pgxpool.Pool
tenantID int64
}
var nonKeyChars = regexp.MustCompile(`[^a-z0-9]+`)
func fieldKey(name string) string {
k := strings.ToLower(name)
k = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss").Replace(k)
return strings.Trim(nonKeyChars.ReplaceAllString(k, "_"), "_")
}
// targetFieldType bildet die archivdms-Feldtypen auf die von MET-02
// (metadata_fields.field_type CHECK) erlaubten Typen ab.
func targetFieldType(legacy string) (string, error) {
switch legacy {
case "text", "number", "date":
return legacy, nil
case "boolean":
return "bool", nil
case "enum":
return "select", nil
case "monetary":
return "number", nil
}
return "", fmt.Errorf("unbekannter archivdms-feldtyp %q", legacy)
}
func (s metadataSource) CustomFields(ctx context.Context) ([]migrationmetadata.SourceCustomField, error) {
rows, err := s.legacy.Query(ctx, `
SELECT id::text, name, field_type, required, enum_options FROM custom_field_defs WHERE tenant_id = $1 ORDER BY id
`, s.tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []migrationmetadata.SourceCustomField
for rows.Next() {
var id, name, legacyType string
var required bool
var enumRaw []byte
if err := rows.Scan(&id, &name, &legacyType, &required, &enumRaw); err != nil {
return nil, err
}
t, err := targetFieldType(legacyType)
if err != nil {
return nil, err
}
rule := map[string]any{}
if len(enumRaw) > 0 {
var opts []any
if err := json.Unmarshal(enumRaw, &opts); err != nil {
return nil, fmt.Errorf("auswahlwerte von feld %s: %w", id, err)
}
rule["options"] = opts
}
if legacyType == "monetary" {
rule["min"] = float64(0)
}
out = append(out, migrationmetadata.SourceCustomField{
SourceFieldID: id, Key: fieldKey(name), Label: name, Type: t, Required: required, ValidationRule: rule,
})
}
return out, rows.Err()
}
func (s metadataSource) ClassificationTemplates(ctx context.Context) ([]migrationmetadata.SourceClassificationTemplate, error) {
rows, err := s.legacy.Query(ctx, `
SELECT t.id::text, t.name, t.retention_years,
COALESCE(array_agg(d.field_def_id::text ORDER BY d.field_def_id) FILTER (WHERE d.field_def_id IS NOT NULL), '{}')
FROM classification_templates t
LEFT JOIN classification_template_field_defaults d ON d.template_id = t.id
WHERE t.tenant_id = $1
GROUP BY t.id ORDER BY t.id
`, s.tenantID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationmetadata.SourceClassificationTemplate, error) {
var t migrationmetadata.SourceClassificationTemplate
var years *int
if err := r.Scan(&t.SourceTemplateID, &t.Name, &years, &t.SourceFieldIDs); err != nil {
return t, err
}
if years != nil {
rc := strconv.Itoa(*years) + "-jahre"
t.RetentionClass = &rc
}
return t, nil
})
}
func (s metadataSource) FieldValues(ctx context.Context) ([]migrationmetadata.SourceFieldValue, error) {
rows, err := s.legacy.Query(ctx, `
SELECT v.document_id::text, v.field_def_id::text, v.value
FROM document_field_values v JOIN documents d ON d.id = v.document_id
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL AND v.value IS NOT NULL
ORDER BY v.document_id, v.field_def_id
`, s.tenantID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationmetadata.SourceFieldValue, error) {
var v migrationmetadata.SourceFieldValue
err := r.Scan(&v.SourceDocumentID, &v.SourceFieldID, &v.Value)
return v, err
})
}
// shareSource liefert nur nicht widerrufene Links auf noch vorhandene
// Dokumente; SourceShareLink kennt kein Widerrufsfeld, ein widerrufener Link
// waere nach der Migration sonst wieder gueltig.
type shareSource struct {
legacy *pgxpool.Pool
tenantID int64
}
func (s shareSource) ShareLinks(ctx context.Context) ([]migrationsharelink.SourceShareLink, error) {
rows, err := s.legacy.Query(ctx, `
SELECT s.id::text, s.document_id::text, s.created_by::text, s.token_hash, s.password_hash,
s.max_accesses, s.access_count, s.created_at, s.expires_at
FROM document_shares s JOIN documents d ON d.id = s.document_id
WHERE s.tenant_id = $1 AND s.revoked_at IS NULL AND d.deleted_at IS NULL
ORDER BY s.id
`, s.tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []migrationsharelink.SourceShareLink
for rows.Next() {
var l migrationsharelink.SourceShareLink
var tokenHex string
var created, expires time.Time
if err := rows.Scan(&l.SourceLinkID, &l.SourceDocumentID, &l.SourceUserID, &tokenHex, &l.PasswordHash,
&l.DownloadLimit, &l.DownloadCount, &created, &expires); err != nil {
return nil, err
}
l.TokenHash, err = hex.DecodeString(strings.TrimSpace(tokenHex))
if err != nil {
return nil, fmt.Errorf("token-hash von freigabelink %s ist kein hex: %w", l.SourceLinkID, err)
}
l.CreatedAt, l.ExpiresAt = created, expires
out = append(out, l)
}
return out, rows.Err()
}
+42
View File
@@ -0,0 +1,42 @@
package cutoverstage
import (
"os"
"path/filepath"
"testing"
)
func TestTargetFieldType_MapsAllArchivdmsTypesToMET02(t *testing.T) {
allowed := map[string]bool{"text": true, "number": true, "date": true, "bool": true, "select": true}
for _, legacy := range []string{"text", "number", "date", "boolean", "enum", "monetary"} {
got, err := targetFieldType(legacy)
if err != nil || !allowed[got] {
t.Errorf("archivdms-typ %q -> %q (err %v) verletzt metadata_fields.field_type", legacy, got, err)
}
}
if _, err := targetFieldType("geodaten"); err == nil {
t.Fatal("unbekannter typ muss abgelehnt werden statt still zu text zu werden")
}
}
func TestFieldKey(t *testing.T) {
cases := map[string]string{"Faellig am": "faellig_am", "Größe (€)": "groesse", "Aktenzeichen": "aktenzeichen"}
for in, want := range cases {
if got := fieldKey(in); got != want {
t.Errorf("fieldKey(%q) = %q, erwartet %q", in, got, want)
}
}
}
func TestLegacyPath_RejectsTraversal(t *testing.T) {
root := t.TempDir()
if _, err := legacyPath(root, "../../etc/passwd"); err == nil {
t.Fatal("objektschluessel ausserhalb des quellverzeichnisses muss abgelehnt werden")
}
if err := os.WriteFile(filepath.Join(root, "ok.txt"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if p, err := legacyPath(root, "ok.txt"); err != nil || p != filepath.Join(root, "ok.txt") {
t.Fatalf("gueltiger schluessel abgelehnt: %v", err)
}
}
+305
View File
@@ -0,0 +1,305 @@
package cutoverstage
import (
"context"
"crypto/rand"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationdocument"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationfolder"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationmetadata"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationsharelink"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
type DocumentHandoff struct {
SourceDocumentID string `json:"source_document_id"`
SourceObjectKey string `json:"source_object_key"`
ObjectReference string `json:"object_reference"`
RetainUntil *time.Time `json:"retain_until"`
Migrated bool `json:"migrated"`
}
type Sample struct {
Label string `json:"label"`
SourceObjectKey string `json:"source_object_key"`
StorageKey string `json:"storage_key"`
}
func Run(ctx context.Context, e *Env, stage string) (Result, error) {
res := Result{Stage: stage}
var err error
switch stage {
case "schema":
err = e.schema(ctx, &res)
case "dok01":
err = e.forDMSTenants(ctx, &res, e.dok01)
case "dok02":
err = e.forDMSTenants(ctx, &res, e.dok02)
case "dok04":
err = e.forDMSTenants(ctx, &res, e.dok04)
case "dok05":
err = e.forDMSTenants(ctx, &res, e.dok05)
case "val02":
err = e.forDMSTenants(ctx, &res, e.val02)
default:
err = fmt.Errorf("unbekannte dms-stufe %q", stage)
}
return res, err
}
func (e *Env) forDMSTenants(ctx context.Context, res *Result, fn func(context.Context, Tenant, int64, *Result) error) error {
for _, t := range e.Ctx.Tenants {
if t.ArchivdmsTenantID == nil {
res.note("%s: kein archivdms-mandant, stufe entfaellt", t.Slug)
continue
}
if err := fn(ctx, t, *t.ArchivdmsTenantID, res); err != nil {
return fmt.Errorf("mandant %s: %w", t.Slug, err)
}
}
return nil
}
func (e *Env) schema(ctx context.Context, res *Result) error {
migrations, err := migrate.Load(e.Ctx.MigrationsDirs["dms"])
if err != nil {
return err
}
for _, t := range e.Ctx.Tenants {
pool, err := e.Pool(ctx, t.TenantDB)
if err != nil {
return err
}
var usersPresent bool
if err := pool.QueryRow(ctx, `SELECT to_regclass('users') IS NOT NULL`).Scan(&usersPresent); err != nil {
return err
}
if !usersPresent {
return fmt.Errorf("mandant %s: tabelle users fehlt, core-tenant-schema nicht ausgerollt", t.Slug)
}
applied, err := migrate.Up(ctx, pool, migrations)
if err != nil {
return fmt.Errorf("mandant %s: dms-migrationen: %w", t.Slug, err)
}
res.add("mandanten", 1)
res.add("dms_migrationen_angewendet", len(applied))
}
return nil
}
type noopUsage struct{}
func (noopUsage) Report(context.Context, string, string, int64) error { return nil }
func (e *Env) dmsServices(t Tenant) (*storage.Service, *dmscrypto.Service, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, nil, err
}
dir := e.TargetDir("dms", t.Slug)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, nil, err
}
driver := storage.NewLocalDriver(dir, secret, "", 0)
return storage.NewService(driver, noopUsage{}, t.Slug), dmscrypto.NewService(e), nil
}
func (e *Env) dok01(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error {
legacy, err := e.Pool(ctx, e.Ctx.ArchivdmsDB)
if err != nil {
return err
}
target, err := e.Pool(ctx, t.TenantDB)
if err != nil {
return err
}
users, err := e.userResolver(ctx, t)
if err != nil {
return err
}
storageSvc, cryptoSvc, err := e.dmsServices(t)
if err != nil {
return err
}
src := documentSource{legacy: legacy, tenantID: legacyTenantID, objectRoot: filepath.Join(e.Ctx.LegacyObjects, "archivdms")}
out, err := migrationdocument.NewMigrator(target, src, users, storageSvc, cryptoSvc, t.Slug).MigrateAll(ctx)
if err != nil {
return err
}
res.add("dokumente", out.DocumentsMigrated)
res.add("revisionen", out.RevisionsMigrated)
res.add("revisionen_uebersprungen", out.RevisionsSkipped)
rows, err := legacy.Query(ctx, `
SELECT id::text, storage_path, retain_until::timestamptz, deleted_at IS NULL
FROM documents WHERE tenant_id = $1 ORDER BY id
`, legacyTenantID)
if err != nil {
return err
}
defer rows.Close()
var handoff []DocumentHandoff
for rows.Next() {
var h DocumentHandoff
if err := rows.Scan(&h.SourceDocumentID, &h.SourceObjectKey, &h.RetainUntil, &h.Migrated); err != nil {
return err
}
h.SourceObjectKey = "archivdms/" + h.SourceObjectKey
h.ObjectReference = migrationdocument.DocumentID(t.Slug, h.SourceDocumentID)
handoff = append(handoff, h)
}
if err := rows.Err(); err != nil {
return err
}
res.add("uebergabe_objekte", len(handoff))
return writeJSON(filepath.Join(e.HandoffDir(t.Slug), "dms_objekte.json"), handoff)
}
func (e *Env) dok02(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error {
legacy, target, users, err := e.legacyTargetUsers(ctx, t)
if err != nil {
return err
}
out, err := migrationfolder.NewMigrator(target, folderSource{legacy: legacy, tenantID: legacyTenantID}, users, t.Slug).MigrateAll(ctx)
if err != nil {
return err
}
res.add("akten", out.FoldersMigrated)
res.add("akten_uebersprungen", out.FoldersSkipped)
res.add("dokumente_verknuepft", out.DocumentsLinked)
return nil
}
func (e *Env) dok04(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error {
legacy, target, _, err := e.legacyTargetUsers(ctx, t)
if err != nil {
return err
}
out, err := migrationmetadata.NewMigrator(target, metadataSource{legacy: legacy, tenantID: legacyTenantID}, t.Slug).MigrateAll(ctx)
if err != nil {
return err
}
res.add("felder", out.CustomFieldsMigrated)
res.add("vorlagen", out.TemplatesMigrated)
res.add("feldwerte", out.FieldValuesMigrated)
return nil
}
func (e *Env) dok05(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error {
legacy, target, users, err := e.legacyTargetUsers(ctx, t)
if err != nil {
return err
}
out, err := migrationsharelink.NewMigrator(target, shareSource{legacy: legacy, tenantID: legacyTenantID}, users, t.Slug).MigrateAll(ctx)
if err != nil {
return err
}
res.add("freigabelinks", out.LinksMigrated)
res.add("freigabelinks_abgelaufen_verworfen", out.LinksExpired)
return nil
}
func (e *Env) legacyTargetUsers(ctx context.Context, t Tenant) (legacy, target *pgxpool.Pool, users registryUserResolver, err error) {
if legacy, err = e.Pool(ctx, e.Ctx.ArchivdmsDB); err != nil {
return
}
if target, err = e.Pool(ctx, t.TenantDB); err != nil {
return
}
users, err = e.userResolver(ctx, t)
return
}
const val02SampleSize = 5
// val02 entschluesselt eine Stichprobe migrierter Revisionen ueber denselben
// crypto.Service wie der Lesepfad; den eigentlichen Hash-Vergleich macht
// internal/migrationverify (VAL-02) im Root-Modul auf diesen Klartexten.
func (e *Env) val02(ctx context.Context, t Tenant, _ int64, res *Result) error {
target, err := e.Pool(ctx, t.TenantDB)
if err != nil {
return err
}
storageSvc, cryptoSvc, err := e.dmsServices(t)
if err != nil {
return err
}
var handoff []DocumentHandoff
if err := readJSON(filepath.Join(e.HandoffDir(t.Slug), "dms_objekte.json"), &handoff); err != nil {
return fmt.Errorf("uebergabe aus dok01 lesen: %w", err)
}
sourceKeyByRef := map[string]string{}
for _, h := range handoff {
sourceKeyByRef[h.ObjectReference] = h.SourceObjectKey
}
rows, err := target.Query(ctx, `
SELECT fr.id::text, fr.document_id::text, fr.storage_key, fr.wrapped_dek
FROM file_revisions fr ORDER BY fr.created_at, fr.id LIMIT $1
`, val02SampleSize)
if err != nil {
return err
}
type rev struct {
id, docID, key string
dek []byte
}
var revs []rev
for rows.Next() {
var r rev
if err := rows.Scan(&r.id, &r.docID, &r.key, &r.dek); err != nil {
rows.Close()
return err
}
revs = append(revs, r)
}
rows.Close()
outDir := filepath.Join(e.Ctx.RunDir, "val02", t.Slug)
if err := os.MkdirAll(outDir, 0o700); err != nil {
return err
}
var samples []Sample
for _, r := range revs {
src, ok := sourceKeyByRef[r.docID]
if !ok {
return fmt.Errorf("revision %s: kein quellobjekt in der dok01-uebergabe", r.id)
}
if err := decryptTo(ctx, storageSvc, cryptoSvc, t.Slug, r.key, r.dek, filepath.Join(outDir, r.id)); err != nil {
return fmt.Errorf("revision %s entschluesseln: %w", r.id, err)
}
samples = append(samples, Sample{Label: t.Slug + "/" + r.id, SourceObjectKey: src, StorageKey: filepath.ToSlash(filepath.Join(t.Slug, r.id))})
}
res.add("stichprobe", len(samples))
return writeJSON(filepath.Join(outDir, "stichprobe.json"), samples)
}
func decryptTo(ctx context.Context, s *storage.Service, c *dmscrypto.Service, slug, key string, dek []byte, dst string) error {
rc, err := s.Get(ctx, key)
if err != nil {
return err
}
defer func() { _ = rc.Close() }()
plain, err := c.Open(ctx, slug, dek, rc)
if err != nil {
return err
}
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(f, plain); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
@@ -85,6 +85,13 @@ type Result struct {
RevisionsSkipped int
}
// DocumentID ist die Ziel-ID, unter der MigrateAll ein Quelldokument ablegt.
// Nachgelagerte Schritte in anderen Modulen (TAR-01/TAR-02 in archive)
// brauchen sie als ObjectReference, koennen deriveUUID aber nicht importieren.
func DocumentID(tenantSlug, sourceDocumentID string) string {
return deriveUUID("document", tenantSlug, sourceDocumentID)
}
// MigrateAll uebertraegt alle Dokumente samt Revisionen (Akzeptanzkriterium
// 1). Dokument- und Revisions-IDs im Ziel werden deterministisch aus den
// Quell-IDs abgeleitet (deriveUUID) statt per gen_random_uuid() vergeben —
+342
View File
@@ -0,0 +1,342 @@
# CUT-01 – Prüfprotokoll: Trockenlauf 1 – Entwicklung
Branch `feature/cut-01-trockenlauf-1-entwicklung`, Testhost `root@192.168.1.204`,
Wegwerf-Cluster `coredev` (PostgreSQL 17, Port 5433, Zugang über
`TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, nie ausgegeben).
Keine Verbindung zu archivmail/archivdms oder deren Daten, kein Zugriff auf
den Cluster `main` (5432) und die dort laufende archivdms-Instanz.
## Umfang
Zentrales Orchestrierungswerkzeug für den ersten Trockenlauf aller
Migrationsskripte. Es erzeugt eine vollständig synthetische Testkopie beider
Altsysteme, führt die **bestehenden** Migratoren in technisch korrekter
Reihenfolge aus und protokolliert jeden Schritt (JSON + Text). Migrationslogik
wird nicht dupliziert; das Werkzeug liefert Reihenfolge, Quell-Adapter gegen
die Testkopie, Testkopie und Protokoll.
| Pfad | Inhalt |
|---|---|
| `cmd/cutover-dryrun` | Einstiegspunkt (Flags: `-repo`, `-arbeitsverzeichnis`, `-seed`, `-bcrypt-kosten`, `-bezeichnung`, `-behalten`) |
| `internal/cutoverorchestrator` | Ablaufplan + Abhängigkeitsprüfung (`plan.go`), Runner mit Abbruch/Protokoll (`runner.go`, `protocol.go`), Redigierung (`redact.go`), Generator der Testkopie (`dataset.go`, `legacyschema.go`, `seed.go`), Anonymisierungsnachweis (`anonymization.go`), Export-Sichten (`export.go`), Schritte (`steps_*.go`), Modulstufen-Aufruf (`stages.go`), Cluster-Hilfen (`cluster.go`) |
| `dms/cmd/cutover-stage`, `dms/internal/cutoverstage` | DMS-Stufe: Schema, DOK-01, DOK-02, DOK-04, DOK-05, VAL-02-Entschlüsselung |
| `mail/cmd/cutover-stage`, `mail/internal/cutoverstage` | Mail-Stufe: MAI-02 |
| `archive/cmd/cutover-stage`, `archive/internal/cutoverstage` | Archive-Stufe: Schema, TAR-01, MAI-03, MAI-04, TAR-02 |
| `workflow/cmd/cutover-stage`, `workflow/internal/cutoverstage` | Workflow-Stufe: Schema, WFL-01 |
| `docs/cut-01/iteration-1`, `docs/cut-01/iteration-2` | Original-Protokolle aller Läufe (Text, JSON, VAL-01-/VAL-02-Berichte) |
**Warum Modulstufen als eigene Prozesse:** `dms/`, `mail/`, `archive/` und
`workflow/` sind eigene Go-Module; ihre `internal`-Pakete (die Migratoren) sind
aus dem Root-Modul nicht importierbar. Der Orchestrator baut je Modul ein
Stufen-Binary (`go build`), startet es mit minimaler Umgebung (Zugangsdaten nur
per Umgebungsvariable, nie als Argument) und liest das Ergebnis als JSON.
Gemeinsamer Laufkontext: `kontext.json` (DB-Namen, Tenant-Zuordnung, keine
Zugangsdaten). Übergaben zwischen Modulen (z. B. DOK-01 → TAR-01: Ziel-IDs der
Dokumente) laufen über Dateien unter `uebergabe/<slug>/`.
**Datenbank-Topologie im Lauf** (alle Namen mit Laufkennung `cut01xxxxxx`,
dadurch keine Kollision mit fremden Test-DBs, Aufräumen nur über diese Kennung):
| Datenbank | Inhalt |
|---|---|
| `<lauf>_quelle_archivmail`, `<lauf>_quelle_archivdms` | synthetische Testkopie der Altsysteme |
| `<lauf>_registry` | Core-Registry (alle `migrations/*.up.sql`) inkl. Maps, `object_acl`, `audit_events` |
| `tenant_<slug>` (von COR-01 provisioniert) | Core-Tenant-Schema + DMS-Schema (gleiche physische DB, siehe DMS FDN-02) |
| `<slug>_archive` | Archive-Schema je Mandant (Modell C) |
| `<slug>_workflow` | Workflow-Schema je Mandant (eigene DB: `module_registrations` existiert in Archive **und** Workflow) |
## Ablaufplan und Reihenfolge
Der Plan wird vor dem ersten Datenbankzugriff von `ValidatePlan` geprüft: jede
Abhängigkeit muss im Plan vorher stehen, sonst läuft kein einziger Schritt.
| # | Schritt | Paket / Funktion | hängt ab von | Begründung |
|---|---|---|---|---|
| 1 | PREP-01 | Arbeitsverzeichnis, Modulstufen bauen | – | |
| 2 | PREP-02 | Testkopie erzeugen | PREP-01 | |
| 3 | PREP-03 | Anonymisierungsnachweis | PREP-02 | vor jeder Verarbeitung |
| 4 | PREP-04 | Core-Registry (Core-Migrationen) | PREP-01 | Maps/Registry-Tabellen |
| 5 | EXP-01 | Export-Sichten (Iteration-1-Korrektur I-1) | PREP-03 | Exportform für MAP-01/MAP-03 |
| 6 | MAP-01 | `tenantmatch.ExportTenants/Match` | EXP-01 | Tenant-Identität vor allem anderen |
| 7 | MAP-02 | `tenantapproval.InsertCandidates/Approve/Correct` | MAP-01, PREP-04 | Freigabe Pflicht vor COR-01 |
| 8 | MAP-03 | `usermatch.ExportUsers/Match` | MAP-02 | Abgleich nur in freigegebenen Tenants |
| 9 | MAP-04* | Trockenlauf-Ersatz: `user_migration_map`/`role_migration_map` | MAP-03 | MAP-04 ist nicht gebaut (siehe Befunde) |
| 10 | COR-01 | `migrationtenant` + `tenant.Provisioner` | MAP-02 | legt `tenant_<slug>` an |
| 11 | SCHEMA-01 | `migrate.Orchestrator.RolloutAll` (TEN-07) | COR-01 | `users`-Tabelle vor DMS-Schema |
| 12 | SCHEMA-02 | DMS `migrate.Up`, Archive-SQL, Workflow `migrate.Up` | SCHEMA-01 | DMS braucht `users` |
| 13 | COR-02 | `migrationuser` (inkl. LDAP-Felder COR-04) | MAP-04*, SCHEMA-01 | |
| 14 | COR-04 | Prüfung `user.LDAPInfo` gegen Map | COR-02 | COR-04 hat keinen eigenen Migrator, steckt in COR-02 |
| 15 | COR-03 | `migrationrole` | COR-02 | `role_assignments` → FK `users` |
| 16 | COR-05 | Staging aus `audit_log` + `migrationaudit` | MAP-04*, COR-01 | braucht beide Maps |
| 17 | DOK-01 | `migrationdocument` | COR-02, SCHEMA-02 | `created_by` → migrierte Benutzer |
| 18 | DOK-02 | `migrationfolder` | DOK-01 | verknüpft migrierte Dokumente |
| 19 | DOK-04 | `migrationmetadata` | DOK-01 | Feldwerte an Dokumenten |
| 20 | DOK-03 | `migrationacl` + Core-Gruppen (`rbac.GroupStore`) | DOK-01, COR-02 | Objektrechte auf migrierte Dokumente/Gruppen (korrigiert durch Verifikation: tatsächliche Abhängigkeit ist COR-02/`user_migration_map`, nicht COR-03; Ausführungsreihenfolge war davon unberührt, da COR-02 ohnehin vor COR-03 läuft) |
| 21 | DOK-05 | `migrationsharelink` | DOK-01 | FK `share_links.document_id` |
| 22 | MAI-02 | `messagemigration` | COR-01 | Ziel-Slug |
| 23 | TAR-01 | `retentionmigration` | DOK-01, MAI-02, SCHEMA-02 | ObjectReference kommt aus DOK-01/MAI-02 |
| 24 | MAI-03 | `dsgvorequestmigration` | TAR-01 | sucht Objekte der betroffenen Person |
| 25 | MAI-04 | `reconcilereportmigration` | SCHEMA-02 | |
| 26 | TAR-02 | `deletionrequestmigration` | TAR-01, COR-02 | gleiche Retention-Objekte, Antragsteller → NEXARCH-ID |
| 27 | WFL-01 | `migrationworkflow` | COR-02, SCHEMA-02 | Akteursauflösung |
| 28 | VAL-01 | `migrationverify.Run` | COR-02, DOK-01, MAI-02 | |
| 29 | VAL-02 | `migrationverify.CompareSamples` | DOK-01, MAI-02 | |
| 30 | VAL-05 | `auth.LoginService.Login` | COR-02 | |
| 31 | CHK-01 | modulübergreifende Kettenprüfung | DOK-03, DOK-05, TAR-02, MAI-03, MAI-04, WFL-01 | |
Abbruchverhalten: beim ersten Fehler wird abgebrochen, alle Folgeschritte
werden mit „nicht ausgeführt: Abbruch nach Fehler in X“ protokolliert (die
Einzelpakete arbeiten selbst fail-closed, ein Weiterlaufen würde nur
Folgefehler protokollieren). Panics werden als Fehler des Schritts erfasst.
## Synthetische Testkopie
Erzeugt ausschließlich von `GenerateDataset` (deterministisch über `-seed`,
Standard 20261014). Strukturell nach `datenmodell-altsysteme.md`: archivmail
mit `tenants`/`tenant_domains`, `users.username`/`role`/`source`, `emails` +
`storage_objects` + `attachments` + `email_attachments` + `email_refs`,
`dsgvo_requests`, `reconciliation_reports`, append-only `audit_log`;
archivdms mit `users` inkl. `auth_source`/`ldap_uid`/`ldap_synced_at`,
`permission_groups`/`_members`, `document_visibility`, `document_grants`
(inkl. `deny`), `akten`, WORM-`documents` ohne Revisionstabelle,
`custom_field_defs`, `classification_templates`, `document_shares`,
`document_delete_requests`, `workflows`/`workflow_runs`, `audit_log`.
Abgebildete Konstellationen: Mandant in beiden Systemen per Slug-Treffer
(„Kanzlei Musterhausen“), Mandant nur über die manuelle Kundenzuordnungsliste
zuordenbar („Steuerbüro Beispielstadt“, abweichende Slugs, zwei Domains),
je ein Mandant nur in archivmail („Praxis Fantasia“) bzw. nur in archivdms
(„Ingenieurbüro Erfundenhausen“); Doppelkonten mit unterschiedlicher
Groß-/Kleinschreibung; LDAP-Konten beider Feldtiefen; alle dokumentierten
archivmail-Rollen außer `superadmin`; Fristen in Zukunft/Vergangenheit/keine;
aktive, abgelaufene, widerrufene Freigabelinks und Link auf gelöschtes
Dokument; offene/vollzogene/abgebrochene Löschanfragen; laufende,
abgeschlossene, abgebrochene Workflows; DSGVO-Anfragen `open`/`partial`/
`completed`; eine Mail mit Sichtbarkeit in zwei Mandanten.
Mengen je Lauf: 6 Altsystem-Mandanten, 30 Konten, 24 Dokumente, 18 Mails,
9 Anhänge, 48 Binärobjekte, 431 Tabellenzeilen.
## Akzeptanzkriterium 3: Anonymisierung nachweislich wirksam
**Argumentation:** Die Testkopie ist keine anonymisierte Kopie echter Daten,
sondern von Grund auf erzeugt. Das Werkzeug hat keinen Eingang für Fremddaten:
`GenerateDataset(seed, now, bcryptCost)` hat keinen Parameter für Dateien,
DSNs oder Exporte, die einzigen Datenquellen sind Konstanten und ein
Pseudozufallsgenerator in `internal/cutoverorchestrator/dataset.go`. Die
Quell-Datenbanken werden in jedem Lauf neu angelegt (`CREATE DATABASE`) und
ausschließlich daraus befüllt. Es existiert kein Codepfad, der eine Verbindung
zu archivmail/archivdms aufbaut. Echte Personendaten können daher im Testlauf
nicht vorkommen.
**Technischer Nachweis je Lauf (Schritt PREP-03, `VerifyAnonymization`)**,
unabhängig vom Generator: jede Zeile jeder Tabelle beider Quell-DBs wird als
JSON gelesen und durchsucht.
| Prüfung | Zielwert | Ergebnis (Läufe 2.1 und 2.2) |
|---|---|---|
| E-Mail-Adressen nur unter `.example` (RFC 2606 reserviert) | 0 Verstöße | 93 Adressen, 0 Verstöße |
| IP-Adressen nur aus Dokumentationsnetzen (RFC 5737) | 0 Verstöße | 24 Adressen, 0 Verstöße |
| Konten mit synthetischem Präfix „testperson“ | alle | 30/30 |
| Binärobjekte beginnen mit Marker `SYNTHETISCH` | alle | 48/48 |
| Tabellen/Zeilen geprüft | alle | 30 Tabellen, 431 Zeilen |
Unit-Tests: `TestGenerateDataset_ContainsOnlySyntheticPersonalData` (alle
erzeugten Personenangaben synthetisch) und
`TestAnonymizationCheck_DetectsRealLookingData` (die Prüfung erkennt eine
reale Domain und eine öffentliche IP – der Nachweis ist also wirksam, nicht
nur formal). Passwörter: zufällig erzeugt, nur als bcrypt-Hash (Kosten 12) in
der Testkopie, Klartext nur im Speicher des Laufs für VAL-05.
## Iterationsprotokoll
### Iteration 1 – Fehlerfindung (Läufe 1.1 bis 1.5)
| Lauf | Kennung | Abbruch bei | Fehler | Korrektur vor dem nächsten Lauf |
|---|---|---|---|---|
| 1.1 | cut01479c9d | MAP-01 | `tenants-export (archivmail): column "domain" does not exist` | **I-1** siehe unten |
| 1.2 | cut01a6173e | MAP-02 | Vollständigkeit: `archivmail/praxis-fantasia`, `archivdms/ingenieurbuero-erfundenhausen` ohne Eintrag in der freigegebenen `tenant_migration_map` | **I-2** |
| 1.3 | cut01c33bb7 | COR-03 | `keine core-rolle fuer archivmail-rolle "auditor" hinterlegt` | **I-3** |
| 1.4 | cut0171964d | MAI-03 | offene DSGVO-Anfrage: 2 migrierte Objekte der betroffenen Person, aber keine Einzelentscheidung | **I-4** |
| 1.5 | cut01748e71 | – | alle 31 Schritte erfolgreich | – |
**I-1 – Export-Schemaannahme MAP-01/MAP-03 (Integrationsfehler).**
`tenantmatch.ExportTenants` liest `tenants(id, slug, name, domain)`,
`usermatch.ExportUsers` liest `users(id, tenant_id, email, name,
password_hash)`. archivmail führt Domains in `tenant_domains` (1:n), beide
Altsysteme kennen nur `users.username`, archivmail-Superadmins haben
`tenant_id NULL`. Das MAP-01-Prüfprotokoll hatte genau diese Annahme als offen
markiert. Bei der Ursachenanalyse zu Lauf 1.1 wurde die gleiche Annahme in
MAP-03 per Spaltenabfrage der Testkopie bestätigt und mitbehoben.
Korrektur (ohne Änderung der MAP-Pakete): neuer Schritt **EXP-01** legt in
jeder Quelle das Schema `cutover_export` mit Sichten `tenants` (archivmail:
erste registrierte Domain je Mandant) und `users` (`username AS name`,
archivmail nur Konten mit Mandant) an; MAP-01/MAP-03 lesen über Pools mit
`search_path=cutover_export,public` (`export.go`, `Cluster.ConnectSchema`).
**I-2 – Einseitige Mandanten gingen verloren (Kettenfehler in MAP-02/MAP-03).**
`tenantapproval.InsertCandidates` verwarf Kandidaten, die nur in einem
Altsystem existieren; `usermatch.Match` übersprang solche Mandanten. Da die
`tenant_migration_map` die einzige Quelle für COR-01 ist, wären diese
Mandanten samt Benutzern, Dokumenten und Mails nie migriert worden – ohne
Fehlermeldung. MAP-01 gibt einseitige Kandidaten ausdrücklich aus, „damit sie
in der Freigabeprüfung sichtbar bleiben“.
Korrektur: `InsertCandidates` legt einseitige Kandidaten als `pending` an
(nur Kandidaten ganz ohne Quelle werden verworfen), Freigabepflicht
unverändert; `Match` liefert für einseitig freigegebene Mandanten die Benutzer
der vorhandenen Seite als einseitige Kandidaten. Test
`TestInsertCandidates_SkipsOneSidedCandidates` ersetzt durch
`TestInsertCandidates_KeepsOneSidedCandidatesPending` (prüft zusätzlich: ohne
Freigabe nicht nutzbar), neu `TestMatch_EinseitigFreigegebenerTenantLiefertEinseitigeKandidaten`.
**I-3 – Dokumentierte archivmail-Rollen ohne Core-Rolle (COR-03).**
`archivmailRoleMap` kannte `domain_admin`, `auditor`, `domain_auditor` nicht
(alle in `datenmodell-altsysteme.md` belegt) → fail-closed-Abbruch.
Korrektur: `domain_admin → tenant_admin`, `auditor`/`domain_auditor → user`
(Core hat keine Prüferrolle; geringste Rolle statt Rechteausweitung),
`superadmin` bleibt bewusst ungemappt. Test `TestCoreRole_DocumentedArchivmailRoles`.
**I-4 – DSGVO-Anfragen ohne Objektbezug (Kettenfehler TAR-01 → MAI-03).**
MAI-03 (`dsgvorequest.ProcessDeletionRequest` → `dpreport.SubjectReport`)
findet Objekte nur über `retention_objects.data_subject_ref`; TAR-01
registrierte über `retention.RegisterObject` ohne diese Referenz. Folge: eine
offene Art.-17-Anfrage wurde als „verarbeitet“ gezählt, aber ohne eine
einzige Einzelentscheidung. Erkannt durch die Nachprüfung der Archive-Stufe.
Korrektur: `retentionmigration.SourceObject.DataSubjectRef`; gesetzt →
`retention.RegisterObjectForSubject` (bestehende CMP-02-Funktion), sonst
unverändert `RegisterObject`. Die Mail-Stufe liefert den Absender als
betroffene Person. Test `TestMigrateAll_CarriesDataSubjectRef`.
Bereits vor Lauf 1.1 (Integrationsanalyse, keine Laufkorrektur):
`migrationdocument.DocumentID` exportiert die DOK-01-Ziel-ID, weil TAR-01/
TAR-02 im Archive-Modul die ObjectReference brauchen, `deriveUUID` aber nicht
importieren können (gleiches Muster wie `messagemigration.MessageID`).
### Iteration 2 – Bestätigung (Läufe 2.1 und 2.2)
Beide Läufe auf Commit `4fdbb9b` (Stand nach allen Iteration-1-Korrekturen),
ohne Codeänderung zwischen den Läufen, ohne `-behalten` (Aufräumen durch das
Werkzeug selbst).
| Lauf | Kennung | Ergebnis | Dauer | aufgeräumt |
|---|---|---|---|---|
| 2.1 | cut017f8b14 | alle 31 Schritte erfolgreich | 28,8 s | 15 Lauf-DBs entfernt |
| 2.2 | cut01d7d2a2 | alle 31 Schritte erfolgreich | 27,4 s | 15 Lauf-DBs entfernt |
Danach: 0 Datenbanken mit Laufkennung auf dem Cluster, alle fremden
Test-Datenbanken unverändert.
## Prüfungen
| # | Prüfung | Zielwert | Ergebnis |
|---|---|---|---|
| T1 | Vollständiger Lauf gegen anonymisierte Kopie protokolliert (Ticket-Prüfung 1) | Lauf ohne Abbruch, Protokoll vorhanden | **erfüllt** – Läufe 1.5, 2.1, 2.2; Protokolle `docs/cut-01/iteration-*/` |
| T2 | Mind. zwei Iterationen mit dokumentierten Korrekturen (Ticket-Prüfung 2) | ≥ 2 | **erfüllt** – Iteration 1 (5 Läufe, 4 Korrekturen), Iteration 2 (2 Läufe) |
| AK1 | Alle Migrationsskripte laufen mind. einmal vollständig durch | jeder Schritt `erfolgreich` | **erfüllt** – 31/31 in 1.5, 2.1, 2.2 (Ausnahme MAI-01, siehe B-5) |
| AK2 | Gefundene Fehler vor Trockenlauf 2 behoben | alle abbrechenden Fehler behoben | **erfüllt** – I-1 bis I-4 behoben und in Iteration 2 bestätigt; nicht abbrechende Befunde mit Fach-/Zielsystem-Entscheidung siehe unten |
| AK3 | Anonymisierung nachweislich wirksam | 0 Verstöße, Prüfung wirksam | **erfüllt** – siehe Abschnitt AK3 |
| V1 | VAL-01 Zeilenzahl je Tenant/Kerntabelle | 0 offene Befunde | **erfüllt** – 12 Vergleiche; Dokumente 24→21, Differenz 3 erklärt (vollzogene Löschungen, nur Audit) |
| V2 | VAL-02 Hash-Stichprobe Binärdateien | 0 Abweichungen | **erfüllt** – 15 DMS-Dateien (über `crypto.Service` entschlüsselt) + 9 Mail-Anhänge, 0 Abweichungen |
| V3 | VAL-05 Login mit migrierten Zugangsdaten | alle Stichproben-Logins ok, falsches Passwort abgewiesen | **erfüllt** – 12/12 Logins (3 je Tenant), 12/12 Ablehnungen |
| V4 | TAR-01 Fristen exakt übernommen | 0 Abweichungen | **erfüllt** – 40 Objekte geprüft, 27 Fristen identisch, 13 ohne Frist bleiben ohne |
| V5 | COR-05 Audit-Kontinuität | Zeitstempel/Aktion unverändert, nichts unmigriert | **erfüllt** – 24/24 Ereignisse |
| V6 | CHK-01 modulübergreifend | 0 Befunde | **erfüllt** – je Dokument/Nachricht ein Retention-Objekt (21/19), keine Objektrechte auf nicht migrierte Dokumente, Freigabelinks 6, Workflows 9, offene Löschanfragen 3, vollzogene 3, Akten 9, Reconciliation-Berichte 9 = Quelle |
| U1 | Orchestrator-Tests (Reihenfolge, Fehlerprotokoll, Abbruch) | grün | **erfüllt** – `go test ./internal/cutoverorchestrator`: Reihenfolge, Abbruch nach Fehler mit Protokoll/Konsole/Text, Panic, ungültiger Plan führt nichts aus, Kontext-Abbruch, Redigierung, Abhängigkeiten des echten Plans, Generator, Anonymisierungsprüfung, Slug/DSN |
| U2 | Regression aller orchestrierten Pakete, real gegen Postgres (`-p 1`) | grün | **erfüllt** – 21 Pakete in root/dms/archive/workflow/mail grün, plus `dms/internal/cutoverstage` |
| S1 | Keine Zugangsdaten in Code/Protokoll | keine Treffer | **erfüllt** – DSN nur aus Umgebung, Redigierung (Literal + URL-Userinfo + `password=`) in Runner und Stufen-Fehlern, Scan der Protokolle ohne Treffer |
## Offene Befunde ohne Abbruch (Übergabe an CUT-02 und Modul-Boards)
Diese Punkte brechen die Kette nicht ab und verfälschen keine Daten in
Richtung Datenverlust oder Rechteausweitung, verlangen aber eine Fach-,
Compliance- oder Zielsystem-Entscheidung („Keine Änderung der
Zielsystem-Fachlogik selbst“, Ticket CUT-01). Sie sind vor dem Stichtag zu
entscheiden.
- **B-1 Archive: abgelaufene Altsystem-Fristen bleiben `active`.** TAR-01
übernimmt `retain_until`, migrierte Objekte haben aber keine
Aufbewahrungsklasse; `deletionworkflow.ReleaseExpired` wertet nur
Klassenzuordnungen aus, nicht `retain_until`. Eine Mail mit Frist in der
Vergangenheit wird von MAI-03 mit „Aufbewahrungsfrist ist noch nicht
abgelaufen“ abgelehnt. GoBD-sicher (nichts wird zu früh gelöscht), aber
sachlich falsche Begründung gegenüber der betroffenen Person.
Empfehlung: RET-02/RET-03 berücksichtigen `retain_until` (Archive-Board).
- **B-2 MAI-03: abgeschlossene DSGVO-Anfragen hinterlassen keine Spur.**
`logHistorical` schreibt nur je noch vorhandenem Objekt; nach einer
vollzogenen Löschung gibt es keine Objekte mehr, `dsgvo_decision_log`
verlangt aber ein Objekt. Im Lauf: 3 abgeschlossene Anfragen, 0 Einträge.
Entscheidung nötig, wo der historische Nachweis liegt (z. B. Archive-Audit).
- **B-3 Dokumenttypen nicht migriert.** archivdms-`document_types` und
`documents.doc_type_id` haben kein Migrationspaket; DOK-04 belegt
`document_types` mit Klassifikationsvorlagen. Feld-Vorgaben der Vorlagen
(`classification_template_field_defaults.default_value`) gehen verloren,
da `SourceClassificationTemplate` kein Feld dafür hat.
- **B-4 Akten-Status.** `akten.status` (offen/geschlossen) hat im
Ordnermodell keine Entsprechung und wird nicht übernommen.
- **B-5 MAI-01 nicht im Lauf.** Die Postfach-Migration verlangt je Postfach
ein vom Betreiber neu vergebenes Passwort und einen echten IMAP-Test; im
Trockenlauf gibt es weder IMAP-Testserver noch sinnvoll erfindbare
Zugangsdaten. Gehört in CUT-02 mit Test-IMAP.
- **B-6 Suchindex-Ersatz.** MAI-02 schreibt Metadaten nur in Manticore; die
einzige Manticore-Instanz auf Host 204 teilt sich das Altsystem archivdms
und bleibt tabu. Im Trockenlauf vertritt ein dateibasierter Index
(`ziel-objekte/mail-index/<slug>.json`) den `SearchIndexer` – gleiches
Interface wie die Paket-Tests. CUT-02 braucht eine eigene Manticore-Instanz.
- **B-7 KEK-Ersatz.** DOK-01/VAL-02 nutzen statt der Core-KEK-API einen
pro Lauf zufälligen Seed (HMAC je Mandant, nur per Umgebungsvariable an die
DMS-Stufe). CUT-02 gegen die echte KEK-API.
- **B-8 MAP-04 fehlt.** Die menschliche Freigabe der Benutzer-Map ist nicht
gebaut; der Trockenlauf gibt jeden MAP-03-Kandidaten mit dem Prüfer
„trockenlauf-pruefer (synthetische freigabe, CUT-01)“ frei, übernimmt
LDAP-Felder aus der Quelle und füllt `role_migration_map` (archivdms:
erste Berechtigungsgruppe, sonst archivmail-Rolle). Ebenso simuliert MAP-02
die Freigabe und korrigiert per `Correct` den Ziel-Slug (Bindestrich →
Unterstrich, Laufpräfix): Altsystem-Slugs mit Bindestrich verletzen die
Core-Slug-Regel, ein Prüfer muss sie bei der Freigabe korrigieren.
- **B-9 archivmail mit mehreren Domains.** MAP-01 vergleicht genau eine
Domain; die Export-Sicht liefert die erste registrierte.
- **B-10 Plattformkonten.** archivmail-Superadmins (`tenant_id NULL`) und die
Rolle `superadmin` werden bewusst nicht mandantenbezogen migriert.
- **B-11 Mandanteneigene Gruppennamen.** COR-03 kennt nur die
archivdms-Standardgruppen; eigene Gruppennamen eines Mandanten brechen
fail-closed ab. Die Testkopie nutzt nur Standardgruppen; für reale Daten
braucht COR-03 eine pflegbare Zuordnung.
- **B-12 Soft-gelöschte Dokumente ohne vollzogene Löschung** sind in der
Testkopie nicht enthalten; DOK-01 kennt keinen Löschzustand und würde sie
als aktiv übernehmen. Der Adapter übernimmt nur `deleted_at IS NULL`.
Beobachtungen an bestehender Test-Infrastruktur (nicht Teil von CUT-01):
Archive-Pakettests teilen sich `TEST_TENANT_DSN` und kollidieren bei
parallelem Paketlauf (mit `-p 1` grün); `internal/migrationtenant`-Test räumt
seine Tenant-DBs nicht ab (`t.Cleanup` nutzt einen per `defer` bereits
geschlossenen Pool) – die durch den eigenen Regressionslauf entstandenen fünf
Datenbanken wurden entfernt.
## Entscheidungen (QA)
1. Modulstufen als Prozesse statt `go.work`/`replace`: keine Änderung an
`go.mod` der Module, `internal`-Sichtbarkeit bleibt unangetastet.
2. Export-Sichten statt Änderung von MAP-01/MAP-03: die Normalisierung
gehört zum Export-Dump des Stichtags, die Pakete bleiben schemagenerisch.
3. Adapter-Entscheidungen (in den Stufen dokumentiert): archivdms-WORM-Datei
= Revision 1; `boolean→bool`, `enum→select`, `monetary→number`;
widerrufene Links und Links auf gelöschte Dokumente werden nicht
angeboten; DSGVO `partial`/`failed` = offen; Löschanfrage
`pending`/`confirmed`/`blocked_retention` = offen, `cancelled` entfällt;
Antragsteller/Bestätiger werden auf NEXARCH-IDs abgebildet (Vier-Augen-
Vergleich); `document_visibility` liefert Erlaubnisse,
`document_grants.deny` die Verbote.
4. Datenbankname enthält stets die Laufkennung, Aufräumen nur über sie; das
Werkzeug löscht nie fremde Datenbanken.
5. Abbruch beim ersten Fehler, kein „weiter bei Fehler“-Modus.
## Bedienung
```bash
set -a && . /etc/nexarch-dev/core-dev.env && set +a # liefert TEST_ADMIN_DSN, nie ausgeben
go run ./cmd/cutover-dryrun -repo . -arbeitsverzeichnis /root/cut01-dryrun -bezeichnung "Trockenlauf"
```
Ergebnis: `protokoll.json`, `protokoll.txt`, VAL-01-/VAL-02-Berichte im
Laufverzeichnis; Exitcode 0 nur bei vollständigem Durchlauf. Mit `-behalten`
bleiben Datenbanken und Arbeitsdateien zur Analyse erhalten.
@@ -0,0 +1,419 @@
{
"lauf_kennung": "cut01479c9d",
"bezeichnung": "Iteration 1 / Lauf 1.1",
"gestartet": "2026-10-14T20:45:13.821217555Z",
"beendet": "2026-10-14T20:45:28.00646213Z",
"erfolgreich": false,
"abbruch_bei": "MAP-01",
"schritte": [
{
"id": "PREP-01",
"titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen",
"status": "erfolgreich",
"gestartet": "2026-10-14T20:45:13.821239068Z",
"dauer_ms": 6353,
"zaehler": {
"modulstufen": 4
},
"hinweise": [
"arbeitsverzeichnis /root/cut01-dryrun/cut01479c9d"
]
},
{
"id": "PREP-02",
"titel": "Synthetische Testkopie archivmail/archivdms erzeugen",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:45:20.17496792Z",
"dauer_ms": 7370,
"zaehler": {
"archivdms_akten": 9,
"archivdms_audit": 12,
"archivdms_delete_requests": 9,
"archivdms_documents": 24,
"archivdms_shares": 15,
"archivdms_tenants": 3,
"archivdms_users": 15,
"archivdms_workflow_runs": 12,
"archivmail_attachments": 9,
"archivmail_audit": 12,
"archivmail_dsgvo": 9,
"archivmail_emails": 18,
"archivmail_reports": 9,
"archivmail_tenants": 3,
"archivmail_users": 15,
"binaerobjekte": 48
},
"hinweise": [
"generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go"
]
},
{
"id": "PREP-03",
"titel": "Anonymisierungsnachweis der Testkopie",
"haengt_ab_von": [
"PREP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:45:27.545213158Z",
"dauer_ms": 23,
"zaehler": {
"binaerobjekte": 48,
"e_mail_adressen": 93,
"ip_adressen": 24,
"personen": 30,
"tabellen": 30,
"verstoesse": 0,
"zeilen": 431
},
"hinweise": [
"alle e-mail-adressen unter der reservierten domain .example (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH"
]
},
{
"id": "PREP-04",
"titel": "Core-Registry anlegen (Core-Migrationen)",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:45:27.56894503Z",
"dauer_ms": 436,
"zaehler": {
"core_migrationen": 42
}
},
{
"id": "MAP-01",
"titel": "Tenant-Export und Slug/Domain-Abgleich",
"haengt_ab_von": [
"PREP-03"
],
"status": "fehlgeschlagen",
"gestartet": "2026-10-14T20:45:28.00539893Z",
"dauer_ms": 0,
"fehler": "tenants-export (archivmail): ERROR: column \"domain\" does not exist (SQLSTATE 42703)"
},
{
"id": "MAP-02",
"titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)",
"haengt_ab_von": [
"MAP-01",
"PREP-04"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "MAP-03",
"titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant",
"haengt_ab_von": [
"MAP-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "MAP-04*",
"titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)",
"haengt_ab_von": [
"MAP-03"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "COR-01",
"titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)",
"haengt_ab_von": [
"MAP-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "SCHEMA-01",
"titel": "Core-Tenant-Schema ausrollen (TEN-07)",
"haengt_ab_von": [
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "SCHEMA-02",
"titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)",
"haengt_ab_von": [
"SCHEMA-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "COR-02",
"titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1",
"haengt_ab_von": [
"MAP-04*",
"SCHEMA-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "COR-04",
"titel": "LDAP-Feld-Migration pruefen",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "COR-03",
"titel": "Rollen- und Rechte-Migration",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "COR-05",
"titel": "Audit-Log-Zusammenfuehrung",
"haengt_ab_von": [
"MAP-04*",
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "DOK-01",
"titel": "Dokument- und Revisions-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "DOK-02",
"titel": "Akten-/Ordnerstruktur-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "DOK-04",
"titel": "Custom-Fields- und Klassifikationsvorlagen-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "DOK-03",
"titel": "ACL-Daten-Migration nach Core-Rechtemodell",
"haengt_ab_von": [
"DOK-01",
"COR-03"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "DOK-05",
"titel": "Freigabelinks-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "MAI-02",
"titel": "Nachrichten- und Anhang-Migration",
"haengt_ab_von": [
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "TAR-01",
"titel": "Retention-Fristen-Migration ohne Reset",
"haengt_ab_von": [
"DOK-01",
"MAI-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "MAI-03",
"titel": "DSGVO-Loeschanfragen-Migration",
"haengt_ab_von": [
"TAR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "MAI-04",
"titel": "Reconciliation-Reports-Archivierung",
"haengt_ab_von": [
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "TAR-02",
"titel": "Vier-Augen-Loeschworkflow-Migration",
"haengt_ab_von": [
"TAR-01",
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "WFL-01",
"titel": "Workflow-Historie-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "VAL-01",
"titel": "Zeilenzahl-Abgleich je Kerntabelle",
"haengt_ab_von": [
"COR-02",
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "VAL-02",
"titel": "Hash-Vergleich migrierter Binaerdateien",
"haengt_ab_von": [
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "VAL-05",
"titel": "Login-Test mit migrierten Zugangsdaten",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
},
{
"id": "CHK-01",
"titel": "Modulgrenzen-uebergreifende Kettenpruefung",
"haengt_ab_von": [
"DOK-03",
"DOK-05",
"TAR-02",
"MAI-03",
"MAI-04",
"WFL-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-01"
]
}
]
}
@@ -0,0 +1,70 @@
CUT-01 Trockenlauf-Protokoll
Lauf: cut01479c9d (Iteration 1 / Lauf 1.1)
Beginn: 2026-10-14 20:45:13 UTC Ende: 2026-10-14 20:45:28 UTC Dauer: 14.185s
Ergebnis: ABGEBROCHEN bei MAP-01
1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (6353 ms)
Zaehler: [modulstufen=4]
Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01479c9d
2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (7370 ms)
Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48]
Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go
3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (23 ms)
Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431]
Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606)
Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737)
Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH
4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (436 ms)
Zaehler: [core_migrationen=42]
5. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – fehlgeschlagen (0 ms)
FEHLER: tenants-export (archivmail): ERROR: column "domain" does not exist (SQLSTATE 42703)
6. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
7. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
8. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
9. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
10. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
11. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
12. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
13. [COR-04] LDAP-Feld-Migration pruefen – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
14. [COR-03] Rollen- und Rechte-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
15. [COR-05] Audit-Log-Zusammenfuehrung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
16. [DOK-01] Dokument- und Revisions-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
17. [DOK-02] Akten-/Ordnerstruktur-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
18. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
19. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
20. [DOK-05] Freigabelinks-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
21. [MAI-02] Nachrichten- und Anhang-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
22. [TAR-01] Retention-Fristen-Migration ohne Reset – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
23. [MAI-03] DSGVO-Loeschanfragen-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
24. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
25. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
26. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
27. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
28. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
29. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
30. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01
@@ -0,0 +1,445 @@
{
"lauf_kennung": "cut01a6173e",
"bezeichnung": "Iteration 1 / Lauf 1.2",
"gestartet": "2026-10-14T20:47:20.105409174Z",
"beendet": "2026-10-14T20:47:33.294769948Z",
"erfolgreich": false,
"abbruch_bei": "MAP-02",
"schritte": [
{
"id": "PREP-01",
"titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen",
"status": "erfolgreich",
"gestartet": "2026-10-14T20:47:20.105419302Z",
"dauer_ms": 5584,
"zaehler": {
"modulstufen": 4
},
"hinweise": [
"arbeitsverzeichnis /root/cut01-dryrun/cut01a6173e"
]
},
{
"id": "PREP-02",
"titel": "Synthetische Testkopie archivmail/archivdms erzeugen",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:47:25.69028809Z",
"dauer_ms": 7135,
"zaehler": {
"archivdms_akten": 9,
"archivdms_audit": 12,
"archivdms_delete_requests": 9,
"archivdms_documents": 24,
"archivdms_shares": 15,
"archivdms_tenants": 3,
"archivdms_users": 15,
"archivdms_workflow_runs": 12,
"archivmail_attachments": 9,
"archivmail_audit": 12,
"archivmail_dsgvo": 9,
"archivmail_emails": 18,
"archivmail_reports": 9,
"archivmail_tenants": 3,
"archivmail_users": 15,
"binaerobjekte": 48
},
"hinweise": [
"generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go"
]
},
{
"id": "PREP-03",
"titel": "Anonymisierungsnachweis der Testkopie",
"haengt_ab_von": [
"PREP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:47:32.825455409Z",
"dauer_ms": 19,
"zaehler": {
"binaerobjekte": 48,
"e_mail_adressen": 93,
"ip_adressen": 24,
"personen": 30,
"tabellen": 30,
"verstoesse": 0,
"zeilen": 431
},
"hinweise": [
"alle e-mail-adressen unter der reservierten domain .example (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH"
]
},
{
"id": "PREP-04",
"titel": "Core-Registry anlegen (Core-Migrationen)",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:47:32.845252493Z",
"dauer_ms": 387,
"zaehler": {
"core_migrationen": 42
}
},
{
"id": "EXP-01",
"titel": "Export-Sichten der Altsysteme bereitstellen",
"haengt_ab_von": [
"PREP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:47:33.233288547Z",
"dauer_ms": 13,
"zaehler": {
"export_sichten": 4,
"plattformkonten_ohne_mandant": 0
},
"hinweise": [
"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"
]
},
{
"id": "MAP-01",
"titel": "Tenant-Export und Slug/Domain-Abgleich",
"haengt_ab_von": [
"EXP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:47:33.246423416Z",
"dauer_ms": 13,
"zaehler": {
"archivdms_tenants": 3,
"archivmail_tenants": 3,
"kandidaten": 4,
"konflikte": 0,
"manuelle_zuordnungen": 1
}
},
{
"id": "MAP-02",
"titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)",
"haengt_ab_von": [
"MAP-01",
"PREP-04"
],
"status": "fehlgeschlagen",
"gestartet": "2026-10-14T20:47:33.260088906Z",
"dauer_ms": 34,
"zaehler": {
"eintraege_angelegt": 2,
"freigegeben": 2
},
"fehler": "vollstaendigkeit: 2 altsystem-mandanten ohne eintrag in der freigegebenen tenant_migration_map, sie wuerden nie migriert: archivmail/praxis-fantasia, archivdms/ingenieurbuero-erfundenhausen"
},
{
"id": "MAP-03",
"titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant",
"haengt_ab_von": [
"MAP-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "MAP-04*",
"titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)",
"haengt_ab_von": [
"MAP-03"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "COR-01",
"titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)",
"haengt_ab_von": [
"MAP-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "SCHEMA-01",
"titel": "Core-Tenant-Schema ausrollen (TEN-07)",
"haengt_ab_von": [
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "SCHEMA-02",
"titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)",
"haengt_ab_von": [
"SCHEMA-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "COR-02",
"titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1",
"haengt_ab_von": [
"MAP-04*",
"SCHEMA-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "COR-04",
"titel": "LDAP-Feld-Migration pruefen",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "COR-03",
"titel": "Rollen- und Rechte-Migration",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "COR-05",
"titel": "Audit-Log-Zusammenfuehrung",
"haengt_ab_von": [
"MAP-04*",
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "DOK-01",
"titel": "Dokument- und Revisions-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "DOK-02",
"titel": "Akten-/Ordnerstruktur-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "DOK-04",
"titel": "Custom-Fields- und Klassifikationsvorlagen-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "DOK-03",
"titel": "ACL-Daten-Migration nach Core-Rechtemodell",
"haengt_ab_von": [
"DOK-01",
"COR-03"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "DOK-05",
"titel": "Freigabelinks-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "MAI-02",
"titel": "Nachrichten- und Anhang-Migration",
"haengt_ab_von": [
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "TAR-01",
"titel": "Retention-Fristen-Migration ohne Reset",
"haengt_ab_von": [
"DOK-01",
"MAI-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "MAI-03",
"titel": "DSGVO-Loeschanfragen-Migration",
"haengt_ab_von": [
"TAR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "MAI-04",
"titel": "Reconciliation-Reports-Archivierung",
"haengt_ab_von": [
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "TAR-02",
"titel": "Vier-Augen-Loeschworkflow-Migration",
"haengt_ab_von": [
"TAR-01",
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "WFL-01",
"titel": "Workflow-Historie-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "VAL-01",
"titel": "Zeilenzahl-Abgleich je Kerntabelle",
"haengt_ab_von": [
"COR-02",
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "VAL-02",
"titel": "Hash-Vergleich migrierter Binaerdateien",
"haengt_ab_von": [
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "VAL-05",
"titel": "Login-Test mit migrierten Zugangsdaten",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
},
{
"id": "CHK-01",
"titel": "Modulgrenzen-uebergreifende Kettenpruefung",
"haengt_ab_von": [
"DOK-03",
"DOK-05",
"TAR-02",
"MAI-03",
"MAI-04",
"WFL-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAP-02"
]
}
]
}
@@ -0,0 +1,74 @@
CUT-01 Trockenlauf-Protokoll
Lauf: cut01a6173e (Iteration 1 / Lauf 1.2)
Beginn: 2026-10-14 20:47:20 UTC Ende: 2026-10-14 20:47:33 UTC Dauer: 13.189s
Ergebnis: ABGEBROCHEN bei MAP-02
1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5584 ms)
Zaehler: [modulstufen=4]
Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01a6173e
2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (7135 ms)
Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48]
Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go
3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (19 ms)
Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431]
Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606)
Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737)
Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH
4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (387 ms)
Zaehler: [core_migrationen=42]
5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (13 ms)
Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0]
Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)
6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (13 ms)
Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1]
7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – fehlgeschlagen (34 ms)
Zaehler: [eintraege_angelegt=2 freigegeben=2]
FEHLER: vollstaendigkeit: 2 altsystem-mandanten ohne eintrag in der freigegebenen tenant_migration_map, sie wuerden nie migriert: archivmail/praxis-fantasia, archivdms/ingenieurbuero-erfundenhausen
8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
14. [COR-04] LDAP-Feld-Migration pruefen – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
15. [COR-03] Rollen- und Rechte-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
16. [COR-05] Audit-Log-Zusammenfuehrung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
17. [DOK-01] Dokument- und Revisions-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
18. [DOK-02] Akten-/Ordnerstruktur-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
21. [DOK-05] Freigabelinks-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
22. [MAI-02] Nachrichten- und Anhang-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
23. [TAR-01] Retention-Fristen-Migration ohne Reset – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
24. [MAI-03] DSGVO-Loeschanfragen-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
25. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
27. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
30. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02
@@ -0,0 +1,459 @@
{
"lauf_kennung": "cut01c33bb7",
"bezeichnung": "Iteration 1 / Lauf 1.3",
"gestartet": "2026-10-14T20:48:40.027043948Z",
"beendet": "2026-10-14T20:48:57.908776698Z",
"erfolgreich": false,
"abbruch_bei": "COR-03",
"schritte": [
{
"id": "PREP-01",
"titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen",
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:40.027055263Z",
"dauer_ms": 5252,
"zaehler": {
"modulstufen": 4
},
"hinweise": [
"arbeitsverzeichnis /root/cut01-dryrun/cut01c33bb7"
]
},
{
"id": "PREP-02",
"titel": "Synthetische Testkopie archivmail/archivdms erzeugen",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:45.279907908Z",
"dauer_ms": 6804,
"zaehler": {
"archivdms_akten": 9,
"archivdms_audit": 12,
"archivdms_delete_requests": 9,
"archivdms_documents": 24,
"archivdms_shares": 15,
"archivdms_tenants": 3,
"archivdms_users": 15,
"archivdms_workflow_runs": 12,
"archivmail_attachments": 9,
"archivmail_audit": 12,
"archivmail_dsgvo": 9,
"archivmail_emails": 18,
"archivmail_reports": 9,
"archivmail_tenants": 3,
"archivmail_users": 15,
"binaerobjekte": 48
},
"hinweise": [
"generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go"
]
},
{
"id": "PREP-03",
"titel": "Anonymisierungsnachweis der Testkopie",
"haengt_ab_von": [
"PREP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.084254643Z",
"dauer_ms": 21,
"zaehler": {
"binaerobjekte": 48,
"e_mail_adressen": 93,
"ip_adressen": 24,
"personen": 30,
"tabellen": 30,
"verstoesse": 0,
"zeilen": 431
},
"hinweise": [
"alle e-mail-adressen unter der reservierten domain .example (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH"
]
},
{
"id": "PREP-04",
"titel": "Core-Registry anlegen (Core-Migrationen)",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.10549922Z",
"dauer_ms": 400,
"zaehler": {
"core_migrationen": 42
}
},
{
"id": "EXP-01",
"titel": "Export-Sichten der Altsysteme bereitstellen",
"haengt_ab_von": [
"PREP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.506305266Z",
"dauer_ms": 12,
"zaehler": {
"export_sichten": 4,
"plattformkonten_ohne_mandant": 0
},
"hinweise": [
"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"
]
},
{
"id": "MAP-01",
"titel": "Tenant-Export und Slug/Domain-Abgleich",
"haengt_ab_von": [
"EXP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.518623143Z",
"dauer_ms": 12,
"zaehler": {
"archivdms_tenants": 3,
"archivmail_tenants": 3,
"kandidaten": 4,
"konflikte": 0,
"manuelle_zuordnungen": 1
}
},
{
"id": "MAP-02",
"titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)",
"haengt_ab_von": [
"MAP-01",
"PREP-04"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.53114518Z",
"dauer_ms": 69,
"zaehler": {
"eintraege_angelegt": 4,
"freigegeben": 4
}
},
{
"id": "MAP-03",
"titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.60035004Z",
"dauer_ms": 2,
"zaehler": {
"archivdms_users": 15,
"archivmail_users": 15,
"kandidaten": 24,
"konflikte": 0,
"zusammengefuehrt": 6
}
},
{
"id": "MAP-04*",
"titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)",
"haengt_ab_von": [
"MAP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.602544679Z",
"dauer_ms": 220,
"zaehler": {
"benutzer_freigegeben": 24,
"mit_ldap_bezug": 6,
"rollen_freigegeben": 24
}
},
{
"id": "COR-01",
"titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:52.823552627Z",
"dauer_ms": 326,
"zaehler": {
"tenants_migriert": 4
}
},
{
"id": "SCHEMA-01",
"titel": "Core-Tenant-Schema ausrollen (TEN-07)",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:53.14978522Z",
"dauer_ms": 1049,
"zaehler": {
"core_tenant_migrationen_angewendet": 84,
"tenants": 4
}
},
{
"id": "SCHEMA-02",
"titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)",
"haengt_ab_von": [
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:54.198832012Z",
"dauer_ms": 3509,
"zaehler": {
"archive_migrationen_angewendet": 72,
"dms_migrationen_angewendet": 148,
"workflow_migrationen_angewendet": 72
}
},
{
"id": "COR-02",
"titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1",
"haengt_ab_von": [
"MAP-04*",
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:57.708862809Z",
"dauer_ms": 149,
"zaehler": {
"benutzer_migriert": 24
}
},
{
"id": "COR-04",
"titel": "LDAP-Feld-Migration pruefen",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:48:57.858476496Z",
"dauer_ms": 24,
"zaehler": {
"benutzer_mit_ldap_bezug_geprueft": 6
}
},
{
"id": "COR-03",
"titel": "Rollen- und Rechte-Migration",
"haengt_ab_von": [
"COR-02"
],
"status": "fehlgeschlagen",
"gestartet": "2026-10-14T20:48:57.883311102Z",
"dauer_ms": 25,
"fehler": "role-map-eintrag 4 (benutzer 6826ed87-1d21-4d5c-8af1-40b700d6c64d): migrationrole: keine core-rolle fuer archivmail-rolle \"auditor\" hinterlegt"
},
{
"id": "COR-05",
"titel": "Audit-Log-Zusammenfuehrung",
"haengt_ab_von": [
"MAP-04*",
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "DOK-01",
"titel": "Dokument- und Revisions-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "DOK-02",
"titel": "Akten-/Ordnerstruktur-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "DOK-04",
"titel": "Custom-Fields- und Klassifikationsvorlagen-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "DOK-03",
"titel": "ACL-Daten-Migration nach Core-Rechtemodell",
"haengt_ab_von": [
"DOK-01",
"COR-03"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "DOK-05",
"titel": "Freigabelinks-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "MAI-02",
"titel": "Nachrichten- und Anhang-Migration",
"haengt_ab_von": [
"COR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "TAR-01",
"titel": "Retention-Fristen-Migration ohne Reset",
"haengt_ab_von": [
"DOK-01",
"MAI-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "MAI-03",
"titel": "DSGVO-Loeschanfragen-Migration",
"haengt_ab_von": [
"TAR-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "MAI-04",
"titel": "Reconciliation-Reports-Archivierung",
"haengt_ab_von": [
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "TAR-02",
"titel": "Vier-Augen-Loeschworkflow-Migration",
"haengt_ab_von": [
"TAR-01",
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "WFL-01",
"titel": "Workflow-Historie-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "VAL-01",
"titel": "Zeilenzahl-Abgleich je Kerntabelle",
"haengt_ab_von": [
"COR-02",
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "VAL-02",
"titel": "Hash-Vergleich migrierter Binaerdateien",
"haengt_ab_von": [
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "VAL-05",
"titel": "Login-Test mit migrierten Zugangsdaten",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
},
{
"id": "CHK-01",
"titel": "Modulgrenzen-uebergreifende Kettenpruefung",
"haengt_ab_von": [
"DOK-03",
"DOK-05",
"TAR-02",
"MAI-03",
"MAI-04",
"WFL-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in COR-03"
]
}
]
}
@@ -0,0 +1,73 @@
CUT-01 Trockenlauf-Protokoll
Lauf: cut01c33bb7 (Iteration 1 / Lauf 1.3)
Beginn: 2026-10-14 20:48:40 UTC Ende: 2026-10-14 20:48:57 UTC Dauer: 17.882s
Ergebnis: ABGEBROCHEN bei COR-03
1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5252 ms)
Zaehler: [modulstufen=4]
Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01c33bb7
2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6804 ms)
Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48]
Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go
3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (21 ms)
Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431]
Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606)
Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737)
Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH
4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (400 ms)
Zaehler: [core_migrationen=42]
5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms)
Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0]
Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)
6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (12 ms)
Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1]
7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (69 ms)
Zaehler: [eintraege_angelegt=4 freigegeben=4]
8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (2 ms)
Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6]
9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (220 ms)
Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24]
10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (326 ms)
Zaehler: [tenants_migriert=4]
11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1049 ms)
Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4]
12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3509 ms)
Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72]
13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (149 ms)
Zaehler: [benutzer_migriert=24]
14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (24 ms)
Zaehler: [benutzer_mit_ldap_bezug_geprueft=6]
15. [COR-03] Rollen- und Rechte-Migration – fehlgeschlagen (25 ms)
FEHLER: role-map-eintrag 4 (benutzer 6826ed87-1d21-4d5c-8af1-40b700d6c64d): migrationrole: keine core-rolle fuer archivmail-rolle "auditor" hinterlegt
16. [COR-05] Audit-Log-Zusammenfuehrung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
17. [DOK-01] Dokument- und Revisions-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
18. [DOK-02] Akten-/Ordnerstruktur-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
21. [DOK-05] Freigabelinks-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
22. [MAI-02] Nachrichten- und Anhang-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
23. [TAR-01] Retention-Fristen-Migration ohne Reset – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
24. [MAI-03] DSGVO-Loeschanfragen-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
25. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
27. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
30. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03
@@ -0,0 +1,508 @@
{
"lauf_kennung": "cut0171964d",
"bezeichnung": "Iteration 1 / Lauf 1.4",
"gestartet": "2026-10-14T20:49:38.491352447Z",
"beendet": "2026-10-14T20:49:58.84425628Z",
"erfolgreich": false,
"abbruch_bei": "MAI-03",
"schritte": [
{
"id": "PREP-01",
"titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen",
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:38.49135999Z",
"dauer_ms": 5139,
"zaehler": {
"modulstufen": 4
},
"hinweise": [
"arbeitsverzeichnis /root/cut01-dryrun/cut0171964d"
]
},
{
"id": "PREP-02",
"titel": "Synthetische Testkopie archivmail/archivdms erzeugen",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:43.630921656Z",
"dauer_ms": 6907,
"zaehler": {
"archivdms_akten": 9,
"archivdms_audit": 12,
"archivdms_delete_requests": 9,
"archivdms_documents": 24,
"archivdms_shares": 15,
"archivdms_tenants": 3,
"archivdms_users": 15,
"archivdms_workflow_runs": 12,
"archivmail_attachments": 9,
"archivmail_audit": 12,
"archivmail_dsgvo": 9,
"archivmail_emails": 18,
"archivmail_reports": 9,
"archivmail_tenants": 3,
"archivmail_users": 15,
"binaerobjekte": 48
},
"hinweise": [
"generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go"
]
},
{
"id": "PREP-03",
"titel": "Anonymisierungsnachweis der Testkopie",
"haengt_ab_von": [
"PREP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:50.538504233Z",
"dauer_ms": 21,
"zaehler": {
"binaerobjekte": 48,
"e_mail_adressen": 93,
"ip_adressen": 24,
"personen": 30,
"tabellen": 30,
"verstoesse": 0,
"zeilen": 431
},
"hinweise": [
"alle e-mail-adressen unter der reservierten domain .example (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH"
]
},
{
"id": "PREP-04",
"titel": "Core-Registry anlegen (Core-Migrationen)",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:50.560460544Z",
"dauer_ms": 381,
"zaehler": {
"core_migrationen": 42
}
},
{
"id": "EXP-01",
"titel": "Export-Sichten der Altsysteme bereitstellen",
"haengt_ab_von": [
"PREP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:50.941720324Z",
"dauer_ms": 12,
"zaehler": {
"export_sichten": 4,
"plattformkonten_ohne_mandant": 0
},
"hinweise": [
"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"
]
},
{
"id": "MAP-01",
"titel": "Tenant-Export und Slug/Domain-Abgleich",
"haengt_ab_von": [
"EXP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:50.954548777Z",
"dauer_ms": 11,
"zaehler": {
"archivdms_tenants": 3,
"archivmail_tenants": 3,
"kandidaten": 4,
"konflikte": 0,
"manuelle_zuordnungen": 1
}
},
{
"id": "MAP-02",
"titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)",
"haengt_ab_von": [
"MAP-01",
"PREP-04"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:50.966417682Z",
"dauer_ms": 62,
"zaehler": {
"eintraege_angelegt": 4,
"freigegeben": 4
}
},
{
"id": "MAP-03",
"titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:51.028954249Z",
"dauer_ms": 1,
"zaehler": {
"archivdms_users": 15,
"archivmail_users": 15,
"kandidaten": 24,
"konflikte": 0,
"zusammengefuehrt": 6
}
},
{
"id": "MAP-04*",
"titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)",
"haengt_ab_von": [
"MAP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:51.03075293Z",
"dauer_ms": 228,
"zaehler": {
"benutzer_freigegeben": 24,
"mit_ldap_bezug": 6,
"rollen_freigegeben": 24
}
},
{
"id": "COR-01",
"titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:51.259338569Z",
"dauer_ms": 345,
"zaehler": {
"tenants_migriert": 4
}
},
{
"id": "SCHEMA-01",
"titel": "Core-Tenant-Schema ausrollen (TEN-07)",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:51.605116869Z",
"dauer_ms": 1132,
"zaehler": {
"core_tenant_migrationen_angewendet": 84,
"tenants": 4
}
},
{
"id": "SCHEMA-02",
"titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)",
"haengt_ab_von": [
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:52.737345175Z",
"dauer_ms": 3486,
"zaehler": {
"archive_migrationen_angewendet": 72,
"dms_migrationen_angewendet": 148,
"workflow_migrationen_angewendet": 72
}
},
{
"id": "COR-02",
"titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1",
"haengt_ab_von": [
"MAP-04*",
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:56.223427227Z",
"dauer_ms": 149,
"zaehler": {
"benutzer_migriert": 24
}
},
{
"id": "COR-04",
"titel": "LDAP-Feld-Migration pruefen",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:56.372462099Z",
"dauer_ms": 23,
"zaehler": {
"benutzer_mit_ldap_bezug_geprueft": 6
}
},
{
"id": "COR-03",
"titel": "Rollen- und Rechte-Migration",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:56.395782437Z",
"dauer_ms": 155,
"zaehler": {
"rolle_tenant_admin": 7,
"rolle_user": 17,
"rollen_zugewiesen": 24
}
},
{
"id": "COR-05",
"titel": "Audit-Log-Zusammenfuehrung",
"haengt_ab_von": [
"MAP-04*",
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:56.551118769Z",
"dauer_ms": 255,
"zaehler": {
"audit_ereignisse_migriert": 24,
"staging_zeilen": 24
},
"hinweise": [
"zeitstempel und aktion je ereignis gegen staging unveraendert"
]
},
{
"id": "DOK-01",
"titel": "Dokument- und Revisions-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:56.806206383Z",
"dauer_ms": 463,
"zaehler": {
"dokumente": 21,
"revisionen": 21,
"revisionen_uebersprungen": 0,
"uebergabe_objekte": 24
},
"hinweise": [
"cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-02",
"titel": "Akten-/Ordnerstruktur-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:57.269900323Z",
"dauer_ms": 162,
"zaehler": {
"akten": 9,
"akten_uebersprungen": 0,
"dokumente_verknuepft": 15
},
"hinweise": [
"cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-04",
"titel": "Custom-Fields- und Klassifikationsvorlagen-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:57.432042823Z",
"dauer_ms": 419,
"zaehler": {
"felder": 15,
"feldwerte": 18,
"vorlagen": 9
},
"hinweise": [
"cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-03",
"titel": "ACL-Daten-Migration nach Core-Rechtemodell",
"haengt_ab_von": [
"DOK-01",
"COR-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:57.851818414Z",
"dauer_ms": 393,
"zaehler": {
"core_gruppen": 9,
"gruppenmitgliedschaften": 15,
"objektrechte": 48
},
"hinweise": [
"cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-05",
"titel": "Freigabelinks-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:58.245324758Z",
"dauer_ms": 77,
"zaehler": {
"freigabelinks": 6,
"freigabelinks_abgelaufen_verworfen": 3
},
"hinweise": [
"cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "MAI-02",
"titel": "Nachrichten- und Anhang-Migration",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:58.322705541Z",
"dauer_ms": 93,
"zaehler": {
"anhaenge": 9,
"nachrichten": 19,
"nachrichten_uebersprungen": 0,
"uebergabe_objekte": 19
},
"hinweise": [
"cut0171964d_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt",
"suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll"
]
},
{
"id": "TAR-01",
"titel": "Retention-Fristen-Migration ohne Reset",
"haengt_ab_von": [
"DOK-01",
"MAI-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:49:58.416722865Z",
"dauer_ms": 397,
"zaehler": {
"fristen_geprueft": 40,
"fristen_uebernommen": 27,
"objekte_registriert": 40
}
},
{
"id": "MAI-03",
"titel": "DSGVO-Loeschanfragen-Migration",
"haengt_ab_von": [
"TAR-01"
],
"status": "fehlgeschlagen",
"gestartet": "2026-10-14T20:49:58.813805756Z",
"dauer_ms": 30,
"fehler": "modulstufe archive/mai03: exit status 1: fehler: mandant cut0171964d_kanzlei_musterhausen: offene dsgvo-anfrage 1: 2 migrierte objekte der betroffenen person, aber keine einzelentscheidung protokolliert"
},
{
"id": "MAI-04",
"titel": "Reconciliation-Reports-Archivierung",
"haengt_ab_von": [
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAI-03"
]
},
{
"id": "TAR-02",
"titel": "Vier-Augen-Loeschworkflow-Migration",
"haengt_ab_von": [
"TAR-01",
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAI-03"
]
},
{
"id": "WFL-01",
"titel": "Workflow-Historie-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAI-03"
]
},
{
"id": "VAL-01",
"titel": "Zeilenzahl-Abgleich je Kerntabelle",
"haengt_ab_von": [
"COR-02",
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAI-03"
]
},
{
"id": "VAL-02",
"titel": "Hash-Vergleich migrierter Binaerdateien",
"haengt_ab_von": [
"DOK-01",
"MAI-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAI-03"
]
},
{
"id": "VAL-05",
"titel": "Login-Test mit migrierten Zugangsdaten",
"haengt_ab_von": [
"COR-02"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAI-03"
]
},
{
"id": "CHK-01",
"titel": "Modulgrenzen-uebergreifende Kettenpruefung",
"haengt_ab_von": [
"DOK-03",
"DOK-05",
"TAR-02",
"MAI-03",
"MAI-04",
"WFL-01"
],
"status": "nicht ausgefuehrt",
"dauer_ms": 0,
"hinweise": [
"nicht ausgefuehrt: Abbruch nach Fehler in MAI-03"
]
}
]
}
@@ -0,0 +1,81 @@
CUT-01 Trockenlauf-Protokoll
Lauf: cut0171964d (Iteration 1 / Lauf 1.4)
Beginn: 2026-10-14 20:49:38 UTC Ende: 2026-10-14 20:49:58 UTC Dauer: 20.353s
Ergebnis: ABGEBROCHEN bei MAI-03
1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5139 ms)
Zaehler: [modulstufen=4]
Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut0171964d
2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6907 ms)
Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48]
Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go
3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (21 ms)
Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431]
Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606)
Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737)
Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH
4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (381 ms)
Zaehler: [core_migrationen=42]
5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms)
Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0]
Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)
6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (11 ms)
Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1]
7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (62 ms)
Zaehler: [eintraege_angelegt=4 freigegeben=4]
8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (1 ms)
Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6]
9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (228 ms)
Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24]
10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (345 ms)
Zaehler: [tenants_migriert=4]
11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1132 ms)
Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4]
12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3486 ms)
Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72]
13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (149 ms)
Zaehler: [benutzer_migriert=24]
14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (23 ms)
Zaehler: [benutzer_mit_ldap_bezug_geprueft=6]
15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (155 ms)
Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24]
16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (255 ms)
Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24]
Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert
17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (463 ms)
Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24]
Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (162 ms)
Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15]
Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (419 ms)
Zaehler: [felder=15 feldwerte=18 vorlagen=9]
Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (393 ms)
Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48]
Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
21. [DOK-05] Freigabelinks-Migration – erfolgreich (77 ms)
Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3]
Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (93 ms)
Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19]
Hinweis: cut0171964d_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll
23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (397 ms)
Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40]
24. [MAI-03] DSGVO-Loeschanfragen-Migration – fehlgeschlagen (30 ms)
FEHLER: modulstufe archive/mai03: exit status 1: fehler: mandant cut0171964d_kanzlei_musterhausen: offene dsgvo-anfrage 1: 2 migrierte objekte der betroffenen person, aber keine einzelentscheidung protokolliert
25. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03
26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03
27. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03
28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03
29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03
30. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03
31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt
Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03
@@ -0,0 +1,555 @@
{
"lauf_kennung": "cut01748e71",
"bezeichnung": "Iteration 1 / Lauf 1.5",
"gestartet": "2026-10-14T20:50:42.771952947Z",
"beendet": "2026-10-14T20:51:10.516841945Z",
"erfolgreich": true,
"schritte": [
{
"id": "PREP-01",
"titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen",
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:42.771993597Z",
"dauer_ms": 5710,
"zaehler": {
"modulstufen": 4
},
"hinweise": [
"arbeitsverzeichnis /root/cut01-dryrun/cut01748e71"
]
},
{
"id": "PREP-02",
"titel": "Synthetische Testkopie archivmail/archivdms erzeugen",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:48.484430778Z",
"dauer_ms": 6859,
"zaehler": {
"archivdms_akten": 9,
"archivdms_audit": 12,
"archivdms_delete_requests": 9,
"archivdms_documents": 24,
"archivdms_shares": 15,
"archivdms_tenants": 3,
"archivdms_users": 15,
"archivdms_workflow_runs": 12,
"archivmail_attachments": 9,
"archivmail_audit": 12,
"archivmail_dsgvo": 9,
"archivmail_emails": 18,
"archivmail_reports": 9,
"archivmail_tenants": 3,
"archivmail_users": 15,
"binaerobjekte": 48
},
"hinweise": [
"generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go"
]
},
{
"id": "PREP-03",
"titel": "Anonymisierungsnachweis der Testkopie",
"haengt_ab_von": [
"PREP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:55.343613461Z",
"dauer_ms": 34,
"zaehler": {
"binaerobjekte": 48,
"e_mail_adressen": 93,
"ip_adressen": 24,
"personen": 30,
"tabellen": 30,
"verstoesse": 0,
"zeilen": 431
},
"hinweise": [
"alle e-mail-adressen unter der reservierten domain .example (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH"
]
},
{
"id": "PREP-04",
"titel": "Core-Registry anlegen (Core-Migrationen)",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:55.377889744Z",
"dauer_ms": 411,
"zaehler": {
"core_migrationen": 42
}
},
{
"id": "EXP-01",
"titel": "Export-Sichten der Altsysteme bereitstellen",
"haengt_ab_von": [
"PREP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:55.789037855Z",
"dauer_ms": 13,
"zaehler": {
"export_sichten": 4,
"plattformkonten_ohne_mandant": 0
},
"hinweise": [
"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"
]
},
{
"id": "MAP-01",
"titel": "Tenant-Export und Slug/Domain-Abgleich",
"haengt_ab_von": [
"EXP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:55.80221065Z",
"dauer_ms": 13,
"zaehler": {
"archivdms_tenants": 3,
"archivmail_tenants": 3,
"kandidaten": 4,
"konflikte": 0,
"manuelle_zuordnungen": 1
}
},
{
"id": "MAP-02",
"titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)",
"haengt_ab_von": [
"MAP-01",
"PREP-04"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:55.815579713Z",
"dauer_ms": 67,
"zaehler": {
"eintraege_angelegt": 4,
"freigegeben": 4
}
},
{
"id": "MAP-03",
"titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:55.882714748Z",
"dauer_ms": 2,
"zaehler": {
"archivdms_users": 15,
"archivmail_users": 15,
"kandidaten": 24,
"konflikte": 0,
"zusammengefuehrt": 6
}
},
{
"id": "MAP-04*",
"titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)",
"haengt_ab_von": [
"MAP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:55.884759847Z",
"dauer_ms": 246,
"zaehler": {
"benutzer_freigegeben": 24,
"mit_ldap_bezug": 6,
"rollen_freigegeben": 24
}
},
{
"id": "COR-01",
"titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:56.130914334Z",
"dauer_ms": 364,
"zaehler": {
"tenants_migriert": 4
}
},
{
"id": "SCHEMA-01",
"titel": "Core-Tenant-Schema ausrollen (TEN-07)",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:56.495818495Z",
"dauer_ms": 1166,
"zaehler": {
"core_tenant_migrationen_angewendet": 84,
"tenants": 4
}
},
{
"id": "SCHEMA-02",
"titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)",
"haengt_ab_von": [
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:50:57.662836035Z",
"dauer_ms": 3917,
"zaehler": {
"archive_migrationen_angewendet": 72,
"dms_migrationen_angewendet": 148,
"workflow_migrationen_angewendet": 72
}
},
{
"id": "COR-02",
"titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1",
"haengt_ab_von": [
"MAP-04*",
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:01.580878777Z",
"dauer_ms": 163,
"zaehler": {
"benutzer_migriert": 24
}
},
{
"id": "COR-04",
"titel": "LDAP-Feld-Migration pruefen",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:01.744190992Z",
"dauer_ms": 29,
"zaehler": {
"benutzer_mit_ldap_bezug_geprueft": 6
}
},
{
"id": "COR-03",
"titel": "Rollen- und Rechte-Migration",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:01.774062351Z",
"dauer_ms": 172,
"zaehler": {
"rolle_tenant_admin": 7,
"rolle_user": 17,
"rollen_zugewiesen": 24
}
},
{
"id": "COR-05",
"titel": "Audit-Log-Zusammenfuehrung",
"haengt_ab_von": [
"MAP-04*",
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:01.946826651Z",
"dauer_ms": 283,
"zaehler": {
"audit_ereignisse_migriert": 24,
"staging_zeilen": 24
},
"hinweise": [
"zeitstempel und aktion je ereignis gegen staging unveraendert"
]
},
{
"id": "DOK-01",
"titel": "Dokument- und Revisions-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:02.229918144Z",
"dauer_ms": 470,
"zaehler": {
"dokumente": 21,
"revisionen": 21,
"revisionen_uebersprungen": 0,
"uebergabe_objekte": 24
},
"hinweise": [
"cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-02",
"titel": "Akten-/Ordnerstruktur-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:02.700087531Z",
"dauer_ms": 182,
"zaehler": {
"akten": 9,
"akten_uebersprungen": 0,
"dokumente_verknuepft": 15
},
"hinweise": [
"cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-04",
"titel": "Custom-Fields- und Klassifikationsvorlagen-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:02.8830651Z",
"dauer_ms": 495,
"zaehler": {
"felder": 15,
"feldwerte": 18,
"vorlagen": 9
},
"hinweise": [
"cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-03",
"titel": "ACL-Daten-Migration nach Core-Rechtemodell",
"haengt_ab_von": [
"DOK-01",
"COR-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:03.378798147Z",
"dauer_ms": 384,
"zaehler": {
"core_gruppen": 9,
"gruppenmitgliedschaften": 15,
"objektrechte": 48
},
"hinweise": [
"cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-05",
"titel": "Freigabelinks-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:03.763562184Z",
"dauer_ms": 104,
"zaehler": {
"freigabelinks": 6,
"freigabelinks_abgelaufen_verworfen": 3
},
"hinweise": [
"cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "MAI-02",
"titel": "Nachrichten- und Anhang-Migration",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:03.867882599Z",
"dauer_ms": 78,
"zaehler": {
"anhaenge": 9,
"nachrichten": 19,
"nachrichten_uebersprungen": 0,
"uebergabe_objekte": 19
},
"hinweise": [
"cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt",
"suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll"
]
},
{
"id": "TAR-01",
"titel": "Retention-Fristen-Migration ohne Reset",
"haengt_ab_von": [
"DOK-01",
"MAI-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:03.94680929Z",
"dauer_ms": 385,
"zaehler": {
"fristen_geprueft": 40,
"fristen_uebernommen": 27,
"objekte_registriert": 40
}
},
{
"id": "MAI-03",
"titel": "DSGVO-Loeschanfragen-Migration",
"haengt_ab_von": [
"TAR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:04.332566261Z",
"dauer_ms": 100,
"zaehler": {
"einzelentscheidungen": 9,
"historische_eintraege": 0,
"offene_anfragen_verarbeitet": 6
},
"hinweise": [
"cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt"
]
},
{
"id": "MAI-04",
"titel": "Reconciliation-Reports-Archivierung",
"haengt_ab_von": [
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:04.432704212Z",
"dauer_ms": 71,
"zaehler": {
"berichte": 9,
"berichte_uebersprungen": 0
},
"hinweise": [
"cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt"
]
},
{
"id": "TAR-02",
"titel": "Vier-Augen-Loeschworkflow-Migration",
"haengt_ab_von": [
"TAR-01",
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:04.50391785Z",
"dauer_ms": 114,
"zaehler": {
"abgebrochene_nicht_migriert": 3,
"offene_loeschanfragen": 3,
"vollzogene_loeschungen_audit": 3
},
"hinweise": [
"cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "WFL-01",
"titel": "Workflow-Historie-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:04.61860834Z",
"dauer_ms": 211,
"zaehler": {
"historieneintraege": 21,
"vorgaenge_migriert": 9,
"vorgaenge_offen_nicht_migriert": 3
},
"hinweise": [
"cut01748e71_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden",
"cut01748e71_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden",
"cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt",
"cut01748e71_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden"
]
},
{
"id": "VAL-01",
"titel": "Zeilenzahl-Abgleich je Kerntabelle",
"haengt_ab_von": [
"COR-02",
"DOK-01",
"MAI-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:04.830201222Z",
"dauer_ms": 20,
"zaehler": {
"quelle_documents": 24,
"quelle_messages": 19,
"quelle_users": 24,
"vergleiche": 12,
"ziel_documents": 21,
"ziel_messages": 19,
"ziel_users": 24
}
},
{
"id": "VAL-02",
"titel": "Hash-Vergleich migrierter Binaerdateien",
"haengt_ab_von": [
"DOK-01",
"MAI-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:04.850764449Z",
"dauer_ms": 62,
"zaehler": {
"abweichungen": 0,
"dms_dateien": 15,
"mail_anhaenge": 9
}
},
{
"id": "VAL-05",
"titel": "Login-Test mit migrierten Zugangsdaten",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:04.913283485Z",
"dauer_ms": 5525,
"zaehler": {
"falsche_passwoerter_abgewiesen": 12,
"logins_erfolgreich": 12
}
},
{
"id": "CHK-01",
"titel": "Modulgrenzen-uebergreifende Kettenpruefung",
"haengt_ab_von": [
"DOK-03",
"DOK-05",
"TAR-02",
"MAI-03",
"MAI-04",
"WFL-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:51:10.439233602Z",
"dauer_ms": 77,
"zaehler": {
"akten": 9,
"befunde": 0,
"dokumente_mit_retention": 21,
"freigabelinks": 6,
"nachrichten_mit_retention": 19,
"offene_loeschanfragen": 3,
"reconciliation_berichte": 9,
"vollzogene_loeschungen": 3,
"workflow_vorgaenge": 9
}
}
]
}
@@ -0,0 +1,88 @@
CUT-01 Trockenlauf-Protokoll
Lauf: cut01748e71 (Iteration 1 / Lauf 1.5)
Beginn: 2026-10-14 20:50:42 UTC Ende: 2026-10-14 20:51:10 UTC Dauer: 27.745s
Ergebnis: ERFOLGREICH – alle 31 Schritte ohne Abbruch durchgelaufen
1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5710 ms)
Zaehler: [modulstufen=4]
Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01748e71
2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6859 ms)
Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48]
Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go
3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (34 ms)
Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431]
Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606)
Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737)
Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH
4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (411 ms)
Zaehler: [core_migrationen=42]
5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (13 ms)
Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0]
Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)
6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (13 ms)
Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1]
7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (67 ms)
Zaehler: [eintraege_angelegt=4 freigegeben=4]
8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (2 ms)
Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6]
9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (246 ms)
Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24]
10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (364 ms)
Zaehler: [tenants_migriert=4]
11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1166 ms)
Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4]
12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3917 ms)
Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72]
13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (163 ms)
Zaehler: [benutzer_migriert=24]
14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (29 ms)
Zaehler: [benutzer_mit_ldap_bezug_geprueft=6]
15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (172 ms)
Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24]
16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (283 ms)
Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24]
Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert
17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (470 ms)
Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24]
Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (182 ms)
Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15]
Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (495 ms)
Zaehler: [felder=15 feldwerte=18 vorlagen=9]
Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (384 ms)
Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48]
Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
21. [DOK-05] Freigabelinks-Migration – erfolgreich (104 ms)
Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3]
Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (78 ms)
Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19]
Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll
23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (385 ms)
Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40]
24. [MAI-03] DSGVO-Loeschanfragen-Migration – erfolgreich (100 ms)
Zaehler: [einzelentscheidungen=9 historische_eintraege=0 offene_anfragen_verarbeitet=6]
Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
25. [MAI-04] Reconciliation-Reports-Archivierung – erfolgreich (71 ms)
Zaehler: [berichte=9 berichte_uebersprungen=0]
Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – erfolgreich (114 ms)
Zaehler: [abgebrochene_nicht_migriert=3 offene_loeschanfragen=3 vollzogene_loeschungen_audit=3]
Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
27. [WFL-01] Workflow-Historie-Migration – erfolgreich (211 ms)
Zaehler: [historieneintraege=21 vorgaenge_migriert=9 vorgaenge_offen_nicht_migriert=3]
Hinweis: cut01748e71_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
Hinweis: cut01748e71_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – erfolgreich (20 ms)
Zaehler: [quelle_documents=24 quelle_messages=19 quelle_users=24 vergleiche=12 ziel_documents=21 ziel_messages=19 ziel_users=24]
29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – erfolgreich (62 ms)
Zaehler: [abweichungen=0 dms_dateien=15 mail_anhaenge=9]
30. [VAL-05] Login-Test mit migrierten Zugangsdaten – erfolgreich (5525 ms)
Zaehler: [falsche_passwoerter_abgewiesen=12 logins_erfolgreich=12]
31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – erfolgreich (77 ms)
Zaehler: [akten=9 befunde=0 dokumente_mit_retention=21 freigabelinks=6 nachrichten_mit_retention=19 offene_loeschanfragen=3 reconciliation_berichte=9 vollzogene_loeschungen=3 workflow_vorgaenge=9]
@@ -0,0 +1,16 @@
# Zeilenzahl-Abgleich je Kerntabelle
| Tenant | Tabelle | Quelle | Ziel | Differenz | Erklaert | Status | Begruendung |
|---|---|---|---|---|---|---|---|
| cut01748e71_ingenieurbuero_erfundenhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut01748e71_ingenieurbuero_erfundenhausen | messages | 0 | 0 | 0 | 0 | OK | - |
| cut01748e71_ingenieurbuero_erfundenhausen | users | 5 | 5 | 0 | 0 | OK | - |
| cut01748e71_kanzlei_musterhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut01748e71_kanzlei_musterhausen | messages | 6 | 6 | 0 | 0 | OK | - |
| cut01748e71_kanzlei_musterhausen | users | 7 | 7 | 0 | 0 | OK | - |
| cut01748e71_praxis_fantasia | documents | 0 | 0 | 0 | 0 | OK | - |
| cut01748e71_praxis_fantasia | messages | 7 | 7 | 0 | 0 | OK | - |
| cut01748e71_praxis_fantasia | users | 5 | 5 | 0 | 0 | OK | - |
| cut01748e71_steuerbuero_beispielstadt | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut01748e71_steuerbuero_beispielstadt | messages | 6 | 6 | 0 | 0 | OK | - |
| cut01748e71_steuerbuero_beispielstadt | users | 7 | 7 | 0 | 0 | OK | - |
@@ -0,0 +1,555 @@
{
"lauf_kennung": "cut017f8b14",
"bezeichnung": "Iteration 2 / Lauf 2.1 (Commit 4fdbb9b)",
"gestartet": "2026-10-14T20:54:05.633268835Z",
"beendet": "2026-10-14T20:54:34.442325027Z",
"erfolgreich": true,
"schritte": [
{
"id": "PREP-01",
"titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen",
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:05.633288672Z",
"dauer_ms": 6723,
"zaehler": {
"modulstufen": 4
},
"hinweise": [
"arbeitsverzeichnis /root/cut01-dryrun/cut017f8b14"
]
},
{
"id": "PREP-02",
"titel": "Synthetische Testkopie archivmail/archivdms erzeugen",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:12.35648661Z",
"dauer_ms": 6851,
"zaehler": {
"archivdms_akten": 9,
"archivdms_audit": 12,
"archivdms_delete_requests": 9,
"archivdms_documents": 24,
"archivdms_shares": 15,
"archivdms_tenants": 3,
"archivdms_users": 15,
"archivdms_workflow_runs": 12,
"archivmail_attachments": 9,
"archivmail_audit": 12,
"archivmail_dsgvo": 9,
"archivmail_emails": 18,
"archivmail_reports": 9,
"archivmail_tenants": 3,
"archivmail_users": 15,
"binaerobjekte": 48
},
"hinweise": [
"generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go"
]
},
{
"id": "PREP-03",
"titel": "Anonymisierungsnachweis der Testkopie",
"haengt_ab_von": [
"PREP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.208245457Z",
"dauer_ms": 20,
"zaehler": {
"binaerobjekte": 48,
"e_mail_adressen": 93,
"ip_adressen": 24,
"personen": 30,
"tabellen": 30,
"verstoesse": 0,
"zeilen": 431
},
"hinweise": [
"alle e-mail-adressen unter der reservierten domain .example (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH"
]
},
{
"id": "PREP-04",
"titel": "Core-Registry anlegen (Core-Migrationen)",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.228890754Z",
"dauer_ms": 388,
"zaehler": {
"core_migrationen": 42
}
},
{
"id": "EXP-01",
"titel": "Export-Sichten der Altsysteme bereitstellen",
"haengt_ab_von": [
"PREP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.617812112Z",
"dauer_ms": 12,
"zaehler": {
"export_sichten": 4,
"plattformkonten_ohne_mandant": 0
},
"hinweise": [
"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"
]
},
{
"id": "MAP-01",
"titel": "Tenant-Export und Slug/Domain-Abgleich",
"haengt_ab_von": [
"EXP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.630219253Z",
"dauer_ms": 12,
"zaehler": {
"archivdms_tenants": 3,
"archivmail_tenants": 3,
"kandidaten": 4,
"konflikte": 0,
"manuelle_zuordnungen": 1
}
},
{
"id": "MAP-02",
"titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)",
"haengt_ab_von": [
"MAP-01",
"PREP-04"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.642981561Z",
"dauer_ms": 85,
"zaehler": {
"eintraege_angelegt": 4,
"freigegeben": 4
}
},
{
"id": "MAP-03",
"titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.728803754Z",
"dauer_ms": 2,
"zaehler": {
"archivdms_users": 15,
"archivmail_users": 15,
"kandidaten": 24,
"konflikte": 0,
"zusammengefuehrt": 6
}
},
{
"id": "MAP-04*",
"titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)",
"haengt_ab_von": [
"MAP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.730860587Z",
"dauer_ms": 250,
"zaehler": {
"benutzer_freigegeben": 24,
"mit_ldap_bezug": 6,
"rollen_freigegeben": 24
}
},
{
"id": "COR-01",
"titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:19.981356091Z",
"dauer_ms": 384,
"zaehler": {
"tenants_migriert": 4
}
},
{
"id": "SCHEMA-01",
"titel": "Core-Tenant-Schema ausrollen (TEN-07)",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:20.365686804Z",
"dauer_ms": 1173,
"zaehler": {
"core_tenant_migrationen_angewendet": 84,
"tenants": 4
}
},
{
"id": "SCHEMA-02",
"titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)",
"haengt_ab_von": [
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:21.539254046Z",
"dauer_ms": 3399,
"zaehler": {
"archive_migrationen_angewendet": 72,
"dms_migrationen_angewendet": 148,
"workflow_migrationen_angewendet": 72
}
},
{
"id": "COR-02",
"titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1",
"haengt_ab_von": [
"MAP-04*",
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:24.938592031Z",
"dauer_ms": 153,
"zaehler": {
"benutzer_migriert": 24
}
},
{
"id": "COR-04",
"titel": "LDAP-Feld-Migration pruefen",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:25.092002703Z",
"dauer_ms": 20,
"zaehler": {
"benutzer_mit_ldap_bezug_geprueft": 6
}
},
{
"id": "COR-03",
"titel": "Rollen- und Rechte-Migration",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:25.112792651Z",
"dauer_ms": 175,
"zaehler": {
"rolle_tenant_admin": 7,
"rolle_user": 17,
"rollen_zugewiesen": 24
}
},
{
"id": "COR-05",
"titel": "Audit-Log-Zusammenfuehrung",
"haengt_ab_von": [
"MAP-04*",
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:25.288480717Z",
"dauer_ms": 382,
"zaehler": {
"audit_ereignisse_migriert": 24,
"staging_zeilen": 24
},
"hinweise": [
"zeitstempel und aktion je ereignis gegen staging unveraendert"
]
},
{
"id": "DOK-01",
"titel": "Dokument- und Revisions-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:25.671154049Z",
"dauer_ms": 780,
"zaehler": {
"dokumente": 21,
"revisionen": 21,
"revisionen_uebersprungen": 0,
"uebergabe_objekte": 24
},
"hinweise": [
"cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-02",
"titel": "Akten-/Ordnerstruktur-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:26.451690737Z",
"dauer_ms": 192,
"zaehler": {
"akten": 9,
"akten_uebersprungen": 0,
"dokumente_verknuepft": 15
},
"hinweise": [
"cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-04",
"titel": "Custom-Fields- und Klassifikationsvorlagen-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:26.64442555Z",
"dauer_ms": 486,
"zaehler": {
"felder": 15,
"feldwerte": 18,
"vorlagen": 9
},
"hinweise": [
"cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-03",
"titel": "ACL-Daten-Migration nach Core-Rechtemodell",
"haengt_ab_von": [
"DOK-01",
"COR-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:27.13065029Z",
"dauer_ms": 487,
"zaehler": {
"core_gruppen": 9,
"gruppenmitgliedschaften": 15,
"objektrechte": 48
},
"hinweise": [
"cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-05",
"titel": "Freigabelinks-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:27.618391812Z",
"dauer_ms": 90,
"zaehler": {
"freigabelinks": 6,
"freigabelinks_abgelaufen_verworfen": 3
},
"hinweise": [
"cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "MAI-02",
"titel": "Nachrichten- und Anhang-Migration",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:27.708803393Z",
"dauer_ms": 70,
"zaehler": {
"anhaenge": 9,
"nachrichten": 19,
"nachrichten_uebersprungen": 0,
"uebergabe_objekte": 19
},
"hinweise": [
"cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt",
"suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll"
]
},
{
"id": "TAR-01",
"titel": "Retention-Fristen-Migration ohne Reset",
"haengt_ab_von": [
"DOK-01",
"MAI-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:27.779646288Z",
"dauer_ms": 481,
"zaehler": {
"fristen_geprueft": 40,
"fristen_uebernommen": 27,
"objekte_registriert": 40
}
},
{
"id": "MAI-03",
"titel": "DSGVO-Loeschanfragen-Migration",
"haengt_ab_von": [
"TAR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:28.261431321Z",
"dauer_ms": 102,
"zaehler": {
"einzelentscheidungen": 9,
"historische_eintraege": 0,
"offene_anfragen_verarbeitet": 6
},
"hinweise": [
"cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt"
]
},
{
"id": "MAI-04",
"titel": "Reconciliation-Reports-Archivierung",
"haengt_ab_von": [
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:28.363694555Z",
"dauer_ms": 92,
"zaehler": {
"berichte": 9,
"berichte_uebersprungen": 0
},
"hinweise": [
"cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt"
]
},
{
"id": "TAR-02",
"titel": "Vier-Augen-Loeschworkflow-Migration",
"haengt_ab_von": [
"TAR-01",
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:28.455763518Z",
"dauer_ms": 138,
"zaehler": {
"abgebrochene_nicht_migriert": 3,
"offene_loeschanfragen": 3,
"vollzogene_loeschungen_audit": 3
},
"hinweise": [
"cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "WFL-01",
"titel": "Workflow-Historie-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:28.594705328Z",
"dauer_ms": 251,
"zaehler": {
"historieneintraege": 21,
"vorgaenge_migriert": 9,
"vorgaenge_offen_nicht_migriert": 3
},
"hinweise": [
"cut017f8b14_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden",
"cut017f8b14_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden",
"cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt",
"cut017f8b14_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden"
]
},
{
"id": "VAL-01",
"titel": "Zeilenzahl-Abgleich je Kerntabelle",
"haengt_ab_von": [
"COR-02",
"DOK-01",
"MAI-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:28.84671894Z",
"dauer_ms": 13,
"zaehler": {
"quelle_documents": 24,
"quelle_messages": 19,
"quelle_users": 24,
"vergleiche": 12,
"ziel_documents": 21,
"ziel_messages": 19,
"ziel_users": 24
}
},
{
"id": "VAL-02",
"titel": "Hash-Vergleich migrierter Binaerdateien",
"haengt_ab_von": [
"DOK-01",
"MAI-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:28.859773974Z",
"dauer_ms": 52,
"zaehler": {
"abweichungen": 0,
"dms_dateien": 15,
"mail_anhaenge": 9
}
},
{
"id": "VAL-05",
"titel": "Login-Test mit migrierten Zugangsdaten",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:28.911947173Z",
"dauer_ms": 5464,
"zaehler": {
"falsche_passwoerter_abgewiesen": 12,
"logins_erfolgreich": 12
}
},
{
"id": "CHK-01",
"titel": "Modulgrenzen-uebergreifende Kettenpruefung",
"haengt_ab_von": [
"DOK-03",
"DOK-05",
"TAR-02",
"MAI-03",
"MAI-04",
"WFL-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:34.376907441Z",
"dauer_ms": 65,
"zaehler": {
"akten": 9,
"befunde": 0,
"dokumente_mit_retention": 21,
"freigabelinks": 6,
"nachrichten_mit_retention": 19,
"offene_loeschanfragen": 3,
"reconciliation_berichte": 9,
"vollzogene_loeschungen": 3,
"workflow_vorgaenge": 9
}
}
]
}
@@ -0,0 +1,88 @@
CUT-01 Trockenlauf-Protokoll
Lauf: cut017f8b14 (Iteration 2 / Lauf 2.1 (Commit 4fdbb9b))
Beginn: 2026-10-14 20:54:05 UTC Ende: 2026-10-14 20:54:34 UTC Dauer: 28.809s
Ergebnis: ERFOLGREICH – alle 31 Schritte ohne Abbruch durchgelaufen
1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (6723 ms)
Zaehler: [modulstufen=4]
Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut017f8b14
2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6851 ms)
Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48]
Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go
3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (20 ms)
Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431]
Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606)
Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737)
Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH
4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (388 ms)
Zaehler: [core_migrationen=42]
5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms)
Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0]
Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)
6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (12 ms)
Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1]
7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (85 ms)
Zaehler: [eintraege_angelegt=4 freigegeben=4]
8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (2 ms)
Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6]
9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (250 ms)
Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24]
10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (384 ms)
Zaehler: [tenants_migriert=4]
11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1173 ms)
Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4]
12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3399 ms)
Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72]
13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (153 ms)
Zaehler: [benutzer_migriert=24]
14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (20 ms)
Zaehler: [benutzer_mit_ldap_bezug_geprueft=6]
15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (175 ms)
Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24]
16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (382 ms)
Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24]
Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert
17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (780 ms)
Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24]
Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (192 ms)
Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15]
Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (486 ms)
Zaehler: [felder=15 feldwerte=18 vorlagen=9]
Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (487 ms)
Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48]
Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
21. [DOK-05] Freigabelinks-Migration – erfolgreich (90 ms)
Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3]
Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (70 ms)
Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19]
Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll
23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (481 ms)
Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40]
24. [MAI-03] DSGVO-Loeschanfragen-Migration – erfolgreich (102 ms)
Zaehler: [einzelentscheidungen=9 historische_eintraege=0 offene_anfragen_verarbeitet=6]
Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
25. [MAI-04] Reconciliation-Reports-Archivierung – erfolgreich (92 ms)
Zaehler: [berichte=9 berichte_uebersprungen=0]
Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – erfolgreich (138 ms)
Zaehler: [abgebrochene_nicht_migriert=3 offene_loeschanfragen=3 vollzogene_loeschungen_audit=3]
Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
27. [WFL-01] Workflow-Historie-Migration – erfolgreich (251 ms)
Zaehler: [historieneintraege=21 vorgaenge_migriert=9 vorgaenge_offen_nicht_migriert=3]
Hinweis: cut017f8b14_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
Hinweis: cut017f8b14_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – erfolgreich (13 ms)
Zaehler: [quelle_documents=24 quelle_messages=19 quelle_users=24 vergleiche=12 ziel_documents=21 ziel_messages=19 ziel_users=24]
29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – erfolgreich (52 ms)
Zaehler: [abweichungen=0 dms_dateien=15 mail_anhaenge=9]
30. [VAL-05] Login-Test mit migrierten Zugangsdaten – erfolgreich (5464 ms)
Zaehler: [falsche_passwoerter_abgewiesen=12 logins_erfolgreich=12]
31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – erfolgreich (65 ms)
Zaehler: [akten=9 befunde=0 dokumente_mit_retention=21 freigabelinks=6 nachrichten_mit_retention=19 offene_loeschanfragen=3 reconciliation_berichte=9 vollzogene_loeschungen=3 workflow_vorgaenge=9]
@@ -0,0 +1,16 @@
# Zeilenzahl-Abgleich je Kerntabelle
| Tenant | Tabelle | Quelle | Ziel | Differenz | Erklaert | Status | Begruendung |
|---|---|---|---|---|---|---|---|
| cut017f8b14_ingenieurbuero_erfundenhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut017f8b14_ingenieurbuero_erfundenhausen | messages | 0 | 0 | 0 | 0 | OK | - |
| cut017f8b14_ingenieurbuero_erfundenhausen | users | 5 | 5 | 0 | 0 | OK | - |
| cut017f8b14_kanzlei_musterhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut017f8b14_kanzlei_musterhausen | messages | 6 | 6 | 0 | 0 | OK | - |
| cut017f8b14_kanzlei_musterhausen | users | 7 | 7 | 0 | 0 | OK | - |
| cut017f8b14_praxis_fantasia | documents | 0 | 0 | 0 | 0 | OK | - |
| cut017f8b14_praxis_fantasia | messages | 7 | 7 | 0 | 0 | OK | - |
| cut017f8b14_praxis_fantasia | users | 5 | 5 | 0 | 0 | OK | - |
| cut017f8b14_steuerbuero_beispielstadt | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut017f8b14_steuerbuero_beispielstadt | messages | 6 | 6 | 0 | 0 | OK | - |
| cut017f8b14_steuerbuero_beispielstadt | users | 7 | 7 | 0 | 0 | OK | - |
@@ -0,0 +1,11 @@
# Hash-Vergleichsbericht: Mandant cut017f8b14_ingenieurbuero_erfundenhausen
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut017f8b14_ingenieurbuero_erfundenhausen/9f5caa7d-ce26-5dee-bf56-8f1141f42c11 | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | OK |
| cut017f8b14_ingenieurbuero_erfundenhausen/09d053fa-5fd4-523c-b06d-5685ac3a7415 | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | OK |
| cut017f8b14_ingenieurbuero_erfundenhausen/3ba7bfd8-3a8d-5754-94e0-80261e83b0c4 | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | OK |
| cut017f8b14_ingenieurbuero_erfundenhausen/ab49f53f-4f07-5be3-9305-d48343908b52 | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | OK |
| cut017f8b14_ingenieurbuero_erfundenhausen/c012f9b7-cd1a-5279-ad21-248d38442461 | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | OK |
Stichprobe: 5 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,14 @@
# Hash-Vergleichsbericht: Mandant cut017f8b14_kanzlei_musterhausen
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut017f8b14_kanzlei_musterhausen/a779942e-b551-5d8f-9073-5c2e9b1666cc | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | OK |
| cut017f8b14_kanzlei_musterhausen/11977af2-9943-50e7-9cba-5539f21fba89 | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | OK |
| cut017f8b14_kanzlei_musterhausen/c000878e-55e2-555f-8c07-96994114b6e3 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | OK |
| cut017f8b14_kanzlei_musterhausen/7d517898-8c03-5739-a10d-35e726342aaf | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | OK |
| cut017f8b14_kanzlei_musterhausen/aa416387-4f99-57fc-9f00-d93f573c00c7 | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | OK |
| cut017f8b14_kanzlei_musterhausen/messages/mig-5c42a32b8c8c4de467dd212802f098a7/parts/0 | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | OK |
| cut017f8b14_kanzlei_musterhausen/messages/mig-ab874da042dbb570e09de407f06656f4/parts/0 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | OK |
| cut017f8b14_kanzlei_musterhausen/messages/mig-ab874da042dbb570e09de407f06656f4/parts/1 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | OK |
Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,9 @@
# Hash-Vergleichsbericht: Mandant cut017f8b14_praxis_fantasia
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut017f8b14_praxis_fantasia/messages/mig-3fbf5bbf5dc00daf9dd107911471a0ee/parts/0 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | OK |
| cut017f8b14_praxis_fantasia/messages/mig-bd88ce4e949582ca44db0b25295d7206/parts/0 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | OK |
| cut017f8b14_praxis_fantasia/messages/mig-bd88ce4e949582ca44db0b25295d7206/parts/1 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | OK |
Stichprobe: 3 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,14 @@
# Hash-Vergleichsbericht: Mandant cut017f8b14_steuerbuero_beispielstadt
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut017f8b14_steuerbuero_beispielstadt/9d630a9d-0424-5fbc-9d4b-534da0f1300c | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | OK |
| cut017f8b14_steuerbuero_beispielstadt/5a160e43-b3b3-54e1-93d1-f4d7993bff1f | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | OK |
| cut017f8b14_steuerbuero_beispielstadt/02b93e24-06f0-5486-bbf2-ce734fa06043 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | OK |
| cut017f8b14_steuerbuero_beispielstadt/81669a6f-a392-5d45-bd16-0886a6acb82e | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | OK |
| cut017f8b14_steuerbuero_beispielstadt/1ed9d9f3-b598-5a7b-b00d-56b66a98ead7 | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | OK |
| cut017f8b14_steuerbuero_beispielstadt/messages/mig-dda2393a74f8db6d68be9459e15451aa/parts/0 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | OK |
| cut017f8b14_steuerbuero_beispielstadt/messages/mig-cb7588459aaeeeead005a0436b0426d9/parts/0 | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | OK |
| cut017f8b14_steuerbuero_beispielstadt/messages/mig-cb7588459aaeeeead005a0436b0426d9/parts/1 | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | OK |
Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,555 @@
{
"lauf_kennung": "cut01d7d2a2",
"bezeichnung": "Iteration 2 / Lauf 2.2 (Commit 4fdbb9b)",
"gestartet": "2026-10-14T20:54:38.00195361Z",
"beendet": "2026-10-14T20:55:05.415408429Z",
"erfolgreich": true,
"schritte": [
{
"id": "PREP-01",
"titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen",
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:38.001974564Z",
"dauer_ms": 5361,
"zaehler": {
"modulstufen": 4
},
"hinweise": [
"arbeitsverzeichnis /root/cut01-dryrun/cut01d7d2a2"
]
},
{
"id": "PREP-02",
"titel": "Synthetische Testkopie archivmail/archivdms erzeugen",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:43.363047532Z",
"dauer_ms": 6799,
"zaehler": {
"archivdms_akten": 9,
"archivdms_audit": 12,
"archivdms_delete_requests": 9,
"archivdms_documents": 24,
"archivdms_shares": 15,
"archivdms_tenants": 3,
"archivdms_users": 15,
"archivdms_workflow_runs": 12,
"archivmail_attachments": 9,
"archivmail_audit": 12,
"archivmail_dsgvo": 9,
"archivmail_emails": 18,
"archivmail_reports": 9,
"archivmail_tenants": 3,
"archivmail_users": 15,
"binaerobjekte": 48
},
"hinweise": [
"generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go"
]
},
{
"id": "PREP-03",
"titel": "Anonymisierungsnachweis der Testkopie",
"haengt_ab_von": [
"PREP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.16227878Z",
"dauer_ms": 19,
"zaehler": {
"binaerobjekte": 48,
"e_mail_adressen": 93,
"ip_adressen": 24,
"personen": 30,
"tabellen": 30,
"verstoesse": 0,
"zeilen": 431
},
"hinweise": [
"alle e-mail-adressen unter der reservierten domain .example (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH"
]
},
{
"id": "PREP-04",
"titel": "Core-Registry anlegen (Core-Migrationen)",
"haengt_ab_von": [
"PREP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.181953214Z",
"dauer_ms": 401,
"zaehler": {
"core_migrationen": 42
}
},
{
"id": "EXP-01",
"titel": "Export-Sichten der Altsysteme bereitstellen",
"haengt_ab_von": [
"PREP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.583047169Z",
"dauer_ms": 12,
"zaehler": {
"export_sichten": 4,
"plattformkonten_ohne_mandant": 0
},
"hinweise": [
"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"
]
},
{
"id": "MAP-01",
"titel": "Tenant-Export und Slug/Domain-Abgleich",
"haengt_ab_von": [
"EXP-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.596038992Z",
"dauer_ms": 12,
"zaehler": {
"archivdms_tenants": 3,
"archivmail_tenants": 3,
"kandidaten": 4,
"konflikte": 0,
"manuelle_zuordnungen": 1
}
},
{
"id": "MAP-02",
"titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)",
"haengt_ab_von": [
"MAP-01",
"PREP-04"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.608962436Z",
"dauer_ms": 67,
"zaehler": {
"eintraege_angelegt": 4,
"freigegeben": 4
}
},
{
"id": "MAP-03",
"titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.676113397Z",
"dauer_ms": 1,
"zaehler": {
"archivdms_users": 15,
"archivmail_users": 15,
"kandidaten": 24,
"konflikte": 0,
"zusammengefuehrt": 6
}
},
{
"id": "MAP-04*",
"titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)",
"haengt_ab_von": [
"MAP-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.67808397Z",
"dauer_ms": 246,
"zaehler": {
"benutzer_freigegeben": 24,
"mit_ldap_bezug": 6,
"rollen_freigegeben": 24
}
},
{
"id": "COR-01",
"titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)",
"haengt_ab_von": [
"MAP-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:50.924911917Z",
"dauer_ms": 379,
"zaehler": {
"tenants_migriert": 4
}
},
{
"id": "SCHEMA-01",
"titel": "Core-Tenant-Schema ausrollen (TEN-07)",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:51.304456415Z",
"dauer_ms": 1199,
"zaehler": {
"core_tenant_migrationen_angewendet": 84,
"tenants": 4
}
},
{
"id": "SCHEMA-02",
"titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)",
"haengt_ab_von": [
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:52.504142805Z",
"dauer_ms": 3374,
"zaehler": {
"archive_migrationen_angewendet": 72,
"dms_migrationen_angewendet": 148,
"workflow_migrationen_angewendet": 72
}
},
{
"id": "COR-02",
"titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1",
"haengt_ab_von": [
"MAP-04*",
"SCHEMA-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:55.879041655Z",
"dauer_ms": 172,
"zaehler": {
"benutzer_migriert": 24
}
},
{
"id": "COR-04",
"titel": "LDAP-Feld-Migration pruefen",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:56.051096318Z",
"dauer_ms": 24,
"zaehler": {
"benutzer_mit_ldap_bezug_geprueft": 6
}
},
{
"id": "COR-03",
"titel": "Rollen- und Rechte-Migration",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:56.07582329Z",
"dauer_ms": 170,
"zaehler": {
"rolle_tenant_admin": 7,
"rolle_user": 17,
"rollen_zugewiesen": 24
}
},
{
"id": "COR-05",
"titel": "Audit-Log-Zusammenfuehrung",
"haengt_ab_von": [
"MAP-04*",
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:56.246391695Z",
"dauer_ms": 298,
"zaehler": {
"audit_ereignisse_migriert": 24,
"staging_zeilen": 24
},
"hinweise": [
"zeitstempel und aktion je ereignis gegen staging unveraendert"
]
},
{
"id": "DOK-01",
"titel": "Dokument- und Revisions-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:56.544854606Z",
"dauer_ms": 456,
"zaehler": {
"dokumente": 21,
"revisionen": 21,
"revisionen_uebersprungen": 0,
"uebergabe_objekte": 24
},
"hinweise": [
"cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-02",
"titel": "Akten-/Ordnerstruktur-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:57.001630278Z",
"dauer_ms": 176,
"zaehler": {
"akten": 9,
"akten_uebersprungen": 0,
"dokumente_verknuepft": 15
},
"hinweise": [
"cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-04",
"titel": "Custom-Fields- und Klassifikationsvorlagen-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:57.178488059Z",
"dauer_ms": 461,
"zaehler": {
"felder": 15,
"feldwerte": 18,
"vorlagen": 9
},
"hinweise": [
"cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-03",
"titel": "ACL-Daten-Migration nach Core-Rechtemodell",
"haengt_ab_von": [
"DOK-01",
"COR-03"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:57.640399037Z",
"dauer_ms": 408,
"zaehler": {
"core_gruppen": 9,
"gruppenmitgliedschaften": 15,
"objektrechte": 48
},
"hinweise": [
"cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "DOK-05",
"titel": "Freigabelinks-Migration",
"haengt_ab_von": [
"DOK-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:58.048496407Z",
"dauer_ms": 78,
"zaehler": {
"freigabelinks": 6,
"freigabelinks_abgelaufen_verworfen": 3
},
"hinweise": [
"cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "MAI-02",
"titel": "Nachrichten- und Anhang-Migration",
"haengt_ab_von": [
"COR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:58.126793783Z",
"dauer_ms": 99,
"zaehler": {
"anhaenge": 9,
"nachrichten": 19,
"nachrichten_uebersprungen": 0,
"uebergabe_objekte": 19
},
"hinweise": [
"cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt",
"suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll"
]
},
{
"id": "TAR-01",
"titel": "Retention-Fristen-Migration ohne Reset",
"haengt_ab_von": [
"DOK-01",
"MAI-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:58.226332874Z",
"dauer_ms": 405,
"zaehler": {
"fristen_geprueft": 40,
"fristen_uebernommen": 27,
"objekte_registriert": 40
}
},
{
"id": "MAI-03",
"titel": "DSGVO-Loeschanfragen-Migration",
"haengt_ab_von": [
"TAR-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:58.632067065Z",
"dauer_ms": 106,
"zaehler": {
"einzelentscheidungen": 9,
"historische_eintraege": 0,
"offene_anfragen_verarbeitet": 6
},
"hinweise": [
"cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt"
]
},
{
"id": "MAI-04",
"titel": "Reconciliation-Reports-Archivierung",
"haengt_ab_von": [
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:58.738701138Z",
"dauer_ms": 75,
"zaehler": {
"berichte": 9,
"berichte_uebersprungen": 0
},
"hinweise": [
"cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt"
]
},
{
"id": "TAR-02",
"titel": "Vier-Augen-Loeschworkflow-Migration",
"haengt_ab_von": [
"TAR-01",
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:58.814358815Z",
"dauer_ms": 111,
"zaehler": {
"abgebrochene_nicht_migriert": 3,
"offene_loeschanfragen": 3,
"vollzogene_loeschungen_audit": 3
},
"hinweise": [
"cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt"
]
},
{
"id": "WFL-01",
"titel": "Workflow-Historie-Migration",
"haengt_ab_von": [
"COR-02",
"SCHEMA-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:58.926068826Z",
"dauer_ms": 215,
"zaehler": {
"historieneintraege": 21,
"vorgaenge_migriert": 9,
"vorgaenge_offen_nicht_migriert": 3
},
"hinweise": [
"cut01d7d2a2_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden",
"cut01d7d2a2_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden",
"cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt",
"cut01d7d2a2_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden"
]
},
{
"id": "VAL-01",
"titel": "Zeilenzahl-Abgleich je Kerntabelle",
"haengt_ab_von": [
"COR-02",
"DOK-01",
"MAI-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:59.142060837Z",
"dauer_ms": 13,
"zaehler": {
"quelle_documents": 24,
"quelle_messages": 19,
"quelle_users": 24,
"vergleiche": 12,
"ziel_documents": 21,
"ziel_messages": 19,
"ziel_users": 24
}
},
{
"id": "VAL-02",
"titel": "Hash-Vergleich migrierter Binaerdateien",
"haengt_ab_von": [
"DOK-01",
"MAI-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:59.156065221Z",
"dauer_ms": 60,
"zaehler": {
"abweichungen": 0,
"dms_dateien": 15,
"mail_anhaenge": 9
}
},
{
"id": "VAL-05",
"titel": "Login-Test mit migrierten Zugangsdaten",
"haengt_ab_von": [
"COR-02"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:54:59.216868135Z",
"dauer_ms": 6119,
"zaehler": {
"falsche_passwoerter_abgewiesen": 12,
"logins_erfolgreich": 12
}
},
{
"id": "CHK-01",
"titel": "Modulgrenzen-uebergreifende Kettenpruefung",
"haengt_ab_von": [
"DOK-03",
"DOK-05",
"TAR-02",
"MAI-03",
"MAI-04",
"WFL-01"
],
"status": "erfolgreich",
"gestartet": "2026-10-14T20:55:05.336524344Z",
"dauer_ms": 78,
"zaehler": {
"akten": 9,
"befunde": 0,
"dokumente_mit_retention": 21,
"freigabelinks": 6,
"nachrichten_mit_retention": 19,
"offene_loeschanfragen": 3,
"reconciliation_berichte": 9,
"vollzogene_loeschungen": 3,
"workflow_vorgaenge": 9
}
}
]
}
@@ -0,0 +1,88 @@
CUT-01 Trockenlauf-Protokoll
Lauf: cut01d7d2a2 (Iteration 2 / Lauf 2.2 (Commit 4fdbb9b))
Beginn: 2026-10-14 20:54:38 UTC Ende: 2026-10-14 20:55:05 UTC Dauer: 27.413s
Ergebnis: ERFOLGREICH – alle 31 Schritte ohne Abbruch durchgelaufen
1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5361 ms)
Zaehler: [modulstufen=4]
Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01d7d2a2
2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6799 ms)
Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48]
Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go
3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (19 ms)
Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431]
Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606)
Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737)
Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH
4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (401 ms)
Zaehler: [core_migrationen=42]
5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms)
Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0]
Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)
6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (12 ms)
Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1]
7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (67 ms)
Zaehler: [eintraege_angelegt=4 freigegeben=4]
8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (1 ms)
Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6]
9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (246 ms)
Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24]
10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (379 ms)
Zaehler: [tenants_migriert=4]
11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1199 ms)
Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4]
12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3374 ms)
Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72]
13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (172 ms)
Zaehler: [benutzer_migriert=24]
14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (24 ms)
Zaehler: [benutzer_mit_ldap_bezug_geprueft=6]
15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (170 ms)
Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24]
16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (298 ms)
Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24]
Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert
17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (456 ms)
Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24]
Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (176 ms)
Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15]
Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (461 ms)
Zaehler: [felder=15 feldwerte=18 vorlagen=9]
Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (408 ms)
Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48]
Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
21. [DOK-05] Freigabelinks-Migration – erfolgreich (78 ms)
Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3]
Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (99 ms)
Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19]
Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll
23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (405 ms)
Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40]
24. [MAI-03] DSGVO-Loeschanfragen-Migration – erfolgreich (106 ms)
Zaehler: [einzelentscheidungen=9 historische_eintraege=0 offene_anfragen_verarbeitet=6]
Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
25. [MAI-04] Reconciliation-Reports-Archivierung – erfolgreich (75 ms)
Zaehler: [berichte=9 berichte_uebersprungen=0]
Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt
26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – erfolgreich (111 ms)
Zaehler: [abgebrochene_nicht_migriert=3 offene_loeschanfragen=3 vollzogene_loeschungen_audit=3]
Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
27. [WFL-01] Workflow-Historie-Migration – erfolgreich (215 ms)
Zaehler: [historieneintraege=21 vorgaenge_migriert=9 vorgaenge_offen_nicht_migriert=3]
Hinweis: cut01d7d2a2_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
Hinweis: cut01d7d2a2_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt
Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden
28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – erfolgreich (13 ms)
Zaehler: [quelle_documents=24 quelle_messages=19 quelle_users=24 vergleiche=12 ziel_documents=21 ziel_messages=19 ziel_users=24]
29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – erfolgreich (60 ms)
Zaehler: [abweichungen=0 dms_dateien=15 mail_anhaenge=9]
30. [VAL-05] Login-Test mit migrierten Zugangsdaten – erfolgreich (6119 ms)
Zaehler: [falsche_passwoerter_abgewiesen=12 logins_erfolgreich=12]
31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – erfolgreich (78 ms)
Zaehler: [akten=9 befunde=0 dokumente_mit_retention=21 freigabelinks=6 nachrichten_mit_retention=19 offene_loeschanfragen=3 reconciliation_berichte=9 vollzogene_loeschungen=3 workflow_vorgaenge=9]
@@ -0,0 +1,16 @@
# Zeilenzahl-Abgleich je Kerntabelle
| Tenant | Tabelle | Quelle | Ziel | Differenz | Erklaert | Status | Begruendung |
|---|---|---|---|---|---|---|---|
| cut01d7d2a2_ingenieurbuero_erfundenhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut01d7d2a2_ingenieurbuero_erfundenhausen | messages | 0 | 0 | 0 | 0 | OK | - |
| cut01d7d2a2_ingenieurbuero_erfundenhausen | users | 5 | 5 | 0 | 0 | OK | - |
| cut01d7d2a2_kanzlei_musterhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut01d7d2a2_kanzlei_musterhausen | messages | 6 | 6 | 0 | 0 | OK | - |
| cut01d7d2a2_kanzlei_musterhausen | users | 7 | 7 | 0 | 0 | OK | - |
| cut01d7d2a2_praxis_fantasia | documents | 0 | 0 | 0 | 0 | OK | - |
| cut01d7d2a2_praxis_fantasia | messages | 7 | 7 | 0 | 0 | OK | - |
| cut01d7d2a2_praxis_fantasia | users | 5 | 5 | 0 | 0 | OK | - |
| cut01d7d2a2_steuerbuero_beispielstadt | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) |
| cut01d7d2a2_steuerbuero_beispielstadt | messages | 6 | 6 | 0 | 0 | OK | - |
| cut01d7d2a2_steuerbuero_beispielstadt | users | 7 | 7 | 0 | 0 | OK | - |
@@ -0,0 +1,11 @@
# Hash-Vergleichsbericht: Mandant cut01d7d2a2_ingenieurbuero_erfundenhausen
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut01d7d2a2_ingenieurbuero_erfundenhausen/5fa55479-709b-50fc-bb4a-4e1d72f258ad | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | OK |
| cut01d7d2a2_ingenieurbuero_erfundenhausen/9929fb0f-7a91-54f7-b1fb-b47106a70b5d | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | OK |
| cut01d7d2a2_ingenieurbuero_erfundenhausen/3afe5a33-2728-5edd-a5ab-8374163a5afa | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | OK |
| cut01d7d2a2_ingenieurbuero_erfundenhausen/6dd21d9a-1324-5c89-8ad8-9aa243825d8d | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | OK |
| cut01d7d2a2_ingenieurbuero_erfundenhausen/abbea734-500a-5e83-9e36-b45f79484395 | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | OK |
Stichprobe: 5 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,14 @@
# Hash-Vergleichsbericht: Mandant cut01d7d2a2_kanzlei_musterhausen
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut01d7d2a2_kanzlei_musterhausen/5c90b9d4-8b9c-563f-802e-f712eba55f7f | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | OK |
| cut01d7d2a2_kanzlei_musterhausen/6598fdf2-e945-5f32-a59f-be9ea3063c3a | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | OK |
| cut01d7d2a2_kanzlei_musterhausen/ff94f333-f60a-5c91-b5e3-31eac6c210f3 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | OK |
| cut01d7d2a2_kanzlei_musterhausen/4fa047d4-7766-508c-8141-f32b485ec0bc | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | OK |
| cut01d7d2a2_kanzlei_musterhausen/f826412b-6e79-57a9-9e15-2b94c43d0c2e | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | OK |
| cut01d7d2a2_kanzlei_musterhausen/messages/mig-f4fdc7e5c1bc7df3412f83fe079094d4/parts/0 | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | OK |
| cut01d7d2a2_kanzlei_musterhausen/messages/mig-a801b5c366c7a2b74d052a38bc031a0d/parts/0 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | OK |
| cut01d7d2a2_kanzlei_musterhausen/messages/mig-a801b5c366c7a2b74d052a38bc031a0d/parts/1 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | OK |
Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,9 @@
# Hash-Vergleichsbericht: Mandant cut01d7d2a2_praxis_fantasia
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut01d7d2a2_praxis_fantasia/messages/mig-56abab033555e94bb89512bddcec6606/parts/0 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | OK |
| cut01d7d2a2_praxis_fantasia/messages/mig-b6722e823fd59249f1d08586c13da54a/parts/0 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | OK |
| cut01d7d2a2_praxis_fantasia/messages/mig-b6722e823fd59249f1d08586c13da54a/parts/1 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | OK |
Stichprobe: 3 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,14 @@
# Hash-Vergleichsbericht: Mandant cut01d7d2a2_steuerbuero_beispielstadt
| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |
|---|---|---|---|
| cut01d7d2a2_steuerbuero_beispielstadt/ac3f9720-0727-5dfe-b87c-2e682d8464c1 | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | OK |
| cut01d7d2a2_steuerbuero_beispielstadt/08b4f6a4-d525-516a-b8d2-ec4c01971a1d | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | OK |
| cut01d7d2a2_steuerbuero_beispielstadt/d18c7e75-9871-53ee-9a47-323dea8e4269 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | OK |
| cut01d7d2a2_steuerbuero_beispielstadt/fbf67d7f-0105-5d06-ac79-3d05f3de160d | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | OK |
| cut01d7d2a2_steuerbuero_beispielstadt/beaa8b3a-4784-52fc-9afd-7794071665e6 | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | OK |
| cut01d7d2a2_steuerbuero_beispielstadt/messages/mig-bc0db504d27ad4e942968e8ccc87ae32/parts/0 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | OK |
| cut01d7d2a2_steuerbuero_beispielstadt/messages/mig-c4f9000fec46a0ea7786a9a5d7acdb38/parts/0 | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | OK |
| cut01d7d2a2_steuerbuero_beispielstadt/messages/mig-c4f9000fec46a0ea7786a9a5d7acdb38/parts/1 | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | OK |
Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler.
@@ -0,0 +1,144 @@
package cutoverorchestrator
import (
"bytes"
"context"
"fmt"
"io/fs"
"net"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
emailInText = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
ipv4InText = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
)
// Nur Dokumentationsnetze nach RFC 5737 sind in der Testkopie zulaessig.
var documentationNets = []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"}
type AnonymizationReport struct {
Tables int
Rows int
Emails int
IPs int
Persons int
Files int
Violations []string
}
func (r AnonymizationReport) Counts() map[string]int {
return map[string]int{
"tabellen": r.Tables, "zeilen": r.Rows, "e_mail_adressen": r.Emails, "ip_adressen": r.IPs,
"personen": r.Persons, "binaerobjekte": r.Files, "verstoesse": len(r.Violations),
}
}
func inDocumentationNet(ip string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return true
}
for _, cidr := range documentationNets {
_, n, _ := net.ParseCIDR(cidr)
if n.Contains(parsed) {
return true
}
}
return false
}
func (r *AnonymizationReport) checkText(where, text string) {
for _, addr := range emailInText.FindAllString(text, -1) {
r.Emails++
if !strings.HasSuffix(strings.ToLower(addr), ReservedMailDomainSuffix) && !strings.HasSuffix(strings.ToLower(addr), ReservedMailDomainSuffix+">") {
r.Violations = append(r.Violations, fmt.Sprintf("%s: e-mail-adresse ausserhalb der reservierten domain %s", where, ReservedMailDomainSuffix))
}
}
for _, ip := range ipv4InText.FindAllString(text, -1) {
r.IPs++
if !inDocumentationNet(ip) {
r.Violations = append(r.Violations, fmt.Sprintf("%s: ip-adresse ausserhalb der dokumentationsnetze (rfc 5737)", where))
}
}
}
// VerifyAnonymization prueft die Testkopie unabhaengig vom Generator: jede
// Zeile jeder Tabelle beider Altsystem-Kopien wird als JSON gelesen und auf
// E-Mail-/IP-Muster durchsucht, Personenkonten muessen das synthetische
// Praefix tragen und jedes Binaerobjekt muss mit dem Synthetik-Marker beginnen.
func VerifyAnonymization(ctx context.Context, pools map[string]*pgxpool.Pool, objectRoot string) (AnonymizationReport, error) {
var rep AnonymizationReport
for system, pool := range pools {
rows, err := pool.Query(ctx, `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY 1`)
if err != nil {
return rep, fmt.Errorf("%s: tabellen auflisten: %w", system, err)
}
tables, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return rep, fmt.Errorf("%s: tabellen lesen: %w", system, err)
}
for _, table := range tables {
rep.Tables++
q := fmt.Sprintf(`SELECT row_to_json(t)::text FROM %s t`, pgx.Identifier{table}.Sanitize())
rows, err := pool.Query(ctx, q)
if err != nil {
return rep, fmt.Errorf("%s.%s lesen: %w", system, table, err)
}
texts, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return rep, fmt.Errorf("%s.%s lesen: %w", system, table, err)
}
for i, text := range texts {
rep.Rows++
rep.checkText(fmt.Sprintf("%s.%s zeile %d", system, table, i+1), text)
}
}
rows, err = pool.Query(ctx, `SELECT username, email FROM users`)
if err != nil {
return rep, fmt.Errorf("%s: benutzer lesen: %w", system, err)
}
for rows.Next() {
var username, email string
if err := rows.Scan(&username, &email); err != nil {
rows.Close()
return rep, err
}
rep.Persons++
if !strings.Contains(strings.ToLower(username), "testperson") || !strings.Contains(strings.ToLower(email), "testperson") {
rep.Violations = append(rep.Violations, fmt.Sprintf("%s.users: konto ohne synthetisches praefix", system))
}
}
rows.Close()
if err := rows.Err(); err != nil {
return rep, err
}
}
err := filepath.WalkDir(objectRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rep.Files++
content, err := os.ReadFile(path)
if err != nil {
return err
}
if !bytes.HasPrefix(content, []byte(SyntheticMarker)) {
rel, _ := filepath.Rel(objectRoot, path)
rep.Violations = append(rep.Violations, fmt.Sprintf("binaerobjekt %s ohne synthetik-marker", rel))
}
return nil
})
if err != nil {
return rep, fmt.Errorf("binaerobjekte pruefen: %w", err)
}
return rep, nil
}
+144
View File
@@ -0,0 +1,144 @@
package cutoverorchestrator
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var errDSNFormat = errors.New("cutoverorchestrator: admin-dsn muss im url-format postgres[ql]://.../<db> vorliegen")
type Cluster struct {
adminDSN string
parsed *url.URL
}
func NewCluster(adminDSN string) (*Cluster, error) {
u, err := url.Parse(adminDSN)
if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.Host == "" {
return nil, errDSNFormat
}
return &Cluster{adminDSN: adminDSN, parsed: u}, nil
}
func (c *Cluster) AdminDSN() string { return c.adminDSN }
func (c *Cluster) DSNFor(database string) string {
u := *c.parsed
u.Path = "/" + database
u.RawPath = ""
return u.String()
}
// TenantDSNTemplate liefert die von tenant.Provisioner erwartete Vorlage mit
// genau einem %s; vorhandene Prozentzeichen (url-kodierte Passwoerter)
// werden verdoppelt, sonst wuerde fmt.Sprintf sie als Verben deuten.
func (c *Cluster) TenantDSNTemplate() string {
const placeholder = "cutoverdbnameplaceholder"
dsn := strings.ReplaceAll(c.DSNFor(placeholder), "%", "%%")
return strings.Replace(dsn, placeholder, "%s", 1)
}
func (c *Cluster) Connect(ctx context.Context, database string) (*pgxpool.Pool, error) {
return c.ConnectSchema(ctx, database, "")
}
func (c *Cluster) ConnectSchema(ctx context.Context, database, schema string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(c.adminDSN)
if err != nil {
return nil, errDSNFormat
}
cfg.ConnConfig.Database = database
if schema != "" {
cfg.ConnConfig.RuntimeParams["search_path"] = schema + ",public"
}
cfg.MaxConns = 4
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("verbindung zu datenbank %q: %w", database, err)
}
return pool, nil
}
func (c *Cluster) admin(ctx context.Context) (*pgx.Conn, error) {
conn, err := pgx.Connect(ctx, c.adminDSN)
if err != nil {
return nil, fmt.Errorf("verbindung zur wartungsdatenbank: %w", err)
}
return conn, nil
}
func (c *Cluster) CreateDatabase(ctx context.Context, name string) error {
conn, err := c.admin(ctx)
if err != nil {
return err
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize()); err != nil {
return fmt.Errorf("datenbank %q anlegen: %w", name, err)
}
return nil
}
// DropRunDatabases entfernt ausschliesslich Datenbanken, deren Name die
// Laufkennung enthaelt – fremde Test-Datenbanken auf dem geteilten
// Wegwerf-Cluster bleiben unberuehrt.
func (c *Cluster) DropRunDatabases(ctx context.Context, runTag string) ([]string, error) {
if len(runTag) < 6 {
return nil, fmt.Errorf("laufkennung %q zu kurz fuer sicheres aufraeumen", runTag)
}
conn, err := c.admin(ctx)
if err != nil {
return nil, err
}
defer conn.Close(ctx)
rows, err := conn.Query(ctx, `SELECT datname FROM pg_database WHERE position($1 in datname) > 0`, runTag)
if err != nil {
return nil, fmt.Errorf("lauf-datenbanken auflisten: %w", err)
}
var names []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
rows.Close()
return nil, err
}
names = append(names, n)
}
rows.Close()
sort.Strings(names)
for _, n := range names {
if _, err := conn.Exec(ctx, "DROP DATABASE IF EXISTS "+pgx.Identifier{n}.Sanitize()+" WITH (FORCE)"); err != nil {
return names, fmt.Errorf("datenbank %q entfernen: %w", n, err)
}
}
return names, nil
}
func applySQLDir(ctx context.Context, pool *pgxpool.Pool, dir string) (int, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.up.sql"))
if err != nil {
return 0, err
}
sort.Strings(files)
for _, f := range files {
sqlText, err := os.ReadFile(f)
if err != nil {
return 0, fmt.Errorf("migration %s lesen: %w", filepath.Base(f), err)
}
if _, err := pool.Exec(ctx, string(sqlText)); err != nil {
return 0, fmt.Errorf("migration %s anwenden: %w", filepath.Base(f), err)
}
}
return len(files), nil
}
+708
View File
@@ -0,0 +1,708 @@
package cutoverorchestrator
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// SyntheticMarker steht am Anfang jedes erzeugten Binaerobjekts und jedes
// Freitexts, damit der Anonymisierungsnachweis jede Zeile der Testkopie als
// erzeugt erkennen kann.
const SyntheticMarker = "SYNTHETISCH"
// ReservedMailDomainSuffix: RFC 2606 reserviert ".example" – Adressen
// darunter koennen keiner realen Person gehoeren.
const ReservedMailDomainSuffix = ".example"
const syntheticPersonPrefix = "Testperson"
type LegacyTenant struct {
ID int64
Slug string
Name string
Domains []string
}
type ArchivmailUser struct {
ID int64
TenantID int64
Username string
Email string
PasswordHash string
Role string
Source string
}
type ArchivdmsUser struct {
ID int64
TenantID int64
Username string
Email string
PasswordHash string
Role string
AuthSource string
LDAPUID *string
LDAPSyncedAt *time.Time
}
type StorageObject struct {
ID int64
Path string
Content []byte
}
type MailAttachment struct {
ID int64
Filename string
MimeType string
StorageID int64
}
type Email struct {
ID string
TenantID int64
ReceivedAt time.Time
From string
To string
Subject string
RetainUntil *time.Time
StorageID int64
AttachmentIDs []int64
SharedWith []int64
IndexedAt *time.Time
}
type DsgvoRequest struct {
ID int64
TenantID int64
Address string
RequestedBy string
Status string
CreatedAt time.Time
}
type ReconciliationReport struct {
ID int64
TenantID int64
Date time.Time
SourceType string
SourceID int64
Expected int
Archived int
}
type LegacyAuditEntry struct {
ID int64
At time.Time
EventType string
Username string
IP string
Ref string
Query string
Success bool
Detail string
TenantID int64
}
type PermissionGroup struct {
ID int64
TenantID int64
Name string
Members []int64
}
type Akte struct {
ID int64
TenantID int64
Titel string
Status string
CreatedBy int64
}
type DocumentType struct {
ID int64
TenantID int64
Name string
}
type LegacyDocument struct {
ID int64
TenantID int64
Title string
StoragePath string
ContentHash string
Content []byte
RetainUntil *time.Time
CreatedBy int64
CreatedAt time.Time
AkteID *int64
DocTypeID *int64
DeletedAt *time.Time
DeletedBy *int64
}
type GroupAccess struct {
DocumentID int64
GroupID int64
Access string
}
type FieldDef struct {
ID int64
TenantID int64
Name string
Type string
Required bool
EnumOptions []string
}
type TemplateField struct {
FieldID int64
Default *string
}
type ClassificationTemplate struct {
ID int64
TenantID int64
Name string
DocTypeID *int64
RetentionYears *int
Fields []TemplateField
}
type FieldValue struct {
DocumentID int64
FieldID int64
Value string
}
type DocumentShare struct {
ID int64
TenantID int64
DocumentID int64
TokenHashHex string
CreatedBy int64
CreatedAt time.Time
ExpiresAt time.Time
MaxAccesses *int
AccessCount int
PasswordHash *string
RevokedAt *time.Time
}
type DeleteRequest struct {
ID int64
DocumentID int64
TenantID int64
RequestedBy int64
RequestedAt time.Time
ConfirmedBy *int64
ConfirmedAt *time.Time
Status string
}
type WorkflowEvent struct {
From string
To string
ActorID int64
Note string
At time.Time
}
type WorkflowRun struct {
ID int64
WorkflowID int64
TenantID int64
DocumentID *int64
Status string
FinalState string
StartedAt time.Time
FinishedAt *time.Time
Events []WorkflowEvent
}
type LegacyWorkflow struct {
ID int64
TenantID int64
Name string
}
type ArchivmailData struct {
Tenants []LegacyTenant
Users []ArchivmailUser
StorageObjects []StorageObject
Attachments []MailAttachment
Emails []Email
DsgvoRequests []DsgvoRequest
Reports []ReconciliationReport
Audit []LegacyAuditEntry
}
type ArchivdmsData struct {
Tenants []LegacyTenant
Users []ArchivdmsUser
Groups []PermissionGroup
DocTypes []DocumentType
Akten []Akte
Documents []LegacyDocument
Visibility []GroupAccess
Grants []GroupAccess
FieldDefs []FieldDef
Templates []ClassificationTemplate
FieldValues []FieldValue
Shares []DocumentShare
DeleteRequests []DeleteRequest
Workflows []LegacyWorkflow
WorkflowRuns []WorkflowRun
Audit []LegacyAuditEntry
}
type Dataset struct {
Archivmail ArchivmailData
Archivdms ArchivdmsData
ManualDecisionsCSV string
// Passwords haelt die erzeugten Klartext-Passwoerter nur im Speicher
// des Laufs (fuer VAL-05), Schluessel "archivmail:<id>"/"archivdms:<id>".
Passwords map[string]string
}
type orgSpec struct {
key string
name string
mailDomain string
archivmailID int64
archivmailSlug string
extraDomains []string
archivdmsID int64
archivdmsSlug string
}
// syntheticOrganisations: zwei Organisationen nutzen beide Altsysteme
// (einmal per Slug, einmal nur ueber die manuelle Kundenliste zuordenbar),
// je eine nutzt nur eines der beiden Systeme – die teilweise
// Ueberschneidung aus MIGRATIONSKONZEPT.md Abschnitt 1.
var syntheticOrganisations = []orgSpec{
{key: "musterhausen", name: "Kanzlei Musterhausen", mailDomain: "kanzlei-musterhausen.example",
archivmailID: 101, archivmailSlug: "kanzlei-musterhausen", archivdmsID: 7, archivdmsSlug: "kanzlei-musterhausen"},
{key: "beispielstadt", name: "Steuerbuero Beispielstadt", mailDomain: "stb-beispielstadt.example",
archivmailID: 102, archivmailSlug: "steuerbuero-beispielstadt", extraDomains: []string{"steuerbuero-beispielstadt.example"},
archivdmsID: 8, archivdmsSlug: "stb-beispielstadt"},
{key: "fantasia", name: "Praxis Fantasia", mailDomain: "praxis-fantasia.example",
archivmailID: 103, archivmailSlug: "praxis-fantasia"},
{key: "erfundenhausen", name: "Ingenieurbuero Erfundenhausen", mailDomain: "ingenieurbuero-erfundenhausen.example",
archivdmsID: 9, archivdmsSlug: "ingenieurbuero-erfundenhausen"},
}
const (
SubjectOpenRequest = "betroffene.person@kundschaft-beispiel.example"
SubjectCompletedRequest = "ehemalige.person@kundschaft-beispiel.example"
SubjectPartialRequest = "teilweise.person@kundschaft-beispiel.example"
)
type idSeq struct{ next int64 }
func (s *idSeq) take() int64 {
s.next++
return s.next
}
type generator struct {
rnd *rand.Rand
now time.Time
bcryptCost int
ds *Dataset
ids map[string]*idSeq
}
func (g *generator) id(kind string, start int64) int64 {
s, ok := g.ids[kind]
if !ok {
s = &idSeq{next: start}
g.ids[kind] = s
}
return s.take()
}
func (g *generator) randomHex(n int) string {
b := make([]byte, n)
g.rnd.Read(b)
return hex.EncodeToString(b)
}
func (g *generator) password(key string) (string, string, error) {
pw := "Syn-" + g.randomHex(8) + "-Pw"
hash, err := bcrypt.GenerateFromPassword([]byte(pw), g.bcryptCost)
if err != nil {
return "", "", fmt.Errorf("synthetischen passwort-hash erzeugen: %w", err)
}
g.ds.Passwords[key] = pw
return pw, string(hash), nil
}
func timePtr(t time.Time) *time.Time { return &t }
func int64Ptr(v int64) *int64 { return &v }
func intPtr(v int) *int { return &v }
func strPtr(v string) *string { return &v }
// GenerateDataset erzeugt die komplette Testkopie ausschliesslich aus
// diesem Code: es gibt keinen Parameter, ueber den Daten aus einem
// Altsystem, einem Export oder einer Datei einfliessen koennten.
func GenerateDataset(seed int64, now time.Time, bcryptCost int) (*Dataset, error) {
g := &generator{
rnd: rand.New(rand.NewSource(seed)),
now: now.UTC().Truncate(time.Second),
bcryptCost: bcryptCost,
ds: &Dataset{Passwords: map[string]string{}},
ids: map[string]*idSeq{},
}
var manual strings.Builder
manual.WriteString("archivmail_slug,archivdms_slug,match,note\n")
for _, org := range syntheticOrganisations {
if org.archivmailID != 0 {
if err := g.archivmailTenant(org); err != nil {
return nil, err
}
}
if org.archivdmsID != 0 {
if err := g.archivdmsTenant(org); err != nil {
return nil, err
}
}
if org.archivmailID != 0 && org.archivdmsID != 0 && org.archivmailSlug != org.archivdmsSlug {
fmt.Fprintf(&manual, "%s,%s,true,synthetische Kundenzuordnungsliste (%s)\n", org.archivmailSlug, org.archivdmsSlug, org.name)
}
}
g.ds.ManualDecisionsCSV = manual.String()
return g.ds, nil
}
func personEmail(n int, domain string) string {
return fmt.Sprintf("testperson%02d@%s", n, domain)
}
func personName(n int, org orgSpec) string {
return fmt.Sprintf("%s %02d (%s)", syntheticPersonPrefix, n, org.name)
}
func (g *generator) archivmailTenant(org orgSpec) error {
am := &g.ds.Archivmail
domains := append([]string{org.mailDomain}, org.extraDomains...)
am.Tenants = append(am.Tenants, LegacyTenant{ID: org.archivmailID, Slug: org.archivmailSlug, Name: org.name, Domains: domains})
roles := []string{"domain_admin", "user", "user", "auditor", "admin"}
users := make([]ArchivmailUser, 0, len(roles))
for i, role := range roles {
n := i + 1
id := g.id("am_user", 1000)
_, hash, err := g.password(fmt.Sprintf("archivmail:%d", id))
if err != nil {
return err
}
source := "local"
if n == 2 {
source = "ldap"
}
users = append(users, ArchivmailUser{
ID: id, TenantID: org.archivmailID,
Username: fmt.Sprintf("testperson%02d.%s", n, org.key),
Email: personEmail(n, org.mailDomain),
PasswordHash: hash, Role: role, Source: source,
})
}
am.Users = append(am.Users, users...)
type mailSpec struct {
from string
retain *time.Time
attachments int
}
specs := []mailSpec{
{from: SubjectOpenRequest, retain: timePtr(g.now.AddDate(6, 0, 0))},
{from: "lieferant02@lieferant-beispiel.example", retain: timePtr(g.now.AddDate(10, 0, 0)), attachments: 1},
{from: SubjectOpenRequest, retain: timePtr(g.now.AddDate(-1, 0, 0))},
{from: "lieferant04@lieferant-beispiel.example", attachments: 2},
{from: SubjectPartialRequest, retain: timePtr(g.now.AddDate(2, 0, 0))},
{from: "rundschreiben@verband-beispiel.example"},
}
for i, spec := range specs {
n := i + 1
body := fmt.Sprintf("%s – frei erfundener Nachrichtentext %d fuer %s. Zufallsanteil %s.", SyntheticMarker, n, org.name, g.randomHex(6))
bodyObj := g.storageObject(fmt.Sprintf("mails/%d/%s.eml", org.archivmailID, g.randomHex(8)), []byte(body))
e := Email{
ID: fmt.Sprintf("am-%d-%04d", org.archivmailID, n),
TenantID: org.archivmailID,
ReceivedAt: g.now.AddDate(0, -n, -n),
From: spec.from,
To: personEmail(1, org.mailDomain),
Subject: fmt.Sprintf("%s Testnachricht %d (%s)", SyntheticMarker, n, org.name),
RetainUntil: spec.retain,
StorageID: bodyObj.ID,
}
if n%2 == 0 {
e.IndexedAt = timePtr(e.ReceivedAt.Add(time.Hour))
}
for a := 1; a <= spec.attachments; a++ {
content := []byte(fmt.Sprintf("%s – erfundener Anhang %d zu Nachricht %s, Zufallsanteil %s", SyntheticMarker, a, e.ID, g.randomHex(6)))
obj := g.storageObject(fmt.Sprintf("attachments/%d/%s.bin", org.archivmailID, g.randomHex(8)), content)
att := MailAttachment{ID: g.id("am_att", 0), Filename: fmt.Sprintf("anlage-%d.txt", a), MimeType: "text/plain", StorageID: obj.ID}
am.Attachments = append(am.Attachments, att)
e.AttachmentIDs = append(e.AttachmentIDs, att.ID)
}
am.Emails = append(am.Emails, e)
}
am.DsgvoRequests = append(am.DsgvoRequests,
DsgvoRequest{ID: g.id("am_dsgvo", 0), TenantID: org.archivmailID, Address: SubjectOpenRequest, RequestedBy: users[0].Username, Status: "open", CreatedAt: g.now.AddDate(0, 0, -14)},
DsgvoRequest{ID: g.id("am_dsgvo", 0), TenantID: org.archivmailID, Address: SubjectCompletedRequest, RequestedBy: users[0].Username, Status: "completed", CreatedAt: g.now.AddDate(-1, 0, 0)},
DsgvoRequest{ID: g.id("am_dsgvo", 0), TenantID: org.archivmailID, Address: SubjectPartialRequest, RequestedBy: users[4].Username, Status: "partial", CreatedAt: g.now.AddDate(0, 0, -3)},
)
for d := 1; d <= 3; d++ {
expected := 10 + d
am.Reports = append(am.Reports, ReconciliationReport{
ID: g.id("am_recon", 0), TenantID: org.archivmailID, Date: g.now.AddDate(0, 0, -d).Truncate(24 * time.Hour),
SourceType: "imap", SourceID: int64(d), Expected: expected, Archived: expected - (d % 2),
})
}
am.Audit = append(am.Audit,
LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, -2, 0), EventType: "login", Username: users[0].Username, IP: "192.0.2.10", Success: true, TenantID: org.archivmailID},
LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, -1, 0), EventType: "search", Username: users[1].Username, IP: "192.0.2.11", Query: "rechnung", Success: true, TenantID: org.archivmailID},
LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, 0, -20), EventType: "mail_export", Username: users[1].Username, IP: "192.0.2.11", Ref: fmt.Sprintf("am-%d-0002", org.archivmailID), Success: true, TenantID: org.archivmailID},
LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, 0, -2), EventType: "login", Username: users[3].Username, IP: "192.0.2.12", Success: false, Detail: SyntheticMarker + " falsches Passwort (erfunden)", TenantID: org.archivmailID},
)
if org.key == "musterhausen" {
for i := range am.Emails {
if am.Emails[i].ID == fmt.Sprintf("am-%d-0006", org.archivmailID) {
am.Emails[i].SharedWith = []int64{103}
}
}
}
return nil
}
func (g *generator) storageObject(path string, content []byte) StorageObject {
obj := StorageObject{ID: g.id("am_storage", 0), Path: path, Content: content}
g.ds.Archivmail.StorageObjects = append(g.ds.Archivmail.StorageObjects, obj)
return obj
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func (g *generator) archivdmsTenant(org orgSpec) error {
dm := &g.ds.Archivdms
tid := org.archivdmsID
dm.Tenants = append(dm.Tenants, LegacyTenant{ID: tid, Slug: org.archivdmsSlug, Name: org.name, Domains: []string{org.mailDomain}})
shared := org.archivmailID != 0
users := make([]ArchivdmsUser, 0, 5)
for n := 1; n <= 5; n++ {
id := g.id("dms_user", 500)
_, hash, err := g.password(fmt.Sprintf("archivdms:%d", id))
if err != nil {
return err
}
email := personEmail(n, org.mailDomain)
username := fmt.Sprintf("testperson%02d.%s", n, org.key)
if shared && n == 2 {
email = fmt.Sprintf("TestPerson%02d@%s", n, org.mailDomain)
}
if !shared || n > 3 {
email = fmt.Sprintf("dms.testperson%02d@%s", n, org.mailDomain)
username = "dms." + username
}
u := ArchivdmsUser{ID: id, TenantID: tid, Username: username, Email: email, PasswordHash: hash, Role: "user", AuthSource: "local"}
if n == 1 {
u.Role = "domain_admin"
}
if n == 3 {
u.AuthSource = "ldap"
u.LDAPUID = strPtr(fmt.Sprintf("uid=testperson%02d,ou=%s,dc=beispiel,dc=example", n, org.key))
u.LDAPSyncedAt = timePtr(g.now.AddDate(0, 0, -1))
}
users = append(users, u)
}
dm.Users = append(dm.Users, users...)
groupSpec := []struct {
name string
members []int
}{
{"Vollzugriff", []int{0}},
{"Bearbeiter", []int{1, 2}},
{"Leser", []int{3, 4}},
}
groups := make([]PermissionGroup, 0, len(groupSpec))
for _, gs := range groupSpec {
pg := PermissionGroup{ID: g.id("dms_group", 0), TenantID: tid, Name: gs.name}
for _, m := range gs.members {
pg.Members = append(pg.Members, users[m].ID)
}
groups = append(groups, pg)
}
dm.Groups = append(dm.Groups, groups...)
docTypes := make([]DocumentType, 0, 3)
for _, name := range []string{"Rechnung", "Vertrag", "Korrespondenz"} {
docTypes = append(docTypes, DocumentType{ID: g.id("dms_doctype", 0), TenantID: tid, Name: name})
}
dm.DocTypes = append(dm.DocTypes, docTypes...)
akten := make([]Akte, 0, 3)
for i, titel := range []string{"Testakte Musterfall A", "Testakte Musterfall B", "Testakte leer"} {
status := "offen"
if i == 1 {
status = "geschlossen"
}
akten = append(akten, Akte{ID: g.id("dms_akte", 0), TenantID: tid, Titel: titel + " (" + org.name + ")", Status: status, CreatedBy: users[0].ID})
}
dm.Akten = append(dm.Akten, akten...)
retain := []*time.Time{
timePtr(g.now.AddDate(8, 0, 0)), timePtr(g.now.AddDate(3, 0, 0)), timePtr(g.now.AddDate(0, -6, 0)), nil,
timePtr(g.now.AddDate(10, 0, 0)), nil, timePtr(g.now.AddDate(-2, 0, 0)), timePtr(g.now.AddDate(1, 0, 0)),
}
docs := make([]LegacyDocument, 0, len(retain))
for i := range retain {
n := i + 1
ext := "txt"
if n%2 == 0 {
ext = "pdf"
}
content := []byte(fmt.Sprintf("%s – Testdokument %d (%s), frei erfunden. Zufallsanteil %s.", SyntheticMarker, n, org.name, g.randomHex(12)))
hash := sha256Hex(content)
created := g.now.AddDate(0, -12+n, 0)
d := LegacyDocument{
ID: g.id("dms_doc", 0), TenantID: tid,
Title: fmt.Sprintf("%s Testdokument %d.%s", SyntheticMarker, n, ext),
StoragePath: fmt.Sprintf("store/%d/%04d/%02d/%s.%s", tid, created.Year(), int(created.Month()), hash, ext),
ContentHash: hash, Content: content, RetainUntil: retain[i],
CreatedBy: users[(n-1)%3].ID, CreatedAt: created,
DocTypeID: int64Ptr(docTypes[(n-1)%3].ID),
}
switch {
case n <= 3:
d.AkteID = int64Ptr(akten[0].ID)
case n <= 5:
d.AkteID = int64Ptr(akten[1].ID)
}
if n == 7 {
d.DeletedAt = timePtr(g.now.AddDate(0, -1, 0))
d.DeletedBy = int64Ptr(users[0].ID)
}
docs = append(docs, d)
}
dm.Documents = append(dm.Documents, docs...)
for i, d := range docs {
n := i + 1
if d.DeletedAt != nil {
continue
}
dm.Visibility = append(dm.Visibility, GroupAccess{DocumentID: d.ID, GroupID: groups[0].ID, Access: "write"})
dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[0].ID, Access: "write"})
if n <= 5 {
dm.Visibility = append(dm.Visibility, GroupAccess{DocumentID: d.ID, GroupID: groups[1].ID, Access: "write"})
dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[1].ID, Access: "write"})
}
if n <= 3 {
dm.Visibility = append(dm.Visibility, GroupAccess{DocumentID: d.ID, GroupID: groups[2].ID, Access: "read"})
dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[2].ID, Access: "read"})
}
if n == 5 {
dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[2].ID, Access: "deny"})
}
}
fAZ := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Aktenzeichen", Type: "text", Required: true}
fBetrag := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Betrag", Type: "monetary"}
fFaellig := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Faellig am", Type: "date"}
fVertraulich := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Vertraulich", Type: "boolean"}
fKategorie := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Kategorie", Type: "enum", EnumOptions: []string{"intern", "extern"}}
dm.FieldDefs = append(dm.FieldDefs, fAZ, fBetrag, fFaellig, fVertraulich, fKategorie)
dm.Templates = append(dm.Templates,
ClassificationTemplate{ID: g.id("dms_tpl", 0), TenantID: tid, Name: "Eingangsrechnung", DocTypeID: int64Ptr(docTypes[0].ID), RetentionYears: intPtr(10),
Fields: []TemplateField{{FieldID: fAZ.ID}, {FieldID: fBetrag.ID}, {FieldID: fFaellig.ID}}},
ClassificationTemplate{ID: g.id("dms_tpl", 0), TenantID: tid, Name: "Vertragsunterlagen", DocTypeID: int64Ptr(docTypes[1].ID), RetentionYears: intPtr(30),
Fields: []TemplateField{{FieldID: fAZ.ID}, {FieldID: fKategorie.ID}, {FieldID: fVertraulich.ID, Default: strPtr("false")}}},
ClassificationTemplate{ID: g.id("dms_tpl", 0), TenantID: tid, Name: "Allgemeiner Schriftverkehr"},
)
dm.FieldValues = append(dm.FieldValues,
FieldValue{DocumentID: docs[0].ID, FieldID: fAZ.ID, Value: fmt.Sprintf("AZ-SYN-%d-0001", tid)},
FieldValue{DocumentID: docs[0].ID, FieldID: fBetrag.ID, Value: "199.99"},
FieldValue{DocumentID: docs[0].ID, FieldID: fFaellig.ID, Value: g.now.AddDate(0, 1, 0).Format("2006-01-02")},
FieldValue{DocumentID: docs[1].ID, FieldID: fAZ.ID, Value: fmt.Sprintf("AZ-SYN-%d-0002", tid)},
FieldValue{DocumentID: docs[1].ID, FieldID: fKategorie.ID, Value: "intern"},
FieldValue{DocumentID: docs[3].ID, FieldID: fVertraulich.ID, Value: "true"},
)
shareHash := func() string { return sha256Hex([]byte("token-" + g.randomHex(16))) }
pwHash := "$2a$10$" + strings.Repeat("S", 53)
dm.Shares = append(dm.Shares,
DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[0].ID, TokenHashHex: shareHash(), CreatedBy: users[1].ID,
CreatedAt: g.now.AddDate(0, 0, -5), ExpiresAt: g.now.AddDate(0, 0, 30), MaxAccesses: intPtr(5), AccessCount: 1},
DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[1].ID, TokenHashHex: shareHash(), CreatedBy: users[1].ID,
CreatedAt: g.now.AddDate(0, 0, -3), ExpiresAt: g.now.AddDate(0, 2, 0), PasswordHash: strPtr(pwHash)},
DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[2].ID, TokenHashHex: shareHash(), CreatedBy: users[2].ID,
CreatedAt: g.now.AddDate(0, -2, 0), ExpiresAt: g.now.AddDate(0, 0, -10)},
DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[3].ID, TokenHashHex: shareHash(), CreatedBy: users[0].ID,
CreatedAt: g.now.AddDate(0, 0, -7), ExpiresAt: g.now.AddDate(0, 0, 20), RevokedAt: timePtr(g.now.AddDate(0, 0, -1))},
DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[6].ID, TokenHashHex: shareHash(), CreatedBy: users[1].ID,
CreatedAt: g.now.AddDate(0, -2, 0), ExpiresAt: g.now.AddDate(0, 0, 15)},
)
dm.DeleteRequests = append(dm.DeleteRequests,
DeleteRequest{ID: g.id("dms_delreq", 0), DocumentID: docs[6].ID, TenantID: tid, RequestedBy: users[1].ID, RequestedAt: g.now.AddDate(0, -1, -2),
ConfirmedBy: int64Ptr(users[0].ID), ConfirmedAt: timePtr(g.now.AddDate(0, -1, 0)), Status: "executed"},
DeleteRequest{ID: g.id("dms_delreq", 0), DocumentID: docs[7].ID, TenantID: tid, RequestedBy: users[1].ID, RequestedAt: g.now.AddDate(0, 0, -4), Status: "pending"},
DeleteRequest{ID: g.id("dms_delreq", 0), DocumentID: docs[5].ID, TenantID: tid, RequestedBy: users[2].ID, RequestedAt: g.now.AddDate(0, 0, -9), Status: "cancelled"},
)
wf := LegacyWorkflow{ID: g.id("dms_wf", 0), TenantID: tid, Name: "Eingangsrechnung pruefen"}
dm.Workflows = append(dm.Workflows, wf)
t0 := g.now.AddDate(0, -3, 0)
dm.WorkflowRuns = append(dm.WorkflowRuns,
WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, DocumentID: int64Ptr(docs[0].ID), Status: "completed", FinalState: "freigegeben",
StartedAt: t0, FinishedAt: timePtr(t0.Add(48 * time.Hour)), Events: []WorkflowEvent{
{From: "", To: "offen", ActorID: users[1].ID, Note: "gestartet", At: t0},
{From: "offen", To: "in_pruefung", ActorID: users[1].ID, Note: "Pruefung begonnen", At: t0.Add(24 * time.Hour)},
{From: "in_pruefung", To: "freigegeben", ActorID: users[0].ID, Note: "freigegeben", At: t0.Add(48 * time.Hour)},
}},
WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, DocumentID: int64Ptr(docs[1].ID), Status: "aborted", FinalState: "abgebrochen",
StartedAt: t0.Add(72 * time.Hour), FinishedAt: timePtr(t0.Add(96 * time.Hour)), Events: []WorkflowEvent{
{From: "", To: "offen", ActorID: users[2].ID, Note: "gestartet", At: t0.Add(72 * time.Hour)},
{From: "offen", To: "abgebrochen", ActorID: users[2].ID, Note: "Dublette", At: t0.Add(96 * time.Hour)},
}},
WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, DocumentID: int64Ptr(docs[3].ID), Status: "running", FinalState: "in_pruefung",
StartedAt: g.now.AddDate(0, 0, -2), Events: []WorkflowEvent{
{From: "", To: "offen", ActorID: users[1].ID, Note: "gestartet", At: g.now.AddDate(0, 0, -2)},
{From: "offen", To: "in_pruefung", ActorID: users[1].ID, Note: "Pruefung begonnen", At: g.now.AddDate(0, 0, -1)},
}},
WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, Status: "completed", FinalState: "erledigt",
StartedAt: t0.Add(120 * time.Hour), FinishedAt: timePtr(t0.Add(121 * time.Hour)), Events: []WorkflowEvent{
{From: "", To: "offen", ActorID: users[3].ID, Note: "gestartet", At: t0.Add(120 * time.Hour)},
{From: "offen", To: "erledigt", ActorID: users[3].ID, Note: "ohne Befund", At: t0.Add(121 * time.Hour)},
}},
)
dm.Audit = append(dm.Audit,
LegacyAuditEntry{ID: g.id("dms_audit", 0), At: docs[0].CreatedAt, EventType: "document_upload", Username: users[0].Username, IP: "198.51.100.20", Ref: fmt.Sprint(docs[0].ID), Success: true, TenantID: tid},
LegacyAuditEntry{ID: g.id("dms_audit", 0), At: g.now.AddDate(0, 0, -30), EventType: "document_view", Username: users[3].Username, IP: "198.51.100.21", Ref: fmt.Sprint(docs[1].ID), Success: true, TenantID: tid},
LegacyAuditEntry{ID: g.id("dms_audit", 0), At: g.now.AddDate(0, -1, 0), EventType: "document_delete_executed", Username: users[0].Username, IP: "198.51.100.20", Ref: fmt.Sprint(docs[6].ID), Success: true, TenantID: tid},
LegacyAuditEntry{ID: g.id("dms_audit", 0), At: g.now.AddDate(0, 0, -1), EventType: "share_created", Username: users[1].Username, IP: "198.51.100.22", Ref: fmt.Sprint(docs[0].ID), Success: true, Detail: SyntheticMarker + " Freigabelink erzeugt", TenantID: tid},
)
return nil
}
@@ -0,0 +1,153 @@
package cutoverorchestrator
import (
"bytes"
"fmt"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func testDataset(t *testing.T) *Dataset {
t.Helper()
ds, err := GenerateDataset(7, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC), 4)
if err != nil {
t.Fatal(err)
}
return ds
}
func TestGenerateDataset_IsDeterministic(t *testing.T) {
a, b := testDataset(t), testDataset(t)
if len(a.Archivdms.Documents) != len(b.Archivdms.Documents) || len(a.Archivmail.Emails) != len(b.Archivmail.Emails) {
t.Fatal("gleicher seed muss gleiche mengen liefern")
}
for i := range a.Archivdms.Documents {
if !bytes.Equal(a.Archivdms.Documents[i].Content, b.Archivdms.Documents[i].Content) {
t.Fatalf("dokument %d unterscheidet sich trotz gleichem seed", i)
}
}
for k, pw := range a.Passwords {
if b.Passwords[k] != pw {
t.Fatalf("passwort %s unterscheidet sich trotz gleichem seed", k)
}
}
}
// Akzeptanzkriterium 3: jede personenbezogen wirkende Angabe der Testkopie
// ist erkennbar synthetisch.
func TestGenerateDataset_ContainsOnlySyntheticPersonalData(t *testing.T) {
ds := testDataset(t)
var rep AnonymizationReport
for _, u := range ds.Archivmail.Users {
rep.checkText("archivmail.users", u.Email+" "+u.Username)
if !strings.Contains(u.Username, "testperson") {
t.Errorf("archivmail-benutzer %q ohne synthetisches praefix", u.Username)
}
}
for _, u := range ds.Archivdms.Users {
rep.checkText("archivdms.users", u.Email+" "+u.Username)
if !strings.Contains(strings.ToLower(u.Email), "testperson") {
t.Errorf("archivdms-benutzer %q ohne synthetisches praefix", u.Email)
}
}
for _, e := range ds.Archivmail.Emails {
rep.checkText("emails", e.From+" "+e.To+" "+e.Subject)
}
for _, r := range ds.Archivmail.DsgvoRequests {
rep.checkText("dsgvo", r.Address)
}
for _, a := range append(ds.Archivmail.Audit, ds.Archivdms.Audit...) {
rep.checkText("audit", a.IP+" "+a.Detail)
}
for _, o := range ds.Archivmail.StorageObjects {
if !bytes.HasPrefix(o.Content, []byte(SyntheticMarker)) {
t.Errorf("mail-objekt %s ohne synthetik-marker", o.Path)
}
}
for _, d := range ds.Archivdms.Documents {
if !bytes.HasPrefix(d.Content, []byte(SyntheticMarker)) {
t.Errorf("dokument %d ohne synthetik-marker", d.ID)
}
}
if len(rep.Violations) > 0 {
t.Fatalf("nicht-synthetische angaben: %v", rep.Violations)
}
if rep.Emails == 0 || rep.IPs == 0 {
t.Fatalf("pruefung hat nichts gesehen: %+v", rep)
}
}
func TestAnonymizationCheck_DetectsRealLookingData(t *testing.T) {
var rep AnonymizationReport
rep.checkText("probe", `{"email":"jemand@realer-anbieter.de","ip":"8.8.8.8"}`)
if len(rep.Violations) != 2 {
t.Fatalf("echte domain und oeffentliche ip muessen auffallen, verstoesse: %v", rep.Violations)
}
}
func TestGenerateDataset_CoversPartialTenantOverlap(t *testing.T) {
ds := testDataset(t)
amSlugs, dmsSlugs := map[string]bool{}, map[string]bool{}
for _, tn := range ds.Archivmail.Tenants {
amSlugs[tn.Slug] = true
}
for _, tn := range ds.Archivdms.Tenants {
dmsSlugs[tn.Slug] = true
}
onlyMail, onlyDMS, both := 0, 0, 0
for s := range amSlugs {
if dmsSlugs[s] {
both++
} else {
onlyMail++
}
}
for s := range dmsSlugs {
if !amSlugs[s] {
onlyDMS++
}
}
if both == 0 || onlyMail == 0 || onlyDMS == 0 || !strings.Contains(ds.ManualDecisionsCSV, "true") {
t.Fatalf("testkopie muss slug-treffer, manuelle zuordnung und einseitige mandanten enthalten: both=%d mail=%d dms=%d", both, onlyMail, onlyDMS)
}
}
func TestTargetSlug_SatisfiesCoreSlugRules(t *testing.T) {
for _, legacy := range []string{"kanzlei-musterhausen", "ingenieurbuero-erfundenhausen", strings.Repeat("sehr-lang-", 10)} {
s := TargetSlug("cut01abcdef", legacy)
if err := tenant.ValidateSlug(s); err != nil {
t.Errorf("ziel-slug %q fuer %q verletzt core-regel: %v", s, legacy, err)
}
}
}
func TestClusterTemplateAndDSN(t *testing.T) {
c, err := NewCluster("postgresql://u:p%40ss@db.intern:5433/postgres?sslmode=disable")
if err != nil {
t.Fatal(err)
}
got := fmt.Sprintf(c.TenantDSNTemplate(), "tenant_x")
if got != "postgresql://u:p%40ss@db.intern:5433/tenant_x?sslmode=disable" {
t.Fatalf("vorlage falsch: %s", got)
}
if c.DSNFor("registry") != "postgresql://u:p%40ss@db.intern:5433/registry?sslmode=disable" {
t.Fatalf("dsn falsch: %s", c.DSNFor("registry"))
}
if _, err := NewCluster("host=db user=u password=p dbname=postgres"); err == nil {
t.Fatal("schluessel/wert-dsn muss abgelehnt werden")
}
}
func TestRedactor(t *testing.T) {
r := NewRedactor("literal-geheimnis-xyz")
in := "a postgres://user:pw@host/db b password=abc c literal-geheimnis-xyz"
out := r.Redact(in)
for _, leak := range []string{":pw@", "abc", "literal-geheimnis-xyz"} {
if strings.Contains(out, leak) {
t.Fatalf("%q nicht redigiert: %s", leak, out)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package cutoverorchestrator
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch"
"gitea.perlbach24.de/scripte/nexarch/internal/usermatch"
)
type Config struct {
RepoRoot string
WorkRoot string
Seed int64
BcryptCost int
}
type Dryrun struct {
cfg Config
cluster *Cluster
RunTag string
RunDir string
Redactor *Redactor
kekSeed string
now time.Time
dataset *Dataset
registryDB string
archivmailDB string
archivdmsDB string
registry *pgxpool.Pool
archivmail *pgxpool.Pool
archivdms *pgxpool.Pool
exportArchivmail *pgxpool.Pool
exportArchivdms *pgxpool.Pool
tenantCands []tenantmatch.Candidate
userCands []usermatch.Candidate
tenants []ContextTenant
tenantPools map[string]*pgxpool.Pool
stages StageRunner
contextPath string
}
func newRunTag() (string, error) {
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "cut01" + hex.EncodeToString(b), nil
}
func NewDryrun(cfg Config, adminDSN string) (*Dryrun, error) {
cluster, err := NewCluster(adminDSN)
if err != nil {
return nil, err
}
tag, err := newRunTag()
if err != nil {
return nil, err
}
seed := make([]byte, 32)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
kekSeed := hex.EncodeToString(seed)
d := &Dryrun{
cfg: cfg,
cluster: cluster,
RunTag: tag,
RunDir: filepath.Join(cfg.WorkRoot, tag),
Redactor: NewRedactor(adminDSN, kekSeed),
kekSeed: kekSeed,
now: time.Now().UTC(),
registryDB: tag + "_registry",
archivmailDB: tag + "_quelle_archivmail",
archivdmsDB: tag + "_quelle_archivdms",
tenantPools: map[string]*pgxpool.Pool{},
}
d.contextPath = filepath.Join(d.RunDir, "kontext.json")
d.stages = StageRunner{
BinDir: filepath.Join(d.RunDir, "bin"), ContextPath: d.contextPath,
AdminDSN: adminDSN, KEKSeed: kekSeed, Redactor: d.Redactor,
}
return d, nil
}
func (d *Dryrun) legacyObjectsDir() string { return filepath.Join(d.RunDir, "altsystem-objekte") }
// TargetSlug: Ziel-Slugs muessen dem Core-Muster ^[a-z][a-z0-9_]{1,48}$
// genuegen und duerfen auf dem geteilten Wegwerf-Cluster nicht mit
// fremden tenant_*-Datenbanken kollidieren – daher Laufpraefix.
func TargetSlug(runTag, legacySlug string) string {
s := runTag + "_" + strings.ReplaceAll(strings.ToLower(legacySlug), "-", "_")
if len(s) > 49 {
s = s[:49]
}
return strings.TrimRight(s, "_")
}
func (d *Dryrun) tenantPool(ctx context.Context, dbName string) (*pgxpool.Pool, error) {
if p, ok := d.tenantPools[dbName]; ok {
return p, nil
}
p, err := d.cluster.Connect(ctx, dbName)
if err != nil {
return nil, err
}
d.tenantPools[dbName] = p
return p, nil
}
func (d *Dryrun) Close() {
for _, p := range d.tenantPools {
p.Close()
}
for _, p := range []*pgxpool.Pool{d.registry, d.archivmail, d.archivdms, d.exportArchivmail, d.exportArchivdms} {
if p != nil {
p.Close()
}
}
}
// Cleanup entfernt alle Datenbanken und Arbeitsdateien des Laufs bis auf
// das Protokollverzeichnis.
func (d *Dryrun) Cleanup(ctx context.Context) ([]string, error) {
d.Close()
d.tenantPools = map[string]*pgxpool.Pool{}
d.registry, d.archivmail, d.archivdms, d.exportArchivmail, d.exportArchivdms = nil, nil, nil, nil, nil
dropped, err := d.cluster.DropRunDatabases(ctx, d.RunTag)
for _, sub := range []string{"altsystem-objekte", "ziel-objekte", "uebergabe", "val02", "bin", "kontext.json"} {
_ = os.RemoveAll(filepath.Join(d.RunDir, sub))
}
return dropped, err
}
func countsOf(kv ...any) map[string]int {
m := map[string]int{}
for i := 0; i+1 < len(kv); i += 2 {
m[fmt.Sprint(kv[i])] = kv[i+1].(int)
}
return m
}
func (d *Dryrun) Plan() []Step {
return []Step{
{ID: "PREP-01", Title: "Arbeitsverzeichnis anlegen, Modulstufen bauen", Run: d.prepWorkdir},
{ID: "PREP-02", Title: "Synthetische Testkopie archivmail/archivdms erzeugen", DependsOn: []string{"PREP-01"}, Run: d.prepLegacyCopy},
{ID: "PREP-03", Title: "Anonymisierungsnachweis der Testkopie", DependsOn: []string{"PREP-02"}, Run: d.prepAnonymization},
{ID: "PREP-04", Title: "Core-Registry anlegen (Core-Migrationen)", DependsOn: []string{"PREP-01"}, Run: d.prepRegistry},
{ID: "EXP-01", Title: "Export-Sichten der Altsysteme bereitstellen", DependsOn: []string{"PREP-03"}, Run: d.exportViews},
{ID: "MAP-01", Title: "Tenant-Export und Slug/Domain-Abgleich", DependsOn: []string{"EXP-01"}, Run: d.map01},
{ID: "MAP-02", Title: "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", DependsOn: []string{"MAP-01", "PREP-04"}, Run: d.map02},
{ID: "MAP-03", Title: "User-Export und E-Mail-Abgleich je freigegebenem Tenant", DependsOn: []string{"MAP-02"}, Run: d.map03},
{ID: "MAP-04*", Title: "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", DependsOn: []string{"MAP-03"}, Run: d.map04},
{ID: "COR-01", Title: "Tenant-Migration (Provisionierung der Mandanten-DBs)", DependsOn: []string{"MAP-02"}, Run: d.cor01},
{ID: "SCHEMA-01", Title: "Core-Tenant-Schema ausrollen (TEN-07)", DependsOn: []string{"COR-01"}, Run: d.schemaCore},
{ID: "SCHEMA-02", Title: "Modul-Schemata anlegen (DMS, Archive, Workflow)", DependsOn: []string{"SCHEMA-01"}, Run: d.schemaModules},
{ID: "COR-02", Title: "Benutzer-Migration inkl. bcrypt-Hash 1:1", DependsOn: []string{"MAP-04*", "SCHEMA-01"}, Run: d.cor02},
{ID: "COR-04", Title: "LDAP-Feld-Migration pruefen", DependsOn: []string{"COR-02"}, Run: d.cor04},
{ID: "COR-03", Title: "Rollen- und Rechte-Migration", DependsOn: []string{"COR-02"}, Run: d.cor03},
{ID: "COR-05", Title: "Audit-Log-Zusammenfuehrung", DependsOn: []string{"MAP-04*", "COR-01"}, Run: d.cor05},
{ID: "DOK-01", Title: "Dokument- und Revisions-Migration", DependsOn: []string{"COR-02", "SCHEMA-02"}, Run: d.stage("dms", "dok01")},
{ID: "DOK-02", Title: "Akten-/Ordnerstruktur-Migration", DependsOn: []string{"DOK-01"}, Run: d.stage("dms", "dok02")},
{ID: "DOK-04", Title: "Custom-Fields- und Klassifikationsvorlagen-Migration", DependsOn: []string{"DOK-01"}, Run: d.stage("dms", "dok04")},
{ID: "DOK-03", Title: "ACL-Daten-Migration nach Core-Rechtemodell", DependsOn: []string{"DOK-01", "COR-03"}, Run: d.dok03},
{ID: "DOK-05", Title: "Freigabelinks-Migration", DependsOn: []string{"DOK-01"}, Run: d.stage("dms", "dok05")},
{ID: "MAI-02", Title: "Nachrichten- und Anhang-Migration", DependsOn: []string{"COR-01"}, Run: d.stage("mail", "mai02")},
{ID: "TAR-01", Title: "Retention-Fristen-Migration ohne Reset", DependsOn: []string{"DOK-01", "MAI-02", "SCHEMA-02"}, Run: d.stage("archive", "tar01")},
{ID: "MAI-03", Title: "DSGVO-Loeschanfragen-Migration", DependsOn: []string{"TAR-01"}, Run: d.stage("archive", "mai03")},
{ID: "MAI-04", Title: "Reconciliation-Reports-Archivierung", DependsOn: []string{"SCHEMA-02"}, Run: d.stage("archive", "mai04")},
{ID: "TAR-02", Title: "Vier-Augen-Loeschworkflow-Migration", DependsOn: []string{"TAR-01", "COR-02"}, Run: d.stage("archive", "tar02")},
{ID: "WFL-01", Title: "Workflow-Historie-Migration", DependsOn: []string{"COR-02", "SCHEMA-02"}, Run: d.stage("workflow", "wfl01")},
{ID: "VAL-01", Title: "Zeilenzahl-Abgleich je Kerntabelle", DependsOn: []string{"COR-02", "DOK-01", "MAI-02"}, Run: d.val01},
{ID: "VAL-02", Title: "Hash-Vergleich migrierter Binaerdateien", DependsOn: []string{"DOK-01", "MAI-02"}, Run: d.val02},
{ID: "VAL-05", Title: "Login-Test mit migrierten Zugangsdaten", DependsOn: []string{"COR-02"}, Run: d.val05},
{ID: "CHK-01", Title: "Modulgrenzen-uebergreifende Kettenpruefung", DependsOn: []string{"DOK-03", "DOK-05", "TAR-02", "MAI-03", "MAI-04", "WFL-01"}, Run: d.chk01},
}
}
func (d *Dryrun) stage(module, stage string) func(context.Context) (Outcome, error) {
return func(ctx context.Context) (Outcome, error) {
return d.stages.Run(ctx, module, stage)
}
}
+56
View File
@@ -0,0 +1,56 @@
package cutoverorchestrator
import (
"context"
"fmt"
)
const exportSchema = "cutover_export"
// Export-Sichten normalisieren die Altsystem-Tabellen auf die Form, die
// tenantmatch.ExportTenants (id, slug, name, domain) und
// usermatch.ExportUsers (id, tenant_id, email, name, password_hash) lesen.
// Lauf 1.1 zeigte: archivmail fuehrt Domains in tenant_domains (1:n), beide
// Altsysteme kennen nur users.username, archivmail-Superadmins haben keinen
// Mandanten. Teil des Export-Dumps, nicht der MAP-Pakete.
const archivmailExportViewsSQL = `
CREATE SCHEMA cutover_export;
CREATE VIEW cutover_export.tenants AS
SELECT t.id, t.slug, t.name,
COALESCE((SELECT d.domain FROM public.tenant_domains d WHERE d.tenant_id = t.id ORDER BY d.id LIMIT 1), '') AS domain
FROM public.tenants t;
CREATE VIEW cutover_export.users AS
SELECT u.id, u.tenant_id, u.email, u.username AS name, u.password_hash
FROM public.users u WHERE u.tenant_id IS NOT NULL;
`
const archivdmsExportViewsSQL = `
CREATE SCHEMA cutover_export;
CREATE VIEW cutover_export.tenants AS
SELECT t.id, t.slug, t.name, COALESCE(t.domain, '') AS domain FROM public.tenants t;
CREATE VIEW cutover_export.users AS
SELECT u.id, u.tenant_id, u.email, u.username AS name, u.password_hash FROM public.users u;
`
func (d *Dryrun) exportViews(ctx context.Context) (Outcome, error) {
if _, err := d.archivmail.Exec(ctx, archivmailExportViewsSQL); err != nil {
return Outcome{}, fmt.Errorf("archivmail-export-sichten: %w", err)
}
if _, err := d.archivdms.Exec(ctx, archivdmsExportViewsSQL); err != nil {
return Outcome{}, fmt.Errorf("archivdms-export-sichten: %w", err)
}
var err error
if d.exportArchivmail, err = d.cluster.ConnectSchema(ctx, d.archivmailDB, exportSchema); err != nil {
return Outcome{}, err
}
if d.exportArchivdms, err = d.cluster.ConnectSchema(ctx, d.archivdmsDB, exportSchema); err != nil {
return Outcome{}, err
}
var platformAccounts int
if err := d.archivmail.QueryRow(ctx, `SELECT count(*) FROM users WHERE tenant_id IS NULL`).Scan(&platformAccounts); err != nil {
return Outcome{}, err
}
out := Outcome{Counts: countsOf("export_sichten", 4, "plattformkonten_ohne_mandant", platformAccounts)}
out.Notes = []string{"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"}
return out, nil
}
@@ -0,0 +1,302 @@
package cutoverorchestrator
// Strukturell an datenmodell-altsysteme.md angelehnte Teilmenge der
// Altsystem-Tabellen, soweit sie fuer die Migrationsschritte gelesen
// werden. Spaltennamen folgen bewusst den Altsystemen (z.B. archivmail
// users.username statt name, tenants ohne domain-Spalte), damit
// Schema-Annahmen der Migrationspakete im Trockenlauf auffallen.
const archivmailSchemaSQL = `
CREATE TABLE tenants (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retention_days INT NOT NULL DEFAULT 0
);
CREATE TABLE tenant_domains (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
source VARCHAR(20) NOT NULL DEFAULT 'local',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ,
tenant_id BIGINT
);
CREATE TABLE storage_objects (
id BIGSERIAL PRIMARY KEY,
storage_type TEXT NOT NULL DEFAULT 'filesystem',
path TEXT NOT NULL,
compression TEXT,
size_original BIGINT,
size_compressed BIGINT,
checksum CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE emails (
id TEXT PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL,
mail_from TEXT,
mail_to TEXT,
subject TEXT,
size_bytes BIGINT,
has_attach BOOLEAN NOT NULL DEFAULT false,
indexed_at TIMESTAMPTZ,
tenant_id BIGINT,
message_id TEXT,
retain_until TIMESTAMPTZ,
retain_until_source TEXT,
storage_id BIGINT REFERENCES storage_objects(id)
);
CREATE TABLE attachments (
id BIGSERIAL PRIMARY KEY,
filename TEXT,
mime_type TEXT,
size_bytes BIGINT,
hash CHAR(64) UNIQUE,
storage_id BIGINT REFERENCES storage_objects(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE email_attachments (
email_id TEXT NOT NULL REFERENCES emails(id),
attachment_id BIGINT NOT NULL REFERENCES attachments(id),
PRIMARY KEY (email_id, attachment_id)
);
CREATE TABLE email_refs (
id BIGSERIAL PRIMARY KEY,
email_id TEXT NOT NULL REFERENCES emails(id),
tenant_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (email_id, tenant_id)
);
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL,
username TEXT,
ip_address TEXT,
query TEXT,
mail_id TEXT,
success BOOLEAN,
detail TEXT,
tenant_id BIGINT
);
CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log ist append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_log_no_mutation BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
CREATE TABLE reconciliation_reports (
id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
tenant_id BIGINT NOT NULL,
source_type TEXT,
source_id BIGINT,
expected_count INT,
archived_count INT,
delta INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE dsgvo_requests (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
requested_address TEXT NOT NULL,
requested_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL CHECK (status IN ('open', 'partial', 'completed', 'failed')),
result_summary JSONB
);
`
const archivdmsSchemaSQL = `
CREATE TABLE tenants (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
domain VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX tenants_domain_uniq ON tenants (domain) WHERE domain IS NOT NULL;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ,
tenant_id BIGINT NOT NULL,
auth_source VARCHAR(20) NOT NULL DEFAULT 'local',
ldap_uid TEXT,
ldap_synced_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX users_tenant_ldap_uid ON users (tenant_id, ldap_uid) WHERE ldap_uid IS NOT NULL;
CREATE TABLE permission_groups (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE permission_group_members (
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE TABLE document_types (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE akten (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
titel TEXT NOT NULL,
beschreibung TEXT,
status TEXT NOT NULL DEFAULT 'offen' CHECK (status IN ('offen', 'geschlossen')),
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ
);
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
title TEXT NOT NULL,
storage_path TEXT NOT NULL,
content_hash TEXT NOT NULL,
retain_until DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by BIGINT,
doc_type_id BIGINT REFERENCES document_types(id),
akte_id BIGINT REFERENCES akten(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
deleted_by BIGINT,
UNIQUE (tenant_id, content_hash)
);
CREATE TABLE document_grants (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
access TEXT NOT NULL CHECK (access IN ('read', 'write', 'deny')),
UNIQUE (document_id, group_id, access)
);
CREATE TABLE document_visibility (
document_id BIGINT NOT NULL,
group_id BIGINT NOT NULL,
access TEXT NOT NULL CHECK (access IN ('read', 'write')),
PRIMARY KEY (document_id, group_id)
);
CREATE TABLE custom_field_defs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
field_type TEXT NOT NULL CHECK (field_type IN ('text', 'number', 'date', 'boolean', 'enum', 'monetary')),
required BOOLEAN NOT NULL DEFAULT false,
enum_options JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE TABLE document_field_values (
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
field_def_id BIGINT NOT NULL REFERENCES custom_field_defs(id),
value TEXT,
PRIMARY KEY (document_id, field_def_id)
);
CREATE TABLE classification_templates (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
doc_type_id BIGINT REFERENCES document_types(id),
retention_years INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE TABLE classification_template_field_defaults (
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
field_def_id BIGINT NOT NULL REFERENCES custom_field_defs(id),
default_value TEXT,
PRIMARY KEY (template_id, field_def_id)
);
CREATE TABLE document_shares (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
max_accesses INT,
access_count INT NOT NULL DEFAULT 0,
password_hash TEXT,
revoked_at TIMESTAMPTZ,
revoked_by BIGINT
);
CREATE TABLE document_delete_requests (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
requested_by BIGINT NOT NULL,
requested_at TIMESTAMPTZ NOT NULL,
confirmed_by BIGINT,
confirmed_at TIMESTAMPTZ,
status TEXT NOT NULL CHECK (status IN ('pending', 'confirmed', 'executed', 'cancelled', 'blocked_retention')),
UNIQUE (document_id, status)
);
CREATE TABLE workflows (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE workflow_runs (
id BIGSERIAL PRIMARY KEY,
workflow_id BIGINT REFERENCES workflows(id),
tenant_id BIGINT NOT NULL,
document_id BIGINT,
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'aborted')),
final_state TEXT,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ
);
CREATE TABLE workflow_run_events (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
from_state TEXT NOT NULL,
to_state TEXT NOT NULL,
actor_user_id BIGINT NOT NULL,
note TEXT NOT NULL DEFAULT '',
occurred_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL,
username TEXT,
ip_address TEXT,
document_id VARCHAR(64),
success BOOLEAN,
detail TEXT,
tenant_id BIGINT
);
CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log ist append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_log_no_mutation BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
`
+51
View File
@@ -0,0 +1,51 @@
// Package cutoverorchestrator implementiert CUT-01: den Trockenlauf aller
// Migrationsschritte in technisch korrekter Reihenfolge gegen eine
// vollstaendig synthetische Testkopie der Altsysteme archivmail/archivdms.
// Die Migrationslogik selbst bleibt in den jeweiligen Paketen; hier liegen
// nur Reihenfolge, Quell-Adapter, Testkopie und Protokoll.
package cutoverorchestrator
import (
"context"
"errors"
"fmt"
)
type Outcome struct {
Counts map[string]int
Notes []string
}
type Step struct {
ID string
Title string
DependsOn []string
Run func(ctx context.Context) (Outcome, error)
}
var ErrInvalidPlan = errors.New("cutoverorchestrator: ungueltiger ablaufplan")
// ValidatePlan erzwingt, dass jede Abhaengigkeit eines Schritts vorher im
// Plan steht; eine Umsortierung, die z.B. DOK-03 vor DOK-01 legt, scheitert
// damit vor dem ersten Datenbankzugriff.
func ValidatePlan(steps []Step) error {
seen := make(map[string]bool, len(steps))
for i, s := range steps {
if s.ID == "" {
return fmt.Errorf("%w: schritt an position %d ohne id", ErrInvalidPlan, i+1)
}
if s.Run == nil {
return fmt.Errorf("%w: schritt %s ohne ausfuehrung", ErrInvalidPlan, s.ID)
}
if seen[s.ID] {
return fmt.Errorf("%w: schritt %s doppelt im plan", ErrInvalidPlan, s.ID)
}
for _, dep := range s.DependsOn {
if !seen[dep] {
return fmt.Errorf("%w: %s haengt von %s ab, das nicht vorher im plan steht", ErrInvalidPlan, s.ID, dep)
}
}
seen[s.ID] = true
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package cutoverorchestrator
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
)
func formatCounts(counts map[string]int) string {
if len(counts) == 0 {
return ""
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return "[" + strings.Join(parts, " ") + "]"
}
func WriteJSON(path string, rep Report) error {
data, err := json.MarshalIndent(rep, "", " ")
if err != nil {
return fmt.Errorf("protokoll serialisieren: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("protokoll schreiben: %w", err)
}
return nil
}
func RenderText(rep Report) string {
var b strings.Builder
fmt.Fprintf(&b, "CUT-01 Trockenlauf-Protokoll\n")
fmt.Fprintf(&b, "Lauf: %s (%s)\n", rep.RunTag, rep.Label)
fmt.Fprintf(&b, "Beginn: %s Ende: %s Dauer: %s\n",
rep.StartedAt.Format("2006-01-02 15:04:05 MST"), rep.FinishedAt.Format("2006-01-02 15:04:05 MST"),
rep.FinishedAt.Sub(rep.StartedAt).Round(1e6))
if rep.Success {
fmt.Fprintf(&b, "Ergebnis: ERFOLGREICH – alle %d Schritte ohne Abbruch durchgelaufen\n", len(rep.Steps))
} else {
fmt.Fprintf(&b, "Ergebnis: ABGEBROCHEN bei %s\n", rep.AbortedAt)
}
if rep.PlanError != "" {
fmt.Fprintf(&b, "Planfehler: %s\n", rep.PlanError)
}
b.WriteString("\n")
for i, s := range rep.Steps {
fmt.Fprintf(&b, "%2d. [%s] %s – %s", i+1, s.ID, s.Title, s.Status)
if s.Status != StatusNotRun {
fmt.Fprintf(&b, " (%d ms)", s.DurationMS)
}
b.WriteString("\n")
if c := formatCounts(s.Counts); c != "" {
fmt.Fprintf(&b, " Zaehler: %s\n", c)
}
for _, n := range s.Notes {
fmt.Fprintf(&b, " Hinweis: %s\n", n)
}
if s.Error != "" {
fmt.Fprintf(&b, " FEHLER: %s\n", s.Error)
}
}
return b.String()
}
+42
View File
@@ -0,0 +1,42 @@
package cutoverorchestrator
import (
"regexp"
"strings"
)
var (
urlUserinfo = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.-]*://)[^\s/@]+@`)
kvPassword = regexp.MustCompile(`(?i)\b(password|passwd|pwd)(\s*=\s*)('[^']*'|"[^"]*"|[^\s&]+)`)
)
const redacted = "<redigiert>"
type Redactor struct {
literals []string
}
func NewRedactor(literals ...string) *Redactor {
r := &Redactor{}
for _, l := range literals {
if len(l) >= 6 {
r.literals = append(r.literals, l)
}
}
return r
}
// Redact entfernt Zugangsdaten aus Texten, die in Protokolle oder auf die
// Konsole gelangen. Literale zuerst, weil ein bekannter Wert (z.B. der
// komplette Admin-DSN) auch dann ersetzt werden muss, wenn er in einer
// Form auftaucht, die die Muster nicht erkennen.
func (r *Redactor) Redact(s string) string {
if r != nil {
for _, l := range r.literals {
s = strings.ReplaceAll(s, l, redacted)
}
}
s = urlUserinfo.ReplaceAllString(s, "${1}"+redacted+"@")
s = kvPassword.ReplaceAllString(s, "${1}${2}"+redacted)
return s
}
+125
View File
@@ -0,0 +1,125 @@
package cutoverorchestrator
import (
"context"
"fmt"
"io"
"time"
)
type Status string
const (
StatusSucceeded Status = "erfolgreich"
StatusFailed Status = "fehlgeschlagen"
StatusNotRun Status = "nicht ausgefuehrt"
)
type StepRecord struct {
ID string `json:"id"`
Title string `json:"titel"`
DependsOn []string `json:"haengt_ab_von,omitempty"`
Status Status `json:"status"`
StartedAt *time.Time `json:"gestartet,omitempty"`
DurationMS int64 `json:"dauer_ms"`
Counts map[string]int `json:"zaehler,omitempty"`
Notes []string `json:"hinweise,omitempty"`
Error string `json:"fehler,omitempty"`
}
type Report struct {
RunTag string `json:"lauf_kennung"`
Label string `json:"bezeichnung"`
StartedAt time.Time `json:"gestartet"`
FinishedAt time.Time `json:"beendet"`
Success bool `json:"erfolgreich"`
AbortedAt string `json:"abbruch_bei,omitempty"`
PlanError string `json:"planfehler,omitempty"`
Steps []StepRecord `json:"schritte"`
}
type Runner struct {
Now func() time.Time
Log io.Writer
Redactor *Redactor
}
func (r Runner) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now()
}
func (r Runner) logf(format string, args ...any) {
if r.Log == nil {
return
}
_, _ = fmt.Fprintf(r.Log, "%s\n", r.Redactor.Redact(fmt.Sprintf(format, args...)))
}
// Run fuehrt die Schritte strikt in Planreihenfolge aus und bricht beim
// ersten Fehler ab: alle Folgeschritte bauen auf den Daten der Vorgaenger
// auf, ein Weiterlaufen wuerde Folgefehler statt der Ursache protokollieren.
func (r Runner) Run(ctx context.Context, runTag, label string, steps []Step) Report {
rep := Report{RunTag: runTag, Label: label, StartedAt: r.now()}
if err := ValidatePlan(steps); err != nil {
rep.PlanError = r.Redactor.Redact(err.Error())
rep.AbortedAt = "ablaufplan"
for _, s := range steps {
rep.Steps = append(rep.Steps, StepRecord{ID: s.ID, Title: s.Title, DependsOn: s.DependsOn, Status: StatusNotRun})
}
rep.FinishedAt = r.now()
r.logf("ABBRUCH vor dem ersten Schritt: %s", rep.PlanError)
return rep
}
for _, s := range steps {
rec := StepRecord{ID: s.ID, Title: s.Title, DependsOn: s.DependsOn, Status: StatusNotRun}
if rep.AbortedAt != "" {
rec.Notes = []string{"nicht ausgefuehrt: Abbruch nach Fehler in " + rep.AbortedAt}
rep.Steps = append(rep.Steps, rec)
continue
}
started := r.now()
rec.StartedAt = &started
r.logf("[%s] %s ...", s.ID, s.Title)
var out Outcome
err := ctx.Err()
if err == nil {
out, err = runGuarded(ctx, s)
}
rec.DurationMS = r.now().Sub(started).Milliseconds()
rec.Counts = out.Counts
for _, n := range out.Notes {
rec.Notes = append(rec.Notes, r.Redactor.Redact(n))
}
if err != nil {
rec.Status = StatusFailed
rec.Error = r.Redactor.Redact(err.Error())
rep.AbortedAt = s.ID
r.logf("[%s] FEHLGESCHLAGEN nach %d ms: %s", s.ID, rec.DurationMS, rec.Error)
} else {
rec.Status = StatusSucceeded
r.logf("[%s] erfolgreich (%d ms) %s", s.ID, rec.DurationMS, formatCounts(rec.Counts))
}
rep.Steps = append(rep.Steps, rec)
}
rep.FinishedAt = r.now()
rep.Success = rep.AbortedAt == ""
return rep
}
func runGuarded(ctx context.Context, s Step) (out Outcome, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("unerwarteter programmabbruch (panic) in %s: %v", s.ID, p)
}
}()
return s.Run(ctx)
}
+169
View File
@@ -0,0 +1,169 @@
package cutoverorchestrator
import (
"bytes"
"context"
"errors"
"strings"
"testing"
)
func recordingStep(id string, order *[]string, deps ...string) Step {
return Step{ID: id, Title: "Schritt " + id, DependsOn: deps, Run: func(context.Context) (Outcome, error) {
*order = append(*order, id)
return Outcome{Counts: map[string]int{"zeilen": len(*order)}}, nil
}}
}
func TestRunner_ExecutesStepsInPlanOrder(t *testing.T) {
var order []string
steps := []Step{
recordingStep("A", &order),
recordingStep("B", &order, "A"),
recordingStep("C", &order, "A", "B"),
}
rep := Runner{}.Run(context.Background(), "lauf", "test", steps)
if !rep.Success || rep.AbortedAt != "" {
t.Fatalf("lauf sollte erfolgreich sein: %+v", rep)
}
if strings.Join(order, ",") != "A,B,C" {
t.Fatalf("reihenfolge = %v, erwartet A,B,C", order)
}
for i, s := range rep.Steps {
if s.Status != StatusSucceeded || s.Counts["zeilen"] != i+1 {
t.Fatalf("schritt %s: status=%s zaehler=%v", s.ID, s.Status, s.Counts)
}
}
}
func TestRunner_AbortsAfterFailingStepAndRecordsError(t *testing.T) {
var order []string
steps := []Step{
recordingStep("A", &order),
{ID: "B", Title: "faellt aus", DependsOn: []string{"A"}, Run: func(context.Context) (Outcome, error) {
order = append(order, "B")
return Outcome{Notes: []string{"teilweise gelaufen"}}, errors.New("tenant x: zeile 3 kaputt")
}},
recordingStep("C", &order, "B"),
recordingStep("D", &order),
}
var log bytes.Buffer
rep := Runner{Log: &log}.Run(context.Background(), "lauf", "test", steps)
if rep.Success || rep.AbortedAt != "B" {
t.Fatalf("erwarte abbruch bei B, habe success=%v abortedAt=%q", rep.Success, rep.AbortedAt)
}
if strings.Join(order, ",") != "A,B" {
t.Fatalf("nach dem fehler darf kein schritt mehr laufen, ausgefuehrt: %v", order)
}
b := rep.Steps[1]
if b.Status != StatusFailed || b.Error != "tenant x: zeile 3 kaputt" || len(b.Notes) != 1 {
t.Fatalf("fehlerprotokoll von B unvollstaendig: %+v", b)
}
for _, s := range rep.Steps[2:] {
if s.Status != StatusNotRun || len(s.Notes) == 0 || !strings.Contains(s.Notes[0], "Abbruch nach Fehler in B") {
t.Fatalf("schritt %s muss als nicht ausgefuehrt mit abbruchgrund protokolliert sein: %+v", s.ID, s)
}
}
if !strings.Contains(log.String(), "[B] FEHLGESCHLAGEN") {
t.Fatalf("konsolenprotokoll ohne fehlerzeile: %s", log.String())
}
text := RenderText(rep)
if !strings.Contains(text, "ABGEBROCHEN bei B") || !strings.Contains(text, "FEHLER: tenant x: zeile 3 kaputt") {
t.Fatalf("textprotokoll unvollstaendig:\n%s", text)
}
}
func TestRunner_PanicIsRecordedAsFailure(t *testing.T) {
var order []string
steps := []Step{
{ID: "A", Title: "panik", Run: func(context.Context) (Outcome, error) { panic("unerwartet") }},
recordingStep("B", &order, "A"),
}
rep := Runner{}.Run(context.Background(), "lauf", "test", steps)
if rep.AbortedAt != "A" || rep.Steps[0].Status != StatusFailed || !strings.Contains(rep.Steps[0].Error, "panic") {
t.Fatalf("panic muss als fehler protokolliert werden: %+v", rep.Steps[0])
}
if len(order) != 0 {
t.Fatalf("nach panic darf nichts mehr laufen: %v", order)
}
}
func TestRunner_InvalidOrderRunsNothing(t *testing.T) {
var order []string
steps := []Step{
recordingStep("DOK-03", &order, "DOK-01"),
recordingStep("DOK-01", &order),
}
rep := Runner{}.Run(context.Background(), "lauf", "test", steps)
if rep.Success || rep.PlanError == "" || len(order) != 0 {
t.Fatalf("ein plan mit verletzter abhaengigkeit darf keinen schritt ausfuehren: %+v order=%v", rep, order)
}
for _, s := range rep.Steps {
if s.Status != StatusNotRun {
t.Fatalf("schritt %s status %s", s.ID, s.Status)
}
}
}
func TestRunner_CancelledContextStopsRun(t *testing.T) {
var order []string
ctx, cancel := context.WithCancel(context.Background())
steps := []Step{
{ID: "A", Title: "bricht ab", Run: func(context.Context) (Outcome, error) {
cancel()
return Outcome{}, nil
}},
recordingStep("B", &order, "A"),
}
rep := Runner{}.Run(ctx, "lauf", "test", steps)
if rep.AbortedAt != "B" || len(order) != 0 {
t.Fatalf("abgebrochener kontext muss den naechsten schritt verhindern: %+v", rep)
}
}
func TestRunner_RedactsSecretsInErrorsAndLog(t *testing.T) {
secretDSN := "postgresql://cutover:sehr-geheim-123@db.intern:5433/postgres?sslmode=disable"
steps := []Step{{ID: "A", Title: "verbindung", Run: func(context.Context) (Outcome, error) {
return Outcome{Notes: []string{"dsn " + secretDSN}}, errors.New("connect " + secretDSN + " failed; host=db password=nochmal-geheim")
}}}
var log bytes.Buffer
rep := Runner{Log: &log, Redactor: NewRedactor(secretDSN)}.Run(context.Background(), "lauf", "test", steps)
all := rep.Steps[0].Error + strings.Join(rep.Steps[0].Notes, " ") + log.String() + RenderText(rep)
for _, leak := range []string{"sehr-geheim-123", "nochmal-geheim", secretDSN} {
if strings.Contains(all, leak) {
t.Fatalf("geheimnis %q im protokoll: %s", leak, all)
}
}
}
func TestDryrunPlan_RespectsMigrationDependencies(t *testing.T) {
d, err := NewDryrun(Config{RepoRoot: ".", WorkRoot: t.TempDir(), Seed: 1, BcryptCost: 4}, "postgresql://u:p@localhost:5433/postgres?sslmode=disable")
if err != nil {
t.Fatal(err)
}
plan := d.Plan()
if err := ValidatePlan(plan); err != nil {
t.Fatalf("ablaufplan ungueltig: %v", err)
}
pos := map[string]int{}
for i, s := range plan {
pos[s.ID] = i
}
before := [][2]string{
{"MAP-01", "MAP-02"}, {"MAP-02", "COR-01"}, {"MAP-02", "MAP-03"}, {"COR-01", "COR-02"},
{"COR-02", "COR-03"}, {"COR-02", "DOK-01"}, {"DOK-01", "DOK-02"}, {"DOK-01", "DOK-03"},
{"DOK-01", "DOK-04"}, {"DOK-01", "DOK-05"}, {"COR-02", "WFL-01"}, {"DOK-01", "TAR-01"},
{"MAI-02", "TAR-01"}, {"TAR-01", "MAI-03"}, {"TAR-01", "TAR-02"}, {"PREP-03", "EXP-01"}, {"EXP-01", "MAP-01"}, {"EXP-01", "MAP-03"},
}
for _, b := range before {
if pos[b[0]] >= pos[b[1]] {
t.Errorf("%s muss vor %s laufen", b[0], b[1])
}
}
for _, id := range []string{"COR-03", "COR-04", "DOK-02", "DOK-03", "DOK-04", "DOK-05", "MAI-03", "MAI-04", "MAP-03", "TAR-01", "TAR-02", "WFL-01", "VAL-01", "VAL-02", "VAL-05"} {
if _, ok := pos[id]; !ok {
t.Errorf("migrationspaket %s fehlt im ablaufplan", id)
}
}
}
+222
View File
@@ -0,0 +1,222 @@
package cutoverorchestrator
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type seedCounts map[string]int
func execBatch(ctx context.Context, pool *pgxpool.Pool, b *pgx.Batch) error {
if b.Len() == 0 {
return nil
}
return pool.SendBatch(ctx, b).Close()
}
func SeedArchivmail(ctx context.Context, pool *pgxpool.Pool, d ArchivmailData) (seedCounts, error) {
if _, err := pool.Exec(ctx, archivmailSchemaSQL); err != nil {
return nil, fmt.Errorf("archivmail-schema anlegen: %w", err)
}
b := &pgx.Batch{}
for _, t := range d.Tenants {
b.Queue(`INSERT INTO tenants (id, name, slug) VALUES ($1, $2, $3)`, t.ID, t.Name, t.Slug)
for _, dom := range t.Domains {
b.Queue(`INSERT INTO tenant_domains (tenant_id, domain) VALUES ($1, $2)`, t.ID, dom)
}
}
for _, u := range d.Users {
b.Queue(`INSERT INTO users (id, username, email, password_hash, role, source, tenant_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.Role, u.Source, u.TenantID)
}
for _, o := range d.StorageObjects {
b.Queue(`INSERT INTO storage_objects (id, path, size_original, checksum) VALUES ($1, $2, $3, $4)`,
o.ID, o.Path, len(o.Content), sha256Hex(o.Content))
}
contentByStorage := map[int64][]byte{}
for _, o := range d.StorageObjects {
contentByStorage[o.ID] = o.Content
}
for _, a := range d.Attachments {
content := contentByStorage[a.StorageID]
b.Queue(`INSERT INTO attachments (id, filename, mime_type, size_bytes, hash, storage_id) VALUES ($1, $2, $3, $4, $5, $6)`,
a.ID, a.Filename, a.MimeType, len(content), sha256Hex(content), a.StorageID)
}
for _, e := range d.Emails {
b.Queue(`INSERT INTO emails (id, received_at, mail_from, mail_to, subject, size_bytes, has_attach, indexed_at, tenant_id, message_id, retain_until, retain_until_source, storage_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
e.ID, e.ReceivedAt, e.From, e.To, e.Subject, len(contentByStorage[e.StorageID]), len(e.AttachmentIDs) > 0, e.IndexedAt,
e.TenantID, "<"+e.ID+"@synthetisch.example>", e.RetainUntil, retainSource(e.RetainUntil != nil), e.StorageID)
for _, aid := range e.AttachmentIDs {
b.Queue(`INSERT INTO email_attachments (email_id, attachment_id) VALUES ($1, $2)`, e.ID, aid)
}
for _, tid := range e.SharedWith {
b.Queue(`INSERT INTO email_refs (email_id, tenant_id) VALUES ($1, $2)`, e.ID, tid)
}
}
for _, r := range d.DsgvoRequests {
b.Queue(`INSERT INTO dsgvo_requests (id, tenant_id, requested_address, requested_by, created_at, status) VALUES ($1, $2, $3, $4, $5, $6)`,
r.ID, r.TenantID, r.Address, r.RequestedBy, r.CreatedAt, r.Status)
}
for _, r := range d.Reports {
b.Queue(`INSERT INTO reconciliation_reports (id, date, tenant_id, source_type, source_id, expected_count, archived_count, delta) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
r.ID, r.Date, r.TenantID, r.SourceType, r.SourceID, r.Expected, r.Archived, r.Expected-r.Archived)
}
for _, a := range d.Audit {
b.Queue(`INSERT INTO audit_log (id, timestamp, event_type, username, ip_address, query, mail_id, success, detail, tenant_id)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''), $8, NULLIF($9, ''), $10)`,
a.ID, a.At, a.EventType, a.Username, a.IP, a.Query, a.Ref, a.Success, a.Detail, a.TenantID)
}
if err := execBatch(ctx, pool, b); err != nil {
return nil, fmt.Errorf("archivmail-testkopie befuellen: %w", err)
}
return seedCounts{
"archivmail_tenants": len(d.Tenants), "archivmail_users": len(d.Users), "archivmail_emails": len(d.Emails),
"archivmail_attachments": len(d.Attachments), "archivmail_dsgvo": len(d.DsgvoRequests),
"archivmail_reports": len(d.Reports), "archivmail_audit": len(d.Audit),
}, nil
}
func retainSource(set bool) any {
if set {
return "archiving_rule"
}
return nil
}
func SeedArchivdms(ctx context.Context, pool *pgxpool.Pool, d ArchivdmsData) (seedCounts, error) {
if _, err := pool.Exec(ctx, archivdmsSchemaSQL); err != nil {
return nil, fmt.Errorf("archivdms-schema anlegen: %w", err)
}
b := &pgx.Batch{}
for _, t := range d.Tenants {
b.Queue(`INSERT INTO tenants (id, name, slug, domain) VALUES ($1, $2, $3, $4)`, t.ID, t.Name, t.Slug, t.Domains[0])
}
for _, u := range d.Users {
b.Queue(`INSERT INTO users (id, username, email, password_hash, role, tenant_id, auth_source, ldap_uid, ldap_synced_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.Role, u.TenantID, u.AuthSource, u.LDAPUID, u.LDAPSyncedAt)
}
for _, g := range d.Groups {
b.Queue(`INSERT INTO permission_groups (id, tenant_id, name) VALUES ($1, $2, $3)`, g.ID, g.TenantID, g.Name)
for _, m := range g.Members {
b.Queue(`INSERT INTO permission_group_members (group_id, user_id) VALUES ($1, $2)`, g.ID, m)
}
}
for _, t := range d.DocTypes {
b.Queue(`INSERT INTO document_types (id, tenant_id, name) VALUES ($1, $2, $3)`, t.ID, t.TenantID, t.Name)
}
for _, a := range d.Akten {
b.Queue(`INSERT INTO akten (id, tenant_id, titel, status, created_by) VALUES ($1, $2, $3, $4, $5)`, a.ID, a.TenantID, a.Titel, a.Status, a.CreatedBy)
}
for _, doc := range d.Documents {
var retain any
if doc.RetainUntil != nil {
retain = doc.RetainUntil.Format("2006-01-02")
}
b.Queue(`INSERT INTO documents (id, tenant_id, title, storage_path, content_hash, retain_until, created_at, updated_at, created_by, doc_type_id, akte_id, deleted_at, deleted_by)
VALUES ($1, $2, $3, $4, $5, $6::date, $7, $7, $8, $9, $10, $11, $12)`,
doc.ID, doc.TenantID, doc.Title, doc.StoragePath, doc.ContentHash, retain, doc.CreatedAt, doc.CreatedBy, doc.DocTypeID, doc.AkteID, doc.DeletedAt, doc.DeletedBy)
}
for _, v := range d.Visibility {
b.Queue(`INSERT INTO document_visibility (document_id, group_id, access) VALUES ($1, $2, $3)`, v.DocumentID, v.GroupID, v.Access)
}
for _, gr := range d.Grants {
b.Queue(`INSERT INTO document_grants (document_id, group_id, access) VALUES ($1, $2, $3)`, gr.DocumentID, gr.GroupID, gr.Access)
}
for _, f := range d.FieldDefs {
var opts any
if len(f.EnumOptions) > 0 {
raw, err := json.Marshal(f.EnumOptions)
if err != nil {
return nil, err
}
opts = string(raw)
}
b.Queue(`INSERT INTO custom_field_defs (id, tenant_id, name, field_type, required, enum_options) VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
f.ID, f.TenantID, f.Name, f.Type, f.Required, opts)
}
for _, t := range d.Templates {
b.Queue(`INSERT INTO classification_templates (id, tenant_id, name, doc_type_id, retention_years) VALUES ($1, $2, $3, $4, $5)`,
t.ID, t.TenantID, t.Name, t.DocTypeID, t.RetentionYears)
for _, f := range t.Fields {
b.Queue(`INSERT INTO classification_template_field_defaults (template_id, field_def_id, default_value) VALUES ($1, $2, $3)`, t.ID, f.FieldID, f.Default)
}
}
for _, v := range d.FieldValues {
b.Queue(`INSERT INTO document_field_values (document_id, field_def_id, value) VALUES ($1, $2, $3)`, v.DocumentID, v.FieldID, v.Value)
}
for _, s := range d.Shares {
b.Queue(`INSERT INTO document_shares (id, tenant_id, document_id, token_hash, created_by, created_at, expires_at, max_accesses, access_count, password_hash, revoked_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
s.ID, s.TenantID, s.DocumentID, s.TokenHashHex, s.CreatedBy, s.CreatedAt, s.ExpiresAt, s.MaxAccesses, s.AccessCount, s.PasswordHash, s.RevokedAt)
}
for _, r := range d.DeleteRequests {
b.Queue(`INSERT INTO document_delete_requests (id, document_id, tenant_id, requested_by, requested_at, confirmed_by, confirmed_at, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
r.ID, r.DocumentID, r.TenantID, r.RequestedBy, r.RequestedAt, r.ConfirmedBy, r.ConfirmedAt, r.Status)
}
for _, w := range d.Workflows {
b.Queue(`INSERT INTO workflows (id, tenant_id, name) VALUES ($1, $2, $3)`, w.ID, w.TenantID, w.Name)
}
for _, r := range d.WorkflowRuns {
b.Queue(`INSERT INTO workflow_runs (id, workflow_id, tenant_id, document_id, status, final_state, started_at, finished_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
r.ID, r.WorkflowID, r.TenantID, r.DocumentID, r.Status, r.FinalState, r.StartedAt, r.FinishedAt)
for _, e := range r.Events {
b.Queue(`INSERT INTO workflow_run_events (run_id, from_state, to_state, actor_user_id, note, occurred_at) VALUES ($1, $2, $3, $4, $5, $6)`,
r.ID, e.From, e.To, e.ActorID, e.Note, e.At)
}
}
for _, a := range d.Audit {
b.Queue(`INSERT INTO audit_log (id, timestamp, event_type, username, ip_address, document_id, success, detail, tenant_id)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), $7, NULLIF($8, ''), $9)`,
a.ID, a.At, a.EventType, a.Username, a.IP, a.Ref, a.Success, a.Detail, a.TenantID)
}
if err := execBatch(ctx, pool, b); err != nil {
return nil, fmt.Errorf("archivdms-testkopie befuellen: %w", err)
}
return seedCounts{
"archivdms_tenants": len(d.Tenants), "archivdms_users": len(d.Users), "archivdms_documents": len(d.Documents),
"archivdms_akten": len(d.Akten), "archivdms_shares": len(d.Shares), "archivdms_delete_requests": len(d.DeleteRequests),
"archivdms_workflow_runs": len(d.WorkflowRuns), "archivdms_audit": len(d.Audit),
}, nil
}
// WriteLegacyObjects legt die Binaerobjekte der Testkopie ab. Dokumente mit
// vollzogener Loeschung erhalten bewusst keine Datei – in archivdms ist die
// WORM-Datei nach "executed" physisch entfernt.
func WriteLegacyObjects(root string, ds *Dataset) (int, error) {
n := 0
write := func(rel string, content []byte) error {
full := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
return err
}
n++
return os.WriteFile(full, content, 0o400)
}
for _, o := range ds.Archivmail.StorageObjects {
if err := write(filepath.Join("archivmail", o.Path), o.Content); err != nil {
return n, fmt.Errorf("archivmail-objekt ablegen: %w", err)
}
}
executed := map[int64]bool{}
for _, r := range ds.Archivdms.DeleteRequests {
if r.Status == "executed" {
executed[r.DocumentID] = true
}
}
for _, d := range ds.Archivdms.Documents {
if executed[d.ID] {
continue
}
if err := write(filepath.Join("archivdms", d.StoragePath), d.Content); err != nil {
return n, fmt.Errorf("archivdms-objekt ablegen: %w", err)
}
}
return n, nil
}
+119
View File
@@ -0,0 +1,119 @@
package cutoverorchestrator
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Modulstufen laufen als eigene Prozesse, weil dms/, mail/, archive/ und
// workflow/ eigene Go-Module sind: ihre internal-Pakete (die eigentlichen
// Migratoren) sind aus dem Root-Modul heraus nicht importierbar.
const (
EnvAdminDSN = "NEXARCH_CUTOVER_ADMIN_DSN"
EnvKEKSeed = "NEXARCH_CUTOVER_KEK_SEED"
EnvContext = "NEXARCH_CUTOVER_CONTEXT"
)
var StageModules = []string{"dms", "mail", "archive", "workflow"}
type ContextTenant struct {
Slug string `json:"slug"`
NexarchTenantID string `json:"nexarch_tenant_id"`
ArchivmailTenantID *int64 `json:"archivmail_tenant_id"`
ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"`
TenantDB string `json:"tenant_db"`
ArchiveDB string `json:"archive_db"`
WorkflowDB string `json:"workflow_db"`
}
type StageContext struct {
RunTag string `json:"run_tag"`
RunDir string `json:"run_dir"`
RegistryDB string `json:"registry_db"`
ArchivmailDB string `json:"archivmail_db"`
ArchivdmsDB string `json:"archivdms_db"`
LegacyObjects string `json:"legacy_objects_dir"`
MigrationsDirs map[string]string `json:"migrations_dirs"`
Tenants []ContextTenant `json:"tenants"`
}
func WriteStageContext(path string, sc StageContext) error {
data, err := json.MarshalIndent(sc, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
type StageResult struct {
Stage string `json:"stage"`
Counts map[string]int `json:"counts"`
Notes []string `json:"notes"`
}
type StageRunner struct {
BinDir string
ContextPath string
AdminDSN string
KEKSeed string
Redactor *Redactor
}
func StageBinary(binDir, module string) string {
return filepath.Join(binDir, module+"-cutover-stage")
}
func BuildStageBinaries(ctx context.Context, repoRoot, binDir string) error {
for _, module := range StageModules {
cmd := exec.CommandContext(ctx, "go", "build", "-o", StageBinary(binDir, module), "./cmd/cutover-stage")
cmd.Dir = filepath.Join(repoRoot, module)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return fmt.Errorf("modulstufe %s bauen: %v: %s", module, err, strings.TrimSpace(out.String()))
}
}
return nil
}
// Run startet eine Modulstufe mit minimaler Umgebung: Zugangsdaten nur ueber
// Umgebungsvariablen, nie als Argument (Argumente sind in der Prozessliste
// fuer andere Benutzer sichtbar).
func (s StageRunner) Run(ctx context.Context, module, stage string) (Outcome, error) {
cmd := exec.CommandContext(ctx, StageBinary(s.BinDir, module), stage)
cmd.Env = []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
EnvAdminDSN + "=" + s.AdminDSN,
EnvKEKSeed + "=" + s.KEKSeed,
EnvContext + "=" + s.ContextPath,
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
if runErr != nil {
return Outcome{}, fmt.Errorf("modulstufe %s/%s: %v: %s", module, stage, runErr, s.Redactor.Redact(lastLines(stderr.String(), 8)))
}
var res StageResult
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
return Outcome{}, fmt.Errorf("modulstufe %s/%s: ergebnis nicht lesbar: %w", module, stage, err)
}
return Outcome{Counts: res.Counts, Notes: res.Notes}, nil
}
func lastLines(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, " | ")
}
+181
View File
@@ -0,0 +1,181 @@
package cutoverorchestrator
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationacl"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// aclSource liest die materialisierte document_visibility (wirksame
// Erlaubnisse je Gruppe) und ergaenzt explizite Verbote aus document_grants,
// die in der Materialisierung nicht als Zeile auftauchen.
type aclSource struct {
legacy *pgxpool.Pool
tenantID int64
}
func (s aclSource) Visibilities(ctx context.Context) ([]migrationacl.SourceVisibility, error) {
rows, err := s.legacy.Query(ctx, `
SELECT v.document_id::text, v.group_id::text, v.access, 'allow'
FROM document_visibility v JOIN documents d ON d.id = v.document_id
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
UNION ALL
SELECT g.document_id::text, g.group_id::text, 'read', 'deny'
FROM document_grants g JOIN documents d ON d.id = g.document_id
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL AND g.access = 'deny'
ORDER BY 1, 2
`, s.tenantID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationacl.SourceVisibility, error) {
v := migrationacl.SourceVisibility{SourcePrincipalType: "group"}
err := r.Scan(&v.SourceDocumentID, &v.SourcePrincipalID, &v.SourceRight, &v.SourceEffect)
return v, err
})
}
type registryUserResolver struct {
registry *pgxpool.Pool
tenantID string
column string
}
func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) {
id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64)
if err != nil {
return "", fmt.Errorf("altsystem-benutzer-id %q ungueltig", sourceUserID)
}
var out string
err = r.registry.QueryRow(ctx, fmt.Sprintf(`
SELECT nexarch_user_id::text FROM user_migration_map
WHERE nexarch_tenant_id = $1 AND %s = $2 AND approved_at IS NOT NULL
`, pgx.Identifier{r.column}.Sanitize()), r.tenantID, id).Scan(&out)
if err != nil {
return "", fmt.Errorf("altsystem-benutzer %d nicht in freigegebener user_migration_map: %w", id, err)
}
return out, nil
}
// coreGroupResolver legt die archivdms-Berechtigungsgruppen als Core-Gruppen
// (RBAC-03/RBAC-04) samt Mitgliedschaften an; DOK-03 setzt bereits
// migrierte Gruppen voraus, ein eigener Migrationsschritt dafuer existiert
// nicht.
type coreGroupResolver struct {
ids map[string]string
}
func (r coreGroupResolver) ResolveGroupID(_ context.Context, sourceGroupID string) (string, error) {
id, ok := r.ids[sourceGroupID]
if !ok {
return "", fmt.Errorf("archivdms-gruppe %s ohne core-gruppe", sourceGroupID)
}
return id, nil
}
func ensureCoreGroups(ctx context.Context, legacy, tenantDB *pgxpool.Pool, legacyTenantID int64, users registryUserResolver) (coreGroupResolver, int, error) {
res := coreGroupResolver{ids: map[string]string{}}
store := rbac.NewGroupStore(tenantDB)
existing, err := store.ListGroups(ctx)
if err != nil {
return res, 0, err
}
byName := map[string]string{}
for _, g := range existing {
byName[g.Name] = g.ID
}
rows, err := legacy.Query(ctx, `
SELECT g.id::text, g.name, COALESCE(array_agg(m.user_id::text ORDER BY m.user_id) FILTER (WHERE m.user_id IS NOT NULL), '{}')
FROM permission_groups g LEFT JOIN permission_group_members m ON m.group_id = g.id
WHERE g.tenant_id = $1 GROUP BY g.id ORDER BY g.id
`, legacyTenantID)
if err != nil {
return res, 0, err
}
type group struct {
id, name string
members []string
}
groups, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (group, error) {
var g group
err := r.Scan(&g.id, &g.name, &g.members)
return g, err
})
if err != nil {
return res, 0, err
}
memberships := 0
for _, g := range groups {
coreID, ok := byName[g.name]
if !ok {
created, err := store.CreateGroup(ctx, g.name)
if err != nil {
return res, 0, err
}
coreID = created.ID
byName[g.name] = coreID
for _, m := range g.members {
uid, err := users.ResolveUserID(ctx, m)
if err != nil {
return res, 0, fmt.Errorf("mitglied von gruppe %s: %w", g.name, err)
}
if err := store.AddMember(ctx, coreID, uid); err != nil {
return res, 0, err
}
memberships++
}
}
res.ids[g.id] = coreID
}
return res, memberships, nil
}
func (d *Dryrun) dok03(ctx context.Context) (Outcome, error) {
store := policy.NewStore(d.registry)
out := Outcome{Counts: map[string]int{}}
for _, t := range d.tenants {
if t.ArchivdmsTenantID == nil {
out.Notes = append(out.Notes, t.Slug+": kein archivdms-mandant, stufe entfaellt")
continue
}
tenantDB, err := d.tenantPool(ctx, t.TenantDB)
if err != nil {
return out, err
}
users := registryUserResolver{registry: d.registry, tenantID: t.NexarchTenantID, column: "archivdms_user_id"}
groups, members, err := ensureCoreGroups(ctx, d.archivdms, tenantDB, *t.ArchivdmsTenantID, users)
if err != nil {
return out, fmt.Errorf("mandant %s: gruppen: %w", t.Slug, err)
}
src := aclSource{legacy: d.archivdms, tenantID: *t.ArchivdmsTenantID}
res, err := migrationacl.NewMigrator(store, src, users, groups, t.Slug).MigrateAll(ctx)
if err != nil {
return out, fmt.Errorf("mandant %s: %w", t.Slug, err)
}
vis, err := src.Visibilities(ctx)
if err != nil {
return out, err
}
var stored int
if err := d.registry.QueryRow(ctx, `SELECT count(*) FROM object_acl WHERE tenant_slug = $1`, t.Slug).Scan(&stored); err != nil {
return out, err
}
if stored != len(vis) {
return out, fmt.Errorf("mandant %s: %d objektrechte gespeichert, %d sichtbarkeitsregeln in der quelle", t.Slug, stored, len(vis))
}
out.Counts["core_gruppen"] += len(groups.ids)
out.Counts["gruppenmitgliedschaften"] += members
out.Counts["objektrechte"] += res.Granted
}
return out, nil
}
+476
View File
@@ -0,0 +1,476 @@
package cutoverorchestrator
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationaudit"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationrole"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationtenant"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationuser"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
"gitea.perlbach24.de/scripte/nexarch/internal/usermatch"
)
const dryrunReviewer = "trockenlauf-pruefer (synthetische freigabe, CUT-01)"
func (d *Dryrun) map01(ctx context.Context) (Outcome, error) {
am, err := tenantmatch.ExportTenants(ctx, d.exportArchivmail, "archivmail")
if err != nil {
return Outcome{}, err
}
dms, err := tenantmatch.ExportTenants(ctx, d.exportArchivdms, "archivdms")
if err != nil {
return Outcome{}, err
}
for name, p := range map[string]*pgxpool.Pool{"archivmail": d.exportArchivmail, "archivdms": d.exportArchivdms} {
n, err := tenantmatch.CountTenants(ctx, p)
if err != nil {
return Outcome{}, err
}
exported := len(am)
if name == "archivdms" {
exported = len(dms)
}
if n != exported {
return Outcome{}, fmt.Errorf("%s: export unvollstaendig (%d von %d)", name, exported, n)
}
}
manual, err := tenantmatch.LoadManualDecisions(strings.NewReader(d.dataset.ManualDecisionsCSV))
if err != nil {
return Outcome{}, err
}
d.tenantCands = tenantmatch.Match(am, dms, manual)
conflicts := 0
for _, c := range d.tenantCands {
if c.Conflict {
conflicts++
}
}
return Outcome{Counts: countsOf("archivmail_tenants", len(am), "archivdms_tenants", len(dms),
"kandidaten", len(d.tenantCands), "konflikte", conflicts, "manuelle_zuordnungen", len(manual))}, nil
}
// map02 simuliert die menschliche Freigabe ueber die bestehenden
// MAP-02-Operationen. Die Korrektur des Slugs auf das Ziel-Format ist
// dieselbe Operation, die ein Pruefer fuer Altsystem-Slugs mit Bindestrich
// ausfuehren muesste (Core erlaubt nur [a-z0-9_]).
func (d *Dryrun) map02(ctx context.Context) (Outcome, error) {
store := tenantapproval.NewStore(d.registry)
ids, err := store.InsertCandidates(ctx, d.tenantCands)
if err != nil {
return Outcome{}, err
}
for _, id := range ids {
if err := store.Approve(ctx, id, dryrunReviewer, "CUT-01 Trockenlauf"); err != nil {
return Outcome{}, err
}
e, err := store.Get(ctx, id)
if err != nil {
return Outcome{}, err
}
slug := TargetSlug(d.RunTag, e.Slug)
if err := store.Correct(ctx, id, dryrunReviewer, "Ziel-Slug-Format und Laufpraefix", tenantapproval.Correction{Slug: &slug}); err != nil {
return Outcome{}, err
}
}
approved, err := store.Approved(ctx)
if err != nil {
return Outcome{}, err
}
covered := map[string]bool{}
for _, e := range approved {
if e.ArchivmailTenantID != nil {
covered[fmt.Sprintf("archivmail:%d", *e.ArchivmailTenantID)] = true
}
if e.ArchivdmsTenantID != nil {
covered[fmt.Sprintf("archivdms:%d", *e.ArchivdmsTenantID)] = true
}
}
var missing []string
for _, t := range d.dataset.Archivmail.Tenants {
if !covered[fmt.Sprintf("archivmail:%d", t.ID)] {
missing = append(missing, "archivmail/"+t.Slug)
}
}
for _, t := range d.dataset.Archivdms.Tenants {
if !covered[fmt.Sprintf("archivdms:%d", t.ID)] {
missing = append(missing, "archivdms/"+t.Slug)
}
}
out := Outcome{Counts: countsOf("eintraege_angelegt", len(ids), "freigegeben", len(approved))}
if len(missing) > 0 {
return out, fmt.Errorf("vollstaendigkeit: %d altsystem-mandanten ohne eintrag in der freigegebenen tenant_migration_map, sie wuerden nie migriert: %s",
len(missing), strings.Join(missing, ", "))
}
return out, nil
}
func (d *Dryrun) map03(ctx context.Context) (Outcome, error) {
am, err := usermatch.ExportUsers(ctx, d.exportArchivmail, "archivmail")
if err != nil {
return Outcome{}, err
}
dms, err := usermatch.ExportUsers(ctx, d.exportArchivdms, "archivdms")
if err != nil {
return Outcome{}, err
}
for name, p := range map[string]*pgxpool.Pool{"archivmail": d.exportArchivmail, "archivdms": d.exportArchivdms} {
n, err := usermatch.CountUsers(ctx, p)
if err != nil {
return Outcome{}, err
}
exported := len(am)
if name == "archivdms" {
exported = len(dms)
}
if n != exported {
return Outcome{}, fmt.Errorf("%s: benutzer-export unvollstaendig (%d von %d)", name, exported, n)
}
}
approved, err := tenantapproval.NewStore(d.registry).Approved(ctx)
if err != nil {
return Outcome{}, err
}
d.userCands = usermatch.Match(am, dms, approved)
matched, conflicts := 0, 0
for _, c := range d.userCands {
if c.ArchivmailUserID != nil && c.ArchivdmsUserID != nil {
matched++
}
if c.Conflict {
conflicts++
}
}
return Outcome{Counts: countsOf("archivmail_users", len(am), "archivdms_users", len(dms),
"kandidaten", len(d.userCands), "zusammengefuehrt", matched, "konflikte", conflicts)}, nil
}
// map04 ersetzt die noch nicht gebaute MAP-04-Freigabe: jeder MAP-03-Kandidat
// wird mit dokumentiertem Trockenlauf-Pruefer freigegeben, LDAP-Felder
// (COR-04) und Rollenherkunft (COR-03) kommen 1:1 aus der Testkopie.
func (d *Dryrun) map04(ctx context.Context) (Outcome, error) {
amByID := map[int64]ArchivmailUser{}
for _, u := range d.dataset.Archivmail.Users {
amByID[u.ID] = u
}
dmsByID := map[int64]ArchivdmsUser{}
for _, u := range d.dataset.Archivdms.Users {
dmsByID[u.ID] = u
}
firstGroup := map[int64]string{}
for _, g := range d.dataset.Archivdms.Groups {
for _, m := range g.Members {
if _, ok := firstGroup[m]; !ok {
firstGroup[m] = g.Name
}
}
}
users, roles, ldap := 0, 0, 0
for _, c := range d.userCands {
var ldapUID, ldapSource *string
var ldapSynced *time.Time
if c.ArchivdmsUserID != nil {
du := dmsByID[*c.ArchivdmsUserID]
ldapUID, ldapSynced = du.LDAPUID, du.LDAPSyncedAt
if du.AuthSource != "local" {
src := du.AuthSource
ldapSource = &src
}
}
if c.ArchivmailUserID != nil && ldapSource == nil {
if src := amByID[*c.ArchivmailUserID].Source; src != "local" {
ldapSource = &src
}
}
if ldapUID != nil || ldapSource != nil {
ldap++
}
var mapID int64
err := d.registry.QueryRow(ctx, `
INSERT INTO user_migration_map (archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id,
email, name, password_hash, approved_by, approved_at, ldap_uid, ldap_synced_at, ldap_source)
VALUES ($1, $2, $3, gen_random_uuid(), $4, $5, $6, $7, now(), $8, $9, $10)
RETURNING id
`, c.ArchivmailUserID, c.ArchivdmsUserID, c.NexarchTenantID, c.Email, c.Name, c.PasswordHash, dryrunReviewer,
ldapUID, ldapSynced, ldapSource).Scan(&mapID)
if err != nil {
return Outcome{}, fmt.Errorf("user_migration_map fuer %s: %w", c.Email, err)
}
users++
var system, role string
switch {
case c.ArchivdmsUserID != nil && firstGroup[*c.ArchivdmsUserID] != "":
system, role = "archivdms", firstGroup[*c.ArchivdmsUserID]
case c.ArchivmailUserID != nil:
system, role = "archivmail", amByID[*c.ArchivmailUserID].Role
default:
continue
}
if _, err := d.registry.Exec(ctx, `
INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at)
VALUES ($1, $2, $3, $4, $4, now())
`, mapID, system, role, dryrunReviewer); err != nil {
return Outcome{}, fmt.Errorf("role_migration_map fuer %s: %w", c.Email, err)
}
roles++
}
return Outcome{Counts: countsOf("benutzer_freigegeben", users, "rollen_freigegeben", roles, "mit_ldap_bezug", ldap)}, nil
}
func (d *Dryrun) cor01(ctx context.Context) (Outcome, error) {
adminPool, err := pgxpool.New(ctx, d.cluster.AdminDSN())
if err != nil {
return Outcome{}, fmt.Errorf("admin-verbindung: %w", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(d.registry)
provisioner := tenant.NewProvisioner(adminPool, registry, d.cluster.TenantDSNTemplate())
res, err := migrationtenant.NewMigrator(migrationtenant.NewMapStore(d.registry), provisioner).MigrateApproved(ctx)
if err != nil {
return Outcome{}, err
}
approved, err := tenantapproval.NewStore(d.registry).Approved(ctx)
if err != nil {
return Outcome{}, err
}
d.tenants = nil
for _, e := range approved {
t, err := registry.GetByID(ctx, e.NexarchTenantID)
if err != nil {
return Outcome{}, fmt.Errorf("tenant %s nach cor-01 nicht in der registry: %w", e.Slug, err)
}
d.tenants = append(d.tenants, ContextTenant{
Slug: t.Slug, NexarchTenantID: t.ID,
ArchivmailTenantID: e.ArchivmailTenantID, ArchivdmsTenantID: e.ArchivdmsTenantID,
TenantDB: t.DBName, ArchiveDB: t.Slug + "_archive", WorkflowDB: t.Slug + "_workflow",
})
}
if err := d.writeContext(); err != nil {
return Outcome{}, err
}
return Outcome{Counts: countsOf("tenants_migriert", len(res.Migrated))}, nil
}
func (d *Dryrun) writeContext() error {
return WriteStageContext(d.contextPath, StageContext{
RunTag: d.RunTag, RunDir: d.RunDir, RegistryDB: d.registryDB,
ArchivmailDB: d.archivmailDB, ArchivdmsDB: d.archivdmsDB, LegacyObjects: d.legacyObjectsDir(),
MigrationsDirs: map[string]string{
"dms": filepath.Join(d.cfg.RepoRoot, "dms", "migrations", "tenant"),
"archive": filepath.Join(d.cfg.RepoRoot, "archive", "migrations"),
"workflow": filepath.Join(d.cfg.RepoRoot, "workflow", "migrations", "tenant"),
},
Tenants: d.tenants,
})
}
func (d *Dryrun) schemaCore(ctx context.Context) (Outcome, error) {
migrations, err := migrate.LoadMigrations(filepath.Join(d.cfg.RepoRoot, "migrations", "tenant"))
if err != nil {
return Outcome{}, err
}
results, err := migrate.NewOrchestrator(tenant.NewRegistry(d.registry)).RolloutAll(ctx, migrations, migrate.GenerateManifest(migrations))
if err != nil {
return Outcome{}, err
}
applied := 0
for _, r := range results {
if r.Err != nil {
return Outcome{}, fmt.Errorf("tenant %s: %w", r.TenantSlug, r.Err)
}
applied += len(r.Applied)
}
return Outcome{Counts: countsOf("tenants", len(results), "core_tenant_migrationen_angewendet", applied)}, nil
}
func (d *Dryrun) schemaModules(ctx context.Context) (Outcome, error) {
for _, t := range d.tenants {
for _, db := range []string{t.ArchiveDB, t.WorkflowDB} {
if err := d.cluster.CreateDatabase(ctx, db); err != nil {
return Outcome{}, err
}
}
}
out := Outcome{Counts: map[string]int{}}
for _, module := range []string{"dms", "archive", "workflow"} {
o, err := d.stages.Run(ctx, module, "schema")
if err != nil {
return out, err
}
for k, v := range o.Counts {
if k != "mandanten" {
out.Counts[k] = v
}
}
}
return out, nil
}
func (d *Dryrun) cor02(ctx context.Context) (Outcome, error) {
m := migrationuser.NewMigrator(migrationuser.NewMapStore(d.registry), tenant.NewRegistry(d.registry))
defer m.Close()
res, err := m.MigrateApproved(ctx)
if err != nil {
return Outcome{}, err
}
return Outcome{Counts: countsOf("benutzer_migriert", len(res.Migrated))}, nil
}
func (d *Dryrun) tenantByID(id string) (ContextTenant, bool) {
for _, t := range d.tenants {
if t.NexarchTenantID == id {
return t, true
}
}
return ContextTenant{}, false
}
func (d *Dryrun) cor04(ctx context.Context) (Outcome, error) {
entries, err := migrationuser.NewMapStore(d.registry).Approved(ctx)
if err != nil {
return Outcome{}, err
}
checked := 0
for _, e := range entries {
if e.LDAPUID == nil && e.LDAPSource == nil && e.LDAPSyncedAt == nil {
continue
}
t, ok := d.tenantByID(e.NexarchTenantID)
if !ok {
return Outcome{}, fmt.Errorf("tenant %s unbekannt", e.NexarchTenantID)
}
pool, err := d.tenantPool(ctx, t.TenantDB)
if err != nil {
return Outcome{}, err
}
info, err := user.NewTenantUserStore(pool).LDAPInfo(ctx, e.NexarchUserID)
if err != nil {
return Outcome{}, fmt.Errorf("ldap-felder von %s: %w", e.Email, err)
}
if !sameStr(info.UID, e.LDAPUID) || !sameStr(info.Source, e.LDAPSource) || !sameTime(info.SyncedAt, e.LDAPSyncedAt) {
return Outcome{}, fmt.Errorf("ldap-felder von %s weichen von der freigegebenen map ab", e.Email)
}
checked++
}
return Outcome{Counts: countsOf("benutzer_mit_ldap_bezug_geprueft", checked)}, nil
}
func sameStr(a, b *string) bool {
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
}
func sameTime(a, b *time.Time) bool {
return (a == nil && b == nil) || (a != nil && b != nil && a.Equal(*b))
}
func (d *Dryrun) cor03(ctx context.Context) (Outcome, error) {
m := migrationrole.NewMigrator(migrationrole.NewMapStore(d.registry), tenant.NewRegistry(d.registry))
defer m.Close()
res, err := m.MigrateApproved(ctx)
if err != nil {
return Outcome{}, err
}
out := Outcome{Counts: map[string]int{"rollen_zugewiesen": len(res.Assigned)}}
for _, a := range res.Assigned {
out.Counts["rolle_"+string(a.NexarchRole)]++
}
return out, nil
}
// cor05 befuellt die Staging-Tabelle aus den audit_log-Exporten beider
// Altsysteme. Die Altsysteme protokollieren den Benutzernamen statt der ID;
// die Aufloesung erfolgt ueber users.username des jeweiligen Altsystems.
func (d *Dryrun) cor05(ctx context.Context) (Outcome, error) {
staged := 0
for _, src := range []struct {
system string
pool *pgxpool.Pool
refCol string
}{{"archivmail", d.archivmail, "mail_id"}, {"archivdms", d.archivdms, "document_id"}} {
rows, err := src.pool.Query(ctx, fmt.Sprintf(`
SELECT a.id, a.tenant_id, u.id, a.timestamp, a.event_type, COALESCE(a.%s, ''),
json_build_object('ip_address', a.ip_address, 'success', a.success, 'detail', a.detail, 'quelle_benutzername', a.username)::text
FROM audit_log a LEFT JOIN users u ON u.username = a.username
ORDER BY a.id
`, src.refCol))
if err != nil {
return Outcome{}, fmt.Errorf("%s audit_log lesen: %w", src.system, err)
}
type row struct {
id, tenantID int64
userID *int64
at time.Time
action, ref string
meta string
}
var batch []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.tenantID, &r.userID, &r.at, &r.action, &r.ref, &r.meta); err != nil {
rows.Close()
return Outcome{}, err
}
batch = append(batch, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return Outcome{}, err
}
for _, r := range batch {
if r.userID == nil {
return Outcome{}, fmt.Errorf("%s audit_log %d: benutzername nicht im altsystem aufloesbar", src.system, r.id)
}
if _, err := d.registry.Exec(ctx, `
INSERT INTO legacy_audit_log_staging (source_system, source_id, source_tenant_id, source_user_id, occurred_at, action, target, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
ON CONFLICT (source_system, source_id) DO NOTHING
`, src.system, r.id, r.tenantID, *r.userID, r.at, r.action, r.ref, r.meta); err != nil {
return Outcome{}, fmt.Errorf("%s audit_log %d in staging: %w", src.system, r.id, err)
}
staged++
}
}
res, err := migrationaudit.NewMigrator(d.registry).MigrateAll(ctx)
if err != nil {
return Outcome{}, err
}
var unmigrated int
if err := d.registry.QueryRow(ctx, `SELECT count(*) FROM legacy_audit_log_staging WHERE migrated_event_id IS NULL`).Scan(&unmigrated); err != nil {
return Outcome{}, err
}
if unmigrated > 0 {
return Outcome{}, fmt.Errorf("%d audit-zeilen nach cor-05 nicht migriert", unmigrated)
}
var mismatched int
if err := d.registry.QueryRow(ctx, `
SELECT count(*) FROM legacy_audit_log_staging s JOIN audit_events e ON e.id = s.migrated_event_id
WHERE e.occurred_at <> s.occurred_at OR e.action <> s.action
`).Scan(&mismatched); err != nil {
return Outcome{}, err
}
if mismatched > 0 {
return Outcome{}, fmt.Errorf("%d audit-ereignisse mit veraendertem zeitstempel oder aktion", mismatched)
}
return Outcome{
Counts: countsOf("staging_zeilen", staged, "audit_ereignisse_migriert", res.Migrated),
Notes: []string{"zeitstempel und aktion je ereignis gegen staging unveraendert"},
}, nil
}
+109
View File
@@ -0,0 +1,109 @@
package cutoverorchestrator
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
func (d *Dryrun) prepWorkdir(ctx context.Context) (Outcome, error) {
for _, sub := range []string{"bin", "altsystem-objekte", "ziel-objekte", "uebergabe", "val02"} {
if err := os.MkdirAll(filepath.Join(d.RunDir, sub), 0o700); err != nil {
return Outcome{}, err
}
}
if err := BuildStageBinaries(ctx, d.cfg.RepoRoot, d.stages.BinDir); err != nil {
return Outcome{}, err
}
return Outcome{
Counts: countsOf("modulstufen", len(StageModules)),
Notes: []string{"arbeitsverzeichnis " + d.RunDir},
}, nil
}
func (d *Dryrun) prepLegacyCopy(ctx context.Context) (Outcome, error) {
ds, err := GenerateDataset(d.cfg.Seed, d.now, d.cfg.BcryptCost)
if err != nil {
return Outcome{}, err
}
d.dataset = ds
for _, db := range []string{d.archivmailDB, d.archivdmsDB} {
if err := d.cluster.CreateDatabase(ctx, db); err != nil {
return Outcome{}, err
}
}
if d.archivmail, err = d.cluster.Connect(ctx, d.archivmailDB); err != nil {
return Outcome{}, err
}
if d.archivdms, err = d.cluster.Connect(ctx, d.archivdmsDB); err != nil {
return Outcome{}, err
}
out := Outcome{Counts: map[string]int{}}
amCounts, err := SeedArchivmail(ctx, d.archivmail, ds.Archivmail)
if err != nil {
return out, err
}
dmsCounts, err := SeedArchivdms(ctx, d.archivdms, ds.Archivdms)
if err != nil {
return out, err
}
for k, v := range amCounts {
out.Counts[k] = v
}
for k, v := range dmsCounts {
out.Counts[k] = v
}
files, err := WriteLegacyObjects(d.legacyObjectsDir(), ds)
if err != nil {
return out, err
}
out.Counts["binaerobjekte"] = files
if err := os.WriteFile(filepath.Join(d.RunDir, "kundenzuordnungsliste.csv"), []byte(ds.ManualDecisionsCSV), 0o600); err != nil {
return out, err
}
out.Notes = append(out.Notes, fmt.Sprintf("generator-seed %d, quelle ausschliesslich internal/cutoverorchestrator/dataset.go", d.cfg.Seed))
return out, nil
}
func (d *Dryrun) prepAnonymization(ctx context.Context) (Outcome, error) {
rep, err := VerifyAnonymization(ctx, map[string]*pgxpool.Pool{
"archivmail": d.archivmail, "archivdms": d.archivdms,
}, d.legacyObjectsDir())
out := Outcome{Counts: rep.Counts()}
if err != nil {
return out, err
}
if len(rep.Violations) > 0 {
max := len(rep.Violations)
if max > 5 {
max = 5
}
return out, fmt.Errorf("anonymisierungsnachweis: %d verstoesse, u.a.: %s", len(rep.Violations), strings.Join(rep.Violations[:max], "; "))
}
out.Notes = []string{
"alle e-mail-adressen unter der reservierten domain " + ReservedMailDomainSuffix + " (RFC 2606)",
"alle ip-adressen aus dokumentationsnetzen (RFC 5737)",
"alle konten mit synthetischem praefix, alle binaerobjekte mit marker " + SyntheticMarker,
}
return out, nil
}
func (d *Dryrun) prepRegistry(ctx context.Context) (Outcome, error) {
if err := d.cluster.CreateDatabase(ctx, d.registryDB); err != nil {
return Outcome{}, err
}
var err error
if d.registry, err = d.cluster.Connect(ctx, d.registryDB); err != nil {
return Outcome{}, err
}
n, err := applySQLDir(ctx, d.registry, filepath.Join(d.cfg.RepoRoot, "migrations"))
if err != nil {
return Outcome{}, err
}
return Outcome{Counts: countsOf("core_migrationen", n)}, nil
}
@@ -0,0 +1,430 @@
package cutoverorchestrator
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationuser"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationverify"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func (d *Dryrun) mailIndexCount(slug string) (int64, error) {
raw, err := os.ReadFile(filepath.Join(d.RunDir, "ziel-objekte", "mail-index", slug+".json"))
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
var docs map[string]json.RawMessage
if err := json.Unmarshal(raw, &docs); err != nil {
return 0, err
}
return int64(len(docs)), nil
}
type dryrunTargetCounts struct {
d *Dryrun
pg migrationverify.PostgresTenantCountProvider
}
func (c dryrunTargetCounts) Counts(ctx context.Context, slug string) (migrationverify.TenantCounts, error) {
tc, err := c.pg.Counts(ctx, slug)
if err != nil {
return tc, err
}
tc.Messages, err = c.d.mailIndexCount(slug)
return tc, err
}
func (d *Dryrun) tenantBySlug(slug string) (ContextTenant, error) {
for _, t := range d.tenants {
if t.Slug == slug {
return t, nil
}
}
return ContextTenant{}, fmt.Errorf("tenant %q unbekannt", slug)
}
func (d *Dryrun) val01(ctx context.Context) (Outcome, error) {
var slugs []string
idBySlug := map[string]string{}
sourceRest := migrationverify.MapCountProvider{}
var explanations []migrationverify.Explanation
for _, t := range d.tenants {
slugs = append(slugs, t.Slug)
idBySlug[t.Slug] = t.NexarchTenantID
var docs, deleted, msgs int64
if t.ArchivdmsTenantID != nil {
if err := d.archivdms.QueryRow(ctx, `
SELECT count(*), count(*) FILTER (WHERE deleted_at IS NOT NULL) FROM documents WHERE tenant_id = $1
`, *t.ArchivdmsTenantID).Scan(&docs, &deleted); err != nil {
return Outcome{}, err
}
}
if t.ArchivmailTenantID != nil {
if err := d.archivmail.QueryRow(ctx, `
SELECT count(*) FROM emails e
WHERE e.tenant_id = $1 OR EXISTS (SELECT 1 FROM email_refs r WHERE r.email_id = e.id AND r.tenant_id = $1)
`, *t.ArchivmailTenantID).Scan(&msgs); err != nil {
return Outcome{}, err
}
}
sourceRest[t.Slug] = migrationverify.TenantCounts{TenantSlug: t.Slug, Documents: docs, Messages: msgs}
if deleted > 0 {
explanations = append(explanations, migrationverify.Explanation{TenantSlug: t.Slug, Table: migrationverify.TableDocuments, Count: deleted,
Reason: "im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02)"})
}
if err := d.checkUserSourceCount(ctx, t); err != nil {
return Outcome{}, err
}
}
source := migrationverify.CombineUsers(migrationverify.RegistryApprovedUserCountProvider{RegistryPool: d.registry, TenantIDBySlug: idBySlug}, sourceRest)
target := dryrunTargetCounts{d: d, pg: migrationverify.PostgresTenantCountProvider{Resolver: func(ctx context.Context, slug string) (*pgxpool.Pool, error) {
t, err := d.tenantBySlug(slug)
if err != nil {
return nil, err
}
return d.tenantPool(ctx, t.TenantDB)
}}}
reports, err := migrationverify.Run(ctx, slugs, source, target, explanations)
if err != nil {
return Outcome{}, err
}
if err := os.WriteFile(filepath.Join(d.RunDir, "val01-zeilenzahl-abgleich.md"), []byte(migrationverify.RenderBericht(reports)), 0o644); err != nil {
return Outcome{}, err
}
out := Outcome{Counts: map[string]int{"vergleiche": len(reports)}}
for _, r := range reports {
out.Counts["quelle_"+string(r.Table)] += int(r.SourceCount)
out.Counts["ziel_"+string(r.Table)] += int(r.TargetCount)
}
if offen := migrationverify.OffeneBefunde(reports); len(offen) > 0 {
var lines []string
for _, r := range offen {
lines = append(lines, r.String())
}
return out, fmt.Errorf("zeilenzahl-abgleich: %d offene befunde: %s", len(offen), strings.Join(lines, "; "))
}
return out, nil
}
// checkUserSourceCount schliesst die Luecke zwischen Altsystem und
// user_migration_map, die VAL-01 selbst nicht sieht (VAL-01 zaehlt als
// Quelle bereits die freigegebene Map).
func (d *Dryrun) checkUserSourceCount(ctx context.Context, t ContextTenant) error {
var legacy, merged, mapped int64
if t.ArchivmailTenantID != nil {
var n int64
if err := d.archivmail.QueryRow(ctx, `SELECT count(*) FROM users WHERE tenant_id = $1`, *t.ArchivmailTenantID).Scan(&n); err != nil {
return err
}
legacy += n
}
if t.ArchivdmsTenantID != nil {
var n int64
if err := d.archivdms.QueryRow(ctx, `SELECT count(*) FROM users WHERE tenant_id = $1`, *t.ArchivdmsTenantID).Scan(&n); err != nil {
return err
}
legacy += n
}
if err := d.registry.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE archivmail_user_id IS NOT NULL AND archivdms_user_id IS NOT NULL), count(*)
FROM user_migration_map WHERE nexarch_tenant_id = $1 AND approved_at IS NOT NULL
`, t.NexarchTenantID).Scan(&merged, &mapped); err != nil {
return err
}
if legacy-merged != mapped {
return fmt.Errorf("tenant %s: %d altsystem-konten (%d zusammengefuehrt) ergeben %d personen, freigegebene map enthaelt %d", t.Slug, legacy, merged, legacy-merged, mapped)
}
return nil
}
type dirObjects struct{ root string }
func (o dirObjects) open(key string) (io.ReadCloser, error) {
full := filepath.Join(o.root, filepath.FromSlash(key))
rel, err := filepath.Rel(o.root, full)
if err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("objektschluessel %q ausserhalb von %s", key, o.root)
}
return os.Open(full)
}
func (o dirObjects) OpenObject(_ context.Context, key string) (io.ReadCloser, error) {
return o.open(key)
}
func (o dirObjects) Get(_ context.Context, key string) (io.ReadCloser, error) { return o.open(key) }
type mailHandoff struct {
ObjectReference string `json:"object_reference"`
Attachments []struct {
SourceObjectKey string `json:"source_object_key"`
StorageKey string `json:"storage_key"`
} `json:"attachments"`
}
const val02MailSampleSize = 5
func (d *Dryrun) val02(ctx context.Context) (Outcome, error) {
if _, err := d.stages.Run(ctx, "dms", "val02"); err != nil {
return Outcome{}, err
}
legacy := dirObjects{root: d.legacyObjectsDir()}
out := Outcome{Counts: map[string]int{}}
var failed []string
for _, t := range d.tenants {
var results []migrationverify.FileResult
if t.ArchivdmsTenantID != nil {
var raw []struct {
Label string `json:"label"`
SourceObjectKey string `json:"source_object_key"`
StorageKey string `json:"storage_key"`
}
if err := readJSONFile(filepath.Join(d.RunDir, "val02", t.Slug, "stichprobe.json"), &raw); err != nil {
return out, fmt.Errorf("dms-stichprobe %s: %w", t.Slug, err)
}
var samples []migrationverify.Sample
for _, r := range raw {
samples = append(samples, migrationverify.Sample{Label: r.Label, SourceObjectKey: r.SourceObjectKey, StorageKey: r.StorageKey})
}
results = append(results, migrationverify.CompareSamples(ctx, t.Slug, legacy, dirObjects{root: filepath.Join(d.RunDir, "val02")}, nil, samples)...)
out.Counts["dms_dateien"] += len(samples)
}
if t.ArchivmailTenantID != nil {
var handoff []mailHandoff
if err := readJSONFile(filepath.Join(d.RunDir, "uebergabe", t.Slug, "mail_objekte.json"), &handoff); err != nil {
return out, fmt.Errorf("mail-uebergabe %s: %w", t.Slug, err)
}
var samples []migrationverify.Sample
for _, m := range handoff {
for _, a := range m.Attachments {
if len(samples) < val02MailSampleSize {
samples = append(samples, migrationverify.Sample{Label: t.Slug + "/" + a.StorageKey, SourceObjectKey: a.SourceObjectKey, StorageKey: a.StorageKey})
}
}
}
results = append(results, migrationverify.CompareSamples(ctx, t.Slug, legacy, dirObjects{root: filepath.Join(d.RunDir, "ziel-objekte", "mail", t.Slug)}, nil, samples)...)
out.Counts["mail_anhaenge"] += len(samples)
}
report := migrationverify.RenderReport(t.Slug, results)
if err := os.WriteFile(filepath.Join(d.RunDir, "val02-hashvergleich-"+t.Slug+".md"), []byte(report), 0o644); err != nil {
return out, err
}
for _, r := range results {
if r.Err != nil || !r.Match {
failed = append(failed, r.Label)
}
}
}
out.Counts["abweichungen"] = len(failed)
if len(failed) > 0 {
return out, fmt.Errorf("hash-vergleich: %d abweichungen/fehler: %s", len(failed), strings.Join(failed, ", "))
}
return out, nil
}
func readJSONFile(path string, v any) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(raw, v)
}
const val05SamplePerTenant = 3
func (d *Dryrun) val05(ctx context.Context) (Outcome, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return Outcome{}, err
}
issuer := auth.NewTokenIssuer(hex.EncodeToString(secret))
entries, err := migrationuser.NewMapStore(d.registry).Approved(ctx)
if err != nil {
return Outcome{}, err
}
perTenant := map[string]int{}
ok, rejected := 0, 0
for _, e := range entries {
if perTenant[e.NexarchTenantID] >= val05SamplePerTenant {
continue
}
key := ""
switch {
case e.ArchivmailUserID != nil:
key = fmt.Sprintf("archivmail:%d", *e.ArchivmailUserID)
case e.ArchivdmsUserID != nil:
key = fmt.Sprintf("archivdms:%d", *e.ArchivdmsUserID)
}
pw, known := d.dataset.Passwords[key]
if !known {
continue
}
t, found := d.tenantByID(e.NexarchTenantID)
if !found {
return Outcome{}, fmt.Errorf("tenant %s unbekannt", e.NexarchTenantID)
}
pool, err := d.tenantPool(ctx, t.TenantDB)
if err != nil {
return Outcome{}, err
}
login := auth.NewLoginService(user.NewTenantUserStore(pool), issuer, t.Slug)
token, err := login.Login(ctx, e.Email, pw)
if err != nil {
return Outcome{}, fmt.Errorf("login mit migriertem passwort fuer %s (%s) fehlgeschlagen: %w", e.Email, t.Slug, err)
}
claims, err := issuer.Verify(token)
if err != nil || claims.UserID != e.NexarchUserID {
return Outcome{}, fmt.Errorf("token fuer %s gehoert nicht zum migrierten benutzer", e.Email)
}
if _, err := login.Login(ctx, e.Email, pw+"-falsch"); err == nil {
return Outcome{}, fmt.Errorf("login mit falschem passwort fuer %s haette scheitern muessen", e.Email)
}
perTenant[e.NexarchTenantID]++
ok++
rejected++
}
for _, t := range d.tenants {
if perTenant[t.NexarchTenantID] == 0 {
return Outcome{}, fmt.Errorf("tenant %s: keine login-stichprobe moeglich", t.Slug)
}
}
return Outcome{Counts: countsOf("logins_erfolgreich", ok, "falsche_passwoerter_abgewiesen", rejected)}, nil
}
func (d *Dryrun) chk01(ctx context.Context) (Outcome, error) {
out := Outcome{Counts: map[string]int{}}
var findings []string
finding := func(format string, args ...any) { findings = append(findings, fmt.Sprintf(format, args...)) }
for _, t := range d.tenants {
tenantDB, err := d.tenantPool(ctx, t.TenantDB)
if err != nil {
return out, err
}
archiveDB, err := d.tenantPool(ctx, t.ArchiveDB)
if err != nil {
return out, err
}
workflowDB, err := d.tenantPool(ctx, t.WorkflowDB)
if err != nil {
return out, err
}
var docIDs []string
if err := collectStrings(ctx, tenantDB, `SELECT id::text FROM documents`, &docIDs); err != nil {
return out, err
}
var withRetention int
if err := archiveDB.QueryRow(ctx, `
SELECT count(*) FROM retention_objects WHERE object_type = 'dms_document' AND object_reference = ANY($1)
`, docIDs).Scan(&withRetention); err != nil {
return out, err
}
if withRetention != len(docIDs) {
finding("%s: %d von %d dokumenten ohne retention-objekt", t.Slug, len(docIDs)-withRetention, len(docIDs))
}
out.Counts["dokumente_mit_retention"] += withRetention
msgs, err := d.mailIndexCount(t.Slug)
if err != nil {
return out, err
}
var msgRetention int64
if err := archiveDB.QueryRow(ctx, `SELECT count(*) FROM retention_objects WHERE object_type = 'mail_message'`).Scan(&msgRetention); err != nil {
return out, err
}
if msgRetention != msgs {
finding("%s: %d nachrichten im index, %d retention-objekte fuer nachrichten", t.Slug, msgs, msgRetention)
}
out.Counts["nachrichten_mit_retention"] += int(msgRetention)
var orphanACL int
if err := d.registry.QueryRow(ctx, `
SELECT count(*) FROM object_acl WHERE tenant_slug = $1 AND object_type = 'document' AND NOT (object_id = ANY($2))
`, t.Slug, docIDs).Scan(&orphanACL); err != nil {
return out, err
}
if orphanACL > 0 {
finding("%s: %d objektrechte verweisen auf nicht migrierte dokumente", t.Slug, orphanACL)
}
if t.ArchivdmsTenantID != nil {
lt := *t.ArchivdmsTenantID
checks := []struct {
label string
legacySQL string
targetPool *pgxpool.Pool
targetSQL string
}{
{"freigabelinks", `SELECT count(*) FROM document_shares s JOIN documents d ON d.id = s.document_id
WHERE s.tenant_id = $1 AND s.revoked_at IS NULL AND d.deleted_at IS NULL AND s.expires_at > now()`, tenantDB, `SELECT count(*) FROM share_links`},
{"workflow_vorgaenge", `SELECT count(*) FROM workflow_runs WHERE tenant_id = $1 AND status IN ('completed', 'aborted')`, workflowDB, `SELECT count(*) FROM workflow_instances`},
{"offene_loeschanfragen", `SELECT count(*) FROM document_delete_requests WHERE tenant_id = $1 AND status IN ('pending', 'confirmed', 'blocked_retention')`, archiveDB, `SELECT count(*) FROM deletion_requests`},
{"vollzogene_loeschungen", `SELECT count(*) FROM document_delete_requests WHERE tenant_id = $1 AND status = 'executed'`, archiveDB, `SELECT count(*) FROM destruction_log`},
{"akten", `SELECT count(*) FROM akten WHERE tenant_id = $1`, tenantDB, `SELECT count(*) FROM folders`},
}
for _, c := range checks {
var want, got int
if err := d.archivdms.QueryRow(ctx, c.legacySQL, lt).Scan(&want); err != nil {
return out, fmt.Errorf("%s quelle: %w", c.label, err)
}
if err := c.targetPool.QueryRow(ctx, c.targetSQL).Scan(&got); err != nil {
return out, fmt.Errorf("%s ziel: %w", c.label, err)
}
if want != got {
finding("%s: %s quelle=%d ziel=%d", t.Slug, c.label, want, got)
}
out.Counts[c.label] += got
}
}
if t.ArchivmailTenantID != nil {
var want, got int
if err := d.archivmail.QueryRow(ctx, `SELECT count(*) FROM reconciliation_reports WHERE tenant_id = $1`, *t.ArchivmailTenantID).Scan(&want); err != nil {
return out, err
}
if err := archiveDB.QueryRow(ctx, `SELECT count(*) FROM reconciliation_report_archive`).Scan(&got); err != nil {
return out, err
}
if want != got {
finding("%s: reconciliation-berichte quelle=%d ziel=%d", t.Slug, want, got)
}
out.Counts["reconciliation_berichte"] += got
}
}
out.Counts["befunde"] = len(findings)
if len(findings) > 0 {
return out, fmt.Errorf("kettenpruefung: %s", strings.Join(findings, "; "))
}
return out, nil
}
func collectStrings(ctx context.Context, pool *pgxpool.Pool, sql string, dst *[]string) error {
rows, err := pool.Query(ctx, sql)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return err
}
*dst = append(*dst, s)
}
return rows.Err()
}
+14 -5
View File
@@ -26,12 +26,21 @@ func (e *ErrUnmappedRole) Error() string {
// Core-Rolle ab (Akzeptanzkriterium 2). archivmail kennt kein eigenes
// Rechtemodell (nur Freitext), daher die grobere Abbildung auf die drei
// Core-Grundrollen aus internal/rbac.
//
// domain_admin/auditor/domain_auditor sind die in datenmodell-altsysteme.md
// dokumentierten archivmail-Rollen, die im CUT-01-Trockenlauf (Befund I-3)
// fehlten. Core kennt keine Pruefer-Rolle, daher landen beide Auditor-
// Varianten bei der geringsten Rolle statt bei tenant_admin. superadmin
// bleibt bewusst ungemappt: Plattformkonten sind keine Mandantenrolle.
var archivmailRoleMap = map[string]rbac.Role{
"admin": rbac.RoleTenantAdmin,
"administrator": rbac.RoleTenantAdmin,
"user": rbac.RoleUser,
"readonly": rbac.RoleUser,
"viewer": rbac.RoleUser,
"admin": rbac.RoleTenantAdmin,
"administrator": rbac.RoleTenantAdmin,
"domain_admin": rbac.RoleTenantAdmin,
"user": rbac.RoleUser,
"readonly": rbac.RoleUser,
"viewer": rbac.RoleUser,
"auditor": rbac.RoleUser,
"domain_auditor": rbac.RoleUser,
}
// archivdmsGroupMap bildet jede bekannte archivdms-Berechtigungsgruppe
+30
View File
@@ -0,0 +1,30 @@
package migrationrole
import (
"errors"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// CUT-01 Befund I-3: alle dokumentierten archivmail-Rollen ausser superadmin
// sind abgebildet, Pruefer-Rollen nie hoeher als user.
func TestCoreRole_DocumentedArchivmailRoles(t *testing.T) {
want := map[string]rbac.Role{
"user": rbac.RoleUser,
"admin": rbac.RoleTenantAdmin,
"domain_admin": rbac.RoleTenantAdmin,
"auditor": rbac.RoleUser,
"domain_auditor": rbac.RoleUser,
}
for source, role := range want {
got, err := CoreRole("archivmail", source)
if err != nil || got != role {
t.Errorf("archivmail-rolle %q: got %q err %v, erwartet %q", source, got, err, role)
}
}
var unmapped *ErrUnmappedRole
if _, err := CoreRole("archivmail", "superadmin"); !errors.As(err, &unmapped) {
t.Fatalf("superadmin muss fail-closed bleiben, err=%v", err)
}
}
+5 -5
View File
@@ -67,14 +67,14 @@ func NewStore(pool *pgxpool.Pool) *Store {
}
// InsertCandidates legt MAP-01-Kandidaten als unbestaetigte (status=pending)
// tenant_migration_map-Zeilen an. Einseitige Kandidaten (nur archivmail ODER
// nur archivdms) werden uebersprungen: eine Zusammenfuehrung setzt per
// Definition beide Quellen voraus, Freigabe-Pflicht gilt nur fuer echte
// Merges.
// tenant_migration_map-Zeilen an. Einseitige Kandidaten (nur in einem
// Altsystem) werden ebenfalls angelegt: die tenant_migration_map ist die
// einzige Quelle fuer COR-01, ein hier verworfener Mandant wuerde nie
// migriert (CUT-01, Trockenlauf 1, Befund I-2). Freigabe bleibt Pflicht.
func (s *Store) InsertCandidates(ctx context.Context, candidates []tenantmatch.Candidate) ([]int64, error) {
var ids []int64
for _, c := range candidates {
if c.ArchivmailTenantID == nil || c.ArchivdmsTenantID == nil {
if c.ArchivmailTenantID == nil && c.ArchivdmsTenantID == nil {
continue
}
var id int64
+23 -3
View File
@@ -232,18 +232,38 @@ func TestCorrect_UpdatesApprovedEntryAndLogsPreviousValues(t *testing.T) {
}
}
func TestInsertCandidates_SkipsOneSidedCandidates(t *testing.T) {
// CUT-01 Befund I-2: ein nur in einem Altsystem vorhandener Mandant muss in
// die Map, sonst migriert COR-01 ihn nie – aber nur als pending, ohne Freigabe.
func TestInsertCandidates_KeepsOneSidedCandidatesPending(t *testing.T) {
ctx := context.Background()
pool := testPool(t)
store := NewStore(pool)
ids, err := store.InsertCandidates(ctx, []tenantmatch.Candidate{
{ArchivmailTenantID: mustID(1), Slug: testdb.Slug("onesided"), Name: "Nur Archivmail"},
{ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("onesided"), Name: "Nur Archivdms"},
{Slug: testdb.Slug("ohnequelle"), Name: "Ohne Quelle"},
})
if err != nil {
t.Fatalf("InsertCandidates: %v", err)
}
if len(ids) != 0 {
t.Fatalf("einseitiger kandidat (kein merge) darf keine map-zeile anlegen, habe %d", len(ids))
if len(ids) != 2 {
t.Fatalf("erwarte 2 einseitige map-zeilen (kandidat ohne quelle verworfen), habe %d", len(ids))
}
for _, id := range ids {
e, err := store.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if e.Status != StatusPending || e.ApprovedAt != nil {
t.Fatalf("einseitiger kandidat darf nicht automatisch freigegeben sein: %+v", e)
}
}
approved, err := store.Approved(ctx)
if err != nil {
t.Fatalf("Approved: %v", err)
}
if len(approved) != 0 {
t.Fatalf("ohne menschliche freigabe darf kein eintrag nutzbar sein, habe %d", len(approved))
}
}
+11 -3
View File
@@ -45,12 +45,20 @@ func Match(archivmailUsers, archivdmsUsers []ExportedUser, approvedTenants []ten
if t.Status != tenantapproval.StatusApproved {
continue
}
if t.ArchivmailTenantID == nil || t.ArchivdmsTenantID == nil {
if t.ArchivmailTenantID == nil && t.ArchivdmsTenantID == nil {
continue
}
amUsers := filterByTenant(archivmailUsers, *t.ArchivmailTenantID)
dmsUsers := filterByTenant(archivdmsUsers, *t.ArchivdmsTenantID)
// Einseitig freigegebene Mandanten (CUT-01 Befund I-2): die Benutzer
// der vorhandenen Seite werden einseitige Kandidaten, sonst fehlten
// sie in user_migration_map und damit in COR-02.
var amUsers, dmsUsers []ExportedUser
if t.ArchivmailTenantID != nil {
amUsers = filterByTenant(archivmailUsers, *t.ArchivmailTenantID)
}
if t.ArchivdmsTenantID != nil {
dmsUsers = filterByTenant(archivdmsUsers, *t.ArchivdmsTenantID)
}
dmsByEmail := make(map[string][]ExportedUser, len(dmsUsers))
for _, d := range dmsUsers {
+32
View File
@@ -130,3 +130,35 @@ func TestMatch_IdentischeEmailUeberTenantGrenzenBleibtGetrennt(t *testing.T) {
t.Fatalf("tenant b sollte nur den archivdms-benutzer einseitig sehen, habe %+v", tenantB)
}
}
// CUT-01 Befund I-2: ein freigegebener Mandant, der nur in einem Altsystem
// existiert, liefert seine Benutzer als einseitige Kandidaten – ohne Bezug zu
// gleichlautenden Adressen anderer Mandanten.
func TestMatch_EinseitigFreigegebenerTenantLiefertEinseitigeKandidaten(t *testing.T) {
amOnly := int64(50)
dmsOnly := int64(60)
entries := []tenantapproval.Entry{
{ID: 3, ArchivmailTenantID: &amOnly, NexarchTenantID: "nexarch-nur-mail", Status: tenantapproval.StatusApproved},
{ID: 4, ArchivdmsTenantID: &dmsOnly, NexarchTenantID: "nexarch-nur-dms", Status: tenantapproval.StatusApproved},
}
archivmail := []ExportedUser{
{SourceID: 7, TenantSourceID: 50, Email: "nur-mail@synth.example", Name: "Nur Mail", PasswordHash: "hash-am"},
{SourceID: 8, TenantSourceID: 99, Email: "fremd@synth.example", Name: "Fremder Mandant"},
}
archivdms := []ExportedUser{
{SourceID: 207, TenantSourceID: 60, Email: "nur-mail@synth.example", Name: "Nur DMS", PasswordHash: "hash-dms"},
}
candidates := Match(archivmail, archivdms, entries)
if len(candidates) != 2 {
t.Fatalf("erwarte je einen einseitigen kandidaten fuer beide mandanten, habe %d: %+v", len(candidates), candidates)
}
mailSide := findByEmail(t, candidates, "nexarch-nur-mail", "nur-mail@synth.example")
if len(mailSide) != 1 || mailSide[0].ArchivdmsUserID != nil || mailSide[0].PasswordHash != "hash-am" {
t.Fatalf("archivmail-seite falsch: %+v", mailSide)
}
dmsSide := findByEmail(t, candidates, "nexarch-nur-dms", "nur-mail@synth.example")
if len(dmsSide) != 1 || dmsSide[0].ArchivmailUserID != nil || dmsSide[0].PasswordHash != "hash-dms" {
t.Fatalf("archivdms-seite falsch: %+v", dmsSide)
}
}
+37
View File
@@ -0,0 +1,37 @@
// cutover-stage fuehrt eine mail-Stufe des CUT-01-Trockenlaufs aus; Aufruf
// ausschliesslich durch cmd/cutover-dryrun im Root-Modul.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/cutoverstage"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "aufruf: cutover-stage <stufe>")
os.Exit(2)
}
env, err := cutoverstage.LoadEnv()
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
os.Exit(1)
}
defer env.Close()
res, err := cutoverstage.Run(context.Background(), env, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
if err := json.NewEncoder(os.Stdout).Encode(res); err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
}
+142
View File
@@ -0,0 +1,142 @@
// Package cutoverstage ist die Mail-Modulstufe des CUT-01-Trockenlaufs
// (Orchestrator: internal/cutoverorchestrator im Root-Modul): Quell-Adapter
// gegen die synthetische archivmail-Testkopie fuer MAI-02, ohne eigene
// Migrationslogik.
package cutoverstage
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
type Tenant struct {
Slug string `json:"slug"`
NexarchTenantID string `json:"nexarch_tenant_id"`
ArchivmailTenantID *int64 `json:"archivmail_tenant_id"`
ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"`
TenantDB string `json:"tenant_db"`
ArchiveDB string `json:"archive_db"`
WorkflowDB string `json:"workflow_db"`
}
type Context struct {
RunTag string `json:"run_tag"`
RunDir string `json:"run_dir"`
RegistryDB string `json:"registry_db"`
ArchivmailDB string `json:"archivmail_db"`
ArchivdmsDB string `json:"archivdms_db"`
LegacyObjects string `json:"legacy_objects_dir"`
MigrationsDirs map[string]string `json:"migrations_dirs"`
Tenants []Tenant `json:"tenants"`
}
type Result struct {
Stage string `json:"stage"`
Counts map[string]int `json:"counts"`
Notes []string `json:"notes"`
}
func (r *Result) add(key string, n int) {
if r.Counts == nil {
r.Counts = map[string]int{}
}
r.Counts[key] += n
}
func (r *Result) note(format string, args ...any) {
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
type Env struct {
Ctx Context
adminDSN string
pools map[string]*pgxpool.Pool
}
func LoadEnv() (*Env, error) {
adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if adminDSN == "" {
return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt")
}
ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT")
if ctxPath == "" {
return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt")
}
raw, err := os.ReadFile(ctxPath)
if err != nil {
return nil, fmt.Errorf("laufkontext lesen: %w", err)
}
e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}}
if err := json.Unmarshal(raw, &e.Ctx); err != nil {
return nil, fmt.Errorf("laufkontext parsen: %w", err)
}
return e, nil
}
func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) {
if p, ok := e.pools[database]; ok {
return p, nil
}
cfg, err := pgxpool.ParseConfig(e.adminDSN)
if err != nil {
return nil, errors.New("admin-dsn nicht lesbar")
}
cfg.ConnConfig.Database = database
cfg.MaxConns = 4
p, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("verbindung zu %q: %w", database, err)
}
e.pools[database] = p
return p, nil
}
func (e *Env) Close() {
for _, p := range e.pools {
p.Close()
}
}
func (e *Env) HandoffDir(slug string) string {
return filepath.Join(e.Ctx.RunDir, "uebergabe", slug)
}
func (e *Env) TargetDir(kind, slug string) string {
return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug)
}
// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses.
func legacyPath(root, key string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(key))
rel, err := filepath.Rel(root, full)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key)
}
return full, nil
}
func writeJSON(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
+229
View File
@@ -0,0 +1,229 @@
package cutoverstage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/messagemigration"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
type AttachmentHandoff struct {
SourceObjectKey string `json:"source_object_key"`
StorageKey string `json:"storage_key"`
}
type MessageHandoff struct {
SourceMessageID string `json:"source_message_id"`
ObjectReference string `json:"object_reference"`
RetainUntil *time.Time `json:"retain_until"`
DataSubjectRef string `json:"data_subject_ref"`
Attachments []AttachmentHandoff `json:"attachments"`
}
func Run(ctx context.Context, e *Env, stage string) (Result, error) {
res := Result{Stage: stage}
switch stage {
case "mai02":
for _, t := range e.Ctx.Tenants {
if t.ArchivmailTenantID == nil {
res.note("%s: kein archivmail-mandant, stufe entfaellt", t.Slug)
continue
}
if err := e.mai02(ctx, t, *t.ArchivmailTenantID, &res); err != nil {
return res, fmt.Errorf("mandant %s: %w", t.Slug, err)
}
}
res.note("suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll")
return res, nil
}
return res, fmt.Errorf("unbekannte mail-stufe %q", stage)
}
// messageSource: Nachrichten eines Mandanten sind die eigenen (emails.tenant_id)
// plus die per email_refs zusaetzlich sichtbaren; der Rohinhalt liegt als
// Objekt unter storage_objects.path.
type messageSource struct {
legacy *pgxpool.Pool
tenantID int64
objectRoot string
}
func (s messageSource) Messages(ctx context.Context) ([]messagemigration.SourceMessage, error) {
rows, err := s.legacy.Query(ctx, `
SELECT e.id, COALESCE(e.mail_to, ''), COALESCE(e.mail_from, ''), COALESCE(e.subject, ''),
e.received_at, e.indexed_at, so.path
FROM emails e JOIN storage_objects so ON so.id = e.storage_id
WHERE e.tenant_id = $1 OR EXISTS (SELECT 1 FROM email_refs r WHERE r.email_id = e.id AND r.tenant_id = $1)
ORDER BY e.id
`, s.tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []messagemigration.SourceMessage
for rows.Next() {
var m messagemigration.SourceMessage
var received time.Time
var indexed *time.Time
var bodyPath string
if err := rows.Scan(&m.SourceMessageID, &m.Mailbox, &m.Sender, &m.Subject, &received, &indexed, &bodyPath); err != nil {
return nil, err
}
body, err := s.read(bodyPath)
if err != nil {
return nil, fmt.Errorf("nachricht %s: rohinhalt: %w", m.SourceMessageID, err)
}
m.Body = string(body)
m.SentAtUnixEpoch = received.Unix()
if indexed != nil {
v := indexed.Unix()
m.IndexedAt = &v
}
out = append(out, m)
}
return out, rows.Err()
}
func (s messageSource) Attachments(ctx context.Context, sourceMessageID string) ([]messagemigration.SourceAttachment, error) {
rows, err := s.legacy.Query(ctx, `
SELECT a.id::text, COALESCE(a.mime_type, 'application/octet-stream'), COALESCE(a.size_bytes, 0), a.hash, so.path
FROM email_attachments ea
JOIN attachments a ON a.id = ea.attachment_id
JOIN storage_objects so ON so.id = a.storage_id
WHERE ea.email_id = $1 ORDER BY a.id
`, sourceMessageID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []messagemigration.SourceAttachment
for rows.Next() {
var a messagemigration.SourceAttachment
if err := rows.Scan(&a.SourceAttachmentID, &a.MimeType, &a.SizeBytes, &a.ChecksumSHA256, &a.ObjectKey); err != nil {
return nil, err
}
a.ChecksumSHA256 = strings.TrimSpace(a.ChecksumSHA256)
a.Index = len(out)
out = append(out, a)
}
return out, rows.Err()
}
func (s messageSource) read(key string) ([]byte, error) {
full, err := legacyPath(s.objectRoot, key)
if err != nil {
return nil, err
}
return os.ReadFile(full)
}
func (s messageSource) OpenObject(_ context.Context, objectKey string) (io.ReadCloser, error) {
full, err := legacyPath(s.objectRoot, objectKey)
if err != nil {
return nil, err
}
return os.Open(full)
}
// fileIndexer vertritt den Manticore-Suchindex: die einzige Manticore-
// Instanz auf dem Testhost wird mit dem Altsystem archivdms geteilt und
// bleibt fuer den Trockenlauf tabu.
type fileIndexer struct {
mu sync.Mutex
path string
docs map[string]search.Document
}
func openFileIndexer(path string) (*fileIndexer, error) {
idx := &fileIndexer{path: path, docs: map[string]search.Document{}}
if _, err := os.Stat(path); err == nil {
if err := readJSON(path, &idx.docs); err != nil {
return nil, err
}
}
return idx, nil
}
func (f *fileIndexer) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
d, ok := f.docs[messageID]
if ok && d.TenantSlug != tenantSlug {
return search.Document{}, false, nil
}
return d, ok, nil
}
func (f *fileIndexer) Index(_ context.Context, doc search.Document) error {
f.mu.Lock()
defer f.mu.Unlock()
f.docs[doc.MessageID] = doc
return writeJSON(f.path, f.docs)
}
type noopUsage struct{}
func (noopUsage) Report(context.Context, string, string, int64) error { return nil }
func (e *Env) mai02(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error {
legacy, err := e.Pool(ctx, e.Ctx.ArchivmailDB)
if err != nil {
return err
}
storeDir := e.TargetDir("mail", t.Slug)
if err := os.MkdirAll(storeDir, 0o700); err != nil {
return err
}
idx, err := openFileIndexer(filepath.Join(e.Ctx.RunDir, "ziel-objekte", "mail-index", t.Slug+".json"))
if err != nil {
return err
}
src := messageSource{legacy: legacy, tenantID: legacyTenantID, objectRoot: filepath.Join(e.Ctx.LegacyObjects, "archivmail")}
storageSvc := storage.NewService(storage.NewLocalDriver(storeDir), noopUsage{}, t.Slug)
out, err := messagemigration.NewMigrator(src, storageSvc, idx, t.Slug).MigrateAll(ctx)
if err != nil {
return err
}
res.add("nachrichten", out.MessagesMigrated)
res.add("nachrichten_uebersprungen", out.MessagesSkipped)
res.add("anhaenge", out.AttachmentsMigrated)
msgs, err := src.Messages(ctx)
if err != nil {
return err
}
var handoff []MessageHandoff
for _, m := range msgs {
h := MessageHandoff{
SourceMessageID: m.SourceMessageID,
ObjectReference: messagemigration.MessageID(t.Slug, m.SourceMessageID),
DataSubjectRef: strings.ToLower(m.Sender),
}
if err := legacy.QueryRow(ctx, `SELECT retain_until FROM emails WHERE id = $1`, m.SourceMessageID).Scan(&h.RetainUntil); err != nil {
return err
}
atts, err := src.Attachments(ctx, m.SourceMessageID)
if err != nil {
return err
}
for _, a := range atts {
h.Attachments = append(h.Attachments, AttachmentHandoff{
SourceObjectKey: "archivmail/" + a.ObjectKey,
StorageKey: storage.ObjectKey(h.ObjectReference, a.Index),
})
}
handoff = append(handoff, h)
}
res.add("uebergabe_objekte", len(handoff))
return writeJSON(filepath.Join(e.HandoffDir(t.Slug), "mail_objekte.json"), handoff)
}
+37
View File
@@ -0,0 +1,37 @@
// cutover-stage fuehrt eine workflow-Stufe des CUT-01-Trockenlaufs aus; Aufruf
// ausschliesslich durch cmd/cutover-dryrun im Root-Modul.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/cutoverstage"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "aufruf: cutover-stage <stufe>")
os.Exit(2)
}
env, err := cutoverstage.LoadEnv()
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
os.Exit(1)
}
defer env.Close()
res, err := cutoverstage.Run(context.Background(), env, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
if err := json.NewEncoder(os.Stdout).Encode(res); err != nil {
fmt.Fprintln(os.Stderr, "fehler:", err)
env.Close()
os.Exit(1)
}
}
+172
View File
@@ -0,0 +1,172 @@
// Package cutoverstage ist die Workflow-Modulstufe des CUT-01-Trockenlaufs
// (Orchestrator: internal/cutoverorchestrator im Root-Modul): Quell-Adapter
// gegen die synthetische archivdms-Testkopie fuer WFL-01, ohne eigene
// Migrationslogik.
package cutoverstage
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
type Tenant struct {
Slug string `json:"slug"`
NexarchTenantID string `json:"nexarch_tenant_id"`
ArchivmailTenantID *int64 `json:"archivmail_tenant_id"`
ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"`
TenantDB string `json:"tenant_db"`
ArchiveDB string `json:"archive_db"`
WorkflowDB string `json:"workflow_db"`
}
type Context struct {
RunTag string `json:"run_tag"`
RunDir string `json:"run_dir"`
RegistryDB string `json:"registry_db"`
ArchivmailDB string `json:"archivmail_db"`
ArchivdmsDB string `json:"archivdms_db"`
LegacyObjects string `json:"legacy_objects_dir"`
MigrationsDirs map[string]string `json:"migrations_dirs"`
Tenants []Tenant `json:"tenants"`
}
type Result struct {
Stage string `json:"stage"`
Counts map[string]int `json:"counts"`
Notes []string `json:"notes"`
}
func (r *Result) add(key string, n int) {
if r.Counts == nil {
r.Counts = map[string]int{}
}
r.Counts[key] += n
}
func (r *Result) note(format string, args ...any) {
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
type Env struct {
Ctx Context
adminDSN string
pools map[string]*pgxpool.Pool
}
func LoadEnv() (*Env, error) {
adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if adminDSN == "" {
return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt")
}
ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT")
if ctxPath == "" {
return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt")
}
raw, err := os.ReadFile(ctxPath)
if err != nil {
return nil, fmt.Errorf("laufkontext lesen: %w", err)
}
e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}}
if err := json.Unmarshal(raw, &e.Ctx); err != nil {
return nil, fmt.Errorf("laufkontext parsen: %w", err)
}
return e, nil
}
func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) {
if p, ok := e.pools[database]; ok {
return p, nil
}
cfg, err := pgxpool.ParseConfig(e.adminDSN)
if err != nil {
return nil, errors.New("admin-dsn nicht lesbar")
}
cfg.ConnConfig.Database = database
cfg.MaxConns = 4
p, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("verbindung zu %q: %w", database, err)
}
e.pools[database] = p
return p, nil
}
func (e *Env) Close() {
for _, p := range e.pools {
p.Close()
}
}
func (e *Env) HandoffDir(slug string) string {
return filepath.Join(e.Ctx.RunDir, "uebergabe", slug)
}
func (e *Env) TargetDir(kind, slug string) string {
return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug)
}
type registryUserResolver struct {
registry *pgxpool.Pool
tenantID string
}
func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) {
id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64)
if err != nil {
return "", fmt.Errorf("archivdms-benutzer-id %q ungueltig", sourceUserID)
}
var out string
err = r.registry.QueryRow(ctx, `
SELECT nexarch_user_id::text FROM user_migration_map
WHERE nexarch_tenant_id = $1 AND archivdms_user_id = $2 AND approved_at IS NOT NULL
`, r.tenantID, id).Scan(&out)
if err != nil {
return "", fmt.Errorf("archivdms-benutzer %d nicht in freigegebener user_migration_map: %w", id, err)
}
return out, nil
}
func (e *Env) userResolver(ctx context.Context, t Tenant) (registryUserResolver, error) {
reg, err := e.Pool(ctx, e.Ctx.RegistryDB)
if err != nil {
return registryUserResolver{}, err
}
return registryUserResolver{registry: reg, tenantID: t.NexarchTenantID}, nil
}
// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses.
func legacyPath(root, key string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(key))
rel, err := filepath.Rel(root, full)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key)
}
return full, nil
}
func writeJSON(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
+122
View File
@@ -0,0 +1,122 @@
package cutoverstage
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrationworkflow"
)
func Run(ctx context.Context, e *Env, stage string) (Result, error) {
res := Result{Stage: stage}
switch stage {
case "schema":
migrations, err := migrate.Load(e.Ctx.MigrationsDirs["workflow"])
if err != nil {
return res, err
}
for _, t := range e.Ctx.Tenants {
pool, err := e.Pool(ctx, t.WorkflowDB)
if err != nil {
return res, err
}
applied, err := migrate.Up(ctx, pool, migrations)
if err != nil {
return res, fmt.Errorf("mandant %s: workflow-migrationen: %w", t.Slug, err)
}
res.add("mandanten", 1)
res.add("workflow_migrationen_angewendet", len(applied))
}
return res, nil
case "wfl01":
for _, t := range e.Ctx.Tenants {
if t.ArchivdmsTenantID == nil {
res.note("%s: kein archivdms-mandant, stufe entfaellt", t.Slug)
continue
}
if err := e.wfl01(ctx, t, *t.ArchivdmsTenantID, &res); err != nil {
return res, fmt.Errorf("mandant %s: %w", t.Slug, err)
}
}
return res, nil
}
return res, fmt.Errorf("unbekannte workflow-stufe %q", stage)
}
// workflowSource: archivdms-Workflowlaeufe (workflow_runs) sind die Vorgaenge,
// ihre Statuswechsel (workflow_run_events) die Historie.
type workflowSource struct {
legacy *pgxpool.Pool
tenantID int64
}
func (s workflowSource) Cases(ctx context.Context) ([]migrationworkflow.SourceCase, error) {
rows, err := s.legacy.Query(ctx, `
SELECT r.id::text, w.name, COALESCE(r.final_state, ''), r.status
FROM workflow_runs r JOIN workflows w ON w.id = r.workflow_id
WHERE r.tenant_id = $1 ORDER BY r.id
`, s.tenantID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationworkflow.SourceCase, error) {
var c migrationworkflow.SourceCase
var status string
err := r.Scan(&c.SourceCaseID, &c.ProcessName, &c.FinalState, &status)
c.Status = migrationworkflow.Status(status)
return c, err
})
}
func (s workflowSource) History(ctx context.Context, sourceCaseID string) ([]migrationworkflow.SourceHistoryEntry, error) {
rows, err := s.legacy.Query(ctx, `
SELECT from_state, to_state, actor_user_id::text, note, occurred_at
FROM workflow_run_events WHERE run_id = $1::bigint ORDER BY occurred_at, id
`, sourceCaseID)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationworkflow.SourceHistoryEntry, error) {
var h migrationworkflow.SourceHistoryEntry
var at time.Time
err := r.Scan(&h.FromState, &h.ToState, &h.SourceActor, &h.Reason, &at)
h.OccurredAtRFC3339 = at.UTC().Format(time.RFC3339Nano)
return h, err
})
}
func (e *Env) wfl01(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error {
legacy, err := e.Pool(ctx, e.Ctx.ArchivdmsDB)
if err != nil {
return err
}
pool, err := e.Pool(ctx, t.WorkflowDB)
if err != nil {
return err
}
users, err := e.userResolver(ctx, t)
if err != nil {
return err
}
m := migrationworkflow.NewMigrator(pool, workflowSource{legacy: legacy, tenantID: legacyTenantID}, users)
out, err := m.MigrateCompleted(ctx)
if err != nil {
return err
}
open, err := m.OpenCases(ctx)
if err != nil {
return err
}
res.add("vorgaenge_migriert", out.InstancesMigrated)
res.add("historieneintraege", out.HistoryEntriesMigrated)
res.add("vorgaenge_offen_nicht_migriert", out.InstancesSkippedOpen)
if len(open) > 0 {
res.note("%s: %d laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", t.Slug, len(open))
}
return nil
}