diff --git a/cmd/cor01-tenant-migrate/main.go b/cmd/cor01-tenant-migrate/main.go new file mode 100644 index 00000000..286b0772 --- /dev/null +++ b/cmd/cor01-tenant-migrate/main.go @@ -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)) +} diff --git a/docs/COR-01-PRUEFPROTOKOLL.md b/docs/COR-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..240158a9 --- /dev/null +++ b/docs/COR-01-PRUEFPROTOKOLL.md @@ -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. diff --git a/internal/migrationtenant/map.go b/internal/migrationtenant/map.go new file mode 100644 index 00000000..68baa804 --- /dev/null +++ b/internal/migrationtenant/map.go @@ -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() +} diff --git a/internal/migrationtenant/migrate.go b/internal/migrationtenant/migrate.go new file mode 100644 index 00000000..080f7d49 --- /dev/null +++ b/internal/migrationtenant/migrate.go @@ -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 +} diff --git a/internal/migrationtenant/migrate_test.go b/internal/migrationtenant/migrate_test.go new file mode 100644 index 00000000..6439e990 --- /dev/null +++ b/internal/migrationtenant/migrate_test.go @@ -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]) +} diff --git a/internal/tenant/provisioner.go b/internal/tenant/provisioner.go index f53a2d05..9e21709a 100644 --- a/internal/tenant/provisioner.go +++ b/internal/tenant/provisioner.go @@ -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 +} diff --git a/internal/tenant/registry.go b/internal/tenant/registry.go index 0be8c654..6bc31c1b 100644 --- a/internal/tenant/registry.go +++ b/internal/tenant/registry.go @@ -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 +} diff --git a/migrations/0019_tenant_migration_map.down.sql b/migrations/0019_tenant_migration_map.down.sql new file mode 100644 index 00000000..80940347 --- /dev/null +++ b/migrations/0019_tenant_migration_map.down.sql @@ -0,0 +1 @@ +DROP TABLE tenant_migration_map; diff --git a/migrations/0019_tenant_migration_map.up.sql b/migrations/0019_tenant_migration_map.up.sql new file mode 100644 index 00000000..4116dfa0 --- /dev/null +++ b/migrations/0019_tenant_migration_map.up.sql @@ -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) +);