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, and validates manifests and parameter values; resolving a Project request against a version and rendering the Composite Resource stay with the Provisioning Broker. The catalog starts empty at boot — an operator builds it through the /v1/blueprints authorship API (and the plexctl blueprint commands that drive it), or imports it from a registered external OCI catalog.
This reference is a single page: the surface is narrow (three aggregates, the closed-enum and OCI-source value objects, one parameter-schema value object, and the package sentinels) and the pieces travel in lockstep with the package-level pin in doc.go.
The third aggregate, CatalogSource, and the import provenance it records on a BlueprintVersion are the persistence foundation for the externalised catalog: the durable record of which external OCI catalogs are registered and where each imported blueprint came from. They carry no external behaviour yet — the OCI adapter, the cosign verifier, and the import application service build on this slice.
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/v1/blueprintstransport Deps.../../../cmd/plexsphere/blueprint_catalogs_factory_prod.go— the production composition root for the catalog-source surface: the env loader, theCatalogSourceServicegraph over the OCI source and cosign verifier, the/v1/blueprint-catalogstransport Deps, and the opt-in tracking reconciler (boot probe + steady-state ticker) plus the optional official-catalog auto-registration.../../../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 Blueprint's XRD and Composition, 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 imported 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. The lifecycle transition is a behaviour on the aggregate — Retire(now) returns a copy in the retired state (idempotent when already retired), and IsActive / IsRetired are the state predicates the application and persistence layers read. | 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, the InjectionStrategy, and the MeshRole. 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, and only through the aggregate's Retire transition. The ActiveStatus() / RetiredStatus() accessors and the IsActive / IsRetired predicates single-source the two literals on the domain. | types.go |
| ProviderKind | A closed enumeration {aws, azure, gcp, hetzner, openstack} 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 |
| MeshRole | A closed enumeration {node, standalone} naming whether a BlueprintVersion provisions a mesh-enrolling plexd Node or a nodeless standalone cloud resource. Optional in bundle metadata, defaulting to node. The broker reads it to gate bootstrap-token minting and the Enrolling/Deregistering phases. | 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 the canonical auditport.Sink ports; the aggregates enforce their own creation invariants. | services/catalog_service.go |
| CatalogSource | The aggregate root recording one registered external OCI catalog the importer pulls blueprints from. Pairs an OCIReference, a VerificationPolicy, an optional RegistryCredentialRef, an optional domainID scope, a TrackingPolicy, the last-resolved digest, and a mutable CatalogSourceStatus. The name is a non-unique label; identity is the UUIDv7 id. See External catalog sources. | catalog_source.go |
| OCIReference / VerificationPolicy / TrackingPolicy / RegistryCredentialRef | The value objects a CatalogSource composes: where the bundle lives (registry/repository plus a tag XOR a digest), how its signature is verified (pinned identity SAN + issuer, or unsigned), whether it re-resolves (pinned, or track-tag on an interval), and the optional namespace/name registry Secret. Each validates at construction. | catalog_source_types.go |
| ImportProvenance | The optional value object on a BlueprintVersion recording an imported version's origin: the source CatalogSource, the source bundle digest, and the resolved source ref. Immutable like the version it rides on; degrades to an orphaned state when the source is deregistered. | provenance.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, and unique per scope — (domain_id, slug), where the NULL catalog-global scope counts as one scope. | Aggregate constructor (ParseSlug); SQL CHECK; scoped unique index blueprints_domain_slug_uq. | ErrInvariant on a malformed slug; a duplicate slug within the same scope surfaces from the repository as ErrBlueprintSlugConflict. See Scope model. |
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, azure, gcp, hetzner, openstack | ParseProviderKind | ErrUnknownProviderKind | SupportedProviderKinds() |
InjectionStrategy | cloud-init-user-data, helm-values, provider-secret | ParseInjectionStrategy | ErrInvalidInjectionStrategy | — |
MeshRole | node, standalone | ParseMeshRole / ParseMeshRoleOrDefault | ErrInvariant (no dedicated sentinel) | SupportedMeshRoles() |
Status | active, retired | ParseStatus | ErrInvariant (no dedicated sentinel) | SupportedStatuses() |
MeshRole names whether a version provisions a mesh-enrolling plexd Node or a standalone (nodeless) cloud resource such as an S3 bucket. Unlike the other enums it carries a domain default: the metadata field is optional, and ParseMeshRoleOrDefault falls back to node when it is absent — the only behaviour the Provisioning Broker supported before standalone resources existed — while a present-but- invalid value still fails closed. The broker reads it through MeshRole.ProvisionsNode() to decide whether to mint a bootstrap token and drive the Enrolling/Deregistering phases; see the Provisioning Broker context for the lifecycle split.
ProviderKind is a 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, and the two contexts evolve on independent cadences. The blueprints package therefore must never import internal/provisioning/cloud.
The two value sets currently coincide, which is not a reason to merge the types. This one names what a template can target; the cloud context's names what a substrate registration represents, and doubles as the routing key for that context's per-provider endpoint validators. Either may gain a member the other has no meaning for.
Provisioning relates them through an explicit correspondence table owned by the Provisioning Broker, the context that compares the two, rather than by string equality — which today would agree on every pair by coincidence rather than by contract. A declaration whose Blueprint version accepts none of the kinds corresponding to the provider of the Cloud behind the chosen credential is refused at admission with 422 blueprint_provider_mismatch; see the Provisioning Broker context.
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.
External catalog sources
The externalised catalog needs a durable record of which external OCI catalogs are registered and where each imported blueprint came from. The CatalogSource aggregate (catalog_source.go) holds the first; the ImportProvenance value object (provenance.go) carried on a BlueprintVersion holds the second. The OCI source adapter reads a registered catalog's bundle (see Blueprint source adapter) and the cosign verifier checks its signature (see Catalog verifier); the import application service that ties a verified bundle to a BlueprintVersion builds on this slice.
Operators reach this machinery over HTTP through the /v1/blueprint-catalogs surface — register, list, get, deregister a source, browse the Blueprints it offers, and import a subset or all of them. That surface is documented in ../../reference/api/blueprint-catalogs.md.
The official catalog ships several AWS-targeting blueprints. Most — aws-ec2-instance, aws-eks-cluster-daemonset — are inert in a lean local stack: imported, they are only ever admitted, since with no AWS provider and no account they never reach Ready. The aws-s3-bucket blueprint is the exception that the dev stack can actually materialise: a parameter-light, dependency-free S3 bucket (a single required region, provider-secret injection) whose namespaced XAWSS3Bucket composite renders one s3.aws.m.upbound.io Bucket. The dev stack pairs it with the upjet provider-aws-s3 family and the floci AWS emulator (deploy/local/base/{provider-aws,floci,blueprint-aws-s3}/) so the same import flow drives an AWS-shaped Resource against an emulated AWS API with no cloud account — see the dev-stack guide's components table for the wiring and the emulated-AWS troubleshooting entry for the runtime endpoint/DNS requirements.
CatalogSource — invariants
A CatalogSource records one registered external OCI catalog. Its identity is a UUIDv7 id; the name is a human-facing label and is not unique. Like the other aggregates it has a New* constructor (auto-assigning a zero id/timestamp) and a Hydrate* constructor (rejecting a zero id/timestamp), both funnelling through one private build* function.
| Invariant | Layer | Failure mode |
|---|---|---|
name is non-empty after trimming and at most MaxCatalogSourceNameLen (256) bytes. | Aggregate constructor. | ErrInvariant. |
ociReference is a concrete (non-zero) OCIReference. | Aggregate constructor; value object ParseOCIReference. | ErrInvariant / ErrInvalidOCIReference. |
verification is a concrete (non-zero) VerificationPolicy. | Aggregate constructor. | ErrInvariant / ErrInvalidVerificationPolicy. |
tracking is a concrete (non-zero) TrackingPolicy, and a track-tag policy requires a tag-pinned reference — re-resolving a tag is meaningless against an immutable digest. | Aggregate constructor; SQL CHECK blueprint_catalog_sources_track_tag_requires_tag. | ErrInvariant / ErrInvalidTrackingPolicy. |
credentialRef is optional; a zero value means the registry needs no credentials. | Aggregate constructor; value object ParseRegistryCredentialRef. | ErrInvalidCredentialRef on a malformed reference. |
domainID is optional; a zero ID means the registration is catalog-global. A non-zero domainID references plexsphere.domains(id) ON DELETE RESTRICT. | SQL FOREIGN KEY. | A Domain carrying scoped sources cannot be deleted until they are deregistered. |
lastResolvedDigest is optional; when present it is a sha256 digest. | Aggregate constructor. | ErrInvariant. |
status is one of the closed set {active, disabled}. | Aggregate constructor (ParseCatalogSourceStatus); SQL CHECK. | ErrInvariant; the CHECK is defence-in-depth. |
id is non-zero UUIDv7; createdAt and updatedAt are non-zero. | Aggregate constructor — auto-assigned by NewCatalogSource, required by HydrateCatalogSource. | ErrInvariant when hydrating a row with a zero field. |
The aggregate is framework-free and composes four value objects, each validating at construction so a collaborator that holds one can rely on it being well-formed:
| Value object | Shape | Parser | Rejection sentinel |
|---|---|---|---|
OCIReference | registry, repository, and exactly one of a mutable tag or an immutable digest (sha256). | ParseOCIReference | ErrInvalidOCIReference |
VerificationPolicy | pinned (a Sigstore identity-SAN regexp + an issuer) or unsigned. | PinnedVerification / UnsignedVerification / ParseVerificationPolicy | ErrInvalidVerificationPolicy |
TrackingPolicy | pinned (never re-resolves) or track-tag (re-resolves on a positive interval). | PinnedTracking / TrackTagTracking / ParseTrackingPolicy | ErrInvalidTrackingPolicy |
RegistryCredentialRef | optional namespace/name Secret reference (two DNS-1123 labels). | ParseRegistryCredentialRef | ErrInvalidCredentialRef |
CatalogSourceStatus | closed enum {active, disabled}. | ParseCatalogSourceStatus | ErrInvariant (no dedicated sentinel) |
The persistence schema (0055_blueprint_catalog_sources.sql) pins the closed sets and the tag-XOR-digest, verification- and tracking-consistency rules as CHECK constraints, mirroring the defence-in-depth posture of the blueprints schema. The repository (repo/catalog_source_repo.go) exposes create / get / list / delete, transaction-wired like BlueprintRepo; a missing row on get or delete surfaces as ErrCatalogSourceNotFound.
Import provenance
A BlueprintVersion carries an optional ImportProvenance value object recording where an imported version came from: the source CatalogSource (source_catalog_id), the source bundle digest (source_bundle_digest), and the resolved source reference (source_ref). The three columns are nullable additions on plexsphere.blueprint_versions; provenance rides on the immutable version, so it is itself immutable.
Three states are valid, and the value-object invariant — any field set requires both the bundle digest and the ref — is what distinguishes them from a malformed value:
| State | source_catalog_id | source_bundle_digest / source_ref | Meaning |
|---|---|---|---|
| Zero | NULL | NULL | A hand-authored version with no external origin. |
| Full | set | set | An imported version still linked to its live CatalogSource. |
| Orphaned | NULL | set | The source was deregistered after import. |
The orphaned state is produced by source_catalog_id … ON DELETE SET NULL: deregistering a source leaves its already-imported versions in place rather than deleting them, and the bundle digest and ref are retained so the version's origin stays auditable even after the source row is gone. Deleting a source therefore orphans, never cascades.
Scope model
Blueprint slug uniqueness is scoped to (domain_id, slug), not to a global slug (0056_blueprint_scoped_slug.sql). An externalised catalog may legitimately register the same blueprint slug in two different Domains, so a globally-unique slug is too strict.
The scoped unique index blueprints_domain_slug_uq keys on COALESCE(domain_id, all-zeros uuid) so the NULL catalog-global scope behaves like any other scope — the same COALESCE-over-nullable-scope pattern 0005_labels.sql uses for label_definition_scope_key_uq. The consequences:
- Two blueprints may share a slug when scoped to different Domains.
- Two blueprints may not share a slug within the same Domain.
- Two catalog-global (NULL-
domain_id) blueprints may not share a slug — NULL is treated as a single concrete scope.
A duplicate within a scope surfaces from the repository as ErrBlueprintSlugConflict; the classifier discriminates the 23505 on blueprints_domain_slug_uq by constraint name.
Blueprint source adapter
Reading a registered catalog's bundle is an outbound port, not behaviour on the aggregate. The BlueprintSource port (source.go) is framework-free and exposes two operations:
List(ctx, ref, cred) → []BundleEntryenumerates the blueprints a bundle offers, each entry carrying a slug and that slug's parsedmetadata.jsonview.Fetch(ctx, ref, cred, slug) → FetchedBlueprintreturns one blueprint's three files (XRD, Composition, metadata) plus the resolved bundle manifest digest, so the import service can record provenance against the exact content it pulled even from a mutable tag.
The credential reference is threaded per call rather than baked into the adapter, because it lives on the CatalogSource, not the OCIReference: one adapter serves every registered source, resolving each source's credentials at fetch time.
The go-containerregistry adapter (ociclient/) is the single binding of the port to a real OCI registry, mirroring the artifacts context's ociclient end to end. It maps registry and layout failures onto a three-sentinel taxonomy:
| Sentinel | Owner | Meaning |
|---|---|---|
ErrCatalogNotFound | domain root | A missing tag/repository, or a Fetch for an absent slug. |
ErrRegistryUnreachable | ociclient/ | A transient failure — a dial refusal, a 5xx, or an auth rejection — the import service logs and retries. |
ErrInvalidBundleLayout | ociclient/ | A malformed bundle — not an image, an unknown layer media type, a non-conforming title, or a slug missing one of its three files — a permanent fault no retry repairs. |
Private-registry pull is handled by a CredentialResolver seam the adapter consults at fetch time: when a CatalogSource carries a non-zero RegistryCredentialRef the adapter asks the resolver for an authenticator and applies it to the pull; a zero reference is an anonymous pull. The Kubernetes-Secret-backed resolver is composition-root wiring — the no-k8s-outside-blueprints-manifest rule keeps a Kubernetes client out of ociclient/ — so the adapter depends only on the narrow seam, never on a Secret reader.
Consumed bundle contract
A registered catalog resolves to a single OCI artifact the upstream github.com/plexsphere/blueprints build-bundle.sh script produces. The bundle is one OCI image whose layers are the per-file blobs of every blueprint the catalog offers, declaring the artifact type application/vnd.plexsphere.blueprints.catalog.v1. Each layer is grouped to a blueprint by its org.opencontainers.image.title annotation, whose value has the form catalog/<slug>/<file>, and is selected by media type — one per kind:
| File | Media type |
|---|---|
| XRD | application/vnd.plexsphere.blueprint.xrd.v1+yaml |
| Composition | application/vnd.plexsphere.blueprint.composition.v1+yaml |
| metadata | application/vnd.plexsphere.blueprint.metadata.v1+json |
The slug — read from the layer title, not the metadata document — is the authoritative grouping key. A well-formed bundle carries exactly those three media types for every slug it offers; anything else is an ErrInvalidBundleLayout. The metadata.json document carries slug, displayName, description, version, providerKinds, injectionStrategy, and a parameterSchema object — so its parameterSchema feeds ParseParameterSchema unchanged.
Catalog verifier
Before the import service trusts a pulled bundle, it verifies the bundle's keyless cosign signature. Verifying is an outbound port, not behaviour on the aggregate. The CatalogVerifier port (verifier.go) is framework-free and exposes one operation:
Verify(ctx, policy, bundleDigest, signature) → errorchecks the verbatim cosign Sigstoresignaturebytes against the resolvedbundleDigest(thesha256:…OCI manifest digest theBlueprintSourcesurfaces) and theCatalogSource'sVerificationPolicy.
The sigstore-go adapter (verify/) is the single binding of the port to the verification library, mirroring the artifacts context's verify adapter. It differs from that mirror in two ways that the externalised catalog requires:
- Per-catalog identity. The signing identity is taken from the
CatalogSource's policy on each call — a SAN regexp plus an OIDC issuer — rather than one globally pinned release identity. Arbitrary third-party catalogs each carry their own signing identity, so the SAN is matched by pattern (with Go's linear-time RE2 engine) against the verified leaf certificate's URI and email SANs. The pattern is anchored to the whole SAN before matching, so it must cover the entire name rather than a substring — an unanchoredrelease-signer@catalog.examplemust not also accept a forgedrelease-signer@catalog.example.attacker.com. A literal dot in an identity must be escaped as\., because an unescaped.is a regexp wildcard that also matches an unrelated character. - Unsigned short-circuit. An
unsignedpolicy returnsnilwithout inspecting the signature — a source registered unsigned publishes none.
A pinned policy runs the same staged pipeline as the artifacts verifier, and each stage maps to exactly one sentinel the verify/ subpackage owns so the import service attributes a rejection via errors.Is without parsing strings:
| Stage | Sentinel | Trigger |
|---|---|---|
| Checksum | ErrChecksumMismatch | The digest the signature attests does not equal the resolved bundle digest, or the supplied digest is not sha256:<64 hex>. The attested digest is the message-signature digest for a message-signature bundle, or the in-toto subject digest for cosign 3.x's DSSE-wrapped statement. |
| Rekor presence | ErrMissingRekorProof | The signature carries no transparency-log entry at all. |
| Signature / certificate chain | ErrBadSignature | The signature does not verify against the trusted material, or the bytes do not parse. |
| Rekor proof | ErrBadRekorProof | A transparency-log entry is present but does not verify against the trusted log material. |
| Identity | ErrBadIdentity | The verified leaf certificate's SAN does not match the policy regexp, or its OIDC issuer does not match. |
The Sigstore trust material the verifier checks against is composition-root wiring; the adapter depends only on it, never on a live network fetch.
Official catalog identity
The official upstream catalog (github.com/plexsphere/blueprints) signs its bundles with a GitHub Actions keyless cosign identity. An operator registering it pins the verification policy to:
| Field | Value |
|---|---|
| Identity SAN pattern | ^https://github\.com/plexsphere/blueprints/\.github/workflows/release\.yml@refs/tags/v.*$ |
| OIDC issuer | https://token.actions.githubusercontent.com |
The SAN's trailing ref segment is constrained to release tags (@refs/tags/v...) rather than @.*. A GitHub Actions OIDC certificate's SAN records the git ref the workflow ran at, so an unconstrained @.* would accept a bundle signed by release.yml invoked from any ref — a branch, a pull-request ref, or a non-release tag. Anchoring to refs/tags/v... accepts only signatures produced by a tagged release run.
Import flow
The CatalogSourceService (catalog_source_service.go) ties the source adapter and the verifier to the publish path. It composes the CatalogService and reuses Register / PublishVersion rather than re-implementing validation, so an imported version runs the same provider-kind, injection-strategy, parameter-schema and manifest-pair checks a hand-authored one does.
The lifecycle and browse operations are thin:
- RegisterSource builds the
CatalogSourceaggregate, persists it (the repository appends aCatalogSourceRegisteredoutbox row in the same transaction), and emits a names-onlycatalog_source.registeraudit row scoped to the registration's authorization boundary — the Domain for a scoped source, the platform singleton for a catalog-global one. The event seeds no ReBAC tuple: catalog-source operations are gated onplatform#manage(global) or the Domain's manage permission (domain-scoped), not a per-source owner grant. - ListSources / GetSource forward the repository result verbatim.
- DeregisterSource reads the source (for its audit scope and a clean not-found), deletes it, and emits a
catalog_source.deregisteraudit row. Deregistration orphans, never cascades: see Import provenance. - Browse reads a registered source's bundle through the
BlueprintSourceport and returns the blueprints it offers. It is a read-through with no persistence.
Import imports a selected subset of slugs or, with the all flag, every blueprint the source offers. For each blueprint it:
- Fetches the bundle's three files and resolved digest.
- Verifies the cosign signature when the source's policy is pinned — it sources the signature for the resolved digest through
FetchSignatureand checks it with the verifier.FetchSignaturediscovers the signature through the OCI 1.1 Referrers API of the bundle digest (cosign 2.x's default, and the only scheme cosign 3.x writes), selecting the referrer that carries a Sigstore-bundle layer, and falls back to the legacy cosign triangulation tag (sha256-<digest>.sig) for bundles signed by an older cosign. An unsigned source skips verification. - Converts the YAML manifests to JSON (the aggregate requires JSON, the persistence column is
jsonb). - Routes to the reused publish path, recording where the version came from via
ImportProvenance.
The routing is the heart of the flow. For a slug with no blueprint yet in the source's scope, import registers a new blueprint and publishes its first version. For a slug already present in the scope, the outcome depends on whether the existing blueprint is owned by the importing source (it is when one of its versions carries the source's id in its provenance):
| Situation | Outcome | Effect |
|---|---|---|
New (domain, slug) | imported | Register + publish the first version. |
Same source, new metadata.json version label | imported | Publish a new immutable version. |
| Same source, same label, identical content | unchanged | A no-op — nothing is published. |
| Same source, same label, changed content | drift | A diagnostic carrying the diff — never an overwrite. |
| Same scope, slug owned by a different source (or hand-authored) | conflict | Refused with ErrBlueprintSlugConflict — never an overwrite. |
| Same slug under a different Domain | imported | A new blueprint coexists in the importing source's scope. |
The identical-vs-changed comparison uses the import service's own versionDrift helper: the version label, injection strategy, sorted provider kinds, and the canonicalised XRD / Composition / parameter-schema JSON are compared, while install-time timestamps are excluded.
Per-blueprint failures (a fetch, signature, verification, conversion, or publish error, plus drift and conflict) are reported as per-blueprint outcomes so an all-import reports every blueprint rather than aborting on the first one. Only a whole-batch precondition — the source not existing, or the all-listing failing — fails the whole import.
A successful import emits a names-only blueprint.import audit row carrying the source id, blueprint id, slug and version label. Import does not emit a separate outbox event: it orchestrates Register and PublishVersion across separate transactions, so there is no atomic boundary at which to append one. The durable, consumer-facing record of an import is the BlueprintVersionPublished event plus the version's provenance columns.
Composition root and tracking
The catalog-source surface is wired by blueprint_catalogs_factory_prod.go. Its opt-in switch is PLEXSPHERE_DSN: an empty value returns a nil factory and the six /v1/blueprint-catalogs handlers stay on the blueprint_catalogs_not_provisioned 501 stub, the same posture the read/authorship /v1/blueprints surface takes. A populated DSN opens the Postgres pool, fetches the Sigstore trusted material in memory, and assembles the CatalogSourceService over the OCI source (ociclient.New(nil) — anonymous pulls; a source carrying a registry credential reference fails fast at fetch time) and the cosign verifier, then threads the transport Deps onto v1.Deps.BlueprintCatalogs. The build refuses to boot when the canonical hash-chained audit.Sink is absent, because the register and import flows fold their audit rows through it.
A background tracking reconciler re-resolves the sources that opted into tag tracking. It runs as a boot probe plus a steady-state ticker: on each pass it lists the registered sources and runs Import(All) against every active, track-tag source, re-resolving the tracked tag's digest and publishing a new version for any blueprint whose content advanced. The sweep is degrade-safe — a per-source import failure (an unreachable upstream registry, a transient pull error) is logged and skipped, so a third-party outage cannot flip /readyz to 503. Only an inability to list the catalog sources from Postgres — a control-plane fault — reds the probe. Sources tracking a pinned digest, and disabled sources, are never re-imported. The reconciler is off unless a source opts into track.
Two optional convenience knobs tune the surface (both documented in the dev-stack reference): PLEXSPHERE_BLUEPRINTS_TRACK_INTERVAL_SECS sets the steady-state tracking cadence (default 15 minutes), and PLEXSPHERE_BLUEPRINTS_OFFICIAL_CATALOG_REF, when set to a fully-qualified OCI reference, auto-registers the official upstream catalog once on first boot — pinned to the official cosign identity above, tracking its tag when the reference is tag-pinned. The auto-registration is best-effort and is not a /readyz invariant.
Error sentinels
Every operation funnels through one of the 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. |
ErrInvalidOCIReference | A malformed OCIReference — bad registry/repository, a non-sha256 digest, or a tag/digest pair that is not exactly one. |
ErrInvalidVerificationPolicy | A malformed VerificationPolicy — a pinned policy missing its identity SAN or issuer, an uncompilable SAN, or an unsigned policy carrying identity fields. |
ErrInvalidTrackingPolicy | A malformed TrackingPolicy — a track-tag policy without a positive interval, or a pinned policy carrying one. |
ErrInvalidCredentialRef | A RegistryCredentialRef not in namespace/name form. |
ErrInvalidProvenance | A partially populated ImportProvenance — any field set without both the bundle digest and the ref. |
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 in the same (domain_id, slug) scope. |
ErrCatalogSourceNotFound | No CatalogSource matches the requested id (get or delete). |
ErrCatalogNotFound | The OCI source cannot resolve a registered catalog's artifact — a missing tag/repository, or a Fetch for a slug the bundle does not offer. Distinct from ErrCatalogSourceNotFound: that is a missing local row, this is a missing remote artifact. |
The two outbound adapters own their own sentinels alongside their behaviour rather than in the domain-root errors.go: the OCI source's transient and layout sentinels are documented under Blueprint source adapter, and the verifier's five stage-sentinels under Catalog verifier.
HTTP authorship surface
The /v1/blueprints transport exposes both the read paths (list, get) and the authorship paths that wire CatalogService.Register and CatalogService.PublishVersion to HTTP:
| Operation | Method + path | ReBAC gate |
|---|---|---|
| Register a Blueprint | POST /v1/blueprints | platform#manage |
| Publish a version | POST /v1/blueprints/{id}/versions | blueprint#publish |
Both handlers authorise before decoding the body, so an unauthorised caller never appends an outbox row. Register returns 201 with a Location: /v1/blueprints/{id} header; publish returns 201 with Location: /v1/blueprints/{id}/versions/{version}. The request body types xrd, composition, and parameter_schema as JSON objects and provider_kinds / injection_strategy as plain strings; the domain parsers are the authoritative validators and surface the value-validation sentinels above, which the transport maps to the closed 400 Problem codes (invalid_provider_kind, invalid_injection_strategy, invalid_parameter_schema, invalid_manifest).
Registrar owner grant (eventual consistency)
Register does not call the authorizer directly. The repository appends a BlueprintRegistered outbox event carrying the registrar's ReBAC subject as created_by; the authz-sync consumer (internal/authz/sync) maps that event to a blueprint:<id>#owner@<registrar> tuple. The schema derives publish = owner + publisher, so the registrar gains publish on the new Blueprint without a separate grant — mirroring the cloudprov.CloudCreated → cloud_admin idiom in the Cloud Inventory context.
Because the tuple is written asynchronously, the grant is eventually consistent: a registrar that publishes a version in the same instant it registers the Blueprint may be denied blueprint#publish until the consumer drains the event. Callers that chain register → publish should retry the publish on a transient 403.
Read authorization and member discovery
GET /v1/blueprints/{id} and the per-row filter on GET /v1/blueprints gate on read on blueprint:<id>. The schema derives
text
permission read = owner + publisher + reader + parent->readso read inherits the owning Domain through the Blueprint's parent edge. A Domain-scoped Blueprint — one imported from a source scoped to a Domain — carries a blueprint:<id>#parent@domain:<id> edge (seeded by the authz-sync arm from the BlueprintRegistered event's optional domain_id; see Provisioning & ReBAC). Because domain#read folds in member, every ordinary member of that Domain resolves read through parent->read and can therefore discover and list the Blueprint without an explicit reader grant — the parent edge is the single source of truth, mirroring how project / resource / policy derive read from parent->read.
publish and manage deliberately do not inherit parent->: they derive from owner / publisher only, so authoring a new version or retiring a Blueprint stays a privileged grant a member never gains from Domain membership. A catalog-global Blueprint (registered with no domain_id) carries no parent edge at all, so it stays privileged — only its owner, an explicit publisher, or an explicit reader can read it, with no implicit member visibility.
The project-scoped offer read
GET /v1/projects/{id}/blueprints is the catalogue read seen from inside one Project. It returns the same slug-ordered window through the same per-row read filter, and adds two answers the platform-scoped list has no Project to compute: per row, the union of the provider kinds the Blueprint's published versions accept together with a provisionable verdict, and per page, the substrates the Project reaches through its approved Cloud Assignments.
The catalogue owns only half of that. The union of accepted kinds is a Blueprint Catalog read — one query aggregating provider_kinds across every published version, so a page of rows costs one read rather than one per row. The reachable half belongs to the Cloud Assignment and Cloud contexts, and the correspondence between a Cloud provider and a Blueprint provider kind belongs to the broker. None of the three is imported here: the transport declares a one-method port for the verdict input it needs, and the composition root — the seat where contexts meet — implements it over the sibling repositories and the broker's exported lookup. The verdict itself is then a set intersection in the handler.
Reusing the broker's table rather than restating it is what keeps the offer honest. The same correspondence decides the admission refusal, so a second copy would eventually offer a Blueprint that Provision then rejects with 422 blueprint_provider_mismatch, and the operator would meet the disagreement after making the choice the offer existed to inform.
A Blueprint that is not provisionable stays in the response marked false. The Blueprint's accepted kinds next to the Project's reachable kinds name the exact gap, which is guidance; dropping the row would say the template does not exist, which is a different and wrong message. A Blueprint with no published version accepts nothing and is therefore never provisionable, which the read expresses by leaving it out of the union map rather than mapping it to an empty set — the absent key is the fail-closed reading.
Depguard layout
The Blueprint Catalog keeps its domain layer framework-free; several 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 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 a manifest-level test can drive a controller-runtime client.no-go-containerregistry-outside-blueprints-ociclientconfines thegithub.com/google/go-containerregistrylibrary to theinternal/provisioning/blueprints/ociclient/**subpackage — the single binding of theBlueprintSourceport to a real OCI registry. Every other blueprints package reaches a registry only through the port, so the domain layer stays free of the registry client. The rule mirrorsno-k8s-outside-blueprints-manifest;_test.gofiles are exempt.no-sigstore-outside-blueprints-verifyconfines thegithub.com/sigstore/sigstore-golibrary to theinternal/provisioning/blueprints/verify/**subpackage — the single binding of theCatalogVerifierport to the verification library. Every other blueprints package verifies a bundle signature only through the port, so the domain layer stays free of the crypto machinery. The rule mirrorsno-go-containerregistry-outside-blueprints-ociclient;_test.gofiles are exempt.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.
Empty boot catalog
The catalog is not seeded at boot — there is no blueprint-catalog-seeds readiness probe and no boot-time reconcile. A fresh stack starts with an empty plexsphere.blueprints table; an operator fills it either by authoring entries through the /v1/blueprints API (or the plexctl blueprint commands) or by registering an external OCI catalog and importing from it, so the learner builds the catalog in the tutorials rather than inheriting a pre-seeded one.
The catalog HTTP 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 the four /v1/blueprints handlers stay on their 501 stub.