feat(server): tenant-registry skeleton boots against dev stack
ci / shared (push) Successful in 4s
ci / test (push) Successful in 11s
ci / image (push) Has been skipped

Minimal Go service: /healthz + /v1/tenants/by-slug/:slug + /v1/tenants/:id with an in-memory store seeded with the acme tenant. Stdlib-only; pgx + JWT validation land in M4.1 follow-up.
This commit was merged in pull request #4.
This commit is contained in:
2026-05-19 09:35:04 +00:00
parent e960a5ff9d
commit af9f331781
16 changed files with 620 additions and 12 deletions
+6 -3
View File
@@ -57,12 +57,12 @@ jobs:
test:
runs-on: docker
if: hashFiles('go.sum') != ''
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
with: { go-version: '1.24' }
- name: fmt
run: test -z "$(gofmt -l .)"
@@ -75,7 +75,10 @@ jobs:
with: { version: latest }
- name: test
run: go test -race -coverprofile=cover.out ./...
# Coverage scoped to ./internal/... — cmd/server is the entrypoint
# with signal-handling + bind that isn't worth unit-testing. When
# real integration tests land in M4.1, widen this back to ./...
run: go test -race -coverprofile=cover.out ./internal/...
- name: coverage gate
run: |
+1
View File
@@ -35,3 +35,4 @@ vendor/
# Rust
**/target/
bin/
+1
View File
@@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
## [Unreleased]
### Added
- 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
-
### Changed
+15
View File
@@ -0,0 +1,15 @@
# Multi-stage build for tenant-registry.
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/tenant-registry ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /
COPY --from=build /out/tenant-registry /tenant-registry
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/tenant-registry"]
+41
View File
@@ -0,0 +1,41 @@
# tenant-registry — Go service for tenant glue, audit, API keys.
.PHONY: help dev test build fmt vet lint docker clean
ADDR ?= :8080
APP_ENV ?= dev
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)"
dev:
@APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server
test:
@go test -race ./...
build:
@mkdir -p bin
@CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server
@echo "built ./bin/tenant-registry"
fmt:
@gofmt -w .
@test -z "$$(gofmt -l .)"
vet:
@go vet ./...
lint: fmt vet
docker:
@docker build -t tenant-registry:dev .
clean:
@rm -rf bin
+41 -9
View File
@@ -20,19 +20,51 @@ Multi-tenant glue: orgs, entitlements, API keys, audit. Scaffolded under milesto
## Run locally
```bash
# prerequisites: see CONTRIBUTING.md for tooling once code lands
make dev # starts dependencies + this service on http://localhost:8080
make test # unit + integration
make e2e # only if this repo ships user-facing flows
# Prerequisites: Go 1.25+
# Dependencies (Keycloak, pg-app) come from the dev stack — see platform/orca-platform/dev.
# In one terminal — bring up dev dependencies (in the orca-platform clone):
cd /path/to/platform/orca-platform && make dev-up
# In another — run the service:
make dev # APP_ENV=dev, listens on :8080
make test # unit tests
make build # compile to ./bin/tenant-registry
```
Local secrets come from `.env.local` (gitignored). Template at `.env.example`.
Env vars (override at the shell):
## Endpoints / surface
| Var | Default | Purpose |
|---|---|---|
| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` |
| `ADDR` | `:8080` | listen address |
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL |
| `DATABASE_URL` | empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR |
{{For services: list the top-level routes or commands.
For libraries: list the public API entry points.
For IaC: list the make targets.}}
## Endpoints
| Method | Path | Returns |
|---|---|---|
| GET | `/healthz` | `{"status":"ok"}` — liveness probe |
| GET | `/v1/tenants/by-slug/{slug}` | 200 with tenant JSON, 404 if missing |
| GET | `/v1/tenants/{id}` | 200 with tenant JSON, 404 if missing |
The skeleton's store is in-memory and pre-seeded with one tenant:
```json
{
"id": "00000000-0000-0000-0000-000000000001",
"slug": "acme",
"name": "Acme Inc.",
"status": "active",
"plan": "professional",
"products": ["certifai", "compliance"]
}
```
So `curl http://localhost:8080/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.
## Deployment
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
slog.Error("config load failed", "err", err)
os.Exit(1)
}
mux := server.NewRouter(cfg, logger)
srv := &http.Server{
Addr: cfg.Addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server crashed", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutdown requested")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown failed", "err", err)
os.Exit(1)
}
slog.Info("bye")
}
+3
View File
@@ -0,0 +1,3 @@
module gitea.meghsakha.com/platform/tenant-registry
go 1.24
+33
View File
@@ -0,0 +1,33 @@
package config
import (
"fmt"
"os"
)
type Config struct {
Env string // dev | stage | prod
Addr string // listen address, e.g. ":8080"
KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev
DatabaseURL string // postgres DSN (unused in skeleton; in-memory store)
}
func Load() (*Config, error) {
env := getenv("APP_ENV", "dev")
if env != "dev" && env != "stage" && env != "prod" {
return nil, fmt.Errorf("invalid APP_ENV %q", env)
}
return &Config{
Env: env,
Addr: getenv("ADDR", ":8080"),
KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"),
DatabaseURL: os.Getenv("DATABASE_URL"),
}, nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"testing"
)
func TestLoad_defaults(t *testing.T) {
t.Setenv("APP_ENV", "")
t.Setenv("ADDR", "")
t.Setenv("KEYCLOAK_ISSUER", "")
t.Setenv("DATABASE_URL", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Env != "dev" {
t.Errorf("Env = %q, want dev", cfg.Env)
}
if cfg.Addr != ":8080" {
t.Errorf("Addr = %q, want :8080", cfg.Addr)
}
if cfg.KeycloakIssuer == "" {
t.Error("KeycloakIssuer is empty; expected a default")
}
if cfg.DatabaseURL != "" {
t.Errorf("DatabaseURL = %q, want empty default", cfg.DatabaseURL)
}
}
func TestLoad_overrides(t *testing.T) {
t.Setenv("APP_ENV", "stage")
t.Setenv("ADDR", ":9000")
t.Setenv("KEYCLOAK_ISSUER", "https://auth.example/realms/r")
t.Setenv("DATABASE_URL", "postgres://x")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Env != "stage" || cfg.Addr != ":9000" || cfg.KeycloakIssuer != "https://auth.example/realms/r" || cfg.DatabaseURL != "postgres://x" {
t.Errorf("overrides not applied: %+v", cfg)
}
}
func TestLoad_invalidEnv(t *testing.T) {
t.Setenv("APP_ENV", "bogus")
_, err := Load()
if err == nil {
t.Fatal("expected error for invalid APP_ENV")
}
}
+106
View File
@@ -0,0 +1,106 @@
package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
type deps struct {
cfg *config.Config
log *slog.Logger
tenant *store.Memory
}
func NewRouter(cfg *config.Config, log *slog.Logger) http.Handler {
d := &deps{cfg: cfg, log: log, tenant: store.NewMemory()}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", d.healthz)
mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", d.tenantBySlug)
mux.HandleFunc("GET /v1/tenants/{id}", d.tenantByID)
return logRequest(log)(mux)
}
func (d *deps) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (d *deps) tenantBySlug(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
t, err := d.tenant.BySlug(ctx, slug)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "tenant_not_found", "no tenant with that slug")
return
}
if err != nil {
d.log.Error("tenant lookup failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "lookup failed")
return
}
writeJSON(w, http.StatusOK, t)
}
func (d *deps) tenantByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
t, err := d.tenant.ByID(ctx, id)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "tenant_not_found", "no tenant with that id")
return
}
if err != nil {
d.log.Error("tenant lookup failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "lookup failed")
return
}
writeJSON(w, http.StatusOK, t)
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, code int, kind, msg string) {
writeJSON(w, code, map[string]string{"error": kind, "message": msg})
}
func logRequest(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &statusRecorder{ResponseWriter: w, code: 200}
next.ServeHTTP(ww, r)
log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", ww.code,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
type statusRecorder struct {
http.ResponseWriter
code int
}
func (s *statusRecorder) WriteHeader(c int) {
s.code = c
s.ResponseWriter.WriteHeader(c)
}
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
)
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
cfg := &config.Config{Env: "dev", Addr: ":0"}
h := NewRouter(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
return httptest.NewServer(h)
}
func TestHealthz(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("got %d, want 200", resp.StatusCode)
}
}
func TestTenantBySlug_acme(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/acme")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("got %d, want 200; body=%s", resp.StatusCode, body)
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload["slug"] != "acme" {
t.Fatalf("expected slug=acme, got %v", payload["slug"])
}
if payload["status"] != "active" {
t.Fatalf("expected status=active, got %v", payload["status"])
}
}
func TestTenantBySlug_unknown(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/nope")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("got %d, want 404", resp.StatusCode)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package store is a stand-in for the real Postgres-backed tenant store.
// The skeleton ships an in-memory implementation pre-seeded with one tenant
// (acme) so portal middleware has something to resolve in local dev.
// Replace with a pgx-backed implementation in the M4.1 follow-up PR.
package store
import (
"context"
"errors"
"sync"
"time"
)
var ErrNotFound = errors.New("tenant not found")
type Tenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"` // active | trial | frozen | archived | demo
Plan string `json:"plan"` // starter | professional | enterprise
Products []string `json:"products"`
CreatedAt time.Time `json:"created_at"`
}
type Memory struct {
mu sync.RWMutex
bySlug map[string]*Tenant
byID map[string]*Tenant
}
func NewMemory() *Memory {
m := &Memory{
bySlug: make(map[string]*Tenant),
byID: make(map[string]*Tenant),
}
seed := &Tenant{
ID: "00000000-0000-0000-0000-000000000001",
Slug: "acme",
Name: "Acme Inc.",
Status: "active",
Plan: "professional",
Products: []string{"certifai", "compliance"},
CreatedAt: time.Now().UTC(),
}
m.bySlug[seed.Slug] = seed
m.byID[seed.ID] = seed
return m
}
func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) {
m.mu.RLock()
defer m.mu.RUnlock()
t, ok := m.bySlug[slug]
if !ok {
return nil, ErrNotFound
}
cp := *t
return &cp, nil
}
func (m *Memory) ByID(_ context.Context, id string) (*Tenant, error) {
m.mu.RLock()
defer m.mu.RUnlock()
t, ok := m.byID[id]
if !ok {
return nil, ErrNotFound
}
cp := *t
return &cp, nil
}
+64
View File
@@ -0,0 +1,64 @@
package store
import (
"context"
"errors"
"testing"
)
func TestMemory_seededAcme(t *testing.T) {
m := NewMemory()
ctx := context.Background()
t.Run("by slug returns seed", func(t *testing.T) {
got, err := m.BySlug(ctx, "acme")
if err != nil {
t.Fatal(err)
}
if got.Slug != "acme" {
t.Errorf("slug = %q, want acme", got.Slug)
}
if got.Status != "active" {
t.Errorf("status = %q, want active", got.Status)
}
if len(got.Products) != 2 {
t.Errorf("products = %v, want [certifai compliance]", got.Products)
}
})
t.Run("by id returns seed", func(t *testing.T) {
got, err := m.ByID(ctx, "00000000-0000-0000-0000-000000000001")
if err != nil {
t.Fatal(err)
}
if got.Slug != "acme" {
t.Errorf("slug = %q, want acme", got.Slug)
}
})
t.Run("missing slug returns ErrNotFound", func(t *testing.T) {
_, err := m.BySlug(ctx, "nope")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
t.Run("missing id returns ErrNotFound", func(t *testing.T) {
_, err := m.ByID(ctx, "deadbeef")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
t.Run("returned tenant is a copy, not the stored pointer", func(t *testing.T) {
got, err := m.BySlug(ctx, "acme")
if err != nil {
t.Fatal(err)
}
got.Name = "mutated"
got2, _ := m.BySlug(ctx, "acme")
if got2.Name == "mutated" {
t.Error("store leaked internal pointer; caller could mutate seeded state")
}
})
}
+5
View File
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS tenant_products;
DROP TABLE IF EXISTS tenants;
DROP TYPE IF EXISTS tenant_kind;
DROP TYPE IF EXISTS tenant_status;
+52
View File
@@ -0,0 +1,52 @@
-- 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.
-- enums --------------------------------------------------------------------
CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo');
CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal');
-- tenants ------------------------------------------------------------------
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
);
CREATE INDEX tenants_status_idx ON tenants (status);
-- tenant ↔ product entitlements -------------------------------------------
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,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, product)
);
-- audit log (Retraced-shape; PRODUCT_INTEGRATION_SPEC.md §8.4) ------------
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,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ip INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);