Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions api/v1/clusterobjectset_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ const (

// ClusterObjectSetSpec defines the desired state of ClusterObjectSet.
type ClusterObjectSetSpec struct {
// group is a required, immutable identifier that links related revisions together.
// All revisions sharing the same group form an ordered sequence, regardless of owner references.
// The value must be 1 to 52 characters long, start with a lowercase letter,
// contain only lowercase letters, digits or hyphens, and end with a letter or digit.
//
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=52
// +kubebuilder:validation:XValidation:rule=`self.matches('^[a-z]([a-z0-9-]*[a-z0-9])?$')`,message="group must start with a lowercase letter, contain only lowercase letters, digits or hyphens, and end with a letter or digit"
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="group is immutable"
Group string `json:"group,omitempty"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this field is required, no need for omitempty:

Suggested change
Group string `json:"group,omitempty"`
Group string `json:"group"`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! The linter anyway (make api-lint-diff) enforces it for required fields:
api/v1/clusterobjectset_types.go:51:2:kubeapilinter:requiredfields: field ClusterObjectSetSpec.Group should have the omitempty tag.
Seems it is deliberate for RequiredFields in kube-api-linter to cope with serialization: serializing without omitempty would serialize and empty group with an empty string (which also violates the validation rule of at least 1 char).
https://github.com/kubernetes-sigs/kube-api-linter/blob/main/docs/linters.md#requiredfields

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird. Seeing "omitempty" on a required field is non-sensical to me. The field is required, therefore it simply is not possible for it to be empty, at which point "omitempty" is a distraction. Maybe I'm misunderstanding the nuance that doc is trying to explain.

I don't think it technically matters since that'll never actually come into play with the field being required and between 1 and 52 chars long.

What is your perspective of this linter rule? Does it make sense to you, or should we set to Ignore?

@fgiudici fgiudici Oct 16, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I agree it is a bit weird. I think the rationale is that for any reasons the group string is not set it gets serialized to the empty string which is invalid.
The point is exactly the "for any reason": I cannot spot any reason, maybe a weird scenario somewhere were the code serializing the COS is not ours and would make a deserialization to fail? 🤔
My guess is that the "omitempty" requirement is for being on the extra safe side. Since this check has been added to the kupe-api-linter by purpose and does not cause any harm to our code I would lean toward keeping the omitempty.
Anyway, I have not strong opinions, so I would not argue to wave the linter setting this specific polity to Ignore repo-wide. Still we cannot ignore the omitzero in the linter config, the option is not there 🤯

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could be more permissive and allow for names that start with a number? There's, e.g., a 3scale operator

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, added starting with number valid in the top commit.
CC @joelanford , WDYT? Double checking if this could be fine.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason we need to allow names that start with a number? Some Kubernetes types require a strict DNS 1035 label (see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names)

So we've always chosen to go with that so that if we ever need to manage Kubernetes objects derived from our objects' names, it is less likely for us to run into validation issues.

Always better to start out more restrictive and relax things later. It is very difficult/impossible to make things more restrictive.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, dropped with the last push


Comment thread
coderabbitai[bot] marked this conversation as resolved.
// lifecycleState specifies the lifecycle state of the ClusterObjectSet.
//
// When set to "Active", the revision is actively managed and reconciled.
Expand All @@ -59,11 +71,11 @@ type ClusterObjectSetSpec struct {
// +kubebuilder:validation:XValidation:rule="oldSelf == 'Active' || oldSelf == 'Archived' && oldSelf == self", message="cannot un-archive"
LifecycleState ClusterObjectSetLifecycleState `json:"lifecycleState,omitempty"`

// revision is a required, immutable sequence number representing a specific revision
// of the parent ClusterExtension.
// revision is a required, immutable sequence number identifying a specific
// ClusterObjectSet within a sequence of related revisions.
//
// The revision field must be a positive integer.
// Each ClusterObjectSet belonging to the same parent ClusterExtension must have a unique revision number.
// Each ClusterObjectSet in the same revision sequence must have a unique revision number.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just calling out that this is not actually enforced anywhere until we require metadata.name to equal <group>-<revision>.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Going to go with a follow-up PR addressing this.

// The revision number must always be the previous revision number plus one, or 1 for the first revision.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop this sentence. We don't enforce this anywhere will we? If I create 1, then 3, then 2. That's totally fine right?

//
// +required
Expand Down Expand Up @@ -559,20 +571,26 @@ type ObservedPhase struct {
Digest string `json:"digest"`
}

// TODO(COD): Update the archival description below when archival decisions move
// to ClusterObjectDeployment reconciliation.

// +genclient
// +genclient:nonNamespaced
// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Cluster
// +kubebuilder:subresource:status
// +kubebuilder:selectablefield:JSONPath=`.spec.group`
// +kubebuilder:printcolumn:name="Group",type=string,JSONPath=`.spec.group`
Comment thread
fgiudici marked this conversation as resolved.
// +kubebuilder:printcolumn:name="Revision",type=integer,JSONPath=`.spec.revision`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
// +kubebuilder:printcolumn:name=Age,type=date,JSONPath=`.metadata.creationTimestamp`

// ClusterObjectSet represents an immutable snapshot of Kubernetes objects
// for a specific version of a ClusterExtension. Each revision contains objects
// organized into phases that roll out sequentially. The same object can only be managed by a single revision
// at a time. Ownership of objects is transitioned from one revision to the next as the extension is upgraded
// or reconfigured. Once the latest revision has rolled out successfully, previous active revisions are archived for
// posterity.
// ClusterObjectSet represents an immutable snapshot of Kubernetes objects to
// apply and manage on the cluster. Each revision contains objects organized into
// phases that roll out sequentially. The same object can only be managed by a
// single revision at a time. Ownership of objects is transitioned from one revision
// to the next as new revisions are rolled out. Once the latest revision has rolled
// out successfully, previous active revisions are archived for posterity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is accurate now, but the archival logic will move to the ClusterObjectDeployment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the "TODO(COD)" comment for later update

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did the TODO comment make it here? I don't see it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, lost in rebases 🤦🏻 Good catch! Adding it back!

type ClusterObjectSet struct {
metav1.TypeMeta `json:",inline"`

Expand All @@ -582,8 +600,8 @@ type ClusterObjectSet struct {
metav1.ObjectMeta `json:"metadata,omitempty"`

// spec defines the desired state of the ClusterObjectSet.
// +optional
Spec ClusterObjectSetSpec `json:"spec,omitempty"`
// +required
Spec ClusterObjectSetSpec `json:"spec,omitzero"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for omitzero since it is required.

Suggested change
Spec ClusterObjectSetSpec `json:"spec,omitzero"`
Spec ClusterObjectSetSpec `json:"spec"`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make lint-api-diff complains here too:
api/v1/clusterobjectset_types.go:594:2:kubeapilinter:requiredfields: field ClusterObjectSet.Spec does not allow the zero value. It must have the omitzero tag.
I would leave it.


// status is optional and defines the observed state of the ClusterObjectSet.
// +optional
Expand Down
159 changes: 156 additions & 3 deletions api/v1/clusterobjectset_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,67 @@ package v1
import (
"context"
"fmt"
"strings"
"testing"

"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
)

func TestClusterObjectSetImmutability(t *testing.T) {
c := newClient(t)
ctx := context.Background()
i := 0
for name, tc := range map[string]struct {
spec ClusterObjectSetSpec
updateFunc func(*ClusterObjectSet)
allowed bool
spec ClusterObjectSetSpec
updateFunc func(*ClusterObjectSet)
allowed bool
expectedError string
}{
"group is immutable": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
Comment thread
fgiudici marked this conversation as resolved.
},
updateFunc: func(cos *ClusterObjectSet) {
cos.Spec.Group = "another-group"
},
expectedError: "group is immutable",
},
"group cannot be cleared": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
},
updateFunc: func(cos *ClusterObjectSet) {
cos.Spec.Group = ""
},
expectedError: "spec.group: Required value",
},
"unchanged group permits lifecycle update": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
Comment thread
fgiudici marked this conversation as resolved.
},
updateFunc: func(cos *ClusterObjectSet) {
cos.Spec.Group = "test-group"
cos.Spec.LifecycleState = ClusterObjectSetLifecycleStateArchived
},
allowed: true,
},
"revision is immutable": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
Expand All @@ -32,6 +74,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
},
"phases may be initially empty": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
Expand All @@ -49,6 +92,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
},
"phases may be initially unset": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
Expand All @@ -65,6 +109,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
},
"phases are immutable if not empty": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
Expand All @@ -86,6 +131,7 @@ func TestClusterObjectSetImmutability(t *testing.T) {
},
"spec collisionProtection is immutable": {
spec: ClusterObjectSetSpec{
Group: "test-group",
LifecycleState: ClusterObjectSetLifecycleStateActive,
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
Expand All @@ -112,6 +158,9 @@ func TestClusterObjectSetImmutability(t *testing.T) {
if !tc.allowed && !errors.IsInvalid(err) {
t.Fatal("expected update to fail due to invalid payload, but got:", err)
}
if tc.expectedError != "" {
require.ErrorContains(t, err, tc.expectedError)
}
})
}
}
Expand Down Expand Up @@ -351,6 +400,7 @@ func TestClusterObjectSetValidity(t *testing.T) {
},
Spec: tc.spec,
}
cos.Spec.Group = "test-group"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this this into the table specs so that it is not implicit? Also, these tests will start to fail when we intro the name validation on COS, so consider changing the ObjectMeta.Name to be the name we'll expect if that's not part of what we're testing here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to make each table spec to declare its own group, and COS names to use -.

