Skip to content

Commit 1224ca3

Browse files
pedjakclaude
andcommitted
Address review feedback: pre-mutation hashing, collect all errors, recovery scenario
- Move phase digest computation before controller mutations in buildBoxcutterPhases - Collect all mismatched phases in verifyObservedPhases instead of failing on first - Collect all mutable secrets in verifyReferencedSecretsImmutable - Add determinism and multi-mismatch tests, empty stored error check - Fix e2e scenario title double negative, add recovery scenario - Rename dedup test for clarity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eb25334 commit 1224ca3

3 files changed

Lines changed: 149 additions & 64 deletions

File tree

internal/operator-controller/controllers/clusterobjectset_controller.go

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -152,22 +152,12 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl
152152
return ctrl.Result{}, nil
153153
}
154154

155-
phases, opts, err := c.buildBoxcutterPhases(ctx, cos)
155+
phases, currentPhases, opts, err := c.buildBoxcutterPhases(ctx, cos)
156156
if err != nil {
157157
setRetryingConditions(cos, err.Error())
158158
return ctrl.Result{}, fmt.Errorf("converting to boxcutter revision: %v", err)
159159
}
160160

161-
currentPhases := make([]ocv1.ObservedPhase, 0, len(phases))
162-
for _, phase := range phases {
163-
hash, err := computePhaseDigest(phase)
164-
if err != nil {
165-
setRetryingConditions(cos, err.Error())
166-
return ctrl.Result{}, fmt.Errorf("computing phase digest: %v", err)
167-
}
168-
currentPhases = append(currentPhases, ocv1.ObservedPhase{Name: phase.GetName(), Digest: hash})
169-
}
170-
171161
if len(cos.Status.ObservedPhases) == 0 {
172162
cos.Status.ObservedPhases = currentPhases
173163
} else if err := verifyObservedPhases(cos.Status.ObservedPhases, currentPhases); err != nil {
@@ -488,10 +478,10 @@ func (c *ClusterObjectSetReconciler) listPreviousRevisions(ctx context.Context,
488478
return previous, nil
489479
}
490480

491-
func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]boxcutter.Phase, []boxcutter.RevisionReconcileOption, error) {
481+
func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]boxcutter.Phase, []ocv1.ObservedPhase, []boxcutter.RevisionReconcileOption, error) {
492482
previous, err := c.listPreviousRevisions(ctx, cos)
493483
if err != nil {
494-
return nil, nil, fmt.Errorf("listing previous revisions: %w", err)
484+
return nil, nil, nil, fmt.Errorf("listing previous revisions: %w", err)
495485
}
496486

497487
// Convert to []client.Object for boxcutter
@@ -502,7 +492,7 @@ func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, c
502492

503493
progressionProbes, err := buildProgressionProbes(cos.Spec.ProgressionProbes)
504494
if err != nil {
505-
return nil, nil, err
495+
return nil, nil, nil, err
506496
}
507497

508498
opts := []boxcutter.RevisionReconcileOption{
@@ -511,9 +501,10 @@ func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, c
511501
boxcutter.WithAggregatePhaseReconcileErrors(),
512502
}
513503

514-
phases := make([]boxcutter.Phase, 0)
504+
phases := make([]boxcutter.Phase, 0, len(cos.Spec.Phases))
505+
observedPhases := make([]ocv1.ObservedPhase, 0, len(cos.Spec.Phases))
515506
for _, specPhase := range cos.Spec.Phases {
516-
objs := make([]client.Object, 0)
507+
objs := make([]client.Object, 0, len(specPhase.Objects))
517508
for _, specObj := range specPhase.Objects {
518509
var obj *unstructured.Unstructured
519510
switch {
@@ -522,31 +513,42 @@ func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, c
522513
case specObj.Ref.Name != "":
523514
resolved, err := c.resolveObjectRef(ctx, specObj.Ref)
524515
if err != nil {
525-
return nil, nil, fmt.Errorf("resolving ref in phase %q: %w", specPhase.Name, err)
516+
return nil, nil, nil, fmt.Errorf("resolving ref in phase %q: %w", specPhase.Name, err)
526517
}
527518
obj = resolved
528519
default:
529-
return nil, nil, fmt.Errorf("object in phase %q has neither object nor ref", specPhase.Name)
520+
return nil, nil, nil, fmt.Errorf("object in phase %q has neither object nor ref", specPhase.Name)
530521
}
531522

523+
objs = append(objs, obj)
524+
}
525+
526+
// Compute digest from the user-provided objects before controller mutations.
527+
digest, err := computePhaseDigest(specPhase.Name, objs)
528+
if err != nil {
529+
return nil, nil, nil, fmt.Errorf("computing phase digest: %w", err)
530+
}
531+
observedPhases = append(observedPhases, ocv1.ObservedPhase{Name: specPhase.Name, Digest: digest})
532+
533+
// Apply controller mutations after digest computation.
534+
for i, obj := range objs {
532535
objLabels := obj.GetLabels()
533536
if objLabels == nil {
534537
objLabels = map[string]string{}
535538
}
536539
objLabels[labels.OwnerNameKey] = cos.Labels[labels.OwnerNameKey]
537540
obj.SetLabels(objLabels)
538541

539-
switch cp := EffectiveCollisionProtection(cos.Spec.CollisionProtection, specPhase.CollisionProtection, specObj.CollisionProtection); cp {
542+
switch cp := EffectiveCollisionProtection(cos.Spec.CollisionProtection, specPhase.CollisionProtection, specPhase.Objects[i].CollisionProtection); cp {
540543
case ocv1.CollisionProtectionIfNoController, ocv1.CollisionProtectionNone:
541544
opts = append(opts, boxcutter.WithObjectReconcileOptions(
542545
obj, boxcutter.WithCollisionProtection(cp)))
543546
}
544-
545-
objs = append(objs, obj)
546547
}
548+
547549
phases = append(phases, boxcutter.NewPhase(specPhase.Name, objs))
548550
}
549-
return phases, opts, nil
551+
return phases, observedPhases, opts, nil
550552
}
551553

552554
// resolveObjectRef fetches the referenced Secret, reads the value at the specified key,
@@ -721,36 +723,48 @@ func markAsArchived(cos *ocv1.ClusterObjectSet) bool {
721723
}
722724

723725
// computePhaseDigest computes a deterministic SHA-256 digest of a phase's
724-
// resolved content (name + objects). JSON serialization of unstructured
725-
// objects produces a canonical encoding with sorted map keys.
726-
func computePhaseDigest(phase boxcutter.Phase) (string, error) {
726+
// resolved content (name + objects) before any controller mutations.
727+
// JSON serialization of unstructured objects produces a canonical encoding
728+
// with sorted map keys.
729+
func computePhaseDigest(name string, objects []client.Object) (string, error) {
727730
phaseMap := map[string]any{
728-
"name": phase.GetName(),
729-
"objects": phase.GetObjects(),
731+
"name": name,
732+
"objects": objects,
730733
}
731734
data, err := json.Marshal(phaseMap)
732735
if err != nil {
733-
return "", fmt.Errorf("marshaling phase %q: %w", phase.GetName(), err)
736+
return "", fmt.Errorf("marshaling phase %q: %w", name, err)
734737
}
735738
h := sha256.Sum256(data)
736739
return fmt.Sprintf("sha256:%x", h), nil
737740
}
738741

739742
// verifyObservedPhases compares current per-phase digests against stored
740-
// digests. Returns an error naming the first mismatched phase.
743+
// digests. Returns an error listing all mismatched phases.
741744
func verifyObservedPhases(stored, current []ocv1.ObservedPhase) error {
745+
if len(stored) == 0 {
746+
return fmt.Errorf("stored observedPhases is unexpectedly empty")
747+
}
748+
if len(stored) != len(current) {
749+
return fmt.Errorf("number of phases has changed (expected %d phases, got %d)", len(stored), len(current))
750+
}
742751
storedMap := make(map[string]string, len(stored))
743752
for _, s := range stored {
744753
storedMap[s.Name] = s.Digest
745754
}
755+
var mismatches []string
746756
for _, c := range current {
747757
if prev, ok := storedMap[c.Name]; ok && prev != c.Digest {
748-
return fmt.Errorf(
749-
"resolved content of phase %q has changed (expected digest %s, got %s): "+
750-
"a referenced object source may have been deleted and recreated with different content",
751-
c.Name, prev, c.Digest)
758+
mismatches = append(mismatches, fmt.Sprintf(
759+
"phase %q (expected digest %s, got %s)", c.Name, prev, c.Digest))
752760
}
753761
}
762+
if len(mismatches) > 0 {
763+
return fmt.Errorf(
764+
"resolved content of %d phase(s) has changed: %s; "+
765+
"a referenced object source may have been deleted and recreated with different content",
766+
len(mismatches), strings.Join(mismatches, "; "))
767+
}
754768
return nil
755769
}
756770

@@ -778,6 +792,7 @@ func (c *ClusterObjectSetReconciler) verifyReferencedSecretsImmutable(ctx contex
778792
}
779793
}
780794

795+
var mutableSecrets []string
781796
for _, ref := range refs {
782797
secret := &corev1.Secret{}
783798
key := client.ObjectKey{Name: ref.name, Namespace: ref.namespace}
@@ -791,9 +806,14 @@ func (c *ClusterObjectSetReconciler) verifyReferencedSecretsImmutable(ctx contex
791806
}
792807

793808
if secret.Immutable == nil || !*secret.Immutable {
794-
return fmt.Errorf("secret %s/%s is not immutable: referenced secrets must have immutable set to true", ref.namespace, ref.name)
809+
mutableSecrets = append(mutableSecrets, fmt.Sprintf("%s/%s", ref.namespace, ref.name))
795810
}
796811
}
797812

813+
if len(mutableSecrets) > 0 {
814+
return fmt.Errorf("the following secrets are not immutable (referenced secrets must have immutable set to true): %s",
815+
strings.Join(mutableSecrets, ", "))
816+
}
817+
798818
return nil
799819
}

