From f9e9f0e21b50c41717d25c6c5ea5a2ca6bd8eb8f Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 19 May 2026 12:06:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(schema):=20M4.1=20=E2=80=94=20full=20tenan?= =?UTF-8?q?t=5Fregistry=20schema=20+=20migrate=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLATFORM_ARCHITECTURE.md §5c schema, end-to-end: enums: tenant_status (demo/trial/active/frozen/archived), tenant_kind (customer/demo), idp_kind (oidc/saml), tenant_project_status (active/archived) tables: tenants id/slug/name/status/kind/plan/erp_id/ stripe_id/trial_ends_at/contract_dates/ sales_owner tenant_projects sub-tenancy (GCP-Project style); opt-in via product manifest.supports_projects=true tenant_products tenant ↔ product matrix + JSONB config tenant_idp_config enterprise SSO (OIDC/SAML metadata) api_keys argon2 hash + prefix + scopes + revoked_at audit_log Retraced-compatible; indexed for cross- product filtering per §8.4 triggers: updated_at auto-bump on every mutable table fks: ON DELETE CASCADE for owned rows; SET NULL for audit_log cmd/migrate (new binary): golang-migrate as a library with migrations embedded via migrations/embed.go; subcommands up/down/version/force. Ships as a self-contained Orca init container in prod. Tests (require Docker; gated by -short): TestMigrate_upDownRoundTrip schema → 6 tables + 4 enums; down→ empty; up-after-down clean TestSeed_canInsertAndQuery insert across every table; FK cascade; audit_log SET-NULL keeps the row TestSlugConstraint regex rejects too-short / leading dash / trailing dash / uppercase / underscore Makefile: migrate-up/down/down-all/version/create NAME=...; test-short to skip integration when Docker isn't around; build-migrate for just the migrator. CI: pin golangci-lint to v2.12.2 (Go 1.25-compatible) + bump golangci-lint-action to v7 (v6 rejects v2.x). The handler-layer in-memory store is unchanged; M4.2 swaps it for the pgx-backed implementation against this schema. Refs: M4.1 --- .gitea/workflows/ci.yaml | 6 +- CHANGELOG.md | 1 + Dockerfile | 9 +- Makefile | 64 +++++++- README.md | 34 +++- cmd/migrate/main.go | 138 ++++++++++++++++ go.mod | 68 +++++++- go.sum | 171 +++++++++++++++++++ internal/server/server_test.go | 6 +- migrations/0001_init.down.sql | 20 ++- migrations/0001_init.up.sql | 205 +++++++++++++++++++---- migrations/embed.go | 10 ++ migrations/migrations_test.go | 291 +++++++++++++++++++++++++++++++++ 13 files changed, 973 insertions(+), 50 deletions(-) create mode 100644 cmd/migrate/main.go create mode 100644 go.sum create mode 100644 migrations/embed.go create mode 100644 migrations/migrations_test.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c2dd182..bc9c7c7 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b696613..ec8ced9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - diff --git a/Dockerfile b/Dockerfile index 0645f05..d83fa9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index d0eb612..e041104 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/README.md b/README.md index 9086d0d..cc606aa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..fa359a1 --- /dev/null +++ b/cmd/migrate/main.go @@ -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 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 >\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 ") + } + 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) + } +} diff --git a/go.mod b/go.mod index 869d5c4..4f156b8 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..edd51c8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6ec0fe2..c5f2779 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) } diff --git a/migrations/0001_init.down.sql b/migrations/0001_init.down.sql index 793ebb3..e1cb570 100644 --- a/migrations/0001_init.down.sql +++ b/migrations/0001_init.down.sql @@ -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; diff --git a/migrations/0001_init.up.sql b/migrations/0001_init.up.sql index f2cc3e1..729450d 100644 --- a/migrations/0001_init.up.sql +++ b/migrations/0001_init.up.sql @@ -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(); diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..4d02c94 --- /dev/null +++ b/migrations/embed.go @@ -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 diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go new file mode 100644 index 0000000..04d767d --- /dev/null +++ b/migrations/migrations_test.go @@ -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) + } + } +} -- 2.52.0