controlplane

package
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrAlreadyExists = errors.New("already exists")
	ErrNotFound      = errors.New("not found")
	ErrInvalidInput  = errors.New("invalid input")
)

Sentinel errors used by the service layer and mapped to HTTP status codes by the control plane HTTP handlers.

Functions

func BackfillSchemaHistory

func BackfillSchemaHistory(ctx context.Context, pool *pgxpool.Pool) (int, error)

BackfillSchemaHistory captures the CURRENT schema of every pre-versioning tenant (json_schema set, history empty) as its v1 — run once at boot, so the history is immediately useful on an existing install. Each schema is re-marshaled through schema.APISchema so its hash is canonical (raw jsonb text would hash differently than the engine's own marshaling and break the unchanged-schema dedup). Best-effort per tenant; returns the first error after attempting all.

func NewControlPlaneRouter

func NewControlPlaneRouter(svc Service, adminKey string) *chi.Mux

NewControlPlaneRouter builds the chi.Mux for the control plane API (port 9090). adminKey is compared against the X-Admin-Key request header on all routes except /health.

func SuggestTenantID

func SuggestTenantID(raw string) string

SuggestTenantID converts a rejected id into the closest VALID one: lowercased, with every separator and any other character DROPPED (mi-clinica → miclinica), leading non-letters trimmed, capped at 30. Separators are dropped rather than replaced because the replacement used to be '_', which the id rule no longer accepts — the suggestion has to be something that actually works. Returns "" when nothing salvageable remains; callers append it to the validation error so the user gets an actionable fix, not just a rule.

Types

type RegisterRequest

type RegisterRequest struct {
	TenantID    string            `json:"tenant_id"`
	DisplayName string            `json:"display_name"`
	Email       string            `json:"email"`
	Plan        string            `json:"plan"`
	Schema      *schema.APISchema `json:"schema"`
}

RegisterRequest carries all data needed to onboard a new tenant.

type RollbackResult

type RollbackResult struct {
	Outcome       *migration.ApplyOutcome
	TargetVersion int
	NewVersion    int
	Schema        *schema.APISchema
}

RollbackResult reports an applied rollback: the migration outcome (what was applied/gated, same shape as a deploy), the version rolled back TO, the NEW history version the rollback appended (0 if the history write failed — logged, never blocks the applied migration), and the schema now live.

type Service

type Service interface {
	Register(ctx context.Context, req RegisterRequest) (*Tenant, error)
	GetByID(ctx context.Context, id string) (*Tenant, error)
	// UpdateSchema applies a schema change with NO destructive approval (additive:
	// every drop is gated). Equivalent to UpdateSchemaApproved with no approved drops.
	UpdateSchema(ctx context.Context, id string, s *schema.APISchema) error
	// UpdateSchemaApproved applies a schema change, executing ONLY the destructive
	// drops whose approval key is in approvedDrops (DropTable "<table>" / DropColumn
	// "<table>.<column>"). With an empty slice it is identical to UpdateSchema (fail-
	// safe: nothing is dropped). It returns what was applied/gated for the response.
	UpdateSchemaApproved(ctx context.Context, id string, s *schema.APISchema, approvedDrops []string) (*migration.ApplyOutcome, error)
	// PreviewSchema computes a dry-run of the schema change WITHOUT applying it: the
	// classified plan plus the impact (rows lost) of each data-losing drop, evaluated
	// against the given approval set. It is the informed-consent surface.
	PreviewSchema(ctx context.Context, id string, s *schema.APISchema, approvedDrops []string) (*migration.Preview, error)
	GetSchema(ctx context.Context, id string) (*schema.APISchema, error)

	// ListSchemaHistory returns one page of the tenant's deployed-schema history,
	// newest first (append-only; the latest version is the current schema).
	ListSchemaHistory(ctx context.Context, id string, page, perPage int) (*schemahistory.Page, error)
	// GetSchemaVersion returns one recorded version WITH its full schema.
	GetSchemaVersion(ctx context.Context, id string, version int) (*schemahistory.Version, error)
	// RollbackSchema re-applies the stored schema of history version v — the SAME
	// diff→gate→apply migration path as UpdateSchemaApproved (NOT a second engine),
	// so what later versions added is reverted as gated destructive drops, and data
	// already lost by an approved forward drop is NOT recovered. Append-only: the
	// rollback records a NEW version whose content is v's.
	RollbackSchema(ctx context.Context, id string, version int, approvedDrops []string) (*RollbackResult, error)
}

Service is the control plane dependency injected into the HTTP handlers. All methods are safe to mock in unit tests.

func NewService

func NewService(pool *pgxpool.Pool, redisClient *redis.Client, opts ...ServiceOption) Service

NewService creates a production Service. redisClient may be nil — in that case schema updates are written to the DB only and pg_notify handles cache invalidation.

type ServiceOption

type ServiceOption func(*pgService)

ServiceOption configures NewService (variadic so existing call sites are untouched).

func WithProvisionHook

func WithProvisionHook(h TenantProvisionHook) ServiceOption

WithProvisionHook wires the consumer's per-tenant provisioning seam (ENG-8) into every registration this Service performs. See TenantProvisionHook.

type Tenant

type Tenant struct {
	ID          string    `json:"id"`
	PGSchema    string    `json:"pg_schema"`
	DisplayName string    `json:"display_name"`
	Email       string    `json:"email"`
	Plan        string    `json:"plan"`
	CreatedAt   time.Time `json:"created_at"`
}

Tenant is the created tenant record returned to the caller.

func RegisterTenant

func RegisterTenant(ctx context.Context, pool *pgxpool.Pool, req RegisterRequest) (*Tenant, error)

RegisterTenant onboards a new tenant in 10 atomic steps:

  1. Validate tenantID format.
  2. Verify no duplicate in public.tenants. 3-7. Transaction: INSERT tenant + CREATE SCHEMA + INSERT policy → COMMIT.
  3. ApplyTenantMigration: CREATE TABLE for each resource (+ the optional TenantProvisionHook — consumer DDL — via RegisterTenantWithHook).
  4. pg_notify('schema_updated', tenantID).
  5. Return the created Tenant.

func RegisterTenantWithHook

func RegisterTenantWithHook(ctx context.Context, pool *pgxpool.Pool, req RegisterRequest, hook TenantProvisionHook) (*Tenant, error)

RegisterTenantWithHook is RegisterTenant with a consumer provisioning hook (nil = identical to RegisterTenant). See TenantProvisionHook.

type TenantProvisionHook

type TenantProvisionHook func(ctx context.Context, pool *pgxpool.Pool, tenantID, pgSchema string) error

TenantProvisionHook is a consumer's per-tenant provisioning seam (ENG-8, CONSUMER-PATH-S1): it runs INSIDE tenant registration, after the engine has provisioned the tenant's tables, so consumer-owned DDL (generated columns, CHECK constraints, partial indexes — the things Config.BeforeStart applies at boot) reaches tenants created while the app is LIVE. Before this seam existed, the normal SaaS flow — install → boot → register tenant — produced a tenant missing the consumer's DDL, and a core endpoint answered 500 until a manual restart re-ran BeforeStart (measured on the 58, commerce GAPS 3-6).

The hook is part of the registration's all-or-nothing contract: an error rolls the whole registration back (no tenant is left half-provisioned) and is returned to the caller. It MUST be idempotent — BeforeStart typically re-runs the same DDL over all tenants at every boot.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL