Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ 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/<label>.json`. The orphan branch is self-bootstrapped on first run. A markdown table of source-update / self-heal / ns-flip p99 latencies by profile is rendered into the workflow's step summary, and the bench JSON is uploaded as an artifact. `pull_request` triggers are deliberately omitted — self-hosted runners on public repos are exposed to fork-PR malicious code, so this workflow is operator-driven only. Per-PR shape-break smoke coverage continues to run on `ubuntu-22.04` via `bench-smoke.yml`.

### Changed

- `spec.source.version` is now optional for the core group. Reference core sources by `kind` alone (e.g. `kind: ConfigMap`); the operator resolves the preferred served version via the RESTMapper. Existing manifests with explicit `version: v1` continue to work unchanged. ([#97](https://github.com/projection-operator/projection/pull/97))
- Renamed the `Destination` print column to `Destination-Name` on both `Projection` and `ClusterProjection`. Symmetric with `Source-Name`, matches the underlying JSONPath (`.status.destinationName`), and removes the where-vs-what ambiguity on `ClusterProjection` (where `Destination` sat next to the `Targets` namespace count). Scripts that parse `kubectl get projection` table output by column name need to be updated. ([#97](https://github.com/projection-operator/projection/pull/97))

### Fixed

- A `Projection` or `ClusterProjection` whose source object was never created now reports `SourceResolved=False, reason=SourceNotFound, message="source X/Y not found"`. Previously every source-NotFound case was bucketed as `reason=SourceDeleted` with the message `"source X/Y has been deleted"` — accurate when the source had previously existed and was deleted, but a lie when the source never existed in the first place. The two cases are now distinguished by `status.destinationName`: empty (never resolved) → `SourceNotFound`; populated (we previously projected it) → `SourceDeleted`. The `SourceDeleted` reason value is unchanged for the genuine deletion case; alerts on `Ready=False` continue to fire for both reasons.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `Projection` — namespaced, single-target. Mirrors one Kubernetes object identified by `(group, version, kind, namespace, name)` into the Projection's own `metadata.namespace`. `spec.destination.name` is optional (defaults to `source.name`); there is no `destination.namespace` field. Use this for "tenant in namespace X needs a copy of object Y".
- `ClusterProjection` — cluster-scoped, fan-out. Mirrors one source object into either an explicit list `destination.namespaces: [a, b, c]` (with `minItems=1`) OR a `destination.namespaceSelector` matching namespaces by label. CEL admission enforces XOR + at-least-one. Use this for "every namespace matching X needs a copy of Y".

Both CRDs share the same `SourceRef{Group, Version, Kind, Namespace, Name}` (all required, all pattern-validated at admission). CEL on SourceRef requires `version` when `group` is empty (i.e. core kinds must specify v1). Non-core groups can omit `version` for preferred-version lookup via the RESTMapper. Both CRDs accept an optional `spec.overlay{Labels, Annotations}` that merges on top of the source with overlay winning on conflicts.
Both CRDs share the same `SourceRef{Group, Version, Kind, Namespace, Name}` (only `Kind`/`Namespace`/`Name` required; `Group` and `Version` optional, all pattern-validated at admission). When `version` is empty the controller resolves the RESTMapper's preferred served version on every reconcile (for the core group, currently `v1`); set `version` explicitly to pin. Both CRDs accept an optional `spec.overlay{Labels, Annotations}` that merges on top of the source with overlay winning on conflicts.

The controller fully implements the write side: it fetches the source via the dynamic client, strips server-owned metadata and `.status`, applies the overlay, stamps a per-CRD-scope ownership annotation, and creates or updates the destination. ClusterProjection iterates target namespaces in parallel (cap `--selector-write-concurrency`, default 16), tracks per-namespace success/failure into `status.namespacesWritten` / `status.namespacesFailed`, rolls up `DestinationWritten`, and cleans up stale destinations when namespaces stop matching the selector or get removed from the list. Distinct finalizers (`projection.sh/finalizer` for Projection, `projection.sh/cluster-finalizer` for ClusterProjection) clean up every owned destination on CR deletion. Source edits propagate via dynamic watches (`ensureSourceWatch`); manual `kubectl delete` of a destination triggers immediate re-creation via a label-filtered watch on the destination GVK (`ensureDestWatch`).

Expand Down Expand Up @@ -43,7 +43,7 @@ Tool binaries (kustomize, controller-gen, setup-envtest, golangci-lint, crd-ref-

**CRDs (`api/v1/`)** — three files:

- `source_types.go` defines the shared `SourceRef{Group, Version, Kind, Namespace, Name}` (DNS-1123 namespace, PascalCase Kind, regex-validated group/version/name). CEL: `size(self.group) != 0 || size(self.version) != 0`.
- `source_types.go` defines the shared `SourceRef{Group, Version, Kind, Namespace, Name}` (DNS-1123 namespace, PascalCase Kind, regex-validated group/version/name). Both `group` and `version` are optional; empty `version` resolves to the RESTMapper's preferred served version (for the core group, currently `v1`).
- `projection_types.go` defines `Projection` (scope: Namespaced) with `Spec{Source, Destination ProjectionDestination{Name string}, Overlay}` and `Status{DestinationName string, Conditions []Condition}` (Ready, SourceResolved, DestinationWritten). Printcolumns: Source, Destination, Ready, Age.
- `clusterprojection_types.go` defines `ClusterProjection` (scope: Cluster) with `Spec{Source, Destination ClusterDestination{Namespaces []string +listType=set +minItems=1, NamespaceSelector LabelSelector}, Overlay}` plus two CEL admission rules on `ClusterProjectionDestination`: `!(has(self.namespaces) && has(self.namespaceSelector))` (mutex) and `has(self.namespaces) || has(self.namespaceSelector)` (at-least-one). `Status{DestinationName string, NamespacesWritten int32, NamespacesFailed int32, Conditions []Condition}`. Printcolumns: Source, Destination, Written, Failed, Ready, Age.

Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ metadata:
namespace: tenant-a # destination namespace = this
spec:
source:
group: "" # core API
version: v1
kind: ConfigMap
name: app-config
namespace: platform
Expand All @@ -83,8 +81,8 @@ spec:

```console
$ kubectl get projections -A
NAMESPACE NAME KIND SOURCE-NAMESPACE SOURCE-NAME DESTINATION READY AGE
tenant-a app-config-mirror ConfigMap platform app-config app-config True 2s
NAMESPACE NAME KIND SOURCE-NAMESPACE SOURCE-NAME DESTINATION-NAME READY AGE
tenant-a app-config-mirror ConfigMap platform app-config app-config True 2s

