Skip to content

Blueprint Catalog

Authoritative bounded-context reference for the Blueprint Catalog (internal/provisioning/blueprints/). It owns the durable record of the curated provisioning recipes a Project can request: which blueprints exist, which versions each blueprint has published, and the contract every version exposes to the Provisioning Broker. Each catalog entry pairs an immutable bundle of Crossplane v2 manifests — a CompositeResourceDefinition and a Composition — with a typed parameter schema and the rule describing how a request is threaded into the rendered Composite Resource.

The context has no HTTP surface: callers reach it through the in-process CatalogService facade, exactly as the sibling cloudcredentials and managementfleet sub-contexts do. The closed port set keeps the domain layer free of pgx and the Kubernetes client libraries — the domain aggregates model the XRD and Composition manifests as opaque []byte blobs and never decode them (services/ports.go). This milestone catalogs blueprints, publishes immutable versions, validates manifests and parameter values, and seeds five platform blueprints; resolving a Project request against a version and rendering the Composite Resource stay with the Provisioning Broker.

This reference is a single page: the surface is narrow (two aggregates, two value-object enums, one parameter-schema value object, nine sentinels, five seeds) and the pieces travel in lockstep with the package-level pin in doc.go.

Cross-references

Ubiquitous language

The terms below travel together across the Go code, the SQL migration, the domain-event payloads, and the embedded catalog metadata. Names are preserved verbatim so a reader chasing a string from one surface finds it in the others without translation.

TermDefinitionCode anchor
BlueprintThe catalog-entry aggregate root. Carries (id, slug, domainID, displayName, description, status, createdAt, updatedAt). The application mints a UUIDv7 id; the slug is the unique kebab-case operator handle. The Blueprint itself holds no manifests — those live on its versions. Fields are unexported and reached through value-receiver accessors so the creation invariants hold only through the constructors.blueprint.go
BlueprintVersionThe immutable versioned unit owned by a Blueprint. Bundles the XRD and Composition manifest blobs, the ParameterSchema, the closed set of accepted ProviderKinds, and the InjectionStrategy. Keyed in the domain by (blueprintID, version). It exposes no mutator methods: a correction is a new version, never an edit.blueprint_version.go
IDThe UUIDv7 identity used by both aggregates, a named wrapper over uuid.UUID. The String() projection is the canonical hyphenated lowercase form; the zero value is "not yet assigned" and is rejected by every invariant that requires a concrete reference.types.go
SlugThe kebab-case handle for a Blueprint — a value object validated against ^[a-z0-9]+(-[a-z0-9]+)*$ and capped at 63 characters so it fits inside a single DNS label. ParseSlug rejects, never trims, leading/trailing whitespace.types.go
StatusThe closed-enum lifecycle discriminator on a Blueprint: active (offerable) or retired (kept for history, not offered). Mutable — the one field of either aggregate a write path may advance.types.go
ProviderKindA closed enumeration {aws, hetzner, openstack, gcp} naming the infrastructure substrate a BlueprintVersion can target. A distinct value object from the cloud context's Provider — see ProviderKind and InjectionStrategy enums.types.go
InjectionStrategyA closed enumeration {cloud-init-user-data, helm-values, provider-secret} naming how a BlueprintVersion threads request parameters into the rendered Composite Resource. Fixed per version.types.go
ParameterSchemaThe validated, queryable set of typed parameter declarations a BlueprintVersion exposes. Each parameter declares a unique name, a type from {string, integer, boolean}, whether it is required, and — for an optional parameter — a default.parameter_schema.go
CatalogServiceThe in-process application-service facade. Methods: Register, PublishVersion, Get, List, Retire. It orchestrates the Repo, ManifestValidator, Clock, and AuditSink ports; the aggregates enforce their own creation invariants.services/catalog_service.go

Aggregates

The context owns two aggregate roots. A Blueprint is created and may later have its status advanced; a BlueprintVersion is created once and never mutated. Both have a New* constructor (for fresh aggregates, which auto-assigns a zero ID/timestamp) and a Hydrate* constructor (for rows read back from persistence, which rejects a zero ID/timestamp rather than defaulting it — a corrupt row is caught at the hydration boundary, not later). Both constructors funnel through a single private build* function so every invariant is enforced uniformly.