i = i + 1
err := c.Create(ctx, cos)
if tc.valid && err != nil {
Expand All @@ -363,6 +413,109 @@ func TestClusterObjectSetValidity(t *testing.T) {
}
}

func TestClusterObjectSetSpecValidation(t *testing.T) {
c := newClient(t)
t.Run("missing spec", func(t *testing.T) {
cos := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": GroupVersion.String(),
"kind": ClusterObjectSetKind,
"metadata": map[string]any{"generateName": "spec-validation-"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, generateName may be something we need to think about specifically since we'll require a specific name format. Not sure what format kubernetes uses for the suffix, but to be safe we may want to look into the possibility of explicitly rejecting use of generate name.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to update the tests to use explicit names ending in a revision number.
I’ll double check generateName behavior alongside name enforcement in the follow-up PR (the name-equality rule may make a separate prohibition unnecessary).

}}
err := c.Create(t.Context(), cos)
require.True(t, errors.IsInvalid(err), "%v", err)
require.ErrorContains(t, err, "spec: Required")
})
}

func TestClusterObjectSetGroupValidation(t *testing.T) {
c := newClient(t)
for _, tc := range []struct {
name string
group *string
valid bool
}{
{name: "missing group", valid: false},
{name: "empty group", group: ptr.To(""), valid: false},
{name: "one character", group: ptr.To("a"), valid: true},
{name: "lowercase hyphens and digits", group: ptr.To("my-group-1"), valid: true},
{name: "maximum length", group: ptr.To(strings.Repeat("a", 52)), valid: true},
{name: "over maximum length", group: ptr.To(strings.Repeat("a", 53)), valid: false},
{name: "starts with digit", group: ptr.To("1group"), valid: false},
{name: "starts with hyphen", group: ptr.To("-group"), valid: false},
{name: "ends with hyphen", group: ptr.To("group-"), valid: false},
{name: "uppercase", group: ptr.To("Group"), valid: false},
{name: "underscore", group: ptr.To("my_group"), valid: false},
{name: "dot", group: ptr.To("my.group"), valid: false},
} {
t.Run(tc.name, func(t *testing.T) {
cos := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": GroupVersion.String(),
"kind": ClusterObjectSetKind,
"metadata": map[string]any{"generateName": "group-validation-"},
}}
spec := map[string]any{
"revision": int64(1), "lifecycleState": string(ClusterObjectSetLifecycleStateActive),
"collisionProtection": string(CollisionProtectionPrevent),
}
if tc.group != nil {
spec["group"] = *tc.group
}
cos.Object["spec"] = spec
err := c.Create(t.Context(), cos)
if tc.valid {
require.NoError(t, err)
} else {
require.True(t, errors.IsInvalid(err), "%v", err)
require.ErrorContains(t, err, "spec.group")
}
})
}
}

