Skip to content

Commit 2b87748

Browse files
fix(Boxcutter-Collision): Fix collision detection bypass after ClusterExtension upgrade
When a ClusterExtension was upgraded (e.g., v1.0.0 to v1.0.1), the upgrade set a higher revision number on managed objects. A second ClusterExtension installing the same package was then allowed because boxcutter's revision linearity check returned ActionProgressed instead of ActionCollision, silently skipping the conflict. Fix: after boxcutter reconcile, check ActionProgressed results for objects whose controller ownerReference belongs to a revision from a different ClusterExtension, and treat those as collisions. Generated-by: Claude/Cursor
1 parent 0db26d7 commit 2b87748

4 files changed

Lines changed: 370 additions & 13 deletions

File tree

internal/operator-controller/controllers/clusterextensionrevision_controller.go

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, cer
142142
return c.delete(ctx, cer)
143143
}
144144

145-
phases, opts, err := c.buildBoxcutterPhases(ctx, cer)
145+
phases, opts, siblingRevisionNames, err := c.buildBoxcutterPhases(ctx, cer)
146146
if err != nil {
147147
setRetryingConditions(cer, err.Error())
148148
return ctrl.Result{}, fmt.Errorf("converting to boxcutter revision: %v", err)
@@ -210,6 +210,11 @@ func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, cer
210210
if ores.Action() == machinery.ActionCollision {
211211
collidingObjs = append(collidingObjs, ores.String())
212212
}
213+
if ores.Action() == machinery.ActionProgressed {
214+
if controllerRef := getForeignRevisionController(ores.Object(), siblingRevisionNames); controllerRef != nil {
215+
collidingObjs = append(collidingObjs, ores.String()+fmt.Sprintf("Conflicting Owner: %s\n", controllerRef.String()))
216+
}
217+
}
213218
}
214219