Blueprint — invariants

InvariantLayerFailure mode
displayName is non-empty after trimming whitespace and at most MaxBlueprintDisplayNameLen (256) bytes.Aggregate constructor.ErrInvariant.
slug is kebab-case and at most 63 characters.Aggregate constructor (ParseSlug); SQL CHECK and UNIQUE constraint blueprints_slug_unique.ErrInvariant on a malformed slug; a duplicate slug surfaces from the repository as ErrBlueprintSlugConflict.
status is one of the closed set {active, retired}.Aggregate constructor (ParseStatus); SQL CHECK blueprints_status_check.ErrInvariant; the CHECK is defence-in-depth.
description is optional; when present it is at most MaxBlueprintDescriptionLen (1024) bytes. It is not trimmed — operator-authored prose may legitimately carry indentation.Aggregate constructor.ErrInvariant.
domainID is optional; a zero ID means the entry is catalog-global, not scoped to a Domain. A non-zero domainID references plexsphere.domains(id) ON DELETE RESTRICT.SQL FOREIGN KEY.A Domain carrying scoped Blueprints cannot be deleted until they are re-scoped or retired.
id is non-zero UUIDv7; createdAt and updatedAt are non-zero.Aggregate constructor — auto-assigned by NewBlueprint, required by HydrateBlueprint.ErrInvariant when hydrating a row with a zero field.

BlueprintVersion — invariants

InvariantLayerFailure mode
blueprintID is non-zero — a version with no parent is corrupt in both constructors.Aggregate constructor.ErrInvariant.
version is non-empty after trimming whitespace.Aggregate constructor.ErrInvariant.
xrd and composition are each non-empty and valid JSON. The aggregate guarantees only that the persistence column holds a well-formed JSON blob — deep structural validation is the manifest/ subpackage's job.Aggregate constructor (json.Valid).ErrInvariant.
providerKinds is non-empty and every entry is a concrete (non-zero) ProviderKind.Aggregate constructor.ErrInvariant.
injectionStrategy is a concrete (non-zero) value.Aggregate constructor.ErrInvariant.
(blueprintID, version) is unique — a Blueprint never carries two rows for the same version label.SQL UNIQUE constraint blueprint_versions_blueprint_version_unique.A re-published pair surfaces from the repository as ErrBlueprintVersionExists.
The aggregate exposes no mutator method. The XRD and Composition blobs and the providerKinds slice are returned as defensive copies; a caller cannot reach into private state.Aggregate design — pinned by a reflection-based marker test.A ChangeXRD / ChangeComposition / Reslug method is rejected at review and trips the marker test.

Immutability is the load-bearing property: a published version is part of the contract the Provisioning Broker resolves a request against, so mutating its manifests would silently change the meaning of every Project that already resolved to it. The persistence schema reflects this — plexsphere.blueprint_versions carries created_at only and has no updated_at column, so the table cannot imply a mutation path the aggregate forbids.

ProviderKind and InjectionStrategy enums

Both are closed-enum value objects: an invariant violation surfaces at construction time so a downstream constructor can rely on a known-good value. Each parses case-sensitively (lowercase only) — a silent lower-case would mask an operator typo — and exposes a sorted, defensive-copy accessor for rendering hints and UI dropdowns.

EnumMembersParserRejection sentinelAccessor
ProviderKindaws, hetzner, openstack, gcpParseProviderKindErrUnknownProviderKindSupportedProviderKinds()
InjectionStrategycloud-init-user-data, helm-values, provider-secretParseInjectionStrategyErrInvalidInjectionStrategy
Statusactive, retiredParseStatusErrInvariant (no dedicated sentinel)SupportedStatuses()

ProviderKind is a new closed enum local to this context. It deliberately does not import or alias internal/provisioning/cloud.Provider: a blueprint declaring its accepted kinds must not silently pick up new entries whenever the cloud inventory adds a provider, the two contexts evolve on independent cadences, and the two enums carry different members (this one names hetzner / openstack / gcp, which the cloud context does not). The blueprints package therefore must never import internal/provisioning/cloud.