$ kubectl get configmap -n tenant-a app-config -o jsonpath='{.metadata.annotations.projection\.sh/owned-by-projection}'
tenant-a/app-config-mirror
Expand All @@ -99,14 +97,14 @@ Need to mirror into many namespaces from one source? Use `ClusterProjection` (cl
## Features

- **Two CRDs, two RBAC tiers** — namespaced `Projection` for in-namespace single-target mirrors (destination namespace is structurally the Projection's own), cluster-scoped `ClusterProjection` for fan-out (`destination.namespaces: [a, b, c]` or `destination.namespaceSelector`). Tenants can self-serve `Projection` via the chart's `rbac.aggregate=true` default; `ClusterProjection` requires an explicit cluster-admin binding.
- **Any Kind** — `RESTMapper`-driven GVR resolution. Works on built-in resources, your CRDs, anything the apiserver knows about. Source uses split `group` + `version` fields; for non-core groups, omitting `version` triggers preferred-version lookup that follows CRD promotions.
- **Any Kind** — `RESTMapper`-driven GVR resolution. Works on built-in resources, your CRDs, anything the apiserver knows about. Source uses split `group` + `version` fields; omitting `version` triggers preferred-version lookup for any group, so the projection follows version promotions automatically.
- **Watch-driven** — dynamic informer registration per source GVK on first reference. Edits propagate in ~100ms; no periodic polling. A label-filtered destination-side watch (`ensureDestWatch`) makes manual `kubectl delete` of a destination trigger an immediate reconcile.
- **Fan-out across namespaces** — one `ClusterProjection` mirrors its source into every namespace listed in `destination.namespaces` or matching a `destination.namespaceSelector`. Destinations are added and removed as namespaces gain or lose the matching label. Bounded fan-out concurrency keeps the apiserver healthy at scale.
- **Source-owner consent** — default `sourceMode=allowlist` requires sources to carry `projection.sh/projectable="true"`. Source owners can also veto with `="false"` regardless of mode.
- **Conflict-safe** — `projection.sh/owned-by-projection` (or `projection.sh/owned-by-cluster-projection`) annotation marks our destinations. We refuse to overwrite objects we don't own and report `DestinationConflict` on status. Source deletion (404) automatically cleans up every owned destination.
- **Clean deletion** — finalizers remove destinations on CR deletion. The cluster CRD's finalizer sweeps every owned destination across the cluster; the namespaced CRD's finalizer cleans up its single in-namespace destination. If ownership has been stripped, we leave the object alone.
- **Observable** — three status conditions (`SourceResolved`, `DestinationWritten`, `Ready`), `events.k8s.io/v1` Events with `action` verbs (Create/Update/Delete/Get/Validate/Resolve/Write), per-fan-out counters (`status.namespacesWritten`, `status.namespacesFailed`), and Prometheus metrics (`projection_reconcile_total{kind,result}`, `projection_watched_gvks`, `projection_watched_dest_gvks`).
- **Validated at admission** — `Source` fields are pattern-validated (DNS-1123 names, PascalCase Kinds) so typos fail at `kubectl apply`, not at runtime. CEL enforces `version` required when `group` is empty, and `namespaces` ⊕ `namespaceSelector` mutual exclusion on `ClusterProjection.destination`.
- **Validated at admission** — `Source` fields are pattern-validated (DNS-1123 names, PascalCase Kinds) so typos fail at `kubectl apply`, not at runtime. CEL enforces `namespaces` ⊕ `namespaceSelector` mutual exclusion (and at-least-one) on `ClusterProjection.destination`.
- **Smart copy** — strips server-owned metadata, drops `.status`, removes `kubectl.kubernetes.io/last-applied-configuration`, strips Kind-specific apiserver-allocated spec fields (Service `clusterIP`/`clusterIPs`, PVC `volumeName`, Pod `nodeName`, Job `selector`+controller-uid labels), and preserves them on update.
- **Production-grade Helm chart** — opt-in `ServiceMonitor`, `NetworkPolicy` (egress lockdown), and `PodDisruptionBudget` templates. Three ClusterRoles for tenant self-service vs cluster-tier authority. Operational tuning via `requeueInterval`, `leaderElection.leaseDuration`, and `selectorWriteConcurrency`. RBAC scope narrowable via `supportedKinds`.
- **Small** — two CRDs, one Deployment, one container. Distroless image, multi-arch (amd64, arm64).
Expand Down
2 changes: 1 addition & 1 deletion api/v1/clusterprojection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ type ClusterProjectionStatus struct {
// +kubebuilder:printcolumn:name="Source-Group",type=string,JSONPath=`.spec.source.group`,priority=1
// +kubebuilder:printcolumn:name="Source-Namespace",type=string,JSONPath=`.spec.source.namespace`
// +kubebuilder:printcolumn:name="Source-Name",type=string,JSONPath=`.spec.source.name`
// +kubebuilder:printcolumn:name="Destination",type=string,JSONPath=`.status.destinationName`
// +kubebuilder:printcolumn:name="Destination-Name",type=string,JSONPath=`.status.destinationName`
// +kubebuilder:printcolumn:name="Targets",type=integer,JSONPath=`.status.namespacesWritten`
// +kubebuilder:printcolumn:name="Failed",type=integer,JSONPath=`.status.namespacesFailed`,priority=1
// +kubebuilder:printcolumn:name="Selector",type=string,JSONPath=`.spec.destination.namespaceSelector.matchLabels`,priority=1
Expand Down
2 changes: 1 addition & 1 deletion api/v1/projection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ type ProjectionStatus struct {
// +kubebuilder:printcolumn:name="Source-Group",type=string,JSONPath=`.spec.source.group`,priority=1
// +kubebuilder:printcolumn:name="Source-Namespace",type=string,JSONPath=`.spec.source.namespace`
// +kubebuilder:printcolumn:name="Source-Name",type=string,JSONPath=`.spec.source.name`
// +kubebuilder:printcolumn:name="Destination",type=string,JSONPath=`.status.destinationName`
// +kubebuilder:printcolumn:name="Destination-Name",type=string,JSONPath=`.status.destinationName`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

Expand Down
20 changes: 10 additions & 10 deletions api/v1/source_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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"`
Expand Down
14 changes: 5 additions & 9 deletions api/v1/testdata/clusterprojection.crd.golden.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ spec:
name: Source-Name
type: string
- jsonPath: .status.destinationName
name: Destination
name: Destination-Name
type: string
- jsonPath: .status.namespacesWritten
name: Targets
Expand Down Expand Up @@ -205,21 +205,17 @@ 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:
- kind
- 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
Expand Down
14 changes: 5 additions & 9 deletions api/v1/testdata/crd.golden.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ spec:
name: Source-Name
type: string
- jsonPath: .status.destinationName
name: Destination
name: Destination-Name
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
Expand Down Expand Up @@ -131,21 +131,17 @@ 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:
- kind
- 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
Expand Down
14 changes: 5 additions & 9 deletions charts/projection/crds/clusterprojections.projection.sh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ spec:
name: Source-Name
type: string
- jsonPath: .status.destinationName
name: Destination
name: Destination-Name
type: string
- jsonPath: .status.namespacesWritten
name: Targets
Expand Down Expand Up @@ -205,21 +205,17 @@ 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:
- kind
- 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
Expand Down
Loading
Loading