215220
if len(collidingObjs) > 0 {
@@ -460,21 +465,38 @@ func (c *ClusterExtensionRevisionReconciler) listPreviousRevisions(ctx context.C
460465
return previous, nil
461466
}
462467

463-
func (c *ClusterExtensionRevisionReconciler) buildBoxcutterPhases(ctx context.Context, cer *ocv1.ClusterExtensionRevision) ([]boxcutter.Phase, []boxcutter.RevisionReconcileOption, error) {
464-
previous, err := c.listPreviousRevisions(ctx, cer)
465-
if err != nil {
466-
return nil, nil, fmt.Errorf("listing previous revisions: %w", err)
467-
}
468+
func (c *ClusterExtensionRevisionReconciler) buildBoxcutterPhases(ctx context.Context, cer *ocv1.ClusterExtensionRevision) ([]boxcutter.Phase, []boxcutter.RevisionReconcileOption, sets.Set[string], error) {
469+
siblingRevisionNames := sets.New[string](cer.Name)
470+
var previousObjs []client.Object
471+
472+
if ownerLabel, ok := cer.Labels[labels.OwnerNameKey]; ok {
473+
revList := &ocv1.ClusterExtensionRevisionList{}
474+
if err := c.TrackingCache.List(ctx, revList, client.MatchingLabels{
475+
labels.OwnerNameKey: ownerLabel,
476+
}); err != nil {
477+
return nil, nil, nil, fmt.Errorf("listing revisions: %w", err)
478+
}
468479

469-
// Convert to []client.Object for boxcutter
470-
previousObjs := make([]client.Object, len(previous))
471-
for i, rev := range previous {
472-
previousObjs[i] = rev
480+
for i := range revList.Items {
481+
r := &revList.Items[i]
482+
siblingRevisionNames.Insert(r.Name)
483+
if r.Name == cer.Name {
484+
continue
485+
}
486+
if r.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived ||
487+
!r.DeletionTimestamp.IsZero() {
488+
continue
489+
}
490+
if r.Spec.Revision >= cer.Spec.Revision {
491+
continue
492+
}
493+
previousObjs = append(previousObjs, r)
494+
}
473495
}
474496

475497
progressionProbes, err := buildProgressionProbes(cer.Spec.ProgressionProbes)
476498
if err != nil {
477-
return nil, nil, err
499+
return nil, nil, nil, err
478500
}
479501

480502
opts := []boxcutter.RevisionReconcileOption{
@@ -507,7 +529,7 @@ func (c *ClusterExtensionRevisionReconciler) buildBoxcutterPhases(ctx context.Co
507529
}
508530
phases = append(phases, boxcutter.NewPhase(specPhase.Name, objs))
509531
}
510-
return phases, opts, nil
532+
return phases, opts, siblingRevisionNames, nil
511533
}
512534

513535
// EffectiveCollisionProtection resolves the collision protection value using
@@ -522,6 +544,24 @@ func EffectiveCollisionProtection(cp ...ocv1.CollisionProtection) ocv1.Collision
522544
return ecp
523545
}
524546

547+
// getForeignRevisionController returns the controller OwnerReference if the object is
548+
// controlled by a ClusterExtensionRevision from a different ClusterExtension. It returns
549+
// nil when the controller is a sibling revision (same ClusterExtension) or not a CER at all.
550+
func getForeignRevisionController(obj metav1.Object, siblingRevisionNames sets.Set[string]) *metav1.OwnerReference {
551+
refs := obj.GetOwnerReferences()
552+
for i := range refs {
553+
ref := refs[i]
554+
if ref.Controller != nil && *ref.Controller &&
555+
ref.Kind == ocv1.ClusterExtensionRevisionKind &&
556+
ref.APIVersion == ocv1.GroupVersion.String() {
557+
if !siblingRevisionNames.Has(ref.Name) {
558+
return &ref
559+
}
560+
}
561+
}
562+
return nil
563+
}
564+
525565
// buildProgressionProbes creates a set of boxcutter probes from the fields provided in the CER's spec.progressionProbes.
526566
// Returns nil and an error if encountered while attempting to build the probes.
527567
func buildProgressionProbes(progressionProbes []ocv1.ProgressionProbe) (probing.And, error) {

internal/operator-controller/controllers/clusterextensionrevision_controller_test.go

Lines changed: 253 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1111,7 +1111,7 @@ func newTestClusterExtensionRevision(t *testing.T, revisionName string, ext *ocv
11111111
labels.ServiceAccountNamespaceKey: ext.Spec.Namespace,
11121112
},
11131113
Labels: map[string]string{
1114-
labels.OwnerNameKey: "test-ext",
1114+
labels.OwnerNameKey: ext.Name,
11151115
},
11161116
},
11171117
Spec: ocv1.ClusterExtensionRevisionSpec{
@@ -1344,6 +1344,258 @@ func (m *mockTrackingCache) Free(ctx context.Context, user client.Object) error
13441344
return nil
13451345
}
13461346

1347+
func Test_ClusterExtensionRevisionReconciler_Reconcile_ForeignRevisionCollision(t *testing.T) {
1348+
testScheme := newScheme(t)
1349+
1350+
for _, tc := range []struct {
1351+
name string
1352+
reconcilingRevisionName string
1353+
existingObjs func() []client.Object
1354+
revisionResult machinery.RevisionResult
1355+
expectCollision bool
1356+
}{
1357+
{
1358+
name: "ActionProgressed with foreign controller ownerRef is treated as collision",
1359+
reconcilingRevisionName: "ext-B-1",
1360+
existingObjs: func() []client.Object {
1361+
extA := &ocv1.ClusterExtension{
1362+
ObjectMeta: metav1.ObjectMeta{Name: "ext-A", UID: "ext-A-uid"},
1363+
Spec: ocv1.ClusterExtensionSpec{
1364+
Namespace: "ns-a",
1365+
ServiceAccount: ocv1.ServiceAccountReference{Name: "sa-a"},
1366+
Source: ocv1.SourceConfig{
1367+
SourceType: ocv1.SourceTypeCatalog,
1368+
Catalog: &ocv1.CatalogFilter{PackageName: "pkg"},
1369+
},
1370+
},
1371+
}
1372+
extB := &ocv1.ClusterExtension{
1373+
ObjectMeta: metav1.ObjectMeta{Name: "ext-B", UID: "ext-B-uid"},
1374+
Spec: ocv1.ClusterExtensionSpec{
1375+
Namespace: "ns-b",
1376+
ServiceAccount: ocv1.ServiceAccountReference{Name: "sa-b"},
1377+
Source: ocv1.SourceConfig{
1378+
SourceType: ocv1.SourceTypeCatalog,
1379+
Catalog: &ocv1.CatalogFilter{PackageName: "pkg"},
1380+
},
1381+
},
1382+
}
1383+
// CER from ext-A's upgrade (revision 2) - this is the "foreign" owner
1384+
cerA2 := newTestClusterExtensionRevision(t, "ext-A-2", extA, testScheme)
1385+
1386+
// CER from ext-B (revision 1) - the one being reconciled
1387+
cerB1 := newTestClusterExtensionRevision(t, "ext-B-1", extB, testScheme)
1388+
1389+
return []client.Object{extA, extB, cerA2, cerB1}
1390+
},
1391+
revisionResult: mockRevisionResult{
1392+
phases: []machinery.PhaseResult{
1393+
mockPhaseResult{
1394+
name: "everything",
1395+
objects: []machinery.ObjectResult{
1396+
mockObjectResult{
1397+
action: machinery.ActionProgressed,
1398+
object: func() machinery.Object {
1399+
obj := &unstructured.Unstructured{
1400+
Object: map[string]interface{}{
1401+
"apiVersion": "apiextensions.k8s.io/v1",
1402+
"kind": "CustomResourceDefinition",
1403+
"metadata": map[string]interface{}{
1404+
"name": "widgets.example.com",
1405+
"ownerReferences": []interface{}{
1406+
map[string]interface{}{
1407+
"apiVersion": ocv1.GroupVersion.String(),
1408+
"kind": ocv1.ClusterExtensionRevisionKind,
1409+
"name": "ext-A-2",
1410+
"uid": "ext-A-2",
1411+
"controller": true,
1412+
"blockOwnerDeletion": true,
1413+
},
1414+
},
1415+
},
1416+
},
1417+
}
1418+
return obj
1419+
}(),
1420+
probes: machinerytypes.ProbeResultContainer{
1421+
boxcutter.ProgressProbeType: {
1422+
Status: machinerytypes.ProbeStatusTrue,
1423+
},
1424+
},
1425+
},
1426+
},
1427+
},
1428+
},
1429+
},
1430+
expectCollision: true,
1431+
},
1432+
{
1433+
name: "ActionProgressed with sibling controller ownerRef is NOT a collision",
1434+
reconcilingRevisionName: "ext-A-1",
1435+
existingObjs: func() []client.Object {
1436+
extA := &ocv1.ClusterExtension{
1437+
ObjectMeta: metav1.ObjectMeta{Name: "ext-A", UID: "ext-A-uid"},
1438+
Spec: ocv1.ClusterExtensionSpec{
1439+
Namespace: "ns-a",
1440+
ServiceAccount: ocv1.ServiceAccountReference{Name: "sa-a"},
1441+
Source: ocv1.SourceConfig{
1442+
SourceType: ocv1.SourceTypeCatalog,
1443+
Catalog: &ocv1.CatalogFilter{PackageName: "pkg"},
1444+
},
1445+
},
1446+
}
1447+
// CER from ext-A revision 1 (being reconciled, older)
1448+
cerA1 := newTestClusterExtensionRevision(t, "ext-A-1", extA, testScheme)
1449+
// CER from ext-A revision 2 (newer, already progressed)
1450+
cerA2 := newTestClusterExtensionRevision(t, "ext-A-2", extA, testScheme)
1451+
1452+
return []client.Object{extA, cerA1, cerA2}
1453+
},
1454+
revisionResult: mockRevisionResult{
1455+
phases: []machinery.PhaseResult{
1456+
mockPhaseResult{
1457+
name: "everything",
1458+
objects: []machinery.ObjectResult{
1459+
mockObjectResult{
1460+
action: machinery.ActionProgressed,
1461+
object: func() machinery.Object {
1462+
obj := &unstructured.Unstructured{
1463+
Object: map[string]interface{}{
1464+
"apiVersion": "apiextensions.k8s.io/v1",
1465+
"kind": "CustomResourceDefinition",
1466+
"metadata": map[string]interface{}{
1467+
"name": "widgets.example.com",
1468+
"ownerReferences": []interface{}{
1469+
map[string]interface{}{
1470+
"apiVersion": ocv1.GroupVersion.String(),
1471+
"kind": ocv1.ClusterExtensionRevisionKind,
1472+
"name": "ext-A-2",
1473+
"uid": "ext-A-2",
1474+
"controller": true,
1475+
"blockOwnerDeletion": true,
1476+
},
1477+
},
1478+
},
1479+
},
1480+
}
1481+
return obj
1482+
}(),
1483+
probes: machinerytypes.ProbeResultContainer{
1484+
boxcutter.ProgressProbeType: {
1485+
Status: machinerytypes.ProbeStatusTrue,
1486+
},
1487+
},
1488+
},
1489+
},
1490+
},
1491+
},
1492+
},
1493+
expectCollision: false,
1494+
},
1495+
{
1496+
name: "ActionProgressed with non-CER controller ownerRef is NOT a collision",
1497+
reconcilingRevisionName: "ext-B-1",
1498+
existingObjs: func() []client.Object {
1499+
extB := &ocv1.ClusterExtension{
1500+
ObjectMeta: metav1.ObjectMeta{Name: "ext-B", UID: "ext-B-uid"},
1501+
Spec: ocv1.ClusterExtensionSpec{
1502+
Namespace: "ns-b",
1503+
ServiceAccount: ocv1.ServiceAccountReference{Name: "sa-b"},
1504+
Source: ocv1.SourceConfig{
1505+
SourceType: ocv1.SourceTypeCatalog,
1506+
Catalog: &ocv1.CatalogFilter{PackageName: "pkg"},
1507+
},
1508+
},
1509+
}
1510+
cerB1 := newTestClusterExtensionRevision(t, "ext-B-1", extB, testScheme)
1511+
1512+
return []client.Object{extB, cerB1}
1513+
},
1514+
revisionResult: mockRevisionResult{
1515+
phases: []machinery.PhaseResult{
1516+
mockPhaseResult{
1517+
name: "everything",
1518+
objects: []machinery.ObjectResult{
1519+
mockObjectResult{
1520+
action: machinery.ActionProgressed,
1521+
object: func() machinery.Object {
1522+
obj := &unstructured.Unstructured{
1523+
Object: map[string]interface{}{
1524+
"apiVersion": "v1",
1525+
"kind": "ConfigMap",
1526+
"metadata": map[string]interface{}{
1527+
"name": "some-cm",
1528+
"namespace": "default",
1529+
"ownerReferences": []interface{}{
1530+
map[string]interface{}{
1531+
"apiVersion": "apps/v1",
1532+
"kind": "Deployment",
1533+
"name": "some-deployment",
1534+
"uid": "deploy-uid",
1535+
"controller": true,
1536+
"blockOwnerDeletion": true,
1537+
},
1538+
},
1539+
},
1540+
},
1541+
}
1542+
return obj
1543+
}(),
1544+
probes: machinerytypes.ProbeResultContainer{
1545+
boxcutter.ProgressProbeType: {
1546+
Status: machinerytypes.ProbeStatusTrue,
1547+
},
1548+
},
1549+
},
1550+
},
1551+
},
1552+
},
1553+
},
1554+
expectCollision: false,
1555+
},
1556+
} {
1557+
t.Run(tc.name, func(t *testing.T) {
1558+
testClient := fake.NewClientBuilder().
1559+
WithScheme(testScheme).
1560+
WithStatusSubresource(&ocv1.ClusterExtensionRevision{}).
1561+
WithObjects(tc.existingObjs()...).
1562+
Build()
1563+
1564+
mockEngine := &mockRevisionEngine{
1565+
reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) {
1566+
return tc.revisionResult, nil
1567+
},
1568+
}
1569+
result, err := (&controllers.ClusterExtensionRevisionReconciler{
1570+
Client: testClient,
1571+
RevisionEngineFactory: &mockRevisionEngineFactory{engine: mockEngine},
1572+
TrackingCache: &mockTrackingCache{client: testClient},
1573+
}).Reconcile(t.Context(), ctrl.Request{
1574+
NamespacedName: types.NamespacedName{
1575+
Name: tc.reconcilingRevisionName,
1576+
},
1577+
})
1578+
1579+
if tc.expectCollision {
1580+
require.Equal(t, ctrl.Result{RequeueAfter: 10 * time.Second}, result)
1581+
require.NoError(t, err)
1582+
1583+
rev := &ocv1.ClusterExtensionRevision{}
1584+
require.NoError(t, testClient.Get(t.Context(), client.ObjectKey{Name: tc.reconcilingRevisionName}, rev))
1585+
cond := meta.FindStatusCondition(rev.Status.Conditions, ocv1.TypeProgressing)
1586+
require.NotNil(t, cond)
1587+
require.Equal(t, metav1.ConditionTrue, cond.Status)
1588+
require.Equal(t, ocv1.ClusterExtensionRevisionReasonRetrying, cond.Reason)
1589+
require.Contains(t, cond.Message, "revision object collisions")
1590+
require.Contains(t, cond.Message, "Conflicting Owner")
1591+
} else {
1592+
require.Equal(t, ctrl.Result{}, result)
1593+
require.NoError(t, err)
1594+
}
1595+
})
1596+
}
1597+
}
1598+
13471599
func Test_effectiveCollisionProtection(t *testing.T) {
13481600
for _, tc := range []struct {
13491601
name string

0 commit comments

Comments
 (0)