Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3376cf99dc | ||
|
|
b12d53f469 |
@@ -0,0 +1,96 @@
|
|||||||
|
// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen
|
||||||
|
// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und
|
||||||
|
// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant).
|
||||||
|
// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03
|
||||||
|
// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen
|
||||||
|
// Schreibpfad (Akzeptanzkriterium 3).
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende
|
||||||
|
// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein
|
||||||
|
// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2).
|
||||||
|
const SystemTenant = "system"
|
||||||
|
|
||||||
|
var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein")
|
||||||
|
var ErrMissingActor = errors.New("audit: actor darf nicht leer sein")
|
||||||
|
var ErrMissingAction = errors.New("audit: action darf nicht leer sein")
|
||||||
|
|
||||||
|
// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur,
|
||||||
|
// Aktion, Zielobjekt, Zeitpunkt, Tenant).
|
||||||
|
type Event struct {
|
||||||
|
TenantSlug string
|
||||||
|
Actor string
|
||||||
|
Action string
|
||||||
|
Target string
|
||||||
|
Metadata map[string]any
|
||||||
|
OccurredAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt
|
||||||
|
// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich
|
||||||
|
// direkt in audit_events schreibt und dabei die Validierung umgeht
|
||||||
|
// (Akzeptanzkriterium 3).
|
||||||
|
type Log struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLog(pool *pgxpool.Pool) *Log {
|
||||||
|
return &Log{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird
|
||||||
|
// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im
|
||||||
|
// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die
|
||||||
|
// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2).
|
||||||
|
func (l *Log) Record(ctx context.Context, e Event) error {
|
||||||
|
if e.TenantSlug == "" {
|
||||||
|
return ErrMissingTenant
|
||||||
|
}
|
||||||
|
if e.Actor == "" {
|
||||||
|
return ErrMissingActor
|
||||||
|
}
|
||||||
|
if e.Action == "" {
|
||||||
|
return ErrMissingAction
|
||||||
|
}
|
||||||
|
if e.Metadata == nil {
|
||||||
|
e.Metadata = map[string]any{}
|
||||||
|
}
|
||||||
|
metadataJSON, err := json.Marshal(e.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("metadaten serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
if e.OccurredAt.IsZero() {
|
||||||
|
e.OccurredAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = l.pool.Exec(ctx, `
|
||||||
|
INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("audit-ereignis schreiben: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die
|
||||||
|
// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen.
|
||||||
|
func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := l.pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM audit_events WHERE tenant_slug = $1
|
||||||
|
`, tenantSlug).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
||||||
|
actor TEXT NOT NULL CHECK (actor <> ''),
|
||||||
|
action TEXT NOT NULL CHECK (action <> ''),
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewLog(pool), pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
|
||||||
|
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
|
||||||
|
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
|
||||||
|
log, pool, cleanup := setupAuditTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := log.Record(ctx, Event{
|
||||||
|
TenantSlug: "test_acme",
|
||||||
|
Actor: "alice@example.com",
|
||||||
|
Action: "iam.login_failed",
|
||||||
|
Target: "user:alice@example.com",
|
||||||
|
Metadata: map[string]any{"reason": "falsches passwort"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := log.CountByTenant(ctx, "test_acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var actor, action, target string
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
|
||||||
|
`).Scan(&actor, &action, &target); err != nil {
|
||||||
|
t.Fatalf("eintrag lesen: %v", err)
|
||||||
|
}
|
||||||
|
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
|
||||||
|
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
|
||||||
|
// bereits vom zentralen Schreibpfad abgewiesen.
|
||||||
|
func TestRecord_RejectsMissingTenant(t *testing.T) {
|
||||||
|
log, _, cleanup := setupAuditTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
|
||||||
|
if !errors.Is(err, ErrMissingTenant) {
|
||||||
|
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
|
||||||
|
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
|
||||||
|
// Schutz haengt nicht allein von der Go-Validierung ab.
|
||||||
|
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
|
||||||
|
_, pool, cleanup := setupAuditTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO audit_events (tenant_slug, actor, action, target)
|
||||||
|
VALUES ('', 'alice', 'irgendwas', 'ziel')
|
||||||
|
`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
|
||||||
|
log, _, cleanup := setupAuditTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
|
||||||
|
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
|
||||||
|
}
|
||||||
|
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
|
||||||
|
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
|
||||||
|
log, _, cleanup := setupAuditTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
|
||||||
|
t.Fatalf("record mit SystemTenant: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// AuditLogObjectType ist der Objekttyp-Bezeichner, unter dem Audit-Log-
|
||||||
|
// Eintraege bei der Archive-Retention-Engine registriert werden
|
||||||
|
// (Akzeptanzkriterium 1).
|
||||||
|
const AuditLogObjectType = "audit_log_entry"
|
||||||
|
|
||||||
|
// DefaultAuditRetentionYears ist die GoBD-Buchungsbeleg-Frist (Akzeptanz-
|
||||||
|
// kriterium 2) — Default, pro Tenant ueberschreibbar sofern rechtlich
|
||||||
|
// zulaessig. Die eigentliche Ueberschreibung/Durchsetzung liegt vollstaendig
|
||||||
|
// bei Archive, siehe AuditRetentionTenantOverridable.
|
||||||
|
const DefaultAuditRetentionYears = 10
|
||||||
|
|
||||||
|
// AuditRetentionTenantOverridable erlaubt Archive, die Default-Frist pro
|
||||||
|
// Tenant zu ueberschreiben — Core trifft dabei keine rechtliche Entscheidung,
|
||||||
|
// sondern erlaubt Archive lediglich, so eine Entscheidung zuzulassen.
|
||||||
|
const AuditRetentionTenantOverridable = true
|
||||||
|
|
||||||
|
// RetentionRegistrar ist der Modul-Adapter-Vertrag aus Archive RET-05, wie
|
||||||
|
// Core ihn konsumiert. Die tatsaechliche Implementierung lebt im
|
||||||
|
// Archive-Modul (RET-01/RET-02/RET-05) und existiert zum Zeitpunkt dieser
|
||||||
|
// Kachel noch nicht als Code — Core kennt nur diese Schnittstelle.
|
||||||
|
//
|
||||||
|
// WICHTIG: Core implementiert absichtlich KEINE eigene Loeschlogik fuer
|
||||||
|
// Audit-Eintraege (Akzeptanzkriterium 3). Dieses Paket enthaelt keinen
|
||||||
|
// Delete-Codepfad fuer audit_events ausser dem durch AUD-02 technisch
|
||||||
|
// unterbundenen — die tatsaechliche Loeschung/Aufbewahrungssperre erfolgt
|
||||||
|
// ausschliesslich innerhalb von Archive, ausserhalb dieses Prozesses.
|
||||||
|
type RetentionRegistrar interface {
|
||||||
|
RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWithArchive meldet den Audit-Log-Objekttyp bei der Archive-
|
||||||
|
// Retention-Engine an. Dies ist die EINZIGE Beruehrung dieses Pakets mit
|
||||||
|
// Retention ueberhaupt — kein zweites, Core-eigenes Retention-System
|
||||||
|
// (siehe "Bekannte Fehler vermeiden" im AUD-05-Ticket).
|
||||||
|
func RegisterWithArchive(ctx context.Context, registrar RetentionRegistrar) error {
|
||||||
|
return registrar.RegisterObjectType(ctx, AuditLogObjectType, DefaultAuditRetentionYears, AuditRetentionTenantOverridable)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRegistrar simuliert den RET-05-Modul-Adapter-Vertrag, da Archive
|
||||||
|
// (RET-01/RET-02/RET-05) zum Zeitpunkt dieser Kachel noch nicht als Code
|
||||||
|
// existiert (nur geplant in archive-kanban). Belegt NUR, dass Core mit den
|
||||||
|
// richtigen Parametern registriert — ersetzt KEINE Integrationspruefung
|
||||||
|
// gegen die echte Archive-Engine, siehe Pruefungen-Abschnitt im Commit.
|
||||||
|
type fakeRegistrar struct {
|
||||||
|
objectType string
|
||||||
|
defaultRetentionYears int
|
||||||
|
tenantOverridable bool
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRegistrar) RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error {
|
||||||
|
f.called = true
|
||||||
|
f.objectType = objectType
|
||||||
|
f.defaultRetentionYears = defaultRetentionYears
|
||||||
|
f.tenantOverridable = tenantOverridable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 2: Registrierung mit korrektem Objekttyp und
|
||||||
|
// GoBD-Default-Frist von 10 Jahren, tenant-ueberschreibbar.
|
||||||
|
func TestRegisterWithArchive_UsesCorrectObjectTypeAndRetention(t *testing.T) {
|
||||||
|
fake := &fakeRegistrar{}
|
||||||
|
|
||||||
|
if err := RegisterWithArchive(context.Background(), fake); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fake.called {
|
||||||
|
t.Fatal("erwartet aufruf von RegisterObjectType")
|
||||||
|
}
|
||||||
|
if fake.objectType != AuditLogObjectType {
|
||||||
|
t.Fatalf("objectType = %q, want %q", fake.objectType, AuditLogObjectType)
|
||||||
|
}
|
||||||
|
if fake.defaultRetentionYears != 10 {
|
||||||
|
t.Fatalf("defaultRetentionYears = %d, want 10 (GoBD-Frist)", fake.defaultRetentionYears)
|
||||||
|
}
|
||||||
|
if !fake.tenantOverridable {
|
||||||
|
t.Fatal("erwartet tenantOverridable = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
// Package migrate implementiert Core TEN-07: das automatisierte, pro Tenant
|
|
||||||
// fehlerisolierte Ausrollen von SQL-Migrationen ueber alle registrierten
|
|
||||||
// Mandanten-Datenbanken (Modell C — jede Migration muss N-mal statt einmal
|
|
||||||
// laufen, siehe TEN-01).
|
|
||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
const upSuffix = ".up.sql"
|
|
||||||
|
|
||||||
type Migration struct {
|
|
||||||
Version string
|
|
||||||
SQL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadMigrations liest alle *.up.sql-Dateien aus dir und sortiert sie nach
|
|
||||||
// Dateiname (die bestehende Namenskonvention 0001_..., 0002_... aus TEN-01
|
|
||||||
// sorgt fuer eine stabile Reihenfolge).
|
|
||||||
func LoadMigrations(dir string) ([]Migration, error) {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("migrationsverzeichnis lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var names []string
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() && strings.HasSuffix(e.Name(), upSuffix) {
|
|
||||||
names = append(names, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
|
|
||||||
migrations := make([]Migration, 0, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
content, err := os.ReadFile(filepath.Join(dir, name))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("migration %q lesen: %w", name, err)
|
|
||||||
}
|
|
||||||
version := strings.TrimSuffix(name, upSuffix)
|
|
||||||
migrations = append(migrations, Migration{Version: version, SQL: string(content)})
|
|
||||||
}
|
|
||||||
return migrations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrationStatus ist der pro Tenant und Version nachvollziehbare Stand
|
|
||||||
// (Akzeptanzkriterium 3): welche Version, wann zuletzt versucht, Erfolg oder
|
|
||||||
// Fehler.
|
|
||||||
type MigrationStatus struct {
|
|
||||||
Version string
|
|
||||||
AppliedAt time.Time
|
|
||||||
Success bool
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TenantResult fasst das Ergebnis eines Rollout-Versuchs fuer EINEN Tenant
|
|
||||||
// zusammen — wird von Orchestrator.RolloutAll pro Tenant gesammelt, damit ein
|
|
||||||
// Fehlschlag bei einem Mandanten die anderen nicht blockiert (Akzeptanzkriterium 2).
|
|
||||||
type TenantResult struct {
|
|
||||||
TenantSlug string
|
|
||||||
Applied []string // erfolgreich in diesem Lauf angewendete Versionen
|
|
||||||
FailedAt string // Version, bei der abgebrochen wurde; leer wenn kein Fehlschlag
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orchestrator rollt Migrationen ueber alle in der Registry gefuehrten
|
|
||||||
// Mandanten aus. Baut bewusst NICHT auf TEN-06 (Router) auf — Migrations-
|
|
||||||
// Rollouts sind seltene Batch-Vorgaenge, kein Hot-Path, ein kurzlebiger Pool
|
|
||||||
// pro Tenant und Lauf ist hier einfacher und unabhaengig von TEN-06 testbar.
|
|
||||||
type Orchestrator struct {
|
|
||||||
registry *tenant.Registry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOrchestrator(registry *tenant.Registry) *Orchestrator {
|
|
||||||
return &Orchestrator{registry: registry}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureTableSQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
|
||||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
success BOOLEAN NOT NULL,
|
|
||||||
error TEXT
|
|
||||||
);`
|
|
||||||
|
|
||||||
// RolloutAll wendet migrations auf JEDE registrierte Tenant-Datenbank an.
|
|
||||||
// Jeder Tenant laeuft unabhaengig — ein Fehlschlag bei einem Mandanten wird
|
|
||||||
// im jeweiligen TenantResult festgehalten und blockiert die uebrigen nicht
|
|
||||||
// (Akzeptanzkriterium 1 + 2).
|
|
||||||
func (o *Orchestrator) RolloutAll(ctx context.Context, migrations []Migration) ([]TenantResult, error) {
|
|
||||||
tenants, err := o.registry.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tenants fuer rollout auflisten: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]TenantResult, 0, len(tenants))
|
|
||||||
for _, t := range tenants {
|
|
||||||
results = append(results, o.rolloutForTenant(ctx, t, migrations))
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Orchestrator) rolloutForTenant(ctx context.Context, t tenant.Tenant, migrations []Migration) TenantResult {
|
|
||||||
result := TenantResult{TenantSlug: t.Slug}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
result.Err = fmt.Errorf("verbindung zu tenant %q: %w", t.Slug, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
if _, err := pool.Exec(ctx, ensureTableSQL); err != nil {
|
|
||||||
result.Err = fmt.Errorf("schema_migrations anlegen fuer tenant %q: %w", t.Slug, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range migrations {
|
|
||||||
alreadyApplied, err := isAlreadySuccessful(ctx, pool, m.Version)
|
|
||||||
if err != nil {
|
|
||||||
result.Err = fmt.Errorf("migrationsstand lesen fuer tenant %q, version %q: %w", t.Slug, m.Version, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if alreadyApplied {
|
|
||||||
continue // Akzeptanzkriterium 3 / Pruefung 3: keine erneute Anwendung.
|
|
||||||
}
|
|
||||||
|
|
||||||
_, execErr := pool.Exec(ctx, m.SQL)
|
|
||||||
if execErr != nil {
|
|
||||||
recordAttempt(ctx, pool, m.Version, false, execErr.Error())
|
|
||||||
result.FailedAt = m.Version
|
|
||||||
result.Err = fmt.Errorf("migration %q fuer tenant %q fehlgeschlagen: %w", m.Version, t.Slug, execErr)
|
|
||||||
return result // spaetere Migrationen bauen typischerweise auf dieser auf — Abbruch NUR fuer diesen Tenant.
|
|
||||||
}
|
|
||||||
|
|
||||||
recordAttempt(ctx, pool, m.Version, true, "")
|
|
||||||
result.Applied = append(result.Applied, m.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAlreadySuccessful(ctx context.Context, pool *pgxpool.Pool, version string) (bool, error) {
|
|
||||||
var success bool
|
|
||||||
err := pool.QueryRow(ctx, `SELECT success FROM schema_migrations WHERE version = $1`, version).Scan(&success)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return success, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordAttempt(ctx context.Context, pool *pgxpool.Pool, version string, success bool, errMsg string) {
|
|
||||||
var errVal *string
|
|
||||||
if errMsg != "" {
|
|
||||||
errVal = &errMsg
|
|
||||||
}
|
|
||||||
_, _ = pool.Exec(ctx, `
|
|
||||||
INSERT INTO schema_migrations (version, applied_at, success, error)
|
|
||||||
VALUES ($1, now(), $2, $3)
|
|
||||||
ON CONFLICT (version) DO UPDATE SET applied_at = now(), success = $2, error = $3
|
|
||||||
`, version, success, errVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status liefert den Migrationsstand eines einzelnen Tenants (Akzeptanzkriterium 3).
|
|
||||||
func (o *Orchestrator) Status(ctx context.Context, tenantSlug string) ([]MigrationStatus, error) {
|
|
||||||
t, err := o.registry.GetBySlug(ctx, tenantSlug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tenant %q nicht gefunden: %w", tenantSlug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("verbindung zu tenant %q: %w", tenantSlug, err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
rows, err := pool.Query(ctx, `
|
|
||||||
SELECT version, applied_at, success, COALESCE(error, '')
|
|
||||||
FROM schema_migrations ORDER BY version
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("migrationsstand abfragen: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []MigrationStatus
|
|
||||||
for rows.Next() {
|
|
||||||
var s MigrationStatus
|
|
||||||
if err := rows.Scan(&s.Version, &s.AppliedAt, &s.Success, &s.Error); err != nil {
|
|
||||||
return nil, fmt.Errorf("migrationsstand lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadMigrations_SortsByFilename(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
|
|
||||||
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
|
|
||||||
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
|
|
||||||
|
|
||||||
migrations, err := LoadMigrations(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load: %v", err)
|
|
||||||
}
|
|
||||||
if len(migrations) != 2 {
|
|
||||||
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
|
|
||||||
}
|
|
||||||
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
|
|
||||||
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeFile(t *testing.T, dir, name, content string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatalf("write %s: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *pgxpool.Pool, func()) {
|
|
||||||
t.Helper()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
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()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
var tenants []tenant.Tenant
|
|
||||||
var slugs []string
|
|
||||||
for i := 0; i < tenantCount; i++ {
|
|
||||||
slug := fmt.Sprintf("mig_t%d", i)
|
|
||||||
slugs = append(slugs, slug)
|
|
||||||
tn, err := provisioner.Provision(ctx, slug, slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
tenants = append(tenants, tn)
|
|
||||||
}
|
|
||||||
|
|
||||||
orchestrator := NewOrchestrator(registry)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
for _, slug := range slugs {
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
|
||||||
}
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return orchestrator, tenants, adminPool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
|
|
||||||
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
|
|
||||||
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
|
|
||||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []Migration{
|
|
||||||
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
|
|
||||||
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
|
|
||||||
// migration 0002 schlaegt dort mit "already exists" fehl.
|
|
||||||
badTenant := tenants[1]
|
|
||||||
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect bad tenant: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
|
|
||||||
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
|
|
||||||
}
|
|
||||||
badPool.Close()
|
|
||||||
|
|
||||||
results, err := orchestrator.RolloutAll(ctx, migrations)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollout: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 3 {
|
|
||||||
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
byslug := map[string]TenantResult{}
|
|
||||||
for _, r := range results {
|
|
||||||
byslug[r.TenantSlug] = r
|
|
||||||
}
|
|
||||||
|
|
||||||
good0 := byslug[tenants[0].Slug]
|
|
||||||
if good0.Err != nil || len(good0.Applied) != 2 {
|
|
||||||
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
|
|
||||||
}
|
|
||||||
good2 := byslug[tenants[2].Slug]
|
|
||||||
if good2.Err != nil || len(good2.Applied) != 2 {
|
|
||||||
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
|
|
||||||
}
|
|
||||||
|
|
||||||
bad := byslug[badTenant.Slug]
|
|
||||||
if bad.Err == nil {
|
|
||||||
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
|
|
||||||
}
|
|
||||||
if bad.FailedAt != "0002_demo_b" {
|
|
||||||
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
|
|
||||||
}
|
|
||||||
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
|
|
||||||
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
|
|
||||||
// jeden Tenant den korrekten, unabhaengigen Stand.
|
|
||||||
func TestStatus_ReflectsPerTenantState(t *testing.T) {
|
|
||||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []Migration{
|
|
||||||
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
|
|
||||||
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
|
|
||||||
t.Fatalf("rollout: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := orchestrator.Status(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("status: %v", err)
|
|
||||||
}
|
|
||||||
if len(status) != 2 {
|
|
||||||
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
|
|
||||||
}
|
|
||||||
if !status[0].Success || status[0].Version != "0001_ok" {
|
|
||||||
t.Fatalf("status[0] unerwartet: %+v", status[0])
|
|
||||||
}
|
|
||||||
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
|
|
||||||
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
|
|
||||||
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
|
|
||||||
// sobald die Ursache behoben ist.
|
|
||||||
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
|
|
||||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
|
|
||||||
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
|
|
||||||
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
|
|
||||||
t.Fatalf("vorbedingung: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrations := []Migration{
|
|
||||||
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
|
|
||||||
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
|
|
||||||
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
|
|
||||||
}
|
|
||||||
|
|
||||||
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollout 1: %v", err)
|
|
||||||
}
|
|
||||||
if firstRun[0].FailedAt != "0002_conflict" {
|
|
||||||
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ursache beheben.
|
|
||||||
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
|
|
||||||
t.Fatalf("ursache beheben: %v", err)
|
|
||||||
}
|
|
||||||
pool.Close()
|
|
||||||
|
|
||||||
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollout 2: %v", err)
|
|
||||||
}
|
|
||||||
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
|
|
||||||
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
|
|
||||||
// Applied-Eintrags fuer 0002_conflict.
|
|
||||||
if secondRun[0].Err != nil {
|
|
||||||
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
|
|
||||||
}
|
|
||||||
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
|
|
||||||
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS audit_events;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
|
||||||
|
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
|
||||||
|
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
|
||||||
|
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
|
||||||
|
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
|
||||||
|
-- Wert 'system', niemals NULL oder leeren String.
|
||||||
|
CREATE TABLE audit_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
||||||
|
actor TEXT NOT NULL CHECK (actor <> ''),
|
||||||
|
action TEXT NOT NULL CHECK (action <> ''),
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
||||||
|
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
||||||
|
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
||||||
|
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
||||||
|
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||||
|
#
|
||||||
|
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
|
ROLE="nexarch_test"
|
||||||
|
|
||||||
|
export PGPASSWORD="$PASS"
|
||||||
|
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
|
||||||
|
|
||||||
|
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||||
|
for db in $dbs; do
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
||||||
|
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
||||||
|
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
||||||
|
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||||
|
#
|
||||||
|
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
|
||||||
|
|
||||||
|
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
|
||||||
|
|
||||||
|
echo "== go build =="
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
echo "== go vet =="
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
echo "== go test (-p 1) =="
|
||||||
|
go test ./... -p 1 -count=1
|
||||||
Reference in New Issue
Block a user