From 669f08379e24df363ea1ac877f705e3c9e5b47e5 Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Fri, 16 Oct 2026 10:07:25 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20report=20ClusterObjectSet=20obje?= =?UTF-8?q?ct=20counts=20together,=20or=20omit=20entirely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-phase and aggregate objectCounts fields present, synced, and ready used omitempty, so a zero count (e.g. ready=0 while a phase rolls out) was dropped from status while its non-zero siblings were shown. Make the count fields (total/present/synced/ready) +required so that when objectCounts is present every field is serialized together, including zeros. Make the parent objectCounts field optional via omitzero (omitempty is a no-op on a non-pointer struct) so it is omitted entirely until counts are computed, rather than rendered as a partially-populated or all-zero object. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Per G. da Silva --- api/v1/clusterobjectset_types.go | 22 ++++++---- api/v1/clusterobjectset_types_test.go | 42 +++++++++++++++++++ .../api/v1/clusterobjectsetstatus.go | 2 + applyconfigurations/api/v1/observedphase.go | 2 + ...peratorframework.io_clusterobjectsets.yaml | 15 +++++-- manifests/experimental-e2e.yaml | 15 +++++-- manifests/experimental.yaml | 15 +++++-- 7 files changed, 95 insertions(+), 18 deletions(-) diff --git a/api/v1/clusterobjectset_types.go b/api/v1/clusterobjectset_types.go index 4c8028a07a..ae1733fecd 100644 --- a/api/v1/clusterobjectset_types.go +++ b/api/v1/clusterobjectset_types.go @@ -519,8 +519,10 @@ type ClusterObjectSetStatus struct { // objectCounts reports aggregate object counts across all phases. // Values are sums of the per-phase counts in observedPhases. + // It is omitted until counts have been computed; when present, all count + // fields are reported (including zeros). // +optional - ObjectCounts ObjectCounts `json:"objectCounts,omitempty"` + ObjectCounts ObjectCounts `json:"objectCounts,omitzero"` // observedPhases records the content hashes of resolved phases // at first successful reconciliation. This is used to detect if @@ -564,8 +566,10 @@ type ObservedPhase struct { Digest string `json:"digest"` // objectCounts reports the number of objects in this phase by state. - // +required - ObjectCounts ObjectCounts `json:"objectCounts"` + // It is omitted until counts have been computed; when present, all count + // fields are reported (including zeros). + // +optional + ObjectCounts ObjectCounts `json:"objectCounts,omitzero"` } // ObjectCounts reports the number of objects in a ClusterObjectSet or phase by state. @@ -580,24 +584,24 @@ type ObjectCounts struct { // cluster. During reconcile, this counts objects that the controller // has found or created. During teardown, this decrements toward zero // as objects are deleted. - // +optional + // +required // +kubebuilder:validation:Minimum:=0 // +kubebuilder:validation:Maximum:=1000 - Present int64 `json:"present,omitempty"` + Present int64 `json:"present"` // synced is the number of objects in this phase whose cluster state // matches the desired state. - // +optional + // +required // +kubebuilder:validation:Minimum:=0 // +kubebuilder:validation:Maximum:=1000 - Synced int64 `json:"synced,omitempty"` + Synced int64 `json:"synced"` // ready is the number of objects in this phase that are synced // and pass their assertions. - // +optional + // +required // +kubebuilder:validation:Minimum:=0 // +kubebuilder:validation:Maximum:=1000 - Ready int64 `json:"ready,omitempty"` + Ready int64 `json:"ready"` } // +genclient diff --git a/api/v1/clusterobjectset_types_test.go b/api/v1/clusterobjectset_types_test.go index 6770fbb129..b722381771 100644 --- a/api/v1/clusterobjectset_types_test.go +++ b/api/v1/clusterobjectset_types_test.go @@ -2,6 +2,7 @@ package v1 import ( "context" + "encoding/json" "fmt" "testing" @@ -374,3 +375,44 @@ func configMap() unstructured.Unstructured { }, } } + +// TestObjectCountsSerializationIncludesZeroCounts guards against the count fields +// being dropped from status when zero. total/present/synced/ready are all required, +// so a "0 of N" count (e.g. ready=0 while a phase is rolling out, or synced/ready=0 +// during teardown) must still be serialized rather than omitted via omitempty. +func TestObjectCountsSerializationIncludesZeroCounts(t *testing.T) { + data, err := json.Marshal(ObjectCounts{Total: 1, Present: 1, Synced: 1, Ready: 0}) + require.NoError(t, err) + require.JSONEq(t, `{"total":1,"present":1,"synced":1,"ready":0}`, string(data)) + + // All-zero counts must still serialize every field (nothing omitted). + data, err = json.Marshal(ObjectCounts{}) + require.NoError(t, err) + require.JSONEq(t, `{"total":0,"present":0,"synced":0,"ready":0}`, string(data)) +} + +// TestObjectCountsOmittedWhenZero verifies the parent objectCounts field is optional: +// a zero-valued ObjectCounts is omitted entirely (via omitzero on a value struct — +// omitempty does not omit non-pointer structs), but when any count is non-zero the whole +// struct is serialized with all of its (required) fields, including zeros. +func TestObjectCountsOmittedWhenZero(t *testing.T) { + // Aggregate status: zero objectCounts is omitted. + data, err := json.Marshal(ClusterObjectSetStatus{}) + require.NoError(t, err) + require.NotContains(t, string(data), "objectCounts") + + // Aggregate status: a non-zero objectCounts serializes with every field. + data, err = json.Marshal(ClusterObjectSetStatus{ObjectCounts: ObjectCounts{Total: 1}}) + require.NoError(t, err) + require.Contains(t, string(data), `"objectCounts":{"total":1,"present":0,"synced":0,"ready":0}`) + + // Per-phase: zero objectCounts is omitted (name/digest still present). + data, err = json.Marshal(ObservedPhase{Name: "deploy", Digest: "sha256:abc"}) + require.NoError(t, err) + require.NotContains(t, string(data), "objectCounts") + + // Per-phase: a non-zero objectCounts serializes with every field. + data, err = json.Marshal(ObservedPhase{Name: "deploy", Digest: "sha256:abc", ObjectCounts: ObjectCounts{Present: 2}}) + require.NoError(t, err) + require.Contains(t, string(data), `"objectCounts":{"total":0,"present":2,"synced":0,"ready":0}`) +} diff --git a/applyconfigurations/api/v1/clusterobjectsetstatus.go b/applyconfigurations/api/v1/clusterobjectsetstatus.go index 906da18177..bf0ed289f6 100644 --- a/applyconfigurations/api/v1/clusterobjectsetstatus.go +++ b/applyconfigurations/api/v1/clusterobjectsetstatus.go @@ -53,6 +53,8 @@ type ClusterObjectSetStatusApplyConfiguration struct { Conditions []metav1.ConditionApplyConfiguration `json:"conditions,omitempty"` // objectCounts reports aggregate object counts across all phases. // Values are sums of the per-phase counts in observedPhases. + // It is omitted until counts have been computed; when present, all count + // fields are reported (including zeros). ObjectCounts *ObjectCountsApplyConfiguration `json:"objectCounts,omitempty"` // observedPhases records the content hashes of resolved phases // at first successful reconciliation. This is used to detect if diff --git a/applyconfigurations/api/v1/observedphase.go b/applyconfigurations/api/v1/observedphase.go index 0d4575bfce..2e97961e79 100644 --- a/applyconfigurations/api/v1/observedphase.go +++ b/applyconfigurations/api/v1/observedphase.go @@ -29,6 +29,8 @@ type ObservedPhaseApplyConfiguration struct { // at first successful resolution, in the format ":". Digest *string `json:"digest,omitempty"` // objectCounts reports the number of objects in this phase by state. + // It is omitted until counts have been computed; when present, all count + // fields are reported (including zeros). ObjectCounts *ObjectCountsApplyConfiguration `json:"objectCounts,omitempty"` } diff --git a/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml index dec8cd06eb..9069188917 100644 --- a/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml +++ b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml @@ -631,6 +631,8 @@ spec: description: |- objectCounts reports aggregate object counts across all phases. Values are sums of the per-phase counts in observedPhases. + It is omitted until counts have been computed; when present, all count + fields are reported (including zeros). properties: present: description: |- @@ -665,6 +667,9 @@ spec: minimum: 0 type: integer required: + - present + - ready + - synced - total type: object observedPhases: @@ -701,8 +706,10 @@ spec: character. rule: '!format.dns1123Label().validate(self).hasValue()' objectCounts: - description: objectCounts reports the number of objects in this - phase by state. + description: |- + objectCounts reports the number of objects in this phase by state. + It is omitted until counts have been computed; when present, all count + fields are reported (including zeros). properties: present: description: |- @@ -737,12 +744,14 @@ spec: minimum: 0 type: integer required: + - present + - ready + - synced - total type: object required: - digest - name - - objectCounts type: object maxItems: 20 type: array diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 062ec31cb7..40a05c836e 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -2042,6 +2042,8 @@ spec: description: |- objectCounts reports aggregate object counts across all phases. Values are sums of the per-phase counts in observedPhases. + It is omitted until counts have been computed; when present, all count + fields are reported (including zeros). properties: present: description: |- @@ -2076,6 +2078,9 @@ spec: minimum: 0 type: integer required: + - present + - ready + - synced - total type: object observedPhases: @@ -2112,8 +2117,10 @@ spec: character. rule: '!format.dns1123Label().validate(self).hasValue()' objectCounts: - description: objectCounts reports the number of objects in this - phase by state. + description: |- + objectCounts reports the number of objects in this phase by state. + It is omitted until counts have been computed; when present, all count + fields are reported (including zeros). properties: present: description: |- @@ -2148,12 +2155,14 @@ spec: minimum: 0 type: integer required: + - present + - ready + - synced - total type: object required: - digest - name - - objectCounts type: object maxItems: 20 type: array diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index cd9328ce7d..4f20f0178e 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -2003,6 +2003,8 @@ spec: description: |- objectCounts reports aggregate object counts across all phases. Values are sums of the per-phase counts in observedPhases. + It is omitted until counts have been computed; when present, all count + fields are reported (including zeros). properties: present: description: |- @@ -2037,6 +2039,9 @@ spec: minimum: 0 type: integer required: + - present + - ready + - synced - total type: object observedPhases: @@ -2073,8 +2078,10 @@ spec: character. rule: '!format.dns1123Label().validate(self).hasValue()' objectCounts: - description: objectCounts reports the number of objects in this - phase by state. + description: |- + objectCounts reports the number of objects in this phase by state. + It is omitted until counts have been computed; when present, all count + fields are reported (including zeros). properties: present: description: |- @@ -2109,12 +2116,14 @@ spec: minimum: 0 type: integer required: + - present + - ready + - synced - total type: object required: - digest - name - - objectCounts type: object maxItems: 20 type: array