CUT-01: Trockenlauf 1 - Entwicklung #276
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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 | - |
|
||||
+11
@@ -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.
|
||||
+14
@@ -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.
|
||||
+14
@@ -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 | - |
|
||||
+11
@@ -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.
|
||||
+14
@@ -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.
|
||||
+14
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, " | ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user