Parameter-schema model

ParameterSchema is the typed parameter declaration a BlueprintVersion exposes (parameter_schema.go). It is a value object, so every collaborator that holds one can rely on it being well-formed. The canonical JSON document is:

json
{"parameters":[
  {"name":"region","type":"string","required":true},
  {"name":"replicas","type":"integer","required":false,"default":3}
]}

Two functions bound the model, each with its own rejection sentinel:

  • ParseParameterSchema(raw []byte) validates schema well-formedness and returns an error wrapping ErrParameterSchemaInvalid when the document is empty or not valid JSON, a parameter has an empty/missing name, two parameters share a name, a parameter declares a type outside {string, integer, boolean}, a default's JSON type does not match the declared type, or a required parameter also declares a default (a contradiction — required means the caller must supply a value, default means it may omit one). Unknown JSON keys are rejected (DisallowUnknownFields) so a misspelled key fails at parse time. A schema with an empty parameter list is well-formed.
  • ValidateValues(values map[string]any) checks a value map against the schema and returns an error wrapping ErrParameterValuesInvalid when a required parameter is absent, a provided value's Go type does not match the declared type, or the map carries a key the schema does not declare. A missing optional parameter is acceptable — it falls back to its declared default.

ParameterType is closed at three scalar members on purpose: a blueprint parameter feeds a fixed Crossplane v2 rendering path that injects scalar values, and nested object/array types have no caller yet. An integer value accepts a Go int, int64, or a float64 with no fractional part — encoding/json decodes every JSON number to float64, so the HTTP-request-body path always carries integers as float64. ParameterSchema implements json.Marshaler, reconstructing the canonical document from the parsed form, which is what lets the repository store the schema as a jsonb column and rehydrate it without retaining the original raw bytes.

CatalogService.PublishVersion runs manifest validation (via the ManifestValidator port) and parameter-schema parsing before any repository write, so a structurally invalid XRD, an out-of-enum provider kind, an illegal injection strategy, or a malformed schema each fail with the matching sentinel and record no row.

Error sentinels

Every operation funnels through one of nine package-local sentinels (errors.go). They split into two classes by whether they wrap the ErrInvariant base sentinel. Callers branch via errors.Is; wrapping with fmt.Errorf("%w", …) keeps identity intact.

The value-validation sentinels each describe a malformed value that breaches an aggregate invariant, so they wrap ErrInvariant — the transport layer maps anything wrapping ErrInvariant to a 4xx malformed-request status:

SentinelTrigger
ErrUnknownProviderKindA value outside the ProviderKind enum.
ErrInvalidInjectionStrategyA value outside the InjectionStrategy enum.
ErrParameterSchemaInvalidA structurally invalid ParameterSchema.
ErrParameterValuesInvalidA value map that does not satisfy the schema.
ErrManifestInvalidAn XRD or Composition manifest that fails to parse or fails validation.

The lifecycle/repository sentinels do not wrap ErrInvariant — a not-found or a conflict is a normal control-flow outcome of a persistence lookup, not a malformed-value breach, and the transport layer maps them to 404/409 distinctly:

SentinelTrigger
ErrBlueprintNotFoundNo Blueprint matches the requested id or slug.
ErrBlueprintVersionExistsA version is published under a label the Blueprint already carries.
ErrBlueprintVersionNotFoundNo BlueprintVersion matches the requested Blueprint and version label.
ErrBlueprintSlugConflictA Blueprint is created with a slug another Blueprint already holds.

Platform seed catalog

The catalog ships five platform seed blueprints under catalog/, each a directory carrying an xrd.yaml, a composition.yaml, and a metadata.json, embedded into the binary via a //go:embed directive (catalog/embed.go):

SlugPurpose
vm-generic-cloudinitA provider-agnostic VM provisioned via cloud-init user-data.
hetzner-vm-nodeA Hetzner VM node.
openstack-vm-nodeAn OpenStack VM node.
aws-ec2-nodeAn AWS EC2 node.
aws-eks-cluster-daemonsetAn AWS EKS cluster daemonset workload.

