Skip to content

Commit 8b504c8

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 dd1c319 commit 8b504c8

4 files changed

Lines changed: 370 additions & 12 deletions

File tree

internal/operator-controller/controllers/clusterextensionrevision_controller.go

Lines changed: 51 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,23 @@ 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+
for i := range obj.GetOwnerReferences() {
552+
ref := obj.GetOwnerReferences()[i]
553+
if ref.Controller != nil && *ref.Controller &&
554+
ref.Kind == ocv1.ClusterExtensionRevisionKind &&
555+
ref.APIVersion == ocv1.GroupVersion.String() {
556+
if !siblingRevisionNames.Has(ref.Name) {
557+
return &ref
558+
}
559+
}
560+
}
561+
return nil
562+
}
563+
525564
// buildProgressionProbes creates a set of boxcutter probes from the fields provided in the CER's spec.progressionProbes.
526565
// Returns nil and an error if encountered while attempting to build the probes.
527566
func buildProgressionProbes(progressionProbes []ocv1.ProgressionProbe) (probing.And, error) {

internal/operator-controller/controllers/clusterextensionrevision_controller_test.go

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

0 commit comments

Comments
 (0)