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

Uebernahme freigegebener tenant_migration_map-Eintraege (MAP-02) als
Core-Tenants (TEN-01), tenants.id folgt der bereits freigegebenen
nexarch_tenant_id. Idempotent, kein Import ohne Freigabe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5 committed 2026-09-28 06:11:06 +00:00
1 parent ba14aaf0d2
commit 3903544d11
9 files changed
+525

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
// cor01-tenant-migrate fuehrt COR-01 aus: Uebernahme der freigegebenen
// tenant_migration_map (MAP-02) in Core TEN-01. Einmalig wiederholbar
// gegen dieselbe Quelle (siehe internal/migrationtenant).
package main
import (
"context"
"log"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationtenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func main() {
registryDSN := os.Getenv("NEXARCH_REGISTRY_DSN")
if registryDSN == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
if adminDSN == "" {
log.Fatal("NEXARCH_ADMIN_DSN nicht gesetzt")
}
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
if dsnTemplate == "" {
log.Fatal("NEXARCH_TENANT_DSN_TEMPLATE nicht gesetzt")
}
ctx := context.Background()
registryPool, err := db.Connect(ctx, registryDSN)
if err != nil {
log.Fatalf("registry-verbindung: %v", err)
}
defer registryPool.Close()
adminPool, err := db.Connect(ctx, adminDSN)
if err != nil {
log.Fatalf("admin-verbindung: %v", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
mapStore := migrationtenant.NewMapStore(registryPool)
migrator := migrationtenant.NewMigrator(mapStore, provisioner)
res, err := migrator.MigrateApproved(ctx)
if err != nil {
log.Fatalf("COR-01-migration fehlgeschlagen: %v", err)
}
log.Printf("COR-01: %d freigegebene Tenants uebernommen", len(res.Migrated))
}
+65
View File
@@ -0,0 +1,65 @@
# COR-01 — Prüfprotokoll: Tenant-Datenmodell-Migration
Datum: 2026-09-28
Testhost: root@192.168.1.204 (Wegwerf-Postgres-Cluster, Ports 5432/5433 wie von bestehenden `internal/tenant`-Integrationstests verwendet, `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, kein Bezug zu `dms_dev`/`archivdms`)
## Synthetische Testdaten — Substitutionsentscheidung
COR-01 selbst liest keine archivmail-/archivdms-Exportdumps: laut Auftrag übernimmt die Kachel die bereits **zusammengeführten** Tenant-Datensätze aus der freigegebenen `tenant_migration_map` (Ergebnis von MAP-01/MAP-02) — nicht die Rohdaten der Altsysteme. Für die Tests wurden ausschließlich frei erfundene Map-Einträge verwendet (Slugs wie `synth_...`, Namen wie „Synthetische Test GmbH“, zufällige `archivmail_tenant_id`), niemals echte Kunden-/Mandantendaten aus archivmail oder archivdms. Diese Substitution ist hier nur der Vollständigkeit halber dokumentiert (Pattern wie bei M365-01) — im konkreten Fall COR-01 gab es keine reale Exportdatei zu ersetzen.
## Ausgangslage geprüft
- `MAP-02` (Tenant-Migration-Map & Freigabe-Workflow) ist laut Kanban-Board als Vorbedingung erfüllt markiert, im Code des `nexarch`-Repos existierte zum Start dieser Kachel jedoch **keine** `tenant_migration_map`-Tabelle und kein Freigabe-Workflow (COR-01 ist die erste tatsächlich umgesetzte Kachel des migration-kanban-Boards). Entscheidung: COR-01 legt die von MAP-02 spezifizierte Referenztabelle `tenant_migration_map` (Spalten `archivmail_tenant_id`, `archivdms_tenant_id`, `nexarch_tenant_id`, plus `slug`/`name`/`approved_by`/`approved_at`) als minimalen Datenbank-Stub selbst an (`migrations/0019_tenant_migration_map.up/down.sql`), OHNE die eigentliche Freigabe-Oberfläche/den Freigabe-Workflow zu bauen — das bleibt ausdrücklich MAP-02-Scope. COR-01 liest ausschließlich Zeilen mit gesetztem `approved_at`.
- Core `TEN-01` (`internal/tenant`) ist vollständig implementiert: `Registry` (Control-Plane-Tabelle `tenants`), `Provisioner.Provision` (legt isolierte Tenant-Datenbank an, generiert dabei aber intern eine neue UUID über `gen_random_uuid()`). Für COR-01 muss die `tenants.id` exakt der bereits freigegebenen `nexarch_tenant_id` aus der Map entsprechen, da alle nachgelagerten Migrationsschritte (DMS, Mail, Archive, Workflow) diese ID zum Umschreiben von `tenant_id`-Fremdschlüsseln verwenden.
## Umsetzung
- `internal/tenant/registry.go`: additiv `GetByID` (liest per ID statt Slug) und `insertTxWithID` (Insert mit vorgegebener ID statt `gen_random_uuid()`) ergänzt — bestehende Signaturen unverändert.
- `internal/tenant/provisioner.go`: additiv `Provisioner.ProvisionWithID(ctx, id, slug, name)` ergänzt, Kopie der bewährten `Provision`-Logik (CREATE DATABASE, REVOKE CONNECT FROM PUBLIC, transaktionale Registrierung, Rollback bei Fehler), aber mit vorgegebener ID. Idempotent: existiert bereits ein Tenant mit dieser ID, wird er unverändert zurückgegeben statt eine zweite Datenbank anzulegen.
- Neues Paket `internal/migrationtenant`: `MapStore.Approved` liest nur freigegebene Map-Einträge; `Migrator.MigrateApproved` übernimmt jeden freigegebenen Eintrag über `ProvisionWithID`.
- Neuer CLI-Einstiegspunkt `cmd/cor01-tenant-migrate` (liest `NEXARCH_REGISTRY_DSN`, `NEXARCH_ADMIN_DSN`, `NEXARCH_TENANT_DSN_TEMPLATE` aus Umgebungsvariablen, keine Zugangsdaten im Code).
- Migration `migrations/0019_tenant_migration_map.up/down.sql`.
## Akzeptanzkriterien
| # | Kriterium | Ergebnis |
|---|---|---|
| 1 | Jeder freigegebene Tenant existiert nach der Migration genau einmal in Core | Erfüllt — `TestMigrateApproved` legt 5 freigegebene, synthetische Map-Einträge an, `MigrateApproved` übernimmt alle 5 in `tenants` |
| 2 | Keine Tenant-Daten ohne Eintrag in der freigegebenen Tenant-Map werden übernommen | Erfüllt — ein zusätzlicher, bewusst NICHT freigegebener Map-Eintrag (`approved_at IS NULL`) bleibt nach dem Lauf nachweislich ohne `tenants`-Eintrag (`ErrTenantNotFound`) |
| 3 | Migration ist wiederholbar (idempotent) gegen dieselbe Quelle | Erfüllt — zweiter `MigrateApproved`-Lauf gegen dieselbe Map erzeugt keine zusätzlichen Zeilen (`SELECT count(*) ... = 1` je Tenant) |
## Prüfungen vor der Abnahme
1. **Zeilenzahl migrierter Tenants entspricht Anzahl freigegebener Map-Einträge.**
Zielwert: 5 freigegebene Einträge → 5 migrierte Tenants, 0 für den unfreigegebenen Eintrag.
Ergebnis: erreicht — `TestMigrateApproved`, `len(res.Migrated) == 5`, unfreigegebener Eintrag liefert `ErrTenantNotFound`.
2. **Erneuter Lauf gegen bereits migrierte Daten erzeugt keine Duplikate.**
Zielwert: zweiter Lauf liefert weiterhin genau 5 Tenants, keine zusätzlichen Zeilen je `tenant_migration_map`-Eintrag.
Ergebnis: erreicht — zweiter `MigrateApproved`-Aufruf, `count(*) == 1` je Tenant-ID nach dem zweiten Lauf.
3. **Stichprobe von 5 Tenants auf korrekt umgeschriebene tenant_id geprüft.**
Zielwert: `tenants.id` nach Migration entspricht exakt `tenant_migration_map.nexarch_tenant_id` je Eintrag.
Ergebnis: erreicht — für alle 5 synthetischen Einträge stimmen `id` und `slug` nach `Registry.GetByID` exakt mit der Map-Vorgabe überein.
Zusätzlich (Regression/Build):
```
go build ./... -> keine Fehler
go vet ./... -> keine Befunde
TEST_ADMIN_DSN=... go test ./internal/tenant/... ./internal/migrationtenant/... -v
-> alle Tests PASS (38 in internal/tenant, 1 neu in internal/migrationtenant),
keine Regression in bestehenden TEN-01/TEN-02/TEN-04/TEN-06/TEN-08/TEN-09-Tests
Migration 0019 gegen leere Wegwerf-DB (alle 19 Migrationen in Reihenfolge) angewendet und zurückgerollt -> APPLY_OK, ROLLBACK_OK
```
## Entscheidungen (dokumentiert, keine Rückfrage nötig)
- `tenant_migration_map` als minimaler Stub in COR-01 selbst angelegt, da MAP-02 im Code noch nicht existiert, obwohl das Ticket es als Vorbedingung voraussetzt. Nur die von MAP-02 im Auftragstext genannten Spalten plus die für Freigabe-Filterung nötigen `approved_by`/`approved_at` — keine Freigabe-Oberfläche, kein Korrektur-Workflow, kein Freigabe-Audit-Log (das bleibt MAP-02-Scope und muss dort nachgezogen werden, inklusive Rückwirkung auf diese Tabelle, falls MAP-02 ein abweichendes Schema wählt).
- `ProvisionWithID` als additive Methode in `internal/tenant/provisioner.go` statt eigener DB-Anlage-Logik in einem neuen Paket dupliziert: folgt der Anweisung „lies den bestehenden Code der Vorbedingungen, halte dich an etablierten Mustern“ und vermeidet zwei parallele Implementierungen derselben sicherheitsrelevanten Schritte (CREATE DATABASE, REVOKE CONNECT FROM PUBLIC).
- Kein Einlesen von rohen archivmail-/archivdms-Exportdumps in COR-01 — bewusst, weil der Ticket-Auftrag ausdrücklich die *zusammengeführten* Datensätze aus der Tenant-Map verlangt, nicht die Altsystem-Rohdaten (das ist MAP-01-Scope).
## Offene Punkte für die echte Cutover-Vorbereitung (keine Produkt-/Entscheidungsfrage für COR-01 selbst)
- MAP-02 muss noch real gebaut werden (Freigabe-Oberfläche, Freigabe-Protokoll mit Zeitpunkt/Person, Korrekturmöglichkeit). Sobald MAP-02 existiert, sollte geprüft werden, ob dessen Schema exakt zu `migrations/0019_tenant_migration_map` passt oder ob COR-01s Stub-Tabelle angepasst/ersetzt werden muss.
- Vor dem echten Stichtag muss geklärt sein, wer `approved_by`/`approved_at` tatsächlich befüllt (Geschäftsseite laut MIGRATIONSKONZEPT.md Abschnitt 8) — für COR-01 selbst ist das nur eine gelesene Spalte, keine Entscheidung dieser Kachel.
- COR-01 provisioniert bei jedem freigegebenen Tenant eine vollständig neue, leere Tenant-Datenbank (wie TEN-01/TEN-02 es für Neuanlagen tun) — die eigentliche Datenübernahme der Fachdaten (Dokumente, Mails, Benutzer, Audit-Log) in diese Datenbank ist ausdrücklich nicht Teil von COR-01 (siehe „Nicht Bestandteil dieser Kachel“) und folgt in COR-02 ff.
+57
View File
@@ -0,0 +1,57 @@
// Package migrationtenant implementiert COR-01: die Uebernahme
// zusammengefuehrter Tenant-Datensaetze aus der freigegebenen
// tenant_migration_map (MAP-02) in Core TEN-01.
package migrationtenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// MapEntry ist eine Zeile der tenant_migration_map.
type MapEntry struct {
ID int64
ArchivmailTenantID *int64
ArchivdmsTenantID *int64
NexarchTenantID string
Slug string
Name string
ApprovedBy *string
}
// MapStore kapselt den lesenden Zugriff auf tenant_migration_map in der
// Registry-Datenbank.
type MapStore struct {
pool *pgxpool.Pool
}
func NewMapStore(pool *pgxpool.Pool) *MapStore {
return &MapStore{pool: pool}
}
// Approved liefert ausschliesslich Zeilen mit gesetztem approved_at
// (Akzeptanzkriterium 2: kein Tenant ohne Freigabe wird uebernommen).
func (s *MapStore) Approved(ctx context.Context) ([]MapEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, archivmail_tenant_id, archivdms_tenant_id, nexarch_tenant_id, slug, name, approved_by
FROM tenant_migration_map
WHERE approved_at IS NOT NULL
ORDER BY id
`)
if err != nil {
return nil, fmt.Errorf("freigegebene tenant-map lesen: %w", err)
}
defer rows.Close()
var out []MapEntry
for rows.Next() {
var e MapEntry
if err := rows.Scan(&e.ID, &e.ArchivmailTenantID, &e.ArchivdmsTenantID, &e.NexarchTenantID, &e.Slug, &e.Name, &e.ApprovedBy); err != nil {
return nil, fmt.Errorf("tenant-map-eintrag lesen: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
+45
View File
@@ -0,0 +1,45 @@
package migrationtenant
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
// Migrator uebernimmt jeden freigegebenen tenant_migration_map-Eintrag als
// Core-Tenant, mit der nexarch_tenant_id aus der Map als tenants.id.
type Migrator struct {
mapStore *MapStore
provisioner *tenant.Provisioner
}
func NewMigrator(mapStore *MapStore, provisioner *tenant.Provisioner) *Migrator {
return &Migrator{mapStore: mapStore, provisioner: provisioner}
}
// Result fasst den Ausgang eines Migrationslaufs zusammen.
type Result struct {
Migrated []tenant.Tenant
}
// MigrateApproved liest die freigegebene Tenant-Map und uebernimmt jeden
// Eintrag genau einmal in Core (Akzeptanzkriterium 1). Ein erneuter Lauf
// gegen dieselbe Quelle erzeugt keine Duplikate, da ProvisionWithID bei
// bereits existierender ID unveraendert zurueckkehrt (Akzeptanzkriterium 3).
func (m *Migrator) MigrateApproved(ctx context.Context) (Result, error) {
entries, err := m.mapStore.Approved(ctx)
if err != nil {
return Result{}, err
}
var res Result
for _, e := range entries {
t, err := m.provisioner.ProvisionWithID(ctx, e.NexarchTenantID, e.Slug, e.Name)
if err != nil {
return res, fmt.Errorf("tenant %q (map-eintrag %d) uebernehmen: %w", e.Slug, e.ID, err)
}
res.Migrated = append(res.Migrated, t)
}
return res, nil
}
+180
View File
@@ -0,0 +1,180 @@
package migrationtenant
import (
"context"
"crypto/rand"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
)
// Integrationstest gegen eine echte Postgres-Instanz mit synthetischen
// Tenant-Map-Eintraegen (fiktive Slugs/Namen, keine echten archivmail-/
// archivdms-Kundendaten) — siehe Prüfprotokoll COR-01 fuer die
// dokumentierte Begruendung. Ohne TEST_ADMIN_DSN uebersprungen.
func setupSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
previous_status TEXT,
deletion_scheduled_at TIMESTAMPTZ,
retention_block_reason TEXT,
retention_checked_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS tenant_migration_map (
id BIGSERIAL PRIMARY KEY,
archivmail_tenant_id BIGINT,
archivdms_tenant_id BIGINT,
nexarch_tenant_id UUID NOT NULL UNIQUE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
approved_by TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tenant_migration_map_source_present
CHECK (archivmail_tenant_id IS NOT NULL OR archivdms_tenant_id IS NOT NULL)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
}
func TestMigrateApproved(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
registryPool := testdb.Pool(t, adminDSN)
defer registryPool.Close()
setupSchema(t, ctx, registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
mapStore := NewMapStore(registryPool)
migrator := NewMigrator(mapStore, provisioner)
// Fuenf synthetische, freigegebene Tenant-Zusammenfuehrungen und ein
// bewusst NICHT freigegebener Eintrag (Akzeptanzkriterium 2).
type synthEntry struct {
id string
slug string
name string
approved bool
}
suffix := testdb.Suffix()
entries := make([]synthEntry, 0, 6)
for i := 1; i <= 5; i++ {
entries = append(entries, synthEntry{
id: newUUIDv4(),
slug: testdb.Slug("synth"),
name: "Synthetische Test GmbH " + suffix,
approved: true,
})
}
unapproved := synthEntry{id: newUUIDv4(), slug: testdb.Slug("unapp"), name: "Unfreigegeben " + suffix, approved: false}
entries = append(entries, unapproved)
for i, e := range entries {
approvedAt := "NULL"
approvedBy := "NULL"
if e.approved {
approvedAt = "now()"
approvedBy = "'qa-tester'"
}
_, err := registryPool.Exec(ctx, `
INSERT INTO tenant_migration_map (archivmail_tenant_id, archivdms_tenant_id, nexarch_tenant_id, slug, name, approved_by, approved_at)
VALUES ($1, NULL, $2, $3, $4, `+approvedBy+`, `+approvedAt+`)
`, int64(1000+i), e.id, e.slug, e.name)
if err != nil {
t.Fatalf("map-eintrag %d anlegen: %v", i, err)
}
}
t.Cleanup(func() {
for _, e := range entries {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, e.slug)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_`+e.slug)
}
})
// AC 1: genau ein Lauf uebernimmt jeden freigegebenen Tenant.
res, err := migrator.MigrateApproved(ctx)
if err != nil {
t.Fatalf("MigrateApproved: %v", err)
}
if len(res.Migrated) != 5 {
t.Fatalf("erwarte 5 migrierte Tenants, habe %d", len(res.Migrated))
}
// AC 2: der unfreigegebene Eintrag wurde nicht uebernommen.
if _, err := registry.GetByID(ctx, unapproved.id); err != tenant.ErrTenantNotFound {
t.Fatalf("unfreigegebener tenant %q haette nicht angelegt werden duerfen, err=%v", unapproved.id, err)
}
// Stichprobe: tenant_id in Core entspricht exakt der Map-Vorgabe.
for _, e := range entries[:5] {
got, err := registry.GetByID(ctx, e.id)
if err != nil {
t.Fatalf("tenant %q nach migration nicht gefunden: %v", e.id, err)
}
if got.ID != e.id || got.Slug != e.slug {
t.Fatalf("tenant_id nicht korrekt umgeschrieben: erwartet id=%s slug=%s, habe id=%s slug=%s", e.id, e.slug, got.ID, got.Slug)
}
}
// AC 3: erneuter Lauf gegen dieselbe (bereits migrierte) Quelle erzeugt
// keine Duplikate.
res2, err := migrator.MigrateApproved(ctx)
if err != nil {
t.Fatalf("zweiter MigrateApproved-Lauf: %v", err)
}
if len(res2.Migrated) != 5 {
t.Fatalf("erwarte weiterhin 5 Tenants nach erneutem Lauf, habe %d", len(res2.Migrated))
}
for _, e := range entries[:5] {
var count int
if err := registryPool.QueryRow(ctx, `SELECT count(*) FROM tenants WHERE id = $1`, e.id).Scan(&count); err != nil {
t.Fatalf("duplikat-pruefung: %v", err)
}
if count != 1 {
t.Fatalf("tenant %q ist nach erneutem Lauf %d mal vorhanden, erwartet 1", e.id, count)
}
}
}
// newUUIDv4 erzeugt eine zufaellige UUIDv4 fuer synthetische Testdaten, ohne
// eine zusaetzliche Abhaengigkeit einzufuehren (Postgres selbst nutzt
// gen_random_uuid() in Produktionscode ueber ProvisionWithID / die
// tenant_migration_map).
func newUUIDv4() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
+66
View File
@@ -119,3 +119,69 @@ func (p *Provisioner) Deprovision(ctx context.Context, t Tenant) error {
p.rollbackDatabase(ctx, t.DBName)
return nil
}
// ProvisionWithID wie Provision, uebernimmt aber eine vorgegebene ID statt
// eine neue zu erzeugen (COR-01: die ID ist bereits durch die freigegebene
// tenant_migration_map festgelegt). Idempotent: existiert bereits ein Tenant
// mit dieser ID, wird er unveraendert zurueckgegeben, statt eine zweite
// Datenbank anzulegen — notwendig, damit ein wiederholter Migrationslauf
// gegen dieselbe Quelle keine Duplikate erzeugt.
func (p *Provisioner) ProvisionWithID(ctx context.Context, id, slug, name string) (Tenant, error) {
if err := ValidateSlug(slug); err != nil {
return Tenant{}, err
}
if existing, err := p.registry.GetByID(ctx, id); err == nil {
return existing, nil
} else if !errors.Is(err, ErrTenantNotFound) {
return Tenant{}, err
}
dbName := dbNameForSlug(slug)
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "42P04" || pgErr.Code == "23505") {
return Tenant{}, ErrTenantExists
}
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
}
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`REVOKE CONNECT ON DATABASE %q FROM PUBLIC`, dbName)); err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("tenant-datenbank isolieren (revoke connect from public): %w", err)
}
t := Tenant{
ID: id,
Slug: slug,
Name: name,
DBName: dbName,
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
Status: StatusActive,
}
tx, err := p.registry.pool.Begin(ctx)
if err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
}
created, err := p.registry.insertTxWithID(ctx, tx, t)
if err != nil {
_ = tx.Rollback(ctx)
p.rollbackDatabase(ctx, dbName)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return Tenant{}, ErrTenantExists
}
return Tenant{}, err
}
if err := tx.Commit(ctx); err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
}
return created, nil
}
+39
View File
@@ -2,6 +2,7 @@ package tenant
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
@@ -130,3 +131,41 @@ func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
}
return out, rows.Err()
}
// GetByID laedt einen Tenant ueber seine Registry-ID (statt Slug) — genutzt
// von COR-01, wo die Ziel-ID bereits durch die freigegebene
// tenant_migration_map vorgegeben ist, nicht durch den Slug.
func (r *Registry) GetByID(ctx context.Context, id string) (Tenant, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at,
retention_block_reason, retention_checked_at
FROM tenants WHERE id = $1
`, id)
t, err := scanTenantWithLifecycle(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Tenant{}, ErrTenantNotFound
}
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
}
return t, nil
}
// insertTxWithID wie insertTx, uebernimmt aber eine vorgegebene ID statt sie
// per gen_random_uuid() erzeugen zu lassen — COR-01 muss die tenants.id mit
// der bereits freigegebenen nexarch_tenant_id aus der Tenant-Map uebereinstimmen
// lassen, da alle Folgeschritte diese ID zum Umschreiben von tenant_id-Fremdschluesseln
// verwenden.
func (r *Registry) insertTxWithID(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
row := tx.QueryRow(ctx, `
INSERT INTO tenants (id, slug, name, db_name, db_dsn, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at
`, t.ID, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
}
return t, nil
}
@@ -0,0 +1 @@
DROP TABLE tenant_migration_map;
@@ -0,0 +1,17 @@
-- COR-01: Referenztabelle fuer freigegebene Tenant-Zusammenfuehrungen
-- (MAP-02). Nur genehmigte Zeilen (approved_at gesetzt) werden von COR-01
-- und allen nachgelagerten Migrationsschritten zum Umschreiben von
-- tenant_id-Fremdschluesseln verwendet.
CREATE TABLE tenant_migration_map (
id BIGSERIAL PRIMARY KEY,
archivmail_tenant_id BIGINT,
archivdms_tenant_id BIGINT,
nexarch_tenant_id UUID NOT NULL UNIQUE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
approved_by TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tenant_migration_map_source_present
CHECK (archivmail_tenant_id IS NOT NULL OR archivdms_tenant_id IS NOT NULL)
);