PlatformSeeds(ctx, clock) (catalog/seeds.go) resolves the five embedded recipes into Blueprint + BlueprintVersion aggregate pairs. The XRD and Composition YAML are converted to canonical JSON (each manifest is authored as comment-annotated YAML for operator readability but the aggregate requires a JSON blob and the column is jsonb); the parameter schema, provider kinds, and injection strategy are routed through their domain parse constructors so a malformed recipe fails at resolution rather than as a corrupt aggregate downstream. Each seed carries a fixed, deterministic UUIDv7-shaped id so the catalog is idempotent — a non-deterministic id would make every reconcile run insert a fresh duplicate row.

ReconcileSeeds(ctx, repo, clock) idempotently asserts the five seeds exist in the repository and match the embedded catalog. It is called at boot after migrations run; the 0025_blueprints.sql migration installs no seed rows, so the first reconcile on a fresh schema heals every seed via CreateBlueprint / CreateBlueprintVersion, and every subsequent reconcile is a no-op. A seed that is present but has been tampered with out of band is detected by seedDrift (and versionDrift for the version) — a byte-for-byte comparison of the persisted row against the embedded recipe — and reported as an "altered" error naming the offending slug. Running ReconcileSeeds twice in a row against a repository that already holds the seeds creates no row and returns nil both times.

Depguard layout

The Blueprint Catalog keeps its domain layer framework-free; two named depguard rules in ../../../.golangci.yml enforce it, mirroring the managementfleet idioms:

  • no-direct-persistence-from-contexts denies driver imports from every bounded context, with a negative-glob carve-out at internal/provisioning/blueprints/repo/**. The repo/ subpackage is the single package in the blueprints module permitted to import github.com/jackc/pgx and the sqlc-generated bundle at internal/platform/db/gen; every other package reaches persistence through the Repo port.
  • no-k8s-outside-blueprints-manifest confines the Kubernetes client libraries (k8s.io/* and sigs.k8s.io/*) to the internal/provisioning/blueprints/manifest/** subpackage. The domain aggregates, the catalog seeds, the application service, the repository adapter, and the events subpackage all model XRD and Composition manifests as opaque []byte blobs and never decode them; only the manifest/ validator decodes XRD/Composition YAML through k8s.io/apimachinery and sigs.k8s.io/yaml. _test.go files are exempt so the envtest-backed render test can drive a controller-runtime client.
  • no-cross-context-imports-provisioning — the provisioning-specific cross-context rule — covers internal/provisioning/blueprints: it denies imports of every other bounded context while permitting the provisioning subpackages to import each other through the documented seams.

Seed readiness probe

Blueprint Catalog seed health gates /readyz. The probe is registered by RegisterBlueprintCatalogSeedsProbe (internal/platform/bootstrap/blueprint_catalog_seeds.go) under the stable name blueprint-catalog-seeds — operators and tests grep for that exact string in /readyz output.

The bootstrap module does not import the catalog package directly: the caller injects a BlueprintCatalogSeedReconciler — a closure over catalog.ReconcileSeeds — so the platform module stays free of the blueprints/repo, pgx, and sqlc transitive graph (the signer binary, which never reconciles the catalog, must not pay for it).

RegisterBlueprintCatalogSeedsProbe runs the reconcile once at boot inside a bounded context. A failure there is fail-fast — the binary refuses to start, because a catalog that cannot be reconciled into its expected shape must not serve traffic. On success the same closure is registered as a /readyz probe, so every later probe tick re-runs the reconcile and a seed drift after start-up flips /readyz to HTTP 503. A nil reconciler or a nil registry is a wiring bug surfaced as ErrBlueprintCatalogSeedReconcilerRequired / ErrBlueprintCatalogSeedRegistryRequired.

The catalog wiring is opt-in at the composition root: the production factory (blueprints_factory_prod.go) treats the Postgres DSN as the switch — with no DSN the factory is inert and no seed probe is registered. Unlike the management fleet, the Blueprint Catalog runs no steady-state reconcile ticker: the boot-time reconcile plus the /readyz probe re-run are the only two cadences, because the embedded catalog only changes on a binary redeploy.