func TestClusterObjectSetGroupFieldSelector(t *testing.T) {
// Use an uncached client so filtering is performed by the API server.
c := newClient(t)
for _, fixture := range []struct {
name string
group string
revision int64
}{
{name: "field-selector-a-1", group: "field-selector-a", revision: 1},
{name: "field-selector-a-2", group: "field-selector-a", revision: 2},
{name: "field-selector-b-1", group: "field-selector-b", revision: 1},
} {
cos := &ClusterObjectSet{
ObjectMeta: metav1.ObjectMeta{Name: fixture.name},
Spec: ClusterObjectSetSpec{
Group: fixture.group,
Revision: fixture.revision,
LifecycleState: ClusterObjectSetLifecycleStateActive,
CollisionProtection: CollisionProtectionPrevent,
},
}
require.NoError(t, c.Create(t.Context(), cos))
}

for _, tc := range []struct {
group string
want []string
}{
{group: "field-selector-a", want: []string{"field-selector-a-1", "field-selector-a-2"}},
{group: "field-selector-b", want: []string{"field-selector-b-1"}},
{group: "field-selector-missing"},
} {
t.Run(tc.group, func(t *testing.T) {
var list ClusterObjectSetList
require.NoError(t, c.List(t.Context(), &list, client.MatchingFields{"spec.group": tc.group}))
names := make([]string, 0, len(list.Items))
for _, cos := range list.Items {
names = append(names, cos.Name)
}
require.ElementsMatch(t, tc.want, names)
})
}
}

