feat(schema): M4.1 — tenant_registry schema + migrate binary
PLATFORM_ARCHITECTURE.md §5c schema as one initial migration: 6 tables + 4 enums + updated_at triggers. cmd/migrate binary (golang-migrate library, embedded SQL). testcontainers round-trip + seed + slug-constraint tests. Refs: M4.1
This commit was merged in pull request #6.
This commit is contained in:
@@ -71,8 +71,10 @@ jobs:
|
||||
run: go vet ./...
|
||||
|
||||
- name: lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with: { version: latest }
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
# Pin to a version built on Go 1.25 — the runner's bundled tool
|
||||
# is Go 1.24 and refuses to lint a 1.25 module.
|
||||
with: { version: v2.12.2 }
|
||||
|
||||
- name: test
|
||||
# Coverage scoped to ./internal/... — cmd/server is the entrypoint
|
||||
|
||||
@@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests
|
||||
- feat(server): minimal Go service — /healthz + GET /v1/tenants/by-slug/:slug + GET /v1/tenants/:id with in-memory store seeded with the acme tenant
|
||||
-
|
||||
|
||||
|
||||
+7
-2
@@ -1,15 +1,20 @@
|
||||
# Multi-stage build for tenant-registry.
|
||||
# Produces two binaries:
|
||||
# /tenant-registry — long-running API server
|
||||
# /migrate — one-shot schema migrator (Orca init container in prod)
|
||||
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/tenant-registry ./cmd/server
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/tenant-registry ./cmd/server && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/migrate ./cmd/migrate
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /
|
||||
COPY --from=build /out/tenant-registry /tenant-registry
|
||||
COPY --from=build /out/migrate /migrate
|
||||
USER nonroot:nonroot
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/tenant-registry"]
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
# tenant-registry — Go service for tenant glue, audit, API keys.
|
||||
|
||||
.PHONY: help dev test build fmt vet lint docker clean
|
||||
.PHONY: help dev test test-short build build-migrate fmt vet lint docker clean \
|
||||
migrate-up migrate-down migrate-down-all migrate-version migrate-create
|
||||
|
||||
ADDR ?= :8090
|
||||
APP_ENV ?= dev
|
||||
ADDR ?= :8090
|
||||
APP_ENV ?= dev
|
||||
DATABASE_URL ?= postgres://platform:platform-dev-pass@localhost:5432/platform?sslmode=disable
|
||||
|
||||
help:
|
||||
@echo "tenant-registry targets:"
|
||||
@echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)"
|
||||
@echo " make test go test -race ./..."
|
||||
@echo " make build compile binary to ./bin/tenant-registry"
|
||||
@echo " make fmt go fmt ./..."
|
||||
@echo " make vet go vet ./..."
|
||||
@echo " make docker build local image (tenant-registry:dev)"
|
||||
@echo ""
|
||||
@echo " Server:"
|
||||
@echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)"
|
||||
@echo " make build compile to ./bin/tenant-registry"
|
||||
@echo " make build-migrate compile to ./bin/migrate"
|
||||
@echo " make docker build local image (tenant-registry:dev)"
|
||||
@echo ""
|
||||
@echo " Schema:"
|
||||
@echo " make migrate-up apply all pending migrations (uses DATABASE_URL)"
|
||||
@echo " make migrate-down roll back the most recent migration"
|
||||
@echo " make migrate-down-all roll back EVERY migration (DESTRUCTIVE)"
|
||||
@echo " make migrate-version print current schema version"
|
||||
@echo " make migrate-create NAME=add_foo"
|
||||
@echo " create a new pair of empty migration files"
|
||||
@echo ""
|
||||
@echo " CI:"
|
||||
@echo " make test go test -race ./... (includes testcontainers)"
|
||||
@echo " make test-short go test -race -short ./... (skips integration)"
|
||||
@echo " make fmt | vet | lint"
|
||||
|
||||
dev:
|
||||
@APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server
|
||||
@@ -20,11 +35,19 @@ dev:
|
||||
test:
|
||||
@go test -race ./...
|
||||
|
||||
test-short:
|
||||
@go test -race -short ./...
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
@CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server
|
||||
@echo "built ./bin/tenant-registry"
|
||||
|
||||
build-migrate:
|
||||
@mkdir -p bin
|
||||
@CGO_ENABLED=0 go build -o bin/migrate ./cmd/migrate
|
||||
@echo "built ./bin/migrate"
|
||||
|
||||
fmt:
|
||||
@gofmt -w .
|
||||
@test -z "$$(gofmt -l .)"
|
||||
@@ -39,3 +62,26 @@ docker:
|
||||
|
||||
clean:
|
||||
@rm -rf bin
|
||||
|
||||
# ─── migrations ────────────────────────────────────────────────────────────
|
||||
|
||||
migrate-up:
|
||||
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate up
|
||||
|
||||
migrate-down:
|
||||
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate down
|
||||
|
||||
migrate-down-all:
|
||||
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate -all down
|
||||
|
||||
migrate-version:
|
||||
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate version
|
||||
|
||||
migrate-create:
|
||||
ifndef NAME
|
||||
$(error usage: make migrate-create NAME=add_something)
|
||||
endif
|
||||
@n=$$(ls migrations/*.up.sql 2>/dev/null | wc -l); \
|
||||
next=$$(printf "%04d" $$((n + 1))); \
|
||||
touch migrations/$${next}_$(NAME).up.sql migrations/$${next}_$(NAME).down.sql; \
|
||||
echo "created migrations/$${next}_$(NAME).{up,down}.sql"
|
||||
|
||||
@@ -64,7 +64,39 @@ The skeleton's store is in-memory and pre-seeded with one tenant:
|
||||
|
||||
So `curl http://localhost:8090/v1/tenants/by-slug/acme` works the moment `make dev` is up.
|
||||
|
||||
The full schema (tenants, tenant_products, audit_log) is committed at `migrations/0001_init.up.sql` for review, but unapplied until the M4.1 follow-up PR swaps the in-memory store for pgx-backed Postgres.
|
||||
The full schema (6 tables: `tenants`, `tenant_projects`, `tenant_products`, `tenant_idp_config`, `api_keys`, `audit_log` — per `PLATFORM_ARCHITECTURE.md §5c`) lives at `migrations/0001_init.up.sql`. The handler-layer in-memory store is still wired in by default; the pgx-backed store + the full REST surface lands in **M4.2**.
|
||||
|
||||
## Schema migrations (M4.1)
|
||||
|
||||
```bash
|
||||
# Apply all pending migrations against the dev Postgres (assumes
|
||||
# `make dev-up` in platform/orca-platform is running):
|
||||
make migrate-up
|
||||
|
||||
# Inspect current version:
|
||||
make migrate-version
|
||||
|
||||
# Roll back the most recent migration:
|
||||
make migrate-down
|
||||
|
||||
# Wipe everything (DESTRUCTIVE — only safe against a dev DB):
|
||||
make migrate-down-all
|
||||
|
||||
# Create the next pair of empty migration files:
|
||||
make migrate-create NAME=add_team_table
|
||||
```
|
||||
|
||||
Migrations are embedded into both `cmd/server` and `cmd/migrate` via `migrations/embed.go`. In production, `cmd/migrate` ships as an Orca init container so the schema is applied before the API server starts (`IMPLEMENTATION_PLAN.md §1.7`: migrations are forward-only and run as an init container before the service).
|
||||
|
||||
The migrations package ships three integration tests (require Docker):
|
||||
|
||||
| Test | What it asserts |
|
||||
|---|---|
|
||||
| `TestMigrate_upDownRoundTrip` | up → all 6 tables + 4 enums exist; down → schema empty; up again succeeds |
|
||||
| `TestSeed_canInsertAndQuery` | end-to-end insert across all 6 tables, FK cascade behaviour, `audit_log` SET-NULL on tenant delete |
|
||||
| `TestSlugConstraint` | tenant slug regex enforced (rejects too-short / leading dash / uppercase / underscore) |
|
||||
|
||||
Run them with `make test`. Use `make test-short` in environments without Docker.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// migrate — standalone CLI that applies tenant-registry's SQL migrations.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// migrate up apply all pending migrations
|
||||
// migrate down roll back the most recent migration
|
||||
// migrate down --all roll back every migration (DESTRUCTIVE)
|
||||
// migrate version print the current schema version
|
||||
// migrate force <version> mark a specific version applied (recovery)
|
||||
//
|
||||
// Reads DATABASE_URL from the environment. Migrations are embedded so this
|
||||
// binary is self-contained — ship it as an Orca init container in prod.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/migrations"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
allDown := flag.Bool("all", false, "with 'down', roll back every migration")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "usage: %s <up|down|version|force <n>>\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) < 1 {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
slog.Error("DATABASE_URL not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := run(context.Background(), args, dbURL, *allDown); err != nil {
|
||||
slog.Error("migrate failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, dbURL string, allDown bool) error {
|
||||
src, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithSourceInstance("iofs", src, dbURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open migrate: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil {
|
||||
slog.Warn("close error", "src_err", srcErr, "db_err", dbErr)
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := args[0]
|
||||
switch cmd {
|
||||
case "up":
|
||||
err = m.Up()
|
||||
if errors.Is(err, migrate.ErrNoChange) {
|
||||
slog.Info("no pending migrations")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("up: %w", err)
|
||||
}
|
||||
v, dirty, _ := m.Version()
|
||||
slog.Info("migrate up complete", "version", v, "dirty", dirty)
|
||||
return nil
|
||||
|
||||
case "down":
|
||||
if allDown {
|
||||
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("down all: %w", err)
|
||||
}
|
||||
slog.Info("migrate down --all complete (schema empty)")
|
||||
return nil
|
||||
}
|
||||
if err := m.Steps(-1); err != nil {
|
||||
if errors.Is(err, migrate.ErrNoChange) {
|
||||
slog.Info("no migrations to roll back")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("down 1: %w", err)
|
||||
}
|
||||
v, dirty, _ := m.Version()
|
||||
slog.Info("migrate down 1 complete", "version", v, "dirty", dirty)
|
||||
return nil
|
||||
|
||||
case "version":
|
||||
v, dirty, err := m.Version()
|
||||
if errors.Is(err, migrate.ErrNilVersion) {
|
||||
slog.Info("no migrations applied")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("version: %w", err)
|
||||
}
|
||||
slog.Info("schema version", "version", v, "dirty", dirty)
|
||||
return nil
|
||||
|
||||
case "force":
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: migrate force <version>")
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(args[1], "%d", &n); err != nil {
|
||||
return fmt.Errorf("invalid version %q", args[1])
|
||||
}
|
||||
if err := m.Force(n); err != nil {
|
||||
return fmt.Errorf("force: %w", err)
|
||||
}
|
||||
slog.Info("forced version", "version", n)
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", cmd)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,69 @@
|
||||
module gitea.meghsakha.com/platform/tenant-registry
|
||||
|
||||
go 1.24
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.1 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
@@ -27,7 +27,7 @@ func TestHealthz(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestTenantBySlug_acme(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("got %d, want 200; body=%s", resp.StatusCode, body)
|
||||
@@ -66,7 +66,7 @@ func TestTenantBySlug_unknown(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("got %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
-- M4.1 down — reverse of 0001_init.up.sql.
|
||||
-- Forward-only in prod (column drops require two releases); the down
|
||||
-- migration exists for testcontainers round-trips + dev tear-downs.
|
||||
|
||||
DROP TRIGGER IF EXISTS tenant_idp_config_touch_updated_at ON tenant_idp_config;
|
||||
DROP TRIGGER IF EXISTS tenant_products_touch_updated_at ON tenant_products;
|
||||
DROP TRIGGER IF EXISTS tenant_projects_touch_updated_at ON tenant_projects;
|
||||
DROP TRIGGER IF EXISTS tenants_touch_updated_at ON tenants;
|
||||
DROP FUNCTION IF EXISTS touch_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
DROP TABLE IF EXISTS tenant_idp_config;
|
||||
DROP TABLE IF EXISTS tenant_products;
|
||||
DROP TABLE IF EXISTS tenant_projects;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
DROP TYPE IF EXISTS tenant_kind;
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
|
||||
DROP TYPE IF EXISTS tenant_project_status;
|
||||
DROP TYPE IF EXISTS idp_kind;
|
||||
DROP TYPE IF EXISTS tenant_kind;
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
|
||||
+175
-30
@@ -1,52 +1,197 @@
|
||||
-- Placeholder for the M4.1 schema (see PLATFORM_ARCHITECTURE.md §5c).
|
||||
-- The skeleton uses an in-memory store; this file lands the table shape
|
||||
-- the real M4.1 PR will use, so the schema review can happen alongside
|
||||
-- the rest of the boot scaffolding.
|
||||
-- M4.1 — initial tenant_registry schema.
|
||||
-- Source of truth: PLATFORM_ARCHITECTURE.md §5c.
|
||||
-- Forward-only per IMPLEMENTATION_PLAN.md §1.7.
|
||||
|
||||
-- enums --------------------------------------------------------------------
|
||||
-- =========================================================================
|
||||
-- enums
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo');
|
||||
CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal');
|
||||
CREATE TYPE tenant_status AS ENUM (
|
||||
'demo', -- shared demo tenant; reset nightly; no billing
|
||||
'trial', -- real customer in their N-day evaluation window
|
||||
'active', -- paid; contract or self-serve plan
|
||||
'frozen', -- read-only after cancel / non-payment (30d grace)
|
||||
'archived' -- data export window closed; only audit log retained
|
||||
);
|
||||
|
||||
-- tenants ------------------------------------------------------------------
|
||||
CREATE TYPE tenant_kind AS ENUM (
|
||||
'customer', -- real paying / trialing customer
|
||||
'demo' -- shared demo tenant; never billed
|
||||
);
|
||||
|
||||
CREATE TYPE idp_kind AS ENUM (
|
||||
'oidc',
|
||||
'saml'
|
||||
);
|
||||
|
||||
CREATE TYPE tenant_project_status AS ENUM (
|
||||
'active',
|
||||
'archived'
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- tenants — the root entity. tenants.id ↔ Keycloak org_id 1:1.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9-]{2,40}$'),
|
||||
name TEXT NOT NULL,
|
||||
status tenant_status NOT NULL DEFAULT 'trial',
|
||||
kind tenant_kind NOT NULL DEFAULT 'customer',
|
||||
plan TEXT NOT NULL DEFAULT 'starter',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
trial_ends_at TIMESTAMPTZ
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'),
|
||||
name TEXT NOT NULL,
|
||||
status tenant_status NOT NULL DEFAULT 'trial',
|
||||
kind tenant_kind NOT NULL DEFAULT 'customer',
|
||||
plan TEXT NOT NULL DEFAULT 'starter',
|
||||
|
||||
-- External system references (one-to-one per §5c "Links")
|
||||
erp_customer_id TEXT UNIQUE,
|
||||
stripe_cust_id TEXT UNIQUE,
|
||||
|
||||
-- Lifecycle dates
|
||||
trial_ends_at TIMESTAMPTZ,
|
||||
contract_start DATE,
|
||||
contract_end DATE,
|
||||
|
||||
-- CRM ownership (ERPNext sales_owner equivalent)
|
||||
sales_owner TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX tenants_status_idx ON tenants (status);
|
||||
CREATE INDEX tenants_kind_idx ON tenants (kind);
|
||||
CREATE INDEX tenants_trial_ends_idx ON tenants (trial_ends_at) WHERE trial_ends_at IS NOT NULL;
|
||||
|
||||
-- tenant ↔ product entitlements -------------------------------------------
|
||||
-- =========================================================================
|
||||
-- tenant_projects — OPTIONAL sub-tenancy (GCP-Project-style).
|
||||
-- Customers without need operate as a single implicit "default" project.
|
||||
-- Products opt in via manifest.supports_projects=true.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenant_projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$'),
|
||||
status tenant_project_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX tenant_projects_tenant_idx ON tenant_projects (tenant_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- tenant_products — entitlement matrix: which tenant has which product.
|
||||
-- config holds product-specific knobs (litellm_url, max_seats, modules_enabled…).
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenant_products (
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, product)
|
||||
);
|
||||
|
||||
-- audit log (Retraced-shape; PRODUCT_INTEGRATION_SPEC.md §8.4) ------------
|
||||
CREATE INDEX tenant_products_product_idx ON tenant_products (product) WHERE enabled = TRUE;
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID REFERENCES tenants(id),
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_type TEXT,
|
||||
-- =========================================================================
|
||||
-- tenant_idp_config — external identity provider per tenant (enterprise SSO).
|
||||
-- metadata holds OIDC discovery URL + client_id, or SAML cert + entity_id.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenant_idp_config (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
type idp_kind NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_ip INET,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, type)
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
|
||||
CREATE INDEX tenant_idp_config_tenant_idx ON tenant_idp_config (tenant_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- api_keys — portal-owned. Single source of truth across all products.
|
||||
-- hash is bcrypt/argon2 of the raw key; the plaintext is shown ONCE on create.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
product TEXT, -- nullable = applies to all products
|
||||
name TEXT NOT NULL, -- human-readable label
|
||||
scopes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
hash TEXT NOT NULL, -- argon2id encoded hash
|
||||
prefix TEXT NOT NULL, -- first 8 chars of the raw key, for UI display
|
||||
created_by TEXT, -- Keycloak user_id
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX api_keys_tenant_idx ON api_keys (tenant_id) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||
|
||||
-- =========================================================================
|
||||
-- audit_log — every state-changing action across portal + products.
|
||||
-- Retraced-compatible shape (PRODUCT_INTEGRATION_SPEC.md §8.4) so we can
|
||||
-- swap implementations without changing producers.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
project_id UUID REFERENCES tenant_projects(id) ON DELETE SET NULL,
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
actor_type TEXT, -- user | service | system
|
||||
action TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_type TEXT,
|
||||
target_name TEXT,
|
||||
product TEXT, -- which product emitted this (NULL = portal/tenant-registry)
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_ip INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
|
||||
CREATE INDEX audit_log_product_idx ON audit_log (product, created_at DESC) WHERE product IS NOT NULL;
|
||||
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC) WHERE actor_id IS NOT NULL;
|
||||
CREATE INDEX audit_log_action_idx ON audit_log (action);
|
||||
CREATE INDEX audit_log_tenant_action_idx ON audit_log (tenant_id, action, created_at DESC);
|
||||
|
||||
-- =========================================================================
|
||||
-- update timestamp trigger — applied to every table with an updated_at.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tenants_touch_updated_at
|
||||
BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
CREATE TRIGGER tenant_projects_touch_updated_at
|
||||
BEFORE UPDATE ON tenant_projects
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
CREATE TRIGGER tenant_products_touch_updated_at
|
||||
BEFORE UPDATE ON tenant_products
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
CREATE TRIGGER tenant_idp_config_touch_updated_at
|
||||
BEFORE UPDATE ON tenant_idp_config
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package migrations exposes the SQL migration files as an embed.FS so the
|
||||
// migrate binary doesn't have to ship them as loose files at runtime.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds every *.sql file in this directory at build time.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,291 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // pgx stdlib driver for database/sql
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
)
|
||||
|
||||
// startPostgres spins a fresh postgres:16-alpine container and returns its
|
||||
// DSN + a cleanup func. Skips the test if Docker is unreachable.
|
||||
func startPostgres(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pgc, err := tcpostgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
tcpostgres.WithDatabase("tenant_registry_test"),
|
||||
tcpostgres.WithUsername("test"),
|
||||
tcpostgres.WithPassword("test"),
|
||||
tcpostgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker unreachable (%v)", err)
|
||||
}
|
||||
|
||||
dsn, err := pgc.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
_ = pgc.Terminate(context.Background())
|
||||
t.Fatalf("dsn: %v", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = pgc.Terminate(c)
|
||||
}
|
||||
return dsn, cleanup
|
||||
}
|
||||
|
||||
func newMigrator(t *testing.T, dsn string) *migrate.Migrate {
|
||||
t.Helper()
|
||||
src, err := iofs.New(FS, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = m.Close()
|
||||
_ = db.Close()
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestMigrate_upDownRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test under -short")
|
||||
}
|
||||
dsn, stop := startPostgres(t)
|
||||
defer stop()
|
||||
|
||||
m := newMigrator(t, dsn)
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up: %v", err)
|
||||
}
|
||||
|
||||
// Schema assertions — every table the spec requires must exist.
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
wantTables := []string{
|
||||
"tenants",
|
||||
"tenant_projects",
|
||||
"tenant_products",
|
||||
"tenant_idp_config",
|
||||
"api_keys",
|
||||
"audit_log",
|
||||
}
|
||||
for _, table := range wantTables {
|
||||
var exists bool
|
||||
err := db.QueryRow(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1)",
|
||||
table,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
t.Fatalf("query for table %s: %v", table, err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("table %s missing after migrate up", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Enum assertions.
|
||||
wantEnums := map[string][]string{
|
||||
"tenant_status": {"demo", "trial", "active", "frozen", "archived"},
|
||||
"tenant_kind": {"customer", "demo"},
|
||||
"idp_kind": {"oidc", "saml"},
|
||||
"tenant_project_status": {"active", "archived"},
|
||||
}
|
||||
for enum, values := range wantEnums {
|
||||
rows, err := db.Query(
|
||||
"SELECT e.enumlabel FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = $1 ORDER BY e.enumsortorder",
|
||||
enum,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("query enum %s: %v", enum, err)
|
||||
}
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, v)
|
||||
}
|
||||
_ = rows.Close()
|
||||
if fmt.Sprint(got) != fmt.Sprint(values) {
|
||||
t.Errorf("enum %s = %v, want %v", enum, got, values)
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trip: down all, then up again — must succeed without leftover state.
|
||||
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("down: %v", err)
|
||||
}
|
||||
var afterDown int
|
||||
err = db.QueryRow(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name = ANY($1)",
|
||||
wantTables,
|
||||
).Scan(&afterDown)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterDown != 0 {
|
||||
t.Errorf("after down: %d tables still present, want 0", afterDown)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up after down: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeed_canInsertAndQuery is the lightweight happy-path: insert a tenant,
|
||||
// give it a project + a product + an api_key + an audit record, query back.
|
||||
// Catches schema-level mistakes (NOT NULL, FK direction, enum cast) that
|
||||
// table-existence checks miss.
|
||||
func TestSeed_canInsertAndQuery(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test under -short")
|
||||
}
|
||||
dsn, stop := startPostgres(t)
|
||||
defer stop()
|
||||
|
||||
m := newMigrator(t, dsn)
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
ctx := context.Background()
|
||||
|
||||
var tid string
|
||||
err = db.QueryRowContext(ctx,
|
||||
`INSERT INTO tenants (slug, name, plan, status, kind)
|
||||
VALUES ($1, $2, 'professional', 'active', 'customer')
|
||||
RETURNING id`,
|
||||
"acme", "Acme Inc.").Scan(&tid)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tenant: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO tenant_projects (tenant_id, name, slug) VALUES ($1, $2, $3)`,
|
||||
tid, "Production", "prod"); err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO tenant_products (tenant_id, product, config) VALUES ($1, 'certifai', '{"max_seats":10}'::jsonb)`,
|
||||
tid); err != nil {
|
||||
t.Fatalf("insert product: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO api_keys (tenant_id, name, hash, prefix, scopes)
|
||||
VALUES ($1, 'ci-bot', 'argon2-hash', 'bp_12345', ARRAY['certifai:read'])`,
|
||||
tid); err != nil {
|
||||
t.Fatalf("insert api_key: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (tenant_id, action, actor_id, actor_name, metadata)
|
||||
VALUES ($1, 'tenant.created', 'sys', 'system', '{"source":"test"}'::jsonb)`,
|
||||
tid); err != nil {
|
||||
t.Fatalf("insert audit: %v", err)
|
||||
}
|
||||
|
||||
// Round-trip read.
|
||||
var slug, status string
|
||||
err = db.QueryRowContext(ctx, `SELECT slug, status::text FROM tenants WHERE id = $1`, tid).Scan(&slug, &status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slug != "acme" || status != "active" {
|
||||
t.Errorf("tenant readback: slug=%q status=%q", slug, status)
|
||||
}
|
||||
|
||||
// FK cascade — delete tenant, projects/products/keys/audit_log handling.
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM tenants WHERE id = $1`, tid); err != nil {
|
||||
t.Fatalf("delete tenant: %v", err)
|
||||
}
|
||||
var nProjects, nProducts, nKeys int
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_projects WHERE tenant_id = $1`, tid).Scan(&nProjects)
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_products WHERE tenant_id = $1`, tid).Scan(&nProducts)
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys WHERE tenant_id = $1`, tid).Scan(&nKeys)
|
||||
if nProjects != 0 || nProducts != 0 || nKeys != 0 {
|
||||
t.Errorf("FK cascade incomplete: projects=%d products=%d keys=%d", nProjects, nProducts, nKeys)
|
||||
}
|
||||
|
||||
// audit_log uses ON DELETE SET NULL — tenant_id becomes NULL but row stays
|
||||
var nAudit, nAuditNullTenant int
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`).Scan(&nAudit)
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log WHERE tenant_id IS NULL`).Scan(&nAuditNullTenant)
|
||||
if nAudit != 1 || nAuditNullTenant != 1 {
|
||||
t.Errorf("audit_log SET NULL: total=%d null=%d, want 1/1", nAudit, nAuditNullTenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugConstraint(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test under -short")
|
||||
}
|
||||
dsn, stop := startPostgres(t)
|
||||
defer stop()
|
||||
|
||||
m := newMigrator(t, dsn)
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
cases := []struct {
|
||||
slug string
|
||||
wantErr bool
|
||||
}{
|
||||
{"acme", false},
|
||||
{"a-c-m-e", false},
|
||||
{"a1b2c3", false},
|
||||
{"a", true}, // too short
|
||||
{"-acme", true}, // leading dash
|
||||
{"acme-", true}, // trailing dash
|
||||
{"AcMe", true}, // uppercase
|
||||
{"a_b", true}, // underscore
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, err := db.Exec(`INSERT INTO tenants (slug, name) VALUES ($1, 'X')`, c.slug)
|
||||
gotErr := err != nil
|
||||
if gotErr != c.wantErr {
|
||||
t.Errorf("slug %q: gotErr=%v wantErr=%v (err=%v)", c.slug, gotErr, c.wantErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user