Skip to content

Commit 8401565

Browse files
fgiudicicodex
andcommitted
Address review feedback on ClusterObjectSet group tracking
Scope COS sibling and previous revision lookup by group and controller owner kind and name, while keeping SSA field ownership group-based. Clarify standalone API docs and extend admission and owner tests. Centralize COS index setup, hide runtime watch configuration behind ClusterExtension setup methods, and mark temporary COS integration paths for the future ClusterObjectDeployment migration. Share ClusterExtension revision discovery across status, apply, and Helm migration, filtering by group and controller owner kind and name. Exclude unrelated revisions from numbering, retention, and fallback. Validate with full unit tests including race detection, lint, API lint diff, and idempotent API and manifest regeneration. Co-authored-by: Codex <codex@openai.com> Signed-off-by: Francesco Giudici <fgiudici@redhat.com>
1 parent c6455ae commit 8401565

25 files changed

Lines changed: 778 additions & 364 deletions

‎api/v1/clusterobjectset_types.go‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ const (
3838

3939
// ClusterObjectSetSpec defines the desired state of ClusterObjectSet.
4040
type ClusterObjectSetSpec struct {
41-
// group identifies the ClusterExtension whose revisions belong together.
42-
// All revisions for the same ClusterExtension must use its name as their group.
41+
// group is a required, immutable identifier that links related revisions together.
42+
// Revisions sharing the same group and controller owner kind and name form an ordered sequence.
43+
// Only owner references with controller set to true are considered.
44+
// Revisions without a controller owner form a sequence with other such revisions in the same group.
45+
// The value must be 1 to 52 characters long, start with a lowercase letter,
46+
// contain only lowercase letters, digits or hyphens, and end with a letter or digit.
4347
//
4448
// +required
4549
// +kubebuilder:validation:MinLength=1
@@ -66,11 +70,11 @@ type ClusterObjectSetSpec struct {
6670
// +kubebuilder:validation:XValidation:rule="oldSelf == 'Active' || oldSelf == 'Archived' && oldSelf == self", message="cannot un-archive"
6771
LifecycleState ClusterObjectSetLifecycleState `json:"lifecycleState,omitempty"`
6872

69-
// revision is a required, immutable sequence number representing a specific revision
70-
// of the parent ClusterExtension.
73+
// revision is a required, immutable sequence number identifying a specific
74+
// ClusterObjectSet within a sequence of related revisions.
7175
//
7276
// The revision field must be a positive integer.
73-
// Each ClusterObjectSet belonging to the same parent ClusterExtension must have a unique revision number.
77+
// Each ClusterObjectSet in the same revision sequence must have a unique revision number.
7478
// The revision number must always be the previous revision number plus one, or 1 for the first revision.
7579
//
7680
// +required
@@ -573,12 +577,12 @@ type ObservedPhase struct {
573577
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
574578
// +kubebuilder:printcolumn:name=Age,type=date,JSONPath=`.metadata.creationTimestamp`
575579

576-
// ClusterObjectSet represents an immutable snapshot of Kubernetes objects
577-
// for a specific version of a ClusterExtension. Each revision contains objects
578-
// organized into phases that roll out sequentially. The same object can only be managed by a single revision
579-
// at a time. Ownership of objects is transitioned from one revision to the next as the extension is upgraded
580-
// or reconfigured. Once the latest revision has rolled out successfully, previous active revisions are archived for
581-
// posterity.
580+
// ClusterObjectSet represents an immutable snapshot of Kubernetes objects to
581+
// apply and manage on the cluster. Each revision contains objects organized into
582+
// phases that roll out sequentially. The same object can only be managed by a
583+
// single revision at a time. Ownership of objects is transitioned from one revision
584+
// to the next as new revisions are rolled out. Once the latest revision has rolled
585+
// out successfully, previous active revisions are archived for posterity.
582586
type ClusterObjectSet struct {
583587
metav1.TypeMeta `json:",inline"`
584588

‎api/v1/clusterobjectset_types_test.go‎

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,51 @@ func TestClusterObjectSetImmutability(t *testing.T) {
1818
ctx := context.Background()
1919
i := 0
2020
for name, tc := range map[string]struct {
21-
spec ClusterObjectSetSpec
22-
updateFunc func(*ClusterObjectSet)
23-
allowed bool
21+
spec ClusterObjectSetSpec
22+
updateFunc func(*ClusterObjectSet)
23+
allowed bool
24+
expectedError string
2425
}{
2526
"group is immutable": {
2627
spec: ClusterObjectSetSpec{
28+
Group: "test-group",
2729
LifecycleState: ClusterObjectSetLifecycleStateActive,
2830
Revision: 1,
2931
CollisionProtection: CollisionProtectionPrevent,
3032
},
3133
updateFunc: func(cos *ClusterObjectSet) {
3234
cos.Spec.Group = "another-group"
3335
},
36+
expectedError: "group is immutable",
37+
},
38+
"group cannot be cleared": {
39+
spec: ClusterObjectSetSpec{
40+
Group: "test-group",
41+
LifecycleState: ClusterObjectSetLifecycleStateActive,
42+
Revision: 1,
43+
CollisionProtection: CollisionProtectionPrevent,
44+
},
45+
updateFunc: func(cos *ClusterObjectSet) {
46+
cos.Spec.Group = ""
47+
},
48+
expectedError: "spec.group: Required value",
3449
},
3550
"unchanged group permits lifecycle update": {
3651
spec: ClusterObjectSetSpec{
52+
Group: "test-group",
3753
LifecycleState: ClusterObjectSetLifecycleStateActive,
3854
Revision: 1,
3955
CollisionProtection: CollisionProtectionPrevent,
4056
},
4157
updateFunc: func(cos *ClusterObjectSet) {
58+
cos.Spec.Group = "test-group"
4259
cos.Spec.LifecycleState = ClusterObjectSetLifecycleStateArchived
4360
},
4461
allowed: true,
4562
},
4663
"revision is immutable": {
4764
spec: ClusterObjectSetSpec{
65+
Group: "test-group",
4866
LifecycleState: ClusterObjectSetLifecycleStateActive,
4967
Revision: 1,
5068
CollisionProtection: CollisionProtectionPrevent,
@@ -55,6 +73,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
5573
},
5674
"phases may be initially empty": {
5775
spec: ClusterObjectSetSpec{
76+
Group: "test-group",
5877
LifecycleState: ClusterObjectSetLifecycleStateActive,
5978
Revision: 1,
6079
CollisionProtection: CollisionProtectionPrevent,
@@ -72,6 +91,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
7291
},
7392
"phases may be initially unset": {
7493
spec: ClusterObjectSetSpec{
94+
Group: "test-group",
7595
LifecycleState: ClusterObjectSetLifecycleStateActive,
7696
Revision: 1,
7797
CollisionProtection: CollisionProtectionPrevent,
@@ -88,6 +108,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
88108
},
89109
"phases are immutable if not empty": {
90110
spec: ClusterObjectSetSpec{
111+
Group: "test-group",
91112
LifecycleState: ClusterObjectSetLifecycleStateActive,
92113
Revision: 1,
93114
CollisionProtection: CollisionProtectionPrevent,
@@ -109,6 +130,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
109130
},
110131
"spec collisionProtection is immutable": {
111132
spec: ClusterObjectSetSpec{
133+
Group: "test-group",
112134
LifecycleState: ClusterObjectSetLifecycleStateActive,
113135
Revision: 1,
114136
CollisionProtection: CollisionProtectionPrevent,
@@ -125,7 +147,6 @@ func TestClusterObjectSetImmutability(t *testing.T) {
125147
},
126148
Spec: tc.spec,
127149
}
128-
cos.Spec.Group = "test-group"
129150
i = i + 1
130151
require.NoError(t, c.Create(ctx, cos))
131152
tc.updateFunc(cos)
@@ -136,6 +157,9 @@ func TestClusterObjectSetImmutability(t *testing.T) {
136157
if !tc.allowed && !errors.IsInvalid(err) {
137158
t.Fatal("expected update to fail due to invalid payload, but got:", err)
138159
}
160+
if tc.expectedError != "" {
161+
require.ErrorContains(t, err, tc.expectedError)
162+
}
139163
})
140164
}
141165
}
@@ -388,54 +412,60 @@ func TestClusterObjectSetValidity(t *testing.T) {
388412
}
389413
}
390414

415+
func TestClusterObjectSetSpecValidation(t *testing.T) {
416+
c := newClient(t)
417+
t.Run("missing spec", func(t *testing.T) {
418+
cos := &unstructured.Unstructured{Object: map[string]any{
419+
"apiVersion": GroupVersion.String(),
420+
"kind": ClusterObjectSetKind,
421+
"metadata": map[string]any{"generateName": "spec-validation-"},
422+
}}
423+
err := c.Create(t.Context(), cos)
424+
require.True(t, errors.IsInvalid(err), "%v", err)
425+
require.ErrorContains(t, err, "spec: Required")
426+
})
427+
}
428+
391429
func TestClusterObjectSetGroupValidation(t *testing.T) {
392430
c := newClient(t)
393431
for _, tc := range []struct {
394-
name string
395-
group *string
396-
omitSpec bool
397-
valid bool
432+
name string
433+
group *string
434+
valid bool
398435
}{
399-
{name: "missing spec", omitSpec: true},
400-
{name: "missing group"},
401-
{name: "empty group", group: ptr.To("")},
436+
{name: "missing group", valid: false},
437+
{name: "empty group", group: ptr.To(""), valid: false},
402438
{name: "one character", group: ptr.To("a"), valid: true},
403439
{name: "lowercase hyphens and digits", group: ptr.To("my-group-1"), valid: true},
404440
{name: "maximum length", group: ptr.To(strings.Repeat("a", 52)), valid: true},
405-
{name: "over maximum length", group: ptr.To(strings.Repeat("a", 53))},
406-
{name: "starts with digit", group: ptr.To("1group")},
407-
{name: "starts with hyphen", group: ptr.To("-group")},
408-
{name: "ends with hyphen", group: ptr.To("group-")},
409-
{name: "uppercase", group: ptr.To("Group")},
410-
{name: "underscore", group: ptr.To("my_group")},
411-
{name: "dot", group: ptr.To("my.group")},
441+
{name: "over maximum length", group: ptr.To(strings.Repeat("a", 53)), valid: false},
442+
{name: "starts with digit", group: ptr.To("1group"), valid: false},
443+
{name: "starts with hyphen", group: ptr.To("-group"), valid: false},
444+
{name: "ends with hyphen", group: ptr.To("group-"), valid: false},
445+
{name: "uppercase", group: ptr.To("Group"), valid: false},
446+
{name: "underscore", group: ptr.To("my_group"), valid: false},
447+
{name: "dot", group: ptr.To("my.group"), valid: false},
412448
} {
413449
t.Run(tc.name, func(t *testing.T) {
414450
cos := &unstructured.Unstructured{Object: map[string]any{
415451
"apiVersion": GroupVersion.String(),
416452
"kind": ClusterObjectSetKind,
417453
"metadata": map[string]any{"generateName": "group-validation-"},
418454
}}
419-
if !tc.omitSpec {
420-
spec := map[string]any{
421-
"revision": int64(1), "lifecycleState": string(ClusterObjectSetLifecycleStateActive),
422-
"collisionProtection": string(CollisionProtectionPrevent),
423-
}
424-
if tc.group != nil {
425-
spec["group"] = *tc.group
426-
}
427-
cos.Object["spec"] = spec
455+
spec := map[string]any{
456+
"revision": int64(1), "lifecycleState": string(ClusterObjectSetLifecycleStateActive),
457+
"collisionProtection": string(CollisionProtectionPrevent),
428458
}
459+
if tc.group != nil {
460+
spec["group"] = *tc.group
461+
}
462+
cos.Object["spec"] = spec
429463
err := c.Create(t.Context(), cos)
430464
if tc.valid {
431465
require.NoError(t, err)
432466
} else {
433467
require.True(t, errors.IsInvalid(err), "%v", err)
434-
if tc.omitSpec {
435-
require.ErrorContains(t, err, "spec: Required")
436-
} else {
437-
require.ErrorContains(t, err, "spec.group")
438-
}
468+
require.ErrorContains(t, err, "spec.group")
439469
}
440470
})
441471
}

‎applyconfigurations/api/v1/clusterobjectset.go‎

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎applyconfigurations/api/v1/clusterobjectsetspec.go‎

Lines changed: 9 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cmd/object-controller/main.go‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import (
4545
ocv1 "github.com/operator-framework/operator-controller/api/v1"
4646
"github.com/operator-framework/operator-controller/internal/object-controller/controllers"
4747
"github.com/operator-framework/operator-controller/internal/object-controller/scheme"
48-
"github.com/operator-framework/operator-controller/internal/shared/clusterobjectset"
4948
cacheutil "github.com/operator-framework/operator-controller/internal/shared/util/cache"
5049
"github.com/operator-framework/operator-controller/internal/shared/util/tlsprofiles"
5150
"github.com/operator-framework/operator-controller/internal/shared/version"
@@ -183,8 +182,7 @@ func newManager(cfg *config, restConfig *rest.Config) (manager.Manager, error) {
183182
if err != nil {
184183
return nil, fmt.Errorf("creating discovery client: %w", err)
185184
}
186-
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &ocv1.ClusterObjectSet{},
187-
clusterobjectset.GroupField, clusterobjectset.ExtractGroup); err != nil {
185+
if err := controllers.SetupIndexes(context.Background(), mgr.GetFieldIndexer()); err != nil {
188186
return nil, fmt.Errorf("indexing ClusterObjectSet group: %w", err)
189187
}
190188
factory, err := controllers.NewDefaultRevisionEngineFactory(

‎cmd/operator-controller/main.go‎

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,12 @@ import (
4848
crcache "sigs.k8s.io/controller-runtime/pkg/cache"
4949
"sigs.k8s.io/controller-runtime/pkg/certwatcher"
5050
"sigs.k8s.io/controller-runtime/pkg/client"
51-
"sigs.k8s.io/controller-runtime/pkg/event"
5251
crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
53-
crhandler "sigs.k8s.io/controller-runtime/pkg/handler"
5452
"sigs.k8s.io/controller-runtime/pkg/healthz"
5553
"sigs.k8s.io/controller-runtime/pkg/log"
5654
"sigs.k8s.io/controller-runtime/pkg/manager"
5755
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
5856
"sigs.k8s.io/controller-runtime/pkg/metrics/server"
59-
"sigs.k8s.io/controller-runtime/pkg/predicate"
6057

6158
helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client"
6259

@@ -75,7 +72,6 @@ import (
7572
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/certproviders"
7673
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/registryv1"
7774
"github.com/operator-framework/operator-controller/internal/operator-controller/scheme"
78-
"github.com/operator-framework/operator-controller/internal/shared/clusterobjectset"
7975
sharedcontrollers "github.com/operator-framework/operator-controller/internal/shared/controllers"
8076
cacheutil "github.com/operator-framework/operator-controller/internal/shared/util/cache"
8177
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
@@ -425,9 +421,11 @@ func run() error {
425421
}
426422

427423
cl := mgr.GetClient()
424+
// TODO(COD): Remove this registration when this manager neither hosts the COS
425+
// reconciler nor queries COS revisions directly. COD integration should replace
426+
// the direct ClusterExtension-to-COS queries, but the COS reconciler needs the index.
428427
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {
429-
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &ocv1.ClusterObjectSet{},
430-
clusterobjectset.GroupField, clusterobjectset.ExtractGroup); err != nil {
428+
if err := clusterobjctrl.SetupIndexes(context.Background(), mgr.GetFieldIndexer()); err != nil {
431429
return fmt.Errorf("indexing ClusterObjectSet group: %w", err)
432430
}
433431
}
@@ -484,25 +482,14 @@ func run() error {
484482
return err
485483
}
486484

487-
var ctrlBuilderOpts []controllers.ControllerBuilderOption
488-
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {
489-
ctrlBuilderOpts = append(ctrlBuilderOpts, controllers.WithClusterObjectSetWatch())
490-
} else {
491-
ctrlBuilderOpts = append(ctrlBuilderOpts, controllers.WithWatchesRawSource(
492-
trackingCache.Source(
493-
crhandler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &ocv1.ClusterExtension{}),
494-
predicate.ResourceVersionChangedPredicate{},
495-
predicate.Funcs{
496-
CreateFunc: func(event.TypedCreateEvent[client.Object]) bool { return false },
497-
},
498-
),
499-
))
500-
}
501-
502485
ceReconciler := &controllers.ClusterExtensionReconciler{
503486
Client: cl,
504487
}
505-
_, err = ceReconciler.SetupWithManager(mgr, ctrlBuilderOpts...)
488+
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {
489+
_, err = ceReconciler.SetupWithManagerForBoxcutter(mgr)
490+
} else {
491+
_, err = ceReconciler.SetupWithManagerForHelm(mgr, trackingCache)
492+
}
506493
if err != nil {
507494
setupLog.Error(err, "unable to create controller", "controller", "ClusterExtension")
508495
return err

0 commit comments

Comments
 (0)