Skip to content

Commit 37bdbc6

Browse files
pedjakclaude
andauthored
🌱 Ensure COS phase immutability for referenced object approach (#2635)
ClusterObjectSet phases are immutable by design, but when objects are stored in external Secrets via refs, the Secret content could be changed by deleting and recreating the Secret. This enforces phase immutability by: - Verifying that referenced Secrets have `immutable: true` set - Computing a per-phase SHA-256 content digest of pre-mutation resolved objects and recording it in `.status.observedPhases` - Blocking reconciliation (`Progressing=False, Reason=Blocked`) if any referenced Secret is mutable or any phase's digest has changed - Allowing blocked COS to recover when original content is restored The digest is source-agnostic — it covers fully resolved phase content regardless of whether objects are inline or from Secrets, making it forward-compatible with future object sources. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 07a470c commit 37bdbc6

File tree

15 files changed

+892
-23
lines changed

15 files changed

+892
-23
lines changed

api/v1/clusterobjectset_types.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,39 @@ type ClusterObjectSetStatus struct {
510510
// +listMapKey=type
511511
// +optional
512512
Conditions []metav1.Condition `json:"conditions,omitempty"`
513+
514+
// observedPhases records the content hashes of resolved phases
515+
// at first successful reconciliation. This is used to detect if
516+
// referenced object sources were deleted and recreated with
517+
// different content. Each entry covers all fully-resolved object
518+
// manifests within a phase, making it source-agnostic.
519+
//
520+
// +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf.size() == 0",message="observedPhases is immutable"
521+
// +kubebuilder:validation:MaxItems=20
522+
// +listType=map
523+
// +listMapKey=name
524+
// +optional
525+
ObservedPhases []ObservedPhase `json:"observedPhases,omitempty"`
526+
}
527+
528+
// ObservedPhase records the observed content digest of a resolved phase.
529+
type ObservedPhase struct {
530+
// name is the phase name matching a phase in spec.phases.
531+
//
532+
// +required
533+
// +kubebuilder:validation:MinLength=1
534+
// +kubebuilder:validation:MaxLength=63
535+
// +kubebuilder:validation:XValidation:rule=`!format.dns1123Label().validate(self).hasValue()`,message="the value must consist of only lowercase alphanumeric characters and hyphens, and must start with an alphabetic character and end with an alphanumeric character."
536+
Name string `json:"name"`
537+
538+
// digest is the digest of the phase's resolved object content
539+
// at first successful resolution, in the format "<algorithm>:<hex>".
540+
//
541+
// +required
542+
// +kubebuilder:validation:MinLength=1
543+
// +kubebuilder:validation:MaxLength=256
544+
// +kubebuilder:validation:XValidation:rule=`self.matches('^[a-z0-9]+:[a-f0-9]+$')`,message="digest must be in the format '<algorithm>:<hex>'"
545+
Digest string `json:"digest"`
513546
}
514547

515548
// +genclient

api/v1/zz_generated.deepcopy.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

applyconfigurations/api/v1/clusterobjectsetstatus.go

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

applyconfigurations/api/v1/observedphase.go

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

applyconfigurations/utils.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api-reference/crd-ref-docs-gen-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
processor:
2-
ignoreTypes: [ClusterObjectSet, ClusterObjectSetList]
2+
ignoreTypes: [ClusterObjectSet, ClusterObjectSetList, ObservedPhase]
33
ignoreFields: []
44

55
render:

docs/draft/concepts/large-bundle-support.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,18 @@ Recommended conventions:
142142
1. **Secret type**: Secrets should use the dedicated type
143143
`olm.operatorframework.io/object-data` to distinguish them from user-created
144144
Secrets and enable easy identification. The system always sets this type on
145-
Secrets it creates. The reconciler does not enforce the type when resolving
146-
refs — Secrets with any type are accepted — but producers should set it for
147-
consistency.
148-
149-
2. **Immutability**: Secrets should set `immutable: true`. Because COS phases
150-
are immutable, the content backing a ref should not change after creation.
151-
Mutable referenced Secrets are not rejected, but modifying them after the
152-
COS is created leads to undefined behavior.
145+
Secrets it creates. The reconciler does not enforce the Secret type when
146+
resolving refs, but it does enforce that referenced Secrets have
147+
`immutable: true` set and that their content has not changed since first
148+
resolution.
149+
150+
2. **Immutability**: Secrets must set `immutable: true`. The reconciler verifies
151+
that all referenced Secrets have `immutable: true` set before proceeding.
152+
Mutable referenced Secrets are rejected and reconciliation is blocked with
153+
`Progressing=False, Reason=Blocked`. Additionally, the reconciler records
154+
content hashes of the resolved phases on first successful reconciliation
155+
and blocks reconciliation if the content changes (e.g., if a Secret is
156+
deleted and recreated with the same name but different data).
153157

154158
3. **Owner references**: Referenced Secrets should carry an ownerReference to
155159
the COS so that Kubernetes garbage collection removes them when the COS is
@@ -388,6 +392,14 @@ Key properties:
388392
389393
### COS reconciler behavior
390394
395+
Before resolving individual object refs, the reconciler verifies that all
396+
referenced Secrets have `immutable: true` set. After successfully building
397+
the phases (resolving all refs), the reconciler computes a per-phase content
398+
digest and compares it against the digests recorded in `.status.observedPhases`
399+
(if present). If any phase's content has changed, reconciliation is blocked
400+
with `Progressing=False, Reason=Blocked`. On first successful build, phase
401+
content digests are persisted to status for future comparisons.
402+
391403
When processing a COS phase:
392404
- For each object entry in the phase:
393405
- If `object` is set, use it directly (current behavior, unchanged).

helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,49 @@ spec:
621621
x-kubernetes-list-map-keys:
622622
- type
623623
x-kubernetes-list-type: map
624+
observedPhases:
625+
description: |-
626+
observedPhases records the content hashes of resolved phases
627+
at first successful reconciliation. This is used to detect if
628+
referenced object sources were deleted and recreated with
629+
different content. Each entry covers all fully-resolved object
630+
manifests within a phase, making it source-agnostic.
631+
items:
632+
description: ObservedPhase records the observed content digest of
633+
a resolved phase.
634+
properties:
635+
digest:
636+
description: |-
637+
digest is the digest of the phase's resolved object content
638+
at first successful resolution, in the format "<algorithm>:<hex>".
639+
maxLength: 256
640+
minLength: 1
641+
type: string
642+
x-kubernetes-validations:
643+
- message: digest must be in the format '<algorithm>:<hex>'
644+
rule: self.matches('^[a-z0-9]+:[a-f0-9]+$')
645+
name:
646+
description: name is the phase name matching a phase in spec.phases.
647+
maxLength: 63
648+
minLength: 1
649+
type: string
650+
x-kubernetes-validations:
651+
- message: the value must consist of only lowercase alphanumeric
652+
characters and hyphens, and must start with an alphabetic
653+
character and end with an alphanumeric character.
654+
rule: '!format.dns1123Label().validate(self).hasValue()'
655+
required:
656+
- digest
657+
- name
658+
type: object
659+
maxItems: 20
660+
type: array
661+
x-kubernetes-list-map-keys:
662+
- name
663+
x-kubernetes-list-type: map
664+
x-kubernetes-validations:
665+
- message: observedPhases is immutable
666+
rule: self == oldSelf || oldSelf.size() == 0
624667
type: object
625668
type: object
626669
served: true

0 commit comments

Comments
 (0)