Skip to content

Commit da4f73c

Browse files
pedjakclaude
andauthored
🌱 Externalize CER phase objects into Secrets (#2595)
* Externalize CER phase objects into Secret refs Add support for storing ClusterExtensionRevision phase objects in content-addressable immutable Secrets instead of inline in the CER spec. This removes the etcd object size limit as a constraint on bundle size. API changes: - Add ObjectSourceRef type with name, namespace, and key fields - Make ClusterExtensionRevisionObject.Object optional (omitzero) - Add optional Ref field with XValidation ensuring exactly one is set - Add RefResolutionFailed condition reason - Add RevisionNameKey label for ref Secret association Applier (boxcutter.go): - Add SecretPacker to bin-pack serialized objects into Secrets with gzip compression for objects exceeding 800KiB - Add createExternalizedRevision with crash-safe three-step sequence: create Secrets, create CER with refs, patch ownerReferences - Externalize desiredRevision before SSA comparison so the patch compares refs-vs-refs instead of inline-vs-refs - Add ensureSecretOwnerReferences for crash recovery - Pass SystemNamespace to Boxcutter from main.go CER controller: - Add resolveObjectRef to fetch and decompress objects from Secrets - Handle ref resolution in buildBoxcutterPhases - Add RBAC for Secret get/list/watch E2e tests: - Add scenario verifying refs, immutability, labels, and ownerRefs - Add step definitions for ref Secret validation - Fix listExtensionRevisionResources and ClusterExtensionRevisionObjectsNotFoundOrNotOwned to resolve refs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR #2595 review feedback - Fix duplicate key size inflation in SecretPacker by only incrementing size for new content hash keys - Add io.LimitReader (10 MiB cap) for gzip decompression to prevent gzip bombs in controller and e2e helpers - Add doc comment clarifying ObjectSourceRef.Namespace defaults to OLM system namespace during ref resolution - Fix docs: orphan cleanup uses ownerReference GC, ref resolution failures are retried (not terminal) - Remove unused ClusterExtensionRevisionReasonRefResolutionFailed constant - Add default error branch in e2e listExtensionRevisionResources for objects missing both ref and inline content Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Change gzipThreshold from 800 KiB to 900 KiB Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e709e65 commit da4f73c

File tree

22 files changed

+2473
-46
lines changed

22 files changed

+2473
-46
lines changed

api/v1/clusterextensionrevision_types.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,14 +392,29 @@ type ClusterExtensionRevisionPhase struct {
392392

393393
// ClusterExtensionRevisionObject represents a Kubernetes object to be applied as part
394394
// of a phase, along with its collision protection settings.
395+
//
396+
// Exactly one of object or ref must be set.
397+
//
398+
// +kubebuilder:validation:XValidation:rule="has(self.object) != has(self.ref)",message="exactly one of object or ref must be set"
395399
type ClusterExtensionRevisionObject struct {
396-
// object is a required embedded Kubernetes object to be applied.
400+
// object is an optional embedded Kubernetes object to be applied.
401+
//
402+
// Exactly one of object or ref must be set.
397403
//
398404
// This object must be a valid Kubernetes resource with apiVersion, kind, and metadata fields.
399405
//
400406
// +kubebuilder:validation:EmbeddedResource
401407
// +kubebuilder:pruning:PreserveUnknownFields
402-
Object unstructured.Unstructured `json:"object"`
408+
// +optional
409+
Object unstructured.Unstructured `json:"object,omitzero"`
410+
411+
// ref is an optional reference to a Secret that holds the serialized
412+
// object manifest.
413+
//
414+
// Exactly one of object or ref must be set.
415+
//
416+
// +optional
417+
Ref ObjectSourceRef `json:"ref,omitzero"`
403418

404419
// collisionProtection controls whether the operator can adopt and modify objects
405420
// that already exist on the cluster.
@@ -425,6 +440,33 @@ type ClusterExtensionRevisionObject struct {
425440
CollisionProtection CollisionProtection `json:"collisionProtection,omitempty"`
426441
}
427442

443+
// ObjectSourceRef references content within a Secret that contains a
444+
// serialized object manifest.
445+
type ObjectSourceRef struct {
446+
// name is the name of the referenced Secret.
447+
//
448+
// +required
449+
// +kubebuilder:validation:MinLength=1
450+
// +kubebuilder:validation:MaxLength=253
451+
Name string `json:"name"`
452+
453+
// namespace is the namespace of the referenced Secret.
454+
// When empty, defaults to the OLM system namespace during ref resolution.
455+
//
456+
// +optional
457+
// +kubebuilder:validation:MaxLength=63
458+
Namespace string `json:"namespace,omitempty"`
459+
460+
// key is the data key within the referenced Secret containing the
461+
// object manifest content. The value at this key must be a
462+
// JSON-serialized Kubernetes object manifest.
463+
//
464+
// +required
465+
// +kubebuilder:validation:MinLength=1
466+
// +kubebuilder:validation:MaxLength=253
467+
Key string `json:"key"`
468+
}
469+
428470
// CollisionProtection specifies if and how ownership collisions are prevented.
429471
type CollisionProtection string
430472

api/v1/clusterextensionrevision_types_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,77 @@ func TestClusterExtensionRevisionValidity(t *testing.T) {
272272
},
273273
valid: true,
274274
},
275+
"object with inline object is valid": {
276+
spec: ClusterExtensionRevisionSpec{
277+
LifecycleState: ClusterExtensionRevisionLifecycleStateActive,
278+
Revision: 1,
279+
CollisionProtection: CollisionProtectionPrevent,
280+
Phases: []ClusterExtensionRevisionPhase{
281+
{
282+
Name: "deploy",
283+
Objects: []ClusterExtensionRevisionObject{
284+
{
285+
Object: configMap(),
286+
},
287+
},
288+
},
289+
},
290+
},
291+
valid: true,
292+
},
293+
"object with ref is valid": {
294+
spec: ClusterExtensionRevisionSpec{
295+
LifecycleState: ClusterExtensionRevisionLifecycleStateActive,
296+
Revision: 1,
297+
CollisionProtection: CollisionProtectionPrevent,
298+
Phases: []ClusterExtensionRevisionPhase{
299+
{
300+
Name: "deploy",
301+
Objects: []ClusterExtensionRevisionObject{
302+
{
303+
Ref: ObjectSourceRef{Name: "my-secret", Key: "my-key"},
304+
},
305+
},
306+
},
307+
},
308+
},
309+
valid: true,
310+
},
311+
"object with both object and ref is invalid": {
312+
spec: ClusterExtensionRevisionSpec{
313+
LifecycleState: ClusterExtensionRevisionLifecycleStateActive,
314+
Revision: 1,
315+
CollisionProtection: CollisionProtectionPrevent,
316+
Phases: []ClusterExtensionRevisionPhase{
317+
{
318+
Name: "deploy",
319+
Objects: []ClusterExtensionRevisionObject{
320+
{
321+
Object: configMap(),
322+
Ref: ObjectSourceRef{Name: "my-secret", Key: "my-key"},
323+
},
324+
},
325+
},
326+
},
327+
},
328+
valid: false,
329+
},
330+
"object with neither object nor ref is invalid": {
331+
spec: ClusterExtensionRevisionSpec{
332+
LifecycleState: ClusterExtensionRevisionLifecycleStateActive,
333+
Revision: 1,
334+
CollisionProtection: CollisionProtectionPrevent,
335+
Phases: []ClusterExtensionRevisionPhase{
336+
{
337+
Name: "deploy",
338+
Objects: []ClusterExtensionRevisionObject{
339+
{},
340+
},
341+
},
342+
},
343+
},
344+
valid: false,
345+
},
275346
} {
276347
t.Run(name, func(t *testing.T) {
277348
cer := &ClusterExtensionRevision{

api/v1/zz_generated.deepcopy.go

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

applyconfigurations/api/v1/clusterextensionrevisionobject.go

Lines changed: 18 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

applyconfigurations/api/v1/objectsourceref.go

Lines changed: 65 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.

cmd/operator-controller/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
634634
Preflights: c.preflights,
635635
PreAuthorizer: preAuth,
636636
FieldOwner: fieldOwner,
637+
SystemNamespace: cfg.systemNamespace,
637638
}
638639
revisionStatesGetter := &controllers.BoxcutterRevisionStatesGetter{Reader: c.mgr.GetClient()}
639640
storageMigrator := &applier.BoxcutterStorageMigrator{

docs/api-reference/olmv1-api-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,8 @@ _Appears in:_
475475
| `label` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#labelselector-v1-meta)_ | label is the label selector definition.<br />Required when type is "Label".<br />A probe using a Label selector will be executed against every object matching the labels or expressions; you must use care<br />when using this type of selector. For example, if multiple Kind objects are selected via labels then the probe is<br />likely to fail because the values of different Kind objects rarely share the same schema.<br />The LabelSelector field uses the following Kubernetes format:<br />https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#LabelSelector<br />Requires exactly one of matchLabels or matchExpressions.<br /><opcon:experimental> | | Optional: \{\} <br /> |
476476

477477

478+
479+
478480
#### PreflightConfig
479481

480482

0 commit comments

Comments
 (0)