func configMap() unstructured.Unstructured {
return unstructured.Unstructured{
Object: map[string]interface{}{
Expand Down
3 changes: 3 additions & 0 deletions api/v1/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestValidate(t *testing.T) {
return s
}
defaultRevisionSpec := func(s *ClusterObjectSetSpec) *ClusterObjectSetSpec {
s.Group = "test-group"
s.Revision = 1
s.CollisionProtection = CollisionProtectionPrevent
return s
Expand Down Expand Up @@ -188,6 +189,7 @@ func TestClusterObjectSetCompletedAtImmutable(t *testing.T) {
cos := &ClusterObjectSet{
ObjectMeta: metav1.ObjectMeta{Name: "cos-completedat-immutable"},
Spec: ClusterObjectSetSpec{
Group: "test-group",
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
LifecycleState: ClusterObjectSetLifecycleStateActive,
Expand Down Expand Up @@ -218,6 +220,7 @@ func TestClusterObjectSetCompletedAtCannotBeRemoved(t *testing.T) {
cos := &ClusterObjectSet{
ObjectMeta: metav1.ObjectMeta{Name: "cos-completedat-noremove"},
Spec: ClusterObjectSetSpec{
Group: "test-group",
Revision: 1,
CollisionProtection: CollisionProtectionPrevent,
LifecycleState: ClusterObjectSetLifecycleStateActive,
Expand Down
12 changes: 6 additions & 6 deletions applyconfigurations/api/v1/clusterobjectset.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 16 additions & 3 deletions applyconfigurations/api/v1/clusterobjectsetspec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading