Skip to content

Commit 8b067ab

Browse files
tmshortclaude
andcommitted
Fix duplicate ClusterExtensionRevisions during Helm-to-Boxcutter migration
This commit fixes OCPBUGS-62943 where two ClusterExtensionRevisions were being created during Helm-to-Boxcutter migration instead of just one. Root causes: 1. Manifest ordering inconsistency: CRDs from Helm release manifest and bundle manifest appeared in different orders, causing PhaseSort to produce different phase structures even though they contained the same objects. 2. CollisionProtection mismatch: The migrated revision had collisionProtection=None (needed to adopt Helm-managed resources) while the bundle-generated revision had collisionProtection=Prevent (the default value). Solution: 1. Added deterministic sorting in PhaseSort (phase.go): - Sort objects within each phase by Group, Version, Kind, Namespace, Name - Ensures consistent phase structure regardless of input order - Critical for comparing revisions from different sources 2. Added CollisionProtection preservation (boxcutter.go): - New preserveCollisionProtection() function copies CollisionProtection values from current revision to desired revision for matching objects - New objectKey() helper generates unique keys based on GVK+namespace+name - Called before patching to ensure CollisionProtection values match With these changes, only a single ClusterExtensionRevision is created during Helm-to-Boxcutter migration, as expected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Todd Short <tshort@redhat.com>
1 parent c95fc24 commit 8b067ab

File tree

3 files changed

+98
-4
lines changed

3 files changed

+98
-4
lines changed

internal/operator-controller/applier/boxcutter.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,12 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
308308
desiredRevision.Spec.Revision = currentRevision.Spec.Revision
309309
desiredRevision.Name = currentRevision.Name
310310

311+
// Preserve CollisionProtection settings from the current revision to avoid
312+
// creating a new revision when the only difference is the CollisionProtection value.
313+
// This is critical during Helm-to-Boxcutter migration where the migrated revision
314+
// has CollisionProtection=None to adopt Helm-managed resources.
315+
preserveCollisionProtection(desiredRevision, currentRevision)
316+
311317
err := bc.createOrUpdate(ctx, desiredRevision)
312318
switch {
313319
case apierrors.IsInvalid(err):
@@ -380,6 +386,38 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
380386
return true, "", nil
381387
}
382388

389+
// preserveCollisionProtection copies the CollisionProtection settings from the current revision to the desired revision
390+
// for objects that have matching GVK, namespace, and name. This ensures that when patching an existing revision,
391+
// we don't inadvertently change the CollisionProtection value, which would cause the patch to fail since
392+
// CollisionProtection is an immutable field.
393+
func preserveCollisionProtection(desired, current *ocv1.ClusterExtensionRevision) {
394+
// Build a map of objects in the current revision by their identity (GVK + namespace + name)
395+
currentObjects := make(map[string]ocv1.CollisionProtection)
396+
for _, phase := range current.Spec.Phases {
397+
for _, obj := range phase.Objects {
398+
key := objectKey(&obj.Object)
399+
currentObjects[key] = obj.CollisionProtection
400+
}
401+
}
402+
403+
// Update desired revision objects to use the same CollisionProtection as current revision
404+
for i := range desired.Spec.Phases {
405+
for j := range desired.Spec.Phases[i].Objects {
406+
desiredObj := &desired.Spec.Phases[i].Objects[j]
407+
key := objectKey(&desiredObj.Object)
408+
if collisionProtection, found := currentObjects[key]; found {
409+
desiredObj.CollisionProtection = collisionProtection
410+
}
411+
}
412+
}
413+
}
414+
415+
// objectKey generates a unique key for an object based on its GVK, namespace, and name
416+
func objectKey(obj *unstructured.Unstructured) string {
417+
gvk := obj.GroupVersionKind()
418+
return fmt.Sprintf("%s/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, obj.GetNamespace(), obj.GetName())
419+
}
420+
383421
// setPreviousRevisions populates spec.previous of latestRevision, trimming the list of previous _archived_ revisions down to
384422
// ClusterExtensionRevisionPreviousLimit or to the first _active_ revision and deletes trimmed revisions from the cluster.
385423
// NOTE: revisionList must be sorted in chronographical order, from oldest to latest.

internal/operator-controller/applier/phase.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package applier
22

33
import (
4+
"slices"
5+
46
"k8s.io/apimachinery/pkg/runtime/schema"
57

68
ocv1 "github.com/operator-framework/operator-controller/api/v1"
@@ -111,6 +113,57 @@ func init() {
111113
}
112114
}
113115

116+
// Sort objects within the phase deterministically by Group, Version, Kind, Namespace, Name
117+
// to ensure consistent ordering regardless of input order. This is critical for
118+
// Helm-to-Boxcutter migration where the same resources may come from different sources
119+
// (Helm release manifest vs bundle manifest) and need to produce identical phases.
120+
func compareClusterExtensionRevision(a, b ocv1.ClusterExtensionRevisionObject) int {
121+
aGVK := a.Object.GroupVersionKind()
122+
bGVK := b.Object.GroupVersionKind()
123+
124+
// Compare Group
125+
if aGVK.Group < bGVK.Group {
126+
return -1
127+
} else if aGVK.Group > bGVK.Group {
128+
return 1
129+
}
130+
131+
// Compare Version
132+
if aGVK.Version < bGVK.Version {
133+
return -1
134+
} else if aGVK.Version > bGVK.Version {
135+
return 1
136+
}
137+
138+
// Compare Kind
139+
if aGVK.Kind < bGVK.Kind {
140+
return -1
141+
} else if aGVK.Kind > bGVK.Kind {
142+
return 1
143+
}
144+
145+
// Compare Namespace
146+
aNs := a.Object.GetNamespace()
147+
bNs := b.Object.GetNamespace()
148+
if aNs < bNs {
149+
return -1
150+
} else if aNs > bNs {
151+
return 1
152+
}
153+
154+
// Compare Name
155+
aName := a.Object.GetName()
156+
bName := b.Object.GetName()
157+
if aName < bName {
158+
return -1
159+
}
160+
if aName > bName {
161+
return 1
162+
}
163+
164+
return 0
165+
}
166+
114167
// PhaseSort takes an unsorted list of objects and organizes them into sorted phases.
115168
// Each phase will be applied in order according to DefaultPhaseOrder. Objects
116169
// within a single phase are applied simultaneously.
@@ -125,6 +178,9 @@ func PhaseSort(unsortedObjs []ocv1.ClusterExtensionRevisionObject) []ocv1.Cluste
125178

126179
for _, phaseName := range defaultPhaseOrder {
127180
if objs, ok := phaseMap[phaseName]; ok {
181+
// Sort objects within the phase deterministically
182+
slices.SortFunc(objs, compareClusterExtensionRevision)
183+
128184
phasesSorted = append(phasesSorted, ocv1.ClusterExtensionRevisionPhase{
129185
Name: string(phaseName),
130186
Objects: objs,

internal/operator-controller/applier/phase_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,16 @@ func Test_PhaseSort(t *testing.T) {
262262
{
263263
Object: unstructured.Unstructured{
264264
Object: map[string]interface{}{
265-
"apiVersion": "apps/v1",
266-
"kind": "Deployment",
265+
"apiVersion": "v1",
266+
"kind": "ConfigMap",
267267
},
268268
},
269269
},
270270
{
271271
Object: unstructured.Unstructured{
272272
Object: map[string]interface{}{
273-
"apiVersion": "v1",
274-
"kind": "ConfigMap",
273+
"apiVersion": "apps/v1",
274+
"kind": "Deployment",
275275
},
276276
},
277277
},

0 commit comments

Comments
 (0)