Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45bc10719a |
@@ -1,106 +0,0 @@
|
||||
// Package license implementiert Core LIC-01: Lizenzmodell je Tenant (Plan,
|
||||
// Modul-Umfang, Laufzeit) und die kryptographische Pruefung signierter
|
||||
// Lizenzschluessel. Feature-Flag-AUSWERTUNG zur Laufzeit (LIC-02) und die
|
||||
// Verwaltungsoberflaeche (LIC-04) sind ausdruecklich nicht Teil dieses Pakets
|
||||
// — hier geht es nur um Ausstellung/Validierung/Persistenz (Unleash-Vorbild:
|
||||
// klare Trennung Flag-Verwaltung vs. Flag-Auswertung).
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSignature = errors.New("license: signatur ungueltig")
|
||||
ErrMalformedKey = errors.New("license: lizenzschluessel hat ungueltiges format")
|
||||
)
|
||||
|
||||
// Payload ist der signierte Lizenzinhalt (Akzeptanzkriterium 3: Plan,
|
||||
// Modul-Liste, Laufzeit).
|
||||
type Payload struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Plan string `json:"plan"`
|
||||
Modules []string `json:"modules"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
}
|
||||
|
||||
// Issuer stellt signierte Lizenzschluessel aus. Haelt den PRIVATEN
|
||||
// Ed25519-Schluessel — lebt in der Praxis beim Lizenzgeber, nicht im
|
||||
// laufenden Core-Prozess (der nur den Validator mit dem oeffentlichen
|
||||
// Schluessel braucht).
|
||||
type Issuer struct {
|
||||
priv ed25519.PrivateKey
|
||||
}
|
||||
|
||||
func NewIssuer(priv ed25519.PrivateKey) *Issuer {
|
||||
return &Issuer{priv: priv}
|
||||
}
|
||||
|
||||
// Issue liefert den Lizenzschluessel im Format base64(payload-json) "." base64(signatur).
|
||||
func (i *Issuer) Issue(payload Payload) (string, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("payload serialisieren: %w", err)
|
||||
}
|
||||
sig := ed25519.Sign(i.priv, raw)
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// Validator prueft Lizenzschluessel gegen den OEFFENTLICHEN Ed25519-Schluessel
|
||||
// — das ist alles, was der laufende Core-Prozess kennen muss.
|
||||
type Validator struct {
|
||||
pub ed25519.PublicKey
|
||||
}
|
||||
|
||||
func NewValidator(pub ed25519.PublicKey) *Validator {
|
||||
return &Validator{pub: pub}
|
||||
}
|
||||
|
||||
// Parse prueft die Signatur (Akzeptanzkriterium 1 / Pruefung 1) und liefert
|
||||
// bei Erfolg den entschluesselten Payload. Ein manipulierter Schluessel wird
|
||||
// hier zuverlaessig erkannt, unabhaengig davon, ob die Laufzeit noch gueltig
|
||||
// waere — Signaturpruefung und Ablaufpruefung sind bewusst getrennt
|
||||
// (Signatur bei Einspielen, Ablauf bei jeder Nutzung, siehe Store.RequireActive).
|
||||
func (v *Validator) Parse(key string) (Payload, error) {
|
||||
rawPart, sigPart, ok := splitOnce(key, '.')
|
||||
if !ok {
|
||||
return Payload{}, ErrMalformedKey
|
||||
}
|
||||
|
||||
raw, err := base64.RawURLEncoding.DecodeString(rawPart)
|
||||
if err != nil {
|
||||
return Payload{}, ErrMalformedKey
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(sigPart)
|
||||
if err != nil {
|
||||
return Payload{}, ErrMalformedKey
|
||||
}
|
||||
|
||||
if !ed25519.Verify(v.pub, raw, sig) {
|
||||
return Payload{}, ErrInvalidSignature
|
||||
}
|
||||
|
||||
var p Payload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
// Signatur war gueltig, aber Payload nicht mehr parsebar — sollte bei
|
||||
// unveraenderten Schluesseln nie vorkommen, trotzdem kein Panic.
|
||||
return Payload{}, fmt.Errorf("%w: payload nicht lesbar", ErrMalformedKey)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func splitOnce(s string, sep byte) (before, after string, ok bool) {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == sep {
|
||||
return s[:i], s[i+1:], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("schluesselpaar erzeugen: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestIssueAndParse_RoundTrip(t *testing.T) {
|
||||
pub, priv := testKeyPair(t)
|
||||
issuer := NewIssuer(priv)
|
||||
validator := NewValidator(pub)
|
||||
|
||||
payload := Payload{
|
||||
TenantSlug: "acme",
|
||||
Plan: "pro",
|
||||
Modules: []string{"dms", "mail"},
|
||||
IssuedAt: time.Now().Truncate(time.Second),
|
||||
ValidUntil: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second),
|
||||
}
|
||||
|
||||
key, err := issuer.Issue(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
got, err := validator.Parse(key)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.TenantSlug != payload.TenantSlug || got.Plan != payload.Plan || len(got.Modules) != 2 {
|
||||
t.Fatalf("payload nach parse unerwartet: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: manipulierter Schluessel wird zuverlaessig erkannt.
|
||||
func TestParse_RejectsTamperedKey(t *testing.T) {
|
||||
pub, priv := testKeyPair(t)
|
||||
issuer := NewIssuer(priv)
|
||||
validator := NewValidator(pub)
|
||||
|
||||
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
// Ein Zeichen im signierten Teil aendern.
|
||||
tampered := []byte(key)
|
||||
changed := false
|
||||
for i, c := range tampered {
|
||||
if c != '.' {
|
||||
if c == 'A' {
|
||||
tampered[i] = 'B'
|
||||
} else {
|
||||
tampered[i] = 'A'
|
||||
}
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("testaufbau fehlerhaft: nichts zum manipulieren gefunden")
|
||||
}
|
||||
|
||||
if _, err := validator.Parse(string(tampered)); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_RejectsWrongKeyPair(t *testing.T) {
|
||||
_, priv := testKeyPair(t)
|
||||
otherPub, _ := testKeyPair(t)
|
||||
|
||||
issuer := NewIssuer(priv)
|
||||
validator := NewValidator(otherPub) // falscher oeffentlicher Schluessel
|
||||
|
||||
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro"})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if _, err := validator.Parse(key); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_RejectsMalformedKey(t *testing.T) {
|
||||
pub, _ := testKeyPair(t)
|
||||
validator := NewValidator(pub)
|
||||
|
||||
cases := []string{"", "keine-punkt-trennung", "!!!.!!!"}
|
||||
for _, c := range cases {
|
||||
if _, err := validator.Parse(c); !errors.Is(err, ErrMalformedKey) {
|
||||
t.Fatalf("Parse(%q): erwartet ErrMalformedKey, habe %v", c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoLicense = errors.New("license: kein lizenzdatensatz fuer diesen tenant")
|
||||
ErrLicenseExpired = errors.New("license: lizenz abgelaufen")
|
||||
)
|
||||
|
||||
// Store persistiert den Lizenzumfang je Tenant in der Control-Plane-Registry
|
||||
// (siehe internal/tenant.Registry — dieselbe Datenbank, aber ein eigener,
|
||||
// unabhaengiger Store, um internal/tenant nicht um lizenzfremde Belange zu
|
||||
// erweitern).
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
validator *Validator
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool, validator *Validator) *Store {
|
||||
return &Store{pool: pool, validator: validator}
|
||||
}
|
||||
|
||||
// Install prueft die Signatur des Lizenzschluessels (Akzeptanzkriterium 1)
|
||||
// und ersetzt den bisherigen Lizenzdatensatz des Tenants vollstaendig. Ein
|
||||
// bereits abgelaufener, aber korrekt signierter Schluessel wird trotzdem
|
||||
// gespeichert — der Ablauf wird erst bei der Nutzung (RequireActive)
|
||||
// bewertet, nicht beim Einspielen.
|
||||
func (s *Store) Install(ctx context.Context, tenantID, licenseKey string) (Payload, error) {
|
||||
payload, err := s.validator.Parse(licenseKey)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO tenant_licenses (tenant_id, plan, modules, issued_at, valid_until, raw_key, installed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
plan = $2, modules = $3, issued_at = $4, valid_until = $5, raw_key = $6, installed_at = now()
|
||||
`, tenantID, payload.Plan, payload.Modules, payload.IssuedAt, payload.ValidUntil, licenseKey)
|
||||
if err != nil {
|
||||
return Payload{}, fmt.Errorf("lizenz speichern: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *Store) get(ctx context.Context, tenantID string) (Payload, error) {
|
||||
var p Payload
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT plan, modules, issued_at, valid_until
|
||||
FROM tenant_licenses WHERE tenant_id = $1
|
||||
`, tenantID)
|
||||
if err := row.Scan(&p.Plan, &p.Modules, &p.IssuedAt, &p.ValidUntil); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Payload{}, ErrNoLicense
|
||||
}
|
||||
return Payload{}, fmt.Errorf("lizenz lesen: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Status liefert den persistierten Lizenzumfang unabhaengig vom Ablauf
|
||||
// (Akzeptanzkriterium 3: Plan, Modul-Liste, Laufzeit abfragbar).
|
||||
func (s *Store) Status(ctx context.Context, tenantID string) (Payload, error) {
|
||||
return s.get(ctx, tenantID)
|
||||
}
|
||||
|
||||
// RequireActive liefert den Lizenzumfang NUR, wenn die Lizenz noch nicht
|
||||
// abgelaufen ist — sonst ErrLicenseExpired statt eines harten Fehlers/Panics
|
||||
// (Akzeptanzkriterium 2: definierter eingeschraenkter Zustand). Aufrufende
|
||||
// Module (LIC-02/03) entscheiden, was "eingeschraenkt" konkret bedeutet.
|
||||
func (s *Store) RequireActive(ctx context.Context, tenantID string) (Payload, error) {
|
||||
p, err := s.get(ctx, tenantID)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
if time.Now().After(p.ValidUntil) {
|
||||
return Payload{}, ErrLicenseExpired
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupStoreTest(t *testing.T) (*Store, *Issuer, string, 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 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()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tenant_licenses (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||
plan TEXT NOT NULL,
|
||||
modules TEXT[] NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
valid_until TIMESTAMPTZ NOT NULL,
|
||||
raw_key TEXT NOT NULL,
|
||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
var tenantID string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO tenants (slug, name, db_name, db_dsn)
|
||||
VALUES ('lic_test_tenant', 'Lic Test', 'tenant_lic_test', 'unused')
|
||||
RETURNING id
|
||||
`).Scan(&tenantID); err != nil {
|
||||
t.Fatalf("test-tenant anlegen: %v", err)
|
||||
}
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("schluesselpaar: %v", err)
|
||||
}
|
||||
issuer := NewIssuer(priv)
|
||||
store := NewStore(pool, NewValidator(pub))
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`, tenantID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
|
||||
pool.Close()
|
||||
}
|
||||
return store, issuer, tenantID, cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3: Lizenzumfang persistiert und abfragbar.
|
||||
func TestStore_InstallAndStatus(t *testing.T) {
|
||||
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
payload := Payload{
|
||||
TenantSlug: "lic_test_tenant",
|
||||
Plan: "enterprise",
|
||||
Modules: []string{"dms", "mail", "archive"},
|
||||
IssuedAt: time.Now().Truncate(time.Second),
|
||||
ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
|
||||
}
|
||||
key, err := issuer.Issue(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
|
||||
status, err := store.Status(ctx, tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
if status.Plan != "enterprise" || len(status.Modules) != 3 {
|
||||
t.Fatalf("status unerwartet: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_InstallRejectsInvalidSignature(t *testing.T) {
|
||||
store, _, tenantID, cleanup := setupStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, otherPriv, _ := ed25519.GenerateKey(nil)
|
||||
foreignIssuer := NewIssuer(otherPriv) // signiert mit falschem schluessel
|
||||
|
||||
key, err := foreignIssuer.Issue(Payload{TenantSlug: "lic_test_tenant", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.Install(ctx, tenantID, key); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: abgelaufene Lizenz fuehrt zu definiertem
|
||||
// eingeschraenktem Zustand (ErrLicenseExpired), nicht zu einem Absturz.
|
||||
func TestStore_RequireActive_DetectsExpiry(t *testing.T) {
|
||||
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
expired := Payload{
|
||||
TenantSlug: "lic_test_tenant",
|
||||
Plan: "pro",
|
||||
Modules: []string{"dms"},
|
||||
IssuedAt: time.Now().Add(-48 * time.Hour),
|
||||
ValidUntil: time.Now().Add(-24 * time.Hour), // bereits abgelaufen
|
||||
}
|
||||
key, err := issuer.Issue(expired)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
// Einspielen einer bereits abgelaufenen, aber korrekt signierten Lizenz
|
||||
// muss funktionieren (Ablauf wird erst bei Nutzung bewertet).
|
||||
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
||||
t.Fatalf("install sollte trotz ablauf funktionieren: %v", err)
|
||||
}
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("RequireActive hat gepanict statt einen fehler zu liefern: %v", r)
|
||||
}
|
||||
}()
|
||||
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrLicenseExpired) {
|
||||
t.Fatalf("erwartet ErrLicenseExpired, habe %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Aber der Umfang bleibt weiterhin abfragbar (Status, im Unterschied zu RequireActive).
|
||||
status, err := store.Status(ctx, tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("status sollte trotz ablauf funktionieren: %v", err)
|
||||
}
|
||||
if status.Plan != "pro" {
|
||||
t.Fatalf("status unerwartet: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RequireActive_NoLicense(t *testing.T) {
|
||||
store, _, tenantID, cleanup := setupStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrNoLicense) {
|
||||
t.Fatalf("erwartet ErrNoLicense, habe %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTenantNotFound = errors.New("tenant: nicht gefunden")
|
||||
ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang")
|
||||
// ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst
|
||||
// EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der
|
||||
// Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der
|
||||
// Nicht-aktiv-Zustaende genau vorliegt.
|
||||
ErrTenantNotActive = errors.New("tenant: nicht aktiv")
|
||||
)
|
||||
|
||||
func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) {
|
||||
var t Tenant
|
||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status,
|
||||
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift
|
||||
// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer
|
||||
// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht,
|
||||
// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt"
|
||||
// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen
|
||||
// sprechenden Fehler liefert statt eines stillen No-Ops.
|
||||
func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) {
|
||||
from := make([]string, len(allowedFrom))
|
||||
for i, s := range allowedFrom {
|
||||
from[i] = string(s)
|
||||
}
|
||||
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
UPDATE tenants
|
||||
SET status = $2, previous_status = $3, deletion_scheduled_at = $4
|
||||
WHERE slug = $1 AND status = ANY($5)
|
||||
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
||||
`, slug, string(to), previousStatus, deletionAt, from)
|
||||
|
||||
t, err := scanTenantWithLifecycle(row)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err)
|
||||
}
|
||||
|
||||
existing, getErr := r.GetBySlug(ctx, slug)
|
||||
if getErr != nil {
|
||||
return Tenant{}, ErrTenantNotFound
|
||||
}
|
||||
return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status)
|
||||
}
|
||||
|
||||
// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff
|
||||
// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt.
|
||||
func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) {
|
||||
return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil)
|
||||
}
|
||||
|
||||
// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her
|
||||
// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt
|
||||
// die Rueckkehr nach StatusActive.
|
||||
func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) {
|
||||
return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil)
|
||||
}
|
||||
|
||||
// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die
|
||||
// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit
|
||||
// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert).
|
||||
func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) {
|
||||
existing, err := r.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return Tenant{}, ErrTenantNotFound
|
||||
}
|
||||
prev := string(existing.Status)
|
||||
deletionAt := time.Now().Add(grace)
|
||||
return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt)
|
||||
}
|
||||
|
||||
// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und
|
||||
// stellt exakt den zuvor gesicherten Zustand wieder her.
|
||||
func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) {
|
||||
existing, err := r.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return Tenant{}, ErrTenantNotFound
|
||||
}
|
||||
if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil {
|
||||
return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status)
|
||||
}
|
||||
restoreTo := Status(*existing.PreviousStatus)
|
||||
return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil)
|
||||
}
|
||||
|
||||
// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der
|
||||
// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung
|
||||
// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool
|
||||
// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner.
|
||||
type Lifecycle struct {
|
||||
registry *Registry
|
||||
adminPool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
|
||||
return &Lifecycle{registry: registry, adminPool: adminPool}
|
||||
}
|
||||
|
||||
// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den
|
||||
// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2).
|
||||
func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error {
|
||||
t, err := l.registry.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return ErrTenantNotFound
|
||||
}
|
||||
if t.Status != StatusActive {
|
||||
slog.Warn("zugriff auf nicht-aktiven mandanten verweigert",
|
||||
"tenant_slug", slug, "tenant_status", t.Status)
|
||||
return ErrTenantNotActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit
|
||||
// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED
|
||||
// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe
|
||||
// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel
|
||||
// laufende Core-Instanzen.
|
||||
func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
||||
tx, err := l.registry.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sweep-transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, db_name FROM tenants
|
||||
WHERE status = $1 AND deletion_scheduled_at <= now()
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`, string(StatusPendingDeletion))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
|
||||
}
|
||||
|
||||
type due struct{ id, dbName string }
|
||||
var candidates []due
|
||||
for rows.Next() {
|
||||
var d due
|
||||
if err := rows.Scan(&d.id, &d.dbName); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
||||
}
|
||||
candidates = append(candidates, d)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for _, c := range candidates {
|
||||
if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil {
|
||||
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL
|
||||
WHERE id = $1
|
||||
`, c.id, string(StatusDeleted)); err != nil {
|
||||
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
|
||||
}
|
||||
processed++
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("sweep-transaktion committen: %w", err)
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird —
|
||||
// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention.
|
||||
func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := l.ProcessDueDeletions(ctx); err != nil {
|
||||
slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *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(),
|
||||
previous_status TEXT,
|
||||
deletion_scheduled_at TIMESTAMPTZ
|
||||
)`); err != nil {
|
||||
t.Fatalf("registry-schema: %v", err)
|
||||
}
|
||||
|
||||
registry := NewRegistry(registryPool)
|
||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||
lifecycle := NewLifecycle(registry, adminPool)
|
||||
|
||||
cleanup := func() {
|
||||
registryPool.Close()
|
||||
adminPool.Close()
|
||||
}
|
||||
_ = provisioner
|
||||
return registry, lifecycle, adminPool, cleanup
|
||||
}
|
||||
|
||||
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
|
||||
t.Helper()
|
||||
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
|
||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
|
||||
t.Fatalf("provision %s: %v", slug, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
||||
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||
})
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
|
||||
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
|
||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_suspend")
|
||||
ctx := context.Background()
|
||||
|
||||
suspended, err := registry.Suspend(ctx, "lc_suspend")
|
||||
if err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
if suspended.Status != StatusSuspended {
|
||||
t.Fatalf("status = %q, want suspended", suspended.Status)
|
||||
}
|
||||
|
||||
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
|
||||
if err != nil {
|
||||
t.Fatalf("reactivate: %v", err)
|
||||
}
|
||||
if reactivated.Status != StatusActive {
|
||||
t.Fatalf("status = %q, want active", reactivated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
|
||||
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
|
||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_invalid")
|
||||
ctx := context.Background()
|
||||
|
||||
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
|
||||
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||
}
|
||||
|
||||
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
|
||||
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||
}
|
||||
|
||||
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
|
||||
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||
}
|
||||
|
||||
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
|
||||
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
|
||||
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
|
||||
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
|
||||
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
|
||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
|
||||
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
|
||||
ctx := context.Background()
|
||||
|
||||
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
|
||||
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
if scheduled.Status != StatusPendingDeletion {
|
||||
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
|
||||
}
|
||||
if scheduled.DeletionScheduledAt == nil {
|
||||
t.Fatal("erwartet gesetzte deletion_scheduled_at")
|
||||
}
|
||||
|
||||
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
|
||||
if err != nil {
|
||||
t.Fatalf("cancel deletion: %v", err)
|
||||
}
|
||||
if restored.Status != StatusActive {
|
||||
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
|
||||
}
|
||||
|
||||
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
|
||||
// 'suspended' zurueckkehren, NICHT zu 'active'.
|
||||
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
|
||||
if err != nil {
|
||||
t.Fatalf("cancel deletion: %v", err)
|
||||
}
|
||||
if restoredSuspended.Status != StatusSuspended {
|
||||
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
|
||||
// Zugriffsversuch einen klaren Fehler.
|
||||
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
|
||||
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
|
||||
}
|
||||
|
||||
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
|
||||
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
|
||||
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
|
||||
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
|
||||
// das RunSweeper periodisch aufruft).
|
||||
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_due")
|
||||
provisionTestTenant(t, registry, adminPool, "lc_not_due")
|
||||
ctx := context.Background()
|
||||
|
||||
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
|
||||
t.Fatalf("schedule deletion (due): %v", err)
|
||||
}
|
||||
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
|
||||
t.Fatalf("schedule deletion (not due): %v", err)
|
||||
}
|
||||
|
||||
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("process due deletions: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
|
||||
}
|
||||
|
||||
due, err := registry.GetBySlug(ctx, "lc_due")
|
||||
if err != nil {
|
||||
t.Fatalf("get lc_due: %v", err)
|
||||
}
|
||||
if due.Status != StatusDeleted {
|
||||
t.Fatalf("lc_due status = %q, want deleted", due.Status)
|
||||
}
|
||||
|
||||
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
|
||||
if err != nil {
|
||||
t.Fatalf("get lc_not_due: %v", err)
|
||||
}
|
||||
if notDue.Status != StatusPendingDeletion {
|
||||
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
|
||||
}
|
||||
|
||||
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
|
||||
var exists bool
|
||||
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
|
||||
t.Fatalf("pg_database pruefen: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,17 @@ func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, e
|
||||
}
|
||||
|
||||
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
||||
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
|
||||
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
||||
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
|
||||
var t Tenant
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
||||
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
||||
FROM tenants WHERE slug = $1
|
||||
`, slug)
|
||||
|
||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt,
|
||||
&t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
|
||||
@@ -12,6 +12,10 @@ type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
// Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go).
|
||||
StatusSuspended Status = "suspended"
|
||||
StatusPendingDeletion Status = "pending_deletion"
|
||||
StatusDeleted Status = "deleted"
|
||||
)
|
||||
|
||||
type Tenant struct {
|
||||
@@ -22,6 +26,11 @@ type Tenant struct {
|
||||
DBDSN string
|
||||
Status Status
|
||||
CreatedAt time.Time
|
||||
// PreviousStatus und DeletionScheduledAt sind nur waehrend
|
||||
// StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen
|
||||
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
|
||||
PreviousStatus *string
|
||||
DeletionScheduledAt *time.Time
|
||||
}
|
||||
|
||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AggregateFunc berechnet/aktualisiert Zaehlerstaende aus einer autoritativen
|
||||
// Quelle (z.B. "zaehle Zeilen in einer Modul-Tabelle") — die konkrete Quelle
|
||||
// haengt vom jeweiligen Modul ab und ist nicht Teil dieser Kachel. Das
|
||||
// Aggregations-Grundgerüst selbst (periodischer Trigger) ist es.
|
||||
type AggregateFunc func(ctx context.Context) error
|
||||
|
||||
// RunPeriodicAggregation ruft aggregate in festen Abstaenden auf, bis ctx
|
||||
// beendet wird — dieselbe In-Prozess-Worker-Goroutine-Konvention wie
|
||||
// internal/tenant.Lifecycle.RunSweeper (Akzeptanzkriterium 1: "periodisch
|
||||
// aggregiert").
|
||||
func RunPeriodicAggregation(ctx context.Context, interval time.Duration, aggregate AggregateFunc) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := aggregate(ctx); err != nil {
|
||||
slog.Error("nutzungszaehler-aggregation fehlgeschlagen", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Package usage implementiert Core LIC-03: Nutzungszaehler je Tenant
|
||||
// (Benutzeranzahl, Speicherverbrauch, API-Aufrufe, ...) und die Pruefung
|
||||
// gegen konfigurierte Quotas. Quotas sind Konfiguration (Tabellenzeile), kein
|
||||
// Hardcode — Zitadel/Unleash-Vorbild.
|
||||
package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNoQuota = errors.New("usage: keine quota fuer diese metrik konfiguriert")
|
||||
|
||||
// Status ist die definierte Reaktion einer Quota-Pruefung (Akzeptanzkriterium 2).
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusWarning Status = "warning" // Schwelle (80%) erreicht, aber noch nicht ueberschritten
|
||||
StatusExceeded Status = "exceeded" // Quota ueberschritten — neue Ressourcen sollten gesperrt werden
|
||||
)
|
||||
|
||||
// warningThreshold liegt bei 80% der Quota.
|
||||
const warningThreshold = 0.8
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// Increment erhoeht einen Zaehler ATOMAR ueber ein einziges SQL-Statement
|
||||
// (UPSERT mit value = value + delta) statt Read-Modify-Write in Go — das
|
||||
// haelt Zaehlerstaende bei parallelen Schreibzugriffen konsistent
|
||||
// (Akzeptanzkriterium 1 / Pruefung 2), ohne eine Anwendungs-Transaktion mit
|
||||
// Lock zu brauchen.
|
||||
func (s *Store) Increment(ctx context.Context, tenantID, metric string, delta int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO usage_counters (tenant_id, metric, value, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (tenant_id, metric) DO UPDATE
|
||||
SET value = usage_counters.value + $3, updated_at = now()
|
||||
`, tenantID, metric, delta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zaehler erhoehen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get liefert den aktuellen Zaehlerstand — 0, wenn noch nie erhoeht wurde.
|
||||
// Der Wert ist strikt tenant-gescoped (Akzeptanzkriterium 3 / Pruefung 3).
|
||||
func (s *Store) Get(ctx context.Context, tenantID, metric string) (int64, error) {
|
||||
var value int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT value FROM usage_counters WHERE tenant_id = $1 AND metric = $2
|
||||
`, tenantID, metric).Scan(&value)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("zaehler lesen: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SetQuota legt die Obergrenze fuer (tenantID, metric) fest — Konfiguration,
|
||||
// kein Hardcode.
|
||||
func (s *Store) SetQuota(ctx context.Context, tenantID, metric string, limit int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO usage_quotas (tenant_id, metric, limit_value)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (tenant_id, metric) DO UPDATE SET limit_value = $3
|
||||
`, tenantID, metric, limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("quota setzen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetQuota(ctx context.Context, tenantID, metric string) (int64, error) {
|
||||
var limit int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT limit_value FROM usage_quotas WHERE tenant_id = $1 AND metric = $2
|
||||
`, tenantID, metric).Scan(&limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrNoQuota
|
||||
}
|
||||
return 0, fmt.Errorf("quota lesen: %w", err)
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
// Check liefert Zaehlerstand, konfigurierte Quota und die daraus abgeleitete
|
||||
// Reaktion (Akzeptanzkriterium 2 / Pruefung 1). Ist keine Quota konfiguriert,
|
||||
// gilt die Metrik als unbegrenzt (StatusOK).
|
||||
func (s *Store) Check(ctx context.Context, tenantID, metric string) (value, limit int64, status Status, err error) {
|
||||
value, err = s.Get(ctx, tenantID, metric)
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
|
||||
limit, err = s.GetQuota(ctx, tenantID, metric)
|
||||
if errors.Is(err, ErrNoQuota) {
|
||||
return value, 0, StatusOK, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
|
||||
switch {
|
||||
case value > limit:
|
||||
return value, limit, StatusExceeded, nil
|
||||
case limit > 0 && float64(value) >= warningThreshold*float64(limit):
|
||||
return value, limit, StatusWarning, nil
|
||||
default:
|
||||
return value, limit, StatusOK, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction wird aufgerufen, wenn Check einen Nicht-OK-Status liefert
|
||||
// (Akzeptanzkriterium 2: "definierte Reaktion").
|
||||
type Reaction func(ctx context.Context, tenantID, metric string, value, limit int64, status Status)
|
||||
|
||||
// Enforce fuehrt Check aus und ruft react auf, wenn der Status nicht OK ist —
|
||||
// die konkrete "Sperre neuer Ressourcen"/Benachrichtigung liegt beim
|
||||
// Aufrufer (z.B. TEN-02 vor dem Anlegen eines neuen Benutzers), Enforce
|
||||
// garantiert nur, dass die Reaktion zuverlaessig ausgeloest wird.
|
||||
func (s *Store) Enforce(ctx context.Context, tenantID, metric string, react Reaction) (Status, error) {
|
||||
value, limit, status, err := s.Check(ctx, tenantID, metric)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status != StatusOK && react != nil {
|
||||
react(ctx, tenantID, metric, value, limit, status)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupTest(t *testing.T) (*Store, 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 usage_counters (
|
||||
tenant_id UUID NOT NULL, metric TEXT NOT NULL, value BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, metric)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS usage_quotas (
|
||||
tenant_id UUID NOT NULL, metric TEXT NOT NULL, limit_value BIGINT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, metric)
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() { pool.Close() }
|
||||
return NewStore(pool), cleanup
|
||||
}
|
||||
|
||||
func newTenantID() string {
|
||||
return fmt.Sprintf("00000000-0000-0000-0000-%012d", time.Now().UnixNano()%1e12)
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 2: Aggregationsjob liefert bei parallelen
|
||||
// Schreibzugriffen konsistente Zaehlerstaende.
|
||||
func TestIncrement_ConsistentUnderConcurrentWrites(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
tenant := newTenantID()
|
||||
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := store.Increment(ctx, tenant, "api_calls", 1); err != nil {
|
||||
t.Errorf("increment: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
value, err := store.Get(ctx, tenant, "api_calls")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if value != goroutines {
|
||||
t.Fatalf("erwartet %d, habe %d (hinweis auf lost update unter nebenlaeufigkeit)", goroutines, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: Zaehlerstand eines Tenants beeinflusst
|
||||
// nicht den eines anderen.
|
||||
func TestIncrement_IsolatedBetweenTenants(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
tenantA, tenantB := newTenantID(), newTenantID()
|
||||
|
||||
if err := store.Increment(ctx, tenantA, "users", 5); err != nil {
|
||||
t.Fatalf("increment a: %v", err)
|
||||
}
|
||||
if err := store.Increment(ctx, tenantB, "users", 1); err != nil {
|
||||
t.Fatalf("increment b: %v", err)
|
||||
}
|
||||
|
||||
valA, err := store.Get(ctx, tenantA, "users")
|
||||
if err != nil {
|
||||
t.Fatalf("get a: %v", err)
|
||||
}
|
||||
valB, err := store.Get(ctx, tenantB, "users")
|
||||
if err != nil {
|
||||
t.Fatalf("get b: %v", err)
|
||||
}
|
||||
if valA != 5 || valB != 1 {
|
||||
t.Fatalf("erwartet a=5 b=1, habe a=%d b=%d", valA, valB)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 1: Quota-Ueberschreitung wird automatisiert
|
||||
// erkannt und die definierte Reaktion ausgeloest.
|
||||
func TestEnforce_TriggersReactionOnExceeded(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
tenant := newTenantID()
|
||||
|
||||
if err := store.SetQuota(ctx, tenant, "users", 10); err != nil {
|
||||
t.Fatalf("set quota: %v", err)
|
||||
}
|
||||
if err := store.Increment(ctx, tenant, "users", 11); err != nil {
|
||||
t.Fatalf("increment: %v", err)
|
||||
}
|
||||
|
||||
var reacted bool
|
||||
var gotStatus Status
|
||||
status, err := store.Enforce(ctx, tenant, "users", func(ctx context.Context, tenantID, metric string, value, limit int64, status Status) {
|
||||
reacted = true
|
||||
gotStatus = status
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("enforce: %v", err)
|
||||
}
|
||||
if status != StatusExceeded {
|
||||
t.Fatalf("erwartet StatusExceeded, habe %q", status)
|
||||
}
|
||||
if !reacted || gotStatus != StatusExceeded {
|
||||
t.Fatal("erwartet ausgeloeste reaktion mit StatusExceeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_WarningThresholdAndOK(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
tenant := newTenantID()
|
||||
|
||||
if err := store.SetQuota(ctx, tenant, "storage_mb", 100); err != nil {
|
||||
t.Fatalf("set quota: %v", err)
|
||||
}
|
||||
|
||||
if err := store.Increment(ctx, tenant, "storage_mb", 50); err != nil {
|
||||
t.Fatalf("increment: %v", err)
|
||||
}
|
||||
_, _, status, err := store.Check(ctx, tenant, "storage_mb")
|
||||
if err != nil {
|
||||
t.Fatalf("check: %v", err)
|
||||
}
|
||||
if status != StatusOK {
|
||||
t.Fatalf("bei 50%% erwartet StatusOK, habe %q", status)
|
||||
}
|
||||
|
||||
if err := store.Increment(ctx, tenant, "storage_mb", 35); err != nil { // insgesamt 85%
|
||||
t.Fatalf("increment: %v", err)
|
||||
}
|
||||
_, _, status, err = store.Check(ctx, tenant, "storage_mb")
|
||||
if err != nil {
|
||||
t.Fatalf("check: %v", err)
|
||||
}
|
||||
if status != StatusWarning {
|
||||
t.Fatalf("bei 85%% erwartet StatusWarning, habe %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_NoQuotaMeansUnlimited(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
tenant := newTenantID()
|
||||
|
||||
if err := store.Increment(ctx, tenant, "api_calls", 1_000_000); err != nil {
|
||||
t.Fatalf("increment: %v", err)
|
||||
}
|
||||
_, _, status, err := store.Check(ctx, tenant, "api_calls")
|
||||
if err != nil {
|
||||
t.Fatalf("check: %v", err)
|
||||
}
|
||||
if status != StatusOK {
|
||||
t.Fatalf("ohne konfigurierte quota erwartet StatusOK, habe %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPeriodicAggregation_CallsRepeatedly(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
RunPeriodicAggregation(ctx, 20*time.Millisecond, func(ctx context.Context) error {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls < 3 {
|
||||
t.Fatalf("erwartet mehrfache aufrufe innerhalb von 120ms bei 20ms interval, habe %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tenants DROP COLUMN previous_status;
|
||||
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Lebenszyklus-Zustaende fuer Mandanten (TEN-04, siehe core-kanban/tickets/TEN-04.md).
|
||||
-- previous_status haelt den Zustand VOR einer Loeschvormerkung, damit
|
||||
-- CancelDeletion "den vorherigen Zustand vollstaendig wiederherstellt"
|
||||
-- (aktiv ODER suspendiert), statt hart auf 'active' zurueckzusetzen.
|
||||
ALTER TABLE tenants ADD COLUMN previous_status TEXT;
|
||||
ALTER TABLE tenants ADD COLUMN deletion_scheduled_at TIMESTAMPTZ;
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS tenant_licenses;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Lizenzumfang pro Mandant (LIC-01, siehe core-kanban/tickets/LIC-01.md).
|
||||
-- Genau ein Lizenzdatensatz pro Tenant (tenant_id PK) — ein neues Einspielen
|
||||
-- ersetzt den vorherigen Datensatz vollstaendig statt eine Historie zu fuehren.
|
||||
CREATE TABLE tenant_licenses (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||
plan TEXT NOT NULL,
|
||||
modules TEXT[] NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
valid_until TIMESTAMPTZ NOT NULL,
|
||||
raw_key TEXT NOT NULL,
|
||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP TABLE IF EXISTS usage_quotas;
|
||||
DROP TABLE IF EXISTS usage_counters;
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Nutzungszaehler & Quotas je Tenant (LIC-03, siehe core-kanban/tickets/LIC-03.md).
|
||||
CREATE TABLE usage_counters (
|
||||
tenant_id UUID NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, metric)
|
||||
);
|
||||
|
||||
CREATE TABLE usage_quotas (
|
||||
tenant_id UUID NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
limit_value BIGINT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, metric)
|
||||
);
|
||||
@@ -13,7 +13,7 @@ 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 tenants;"
|
||||
|
||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||
for db in $dbs; do
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/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