Composable control plane library for deploying and managing SaaS instances at scale.
Module: github.com/xraph/ctrlplane (Go 1.25.7)
Foundation: github.com/xraph/forge
Status: Active development — architecture docs in _project_files/ are the source of truth.
go build ./...
go test ./...
golangci-lint run ./...
goimports -w -local github.com/xraph/ctrlplane .The .golangci.yml at the project root is authoritative (v2 format, default: none, explicit enable list). It mirrors the Forge project's configuration.
ctrlplane.go, config.go, errors.go, options.go, doc.go # Root package
id/ # TypeID wrapper (prefix-qualified, UUIDv7-based identifiers)
auth/ # Auth abstraction + adapters (authsome/, forgeauth/)
instance/ # Instance lifecycle (entity, service, store, state machine)
deploy/ # Deployments & releases (strategies: rolling, blue-green, canary)
health/ # Health checks (http, tcp, grpc, command checkers)
telemetry/ # Metrics, logs, traces, resource snapshots
provider/ # Cloud provider abstraction + implementations
kubernetes/, nomad/, docker/, aws/, gcp/, azure/, fly/
network/ # Domains, routes, TLS certificates
secrets/ # Secret management + vault interface
admin/ # Tenant management, quotas, audit
event/ # Event bus (inmemory, nats, redis) + webhooks
worker/ # Background workers (reconciler, health runner, GC, cert renewer)
store/ # Aggregate store interface + implementations
postgres/, sqlite/, memory/
api/ # HTTP handlers + middleware
extension/ # Forge extension adapter
cmd/ # Reference binary
All entity IDs use id.ID (which wraps typeid.TypeID from go.jetify.com/typeid/v2). IDs are prefix-qualified, globally unique, sortable, and URL-safe in the format prefix_suffix (e.g., inst_01h455vb4pex5vsknk084sn02q). Never use uuid.UUID or string for entity identifiers.
import "github.com/xraph/ctrlplane/id"
entity := Entity{ID: id.New(id.PrefixInstance)}
parsed, err := id.Parse(s)
parsedWithPrefix, err := id.ParseWithPrefix(s, id.PrefixInstance)Each entity type has a dedicated prefix constant (id.PrefixInstance, id.PrefixDeployment, etc.). The id.New() function requires a prefix argument.
Every domain entity embeds ctrlplane.Entity:
type Instance struct {
ctrlplane.Entity // ID, CreatedAt, UpdatedAt
TenantID string `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
}Create entities with ctrlplane.NewEntity(prefix) which sets a prefix-qualified ID and timestamps (UTC).
Every exported struct field that is persisted or serialized must have tags:
// Domain entity — json + db
Field string `json:"field_name" db:"field_name"`
// Config struct — json + env, optionally default
Field string `json:"field_name" env:"CP_FIELD_NAME" default:"value"`
// Request DTO — json, optionally validate
Field string `json:"field_name" validate:"required"`
// Internal/secret — suppress serialization
Value []byte `json:"-" db:"value"`Use snake_case for json and db tags. Use CP_ prefix for env tags.
The root CtrlPlane and extension use functional options:
type Option func(*CtrlPlane) error
func WithStore(s store.Store) Option {
return func(cp *CtrlPlane) error {
cp.store = s
return nil
}
}Options return error. Validate inside the option function.
Providers and implementations use variadic options with sane defaults:
func New(opts ...Option) (*Provider, error) {
p := &Provider{
cfg: Config{
Namespace: "default", // sane default
Region: "local",
},
}
for _, opt := range opts {
if err := opt(p); err != nil {
return nil, err
}
}
// post-option validation and client initialization
return p, nil
}Each provider has an options.go with type Option func(*Provider) error and With* functions. A WithConfig(cfg Config) option bridges env/file-based configuration.
Every method that does I/O or may block takes context.Context as first parameter:
func (s *service) Get(ctx context.Context, id id.ID) (*Instance, error)Each subsystem has a Service interface following CRUD + domain actions:
type Service interface {
Create(ctx context.Context, req CreateRequest) (*Entity, error)
Get(ctx context.Context, id id.ID) (*Entity, error)
List(ctx context.Context, opts ListOptions) (*ListResult, error)
Update(ctx context.Context, id id.ID, req UpdateRequest) (*Entity, error)
Delete(ctx context.Context, id id.ID) error
}Store methods always accept tenantID for multi-tenant scoping:
type Store interface {
Insert(ctx context.Context, entity *Entity) error
GetByID(ctx context.Context, tenantID string, id id.ID) (*Entity, error)
List(ctx context.Context, tenantID string, opts ListOptions) (*ListResult, error)
Update(ctx context.Context, entity *Entity) error
Delete(ctx context.Context, tenantID string, id id.ID) error
}Thread-safe with sync.RWMutex. Use defer for unlocking, RLock for reads, Lock for writes:
type Registry struct {
mu sync.RWMutex
providers map[string]Provider
}Valid transitions defined as maps. Always validate before updating state:
var validTransitions = map[State][]State{
StateProvisioning: {StateStarting, StateFailed, StateDestroying},
StateStarting: {StateRunning, StateFailed},
}CreateRequest,UpdateRequest,ScaleRequest,DeployRequestListOptions(cursor-based pagination),ListResult(items + next cursor + total)- Pointer fields in update requests for partial updates:
Name *string
Defined in root errors.go. Always use as wrapping targets:
var (
ErrNotFound = errors.New("ctrlplane: resource not found")
ErrAlreadyExists = errors.New("ctrlplane: resource already exists")
ErrInvalidState = errors.New("ctrlplane: invalid state transition")
ErrProviderNotFound = errors.New("ctrlplane: provider not registered")
ErrUnauthorized = errors.New("ctrlplane: unauthorized")
ErrForbidden = errors.New("ctrlplane: forbidden")
ErrQuotaExceeded = errors.New("ctrlplane: quota exceeded")
)Always wrap with %w and add context:
// Correct
return fmt.Errorf("instance %s: %w", instanceID, ctrlplane.ErrNotFound)
// Wrong — loses error chain
return fmt.Errorf("failed: %v", err)Use errors.Is and errors.As. Never compare error strings.
| Linter | What to Do |
|---|---|
| errcheck | Handle every returned error. Use _ = only with justifying comment. |
| errorlint | Use %w in fmt.Errorf. Use errors.Is/errors.As not ==. |
| errname | Error vars use Err prefix: ErrNotFound, not NotFoundError. |
| errchkjson | Check errors from json.Marshal/json.Unmarshal. |
| govet | Watch struct field alignment, printf format strings. |
| staticcheck | No deprecated APIs, proper context cancellation. |
| bodyclose | Always defer resp.Body.Close() after nil-error check. |
| noctx | Use http.NewRequestWithContext, never http.Get/http.Post. |
| nilerr | Never return nil inside if err != nil. |
| nilnil | Avoid returning (nil, nil) from functions with (*T, error) signature where confusing. |
| ineffassign | Remove dead assignments. |
| unconvert | Remove redundant type conversions. |
| misspell | Check comments and strings for typos. |
| gocritic | Prefer strings.Contains over strings.Index >= 0, etc. |
| gosec | No hardcoded creds, correct crypto/rand vs math/rand. |
| fatcontext | Never ctx = context.WithValue(...) in a loop. Build context outside. |
| containedctx | Do not store context.Context as a struct field. |
| sqlclosecheck | Always defer rows.Close(). |
| makezero | Use make([]T, 0, cap) then append, not make([]T, n) then append. |
| godot | End exported doc comments with a period. |
| dupword | No duplicate words in comments ("the the", "is is"). |
| durationcheck | Never multiply two time.Duration values together. |
| perfsprint | Use strconv.Itoa over fmt.Sprintf("%d", n) when simple. |
| nlreturn | Add blank line before return in multi-statement functions. |
| wsl_v5 | Blank line after block close, before conditionals. |
| gochecknoinits | Do not use func init(). Use explicit constructors. |
| nakedret | Never use naked returns. Always return explicit values. |
| decorder | Keep type, const, var, func in consistent order. |
| copyloopvar | Loop variables are copied in Go 1.22+. No manual copies needed. |
| intrange | Use for i := range n instead of for i := 0; i < n; i++. |
| modernize | Use modern Go idioms (e.g., slices package, any over interface{}). |
| usestdlibvars | Use http.StatusOK not 200, http.MethodGet not "GET". |
| usetesting | Use t.TempDir() not os.MkdirTemp, t.Setenv() not os.Setenv. |
| canonicalheader | Use http.CanonicalHeaderKey for header names. |
| sloglint | Structured log attributes must be key-value pairs. |
| tagalign | Struct tags must be aligned within a struct. |
| reassign | Do not reassign package-level variables. |
| spancheck | Always defer span.End() after starting a trace span. |
| rowserrcheck | Always check rows.Err() after iterating. |
| protogetter | Use getter methods on protobuf messages, not direct field access. |
| importas | Use consistent import aliases. |
| predeclared | Do not shadow built-in identifiers (len, cap, error, etc.). |
| bidichk | No invisible Unicode bidirectional characters. |
| asciicheck | No non-ASCII identifiers. |
exhaustive, exhaustruct, wrapcheck, ireturn, funlen, cyclop, gocognit, gocyclo, dupl, lll, mnd, prealloc, paralleltest, testpackage, revive, unused, unparam, err113, depguard, contextcheck, forcetypeassert, goconst, gochecknoglobals, godox, nestif, noinlineerr, nosprintfhostport, interfacebloat, iface, funcorder, maintidx, unqueryvet, testifylint, thelper, nonamedreturns, tagliatelle, varnamelen
Three groups separated by blank lines. Use goimports with local prefix:
import (
// Standard library
"context"
"fmt"
"time"
// Third-party
"go.jetify.com/typeid/v2"
"github.com/uptrace/bun"
"go.uber.org/zap"
// Local modules
"github.com/xraph/ctrlplane/id"
"github.com/xraph/ctrlplane/provider"
)The project uses gofumpt with extra-rules: true:
- No empty lines at start/end of function body.
- No empty lines around a lone statement in a block.
- Grouped
var/constblocks for related declarations.
- Tests in
*_test.goin the same package (white-box testing). - Use table-driven tests as the default pattern.
- Use
t.Helper()in all test helpers. - Use
t.Cleanup()for teardown, notdefer. - Use
t.TempDir()andt.Setenv()instead ofos.*equivalents. - In-memory store (
store/memory/) is the default for unit tests. - Prefer stdlib
testing— usetestifyonly if already in the dependency tree.
func TestValidTransition(t *testing.T) {
tests := []struct {
name string
from InstanceState
to InstanceState
wantErr bool
}{
{"provisioning to starting", StateProvisioning, StateStarting, false},
{"running to destroyed", StateRunning, StateDestroyed, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTransition(tt.from, tt.to)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateTransition(%s, %s) error = %v, wantErr %v",
tt.from, tt.to, err, tt.wantErr)
}
})
}
}- Every package has a
doc.gowith a package-level comment. - Every exported type, function, method, const, and var has a doc comment.
- Doc comments start with the symbol name and end with a period.
// Instance represents a managed application instance belonging to a tenant.
type Instance struct { ... }
// ErrNotFound indicates the requested resource does not exist.
var ErrNotFound = errors.New("ctrlplane: resource not found")- Using UUID instead of TypeID. All IDs are
id.ID(TypeID).id.New()requires a prefix constant. Never use raw UUIDs or strings for entity identifiers. - Forgetting tenant scoping. Every query must filter by
tenantID. Store methods enforce this. - Storing
context.Contextin structs. Pass it as a function parameter.containedctxcatches this. - Using
http.Get/http.Post. Usehttp.NewRequestWithContext+client.Do.noctxenforces this. - Forgetting
defer rows.Close()ordefer resp.Body.Close().sqlclosecheck/bodyclosecatch these. - Using
%vinstead of%w. Always use%wto preserve the error chain. - Using
func init(). Prohibited. Use explicit constructors and options. - Naked returns. Always specify return values explicitly.
- Missing error checks. Every returned error must be checked.
- Multiplying two
time.Durationvalues.time.Second * time.Secondis wrong. make([]T, n)thenappend. Usemake([]T, 0, n)thenappend.- Missing blank line before
return. Required bynlreturnin multi-statement functions. - Comments without terminal period. Required by
godoton exported symbols. context.WithValuein a loop. Build context outside the loop.fatcontextflags this.
Every entity has TenantID string. All store methods require it. Auth middleware extracts tenant from claims and places it in context via auth.ClaimsFrom(ctx).
| Doc | Covers |
|---|---|
_project_files/00-ARCHITECTURE.md |
High-level design, module layout, usage modes |
_project_files/01-CORE-TYPES.md |
Entity, Config, Errors, Options, CtrlPlane struct |
_project_files/02-AUTH-ABSTRACTION.md |
Auth interface, claims, context helpers, adapters |
_project_files/03-PROVIDER-LAYER.md |
Provider interface, registry, capability system |
_project_files/04-INSTANCE-DEPLOY.md |
Instance lifecycle, state machine, deploy strategies |
_project_files/05-HEALTH-TELEMETRY.md |
Health checks, telemetry collection, TimescaleDB |
_project_files/06-NETWORK-SECRETS-ADMIN.md |
Domains, routes, TLS, secrets vault, admin/quotas |
_project_files/07-EVENTS-WORKERS.md |
Event bus, worker scheduler, webhooks |
_project_files/08-API-STORE.md |
HTTP routes, middleware, store implementations |
_project_files/09-FORGE-EXTENSION.md |
Forge extension adapter, OpenAPI, integration |
Always read the relevant architecture doc before implementing a subsystem.