Files
patrickandClaude Sonnet 5 8f75ab3b37 CUT-01: trockenlauf-1-entwicklung - orchestrator erster entwurf
Orchestrator (internal/cutoverorchestrator, cmd/cutover-dryrun) fuer den
Trockenlauf aller Migrationspakete gegen eine vollstaendig synthetische
Testkopie: Ablaufplan mit Abhaengigkeitspruefung, Abbruch beim ersten
Fehler, JSON-/Textprotokoll mit Redigierung von Zugangsdaten, Generator
und Anonymisierungsnachweis der Testkopie.

Modulstufen (dms/mail/archive/workflow cmd/cutover-stage) liefern die
Quell-Adapter und rufen die bestehenden Migratoren der jeweiligen Module
auf, da deren internal-Pakete aus dem Root-Modul nicht importierbar sind.
migrationdocument.DocumentID macht die DOK-01-Ziel-ID fuer TAR-01/TAR-02
im Archive-Modul verfuegbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 20:45:03 +00:00

218 lines
8.1 KiB
Go

// Package migrationdocument implementiert DOK-01: Uebernahme aller
// Dokumente und Datei-Revisionen aus archivdms in das DMS-Zielschema
// (dms/migrations/tenant/0001_documents.up.sql) — Binaerdaten per
// Objekt-Storage-Kopie ueber dieselbe storage.Service/crypto.Service-
// Kette wie der reguläre Upload-Pfad (dms/internal/upload), Metadaten per
// SQL-Export/Import ueber Source.
package migrationdocument
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
// SourceDocument ist eine Zeile aus archivdms' Dokumenttabelle (SQL-Export).
type SourceDocument struct {
SourceDocumentID string
Title string
SourceUserID string
}
// SourceRevision ist eine Zeile aus archivdms' Revisionstabelle, ObjectKey
// verweist auf die Binaerdatei im Quell-Objekt-Storage.
type SourceRevision struct {
SourceRevisionID string
SourceDocumentID string
RevisionNumber int
ChecksumSHA256 string
SizeBytes int64
MimeType string
SourceUserID string
ObjectKey string
}
// Source kapselt den lesenden Zugriff auf archivdms: Metadaten per
// SQL-Export (Documents/Revisions), Binaerdaten per Objekt-Storage-Kopie
// (OpenObject). Eine echte Implementierung liest gegen archivdms, Tests
// verwenden ausschliesslich synthetische Fixtures.
type Source interface {
Documents(ctx context.Context) ([]SourceDocument, error)
Revisions(ctx context.Context, sourceDocumentID string) ([]SourceRevision, error)
OpenObject(ctx context.Context, objectKey string) (io.ReadCloser, error)
}
// UserResolver bildet eine archivdms-Benutzer-ID auf die bereits migrierte
// NEXARCH-Benutzer-ID ab (users.id, siehe Voraussetzung in COR-01). ACL-/
// Benutzermigration selbst ist nicht Bestandteil von DOK-01.
type UserResolver interface {
ResolveUserID(ctx context.Context, sourceUserID string) (string, error)
}
// ErrChecksumMismatch meldet eine Abweichung zwischen der beim Kopieren
// tatsaechlich berechneten Pruefsumme und der von Source gelieferten
// Metadaten-Pruefsumme (Pruefung 1: Hash-Vergleich gegen Quelle).
var ErrChecksumMismatch = errors.New("migrationdocument: pruefsumme weicht von quelle ab")
// Migrator uebertraegt Dokumente+Revisionen einer Quelle in genau einen
// Ziel-Tenant (eine physische Tenant-DB, Modell C).
type Migrator struct {
pool *pgxpool.Pool
source Source
users UserResolver
storageSvc *storage.Service
cryptoSvc *dmscrypto.Service
tenantSlug string
}
func NewMigrator(pool *pgxpool.Pool, source Source, users UserResolver, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string) *Migrator {
return &Migrator{pool: pool, source: source, users: users, storageSvc: storageSvc, cryptoSvc: cryptoSvc, tenantSlug: tenantSlug}
}
// Result fasst den Ausgang eines Migrationslaufs zusammen.
type Result struct {
DocumentsMigrated int
RevisionsMigrated int
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 —
// ein erneuter Lauf gegen dieselbe Quelle trifft so IMMER dieselben Zeilen
// und erzeugt keine Duplikate (Akzeptanzkriterium 3), ganz ohne eine
// zusaetzliche Mapping-Tabelle.
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
var res Result
docs, err := m.source.Documents(ctx)
if err != nil {
return res, fmt.Errorf("migrationdocument: dokumente aus quelle lesen: %w", err)
}
for _, doc := range docs {
if err := m.migrateDocument(ctx, doc, &res); err != nil {
return res, err
}
}
return res, nil
}
func (m *Migrator) migrateDocument(ctx context.Context, doc SourceDocument, res *Result) error {
documentID := deriveUUID("document", m.tenantSlug, doc.SourceDocumentID)
createdBy, err := m.users.ResolveUserID(ctx, doc.SourceUserID)
if err != nil {
return fmt.Errorf("migrationdocument: ersteller fuer dokument %q aufloesen: %w", doc.SourceDocumentID, err)
}
tag, err := m.pool.Exec(ctx, `
INSERT INTO documents (id, title, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING
`, documentID, doc.Title, createdBy)
if err != nil {
return fmt.Errorf("migrationdocument: dokument %q anlegen: %w", doc.SourceDocumentID, err)
}
if tag.RowsAffected() > 0 {
res.DocumentsMigrated++
}
revisions, err := m.source.Revisions(ctx, doc.SourceDocumentID)
if err != nil {
return fmt.Errorf("migrationdocument: revisionen fuer dokument %q lesen: %w", doc.SourceDocumentID, err)
}
var latestRevisionID string
var latestRevisionNumber int = -1
for _, rev := range revisions {
revisionID, err := m.migrateRevision(ctx, documentID, rev, res)
if err != nil {
return err
}
if rev.RevisionNumber > latestRevisionNumber {
latestRevisionNumber = rev.RevisionNumber
latestRevisionID = revisionID
}
}
if latestRevisionID != "" {
if _, err := m.pool.Exec(ctx, `
UPDATE documents SET current_revision_id = $2 WHERE id = $1 AND current_revision_id IS DISTINCT FROM $2
`, documentID, latestRevisionID); err != nil {
return fmt.Errorf("migrationdocument: aktuelle revision fuer dokument %q setzen: %w", doc.SourceDocumentID, err)
}
}
return nil
}
func (m *Migrator) migrateRevision(ctx context.Context, documentID string, rev SourceRevision, res *Result) (revisionID string, err error) {
revisionID = deriveUUID("revision", m.tenantSlug, rev.SourceRevisionID)
var existing bool
if err := m.pool.QueryRow(ctx, `SELECT true FROM file_revisions WHERE id = $1`, revisionID).Scan(&existing); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", fmt.Errorf("migrationdocument: bestand von revision %q pruefen: %w", rev.SourceRevisionID, err)
}
if existing {
res.RevisionsSkipped++
return revisionID, nil
}
createdBy, err := m.users.ResolveUserID(ctx, rev.SourceUserID)
if err != nil {
return "", fmt.Errorf("migrationdocument: ersteller fuer revision %q aufloesen: %w", rev.SourceRevisionID, err)
}
plaintext, err := m.source.OpenObject(ctx, rev.ObjectKey)
if err != nil {
return "", fmt.Errorf("migrationdocument: binaerobjekt fuer revision %q oeffnen: %w", rev.SourceRevisionID, err)
}
defer func() { _ = plaintext.Close() }()
hasher := sha256.New()
tee := io.TeeReader(plaintext, hasher)
env, err := m.cryptoSvc.Seal(ctx, m.tenantSlug, tee)
if err != nil {
return "", fmt.Errorf("migrationdocument: revision %q verschluesseln: %w", rev.SourceRevisionID, err)
}
ciphertext, err := io.ReadAll(env.Ciphertext)
if err != nil {
return "", fmt.Errorf("migrationdocument: chiffretext fuer revision %q lesen: %w", rev.SourceRevisionID, err)
}
checksum := fmt.Sprintf("%x", hasher.Sum(nil))
if checksum != rev.ChecksumSHA256 {
return "", fmt.Errorf("%w: revision %q (quelle %s, kopiert %s)", ErrChecksumMismatch, rev.SourceRevisionID, rev.ChecksumSHA256, checksum)
}
storageKey := storage.ObjectKey(documentID, revisionID)
if _, err := m.storageSvc.Put(ctx, storageKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil {
return "", fmt.Errorf("migrationdocument: binaerobjekt fuer revision %q ablegen: %w", rev.SourceRevisionID, err)
}
if _, err := m.pool.Exec(ctx, `
INSERT INTO file_revisions (id, document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by, wrapped_dek)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO NOTHING
`, revisionID, documentID, rev.RevisionNumber, storageKey, checksum, rev.SizeBytes, rev.MimeType, createdBy, env.WrappedDEK); err != nil {
return "", fmt.Errorf("migrationdocument: revision %q anlegen: %w", rev.SourceRevisionID, err)
}
res.RevisionsMigrated++
return revisionID, nil
}