Skip to content

Commit 8d43ef4

Browse files
pedjakclaude
andcommitted
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>
1 parent d57c077 commit 8d43ef4

22 files changed

Lines changed: 2434 additions & 52 deletions

api/v1/clusterextensionrevision_types.go

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ const (
3030
ClusterExtensionRevisionTypeSucceeded = "Succeeded"
3131

3232
// Condition Reasons
33-
ClusterExtensionRevisionReasonArchived = "Archived"
34-
ClusterExtensionRevisionReasonBlocked = "Blocked"
35-
ClusterExtensionRevisionReasonProbeFailure = "ProbeFailure"
36-
ClusterExtensionRevisionReasonProbesSucceeded = "ProbesSucceeded"
37-
ClusterExtensionRevisionReasonReconciling = "Reconciling"
38-
ClusterExtensionRevisionReasonRetrying = "Retrying"
33+
ClusterExtensionRevisionReasonArchived = "Archived"
34+
ClusterExtensionRevisionReasonBlocked = "Blocked"
35+
ClusterExtensionRevisionReasonProbeFailure = "ProbeFailure"
36+
ClusterExtensionRevisionReasonProbesSucceeded = "ProbesSucceeded"
37+
ClusterExtensionRevisionReasonReconciling = "Reconciling"
38+
ClusterExtensionRevisionReasonRefResolutionFailed = "RefResolutionFailed"
39+
ClusterExtensionRevisionReasonRetrying = "Retrying"
3940
)
4041

4142
// ClusterExtensionRevisionSpec defines the desired state of ClusterExtensionRevision.
@@ -392,14 +393,29 @@ type ClusterExtensionRevisionPhase struct {
392393

393394
// ClusterExtensionRevisionObject represents a Kubernetes object to be applied as part
394395
// of a phase, along with its collision protection settings.
396+
//
397+
// Exactly one of object or ref must be set.
398+
//
399+
// +kubebuilder:validation:XValidation:rule="has(self.object) != has(self.ref)",message="exactly one of object or ref must be set"
395400
type ClusterExtensionRevisionObject struct {
396-
// object is a required embedded Kubernetes object to be applied.
401+
// object is an optional embedded Kubernetes object to be applied.
402+
//
403+
// Exactly one of object or ref must be set.
397404
//
398405
// This object must be a valid Kubernetes resource with apiVersion, kind, and metadata fields.
399406
//
400407
// +kubebuilder:validation:EmbeddedResource
401408
// +kubebuilder:pruning:PreserveUnknownFields
402-
Object unstructured.Unstructured `json:"object"`
409+
// +optional
410+
Object unstructured.Unstructured `json:"object,omitzero"`
411+
412+
// ref is an optional reference to a Secret that holds the serialized
413+
// object manifest.
414+
//
415+
// Exactly one of object or ref must be set.
416+
//
417+
// +optional
418+
Ref ObjectSourceRef `json:"ref,omitzero"`
403419

404420
// collisionProtection controls whether the operator can adopt and modify objects
405421
// that already exist on the cluster.
@@ -425,6 +441,32 @@ type ClusterExtensionRevisionObject struct {
425441
CollisionProtection CollisionProtection `json:"collisionProtection,omitempty"`
426442
}
427443

444+
// ObjectSourceRef references content within a Secret that contains a
445+
// serialized object manifest.
446+
type ObjectSourceRef struct {
447+
// name is the name of the referenced Secret.
448+
//
449+
// +required
450+
// +kubebuilder:validation:MinLength=1
451+
// +kubebuilder:validation:MaxLength=253
452+
Name string `json:"name"`
453+
454+
// namespace is the namespace of the referenced Secret.
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: 64 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)