From 31781d46365af11f586d225919abb7763071ac96 Mon Sep 17 00:00:00 2001 From: Daniel Franz Date: Wed, 21 Oct 2026 14:14:14 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20enrich=20ObservedPhase=20with=20Sta?= =?UTF-8?q?tus,=20CompletedAt,=20Message,=20and=20PhaseStatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds phase-level status tracking to ObservedPhase: - Status (PhaseStatus enum): InvalidSpecification, PreconditionsNotMet, Pending, Reconciling, Stabilizing, Ready, Unknown, Superseded, Archiving, Archived - CompletedAt: timestamp of first Available transition - Message: phase-level human-readable context - LastTransitionTime: marks time of last phase status change Introduces the PhaseStatus type and new Ready-condition reason constants. Updates status.go to compute and populate all three fields on every reconcile and teardown pass. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- api/v1/clusterobjectset_types.go | 89 ++- api/v1/validation_test.go | 8 +- api/v1/zz_generated.deepcopy.go | 6 +- applyconfigurations/api/v1/objectcounts.go | 8 +- applyconfigurations/api/v1/observedphase.go | 54 +- applyconfigurations/internal/internal.go | 14 + docs/api-reference/olmv1-api-reference.md | 2 + ...peratorframework.io_clusterobjectsets.yaml | 67 +- .../clusterobjectset_controller.go | 21 +- internal/object-controller/status/status.go | 300 +++++---- .../object-controller/status/status_test.go | 615 +++++++++++++----- manifests/experimental-e2e.yaml | 67 +- manifests/experimental.yaml | 67 +- 13 files changed, 960 insertions(+), 358 deletions(-) diff --git a/api/v1/clusterobjectset_types.go b/api/v1/clusterobjectset_types.go index 377aa81633..7a933208fd 100644 --- a/api/v1/clusterobjectset_types.go +++ b/api/v1/clusterobjectset_types.go @@ -557,10 +557,11 @@ type ClusterObjectSetStatus struct { // // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf == null",message="completedAt is immutable" // +optional - CompletedAt metav1.Time `json:"completedAt,omitempty,omitzero"` + CompletedAt metav1.Time `json:"completedAt,omitzero"` } // ObservedPhase records the observed content digest of a resolved phase. +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.completedAt) || has(self.completedAt)",message="completedAt cannot be removed once set" type ObservedPhase struct { // name is the phase name matching a phase in spec.phases. // @@ -570,6 +571,28 @@ type ObservedPhase struct { // +kubebuilder:validation:XValidation:rule=`!format.dns1123Label().validate(self).hasValue()`,message="the value must consist of only lowercase alphanumeric characters and hyphens, and must start and end with an alphanumeric character." Name string `json:"name"` + // status is the current state of this phase in the rollout. Must be one of + // InvalidSpecification, PreconditionsNotMet, Pending, Reconciling, + // Stabilizing, Ready, Unknown, Superseded, Archiving, or Archived. + // +required + Status PhaseStatus `json:"status"` + + // completedAt is the timestamp when this phase first became Ready. + // Set once and never cleared. Nil means the phase has never been + // Ready. + // +optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf == null",message="completedAt is immutable" + CompletedAt metav1.Time `json:"completedAt,omitzero"` + + // message is a phase-level message providing context about the + // current status (e.g. a validation error for Invalid phases, or + // a summary for Pending phases). At most 1024 characters; longer + // messages are truncated by the controller. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + // +optional + Message string `json:"message,omitempty"` + // digest is the digest of the phase's resolved object content // at first successful resolution, in the format ":". // @@ -581,10 +604,14 @@ type ObservedPhase struct { Digest string `json:"digest"` // 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). // +optional ObjectCounts ObjectCounts `json:"objectCounts,omitzero"` + + // lastTransitionTime is the last time the phase transitioned from one status to another. + // +required + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Format=date-time + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitzero"` } // ObjectCounts reports the number of objects in a ClusterObjectSet or phase by state. @@ -596,16 +623,16 @@ type ObjectCounts struct { 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. + // cluster and are owned by this object set, or another in the same group. + // During reconcile, this counts objects that the controller has found or + // created. During teardown, this decrements toward zero as objects are deleted. // +required // +kubebuilder:validation:Minimum:=0 // +kubebuilder:validation:Maximum:=1000 Present int64 `json:"present"` // synced is the number of objects in this phase whose cluster state - // matches the desired state. + // matches the desired state. Set to zero during teardown. // +required // +kubebuilder:validation:Minimum:=0 // +kubebuilder:validation:Maximum:=1000 @@ -619,6 +646,54 @@ type ObjectCounts struct { Ready int64 `json:"ready"` } +// PhaseStatus describes the current state of a phase in the rollout. +// +// +kubebuilder:validation:Enum=InvalidSpecification;Pending;Reconciling;Stabilizing;Ready;Unknown;Superseded;Archiving;Archived +type PhaseStatus string + +const ( + // PhaseStatusInvalidSpecification indicates the phase failed preflight validation + // in a permanent way (e.g. cross-phase duplication). + PhaseStatusInvalidSpecification PhaseStatus = "InvalidSpecification" + + // PhaseStatusPending indicates the phase is not being actively + // reconciled, but the controller has checked and some objects do not + // match the desired state. The objectDetails field lists what would + // need to change. + PhaseStatusPending PhaseStatus = "Pending" + + // PhaseStatusReconciling indicates the controller is actively applying + // objects in this phase and not all objects are synced yet. + PhaseStatusReconciling PhaseStatus = "Reconciling" + + // PhaseStatusStabilizing indicates all objects in this phase are + // synced but some assertions are not yet passing. The controller is + // not actively writing; it is waiting for other controllers or + // external actions to bring the objects into the expected state. + PhaseStatusStabilizing PhaseStatus = "Stabilizing" + + // PhaseStatusReady indicates all objects in this phase are synced + // and pass their assertions. + PhaseStatusReady PhaseStatus = "Ready" + + // PhaseStatusUnknown indicates this phase was not evaluated during + // the most recent reconcile. + PhaseStatusUnknown PhaseStatus = "Unknown" + + // PhaseStatusSuperseded indicates all objects in this phase have been + // adopted by a newer revision. + PhaseStatusSuperseded PhaseStatus = "Superseded" + + // PhaseStatusArchiving indicates the controller is actively deleting + // objects in this phase. Objects still awaiting deletion are listed + // in objectDetails. + PhaseStatusArchiving PhaseStatus = "Archiving" + + // PhaseStatusArchived indicates all objects in this phase have + // been deleted from the cluster. + PhaseStatusArchived PhaseStatus = "Archived" +) + // TODO(COD): Update the archival description below when archival decisions move // to ClusterObjectDeployment reconciliation. diff --git a/api/v1/validation_test.go b/api/v1/validation_test.go index 1e7ce6e034..f10c661aac 100644 --- a/api/v1/validation_test.go +++ b/api/v1/validation_test.go @@ -257,9 +257,11 @@ func TestClusterObjectSetDigestImmutable(t *testing.T) { // A phase digest may be set once, from empty. cos.Status.ObservedPhases = []ObservedPhase{ { - Name: "deploy", - Digest: "sha256:abc123", - ObjectCounts: ObjectCounts{Total: 1}, + Name: "deploy", + Digest: "sha256:abc123", + ObjectCounts: ObjectCounts{Total: 1}, + Status: PhaseStatusReady, + LastTransitionTime: metav1.Now(), }, } require.NoError(t, c.Status().Update(t.Context(), cos)) diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 0f679a1384..2917b211be 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -706,7 +706,9 @@ func (in *ClusterObjectSetStatus) DeepCopyInto(out *ClusterObjectSetStatus) { if in.ObservedPhases != nil { in, out := &in.ObservedPhases, &out.ObservedPhases *out = make([]ObservedPhase, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } in.CompletedAt.DeepCopyInto(&out.CompletedAt) } @@ -836,7 +838,9 @@ 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 + in.CompletedAt.DeepCopyInto(&out.CompletedAt) out.ObjectCounts = in.ObjectCounts + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedPhase. diff --git a/applyconfigurations/api/v1/objectcounts.go b/applyconfigurations/api/v1/objectcounts.go index 0d00f2380c..8ca1983829 100644 --- a/applyconfigurations/api/v1/objectcounts.go +++ b/applyconfigurations/api/v1/objectcounts.go @@ -26,12 +26,12 @@ 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. + // cluster and are owned by this object set, or another in the same group. + // 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. + // matches the desired state. Set to zero during teardown. Synced *int64 `json:"synced,omitempty"` // ready is the number of objects in this phase that are synced // and pass their assertions. diff --git a/applyconfigurations/api/v1/observedphase.go b/applyconfigurations/api/v1/observedphase.go index 2e97961e79..163e53390a 100644 --- a/applyconfigurations/api/v1/observedphase.go +++ b/applyconfigurations/api/v1/observedphase.go @@ -18,6 +18,11 @@ limitations under the License. package v1 +import ( + apiv1 "github.com/operator-framework/operator-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + // ObservedPhaseApplyConfiguration represents a declarative configuration of the ObservedPhase type for use // with apply. // @@ -25,13 +30,26 @@ package v1 type ObservedPhaseApplyConfiguration struct { // name is the phase name matching a phase in spec.phases. Name *string `json:"name,omitempty"` + // status is the current state of this phase in the rollout. Must be one of + // InvalidSpecification, PreconditionsNotMet, Pending, Reconciling, + // Stabilizing, Ready, Unknown, Superseded, Archiving, or Archived. + Status *apiv1.PhaseStatus `json:"status,omitempty"` + // completedAt is the timestamp when this phase first became Ready. + // Set once and never cleared. Nil means the phase has never been + // Ready. + CompletedAt *metav1.Time `json:"completedAt,omitempty"` + // message is a phase-level message providing context about the + // current status (e.g. a validation error for Invalid phases, or + // a summary for Pending phases). At most 1024 characters; longer + // messages are truncated by the controller. + Message *string `json:"message,omitempty"` // 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. - // It is omitted until counts have been computed; when present, all count - // fields are reported (including zeros). ObjectCounts *ObjectCountsApplyConfiguration `json:"objectCounts,omitempty"` + // lastTransitionTime is the last time the phase transitioned from one status to another. + LastTransitionTime *metav1.Time `json:"lastTransitionTime,omitempty"` } // ObservedPhaseApplyConfiguration constructs a declarative configuration of the ObservedPhase type for use with @@ -48,6 +66,30 @@ func (b *ObservedPhaseApplyConfiguration) WithName(value string) *ObservedPhaseA return b } +// WithStatus sets the Status 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 Status field is set to the value of the last call. +func (b *ObservedPhaseApplyConfiguration) WithStatus(value apiv1.PhaseStatus) *ObservedPhaseApplyConfiguration { + b.Status = &value + return b +} + +// WithCompletedAt sets the CompletedAt 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 CompletedAt field is set to the value of the last call. +func (b *ObservedPhaseApplyConfiguration) WithCompletedAt(value metav1.Time) *ObservedPhaseApplyConfiguration { + b.CompletedAt = &value + return b +} + +// WithMessage sets the Message 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 Message field is set to the value of the last call. +func (b *ObservedPhaseApplyConfiguration) WithMessage(value string) *ObservedPhaseApplyConfiguration { + b.Message = &value + return b +} + // WithDigest sets the Digest 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 Digest field is set to the value of the last call. @@ -63,3 +105,11 @@ func (b *ObservedPhaseApplyConfiguration) WithObjectCounts(value *ObjectCountsAp b.ObjectCounts = value return b } + +// WithLastTransitionTime sets the LastTransitionTime 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 LastTransitionTime field is set to the value of the last call. +func (b *ObservedPhaseApplyConfiguration) WithLastTransitionTime(value metav1.Time) *ObservedPhaseApplyConfiguration { + b.LastTransitionTime = &value + return b +} diff --git a/applyconfigurations/internal/internal.go b/applyconfigurations/internal/internal.go index 0c03ea2e92..fc6e4af67b 100644 --- a/applyconfigurations/internal/internal.go +++ b/applyconfigurations/internal/internal.go @@ -494,15 +494,29 @@ var schemaYAML = typed.YAMLObject(`types: - name: com.github.operator-framework.operator-controller.api.v1.ObservedPhase map: fields: + - name: completedAt + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time - name: digest type: scalar: string + - name: lastTransitionTime + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time + - name: message + type: + scalar: string - name: name type: scalar: string - name: objectCounts type: namedType: com.github.operator-framework.operator-controller.api.v1.ObjectCounts + - name: status + type: + namedType: com.github.operator-framework.operator-controller.api.v1.PhaseStatus +- name: com.github.operator-framework.operator-controller.api.v1.PhaseStatus + scalar: string - name: com.github.operator-framework.operator-controller.api.v1.PreflightConfig map: fields: diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 8d2df41777..f16a94af18 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -668,6 +668,8 @@ _Appears in:_ | `key` _string_ | key is the data key within the referenced Secret containing the
object manifest content. The value at this key must be a
JSON-serialized Kubernetes object manifest. | | MaxLength: 253
MinLength: 1
Required: \{\}
| + + #### PreflightConfig 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 2c8bd4ab83..44003da60f 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 @@ -679,9 +679,9 @@ spec: 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. + cluster and are owned by this object set, or another in the same group. + 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 @@ -697,7 +697,7 @@ spec: synced: description: |- synced is the number of objects in this phase whose cluster state - matches the desired state. + matches the desired state. Set to zero during teardown. format: int64 maximum: 1000 minimum: 0 @@ -725,6 +725,16 @@ spec: description: ObservedPhase records the observed content digest of a resolved phase. properties: + completedAt: + description: |- + completedAt is the timestamp when this phase first became Ready. + Set once and never cleared. Nil means the phase has never been + Ready. + format: date-time + type: string + x-kubernetes-validations: + - message: completedAt is immutable + rule: self == oldSelf || oldSelf == null digest: description: |- digest is the digest of the phase's resolved object content @@ -737,6 +747,20 @@ spec: rule: self == oldSelf || oldSelf.size() == 0 - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') + lastTransitionTime: + description: lastTransitionTime is the last time the phase transitioned + from one status to another. + format: date-time + type: string + message: + description: |- + message is a phase-level message providing context about the + current status (e.g. a validation error for Invalid phases, or + a summary for Pending phases). At most 1024 characters; longer + messages are truncated by the controller. + maxLength: 1024 + minLength: 1 + type: string name: description: name is the phase name matching a phase in spec.phases. maxLength: 63 @@ -748,17 +772,15 @@ spec: character. rule: '!format.dns1123Label().validate(self).hasValue()' objectCounts: - 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). + 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. + cluster and are owned by this object set, or another in the same group. + 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 @@ -774,7 +796,7 @@ spec: synced: description: |- synced is the number of objects in this phase whose cluster state - matches the desired state. + matches the desired state. Set to zero during teardown. format: int64 maximum: 1000 minimum: 0 @@ -791,10 +813,31 @@ spec: - synced - total type: object + status: + description: |- + status is the current state of this phase in the rollout. Must be one of + InvalidSpecification, PreconditionsNotMet, Pending, Reconciling, + Stabilizing, Ready, Unknown, Superseded, Archiving, or Archived. + enum: + - InvalidSpecification + - Pending + - Reconciling + - Stabilizing + - Ready + - Unknown + - Superseded + - Archiving + - Archived + type: string required: - digest + - lastTransitionTime - name + - status type: object + x-kubernetes-validations: + - message: completedAt cannot be removed once set + rule: '!has(oldSelf.completedAt) || has(self.completedAt)' maxItems: 20 type: array x-kubernetes-list-map-keys: diff --git a/internal/object-controller/controllers/clusterobjectset_controller.go b/internal/object-controller/controllers/clusterobjectset_controller.go index 89174880ff..a867867b30 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller.go +++ b/internal/object-controller/controllers/clusterobjectset_controller.go @@ -243,8 +243,10 @@ 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) + reconcileTime := metav1.NewTime(c.Clock.Now()) + // Set object counts and phase status + // TODO: refactor and move condition setting operations into status pkg + status.UpdateObservedPhasesForReconcile(cos, rres, reconcileTime) // 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 @@ -344,7 +346,7 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl // It also serves as the signal that the revision has completed its rollout, // which the ClusterExtension controller uses to determine the installed revision. if cos.Status.CompletedAt.IsZero() { - cos.Status.CompletedAt = metav1.NewTime(c.Clock.Now()) + cos.Status.CompletedAt = reconcileTime } } else { var notReadyMsgs []string @@ -421,8 +423,8 @@ func (c *ClusterObjectSetReconciler) archive(ctx context.Context, revisionEngine // the error for rate-limited backoff. return ctrl.Result{}, fmt.Errorf("error archiving revision: %v", err) } - // Set object counts - status.FromTeardown(cos, tdres) + teardownTime := metav1.NewTime(c.Clock.Now()) + status.UpdateObservedPhasesForTeardown(cos, tdres, teardownTime) if tdres != nil && !tdres.IsComplete() { markAsArchiving(cos) return ctrl.Result{RequeueAfter: 5 * time.Second}, nil @@ -642,7 +644,14 @@ func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, c if err != nil { return nil, nil, nil, fmt.Errorf("computing phase digest: %w", err) } - observedPhases = append(observedPhases, ocv1.ObservedPhase{Name: specPhase.Name, Digest: digest}) + // Make sure we set all the required fields + observedPhases = append(observedPhases, ocv1.ObservedPhase{ + Name: specPhase.Name, + Digest: digest, + Status: ocv1.PhaseStatusUnknown, + LastTransitionTime: metav1.NewTime(c.Clock.Now()), + Message: "phase has not been evaluated", + }) // Managed objects are labeled app.kubernetes.io/managed-by by the boxcutter // revision engine; the controller does not add owner labels of its own. diff --git a/internal/object-controller/status/status.go b/internal/object-controller/status/status.go index b984c721eb..26ab713781 100644 --- a/internal/object-controller/status/status.go +++ b/internal/object-controller/status/status.go @@ -1,6 +1,9 @@ package status import ( + "slices" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "pkg.package-operator.run/boxcutter/machinery" ocv1 "github.com/operator-framework/operator-controller/api/v1" @@ -24,174 +27,199 @@ func sumObjectCounts(phases []ocv1.ObservedPhase) ocv1.ObjectCounts { 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 { +func UpdateObservedPhasesForReconcile(objSet *ocv1.ClusterObjectSet, rres machinery.RevisionResult, now metav1.Time) { + if objSet == nil || rres == 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 { + if rres.GetValidationError() != 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 -} + // Calculate totals from the spec + phaseTotals := make(map[string]int64) + for _, phase := range objSet.Spec.Phases { + phaseTotals[phase.Name] = int64(len(phase.Objects)) + } + + // Phases are reconciled in order; once a phase is incomplete, later phases are + // only observed read-only (paused). A paused phase's preflight dry-run can fail + // for reasons that resolve once earlier phases roll out (e.g. a Namespace a + // later phase depends on has not been created yet), so its validation error is + // not actionable yet. Only treat a phase's validation error as a terminal + // validation failure while every preceding phase is complete; otherwise skip it + // and let the rollout status (and the progress deadline) below take over. + // Collisions are still reported for paused phases below: an object owned by + // another controller does not become ownable just because earlier phases roll + // out, so it remains actionable regardless of phase ordering. + precedingPhasesComplete := true + for _, pres := range rres.GetPhases() { + observedPhase := ocv1.ObservedPhase{ + Name: pres.GetName(), + } -// 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, - ) -} + if pres.GetValidationError() != nil { + if !precedingPhasesComplete { + observedPhase.Status = ocv1.PhaseStatusPending + observedPhase.Message = "waiting for earlier phases to complete" + } else { + observedPhase.Status = ocv1.PhaseStatusInvalidSpecification + observedPhase.Message = truncateMessage(pres.GetValidationError().Error()) + } + // A validation-error phase is not complete; record that for subsequent phases. + precedingPhasesComplete = false + updateObservedPhaseStatus(objSet, observedPhase, now) + continue + } -// 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 !pres.IsComplete() { + precedingPhasesComplete = false } - if obj.IsPaused() { - if obj.Action() == machinery.ActionIdle { - observedPhase.ObjectCounts.Synced++ + + isPaused := false + observedPhase.ObjectCounts.Total = phaseTotals[pres.GetName()] + for _, pobj := range pres.GetObjects() { + if pobj.IsComplete() { + observedPhase.ObjectCounts.Ready++ } - } else { - switch obj.Action() { - case machinery.ActionIdle, machinery.ActionUpdated, machinery.ActionCreated, machinery.ActionRecovered: + switch pobj.Action() { + case machinery.ActionCreated: + if !pobj.IsPaused() { + observedPhase.ObjectCounts.Present++ + observedPhase.ObjectCounts.Synced++ + } + case machinery.ActionIdle: + observedPhase.ObjectCounts.Present++ observedPhase.ObjectCounts.Synced++ + case machinery.ActionProgressed: + observedPhase.ObjectCounts.Present++ + case machinery.ActionUpdated, machinery.ActionRecovered: + observedPhase.ObjectCounts.Present++ + if !pobj.IsPaused() { + observedPhase.ObjectCounts.Synced++ + } + case machinery.ActionCollision: + // TODO: collect object collisions and put in status + } + + // track for phase status derivation + if pobj.IsPaused() { + isPaused = true } } - if obj.IsComplete() { - observedPhase.ObjectCounts.Ready++ + + var ( + // TODO: consider reframing as one or more objects have transitioned (rather than all - since there can be differences b/w the revisions) + isSuperseded = pres.HasProgressed() + isReady = pres.IsComplete() && !isSuperseded + isSynced = observedPhase.ObjectCounts.Synced == observedPhase.ObjectCounts.Total + isReconciling = !isSynced && !isPaused + ) + + switch { + case isReady: + observedPhase.Status = ocv1.PhaseStatusReady + observedPhase.Message = "all objects are ready" + case isSuperseded: + observedPhase.Status = ocv1.PhaseStatusSuperseded + observedPhase.Message = "all objects have progressed to a new revision" + case isSynced: + // TODO: collect assertion failures and add to status + observedPhase.Status = ocv1.PhaseStatusStabilizing + observedPhase.Message = "phase is waiting for objects to reach the desired state" + case isReconciling: + // TODO: deal with surfacing collisions + // TODO: reconsider splitting into the cases where reconciling might appear (e.g. ObjectCollision, objects are transitioning to another revision, etc.) + observedPhase.Status = ocv1.PhaseStatusReconciling + observedPhase.Message = "phase is being reconciled" + default: + // read-only while an earlier phase is still rolling out. + observedPhase.Status = ocv1.PhaseStatusPending + observedPhase.Message = "waiting for previous phases to be ready" } + updateObservedPhaseStatus(objSet, observedPhase, now) } + objSet.Status.ObjectCounts = sumObjectCounts(objSet.Status.ObservedPhases) } -// 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 { +func UpdateObservedPhasesForTeardown(objSet *ocv1.ClusterObjectSet, tres machinery.RevisionTeardownResult, now metav1.Time) { + if objSet == nil || tres == 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 + // Calculate totals from the spec + phaseTotals := make(map[string]int64) + for _, phase := range objSet.Spec.Phases { + phaseTotals[phase.Name] = int64(len(phase.Objects)) } - 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, - ) -} + for _, pres := range tres.GetPhases() { + observedPhase := ocv1.ObservedPhase{ + Name: pres.GetName(), + } + + observedPhase.ObjectCounts.Total = phaseTotals[pres.GetName()] + observedPhase.ObjectCounts.Present = int64(len(pres.Waiting())) + observedPhase.ObjectCounts.Synced = 0 -// 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())), + if name, ok := tres.GetActivePhaseName(); ok && name == pres.GetName() { + observedPhase.Status = ocv1.PhaseStatusArchiving + observedPhase.Message = "removing all phase objects owned by this object set" + } else if slices.Contains(tres.GetGonePhaseNames(), pres.GetName()) { + observedPhase.Status = ocv1.PhaseStatusArchived + observedPhase.Message = "all phase objects owned by this object set have been removed" + } else { + observedPhase.Status = ocv1.PhaseStatusPending + observedPhase.Message = "waiting for later phases to complete teardown" + } + updateObservedPhaseStatus(objSet, observedPhase, now) } + objSet.Status.ObjectCounts = sumObjectCounts(objSet.Status.ObservedPhases) } -// 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 { +func updateObservedPhaseStatus(objSet *ocv1.ClusterObjectSet, observedPhase ocv1.ObservedPhase, now metav1.Time) { + if objSet == 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} + for i, ophase := range objSet.Status.ObservedPhases { + if ophase.Name != observedPhase.Name { continue } - if r.IsComplete() { - objCounts := completeObjectCounts(total) - observedPhasesMap[sp.Name].ObjectCounts = objCounts - continue + // Digest is computed once at resolution time (before controller mutations) + // and is immutable; carry it forward so the full-struct replacement below + // does not drop the required field. + observedPhase.Digest = ophase.Digest + // Preserve CompletedAt once set. + if !ophase.CompletedAt.IsZero() { + observedPhase.CompletedAt = ophase.CompletedAt + } + if observedPhase.Status == ocv1.PhaseStatusReady && observedPhase.CompletedAt.IsZero() { + observedPhase.CompletedAt = now + } + // LastTransitionTime advances only when Status changes. + if ophase.Status != observedPhase.Status { + observedPhase.LastTransitionTime = now + } else { + observedPhase.LastTransitionTime = ophase.LastTransitionTime } - buildIncomplete(sp, r, observedPhasesMap[sp.Name]) + objSet.Status.ObservedPhases[i] = observedPhase + return + } + // First time this phase appears in the status. + if observedPhase.Status == ocv1.PhaseStatusReady && observedPhase.CompletedAt.IsZero() { + observedPhase.CompletedAt = now } + observedPhase.LastTransitionTime = now + objSet.Status.ObservedPhases = append(objSet.Status.ObservedPhases, observedPhase) +} - result := make([]ocv1.ObservedPhase, 0, len(specPhases)) - for _, sp := range specPhases { - if op, ok := observedPhasesMap[sp.Name]; ok { - result = append(result, *op) - } +const maxMessageLength = 1024 + +// truncateMessage truncates s to at most maxMessageLength runes to fit within the CRD limit. +func truncateMessage(s string) string { + r := []rune(s) + if len(r) <= maxMessageLength { + return s } - *observedPhases = result + return string(r[:maxMessageLength]) } diff --git a/internal/object-controller/status/status_test.go b/internal/object-controller/status/status_test.go index b0afef99ad..64d4d47b85 100644 --- a/internal/object-controller/status/status_test.go +++ b/internal/object-controller/status/status_test.go @@ -1,11 +1,14 @@ package status import ( + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "pkg.package-operator.run/boxcutter/machinery" boxcuttertypes "pkg.package-operator.run/boxcutter/machinery/types" @@ -14,72 +17,295 @@ import ( 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) { +func TestUpdateObservedPhasesForReconcile(t *testing.T) { + now := metav1.Now() + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + obj.SetName("my-cm") + + t.Run("revision validation error skips all phase updates", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + cos.Status.ObservedPhases[0].Status = ocv1.PhaseStatusReconciling + result := &fakeRevisionResult{ + validationError: &validation.RevisionValidationError{}, + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: true}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + // phases must not be updated when there is a revision-level validation error + assert.Equal(t, ocv1.PhaseStatusReconciling, cos.Status.ObservedPhases[0].Status) + }) + + t.Run("phase validation error when preceding phases are complete reports InvalidSpecification", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + ve := &validation.PhaseValidationError{} + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: true, validationError: ve}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + assert.Equal(t, ocv1.PhaseStatusInvalidSpecification, cos.Status.ObservedPhases[0].Status) + }) + + t.Run("phase validation error when preceding phases are incomplete reports Pending", func(t *testing.T) { cos := cosWithPhaseObjects( struct { name string objects int }{"p1", 1}, + struct { + name string + objects int + }{"p2", 1}, + ) + ve := &validation.PhaseValidationError{} + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + // p1 is incomplete — this sets precedingPhasesComplete = false + &fakePhaseResult{name: "p1", complete: false, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionIdle}, + }}, + // p2 has a validation error but preceding phase is not complete + &fakePhaseResult{name: "p2", complete: false, validationError: ve}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + assert.Equal(t, ocv1.PhaseStatusPending, cos.Status.ObservedPhases[1].Status) + }) + + t.Run("per-phase progressed marks phase as Superseded", func(t *testing.T) { + cos := cosWithPhaseObjects( + struct { + name string + objects int + }{"p1", 2}, + struct { + name string + objects int + }{"p2", 3}, ) - 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, progressed: true}, + &fakePhaseResult{name: "p2", complete: false, progressed: true}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + require.Len(t, cos.Status.ObservedPhases, 2) + assert.Equal(t, ocv1.PhaseStatusSuperseded, cos.Status.ObservedPhases[0].Status) + assert.Equal(t, int64(2), cos.Status.ObservedPhases[0].ObjectCounts.Total) + assert.Equal(t, ocv1.PhaseStatusSuperseded, cos.Status.ObservedPhases[1].Status) + assert.Equal(t, int64(3), cos.Status.ObservedPhases[1].ObjectCounts.Total) + }) + + t.Run("collision object is not counted as Present and phase reports Reconciling", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + result := &fakeRevisionResult{ phases: []machinery.PhaseResult{ &fakePhaseResult{name: "p1", complete: false, objects: []machinery.ObjectResult{ &fakeCollisionResult{ fakeObjectResult: fakeObjectResult{obj: obj, complete: false, probes: boxcuttertypes.ProbeResultContainer{}}, }, - }, - }, + }}, }, } - FromReconcile(&cos, result) + UpdateObservedPhasesForReconcile(&cos, result, now) require.NotNil(t, cos.Status.ObservedPhases) - assert.Equal(t, ocv1.ObjectCounts{Total: 1, Present: 1}, cos.Status.ObservedPhases[0].ObjectCounts) + assert.Equal(t, ocv1.ObjectCounts{Total: 1}, cos.Status.ObservedPhases[0].ObjectCounts) + assert.Equal(t, ocv1.PhaseStatusReconciling, cos.Status.ObservedPhases[0].Status) + }) + + t.Run("complete phase reports Ready with full counts", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 2}) + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: true, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + }}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + assert.Equal(t, ocv1.PhaseStatusReady, cos.Status.ObservedPhases[0].Status) + assert.Equal(t, ocv1.ObjectCounts{Total: 2, Present: 2, Synced: 2, Ready: 2}, cos.Status.ObservedPhases[0].ObjectCounts) + }) + + t.Run("all idle objects reports Stabilizing", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 2}) + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: false, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionIdle}, + }}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + assert.Equal(t, ocv1.PhaseStatusStabilizing, cos.Status.ObservedPhases[0].Status) + assert.Equal(t, ocv1.ObjectCounts{Total: 2, Present: 2, Synced: 2, Ready: 1}, cos.Status.ObservedPhases[0].ObjectCounts) }) -} -func TestFromTeardown(t *testing.T) { - t.Run("teardown in progress", func(t *testing.T) { + t.Run("write actions (Created, Updated, Recovered) count as Synced for non-paused objects", func(t *testing.T) { + // All three write actions increment Synced for non-paused objects, + // so a freshly-applied phase immediately transitions to Stabilizing. + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 3}) + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: false, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionCreated}, + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionUpdated}, + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionRecovered}, + }}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + assert.Equal(t, ocv1.PhaseStatusStabilizing, cos.Status.ObservedPhases[0].Status) + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 3, Synced: 3}, cos.Status.ObservedPhases[0].ObjectCounts) + }) + + t.Run("paused write actions do not increment Synced, keeping phase Reconciling", func(t *testing.T) { + // ActionUpdated and ActionRecovered on paused objects do not count as Synced; + // paused ActionCreated counts as neither Present nor Synced. + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 3}) + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: false, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: false, paused: true, action: machinery.ActionUpdated}, + &fakeObjectResult{obj: obj, complete: false, paused: true, action: machinery.ActionRecovered}, + &fakeObjectResult{obj: obj, complete: false, paused: true, action: machinery.ActionCreated}, + }}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + // isPaused=true → default case → Pending (read-only while earlier phase is rolling out) + assert.Equal(t, ocv1.PhaseStatusPending, cos.Status.ObservedPhases[0].Status) + // ActionUpdated+paused: Present; ActionRecovered+paused: Present; ActionCreated+paused: nothing + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2, Synced: 0}, cos.Status.ObservedPhases[0].ObjectCounts) + }) + + t.Run("phase with paused objects from blocked preceding phase reports Pending", func(t *testing.T) { cos := cosWithPhaseObjects( struct { name string objects int - }{"p1", 3}, + }{"phase-1", 2}, + struct { + name string + objects int + }{"phase-2", 3}, ) - result := &fakeTeardownResult{ - complete: false, - phases: []machinery.PhaseTeardownResult{ - &fakePhaseTeardownResult{name: "p1", complete: false, waiting: []boxcuttertypes.ObjectRef{{}, {}}}, + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "phase-1", complete: true, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + }}, + &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}, + }}, }, } - FromTeardown(&cos, result) - require.NotNil(t, cos.Status.ObservedPhases) - assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2}, cos.Status.ObservedPhases[0].ObjectCounts) + UpdateObservedPhasesForReconcile(&cos, result, now) + require.Len(t, cos.Status.ObservedPhases, 2) + assert.Equal(t, ocv1.PhaseStatusPending, cos.Status.ObservedPhases[1].Status) + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2, Synced: 1, Ready: 1}, cos.Status.ObservedPhases[1].ObjectCounts) }) - t.Run("teardown complete sets all counts to zero except total", func(t *testing.T) { + t.Run("aggregate ObjectCounts sums across phases", func(t *testing.T) { cos := cosWithPhaseObjects( struct { name string objects int - }{"p1", 5}, + }{"p1", 2}, + struct { + name string + objects int + }{"p2", 3}, ) + result := &fakeRevisionResult{ + phases: []machinery.PhaseResult{ + &fakePhaseResult{name: "p1", complete: true, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + &fakeObjectResult{obj: obj, complete: true, action: machinery.ActionIdle}, + }}, + &fakePhaseResult{name: "p2", complete: false, objects: []machinery.ObjectResult{ + &fakeObjectResult{obj: obj, complete: false, action: machinery.ActionIdle}, + }}, + }, + } + UpdateObservedPhasesForReconcile(&cos, result, now) + // p1: Total=2,Present=2,Synced=2,Ready=2 — p2: Total=3,Present=1,Synced=1,Ready=0 + assert.Equal(t, ocv1.ObjectCounts{Total: 5, Present: 3, Synced: 3, Ready: 2}, cos.Status.ObjectCounts) + }) +} + +func TestUpdateObservedPhasesForTeardown(t *testing.T) { + now := metav1.Now() + + t.Run("active phase gets Archiving status with message", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 3}) result := &fakeTeardownResult{ - complete: true, + active: "p1", phases: []machinery.PhaseTeardownResult{ - &fakePhaseTeardownResult{name: "p1", complete: true}, + &fakePhaseTeardownResult{name: "p1", waiting: []boxcuttertypes.ObjectRef{{}, {}}}, }, } - FromTeardown(&cos, result) - require.NotNil(t, cos.Status.ObservedPhases) - assert.Equal(t, ocv1.ObjectCounts{Total: 5}, cos.Status.ObservedPhases[0].ObjectCounts) + UpdateObservedPhasesForTeardown(&cos, result, now) + require.Len(t, cos.Status.ObservedPhases, 1) + phase := cos.Status.ObservedPhases[0] + assert.Equal(t, ocv1.PhaseStatusArchiving, phase.Status) + assert.Equal(t, "removing all phase objects owned by this object set", phase.Message) + assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2}, phase.ObjectCounts) + }) + + t.Run("gone phase gets Archived status with message", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 5}) + result := &fakeTeardownResult{ + gone: []string{"p1"}, + phases: []machinery.PhaseTeardownResult{ + &fakePhaseTeardownResult{name: "p1"}, + }, + } + UpdateObservedPhasesForTeardown(&cos, result, now) + require.Len(t, cos.Status.ObservedPhases, 1) + phase := cos.Status.ObservedPhases[0] + assert.Equal(t, ocv1.PhaseStatusArchived, phase.Status) + assert.Equal(t, ocv1.ObjectCounts{Total: 5}, phase.ObjectCounts) }) - t.Run("read-only teardown phases included in GetPhases", func(t *testing.T) { + t.Run("multi-phase teardown: status, messages, and aggregate counts", func(t *testing.T) { cos := cosWithPhaseObjects( struct { name string @@ -95,25 +321,193 @@ func TestFromTeardown(t *testing.T) { }{"p3", 4}, ) result := &fakeTeardownResult{ - complete: false, - active: "p2", + active: "p2", + gone: []string{"p3"}, phases: []machinery.PhaseTeardownResult{ - &fakePhaseTeardownResult{name: "p3", complete: true}, - &fakePhaseTeardownResult{name: "p2", complete: false, waiting: []boxcuttertypes.ObjectRef{{}, {}}}, - &fakePhaseTeardownResult{name: "p1", complete: false, waiting: []boxcuttertypes.ObjectRef{{}, {}}}, + &fakePhaseTeardownResult{name: "p3"}, + &fakePhaseTeardownResult{name: "p2", waiting: []boxcuttertypes.ObjectRef{{}, {}}}, + &fakePhaseTeardownResult{name: "p1", waiting: []boxcuttertypes.ObjectRef{{}, {}}}, }, } - FromTeardown(&cos, result) - require.NotNil(t, cos.Status.ObservedPhases) + UpdateObservedPhasesForTeardown(&cos, result, now) + require.Len(t, cos.Status.ObservedPhases, 3) phases := cos.Status.ObservedPhases - require.Len(t, phases, 3) + // p1 — pending, waiting for later phases to complete + assert.Equal(t, ocv1.PhaseStatusPending, phases[0].Status) + assert.Equal(t, "waiting for later phases to complete teardown", phases[0].Message) assert.Equal(t, ocv1.ObjectCounts{Total: 2, Present: 2}, phases[0].ObjectCounts) + // p2 — active teardown + assert.Equal(t, ocv1.PhaseStatusArchiving, phases[1].Status) + assert.Equal(t, "removing all phase objects owned by this object set", phases[1].Message) assert.Equal(t, ocv1.ObjectCounts{Total: 3, Present: 2}, phases[1].ObjectCounts) + // p3 — complete (in GonePhaseNames) + assert.Equal(t, ocv1.PhaseStatusArchived, phases[2].Status) assert.Equal(t, ocv1.ObjectCounts{Total: 4}, phases[2].ObjectCounts) + + // aggregate + assert.Equal(t, ocv1.ObjectCounts{Total: 9, Present: 4}, cos.Status.ObjectCounts) + }) + + t.Run("phase absent from GetPhases retains its previous status", func(t *testing.T) { + cos := cosWithPhaseObjects( + struct { + name string + objects int + }{"p1", 2}, + struct { + name string + objects int + }{"p2", 3}, + ) + // Set a recognisable prior status on p1 + cos.Status.ObservedPhases[0].Status = ocv1.PhaseStatusReconciling + // Only p2 has a result; p1 is absent from GetPhases + result := &fakeTeardownResult{ + active: "p2", + phases: []machinery.PhaseTeardownResult{ + &fakePhaseTeardownResult{name: "p2", waiting: []boxcuttertypes.ObjectRef{{}}}, + }, + } + UpdateObservedPhasesForTeardown(&cos, result, now) + require.Len(t, cos.Status.ObservedPhases, 2) + // p1 not in GetPhases — must not be touched + assert.Equal(t, ocv1.PhaseStatusReconciling, cos.Status.ObservedPhases[0].Status) + }) + + t.Run("CompletedAt is not stamped on non-Ready teardown phases", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 2}) + result := &fakeTeardownResult{ + active: "p1", + phases: []machinery.PhaseTeardownResult{ + &fakePhaseTeardownResult{name: "p1", waiting: []boxcuttertypes.ObjectRef{{}}}, + }, + } + UpdateObservedPhasesForTeardown(&cos, result, now) + assert.True(t, cos.Status.ObservedPhases[0].CompletedAt.IsZero()) + }) + + t.Run("CompletedAt from previous reconcile is preserved through teardown", func(t *testing.T) { + earlier := metav1.NewTime(now.Add(-time.Hour)) + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 2}) + cos.Status.ObservedPhases[0].CompletedAt = earlier + result := &fakeTeardownResult{ + active: "p1", + phases: []machinery.PhaseTeardownResult{ + &fakePhaseTeardownResult{name: "p1", waiting: []boxcuttertypes.ObjectRef{{}}}, + }, + } + UpdateObservedPhasesForTeardown(&cos, result, now) + require.NotNil(t, cos.Status.ObservedPhases[0].CompletedAt) + assert.Equal(t, earlier, cos.Status.ObservedPhases[0].CompletedAt) + }) +} + +func TestUpdateObservedPhaseStatus(t *testing.T) { + fixedNow := metav1.NewTime(time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)) + earlier := metav1.NewTime(fixedNow.Add(-time.Hour)) + + t.Run("sets CompletedAt on first Ready status", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + updateObservedPhaseStatus(&cos, ocv1.ObservedPhase{Name: "p1", Status: ocv1.PhaseStatusReady}, fixedNow) + require.NotNil(t, cos.Status.ObservedPhases[0].CompletedAt) + assert.Equal(t, fixedNow, cos.Status.ObservedPhases[0].CompletedAt) + }) + + t.Run("does not set CompletedAt on non-Ready status", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + updateObservedPhaseStatus(&cos, ocv1.ObservedPhase{Name: "p1", Status: ocv1.PhaseStatusReconciling}, fixedNow) + assert.True(t, cos.Status.ObservedPhases[0].CompletedAt.IsZero()) + }) + + t.Run("preserves existing CompletedAt regardless of current status", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + cos.Status.ObservedPhases[0].CompletedAt = earlier + updateObservedPhaseStatus(&cos, ocv1.ObservedPhase{Name: "p1", Status: ocv1.PhaseStatusArchiving}, fixedNow) + require.NotNil(t, cos.Status.ObservedPhases[0].CompletedAt) + assert.Equal(t, earlier, cos.Status.ObservedPhases[0].CompletedAt) + }) + + t.Run("advances LastTransitionTime on status change", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + cos.Status.ObservedPhases[0].Status = ocv1.PhaseStatusReconciling + cos.Status.ObservedPhases[0].LastTransitionTime = earlier + updateObservedPhaseStatus(&cos, ocv1.ObservedPhase{Name: "p1", Status: ocv1.PhaseStatusStabilizing}, fixedNow) + assert.Equal(t, fixedNow, cos.Status.ObservedPhases[0].LastTransitionTime) + }) + + t.Run("preserves LastTransitionTime when status is unchanged", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + cos.Status.ObservedPhases[0].Status = ocv1.PhaseStatusReconciling + cos.Status.ObservedPhases[0].LastTransitionTime = earlier + updateObservedPhaseStatus(&cos, ocv1.ObservedPhase{Name: "p1", Status: ocv1.PhaseStatusReconciling}, fixedNow) + assert.Equal(t, earlier, cos.Status.ObservedPhases[0].LastTransitionTime) + }) + + t.Run("preserves Digest from previous observation", func(t *testing.T) { + cos := cosWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}) + cos.Status.ObservedPhases[0].Digest = "sha256:abc123" + updateObservedPhaseStatus(&cos, ocv1.ObservedPhase{Name: "p1", Status: ocv1.PhaseStatusReconciling}, fixedNow) + assert.Equal(t, "sha256:abc123", cos.Status.ObservedPhases[0].Digest) + }) + + t.Run("appends new phase when not yet in ObservedPhases", func(t *testing.T) { + // Start with a COS that has no pre-existing ObservedPhases + cos := ocv1.ClusterObjectSet{ + Spec: cosSpecWithPhaseObjects(struct { + name string + objects int + }{"p1", 1}), + } + updateObservedPhaseStatus(&cos, ocv1.ObservedPhase{Name: "p1", Status: ocv1.PhaseStatusReconciling}, fixedNow) + require.Len(t, cos.Status.ObservedPhases, 1) + assert.Equal(t, "p1", cos.Status.ObservedPhases[0].Name) + assert.Equal(t, ocv1.PhaseStatusReconciling, cos.Status.ObservedPhases[0].Status) + assert.Equal(t, fixedNow, cos.Status.ObservedPhases[0].LastTransitionTime) + }) +} + +func TestTruncateMessage(t *testing.T) { + t.Run("short message unchanged", func(t *testing.T) { + assert.Equal(t, "hello", truncateMessage("hello")) + }) + + t.Run("message at limit unchanged", func(t *testing.T) { + s := strings.Repeat("a", maxMessageLength) + assert.Equal(t, s, truncateMessage(s)) + }) + + t.Run("message over limit truncated to maxMessageLength runes", func(t *testing.T) { + s := strings.Repeat("a", maxMessageLength+10) + result := truncateMessage(s) + assert.Len(t, []rune(result), maxMessageLength) }) } @@ -138,15 +532,16 @@ func (f *fakeRevisionResult) String() string { return "" } type fakePhaseResult struct { name string complete bool + progressed 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) HasProgressed() bool { return f.progressed } 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 @@ -176,6 +571,7 @@ func (f *fakeCollisionResult) Action() machinery.Action { return machinery.Actio type fakeTeardownResult struct { complete bool active string + gone []string phases []machinery.PhaseTeardownResult waiting []string } @@ -184,21 +580,34 @@ func (f *fakeTeardownResult) IsComplete() bool { retur 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) GetGonePhaseNames() []string { return f.gone } func (f *fakeTeardownResult) String() string { return "" } type fakePhaseTeardownResult struct { - name string - complete bool - waiting []boxcuttertypes.ObjectRef + name string + waiting []boxcuttertypes.ObjectRef } func (f *fakePhaseTeardownResult) GetName() string { return f.name } -func (f *fakePhaseTeardownResult) IsComplete() bool { return f.complete } +func (f *fakePhaseTeardownResult) IsComplete() bool { return len(f.waiting) == 0 } 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 cosSpecWithPhaseObjects(phaseDefs ...struct { + name string + objects int +}) ocv1.ClusterObjectSetSpec { + phases := make([]ocv1.ClusterObjectSetPhase, len(phaseDefs)) + for i, pd := range phaseDefs { + objs := make([]ocv1.ClusterObjectSetObject, pd.objects) + phases[i] = ocv1.ClusterObjectSetPhase{Name: pd.name, Objects: objs} + } + return ocv1.ClusterObjectSetSpec{ + Phases: phases, + } +} + func cosWithPhaseObjects(phaseDefs ...struct { name string objects int @@ -219,123 +628,3 @@ func cosWithPhaseObjects(phaseDefs ...struct { }, } } - -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 d79111109d..3cb7bbdbc4 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -2650,9 +2650,9 @@ spec: 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. + cluster and are owned by this object set, or another in the same group. + 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 @@ -2668,7 +2668,7 @@ spec: synced: description: |- synced is the number of objects in this phase whose cluster state - matches the desired state. + matches the desired state. Set to zero during teardown. format: int64 maximum: 1000 minimum: 0 @@ -2696,6 +2696,16 @@ spec: description: ObservedPhase records the observed content digest of a resolved phase. properties: + completedAt: + description: |- + completedAt is the timestamp when this phase first became Ready. + Set once and never cleared. Nil means the phase has never been + Ready. + format: date-time + type: string + x-kubernetes-validations: + - message: completedAt is immutable + rule: self == oldSelf || oldSelf == null digest: description: |- digest is the digest of the phase's resolved object content @@ -2708,6 +2718,20 @@ spec: rule: self == oldSelf || oldSelf.size() == 0 - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') + lastTransitionTime: + description: lastTransitionTime is the last time the phase transitioned + from one status to another. + format: date-time + type: string + message: + description: |- + message is a phase-level message providing context about the + current status (e.g. a validation error for Invalid phases, or + a summary for Pending phases). At most 1024 characters; longer + messages are truncated by the controller. + maxLength: 1024 + minLength: 1 + type: string name: description: name is the phase name matching a phase in spec.phases. maxLength: 63 @@ -2719,17 +2743,15 @@ spec: character. rule: '!format.dns1123Label().validate(self).hasValue()' objectCounts: - 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). + 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. + cluster and are owned by this object set, or another in the same group. + 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 @@ -2745,7 +2767,7 @@ spec: synced: description: |- synced is the number of objects in this phase whose cluster state - matches the desired state. + matches the desired state. Set to zero during teardown. format: int64 maximum: 1000 minimum: 0 @@ -2762,10 +2784,31 @@ spec: - synced - total type: object + status: + description: |- + status is the current state of this phase in the rollout. Must be one of + InvalidSpecification, PreconditionsNotMet, Pending, Reconciling, + Stabilizing, Ready, Unknown, Superseded, Archiving, or Archived. + enum: + - InvalidSpecification + - Pending + - Reconciling + - Stabilizing + - Ready + - Unknown + - Superseded + - Archiving + - Archived + type: string required: - digest + - lastTransitionTime - name + - status type: object + x-kubernetes-validations: + - message: completedAt cannot be removed once set + rule: '!has(oldSelf.completedAt) || has(self.completedAt)' maxItems: 20 type: array x-kubernetes-list-map-keys: diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 31f9c4232e..764e733519 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -2611,9 +2611,9 @@ spec: 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. + cluster and are owned by this object set, or another in the same group. + 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 @@ -2629,7 +2629,7 @@ spec: synced: description: |- synced is the number of objects in this phase whose cluster state - matches the desired state. + matches the desired state. Set to zero during teardown. format: int64 maximum: 1000 minimum: 0 @@ -2657,6 +2657,16 @@ spec: description: ObservedPhase records the observed content digest of a resolved phase. properties: + completedAt: + description: |- + completedAt is the timestamp when this phase first became Ready. + Set once and never cleared. Nil means the phase has never been + Ready. + format: date-time + type: string + x-kubernetes-validations: + - message: completedAt is immutable + rule: self == oldSelf || oldSelf == null digest: description: |- digest is the digest of the phase's resolved object content @@ -2669,6 +2679,20 @@ spec: rule: self == oldSelf || oldSelf.size() == 0 - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') + lastTransitionTime: + description: lastTransitionTime is the last time the phase transitioned + from one status to another. + format: date-time + type: string + message: + description: |- + message is a phase-level message providing context about the + current status (e.g. a validation error for Invalid phases, or + a summary for Pending phases). At most 1024 characters; longer + messages are truncated by the controller. + maxLength: 1024 + minLength: 1 + type: string name: description: name is the phase name matching a phase in spec.phases. maxLength: 63 @@ -2680,17 +2704,15 @@ spec: character. rule: '!format.dns1123Label().validate(self).hasValue()' objectCounts: - 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). + 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. + cluster and are owned by this object set, or another in the same group. + 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 @@ -2706,7 +2728,7 @@ spec: synced: description: |- synced is the number of objects in this phase whose cluster state - matches the desired state. + matches the desired state. Set to zero during teardown. format: int64 maximum: 1000 minimum: 0 @@ -2723,10 +2745,31 @@ spec: - synced - total type: object + status: + description: |- + status is the current state of this phase in the rollout. Must be one of + InvalidSpecification, PreconditionsNotMet, Pending, Reconciling, + Stabilizing, Ready, Unknown, Superseded, Archiving, or Archived. + enum: + - InvalidSpecification + - Pending + - Reconciling + - Stabilizing + - Ready + - Unknown + - Superseded + - Archiving + - Archived + type: string required: - digest + - lastTransitionTime - name + - status type: object + x-kubernetes-validations: + - message: completedAt cannot be removed once set + rule: '!has(oldSelf.completedAt) || has(self.completedAt)' maxItems: 20 type: array x-kubernetes-list-map-keys: