A blueprint is a krop.opendefense.cloud/v1alpha1 ResourceGraphDefinition
(RGD). You author one; krop compiles it into a bindable APIExport and reconciles
the generated instance kind across every consumer that binds it. This page is the
authoring reference: the RGD shape, krop's additions (the target routing field
and externalRef reads), CEL, auto-derived permissionClaims, naming/pruning,
schema evolution, and a fully annotated example.
If you have not run the end-to-end walkthrough yet, do getting-started.md first. For the internals, see architecture.md.
krop's RGD is kro's RGD wrapped in a thin krop spec: the
spec is kro's Schema + Resources, and each resource inlines kro's resource
verbatim plus one krop-owned field — target
(api/v1alpha1/resourcegraphdefinition_types.go). A build-time ToKro()
conversion strips target back into a routing map and hands the graph builder
pristine kro types. The object is cluster-scoped in the provider workspace.
apiVersion: krop.opendefense.cloud/v1alpha1 # krop's group…
kind: ResourceGraphDefinition # …but kro's RGD spec verbatim
metadata:
name: hosteddatabase # blueprint name (cluster-scoped)
spec:
schema: # the generated instance API: its spec + status (kro SimpleSchema)
apiVersion: v1alpha1
kind: HostedDatabase
group: krop.opendefense.cloud
spec:
name: string
engine: string | default="postgres"
status:
endpoint: ${connection.data.endpoint}
vpcID: ${vpc.status.vpcID}
resources: # the graph of child objects the instance materializes
- id: vpc
externalRef: { ... }
- id: db
template: { ... }
- id: connection
template: { ... }This is a kro SimpleSchema. group/version/kind name the API krop
generates and serves through the APIExport; spec is the instance's input
surface; status is a set of CEL projections krop writes back onto each instance
(see CEL). SimpleSchema uses shorthand types (string,
integer, boolean, []string, nested objects, string | default=...,
string | required=true); the fuller SimpleSchema reference lives in kro's docs.
Each resource is a node in a DAG that krop derives from the ${...} references
between nodes (kro builds and topologically orders it). Per-resource fields:
| Field | Meaning |
|---|---|
id |
node identifier; also the CEL handle other nodes reference (${config.status...}) |
target |
krop addition. Which plane the node's object(s) route to: consumer (default) | provider | host. See target routing |
template |
the child object to write (any GVK; a full manifest with ${...} CEL). Mutually exclusive with externalRef |
externalRef |
read-only. An existing object krop reads but never creates or GCs. Mutually exclusive with template. See external references |
readyWhen |
CEL predicate(s) that must hold before the node counts as ready and dependents proceed |
includeWhen |
CEL predicate(s) gating whether the node is created at all |
forEach |
expand the node into one child per collection element |
readyWhen/includeWhen/forEach/${...}/template/externalRef are all
standard kro semantics — krop does not change them; target is krop's only added
field.
krop resolves each resource to a plane using a single per-resource target
field (a sibling of id/template, not a template annotation):
resources:
- id: config
target: consumer # or: provider | host — omit for the consumer default
template: { ... }| Value | Where the object is written / read | Authorized by | Identity |
|---|---|---|---|
consumer (default, if target is absent/empty) |
the consumer (tenant) workspace | an accepted permissionClaim on the consumer's APIBinding | the APIExport virtual-workspace identity |
provider |
the provider workspace (default namespace) |
the controller's own provider-workspace RBAC | the controller's own kcp identity |
host |
the physical host cluster the controller runs in | RBAC in the host cluster (the provider's responsibility) | the in-cluster / --host-kubeconfig host client |
consumer/provider/host are the only valid values; the CRD enum-validates
target at apply time, and the engine re-checks it. target routes both
template children (where they are written) and externalRef reads (which plane
they are read from).
Why three targets. A single instance often needs to write some state into the tenant's own workspace (things the tenant should see and the claim authorizes), some into the provider's back-office workspace (fulfilment requests the tenant should never touch), and — for the api-syncagent use case — some into the physical host cluster the controller runs in (real infrastructure like VMs). See decisions/0011-external-refs-and-host-target.md.
Provider- and host-target children are in the same ownership domain as the
controller (its own / the host client), so they involve no permissionClaim.
Consumer-target children cross a workspace boundary and therefore require the claim
handshake below. A host-routed resource fails loudly if the controller has no
host client configured (fail-closed) — see
permissions.md.
A resource can carry an externalRef instead of a template (the two are
mutually exclusive, enforced by the CRD). An externalRef is an existing object
that krop reads but never creates, mutates, labels, owns, or garbage-collects.
Its observed status/data funnels into other resources via ${id.status.x}
CEL, exactly like a template node's observed object — so an external input on one
plane can drive a written child on another.
Two forms:
# single — one object by name (NotFound ⇒ the instance pends until it appears)
- id: vpc
target: consumer
externalRef:
apiVersion: net.example/v1
kind: VPC
metadata:
name: ${schema.spec.vpcName} # CEL allowed in name/namespace/selector
namespace: default
# collection — every object matching a label selector
- id: peers
target: consumer
externalRef:
apiVersion: net.example/v1
kind: VPC
metadata:
namespace: default
selector:
matchLabels: { tier: shared }externalRef reads honor target like any resource: a consumer external ref is
read through the virtual workspace under a read-only permissionClaim
(get,list,watch); a provider/host external ref is read with the controller's
own / the host client (no claim). External refs are never pruned or swept —
krop does not own them.
Foreign-type invariant. Reading a foreign (non-core) external CRD type still requires that type's owning
APIExportto be bound in the provider workspace so itsidentityHashresolves — the same invariant that governs foreign consumer-target writes. Core types (group"") need no binding. See permissions.md.
The motivating pattern: provision a VM into the host cluster that must live
inside an existing VPC owned by the tenant. Declare the VPC as a consumer
externalRef, read ${vpc.status.vpcId}, and funnel it into a host-target VM
template:
resources:
# read-only: an existing VPC in the consumer workspace (never created/GC'd)
- id: vpc
target: consumer
externalRef:
apiVersion: net.example/v1
kind: VPC
metadata:
name: ${schema.spec.vpcName}
namespace: default
# written into the physical host cluster, wired to the VPC's observed id
- id: vm
target: host
template:
apiVersion: compute.example/v1
kind: VirtualMachine
metadata:
name: ${schema.spec.name}
namespace: default
spec:
vpcId: ${vpc.status.vpcId} # cross-plane CEL: consumer read → host writeThe ${vpc.status.vpcId} reference makes vm depend on vpc; krop reads the VPC
first, and if it (or its status.vpcId) is not present yet the VM pends —
identical convergence to any cross-target CEL dependency.
Templates and schema.status use kro's ${...} CEL. The handles krop supports:
${schema.spec.*}— the instance's own input, e.g.${schema.spec.region}.${<resourceId>.status.*}(and.metadata.*,.spec.*) — read another node's live object. This works cross-resource and cross-target: a consumer-target child can read a host- or provider-target child's status/metadata. In the example, the consumerconnectionConfigMapreads${db.metadata.name}from the host-targetDeployment.${schema.metadata.annotations["krop.opendefense.cloud/consumer-cluster"]}— the consumer's kcp logical-cluster name, stamped by the reconciler so blueprints can derive collision-free host/provider child names. See Consumer workspace info in CEL.schema.status.*— CEL projections written back onto each instance's.status. In the example,endpoint: ${connection.data.endpoint}andvpcID: ${vpc.status.vpcID}.
Cross-target dependencies pend, they do not fail. If a consumer child reads a
provider child's status that is not set yet, that child pends — the reconcile
reports "not complete", requeues (~30s), and the instance's Ready condition
stays False/Progressing until the referenced status appears. Only then does
the consumer child materialize. (This is exactly the token flow in the
walkthrough.) Because pending suppresses pruning of not-yet-applied children,
a partially-converged instance never has its work reclaimed mid-flight — see
operations.md.
You do not hand-write claims. The Registrar scans the blueprint's
consumer-target nodes and emits one permissionClaim per GroupResource that is
not the instance's own type, with verbs split by what the node does:
- writable children (
template, consumer) → full CRUD (get,list,watch,create,update,patch,delete); - external refs (
externalRef, consumer) → read-only (get,list,watch) — krop only ever reads them.
Claims are sorted for stable publications (internal/registrar/claims.go) and
published on the APIExport. Provider/host-target nodes get no claim (own /
host client).
Two rules follow from this:
-
The consumer must ACCEPT the claim. A cross-workspace (consumer-target) write is authorized only when the consumer's APIBinding sets the matching claim to
state: Accepted. ARejected(or absent) claim → the child is silently never written. This is by design; see the negative fixturetest/fixtures/apibinding-kubernetescluster-noclaim.yaml. -
Foreign (non-core) consumer-target types must be bound in the provider workspace — whether they are writable children or external refs. A claim for a core type (group
"", e.g.configmaps) legitimately carries an empty identity hash. But a claim for a foreign group needs that group'sidentityHashto resolve — which requires the owning APIExport to be bound in the provider workspace. If it is not, publish fails with condition reasonClaimIdentityUnresolvedrather than emitting a silently-broken claim (internal/registrar/claims.govalidateClaims). Fix: bind the foreign type's export in the provider workspace, and the next resync republishes.
See decisions/0004-auto-derived-permission-claims.md and permissions.md.
Many consumers' provider-target children land in one provider workspace, so
krop cannot use the template's literal metadata.name — two tenants both creating
eu-agent would collide. Instead every provider-target child is renamed
deterministically to <cluster>-<instance>-<originalName>-<hash>, where the hash
is a short SHA-256 of the null-joined (consumerCluster, instanceName, originalName) tuple, truncated to fit 253 chars (internal/engine/naming.go).
The rename is collision-free across consumers and stable across reconciles, so the
same instance always addresses the same provider child.
Consumer-target children keep their literal name — they live in the consumer's own workspace, so there is no cross-tenant collision.
CEL reads still work across the rename.
${db.metadata.name}resolves against the live renamed object; you reference the node by itsid, not its on-cluster name.
krop renames provider-target children automatically (above), but host- and
consumer-target children keep their template metadata.name. When many tenants
share one host cluster (or provider namespace), a literal name like db would
collide. To let a blueprint build collision-free names itself, the reconciler
stamps the consumer's kcp logical-cluster name onto each instance's metadata as
an annotation, on a runtime-only copy of the instance (never persisted):
krop.opendefense.cloud/consumer-cluster
Reference it from any template (or schema.status) via CEL:
metadata:
name: ${schema.metadata.annotations["krop.opendefense.cloud/consumer-cluster"]}-${schema.spec.name}Prefix host/provider child names with it for collision-free naming. Two
consumers both creating a HostedDatabase named db yield host Deployments named
<clusterA>-db and <clusterB>-db — no collision in the shared databases
namespace.
Why the cluster name and not the workspace path. The value is kcp's
canonical logical-cluster name (e.g. kvdk8299mah3yj1p), which is globally
unique and immutable — the correct handle for collision-free naming in a shared
host cluster or provider workspace. It is not the human workspace path
(root:acme:team), which is mutable (a workspace can be renamed/moved) and would
require an extra RBAC-gated lookup to resolve.
Why an annotation and not a ${workspace.name} variable. kro exposes the
instance to CEL only as the schema variable (its spec + metadata, no
status). A bare new top-level variable would fail kro's build-time validation,
which does not know about it; metadata.annotations is the one CEL-reachable open
map, so the reconciler injects the value there
(internal/engine/workspace.go AnnotationConsumerCluster/StampConsumerCluster,
stamped in internal/controller/reconciler.go before the kro runtime is built).
The same value is also applied to every materialized child as the matching
krop.opendefense.cloud/consumer-cluster label, so instance annotation and
child label always agree.
See the shipped
config/kcp/examples/blueprint-hosteddatabase.yaml
(which prefixes its host Deployment name with the annotation) and
decisions/0012-consumer-workspace-info-in-cel.md.
krop reconciles the desired child set on every pass and prunes labeled children that are no longer desired (only after a complete pass, so pending children are never reclaimed). This means:
- An
includeWhenthat flips to false → its child is pruned. - A
forEachcollection that shrinks → the removed elements' children are pruned. - A resource removed from the blueprint → its children are pruned.
Pruning runs per target (consumer children in the consumer workspace, provider
children in the provider workspace, host children in the host cluster).
externalRef nodes are never pruned — krop does not own them. See
architecture.md.
Editing a live blueprint's spec is supported: the Registrar detects the new spec
hash and restarts the per-blueprint instance manager to serve the new
compiled graph (change-detected Stop+Ensure,
decisions/0009-change-detected-manager-restart.md).
An unchanged 5-minute resync does not restart anything.
version/kind.
config/kcp/examples/blueprint-hosteddatabase.yaml
— a HostedDatabase that spans all three planes in one graph: it reads the
tenant's existing VPC (consumer, read-only), provisions the database workload on
the provider's host cluster (its name prefixed with the unique consumer-cluster
annotation), records the tenant→database mapping in the provider workspace, and
publishes connection details back to the tenant:
apiVersion: krop.opendefense.cloud/v1alpha1
kind: ResourceGraphDefinition
metadata:
name: hosteddatabase
spec:
schema:
apiVersion: v1alpha1
kind: HostedDatabase
group: krop.opendefense.cloud
spec:
name: string
engine: string | default="postgres" # instance input
status:
endpoint: ${connection.data.endpoint} # projection: the consumer child's data
vpcID: ${vpc.status.vpcID} # projection: the external-ref's live status
resources:
# (1) CONSUMER-target externalRef: an existing VPC krop READS but never
# creates/GCs; auto-derives a read-only (get,list,watch) permissionClaim.
- id: vpc
target: consumer
externalRef:
apiVersion: ec2.services.k8s.aws/v1alpha1
kind: VPC
metadata:
name: ${schema.spec.name}-vpc
# (2) HOST-target child: written into the physical host cluster. The name is
# PREFIXED with the consumer's kcp logical-cluster name (globally unique +
# immutable) so two tenants requesting "db" never collide on the host.
- id: db
target: host
template:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${schema.metadata.annotations["krop.opendefense.cloud/consumer-cluster"]}-${schema.spec.name}
namespace: databases
spec:
replicas: 1
selector: { matchLabels: { app: ${schema.spec.name} } }
template:
metadata: { labels: { app: ${schema.spec.name} } }
spec:
containers:
- name: db
image: ${schema.spec.engine}:16
env:
- name: VPC_ID
value: ${vpc.status.vpcID} # consumer read → host write
# (3) PROVIDER-target child: tenant→database inventory record, written with the
# controller's own identity; renamed collision-free; no permissionClaim.
- id: registration
target: provider
template:
apiVersion: v1
kind: ConfigMap
metadata:
name: ${schema.spec.name}-registration
namespace: default
data:
consumerCluster: ${schema.metadata.annotations["krop.opendefense.cloud/consumer-cluster"]}
engine: ${schema.spec.engine}
# (4) CONSUMER-target child (default): connection details published back to the
# tenant through the APIExport vw, authorized by the accepted `configmaps`
# claim. Funnels the host Deployment name (host → consumer cross-target CEL).
- id: connection
target: consumer
template:
apiVersion: v1
kind: ConfigMap
metadata:
name: ${schema.spec.name}-connection
namespace: default
data:
endpoint: ${db.metadata.name}.databases.svc.cluster.local # host → consumer CEL
vpcID: ${vpc.status.vpcID}Publishing this blueprint yields:
- an
APIExportnamedhosteddatabases.krop.opendefense.cloudserving theHostedDatabasekind, - auto-derived permissionClaims from its consumer-target nodes: a read-only
(
get,list,watch) claim for the foreignec2.services.k8s.aws/VPCexternal ref and a full-CRUDconfigmapsclaim for the consumerconnectionConfigMap. The hostdbDeployment and the providerregistrationConfigMap are in the controller's own ownership domain, so they need no claim, - and, per instance, a host-cluster
Deployment, a provider-workspaceConfigMap, and a consumer-workspaceConfigMaponce the VPC and Deployment name resolve.
Every provider-target GVK your blueprint emits needs a matching rule in the
provider-workspace RBAC (config/kcp/rbac/provider-rbac.yaml). This example's
provider child is a core ConfigMap (registration), already covered by the
required configmaps rule; a foreign provider-target GVK would need its own rule.
Never widen it to */*. See operations.md and
permissions.md.
-
spec.schemadefines the instancegroup/version/kind, its inputspec, and anystatusCEL projections. - Every resource has an
id, exactly one oftemplate/externalRef, and (if not consumer) an explicittarget: providerortarget: host. - Every foreign consumer-target type's export (writable or external-ref)
is bound in the provider workspace (else publish fails
ClaimIdentityUnresolved). - If any resource routes to
host, the controller has a host client (in-cluster config or--host-kubeconfig) and the provider has granted it host-cluster RBAC for those GVKs. - Every provider-target GVK has a least-privilege rule in
provider-rbac.yaml(never*/*). - Every provider-target child CRD is served in the provider workspace before the blueprint is created (kro type-checks CEL against it).
- Consumers know to bind the export accepting the derived claims.