internal/operator-controller/controllers/clusterobjectset_controller_internal_test.go

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package controllers
22

33
import (
44
"context"
5+
"fmt"
56
"strings"
67
"testing"
78
"time"
@@ -16,7 +17,6 @@ import (
1617
"k8s.io/apimachinery/pkg/types"
1718
"k8s.io/apimachinery/pkg/util/sets"
1819
"k8s.io/utils/ptr"
19-
"pkg.package-operator.run/boxcutter"
2020
"sigs.k8s.io/controller-runtime/pkg/client"
2121
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2222
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
@@ -243,10 +243,6 @@ func (m *mockTrackingCacheInternal) Source(h handler.EventHandler, predicates ..
243243
}
244244

245245
func TestComputePhaseDigest(t *testing.T) {
246-
makePhase := func(name string, objs ...client.Object) boxcutter.Phase {
247-
return boxcutter.NewPhase(name, objs)
248-
}
249-
250246
makeObj := func(apiVersion, kind, name string) *unstructured.Unstructured {
251247
return &unstructured.Unstructured{
252248
Object: map[string]interface{}{
@@ -258,45 +254,66 @@ func TestComputePhaseDigest(t *testing.T) {
258254
}
259255

260256
t.Run("deterministic for same content", func(t *testing.T) {
261-
phase := makePhase("deploy", makeObj("v1", "ConfigMap", "cm1"))
262-
hash1, err := computePhaseDigest(phase)
257+
objs := []client.Object{makeObj("v1", "ConfigMap", "cm1")}
258+
hash1, err := computePhaseDigest("deploy", objs)
263259
require.NoError(t, err)
264-
hash2, err := computePhaseDigest(phase)
260+
hash2, err := computePhaseDigest("deploy", objs)
265261
require.NoError(t, err)
266262
assert.Equal(t, hash1, hash2)
267263
})
268264

265+
t.Run("deterministic with many objects", func(t *testing.T) {
266+
objs := make([]client.Object, 100)
267+
for i := range objs {
268+
objs[i] = makeObj("v1", "ConfigMap", fmt.Sprintf("cm-%d", i))
269+
}
270+
var first string
271+
for i := range 100 {
272+
digest, err := computePhaseDigest("deploy", objs)
273+
require.NoError(t, err)
274+
if i == 0 {
275+
first = digest
276+
} else {
277+
assert.Equal(t, first, digest, "digest changed on iteration %d", i)
278+
}
279+
}
280+
})
281+
269282
t.Run("different for different object content", func(t *testing.T) {
270-
p1 := makePhase("deploy", makeObj("v1", "ConfigMap", "cm1"))
271-
p2 := makePhase("deploy", makeObj("v1", "ConfigMap", "cm2"))
272-
h1, err := computePhaseDigest(p1)
283+
h1, err := computePhaseDigest("deploy", []client.Object{makeObj("v1", "ConfigMap", "cm1")})
273284
require.NoError(t, err)
274-
h2, err := computePhaseDigest(p2)
285+
h2, err := computePhaseDigest("deploy", []client.Object{makeObj("v1", "ConfigMap", "cm2")})
275286
require.NoError(t, err)
276287
assert.NotEqual(t, h1, h2)
277288
})
278289

279290
t.Run("different for different phase names", func(t *testing.T) {
280-
obj := makeObj("v1", "ConfigMap", "cm1")
281-
p1 := makePhase("deploy", obj)
282-
p2 := makePhase("crds", obj)
283-
h1, err := computePhaseDigest(p1)
291+
objs := []client.Object{makeObj("v1", "ConfigMap", "cm1")}
292+
h1, err := computePhaseDigest("deploy", objs)
293+
require.NoError(t, err)
294+
h2, err := computePhaseDigest("crds", objs)
295+
require.NoError(t, err)
296+
assert.NotEqual(t, h1, h2)
297+
})
298+
299+
t.Run("different order produces different digest", func(t *testing.T) {
300+
obj1 := makeObj("v1", "ConfigMap", "cm1")
301+
obj2 := makeObj("v1", "ConfigMap", "cm2")
302+
h1, err := computePhaseDigest("deploy", []client.Object{obj1, obj2})
284303
require.NoError(t, err)
285-
h2, err := computePhaseDigest(p2)
304+
h2, err := computePhaseDigest("deploy", []client.Object{obj2, obj1})
286305
require.NoError(t, err)
287306
assert.NotEqual(t, h1, h2)
288307
})
289308

290309
t.Run("empty phase produces valid digest", func(t *testing.T) {
291-
phase := makePhase("empty")
292-
hash, err := computePhaseDigest(phase)
310+
hash, err := computePhaseDigest("empty", nil)
293311
require.NoError(t, err)
294312
assert.NotEmpty(t, hash)
295313
})
296314

297315
t.Run("digest has sha256 prefix", func(t *testing.T) {
298-
phase := makePhase("deploy", makeObj("v1", "ConfigMap", "cm1"))
299-
digest, err := computePhaseDigest(phase)
316+
digest, err := computePhaseDigest("deploy", []client.Object{makeObj("v1", "ConfigMap", "cm1")})
300317
require.NoError(t, err)
301318
assert.True(t, strings.HasPrefix(digest, "sha256:"), "digest should start with sha256: prefix, got %s", digest)
302319
})
@@ -314,21 +331,42 @@ func TestVerifyObservedPhases(t *testing.T) {
314331
current := []ocv1.ObservedPhase{{Name: "deploy", Digest: "sha256:def456"}}
315332
err := verifyObservedPhases(stored, current)
316333
require.Error(t, err)
317-
assert.Contains(t, err.Error(), `resolved content of phase "deploy" has changed`)
334+
assert.Contains(t, err.Error(), `resolved content of 1 phase(s) has changed`)
335+
assert.Contains(t, err.Error(), `phase "deploy"`)
336+
})
337+
338+
t.Run("reports all mismatched phases", func(t *testing.T) {
339+
stored := []ocv1.ObservedPhase{
340+
{Name: "deploy", Digest: "sha256:aaa"},
341+
{Name: "crds", Digest: "sha256:bbb"},
342+
}
343+
current := []ocv1.ObservedPhase{
344+
{Name: "deploy", Digest: "sha256:xxx"},
345+
{Name: "crds", Digest: "sha256:yyy"},
346+
}
347+
err := verifyObservedPhases(stored, current)
348+
require.Error(t, err)
349+
assert.Contains(t, err.Error(), `resolved content of 2 phase(s) has changed`)
350+
assert.Contains(t, err.Error(), `phase "deploy"`)
351+
assert.Contains(t, err.Error(), `phase "crds"`)
318352
})
319353

320-
t.Run("passes when current has new phase not in stored", func(t *testing.T) {
354+
t.Run("fails when phase count changes", func(t *testing.T) {
321355
stored := []ocv1.ObservedPhase{{Name: "deploy", Digest: "sha256:abc123"}}
322356
current := []ocv1.ObservedPhase{
323357
{Name: "deploy", Digest: "sha256:abc123"},
324358
{Name: "crds", Digest: "sha256:def456"},
325359
}
326-
assert.NoError(t, verifyObservedPhases(stored, current))
360+
err := verifyObservedPhases(stored, current)
361+
require.Error(t, err)
362+
assert.Contains(t, err.Error(), "number of phases has changed")
327363
})
328364

329-
t.Run("passes with empty stored", func(t *testing.T) {
365+
t.Run("fails with empty stored", func(t *testing.T) {
330366
current := []ocv1.ObservedPhase{{Name: "deploy", Digest: "sha256:abc123"}}
331-
assert.NoError(t, verifyObservedPhases(nil, current))
367+
err := verifyObservedPhases(nil, current)
368+
require.Error(t, err)
369+
assert.Contains(t, err.Error(), "unexpectedly empty")
332370
})
333371
}
334372

@@ -428,7 +466,7 @@ func TestVerifyReferencedSecretsImmutable(t *testing.T) {
428466
require.NoError(t, err)
429467
})
430468

431-
t.Run("deduplicates refs to same secret", func(t *testing.T) {
469+
t.Run("checks secret only once when referenced multiple times", func(t *testing.T) {
432470
secret := immutableSecret.DeepCopy()
433471
testClient := fake.NewClientBuilder().
434472
WithScheme(testScheme).

0 commit comments

Comments
 (0)