From d976c255d035158c629adad0f6ba9ed72d768e73 Mon Sep 17 00:00:00 2001 From: Daniel Franz Date: Fri, 9 Oct 2026 14:55:53 +0900 Subject: [PATCH] ClusterObjectSet ObjectCounts Adds object count status to the ClusterObjectSet Status, which reflects the number of objects in each phase for several conditions, as well a top-level rollup status which aggregates them all. Signed-off-by: Daniel Franz --- api/v1/clusterobjectset_types.go | 43 ++- api/v1/zz_generated.deepcopy.go | 17 + .../api/v1/clusterobjectsetstatus.go | 11 + applyconfigurations/api/v1/objectcounts.go | 77 ++++ applyconfigurations/api/v1/observedphase.go | 10 + applyconfigurations/internal/internal.go | 21 ++ applyconfigurations/utils.go | 2 + docs/api-reference/olmv1-api-reference.md | 2 + ...peratorframework.io_clusterobjectsets.yaml | 85 ++++- .../clusterobjectset_controller.go | 6 + internal/object-controller/status/status.go | 197 ++++++++++ .../object-controller/status/status_test.go | 341 ++++++++++++++++++ manifests/experimental-e2e.yaml | 85 ++++- manifests/experimental.yaml | 85 ++++- test/e2e/features/revision.feature | 78 ++++ test/e2e/steps/steps.go | 52 +++ 16 files changed, 1102 insertions(+), 10 deletions(-) create mode 100644 applyconfigurations/api/v1/objectcounts.go create mode 100644 internal/object-controller/status/status.go create mode 100644 internal/object-controller/status/status_test.go diff --git a/api/v1/clusterobjectset_types.go b/api/v1/clusterobjectset_types.go index 5f54a1c02d..d3ee70d0c7 100644 --- a/api/v1/clusterobjectset_types.go +++ b/api/v1/clusterobjectset_types.go @@ -517,13 +517,17 @@ type ClusterObjectSetStatus struct { // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` + // objectCounts reports aggregate object counts across all phases. + // Values are sums of the per-phase counts in observedPhases. + // +optional + ObjectCounts ObjectCounts `json:"objectCounts,omitempty"` + // observedPhases records the content hashes of resolved phases // at first successful reconciliation. This is used to detect if // referenced object sources were deleted and recreated with // different content. Each entry covers all fully-resolved object // manifests within a phase, making it source-agnostic. // - // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf.size() == 0",message="observedPhases is immutable" // +kubebuilder:validation:MaxItems=20 // +listType=map // +listMapKey=name @@ -553,10 +557,47 @@ type ObservedPhase struct { // at first successful resolution, in the format ":". // // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf.size() == 0",message="digest is immutable" // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=256 // +kubebuilder:validation:XValidation:rule=`self.matches('^[a-z0-9]+:[a-f0-9]+$')`,message="digest must be in the format ':'" Digest string `json:"digest"` + + // objectCounts reports the number of objects in this phase by state. + // +required + ObjectCounts ObjectCounts `json:"objectCounts"` +} + +// ObjectCounts reports the number of objects in a ClusterObjectSet or phase by state. +type ObjectCounts struct { + // total is the number of objects in this phase. + // +required + // +kubebuilder:validation:Minimum:=0 + // +kubebuilder:validation:Maximum:=1000 + Total int64 `json:"total"` + + // present is the number of objects in this phase that exist on the + // cluster. During reconcile, this counts objects that the controller + // has found or created. During teardown, this decrements toward zero + // as objects are deleted. + // +optional + // +kubebuilder:validation:Minimum:=0 + // +kubebuilder:validation:Maximum:=1000 + Present int64 `json:"present,omitempty"` + + // synced is the number of objects in this phase whose cluster state + // matches the desired state. + // +optional + // +kubebuilder:validation:Minimum:=0 + // +kubebuilder:validation:Maximum:=1000 + Synced int64 `json:"synced,omitempty"` + + // ready is the number of objects in this phase that are synced + // and pass their assertions. + // +optional + // +kubebuilder:validation:Minimum:=0 + // +kubebuilder:validation:Maximum:=1000 + Ready int64 `json:"ready,omitempty"` } // +genclient diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index e1cfd07887..2ddfb0da6a 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -560,6 +560,7 @@ func (in *ClusterObjectSetStatus) DeepCopyInto(out *ClusterObjectSetStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + out.ObjectCounts = in.ObjectCounts if in.ObservedPhases != nil { in, out := &in.ObservedPhases, &out.ObservedPhases *out = make([]ObservedPhase, len(*in)) @@ -643,6 +644,21 @@ func (in *ImageSource) DeepCopy() *ImageSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectCounts) DeepCopyInto(out *ObjectCounts) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectCounts. +func (in *ObjectCounts) DeepCopy() *ObjectCounts { + if in == nil { + return nil + } + out := new(ObjectCounts) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObjectSelector) DeepCopyInto(out *ObjectSelector) { *out = *in @@ -678,6 +694,7 @@ func (in *ObjectSourceRef) DeepCopy() *ObjectSourceRef { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObservedPhase) DeepCopyInto(out *ObservedPhase) { *out = *in + out.ObjectCounts = in.ObjectCounts } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedPhase. diff --git a/applyconfigurations/api/v1/clusterobjectsetstatus.go b/applyconfigurations/api/v1/clusterobjectsetstatus.go index ed8ed67aef..b9573d88d9 100644 --- a/applyconfigurations/api/v1/clusterobjectsetstatus.go +++ b/applyconfigurations/api/v1/clusterobjectsetstatus.go @@ -51,6 +51,9 @@ type ClusterObjectSetStatusApplyConfiguration struct { // // Rollout completion is recorded separately by status.completedAt. Conditions []metav1.ConditionApplyConfiguration `json:"conditions,omitempty"` + // objectCounts reports aggregate object counts across all phases. + // Values are sums of the per-phase counts in observedPhases. + ObjectCounts *ObjectCountsApplyConfiguration `json:"objectCounts,omitempty"` // observedPhases records the content hashes of resolved phases // at first successful reconciliation. This is used to detect if // referenced object sources were deleted and recreated with @@ -82,6 +85,14 @@ func (b *ClusterObjectSetStatusApplyConfiguration) WithConditions(values ...*met return b } +// WithObjectCounts sets the ObjectCounts field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObjectCounts field is set to the value of the last call. +func (b *ClusterObjectSetStatusApplyConfiguration) WithObjectCounts(value *ObjectCountsApplyConfiguration) *ClusterObjectSetStatusApplyConfiguration { + b.ObjectCounts = value + return b +} + // WithObservedPhases adds the given value to the ObservedPhases field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the ObservedPhases field. diff --git a/applyconfigurations/api/v1/objectcounts.go b/applyconfigurations/api/v1/objectcounts.go new file mode 100644 index 0000000000..0d00f2380c --- /dev/null +++ b/applyconfigurations/api/v1/objectcounts.go @@ -0,0 +1,77 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen-v0.21. DO NOT EDIT. + +package v1 + +// ObjectCountsApplyConfiguration represents a declarative configuration of the ObjectCounts type for use +// with apply. +// +// ObjectCounts reports the number of objects in a ClusterObjectSet or phase by state. +type ObjectCountsApplyConfiguration struct { + // total is the number of objects in this phase. + Total *int64 `json:"total,omitempty"` + // present is the number of objects in this phase that exist on the + // cluster. During reconcile, this counts objects that the controller + // has found or created. During teardown, this decrements toward zero + // as objects are deleted. + Present *int64 `json:"present,omitempty"` + // synced is the number of objects in this phase whose cluster state + // matches the desired state. + Synced *int64 `json:"synced,omitempty"` + // ready is the number of objects in this phase that are synced + // and pass their assertions. + Ready *int64 `json:"ready,omitempty"` +} + +// ObjectCountsApplyConfiguration constructs a declarative configuration of the ObjectCounts type for use with +// apply. +func ObjectCounts() *ObjectCountsApplyConfiguration { + return &ObjectCountsApplyConfiguration{} +} + +// WithTotal sets the Total field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Total field is set to the value of the last call. +func (b *ObjectCountsApplyConfiguration) WithTotal(value int64) *ObjectCountsApplyConfiguration { + b.Total = &value + return b +} + +// WithPresent sets the Present field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Present field is set to the value of the last call. +func (b *ObjectCountsApplyConfiguration) WithPresent(value int64) *ObjectCountsApplyConfiguration { + b.Present = &value + return b +} + +// WithSynced sets the Synced field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Synced field is set to the value of the last call. +func (b *ObjectCountsApplyConfiguration) WithSynced(value int64) *ObjectCountsApplyConfiguration { + b.Synced = &value + return b +} + +// WithReady sets the Ready field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Ready field is set to the value of the last call. +func (b *ObjectCountsApplyConfiguration) WithReady(value int64) *ObjectCountsApplyConfiguration { + b.Ready = &value + return b +} diff --git a/applyconfigurations/api/v1/observedphase.go b/applyconfigurations/api/v1/observedphase.go index 323c7c9ac7..0d4575bfce 100644 --- a/applyconfigurations/api/v1/observedphase.go +++ b/applyconfigurations/api/v1/observedphase.go @@ -28,6 +28,8 @@ type ObservedPhaseApplyConfiguration struct { // digest is the digest of the phase's resolved object content // at first successful resolution, in the format ":". Digest *string `json:"digest,omitempty"` + // objectCounts reports the number of objects in this phase by state. + ObjectCounts *ObjectCountsApplyConfiguration `json:"objectCounts,omitempty"` } // ObservedPhaseApplyConfiguration constructs a declarative configuration of the ObservedPhase type for use with @@ -51,3 +53,11 @@ func (b *ObservedPhaseApplyConfiguration) WithDigest(value string) *ObservedPhas b.Digest = &value return b } + +// WithObjectCounts sets the ObjectCounts field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObjectCounts field is set to the value of the last call. +func (b *ObservedPhaseApplyConfiguration) WithObjectCounts(value *ObjectCountsApplyConfiguration) *ObservedPhaseApplyConfiguration { + b.ObjectCounts = value + return b +} diff --git a/applyconfigurations/internal/internal.go b/applyconfigurations/internal/internal.go index 1da3b4c1a6..12d01d336f 100644 --- a/applyconfigurations/internal/internal.go +++ b/applyconfigurations/internal/internal.go @@ -339,6 +339,9 @@ var schemaYAML = typed.YAMLObject(`types: elementRelationship: associative keys: - type + - name: objectCounts + type: + namedType: com.github.operator-framework.operator-controller.api.v1.ObjectCounts - name: observedPhases type: list: @@ -385,6 +388,21 @@ var schemaYAML = typed.YAMLObject(`types: - name: ref type: scalar: string +- name: com.github.operator-framework.operator-controller.api.v1.ObjectCounts + map: + fields: + - name: present + type: + scalar: numeric + - name: ready + type: + scalar: numeric + - name: synced + type: + scalar: numeric + - name: total + type: + scalar: numeric - name: com.github.operator-framework.operator-controller.api.v1.ObjectSelector map: fields: @@ -418,6 +436,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: name type: scalar: string + - name: objectCounts + type: + namedType: com.github.operator-framework.operator-controller.api.v1.ObjectCounts - name: com.github.operator-framework.operator-controller.api.v1.PreflightConfig map: fields: diff --git a/applyconfigurations/utils.go b/applyconfigurations/utils.go index f61f811e64..b82ea41164 100644 --- a/applyconfigurations/utils.go +++ b/applyconfigurations/utils.go @@ -80,6 +80,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apiv1.FieldValueProbeApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("ImageSource"): return &apiv1.ImageSourceApplyConfiguration{} + case v1.SchemeGroupVersion.WithKind("ObjectCounts"): + return &apiv1.ObjectCountsApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("ObjectSelector"): return &apiv1.ObjectSelectorApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("ObjectSourceRef"): diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 6cb5b43785..5a87485a8e 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -457,6 +457,8 @@ _Appears in:_ | `pollIntervalMinutes` _integer_ | pollIntervalMinutes is an optional field that sets the interval, in minutes, at which the image source is polled for new content.
You cannot specify pollIntervalMinutes when ref is a digest-based reference.
When omitted, the image is not polled for new content. | | Minimum: 1
Optional: \{\}
| + + #### ObjectSelector 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 9fb96efbb3..4434b754cc 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 @@ -627,6 +627,46 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + objectCounts: + description: |- + objectCounts reports aggregate object counts across all phases. + Values are sums of the per-phase counts in observedPhases. + properties: + present: + description: |- + present is the number of objects in this phase that exist on the + cluster. During reconcile, this counts objects that the controller + has found or created. During teardown, this decrements toward zero + as objects are deleted. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + ready: + description: |- + ready is the number of objects in this phase that are synced + and pass their assertions. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + synced: + description: |- + synced is the number of objects in this phase whose cluster state + matches the desired state. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + total: + description: total is the number of objects in this phase. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + required: + - total + type: object observedPhases: description: |- observedPhases records the content hashes of resolved phases @@ -646,6 +686,8 @@ spec: minLength: 1 type: string x-kubernetes-validations: + - message: digest is immutable + rule: self == oldSelf || oldSelf.size() == 0 - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') name: @@ -658,18 +700,55 @@ spec: characters and hyphens, and must start and end with an alphanumeric character. rule: '!format.dns1123Label().validate(self).hasValue()' + objectCounts: + description: objectCounts reports the number of objects in this + phase by state. + properties: + present: + description: |- + present is the number of objects in this phase that exist on the + cluster. During reconcile, this counts objects that the controller + has found or created. During teardown, this decrements toward zero + as objects are deleted. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + ready: + description: |- + ready is the number of objects in this phase that are synced + and pass their assertions. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + synced: + description: |- + synced is the number of objects in this phase whose cluster state + matches the desired state. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + total: + description: total is the number of objects in this phase. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + required: + - total + type: object required: - digest - name + - objectCounts type: object maxItems: 20 type: array x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 type: object x-kubernetes-validations: - message: completedAt cannot be removed once set diff --git a/internal/object-controller/controllers/clusterobjectset_controller.go b/internal/object-controller/controllers/clusterobjectset_controller.go index f4f2880ddb..2370b2fecb 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller.go +++ b/internal/object-controller/controllers/clusterobjectset_controller.go @@ -41,6 +41,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/object-controller/status" "github.com/operator-framework/operator-controller/internal/shared/labels" ) @@ -231,6 +232,9 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl return ctrl.Result{}, fmt.Errorf("revision reconcile: %v", err) } + // Set object counts + status.FromReconcile(cos, rres) + // Report revision-level preflight validation failures and re-check on a flat timer: // the dry-run can be rejected for reasons that clear without a spec change (e.g. RBAC // or a dependency CRD still propagating), and those inputs are not watched. @@ -405,6 +409,8 @@ func (c *ClusterObjectSetReconciler) archive(ctx context.Context, revisionEngine setRetryableErrorConditions(cos, err.Error(), false) return ctrl.Result{}, err } + // Set object counts + status.FromTeardown(cos, tdres) if tdres != nil && !tdres.IsComplete() { markAsArchiving(cos) return ctrl.Result{RequeueAfter: 5 * time.Second}, nil diff --git a/internal/object-controller/status/status.go b/internal/object-controller/status/status.go new file mode 100644 index 0000000000..b984c721eb --- /dev/null +++ b/internal/object-controller/status/status.go @@ -0,0 +1,197 @@ +package status + +import ( + "pkg.package-operator.run/boxcutter/machinery" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +// sumObjectCounts aggregates ObjectCounts across all observed phases into a single total. +// Returns ObjectCounts with a Total of zero if phases is empty. +func sumObjectCounts(phases []ocv1.ObservedPhase) ocv1.ObjectCounts { + counts := ocv1.ObjectCounts{ + Total: 0, + } + if len(phases) == 0 { + return counts + } + for i := range phases { + counts.Total += phases[i].ObjectCounts.Total + counts.Present += phases[i].ObjectCounts.Present + counts.Synced += phases[i].ObjectCounts.Synced + counts.Ready += phases[i].ObjectCounts.Ready + } + return counts +} + +// FromReconcile updates the status of the given ClusterObjectSet based on a reconcile result, +// populating the phase level and aggregate ObjectCounts. +func FromReconcile(cos *ocv1.ClusterObjectSet, result machinery.RevisionResult) { + if cos == nil { + return + } + observedPhasesFromReconcileResult(cos.Spec.Phases, result, &cos.Status.ObservedPhases) + cos.Status.ObjectCounts = sumObjectCounts(cos.Status.ObservedPhases) +} + +// observedPhasesFromReconcileResult populates observedPhases from a reconcile result. +// When the revision has progressed (i.e. is transitioning), only Total counts are preserved from existing +// observed phases; otherwise full per-object counts are derived from phase results. +func observedPhasesFromReconcileResult(specPhases []ocv1.ClusterObjectSetPhase, result machinery.RevisionResult, observedPhases *[]ocv1.ObservedPhase) { + if result == nil || observedPhases == nil { + return + } + if result.HasProgressed() { + allPhasesWithCounts(specPhases, observedPhases) + } else { + buildObservedPhases(specPhases, result.GetPhases(), observedPhases) + } +} + +// allPhasesWithCounts updates the Total in each existing observed phase's ObjectCounts from the spec phase object list, +// leaving all other counts (Present, Synced, Ready) unchanged. Used when a revision is progressing and +// detailed per-object results are not yet available. +func allPhasesWithCounts(specPhases []ocv1.ClusterObjectSetPhase, observedPhases *[]ocv1.ObservedPhase) { + objTotalCountMap := make(map[string]int) + for _, specPhase := range specPhases { + objTotalCountMap[specPhase.Name] = len(specPhase.Objects) + } + op := *observedPhases + for i := range op { + op[i].ObjectCounts.Total = int64(objTotalCountMap[op[i].Name]) + } + *observedPhases = op +} + +// buildObservedPhases constructs observed phases from reconcile phase results. +// Complete phases receive full counts (Total=Present=Synced=Ready); incomplete phases are handled by incompletePhase. +func buildObservedPhases(specPhases []ocv1.ClusterObjectSetPhase, phaseResults []machinery.PhaseResult, observedPhases *[]ocv1.ObservedPhase) { + mapSpecPhases(specPhases, phaseResults, + func(total int64) ocv1.ObjectCounts { + return ocv1.ObjectCounts{Total: total, Present: total, Synced: total, Ready: total} + }, + incompletePhase, + observedPhases, + ) +} + +// incompletePhase computes ObjectCounts for a phase whose reconcile result is not yet complete. +// Present counts all objects that exist on the cluster (non-paused, or paused but not freshly created). +// Synced counts objects that are idle (no pending changes). Ready counts objects reported as complete. +func incompletePhase(sp ocv1.ClusterObjectSetPhase, pr machinery.PhaseResult, observedPhase *ocv1.ObservedPhase) { + observedPhase.ObjectCounts = ocv1.ObjectCounts{Total: int64(len(sp.Objects))} + for _, obj := range pr.GetObjects() { + if !obj.IsPaused() || obj.Action() != machinery.ActionCreated { + observedPhase.ObjectCounts.Present++ + } + if obj.IsPaused() { + if obj.Action() == machinery.ActionIdle { + observedPhase.ObjectCounts.Synced++ + } + } else { + switch obj.Action() { + case machinery.ActionIdle, machinery.ActionUpdated, machinery.ActionCreated, machinery.ActionRecovered: + observedPhase.ObjectCounts.Synced++ + } + } + if obj.IsComplete() { + observedPhase.ObjectCounts.Ready++ + } + } +} + +// FromTeardown updates the status of the given ClusterObjectSet based on a teardown result, +// populating ObservedPhases and the aggregate ObjectCounts. +func FromTeardown(cos *ocv1.ClusterObjectSet, result machinery.RevisionTeardownResult) { + if cos == nil { + return + } + observedPhasesFromTeardownResult(cos.Spec.Phases, result, &cos.Status.ObservedPhases) + cos.Status.ObjectCounts = sumObjectCounts(cos.Status.ObservedPhases) +} + +// observedPhasesFromTeardownResult populates observedPhases from a teardown result. +func observedPhasesFromTeardownResult(specPhases []ocv1.ClusterObjectSetPhase, result machinery.RevisionTeardownResult, observedPhases *[]ocv1.ObservedPhase) { + if result == nil || observedPhases == nil { + return + } + buildTeardownObservedPhases(specPhases, result.GetPhases(), observedPhases) +} + +// buildTeardownObservedPhases constructs observed phases from teardown phase results. +// Complete phases receive only the Total count; incomplete phases are handled by tearingDownPhase. +func buildTeardownObservedPhases(specPhases []ocv1.ClusterObjectSetPhase, phaseResults []machinery.PhaseTeardownResult, observedPhases *[]ocv1.ObservedPhase) { + mapSpecPhases(specPhases, phaseResults, + func(total int64) ocv1.ObjectCounts { + return ocv1.ObjectCounts{Total: total} + }, + tearingDownPhase, + observedPhases, + ) +} + +// tearingDownPhase sets ObjectCounts for a phase that is still being torn down. +// Total is derived from the spec; Present reflects objects still waiting to be deleted. +func tearingDownPhase(sp ocv1.ClusterObjectSetPhase, pr machinery.PhaseTeardownResult, op *ocv1.ObservedPhase) { + op.ObjectCounts = ocv1.ObjectCounts{ + Total: int64(len(sp.Objects)), + Present: int64(len(pr.Waiting())), + } +} + +// mapSpecPhases merges spec phase definitions with evaluated phase results into observedPhases. +// For each spec phase: if no result exists, only Total is set; if the result is complete, +// completeObjectCounts is called; otherwise buildIncomplete fills in partial counts. +// The output slice is ordered to match specPhases. +func mapSpecPhases[T interface { + GetName() string + IsComplete() bool +}]( + specPhases []ocv1.ClusterObjectSetPhase, + results []T, + completeObjectCounts func(total int64) ocv1.ObjectCounts, + buildIncomplete func(ocv1.ClusterObjectSetPhase, T, *ocv1.ObservedPhase), + observedPhases *[]ocv1.ObservedPhase, +) { + if observedPhases == nil { + return + } + resultsByName := make(map[string]T, len(results)) + for _, r := range results { + resultsByName[r.GetName()] = r + } + + observedPhasesMap := make(map[string]*ocv1.ObservedPhase) + for i := range *observedPhases { + observedPhasesMap[(*observedPhases)[i].Name] = &(*observedPhases)[i] + } + + for _, sp := range specPhases { + total := int64(len(sp.Objects)) + r, evaluated := resultsByName[sp.Name] + // Digest is not set here, despite it being a required field, as by this + // point the objects have already been mutated by the controller and + // their digests can no longer be computed. + if _, initialized := observedPhasesMap[sp.Name]; !initialized { + observedPhasesMap[sp.Name] = &ocv1.ObservedPhase{Name: sp.Name} + } + if !evaluated { + observedPhasesMap[sp.Name].ObjectCounts = ocv1.ObjectCounts{Total: total} + continue + } + if r.IsComplete() { + objCounts := completeObjectCounts(total) + observedPhasesMap[sp.Name].ObjectCounts = objCounts + continue + } + buildIncomplete(sp, r, observedPhasesMap[sp.Name]) + } + + result := make([]ocv1.ObservedPhase, 0, len(specPhases)) + for _, sp := range specPhases { + if op, ok := observedPhasesMap[sp.Name]; ok { + result = append(result, *op) + } + } + *observedPhases = result +} diff --git a/internal/object-controller/status/status_test.go b/internal/object-controller/status/status_test.go new file mode 100644 index 0000000000..b0afef99ad --- /dev/null +++ b/internal/object-controller/status/status_test.go @@ -0,0 +1,341 @@ +package status + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "pkg.package-operator.run/boxcutter/machinery" + boxcuttertypes "pkg.package-operator.run/boxcutter/machinery/types" + "pkg.package-operator.run/boxcutter/validation" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +func TestFromReconcile(t *testing.T) { + t.Run("complete sets available and completed at", func(t *testing.T) { + cos := cosWithPhaseObjects( + struct { + name string + objects int + }{"p1", 1}, + ) + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + obj.SetName("created-cm") + result := &fakeRevisionResult{ + complete: false, + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: false, objects: []machinery.ObjectResult{ + &fakeCollisionResult{ + fakeObjectResult: fakeObjectResult{obj: obj, complete: false, probes: boxcuttertypes.ProbeResultContainer{}}, + }, + }, + }, + }, + } + FromReconcile(&cos, result) + require.NotNil(t, cos.Status.ObservedPhases) + assert.Equal(t, ocv1.ObjectCounts{Total: 1, Present: 1}, cos.Status.ObservedPhases[0].ObjectCounts) + }) +} + +func TestFromTeardown(t *testing.T) { + t.Run("teardown in progress", func(t *testing.T) { + cos := cosWithPhaseObjects( + struct { + name string + objects int + }{"p1", 3}, + ) + result := &fakeTeardownResult{ + complete: false, + phases: []machinery.PhaseTeardownResult{ + &fakePhaseTeardownResult{name: "p1", complete: false, waiting: []boxcuttertypes.ObjectRef{{}, {}}}, + }, + } + FromTeardown(&cos, result) + require.NotNil(t, cos.Status.ObservedPhases) + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2}, cos.Status.ObservedPhases[0].ObjectCounts) + }) + + t.Run("teardown complete sets all counts to zero except total", func(t *testing.T) { + cos := cosWithPhaseObjects( + struct { + name string + objects int + }{"p1", 5}, + ) + result := &fakeTeardownResult{ + complete: true, + phases: []machinery.PhaseTeardownResult{ + &fakePhaseTeardownResult{name: "p1", complete: true}, + }, + } + FromTeardown(&cos, result) + require.NotNil(t, cos.Status.ObservedPhases) + assert.Equal(t, ocv1.ObjectCounts{Total: 5}, cos.Status.ObservedPhases[0].ObjectCounts) + }) + + t.Run("read-only teardown phases included in GetPhases", func(t *testing.T) { + cos := cosWithPhaseObjects( + struct { + name string + objects int + }{"p1", 2}, + struct { + name string + objects int + }{"p2", 3}, + struct { + name string + objects int + }{"p3", 4}, + ) + result := &fakeTeardownResult{ + complete: false, + active: "p2", + phases: []machinery.PhaseTeardownResult{ + &fakePhaseTeardownResult{name: "p3", complete: true}, + &fakePhaseTeardownResult{name: "p2", complete: false, waiting: []boxcuttertypes.ObjectRef{{}, {}}}, + &fakePhaseTeardownResult{name: "p1", complete: false, waiting: []boxcuttertypes.ObjectRef{{}, {}}}, + }, + } + FromTeardown(&cos, result) + require.NotNil(t, cos.Status.ObservedPhases) + + phases := cos.Status.ObservedPhases + require.Len(t, phases, 3) + + assert.Equal(t, ocv1.ObjectCounts{Total: 2, Present: 2}, phases[0].ObjectCounts) + + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2}, phases[1].ObjectCounts) + + assert.Equal(t, ocv1.ObjectCounts{Total: 4}, phases[2].ObjectCounts) + }) +} + +// Test fakes + +type fakeRevisionResult struct { + validationError *validation.RevisionValidationError + phases []machinery.PhaseResult + progressed bool + complete bool +} + +func (f *fakeRevisionResult) GetValidationError() *validation.RevisionValidationError { + return f.validationError +} +func (f *fakeRevisionResult) GetPhases() []machinery.PhaseResult { return f.phases } +func (f *fakeRevisionResult) InTransition() bool { return false } +func (f *fakeRevisionResult) IsComplete() bool { return f.complete } +func (f *fakeRevisionResult) HasProgressed() bool { return f.progressed } +func (f *fakeRevisionResult) String() string { return "" } + +type fakePhaseResult struct { + name string + complete bool + objects []machinery.ObjectResult + validationError *validation.PhaseValidationError +} + +func (f *fakePhaseResult) GetName() string { return f.name } +func (f *fakePhaseResult) IsComplete() bool { return f.complete } +func (f *fakePhaseResult) GetObjects() []machinery.ObjectResult { return f.objects } +func (f *fakePhaseResult) InTransition() bool { return false } +func (f *fakePhaseResult) HasProgressed() bool { return false } +func (f *fakePhaseResult) String() string { return f.name } +func (f *fakePhaseResult) GetValidationError() *validation.PhaseValidationError { + return f.validationError +} + +type fakeObjectResult struct { + obj machinery.Object + complete bool + paused bool + probes boxcuttertypes.ProbeResultContainer + action machinery.Action +} + +func (f *fakeObjectResult) Object() machinery.Object { return f.obj } +func (f *fakeObjectResult) IsComplete() bool { return f.complete } +func (f *fakeObjectResult) IsPaused() bool { return f.paused } +func (f *fakeObjectResult) ProbeResults() boxcuttertypes.ProbeResultContainer { return f.probes } +func (f *fakeObjectResult) Action() machinery.Action { return f.action } +func (f *fakeObjectResult) String() string { return "" } + +type fakeCollisionResult struct { + fakeObjectResult +} + +func (f *fakeCollisionResult) Action() machinery.Action { return machinery.ActionCollision } + +type fakeTeardownResult struct { + complete bool + active string + phases []machinery.PhaseTeardownResult + waiting []string +} + +func (f *fakeTeardownResult) IsComplete() bool { return f.complete } +func (f *fakeTeardownResult) GetPhases() []machinery.PhaseTeardownResult { return f.phases } +func (f *fakeTeardownResult) GetActivePhaseName() (string, bool) { return f.active, f.active != "" } +func (f *fakeTeardownResult) GetWaitingPhaseNames() []string { return f.waiting } +func (f *fakeTeardownResult) GetGonePhaseNames() []string { return nil } +func (f *fakeTeardownResult) String() string { return "" } + +type fakePhaseTeardownResult struct { + name string + complete bool + waiting []boxcuttertypes.ObjectRef +} + +func (f *fakePhaseTeardownResult) GetName() string { return f.name } +func (f *fakePhaseTeardownResult) IsComplete() bool { return f.complete } +func (f *fakePhaseTeardownResult) Gone() []boxcuttertypes.ObjectRef { return nil } +func (f *fakePhaseTeardownResult) Waiting() []boxcuttertypes.ObjectRef { return f.waiting } +func (f *fakePhaseTeardownResult) String() string { return f.name } + +func cosWithPhaseObjects(phaseDefs ...struct { + name string + objects int +}) ocv1.ClusterObjectSet { + phases := make([]ocv1.ClusterObjectSetPhase, len(phaseDefs)) + observedPhases := make([]ocv1.ObservedPhase, len(phaseDefs)) + for i, pd := range phaseDefs { + objs := make([]ocv1.ClusterObjectSetObject, pd.objects) + phases[i] = ocv1.ClusterObjectSetPhase{Name: pd.name, Objects: objs} + observedPhases[i] = ocv1.ObservedPhase{Name: pd.name} + } + return ocv1.ClusterObjectSet{ + Spec: ocv1.ClusterObjectSetSpec{ + Phases: phases, + }, + Status: ocv1.ClusterObjectSetStatus{ + ObservedPhases: observedPhases, + }, + } +} + +func TestBuildObservedPhases_ReadOnly(t *testing.T) { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + obj.SetName("my-cm") + + spec := cosWithPhaseObjects( + struct { + name string + objects int + }{"phase-1", 2}, + struct { + name string + objects int + }{"phase-2", 3}, + ).Spec + + t.Run("read-only phase all objects idle and complete", func(t *testing.T) { + results := []machinery.PhaseResult{ + &fakePhaseResult{name: "phase-1", complete: true}, + &fakePhaseResult{ + name: "phase-2", + complete: true, + objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, paused: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: true, paused: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: true, paused: true, action: machinery.ActionIdle}, + }, + }, + } + observedPhases := []ocv1.ObservedPhase{ + {Name: "phase-1"}, + {Name: "phase-2"}, + } + buildObservedPhases(spec.Phases, results, &observedPhases) + require.Len(t, observedPhases, 2) + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 3, Synced: 3, Ready: 3}, observedPhases[1].ObjectCounts) + }) + + t.Run("read-only phase with objects needing updates", func(t *testing.T) { + results := []machinery.PhaseResult{ + &fakePhaseResult{name: "phase-1", complete: true}, + &fakePhaseResult{ + name: "phase-2", + complete: false, + objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, paused: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: false, paused: true, action: machinery.ActionUpdated}, + &fakeObjectResult{obj: obj, complete: false, paused: true, action: machinery.ActionCreated}, + }, + }, + } + observedPhases := []ocv1.ObservedPhase{ + {Name: "phase-1"}, + {Name: "phase-2"}, + } + buildObservedPhases(spec.Phases, results, &observedPhases) + require.Len(t, observedPhases, 2) + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2, Synced: 1, Ready: 1}, observedPhases[1].ObjectCounts) + }) + + t.Run("active phase synced but one probe failing reports WaitingForAssertions", func(t *testing.T) { + results := []machinery.PhaseResult{ + &fakePhaseResult{ + name: "phase-1", + complete: false, + objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionUpdated}, + }, + }, + } + observedPhases := []ocv1.ObservedPhase{ + {Name: "phase-1"}, + } + buildObservedPhases(spec.Phases, results, &observedPhases) + require.Len(t, observedPhases, 2) + assert.Equal(t, ocv1.ObjectCounts{Total: 2, Present: 2, Synced: 2, Ready: 1}, observedPhases[0].ObjectCounts) + }) + + t.Run("active phase fully synced but probes failing", func(t *testing.T) { + results := []machinery.PhaseResult{ + &fakePhaseResult{ + name: "phase-1", + complete: false, + objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionIdle}, + }, + }, + } + observedPhases := []ocv1.ObservedPhase{ + {Name: "phase-1"}, + } + buildObservedPhases(spec.Phases, results, &observedPhases) + require.Len(t, observedPhases, 2) + assert.Equal(t, ocv1.ObjectCounts{Total: 2, Present: 2, Synced: 2, Ready: 1}, observedPhases[0].ObjectCounts) + }) + + t.Run("complete phase has all counts equal to total", func(t *testing.T) { + results := []machinery.PhaseResult{ + &fakePhaseResult{name: "phase-1", complete: true}, + } + observedPhases := []ocv1.ObservedPhase{ + {Name: "phase-1"}, + } + buildObservedPhases(spec.Phases, results, &observedPhases) + require.Len(t, observedPhases, 2) + assert.Equal(t, ocv1.ObjectCounts{Total: 2, Present: 2, Synced: 2, Ready: 2}, observedPhases[0].ObjectCounts) + }) + + t.Run("unknown phase has total but zero synced available and present", func(t *testing.T) { + observedPhases := []ocv1.ObservedPhase{ + {Name: "phase-1"}, + } + buildObservedPhases(spec.Phases, nil, &observedPhases) + require.Len(t, observedPhases, 2) + assert.Equal(t, ocv1.ObjectCounts{Total: 2}, observedPhases[0].ObjectCounts) + }) +} diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 1ab1aa4887..b0b7e99f30 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -2038,6 +2038,46 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + objectCounts: + description: |- + objectCounts reports aggregate object counts across all phases. + Values are sums of the per-phase counts in observedPhases. + properties: + present: + description: |- + present is the number of objects in this phase that exist on the + cluster. During reconcile, this counts objects that the controller + has found or created. During teardown, this decrements toward zero + as objects are deleted. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + ready: + description: |- + ready is the number of objects in this phase that are synced + and pass their assertions. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + synced: + description: |- + synced is the number of objects in this phase whose cluster state + matches the desired state. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + total: + description: total is the number of objects in this phase. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + required: + - total + type: object observedPhases: description: |- observedPhases records the content hashes of resolved phases @@ -2057,6 +2097,8 @@ spec: minLength: 1 type: string x-kubernetes-validations: + - message: digest is immutable + rule: self == oldSelf || oldSelf.size() == 0 - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') name: @@ -2069,18 +2111,55 @@ spec: characters and hyphens, and must start and end with an alphanumeric character. rule: '!format.dns1123Label().validate(self).hasValue()' + objectCounts: + description: objectCounts reports the number of objects in this + phase by state. + properties: + present: + description: |- + present is the number of objects in this phase that exist on the + cluster. During reconcile, this counts objects that the controller + has found or created. During teardown, this decrements toward zero + as objects are deleted. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + ready: + description: |- + ready is the number of objects in this phase that are synced + and pass their assertions. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + synced: + description: |- + synced is the number of objects in this phase whose cluster state + matches the desired state. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + total: + description: total is the number of objects in this phase. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + required: + - total + type: object required: - digest - name + - objectCounts type: object maxItems: 20 type: array x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 type: object x-kubernetes-validations: - message: completedAt cannot be removed once set diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index ffa7dab444..70f956f538 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1999,6 +1999,46 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + objectCounts: + description: |- + objectCounts reports aggregate object counts across all phases. + Values are sums of the per-phase counts in observedPhases. + properties: + present: + description: |- + present is the number of objects in this phase that exist on the + cluster. During reconcile, this counts objects that the controller + has found or created. During teardown, this decrements toward zero + as objects are deleted. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + ready: + description: |- + ready is the number of objects in this phase that are synced + and pass their assertions. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + synced: + description: |- + synced is the number of objects in this phase whose cluster state + matches the desired state. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + total: + description: total is the number of objects in this phase. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + required: + - total + type: object observedPhases: description: |- observedPhases records the content hashes of resolved phases @@ -2018,6 +2058,8 @@ spec: minLength: 1 type: string x-kubernetes-validations: + - message: digest is immutable + rule: self == oldSelf || oldSelf.size() == 0 - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') name: @@ -2030,18 +2072,55 @@ spec: characters and hyphens, and must start and end with an alphanumeric character. rule: '!format.dns1123Label().validate(self).hasValue()' + objectCounts: + description: objectCounts reports the number of objects in this + phase by state. + properties: + present: + description: |- + present is the number of objects in this phase that exist on the + cluster. During reconcile, this counts objects that the controller + has found or created. During teardown, this decrements toward zero + as objects are deleted. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + ready: + description: |- + ready is the number of objects in this phase that are synced + and pass their assertions. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + synced: + description: |- + synced is the number of objects in this phase whose cluster state + matches the desired state. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + total: + description: total is the number of objects in this phase. + format: int64 + maximum: 1000 + minimum: 0 + type: integer + required: + - total + type: object required: - digest - name + - objectCounts type: object maxItems: 20 type: array x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 type: object x-kubernetes-validations: - message: completedAt cannot be removed once set diff --git a/test/e2e/features/revision.feature b/test/e2e/features/revision.feature index a9185b73a5..78cd131fb7 100644 --- a/test/e2e/features/revision.feature +++ b/test/e2e/features/revision.feature @@ -704,3 +704,81 @@ Feature: Install ClusterObjectSet """ Then ClusterObjectSet "${COS_NAME}" reports Ready as False with Reason ProgressDeadlineExceeded And ClusterObjectSet "${COS_NAME}" reports Ready as True with Reason AllObjectsReady + + Scenario: Report Object Counts for managed workloads + Given namespace "${TEST_NAMESPACE}" is available + When ClusterObjectSet is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterObjectSet + metadata: + name: ${COS_NAME} + spec: + lifecycleState: Active + collisionProtection: Prevent + progressionProbes: + - selector: + type: GroupKind + groupKind: + group: apps + kind: Deployment + assertions: + - type: ConditionEqual + conditionEqual: + type: Available + status: "True" + phases: + - name: workload + objects: + - object: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: test-deployment + namespace: ${TEST_NAMESPACE} + spec: + replicas: 1 + selector: + matchLabels: + app: workload + template: + metadata: + labels: + app: workload + spec: + containers: + - name: workload + image: busybox:1.36 + imagePullPolicy: IfNotPresent + command: ["sleep", "1000"] + readinessProbe: + exec: + command: ["true"] + initialDelaySeconds: 120 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + - name: config + objects: + - object: + apiVersion: v1 + kind: ConfigMap + metadata: + name: test-configmap + namespace: ${TEST_NAMESPACE} + data: + key: value + revision: 1 + """ + Then resource "deployment/test-deployment" is installed + And ClusterObjectSet "${COS_NAME}" reports phase "workload" object counts: total=1, present=1, synced=1, ready=0 + And ClusterObjectSet "${COS_NAME}" reports phase "config" object counts: total=1, present=0, synced=0, ready=0 + And ClusterObjectSet "${COS_NAME}" reports aggregate object counts: total=2, present=1, synced=1, ready=0 + And ClusterObjectSet "${COS_NAME}" reports Ready as True with Reason AllObjectsReady + And ClusterObjectSet "${COS_NAME}" reports phase "workload" object counts: total=1, present=1, synced=1, ready=1 + And ClusterObjectSet "${COS_NAME}" reports phase "config" object counts: total=1, present=1, synced=1, ready=1 + And ClusterObjectSet "${COS_NAME}" reports aggregate object counts: total=2, present=2, synced=2, ready=2 diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 330dffc41e..a247354454 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -132,6 +132,8 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reconciliation is triggered$`, TriggerClusterObjectSetReconciliation) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has observed phase "([^"]+)" with a non-empty digest$`, ClusterObjectSetHasObservedPhase) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" is archived$`, ClusterObjectSetIsArchived) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports phase "([^"]+)" object counts: total=(\d+), present=(\d+), synced=(\d+), ready=(\d+)$`, ClusterObjectSetPhaseHasObjectCounts) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports aggregate object counts: total=(\d+), present=(\d+), synced=(\d+), ready=(\d+)$`, ClusterObjectSetHasAggregateObjectCounts) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" contains annotation "([^"]+)" with value$`, ClusterObjectSetHasAnnotationWithValue) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has label "([^"]+)" with value "([^"]+)"$`, ClusterObjectSetHasLabelWithValue) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" phase objects are not found or not owned by the revision$`, ClusterObjectSetObjectsNotFoundOrNotOwned) @@ -980,6 +982,56 @@ func ClusterObjectSetIsArchived(ctx context.Context, revisionName string) error return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), "Ready", "False", ptr.To("Archived"), nil) } +// ClusterObjectSetPhaseHasObjectCounts waits for a specific phase of the named ClusterObjectSet to +// report exact object counts for total, present, synced, and available. Polls with timeout. +func ClusterObjectSetPhaseHasObjectCounts(ctx context.Context, cosName, phaseName string, total, present, synced, ready int64) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + phaseName = substituteScenarioVars(phaseName, sc) + waitFor(ctx, func() bool { + out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", "json") + if err != nil { + return false + } + var cos ocv1.ClusterObjectSet + if err := json.Unmarshal([]byte(out), &cos); err != nil { + return false + } + for _, phase := range cos.Status.ObservedPhases { + if phase.Name == phaseName { + return phase.ObjectCounts.Total == total && + phase.ObjectCounts.Present == present && + phase.ObjectCounts.Synced == synced && + phase.ObjectCounts.Ready == ready + } + } + return false + }) + return nil +} + +// ClusterObjectSetHasAggregateObjectCounts waits for the named ClusterObjectSet to report exact +// aggregate object counts (summed across all phases). Polls with timeout. +func ClusterObjectSetHasAggregateObjectCounts(ctx context.Context, cosName string, total, present, synced, ready int64) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + waitFor(ctx, func() bool { + out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", "json") + if err != nil { + return false + } + var cos ocv1.ClusterObjectSet + if err := json.Unmarshal([]byte(out), &cos); err != nil { + return false + } + return cos.Status.ObjectCounts.Total == total && + cos.Status.ObjectCounts.Present == present && + cos.Status.ObjectCounts.Synced == synced && + cos.Status.ObjectCounts.Ready == ready + }) + return nil +} + // ClusterObjectSetHasAnnotationWithValue waits for the named ClusterObjectSet to have the specified // annotation with the expected value. Polls with timeout. func ClusterObjectSetHasAnnotationWithValue(ctx context.Context, revisionName, annotationKey string, annotationValue *godog.DocString) error {