feat(schema): M4.1 — tenant_registry schema + migrate binary #6

Merged
sharang merged 1 commits from feat/m4.1-schema into main 2026-05-19 10:10:15 +00:00
13 changed files with 973 additions and 50 deletions
+4 -2
View File
@@ -71,8 +71,10 @@ jobs:
run: go vet ./... run: go vet ./...
- name: lint - name: lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v7
with: { version: latest } # 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 - name: test
# Coverage scoped to ./internal/... — cmd/server is the entrypoint # Coverage scoped to ./internal/... — cmd/server is the entrypoint
+1
View File
@@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
## [Unreleased] ## [Unreleased]
### Added ### 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 - 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
View File
@@ -1,15 +1,20 @@
# Multi-stage build for tenant-registry. # 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 FROM golang:1.24-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR / WORKDIR /
COPY --from=build /out/tenant-registry /tenant-registry COPY --from=build /out/tenant-registry /tenant-registry
COPY --from=build /out/migrate /migrate
USER nonroot:nonroot USER nonroot:nonroot
EXPOSE 8090 EXPOSE 8090
ENTRYPOINT ["/tenant-registry"] ENTRYPOINT ["/tenant-registry"]
+55 -9
View File
@@ -1,18 +1,33 @@
# tenant-registry — Go service for tenant glue, audit, API keys. # 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 ADDR ?= :8090
APP_ENV ?= dev APP_ENV ?= dev
DATABASE_URL ?= postgres://platform:platform-dev-pass@localhost:5432/platform?sslmode=disable
help: help:
@echo "tenant-registry targets:" @echo "tenant-registry targets:"
@echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)" @echo ""
@echo " make test go test -race ./..." @echo " Server:"
@echo " make build compile binary to ./bin/tenant-registry" @echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)"
@echo " make fmt go fmt ./..." @echo " make build compile to ./bin/tenant-registry"
@echo " make vet go vet ./..." @echo " make build-migrate compile to ./bin/migrate"
@echo " make docker build local image (tenant-registry:dev)" @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: dev:
@APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server @APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server
@@ -20,11 +35,19 @@ dev:
test: test:
@go test -race ./... @go test -race ./...
test-short:
@go test -race -short ./...
build: build:
@mkdir -p bin @mkdir -p bin
@CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server @CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server
@echo "built ./bin/tenant-registry" @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: fmt:
@gofmt -w . @gofmt -w .
@test -z "$$(gofmt -l .)" @test -z "$$(gofmt -l .)"
@@ -39,3 +62,26 @@ docker:
clean: clean:
@rm -rf bin @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"
+33 -1
View File
@@ -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. 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 ## Deployment
+138
View File
@@ -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)
}
}
+67 -1
View File
@@ -1,3 +1,69 @@
module gitea.meghsakha.com/platform/tenant-registry 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
)
+171
View File
@@ -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=
+3 -3
View File
@@ -27,7 +27,7 @@ func TestHealthz(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("got %d, want 200", resp.StatusCode) t.Fatalf("got %d, want 200", resp.StatusCode)
} }
@@ -41,7 +41,7 @@ func TestTenantBySlug_acme(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
t.Fatalf("got %d, want 200; body=%s", resp.StatusCode, 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound { if resp.StatusCode != http.StatusNotFound {
t.Fatalf("got %d, want 404", resp.StatusCode) t.Fatalf("got %d, want 404", resp.StatusCode)
} }
+18 -2
View File
@@ -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 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_products;
DROP TABLE IF EXISTS tenant_projects;
DROP TABLE IF EXISTS tenants; 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
View File
@@ -1,52 +1,197 @@
-- Placeholder for the M4.1 schema (see PLATFORM_ARCHITECTURE.md §5c). -- M4.1 — initial tenant_registry schema.
-- The skeleton uses an in-memory store; this file lands the table shape -- Source of truth: PLATFORM_ARCHITECTURE.md §5c.
-- the real M4.1 PR will use, so the schema review can happen alongside -- Forward-only per IMPLEMENTATION_PLAN.md §1.7.
-- the rest of the boot scaffolding.
-- enums -------------------------------------------------------------------- -- =========================================================================
-- enums
-- =========================================================================
CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo'); CREATE TYPE tenant_status AS ENUM (
CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal'); '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 ( CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9-]{2,40}$'), slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'),
name TEXT NOT NULL, name TEXT NOT NULL,
status tenant_status NOT NULL DEFAULT 'trial', status tenant_status NOT NULL DEFAULT 'trial',
kind tenant_kind NOT NULL DEFAULT 'customer', kind tenant_kind NOT NULL DEFAULT 'customer',
plan TEXT NOT NULL DEFAULT 'starter', plan TEXT NOT NULL DEFAULT 'starter',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- External system references (one-to-one per §5c "Links")
trial_ends_at TIMESTAMPTZ 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_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 ( CREATE TABLE tenant_products (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
product TEXT NOT NULL, product TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, product) 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_idp_config — external identity provider per tenant (enterprise SSO).
tenant_id UUID REFERENCES tenants(id), -- metadata holds OIDC discovery URL + client_id, or SAML cert + entity_id.
actor_id TEXT, -- =========================================================================
actor_name TEXT,
action TEXT NOT NULL, CREATE TABLE tenant_idp_config (
target_id TEXT, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_type TEXT, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type idp_kind NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ip INET, verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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();
+10
View File
@@ -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
+291
View File
@@ -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)
}
}
}