Appearance
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
../../contributing/layout.md— the bounded-context map row that locatesinternal/provisioning/blueprintsinside the codebase and enumerates the depguard rules that confinepgxtorepo/, the Kubernetes libraries tomanifest/, and bar cross-context imports../credentials.md,./credential-pool.md, and./management-fleet.md— the sibling provisioning contexts. The Blueprint Catalog mirrors their module shape (a domain package, arepo/adapter, a port set, anevents/subpackage) but owns curated provisioning recipes rather than secret material or cluster inventory.../identity/tenancy.md— theDomain → Project → Resource → Nodeaggregate model. A Blueprint may carry an optionaldomain_idthat scopes the catalog entry to a single Domain; the Domain aggregate itself is owned by the tenancy context.../../../internal/provisioning/blueprints/doc.go— the package-level pin of the ubiquitous language and the bounded-context integration contract.../../../internal/platform/db/migrations/0025_blueprints.sql— the persistence schema forplexsphere.blueprintsandplexsphere.blueprint_versions.../../../cmd/plexsphere/blueprints_factory_prod.go— the production composition root: the env loader, thepgxpoolpool, the repository and catalog-service wiring, and the seed reconcile closure.../../../internal/provisioning/blueprints/render_envtest_test.go— the envtest-backed render test that applies every seed Blueprint's XRD and Composition into a real apiserver and asserts each is admitted.../../../tests/e2e/provisioning/blueprint-catalog/chainsaw-test.yaml— the Chainsaw e2e suite that stands up a kind cluster with digest-pinned Crossplane v2, applies a seed Blueprint's manifests, materialises a Composite Resource, and asserts Crossplane core admits it.
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.
| Term | Definition | Code anchor |
|---|---|---|
| Blueprint | The 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 |
| BlueprintVersion | The 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 |
| ID | The 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 |
| Slug | The 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 |
| Status | The 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 |
| ProviderKind | A 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 |
| InjectionStrategy | A 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 |
| ParameterSchema | The 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 |
| CatalogService | The 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
| Invariant | Layer | Failure 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
| Invariant | Layer | Failure 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.
| Enum | Members | Parser | Rejection sentinel | Accessor |
|---|---|---|---|---|
ProviderKind | aws, hetzner, openstack, gcp | ParseProviderKind | ErrUnknownProviderKind | SupportedProviderKinds() |
InjectionStrategy | cloud-init-user-data, helm-values, provider-secret | ParseInjectionStrategy | ErrInvalidInjectionStrategy | — |
Status | active, retired | ParseStatus | ErrInvariant (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 wrappingErrParameterSchemaInvalidwhen 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 —requiredmeans the caller must supply a value,defaultmeans 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 wrappingErrParameterValuesInvalidwhen 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:
| Sentinel | Trigger |
|---|---|
ErrUnknownProviderKind | A value outside the ProviderKind enum. |
ErrInvalidInjectionStrategy | A value outside the InjectionStrategy enum. |
ErrParameterSchemaInvalid | A structurally invalid ParameterSchema. |
ErrParameterValuesInvalid | A value map that does not satisfy the schema. |
ErrManifestInvalid | An 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:
| Sentinel | Trigger |
|---|---|
ErrBlueprintNotFound | No Blueprint matches the requested id or slug. |
ErrBlueprintVersionExists | A version is published under a label the Blueprint already carries. |
ErrBlueprintVersionNotFound | No BlueprintVersion matches the requested Blueprint and version label. |
ErrBlueprintSlugConflict | A 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):
| Slug | Purpose |
|---|---|
vm-generic-cloudinit | A provider-agnostic VM provisioned via cloud-init user-data. |
hetzner-vm-node | A Hetzner VM node. |
openstack-vm-node | An OpenStack VM node. |
aws-ec2-node | An AWS EC2 node. |
aws-eks-cluster-daemonset | An 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-contextsdenies driver imports from every bounded context, with a negative-glob carve-out atinternal/provisioning/blueprints/repo/**. Therepo/subpackage is the single package in the blueprints module permitted to importgithub.com/jackc/pgxand the sqlc-generated bundle atinternal/platform/db/gen; every other package reaches persistence through theRepoport.no-k8s-outside-blueprints-manifestconfines the Kubernetes client libraries (k8s.io/*andsigs.k8s.io/*) to theinternal/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[]byteblobs and never decode them; only themanifest/validator decodes XRD/Composition YAML throughk8s.io/apimachineryandsigs.k8s.io/yaml._test.gofiles 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 — coversinternal/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.