CRD behavior and examples¶
This page covers cross-field invariants, controller-side condition reasons, the finalizers/annotations the operator manages, and fully worked YAML examples for both CRDs in projection.sh/v1. For the field-by-field API schema — types, validation rules, defaults — see the auto-generated API reference, which is regenerated from api/v1/*.go by make docs-ref and verified in CI.
projection.sh/v1 ships two CRDs:
| CRD | Scope | Short name | Destinations |
|---|---|---|---|
Projection |
Namespaced | proj |
Single — Projection's own metadata.namespace. |
ClusterProjection |
Cluster | cproj |
Fan-out — explicit list, or selector. |
Both CRDs share the same source, overlay, ownership keys (with different suffixes), and reconcile model. Apiserver floor is Kubernetes ≥ 1.32, required by the CEL admission rules below.
source (shared)¶
The SourceRef struct is the same for both CRDs:
| Field | Type | Required | Notes |
|---|---|---|---|
source.group |
string | no | "" (or omitted) for the core API; otherwise a DNS-subdomain group name. |
source.version |
string | no | Optional. Empty resolves to the RESTMapper's preferred served version (for the core group, currently v1). Set explicitly to pin. |
source.kind |
string | yes | PascalCase. Pattern-validated. |
source.namespace |
string | yes | DNS-1123. Source must be a namespaced object. |
source.name |
string | yes | DNS-1123. |
source.version semantics¶
| Form | Resolution |
|---|---|
kind: ConfigMap (group + version omitted) |
Core group, RESTMapper-preferred served version (v1 today). |
group: "", version: v1 |
Core group, pinned to v1. |
group: apps, version: v1 |
Named group, pinned to v1. |
group: apps (version omitted) |
Named group, RESTMapper-preferred served version. Follows CRD promotions. |
The resolved version is surfaced in the SourceResolved condition message:
kubectl get projection <name> -o jsonpath='{.status.conditions[?(@.type=="SourceResolved")].message}'
# → resolved apps/Deployment to preferred version v1
The unpinned form is the recommended default for sources whose CRD versions evolve over time: when an author promotes v1beta1 → v1 and stops serving v1beta1, projection picks up the new preferred version on the next reconcile rather than reporting SourceResolutionFailed and garbage-collecting destinations.
Projection (namespaced)¶
- API group:
projection.sh - API version:
v1(storage version; stability commitments documented in API stability) - Kind:
Projection - Scope: Namespaced
- Short name:
proj
A Projection mirrors one source object into the Projection's own namespace.
Spec¶
| Field | Type | Required | Notes |
|---|---|---|---|
spec.source |
SourceRef |
yes | See source (shared) above. |
spec.destination.name |
string | no | Rename override. Defaults to source.name. |
spec.overlay.labels |
map[string]string | no | Labels merged on top of source metadata. Overlay wins. |
spec.overlay.annotations |
map[string]string | no | Annotations merged on top of source metadata. Overlay wins. |
Note: there is no spec.destination.namespace and no spec.destination.namespaceSelector. The destination namespace is always the Projection's own metadata.namespace. Use ClusterProjection for fan-out across multiple namespaces.
Status¶
| Field | Type | Notes |
|---|---|---|
status.conditions |
[]metav1.Condition |
Standard conditions array. See status.conditions (both CRDs) below. |
status.destinationName |
string | The resolved destination name (after rename). Populated after first successful write. |
Print columns¶
kubectl get projections (and -A, also as proj) surfaces:
| Column | JSONPath | Default? |
|---|---|---|
Kind |
.spec.source.kind |
yes |
Source-Group |
.spec.source.group |
with -o wide (priority=1) |
Source-Namespace |
.spec.source.namespace |
yes |
Source-Name |
.spec.source.name |
yes |
Destination-Name |
.status.destinationName |
yes |
Ready |
.status.conditions[?(@.type=='Ready')].status |
yes |
Age |
.metadata.creationTimestamp |
yes |
Destination-Name reflects the resolved destination name from status — the rename applied (or the source name when no rename is set), populated after the first successful write.
Worked example¶
apiVersion: projection.sh/v1
kind: Projection
metadata:
name: app-config-mirror
namespace: tenant-a # destination namespace = this
spec:
source:
group: ""
version: v1
kind: ConfigMap
namespace: platform
name: app-config
destination:
name: shared-app-config # optional rename; defaults to source.name
overlay:
labels:
tenant: tenant-a
projected-by: projection
annotations:
mirror.example.com/source: platform/app-config
status:
destinationName: shared-app-config
conditions:
- type: SourceResolved
status: "True"
reason: Resolved
message: ""
lastTransitionTime: "2026-05-07T10:00:00Z"
- type: DestinationWritten
status: "True"
reason: Projected
message: ""
lastTransitionTime: "2026-05-07T10:00:00Z"
- type: Ready
status: "True"
reason: Projected
message: ""
lastTransitionTime: "2026-05-07T10:00:00Z"
ClusterProjection (cluster-scoped)¶
- API group:
projection.sh - API version:
v1 - Kind:
ClusterProjection - Scope: Cluster
- Short name:
cproj
A ClusterProjection is fan-out only: it writes the same destination object into multiple namespaces, either an explicit list or every namespace matching a label selector.
Spec¶
| Field | Type | Required | Notes |
|---|---|---|---|
spec.source |
SourceRef |
yes | See source (shared) above. |
spec.destination.namespaces |
[]string (set) |
conditional | Explicit target namespace list. +listType=set, minItems=1. Mutex with namespaceSelector. |
spec.destination.namespaceSelector |
metav1.LabelSelector |
conditional | Match every namespace whose labels satisfy the selector. Mutex with namespaces. |
spec.destination.name |
string | no | Rename override applied in every target namespace. Defaults to source.name. |
spec.overlay.labels |
map[string]string | no | Labels merged on top of source metadata. Overlay wins. |
spec.overlay.annotations |
map[string]string | no | Annotations merged on top of source metadata. Overlay wins. |
CEL admission rules on spec.destination¶
Two rules are enforced at admission time:
namespaces and namespaceSelector are mutually exclusive — setting both is rejected.
At least one must be set — empty destination is rejected.
namespaces carries +listType=set (no duplicates) and minItems=1 (cannot be the empty list). The minItems rule is what keeps namespaces: [] from satisfying the at-least-one rule.
Status¶
| Field | Type | Notes |
|---|---|---|
status.conditions |
[]metav1.Condition |
Standard conditions array. |
status.destinationName |
string | The resolved destination name (after rename). Populated after first successful write. |
status.namespacesWritten |
int32 | Count of target namespaces where the destination was successfully written on the last reconcile. |
status.namespacesFailed |
int32 | Count of target namespaces where the write failed on the last reconcile. Per-namespace detail surfaces via Events. |
Print columns¶
kubectl get clusterprojections (also as cproj) surfaces:
| Column | JSONPath | Default? |
|---|---|---|
Kind |
.spec.source.kind |
yes |
Source-Group |
.spec.source.group |
with -o wide (priority=1) |
Source-Namespace |
.spec.source.namespace |
yes |
Source-Name |
.spec.source.name |
yes |
Destination-Name |
.status.destinationName |
yes |
Targets |
.status.namespacesWritten |
yes |
Failed |
.status.namespacesFailed |
with -o wide (priority=1) |
Selector |
.spec.destination.namespaceSelector.matchLabels |
with -o wide (priority=1) |
Ready |
.status.conditions[?(@.type=='Ready')].status |
yes |
Age |
.metadata.creationTimestamp |
yes |
Selector is empty for list-based ClusterProjections; Targets shows the count for either form.
Worked example: explicit list¶
apiVersion: projection.sh/v1
kind: ClusterProjection
metadata:
name: shared-config-fanout
spec:
source:
group: ""
version: v1
kind: ConfigMap
namespace: platform
name: app-config
destination:
namespaces:
- tenant-a
- tenant-b
- tenant-c
name: shared-app-config # optional rename; same name applied in each target
overlay:
labels:
projected-by: projection
status:
destinationName: shared-app-config
namespacesWritten: 3
namespacesFailed: 0
conditions:
- type: SourceResolved
status: "True"
reason: Resolved
message: ""
lastTransitionTime: "2026-05-07T10:00:00Z"
- type: DestinationWritten
status: "True"
reason: Projected
message: ""
lastTransitionTime: "2026-05-07T10:00:00Z"
- type: Ready
status: "True"
reason: Projected
message: ""
lastTransitionTime: "2026-05-07T10:00:00Z"
Worked example: selector¶
apiVersion: projection.sh/v1
kind: ClusterProjection
metadata:
name: shared-config-fanout
spec:
source:
group: ""
version: v1
kind: ConfigMap
namespace: platform
name: app-config
destination:
namespaceSelector:
matchLabels:
projection.sh/mirror: "true"
name: shared-app-config
overlay:
labels:
projected-by: projection
Every namespace carrying the label projection.sh/mirror=true gets a copy. Adding the label to a new namespace triggers a reconcile and the destination appears; removing it deletes the destination.
status.conditions (both CRDs)¶
Both CRDs use the standard metav1.Condition array. The controller maintains three condition types. The reasons are shared.
| Type | True reason(s) | False reason(s) | Unknown reason(s) |
|---|---|---|---|
SourceResolved |
Resolved |
SourceResolutionFailed (RESTMapper can't find Kind, cluster-scoped Kind), SourceFetchFailed (transient fetch error), SourceDeleted (source 404 — owned destinations cleaned up), SourceOptedOut (source has projectable="false"), SourceNotProjectable (allowlist mode, source missing projectable="true") |
— |
DestinationWritten |
Projected |
DestinationCreateFailed, DestinationUpdateFailed, DestinationFetchFailed, DestinationConflict, NamespaceResolutionFailed, DestinationWriteFailed |
SourceNotResolved |
Ready |
Projected |
Mirrors whichever of SourceResolved or DestinationWritten failed, with the same reason and message |
— |
DestinationWritten=Unknown reason=SourceNotResolved specifically means the source-side step failed, so a destination write was never attempted.
For ClusterProjection, DestinationWritten is a rollup across all target namespaces. If every target succeeds, the condition is True. If any fail, it's False with a reason from the failure set (or the generic DestinationWriteFailed when reasons differ across namespaces); the message lists the failed namespaces. status.namespacesWritten and status.namespacesFailed are the canonical counts. Per-namespace detail surfaces via Events.
DestinationWritten=False reason=NamespaceResolutionFailed means the label selector was malformed or namespace listing failed.
Note: pre-v0.3.0 surfaced
DestinationWritten=False reason=InvalidSpecwhen the v0.2 mutex (destination.namespacexordestination.namespaceSelector) was violated at runtime. v0.3.0 removes that runtime check entirely — the namespacedProjectionno longer has a destination-namespace mutex (its destination is always the Projection's own namespace), and theClusterProjectionmutex is enforced by CEL admission so a violation never reaches the reconciler.
Stripped fields by Kind (both CRDs)¶
Some Kinds carry apiserver-allocated spec fields the controller strips before writing the destination. These fields would either be rejected on create (spec.clusterIP: field is immutable) or carry meaningless values across namespaces. The current set:
| Kind | Stripped fields |
|---|---|
v1/Service |
spec.clusterIP, spec.clusterIPs, spec.ipFamilies, spec.ipFamilyPolicy |
v1/PersistentVolumeClaim |
spec.volumeName |
v1/Pod |
spec.nodeName |
batch/v1/Job |
spec.selector, plus the auto-generated controller-uid / batch.kubernetes.io/controller-uid / batch.kubernetes.io/job-name labels on spec.template.metadata.labels. Jobs created with spec.manualSelector: true are a known limitation — the controller's stripping logic assumes the apiserver-managed selector path. |
On update, the controller copies these fields from the existing destination back onto the desired object before issuing the Update, so an Update of a Service whose clusterIP we stripped at build time isn't rejected for trying to clear an immutable field.
If you hit field is immutable errors for a Kind not in the table above, the controller is likely missing an entry in droppedSpecFieldsByGVK — see CONTRIBUTING.md for the path to add one, and please open an issue.
Finalizers and ownership keys¶
| Name | Where | Owner | Purpose |
|---|---|---|---|
projection.sh/finalizer |
Projection.metadata |
Projection |
Blocks deletion until the controller has cleaned up the destination (if it still owns it). |
projection.sh/cluster-finalizer |
ClusterProjection.metadata |
ClusterProjection |
Blocks deletion until the controller has cleaned up every owned destination across the cluster. |
projection.sh/owned-by-projection |
Destination annotations | Projection |
Marks the destination as owned by <projection-ns>/<projection-name>. Authoritative ownership signal — checked on every write and delete. |
projection.sh/owned-by-projection-uid |
Destination labels | Projection |
The owning Projection's metadata.uid. Used by destination-side watches and label-selector cleanup paths. Watch hint only — never trusted alone for access decisions; the annotation is verified after every label-driven list. |
projection.sh/owned-by-cluster-projection |
Destination annotations | ClusterProjection |
Marks the destination as owned by <cluster-projection-name>. (No <ns>/ prefix — ClusterProjection is cluster-scoped.) Authoritative ownership signal. |
projection.sh/owned-by-cluster-projection-uid |
Destination labels | ClusterProjection |
The owning ClusterProjection's metadata.uid. Watch hint, same discipline as the namespaced UID label. |
projection.sh/projectable |
Source annotations (read by controller, written by source owners) | (n/a) | Source-side opt-in/veto. "true" = opt-in (required under default sourceMode=allowlist). "false" = veto (always honored regardless of mode; flipping a previously-projected source to "false" garbage-collects every destination). Any other value is treated as "not opted in" under allowlist, "projectable by default" under permissive. |
The controller always stamps the appropriate ownership annotation and UID label on the destination, regardless of overlay. Do not attempt to set these keys via overlay — they will be overwritten on every reconcile.
The discipline is: annotation is authoritative, UID label is a watch hint. The controller's ensureDestWatch registers a label-filtered watch on the destination GVK so that manual deletion of a destination triggers an immediate reconcile. Cleanup paths use the UID label for an indexed cluster-wide List(LabelSelector), but every candidate's annotation is verified again before any write or delete. A malicious or accidental copy of the UID label onto a stranger's object would not let the controller touch it — the annotation wouldn't match.