From 3fed56e97a40ba5b880548ca66a3fa95e537a889 Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:05:14 +0200 Subject: [PATCH 01/15] feat(api): make spec.source.version optional for core group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the CEL rule that required version when group was empty. Core sources can now be referenced by kind alone (e.g. kind: ConfigMap), matching the unpinned form already valid for non-core groups. Empty version resolves to the cluster's preferred served version via the RESTMapper, identical to the existing non-core path; the controller already handles this case in resolveGVR, so no controller code changes. Existing manifests with explicit version: v1 continue to work unchanged — this is a pure relaxation, not a breaking change. --- api/v1/source_types.go | 12 ++++++------ api/v1/testdata/clusterprojection.crd.golden.yaml | 4 ---- api/v1/testdata/crd.golden.yaml | 4 ---- .../crds/clusterprojections.projection.sh.yaml | 4 ---- .../projection/crds/projections.projection.sh.yaml | 4 ---- .../crd/bases/projection.sh_clusterprojections.yaml | 4 ---- config/crd/bases/projection.sh_projections.yaml | 4 ---- docs/api-reference.md | 10 ++++++---- 8 files changed, 12 insertions(+), 34 deletions(-) diff --git a/api/v1/source_types.go b/api/v1/source_types.go index f4407de..0f7c0bb 100644 --- a/api/v1/source_types.go +++ b/api/v1/source_types.go @@ -20,12 +20,12 @@ package v1 // // Group + Version + Kind name the GVK; Namespace + Name name the object. // -// `version` may be omitted for non-core groups, in which case the operator -// resolves the preferred served version via the RESTMapper on every -// reconcile. The core group has only `v1` as a stable form, so `version` -// MUST be set when `group` is empty — enforced by the CEL rule below. -// -// +kubebuilder:validation:XValidation:rule="size(self.group) != 0 || size(self.version) != 0",message="version is required when group is empty (no unpinned form for core)" +// Both `group` and `version` may be omitted. Empty `group` means the core +// group (e.g. ConfigMap, Secret, Service). Empty `version` means the +// operator resolves the preferred served version via the RESTMapper on +// every reconcile, so the source automatically follows version promotions +// (e.g. CRD `v1beta1 → v1`; or, hypothetically, core `v1 → v2`). Set +// `version` explicitly to pin. type SourceRef struct { // Group is the API group of the source object. Empty string means the // core group (e.g. ConfigMap, Secret, Service). diff --git a/api/v1/testdata/clusterprojection.crd.golden.yaml b/api/v1/testdata/clusterprojection.crd.golden.yaml index 409583c..695075b 100644 --- a/api/v1/testdata/clusterprojection.crd.golden.yaml +++ b/api/v1/testdata/clusterprojection.crd.golden.yaml @@ -216,10 +216,6 @@ spec: - name - namespace type: object - x-kubernetes-validations: - - message: version is required when group is empty (no unpinned form - for core) - rule: size(self.group) != 0 || size(self.version) != 0 required: - destination - source diff --git a/api/v1/testdata/crd.golden.yaml b/api/v1/testdata/crd.golden.yaml index f50e61f..3cd62de 100644 --- a/api/v1/testdata/crd.golden.yaml +++ b/api/v1/testdata/crd.golden.yaml @@ -142,10 +142,6 @@ spec: - name - namespace type: object - x-kubernetes-validations: - - message: version is required when group is empty (no unpinned form - for core) - rule: size(self.group) != 0 || size(self.version) != 0 required: - source type: object diff --git a/charts/projection/crds/clusterprojections.projection.sh.yaml b/charts/projection/crds/clusterprojections.projection.sh.yaml index 409583c..695075b 100644 --- a/charts/projection/crds/clusterprojections.projection.sh.yaml +++ b/charts/projection/crds/clusterprojections.projection.sh.yaml @@ -216,10 +216,6 @@ spec: - name - namespace type: object - x-kubernetes-validations: - - message: version is required when group is empty (no unpinned form - for core) - rule: size(self.group) != 0 || size(self.version) != 0 required: - destination - source diff --git a/charts/projection/crds/projections.projection.sh.yaml b/charts/projection/crds/projections.projection.sh.yaml index f50e61f..3cd62de 100644 --- a/charts/projection/crds/projections.projection.sh.yaml +++ b/charts/projection/crds/projections.projection.sh.yaml @@ -142,10 +142,6 @@ spec: - name - namespace type: object - x-kubernetes-validations: - - message: version is required when group is empty (no unpinned form - for core) - rule: size(self.group) != 0 || size(self.version) != 0 required: - source type: object diff --git a/config/crd/bases/projection.sh_clusterprojections.yaml b/config/crd/bases/projection.sh_clusterprojections.yaml index 409583c..695075b 100644 --- a/config/crd/bases/projection.sh_clusterprojections.yaml +++ b/config/crd/bases/projection.sh_clusterprojections.yaml @@ -216,10 +216,6 @@ spec: - name - namespace type: object - x-kubernetes-validations: - - message: version is required when group is empty (no unpinned form - for core) - rule: size(self.group) != 0 || size(self.version) != 0 required: - destination - source diff --git a/config/crd/bases/projection.sh_projections.yaml b/config/crd/bases/projection.sh_projections.yaml index f50e61f..3cd62de 100644 --- a/config/crd/bases/projection.sh_projections.yaml +++ b/config/crd/bases/projection.sh_projections.yaml @@ -142,10 +142,6 @@ spec: - name - namespace type: object - x-kubernetes-validations: - - message: version is required when group is empty (no unpinned form - for core) - rule: size(self.group) != 0 || size(self.version) != 0 required: - source type: object diff --git a/docs/api-reference.md b/docs/api-reference.md index 5dff6a8..1cf357e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -248,10 +248,12 @@ SourceRef identifies the object to project. Group + Version + Kind name the GVK; Namespace + Name name the object. -`version` may be omitted for non-core groups, in which case the operator -resolves the preferred served version via the RESTMapper on every -reconcile. The core group has only `v1` as a stable form, so `version` -MUST be set when `group` is empty — enforced by the CEL rule below. +Both `group` and `version` may be omitted. Empty `group` means the core +group (e.g. ConfigMap, Secret, Service). Empty `version` means the +operator resolves the preferred served version via the RESTMapper on +every reconcile, so the source automatically follows version promotions +(e.g. CRD `v1beta1 → v1`; or, hypothetically, core `v1 → v2`). Set +`version` explicitly to pin. From a8e708476831a98b6010ecf605b978826871150f Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:13:26 +0200 Subject: [PATCH 02/15] docs: align SourceRef.Version godoc and resolveGVR comment with relaxed admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stale comments induced by the previous commit (3fed56e): - SourceRef.Version godoc said "Omit for non-core groups" — implied omission was invalid for core, contradicting the new struct-level paragraph that says both group and version are optional. - resolveGVR's leading comment claimed admission rejects empty Version with empty Group, which is no longer true. Reflows into the rendered CRD descriptions (and goldens) verbatim. --- api/v1/source_types.go | 8 ++++---- api/v1/testdata/clusterprojection.crd.golden.yaml | 8 ++++---- api/v1/testdata/crd.golden.yaml | 8 ++++---- .../projection/crds/clusterprojections.projection.sh.yaml | 8 ++++---- charts/projection/crds/projections.projection.sh.yaml | 8 ++++---- config/crd/bases/projection.sh_clusterprojections.yaml | 8 ++++---- config/crd/bases/projection.sh_projections.yaml | 8 ++++---- docs/api-reference.md | 2 +- internal/controller/source.go | 5 +++-- 9 files changed, 32 insertions(+), 31 deletions(-) diff --git a/api/v1/source_types.go b/api/v1/source_types.go index 0f7c0bb..675e249 100644 --- a/api/v1/source_types.go +++ b/api/v1/source_types.go @@ -34,10 +34,10 @@ type SourceRef struct { Group string `json:"group,omitempty"` // Version is the API version of the source object within its Group. Omit - // for non-core groups to use the RESTMapper's preferred served version - // (the source automatically follows CRD version promotions). When set, - // must match Kubernetes' canonical version-string shape (`vN`, - // `vNalphaM`, `vNbetaM`). + // to use the RESTMapper's preferred served version (the source + // automatically follows version promotions). When set, must match + // Kubernetes' canonical version-string shape (`vN`, `vNalphaM`, + // `vNbetaM`). // +optional // +kubebuilder:validation:Pattern=`^$|^v[0-9]+((alpha|beta)[0-9]+)?$` Version string `json:"version,omitempty"` diff --git a/api/v1/testdata/clusterprojection.crd.golden.yaml b/api/v1/testdata/clusterprojection.crd.golden.yaml index 695075b..8feb881 100644 --- a/api/v1/testdata/clusterprojection.crd.golden.yaml +++ b/api/v1/testdata/clusterprojection.crd.golden.yaml @@ -205,10 +205,10 @@ spec: version: description: |- Version is the API version of the source object within its Group. Omit - for non-core groups to use the RESTMapper's preferred served version - (the source automatically follows CRD version promotions). When set, - must match Kubernetes' canonical version-string shape (`vN`, - `vNalphaM`, `vNbetaM`). + to use the RESTMapper's preferred served version (the source + automatically follows version promotions). When set, must match + Kubernetes' canonical version-string shape (`vN`, `vNalphaM`, + `vNbetaM`). pattern: ^$|^v[0-9]+((alpha|beta)[0-9]+)?$ type: string required: diff --git a/api/v1/testdata/crd.golden.yaml b/api/v1/testdata/crd.golden.yaml index 3cd62de..31fd8be 100644 --- a/api/v1/testdata/crd.golden.yaml +++ b/api/v1/testdata/crd.golden.yaml @@ -131,10 +131,10 @@ spec: version: description: |- Version is the API version of the source object within its Group. Omit - for non-core groups to use the RESTMapper's preferred served version - (the source automatically follows CRD version promotions). When set, - must match Kubernetes' canonical version-string shape (`vN`, - `vNalphaM`, `vNbetaM`). + to use the RESTMapper's preferred served version (the source + automatically follows version promotions). When set, must match + Kubernetes' canonical version-string shape (`vN`, `vNalphaM`, + `vNbetaM`). pattern: ^$|^v[0-9]+((alpha|beta)[0-9]+)?$ type: string required: diff --git a/charts/projection/crds/clusterprojections.projection.sh.yaml b/charts/projection/crds/clusterprojections.projection.sh.yaml index 695075b..8feb881 100644 --- a/charts/projection/crds/clusterprojections.projection.sh.yaml +++ b/charts/projection/crds/clusterprojections.projection.sh.yaml @@ -205,10 +205,10 @@ spec: version: description: |- Version is the API version of the source object within its Group. Omit - for non-core groups to use the RESTMapper's preferred served version - (the source automatically follows CRD version promotions). When set, - must match Kubernetes' canonical version-string shape (`vN`, - `vNalphaM`, `vNbetaM`). + to use the RESTMapper's preferred served version (the source + automatically follows version promotions). When set, must match + Kubernetes' canonical version-string shape (`vN`, `vNalphaM`, + `vNbetaM`). pattern: ^$|^v[0-9]+((alpha|beta)[0-9]+)?$ type: string required: diff --git a/charts/projection/crds/projections.projection.sh.yaml b/charts/projection/crds/projections.projection.sh.yaml index 3cd62de..31fd8be 100644 --- a/charts/projection/crds/projections.projection.sh.yaml +++ b/charts/projection/crds/projections.projection.sh.yaml @@ -131,10 +131,10 @@ spec: version: description: |- Version is the API version of the source object within its Group. Omit - for non-core groups to use the RESTMapper's preferred served version - (the source automatically follows CRD version promotions). When set, - must match Kubernetes' canonical version-string shape (`vN`, - `vNalphaM`, `vNbetaM`). + to use the RESTMapper's preferred served version (the source + automatically follows version promotions). When set, must match + Kubernetes' canonical version-string shape (`vN`, `vNalphaM`, + `vNbetaM`). pattern: ^$|^v[0-9]+((alpha|beta)[0-9]+)?$ type: string required: diff --git a/config/crd/bases/projection.sh_clusterprojections.yaml b/config/crd/bases/projection.sh_clusterprojections.yaml index 695075b..8feb881 100644 --- a/config/crd/bases/projection.sh_clusterprojections.yaml +++ b/config/crd/bases/projection.sh_clusterprojections.yaml @@ -205,10 +205,10 @@ spec: version: description: |- Version is the API version of the source object within its Group. Omit - for non-core groups to use the RESTMapper's preferred served version - (the source automatically follows CRD version promotions). When set, - must match Kubernetes' canonical version-string shape (`vN`, - `vNalphaM`, `vNbetaM`). + to use the RESTMapper's preferred served version (the source + automatically follows version promotions). When set, must match + Kubernetes' canonical version-string shape (`vN`, `vNalphaM`, + `vNbetaM`). pattern: ^$|^v[0-9]+((alpha|beta)[0-9]+)?$ type: string required: diff --git a/config/crd/bases/projection.sh_projections.yaml b/config/crd/bases/projection.sh_projections.yaml index 3cd62de..31fd8be 100644 --- a/config/crd/bases/projection.sh_projections.yaml +++ b/config/crd/bases/projection.sh_projections.yaml @@ -131,10 +131,10 @@ spec: version: description: |- Version is the API version of the source object within its Group. Omit - for non-core groups to use the RESTMapper's preferred served version - (the source automatically follows CRD version promotions). When set, - must match Kubernetes' canonical version-string shape (`vN`, - `vNalphaM`, `vNbetaM`). + to use the RESTMapper's preferred served version (the source + automatically follows version promotions). When set, must match + Kubernetes' canonical version-string shape (`vN`, `vNalphaM`, + `vNbetaM`). pattern: ^$|^v[0-9]+((alpha|beta)[0-9]+)?$ type: string required: diff --git a/docs/api-reference.md b/docs/api-reference.md index 1cf357e..1de8ca9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -264,7 +264,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `group` _string_ | Group is the API group of the source object. Empty string means the
core group (e.g. ConfigMap, Secret, Service). | | Pattern: `^$\|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
Optional: \{\}
| -| `version` _string_ | Version is the API version of the source object within its Group. Omit
for non-core groups to use the RESTMapper's preferred served version
(the source automatically follows CRD version promotions). When set,
must match Kubernetes' canonical version-string shape (`vN`,
`vNalphaM`, `vNbetaM`). | | Pattern: `^$\|^v[0-9]+((alpha\|beta)[0-9]+)?$`
Optional: \{\}
| +| `version` _string_ | Version is the API version of the source object within its Group. Omit
to use the RESTMapper's preferred served version (the source
automatically follows version promotions). When set, must match
Kubernetes' canonical version-string shape (`vN`, `vNalphaM`,
`vNbetaM`). | | Pattern: `^$\|^v[0-9]+((alpha\|beta)[0-9]+)?$`
Optional: \{\}
| | `kind` _string_ | Kind is the API Kind of the source object (PascalCase). | | Pattern: `^[A-Z][a-zA-Z0-9]*$`
| | `namespace` _string_ | Namespace where the source object lives. | | MaxLength: 63
Pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
| | `name` _string_ | Name of the source object (DNS-1123 subdomain). | | MaxLength: 253
Pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
| diff --git a/internal/controller/source.go b/internal/controller/source.go index f741231..fda142d 100644 --- a/internal/controller/source.go +++ b/internal/controller/source.go @@ -70,8 +70,9 @@ func sourceKey(s projectionv1.SourceRef) string { // surface the resolved version in the SourceResolved condition message // for operator-visibility. // -// SourceRef admission rejects an empty Version when Group is empty (no -// unpinned form for the core group), so we don't repeat that check here. +// Admission permits any combination of (Group, Version), including both +// empty. When Version is empty we ask the RESTMapper for the preferred +// served version (v1 for core); when set, we pin to it. func (d *ControllerDeps) resolveGVR(src projectionv1.SourceRef) (schema.GroupVersionResource, string, error) { gk := schema.GroupKind{Group: src.Group, Kind: src.Kind} From 79932028bcfd1ae9cf6af47186fc19ef8cc9d0df Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:19:06 +0200 Subject: [PATCH 03/15] test(controller): cover bare-Kind admission for core-group sources --- .../controller/projection_controller_test.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/controller/projection_controller_test.go b/internal/controller/projection_controller_test.go index 9c0ecb7..ea1982c 100644 --- a/internal/controller/projection_controller_test.go +++ b/internal/controller/projection_controller_test.go @@ -361,6 +361,52 @@ var _ = Describe("Projection Controller (integration)", func() { Expect(sr.Status).To(Equal(metav1.ConditionTrue)) Expect(sr.Message).To(Equal("resolved apps/Deployment to preferred version v1")) }) + + It("admits a Projection with only Kind set on the source (no group, no version)", func() { + // Regression test for the relaxed admission rule: dropping the + // CEL guard "size(self.group) != 0 || size(self.version) != 0" + // means a core-group source can be referenced by Kind alone. + // resolveGVR should fall through to the preferred-version path + // just like the non-core unpinned form above. + bareNS := uniqueNS("bare-kind") + ensureNamespace(bareNS) + + src := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bare-kind-source", + Namespace: bareNS, + Annotations: map[string]string{projectableAnnotation: "true"}, + }, + Data: map[string]string{"k": "v"}, + } + Expect(k8sClient.Create(ctx, src)).To(Succeed()) + + // Same-namespace source/destination would collide on names, so + // pick a destination name distinct from the source. + bareKey := types.NamespacedName{Name: "bare-kind-projection", Namespace: bareNS} + proj := &projectionv1.Projection{ + ObjectMeta: metav1.ObjectMeta{Name: bareKey.Name, Namespace: bareKey.Namespace}, + Spec: projectionv1.ProjectionSpec{ + Source: projectionv1.SourceRef{ + // Group AND Version both omitted — this is what we're testing. + Kind: "ConfigMap", + Namespace: bareNS, + Name: src.Name, + }, + Destination: projectionv1.ProjectionDestination{Name: "bare-kind-dst"}, + }, + } + Expect(k8sClient.Create(ctx, proj)).To(Succeed()) + DeferCleanup(deleteProjection, bareKey) + + reconcileOnce(r, bareKey) + + got := &projectionv1.Projection{} + Expect(k8sClient.Get(ctx, bareKey, got)).To(Succeed()) + ready := apimeta.FindStatusCondition(got.Status.Conditions, "Ready") + Expect(ready).ToNot(BeNil()) + Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + }) }) Context("Conflict path", func() { From 0f291d36d65544cb0d01a579580f0edef1e3c40f Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:25:26 +0200 Subject: [PATCH 04/15] test(controller): assert SourceResolved message on bare-Kind admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a regression vector the bare-Kind test previously missed: a defaulter that injected version: v1 would silently mask the unpinned code path while still letting Ready=True flip. Asserting the resolved- version message ("resolved /ConfigMap to preferred version v1") forces the test to traverse the empty-Version branch in resolveGVR — symmetric with the existing unpinned-apps-Deployment spec. --- internal/controller/projection_controller_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/controller/projection_controller_test.go b/internal/controller/projection_controller_test.go index ea1982c..7b5a54d 100644 --- a/internal/controller/projection_controller_test.go +++ b/internal/controller/projection_controller_test.go @@ -406,6 +406,12 @@ var _ = Describe("Projection Controller (integration)", func() { ready := apimeta.FindStatusCondition(got.Status.Conditions, "Ready") Expect(ready).ToNot(BeNil()) Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + + sr := apimeta.FindStatusCondition(got.Status.Conditions, "SourceResolved") + Expect(sr).ToNot(BeNil()) + Expect(sr.Message).To(Equal("resolved /ConfigMap to preferred version v1"), + "unpinned core source must surface the RESTMapper-resolved version "+ + "so a future defaulter that re-pins to v1 can't silently mask the bare-Kind path") }) }) From 2eecc79c50c07b8eed8b973c7c12956dab1444ab Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:27:50 +0200 Subject: [PATCH 05/15] docs(samples): use bare Kind for core-group sources --- config/samples/projection_v1_clusterprojection.yaml | 2 -- config/samples/projection_v1_projection.yaml | 2 -- examples/configmap-cross-namespace.yaml | 2 -- examples/configmap-fan-out-list.yaml | 2 -- examples/configmap-fan-out-selector.yaml | 2 -- examples/multiple-destinations-from-one-source.yaml | 6 ------ examples/secret-cross-namespace.yaml | 2 -- examples/service-mirror.yaml | 2 -- examples/with-overlay-annotations.yaml | 2 -- examples/with-overlay-labels.yaml | 2 -- 10 files changed, 24 deletions(-) diff --git a/config/samples/projection_v1_clusterprojection.yaml b/config/samples/projection_v1_clusterprojection.yaml index 5fe17d9..3770716 100644 --- a/config/samples/projection_v1_clusterprojection.yaml +++ b/config/samples/projection_v1_clusterprojection.yaml @@ -7,8 +7,6 @@ metadata: name: clusterprojection-sample spec: source: - group: "" - version: v1 kind: ConfigMap name: shared-config namespace: config-source diff --git a/config/samples/projection_v1_projection.yaml b/config/samples/projection_v1_projection.yaml index f7e4c0f..0cc5cbd 100644 --- a/config/samples/projection_v1_projection.yaml +++ b/config/samples/projection_v1_projection.yaml @@ -7,8 +7,6 @@ metadata: name: projection-sample spec: source: - group: "" - version: v1 kind: ConfigMap name: cm-1 namespace: ns-1 diff --git a/examples/configmap-cross-namespace.yaml b/examples/configmap-cross-namespace.yaml index ac369ad..216fc51 100644 --- a/examples/configmap-cross-namespace.yaml +++ b/examples/configmap-cross-namespace.yaml @@ -39,8 +39,6 @@ metadata: namespace: tenant-a spec: source: - group: "" - version: v1 kind: ConfigMap name: app-config namespace: default diff --git a/examples/configmap-fan-out-list.yaml b/examples/configmap-fan-out-list.yaml index e54fb16..387b65f 100644 --- a/examples/configmap-fan-out-list.yaml +++ b/examples/configmap-fan-out-list.yaml @@ -69,8 +69,6 @@ metadata: name: app-config-fanout spec: source: - group: "" - version: v1 kind: ConfigMap name: app-config namespace: default diff --git a/examples/configmap-fan-out-selector.yaml b/examples/configmap-fan-out-selector.yaml index 3b43c7c..02e0c39 100644 --- a/examples/configmap-fan-out-selector.yaml +++ b/examples/configmap-fan-out-selector.yaml @@ -55,8 +55,6 @@ metadata: name: app-config-fanout spec: source: - group: "" - version: v1 kind: ConfigMap name: app-config namespace: default diff --git a/examples/multiple-destinations-from-one-source.yaml b/examples/multiple-destinations-from-one-source.yaml index 84c927e..63afba2 100644 --- a/examples/multiple-destinations-from-one-source.yaml +++ b/examples/multiple-destinations-from-one-source.yaml @@ -44,8 +44,6 @@ metadata: namespace: tenant-a spec: source: - group: "" - version: v1 kind: ConfigMap name: org-policy namespace: platform @@ -59,8 +57,6 @@ metadata: namespace: tenant-b spec: source: - group: "" - version: v1 kind: ConfigMap name: org-policy namespace: platform @@ -74,8 +70,6 @@ metadata: namespace: tenant-c spec: source: - group: "" - version: v1 kind: ConfigMap name: org-policy namespace: platform diff --git a/examples/secret-cross-namespace.yaml b/examples/secret-cross-namespace.yaml index 4f8977c..773051d 100644 --- a/examples/secret-cross-namespace.yaml +++ b/examples/secret-cross-namespace.yaml @@ -40,8 +40,6 @@ metadata: namespace: app-prod spec: source: - group: "" - version: v1 kind: Secret name: shared-tls namespace: cert-manager diff --git a/examples/service-mirror.yaml b/examples/service-mirror.yaml index adbc7ac..073daf9 100644 --- a/examples/service-mirror.yaml +++ b/examples/service-mirror.yaml @@ -43,8 +43,6 @@ metadata: namespace: team-b spec: source: - group: "" - version: v1 kind: Service name: api namespace: default diff --git a/examples/with-overlay-annotations.yaml b/examples/with-overlay-annotations.yaml index ac75a99..b26f986 100644 --- a/examples/with-overlay-annotations.yaml +++ b/examples/with-overlay-annotations.yaml @@ -32,8 +32,6 @@ metadata: namespace: app-staging spec: source: - group: "" - version: v1 kind: ConfigMap name: feature-flags namespace: platform diff --git a/examples/with-overlay-labels.yaml b/examples/with-overlay-labels.yaml index 2d7c699..422b1a7 100644 --- a/examples/with-overlay-labels.yaml +++ b/examples/with-overlay-labels.yaml @@ -37,8 +37,6 @@ metadata: namespace: tenant-a spec: source: - group: "" - version: v1 kind: ConfigMap name: shared-config namespace: platform From 29bb83e94d7799ab376fffb44f7708c2cec0ae96 Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:32:11 +0200 Subject: [PATCH 06/15] docs(getting-started): lead with bare-Kind form for core sources --- docs/getting-started.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 7680c35..9b575a4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -102,8 +102,6 @@ metadata: namespace: tenant-a # destination namespace = this spec: source: - group: "" # core API - version: v1 kind: ConfigMap namespace: default name: app-config @@ -111,6 +109,14 @@ spec: # name: shared-app-config # optional rename; defaults to source.name ``` +> **Why no `apiVersion`, `group`, or `version`?** `spec.source.kind` alone +> is enough for core-group resources (`ConfigMap`, `Secret`, `Service`, +> ...). Omitting `version` asks the operator to resolve the preferred +> served version via the RESTMapper on every reconcile — for core that's +> always `v1`. For CRDs that go through a `v1beta1 → v1` promotion, +> omitting `version` lets the source automatically follow the promotion. +> Set `version` explicitly when you want to pin. + ```bash kubectl apply -f tenant-a-projection.yaml ``` @@ -167,8 +173,6 @@ metadata: name: shared-config-fanout spec: source: - group: "" - version: v1 kind: ConfigMap namespace: default name: app-config @@ -206,8 +210,6 @@ metadata: name: shared-config-fanout spec: source: - group: "" - version: v1 kind: ConfigMap namespace: default name: app-config @@ -235,7 +237,7 @@ Adding the label to a new namespace triggers an immediate reconcile and the dest ## Sources outside the core group -The examples above use `group: ""` + `version: v1` — the core API. For sources in any **named group** — built-ins like `apps`, `networking.k8s.io`, or your own CRDs at `example.com` — `version` is optional. Two forms work: +The examples above use bare `kind: ConfigMap` — core-group sources resolved via the RESTMapper. For sources in any **named group** — built-ins like `apps`, `networking.k8s.io`, or your own CRDs at `example.com` — `group` is required and `version` is optional. Two forms work: ### Pinned named-group source @@ -277,7 +279,7 @@ spec: The benefit is most visible against **CRD sources**: when a CRD author promotes `v1beta1` → `v1` and stops serving `v1beta1`, the projection picks up the new version automatically on the next reconcile rather than failing with `SourceResolutionFailed` and garbage-collecting the destination. -For the core group (`group: ""`), `version` is required — CEL admission enforces this with the rule `size(self.group) != 0 || size(self.version) != 0`. Setting `group: ""` and leaving `version` empty fails at `kubectl apply` with a CEL violation message. +The same preferred-version lookup is what powers the bare `kind: ConfigMap` form for core sources — for the core group, the preferred version is always `v1`, so the resolved GVR is stable. Set `version` explicitly when you want to pin to a specific served version. As with the ConfigMap example above, the source object must carry `projection.sh/projectable: "true"` if the controller is running in From dcecd59e9e1d28a4c0e82bfe8df1708e1e522865 Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:39:27 +0200 Subject: [PATCH 07/15] docs: explain optional version including for core group --- docs/concepts.md | 32 +++++++++++++++++--------------- docs/crd-reference.md | 28 +++++++++------------------- docs/observability.md | 2 +- docs/troubleshooting.md | 38 ++++++++------------------------------ docs/use-cases.md | 20 +++----------------- 5 files changed, 38 insertions(+), 82 deletions(-) diff --git a/docs/concepts.md b/docs/concepts.md index 516773e..d60a8c5 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -18,39 +18,41 @@ The source uniquely identifies the Kubernetes object to mirror: ```yaml spec: source: - group: "" # "" for the core API; otherwise a DNS-subdomain group name - version: v1 # required when group is empty kind: ConfigMap # required, PascalCase namespace: platform # required, DNS-1123 name: app-config # required, DNS-1123 + # group and version are optional — see SourceRef fields below ``` -`group` and `version` together identify a `GroupVersionKind`. `kind`, `namespace`, and `name` are required and pattern-validated at admission time — typos fail at `kubectl apply`, not at runtime. The combination is resolved through the apiserver's `RESTMapper`, so anything the cluster knows about works: built-ins, aggregated APIs, CRDs. +The five SourceRef fields: -`projection` only mirrors **namespaced resources**. Pointing the source at a cluster-scoped Kind (`Namespace`, `ClusterRole`, `StorageClass`, `CustomResourceDefinition`, `PriorityClass`, …) is rejected at reconcile time with `SourceResolved=False reason=SourceResolutionFailed` and a message identifying the Kind as cluster-scoped. There can only be one `Namespace` named `foo` in a cluster, so mirroring it has no meaning; the rejection prevents a malformed dynamic-client URL from surfacing as a confusing 404. +- `group` — API group of the source object. Optional; empty means the core group (`ConfigMap`, `Secret`, `Service`, ...). +- `version` — API version of the source object within its group. Optional; empty means the operator resolves the preferred served version via the RESTMapper on every reconcile. Set explicitly to pin. +- `kind` — API Kind, PascalCase. Required. +- `namespace` — Source namespace. Required. +- `name` — Source object name. Required. -### Empty group requires a version +`group`, `version`, and `kind` together identify a `GroupVersionKind`. All five fields are pattern-validated at admission time — typos fail at `kubectl apply`, not at runtime. The combination is resolved through the apiserver's `RESTMapper`, so anything the cluster knows about works: built-ins, aggregated APIs, CRDs. -For core API sources (`group: ""`), `version` is required. CEL admission enforces this with the rule `size(self.group) != 0 || size(self.version) != 0`: if the group is empty, the version field must be set. Setting `group: ""` and leaving `version` empty fails at `kubectl apply` with a CEL violation message, not at reconcile time. +`projection` only mirrors **namespaced resources**. Pointing the source at a cluster-scoped Kind (`Namespace`, `ClusterRole`, `StorageClass`, `CustomResourceDefinition`, `PriorityClass`, …) is rejected at reconcile time with `SourceResolved=False reason=SourceResolutionFailed` and a message identifying the Kind as cluster-scoped. There can only be one `Namespace` named `foo` in a cluster, so mirroring it has no meaning; the rejection prevents a malformed dynamic-client URL from surfacing as a confusing 404. ### Pinned vs. preferred version -For non-core groups, `version` is optional. Two forms are supported: +`version` is optional for any group, including core. Four forms are supported: -| Form | Semantics | -| ------------------------------------ | ---------------------------------------------------------------------------------- | -| `group: apps`, `version: v1` | Named group, pinned to v1. | -| `group: apps` (version omitted) | Named group, RESTMapper-preferred served version. | -| `group: ""`, `version: v1` | Core group. Pinned (the only form for core). | +| Form | Semantics | +| ------------------------------------------- | ---------------------------------------------------------------------------------- | +| `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. | **Pinned** is an explicit stability anchor: useful when you're mid-migration and want to lock the projection to a specific version while you validate behavior, or when you intentionally need to fall behind a CRD upgrade. -**Preferred** (no `version`) is the default recommendation for sources outside the core group, and especially valuable for CRD sources. It follows the cluster: when a CRD author promotes `v1beta1` → `v1` and stops serving `v1beta1`, projection picks up the new preferred version on the next reconcile rather than failing with `SourceResolutionFailed` and garbage-collecting your destinations. The same form works for any named group — `apps`, `networking.k8s.io`, `example.com`. +**Preferred** (no `version`) follows the cluster: when a CRD author promotes `v1beta1` → `v1` and stops serving `v1beta1`, projection picks up the new preferred version on the next reconcile rather than failing with `SourceResolutionFailed` and garbage-collecting your destinations. The same form works for any group — core, `apps`, `networking.k8s.io`, `example.com`. For core sources the preferred version is always `v1`, so the resolved GVR is stable in practice. The resolved version is reported in the `SourceResolved` condition message (`kubectl describe projection`), so you can always answer "which version is this currently on?" without operator log access. -The core group does not have an unpinned form — its versions are stable, and the CEL rule above forbids leaving both fields empty. - ## 2. Destination The destination says where to write the copy. The shape depends on which CRD you're using. diff --git a/docs/crd-reference.md b/docs/crd-reference.md index c21caf7..6d2e545 100644 --- a/docs/crd-reference.md +++ b/docs/crd-reference.md @@ -17,30 +17,20 @@ The `SourceRef` struct is the same for both CRDs: | Field | Type | Required | Notes | | ------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | -| `source.group` | string | yes (may be empty) | `""` for the core API; otherwise a DNS-subdomain group name. | -| `source.version` | string | conditional | Required when `source.group == ""`. Optional otherwise. | +| `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` CEL rule - -``` -size(self.group) != 0 || size(self.version) != 0 -``` - -If the group is empty (core API), the version field must be set. Setting `group: ""` and leaving `version` empty fails at `kubectl apply` with a CEL violation. - -For non-core groups, omitting `version` triggers preferred-version lookup via the `RESTMapper`. Pinning a specific version (`group: apps`, `version: v1`) locks the projection to that version regardless of CRD promotions in the cluster. - ### `source.version` semantics -| Form | Resolution | -| ----------------------------------- | ------------------------------------------------------------------------------ | -| `group: ""`, `version: v1` | Core group, pinned to v1. (Only form for core.) | -| `group: apps`, `version: v1` | Named group, pinned to v1. | -| `group: apps` (`version` omitted) | Named group, RESTMapper-preferred served version. Follows CRD promotions. | -| `group: ""` (`version` omitted) | Rejected by CEL admission. | +| 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: @@ -49,7 +39,7 @@ kubectl get projection -o jsonpath='{.status.conditions[?(@.type=="Source # → resolved apps/Deployment to preferred version v1 ``` -The unpinned form is the recommended default for sources outside the core group, especially valuable for CRDs: 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. +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. --- diff --git a/docs/observability.md b/docs/observability.md index 554255b..31d3447 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -105,7 +105,7 @@ For ClusterProjection, partial failures are emitted as **per-namespace events**: | `SourceFetchFailed` | Warning | `Get` | Dynamic client `Get` on the source returned an error. | | `SourceResolutionFailed` | Warning | `Resolve` | RESTMapper couldn't resolve the source `{group, version, kind}` triple. | | `SourceOptedOut` / `SourceNotProjectable` | Warning | `Validate` | Source is missing the `projection.sh/projectable=true` annotation (allowlist mode) or explicitly sets it to `false`. | -| `InvalidSpec` | Warning | `Validate` | Admission rejected the spec — e.g. SourceRef with empty `group` AND empty `version`, or ClusterProjection.destination with both / neither of `namespaces` and `namespaceSelector` set. See [troubleshooting](troubleshooting.md#invalidspec). | +| `InvalidSpec` | Warning | `Validate` | Admission rejected the spec — e.g. ClusterProjection.destination with both / neither of `namespaces` and `namespaceSelector` set. See [troubleshooting](troubleshooting.md#invalidspec). | ### Querying diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1f7e051..4ceffb3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -69,11 +69,11 @@ Things that can cause it: - **The Kind is not registered in the cluster.** A CRD you project from is not installed, or was uninstalled. Confirm with `kubectl api-resources | grep `. - **The `group`, `version`, or `kind` is mis-spelled.** The pattern validation on the CRD catches obvious typos at admission, but a Kind that happens to look right syntactically but does not exist slips through. - **The target Kind is cluster-scoped.** `projection` only mirrors namespaced resources (`Namespace`, `ClusterRole`, `StorageClass`, CRDs themselves, `PriorityClass`, and similar are all rejected). The message will read `// is cluster-scoped; projection only mirrors namespaced resources`. -- **You omitted `source.version` for a non-core group with multiple served versions and no clear preferred.** When `version:` is omitted, the RESTMapper's preferred-version lookup picks one of the served versions; if your CRD declares more than one served version with no explicit preferred, the pick can be surprising. See the sub-entry below for the remedy. +- **You omitted `source.version` for a CRD with multiple served versions and no clear preferred.** When `version:` is omitted, the RESTMapper's preferred-version lookup picks one of the served versions; if your CRD declares more than one served version with no explicit preferred, the pick can be surprising. See the sub-entry below for the remedy. #### Sub-cause: unpinned version with multiple served versions -The v0.3.0 SourceRef permits omitting `source.version` for non-core groups (the empty-group + empty-version combination is rejected by CEL admission — see [InvalidSpec](#invalidspec) below — but `group: example.com` with `version` omitted is fine). The RESTMapper resolves the omitted version through `RESTMapping(GroupKind)`, which returns the *preferred* version. If the source CRD has multiple served versions and either declares no preferred or its preferred isn't the one that holds your data, you'll see `SourceResolutionFailed` (no mapping for the picked version) or, more confusingly, a successful resolve onto the wrong version with subsequent `SourceFetchFailed` because the data lives on a different served version. +The v0.3.0 SourceRef permits omitting `source.version` for any group, including core (`kind: ConfigMap` alone is valid; the operator resolves to `v1` via the RESTMapper). The RESTMapper resolves the omitted version through `RESTMapping(GroupKind)`, which returns the *preferred* version. For core resources the preferred version is always `v1`, so the resolved GVR is stable in practice. The pitfall is on **CRDs with multiple served versions**: if the source CRD has more than one served version and either declares no preferred or its preferred isn't the one that holds your data, you'll see `SourceResolutionFailed` (no mapping for the picked version) or, more confusingly, a successful resolve onto the wrong version with subsequent `SourceFetchFailed` because the data lives on a different served version. Diagnose: @@ -87,7 +87,7 @@ kubectl get crd . \ -o jsonpath='{range .spec.versions[?(@.storage==true)]}{.name}{"\n"}{end}' ``` -If the served-version list has more than one entry and none is explicitly preferred, **pin `source.version` explicitly** to the version your data lives on. The version-omission shortcut is intended for stable single-version CRDs and core extension groups where the preferred version is unambiguous; multi-version CRDs should always pin. +If the served-version list has more than one entry and none is explicitly preferred, **pin `source.version` explicitly** to the version your data lives on. The version-omission shortcut is intended for the core group (where preferred is always `v1`) and for stable single-version CRDs where the preferred version is unambiguous; multi-version CRDs should always pin. **Fix:** Install the missing CRD; correct the `group`/`version`/`kind` spelling; pin `source.version` explicitly when the source CRD has multiple served versions; or, if the Kind is genuinely cluster-scoped, `projection` is not the right tool for the job. @@ -155,35 +155,13 @@ If you see `SourceNotResolved`, the real failure is on the `SourceResolved` cond ### InvalidSpec -**Applies to:** `Projection` and `ClusterProjection` (admission-time, so the offending CR usually never makes it past `kubectl apply`). +**Applies to:** `ClusterProjection` only in v0.3 (admission-time, so the offending CR usually never makes it past `kubectl apply`). -The apiserver rejected the spec via CEL validation rules on the CRD. The CR either never created (you'll see this as a `kubectl apply` error) or, in the rare case where CEL validation is bypassed, the controller surfaces it as a runtime `DestinationWritten=False reason=InvalidSpec` event. Either way, the cause is one of three structural mistakes: +The apiserver rejected the spec via CEL validation rules on the CRD. The CR either never created (you'll see this as a `kubectl apply` error) or, in the rare case where CEL validation is bypassed, the controller surfaces it as a runtime `DestinationWritten=False reason=InvalidSpec` event. Either way, the cause is one of two structural mistakes: -#### 1. SourceRef with empty `group` AND empty `version` +> Pre-v0.3 SourceRef carried a CEL rule `size(self.group) != 0 || size(self.version) != 0` that rejected `kubectl apply` for any Projection or ClusterProjection with both `source.group` and `source.version` empty. v0.3 drops that rule — `source.version` is now optional for any group, including core. Manifests with explicit `version: v1` continue to validate; `kind: ConfigMap` alone now works too. Old runbooks mentioning an `InvalidSpec` admission error from `source must specify version when group is empty` apply only to pre-v0.3. -**Applies to:** `Projection` and `ClusterProjection`. - -The CEL rule `size(self.group) != 0 || size(self.version) != 0` enforces that when the group is empty (i.e. core API), the version must be specified. Mirroring a core-group object with no explicit version isn't supportable — there's no preferred-version lookup for core (it's always `v1`, but the rule forces you to say so). The literal admission error: - -``` -The Projection "..." is invalid: spec.source: Invalid value: "object": source must specify version when group is empty -``` - -(Or `spec.source` on `ClusterProjection`.) - -**Fix:** add `version: v1` to the source. For core Kinds, `v1` is always correct. - -```yaml -spec: - source: - group: "" # core API group - version: v1 # required when group is empty - kind: ConfigMap - namespace: shared - name: app-config -``` - -#### 2. ClusterProjection.destination with both `namespaces` AND `namespaceSelector` set +#### 1. ClusterProjection.destination with both `namespaces` AND `namespaceSelector` set **Applies to:** `ClusterProjection` only. @@ -220,7 +198,7 @@ spec: tier: tenant ``` -#### 3. ClusterProjection.destination with NEITHER `namespaces` NOR `namespaceSelector` set +#### 2. ClusterProjection.destination with NEITHER `namespaces` NOR `namespaceSelector` set **Applies to:** `ClusterProjection` only. diff --git a/docs/use-cases.md b/docs/use-cases.md index 0eebf75..b43cd1b 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -32,8 +32,6 @@ metadata: name: app-config-fanout spec: source: - group: "" - version: v1 kind: ConfigMap name: app-config namespace: platform @@ -65,8 +63,6 @@ metadata: name: app-config-fanout spec: source: - group: "" - version: v1 kind: ConfigMap name: app-config namespace: platform @@ -97,8 +93,6 @@ metadata: namespace: app-prod # destination namespace = this spec: source: - group: "" - version: v1 kind: Secret name: shared-tls namespace: cert-manager @@ -130,19 +124,19 @@ This is also the right shape when the destinations don't share a label predicate kind: Projection metadata: { name: org-policy, namespace: tenant-a } spec: - source: { group: "", version: v1, kind: ConfigMap, namespace: platform, name: org-policy } + source: { kind: ConfigMap, namespace: platform, name: org-policy } overlay: { labels: { tenant: tenant-a } } - apiVersion: projection.sh/v1 kind: Projection metadata: { name: org-policy, namespace: tenant-b } spec: - source: { group: "", version: v1, kind: ConfigMap, namespace: platform, name: org-policy } + source: { kind: ConfigMap, namespace: platform, name: org-policy } overlay: { labels: { tenant: tenant-b } } - apiVersion: projection.sh/v1 kind: Projection metadata: { name: org-policy, namespace: tenant-c } spec: - source: { group: "", version: v1, kind: ConfigMap, namespace: platform, name: org-policy } + source: { kind: ConfigMap, namespace: platform, name: org-policy } overlay: { labels: { tenant: tenant-c } } ``` @@ -171,8 +165,6 @@ metadata: namespace: team-a # destination namespace = this spec: source: - group: "" - version: v1 kind: ConfigMap name: base-config namespace: platform @@ -202,8 +194,6 @@ metadata: name: cluster-root-ca-fanout spec: source: - group: "" - version: v1 kind: Secret name: cluster-root-ca namespace: cert-manager @@ -221,8 +211,6 @@ metadata: namespace: app-prod spec: source: - group: "" - version: v1 kind: Secret name: cluster-root-ca namespace: cert-manager @@ -253,8 +241,6 @@ metadata: namespace: team-b # destination namespace = this spec: source: - group: "" - version: v1 kind: Service name: api namespace: default From 61629754f2aee668eaf9104ea481a8530457875d Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:42:22 +0200 Subject: [PATCH 08/15] docs(api-stability): version is optional for core-group sources too --- docs/api-stability.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/api-stability.md b/docs/api-stability.md index 9e656ee..e82572f 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -24,7 +24,14 @@ The list of "what is covered" below describes the post-v1.0 surface — the surf Two CRDs at `projection.sh/v1`: `Projection` (namespaced, single-target) and `ClusterProjection` (cluster-scoped, fan-out). The fields of each CRD's `.spec` and `.status` listed in [`crd-reference.md`](crd-reference.md) are permanent. New optional fields may be added; existing fields are not removed or renamed. -The shared `SourceRef` shape (`group`, `version`, `kind`, `namespace`, `name`) is part of both CRDs and equally permanent. The CEL admission rules (`size(self.group) != 0 || size(self.version) != 0` on SourceRef; the `namespaces` ⊕ `namespaceSelector` mutex on `ClusterProjection.destination`) are stable. +The shared `SourceRef` shape (`group`, `version`, `kind`, `namespace`, `name`) is part of both CRDs and equally permanent. Within that shape: + +- `spec.source.group` — optional. Empty means the core group. +- `spec.source.version` — optional for any group. Empty means the operator resolves the preferred served version via the RESTMapper on every reconcile (for the core group, currently always `v1`). Set explicitly to pin. + +These two fields' optionality is a v1.0 commitment: tightening either back to required would be a breaking change and is forbidden post-v1.0. Existing manifests with explicit `version: v1` for any group continue to work unchanged. + +The remaining CEL admission rule (`namespaces` ⊕ `namespaceSelector` mutex on `ClusterProjection.destination`) is stable. ### Annotation and label keys @@ -123,6 +130,7 @@ This is the standing record of breaking changes between minor pre-v1.0 releases. | Version | Breaking changes | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unreleased | **Rescinded:** the SourceRef `XValidation` rule `size(self.group) != 0 || size(self.version) != 0` (introduced in PR #76 with the message "version is required when group is empty"). `spec.source.version` is now optional for any group, including core. Pure permission grant — manifests with explicit `version: v1` continue to validate. | | `v0.3.0` | Single CRD split into `Projection` (namespaced, single-target) and `ClusterProjection` (cluster-scoped, fan-out). `SourceRef.apiVersion` replaced with separate `group` + `version` fields (CEL admission requires `version` when `group` is empty). Ownership annotation renamed from `projection.sh/owned-by` to `projection.sh/owned-by-projection` (namespaced) and `projection.sh/owned-by-cluster-projection` (cluster). New UID labels stamped on every destination (`projection.sh/owned-by-projection-uid` and `projection.sh/owned-by-cluster-projection-uid`). New cluster finalizer (`projection.sh/cluster-finalizer`) on `ClusterProjection`. `projection_reconcile_total` gained a `kind` label; new `projection_watched_dest_gvks` gauge and `projection_e2e_seconds` histogram added. | | `v0.2.0` | Ownership annotation renamed and source-projectability policy introduced (`projection.sh/projectable` annotation, `--source-mode=allowlist|permissive`). Default mode is `allowlist`. Events moved from `core/v1` to `events.k8s.io/v1`. | | `v0.1.0` | Initial public release. Single CRD `projections.projection.sh/v1` with `destination.namespace` and `destination.namespaceSelector` fields on the same CRD. | From a018da30418f0cafabd2caa366350dfcd8a88a0a Mon Sep 17 00:00:00 2001 From: be0x74a Date: Fri, 8 May 2026 20:43:34 +0200 Subject: [PATCH 09/15] docs(changelog): note optional version for core-group sources --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac89647..1c431e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release-time bench workflow** (`.github/workflows/bench.yml`). Manually triggered (`workflow_dispatch`) against a release tag, branch, or SHA; runs the full 8-profile bench matrix on a self-hosted runner (label `bench-runner`) and persists the resulting JSON to the `bench-history` orphan branch under `bench-history/