From a9c45f42fbc8369f49cda44a804a93074f8f34a9 Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Wed, 11 Nov 2026 13:51:46 +0100 Subject: [PATCH] Move ClusterObjectDeployment progress deadlines outside the revision template Move progressDeadlineMinutes from spec.template.spec to spec, alongside revisionHistoryLimit. The deadline is rollout policy rather than revision content, so deadline-only edits must not create a new ClusterObjectSet. Apply deadline changes to the latest active revision using its original creation timestamp. Preserve the total rollout budget and existing recovery behavior, including recalculation after expiry. Update the ClusterExtension applier, schema and controller regression tests, generated artifacts, E2E features, and standalone sample. Hand-authored COD concept and tutorial documentation is handled in a separate follow-up PR. Signed-off-by: Per G. da Silva --- api/v1/clusterobjectdeployment_types.go | 31 ++++++++++--------- api/v1/clusterobjectdeployment_types_test.go | 10 +++--- api/v1/validation_test.go | 16 +++------- .../api/v1/clusterobjectdeploymentspec.go | 21 ++++++++++++- .../api/v1/clusterobjectdeploymentstatus.go | 2 +- .../v1/clusterobjectdeploymenttemplatespec.go | 16 ---------- applyconfigurations/internal/internal.go | 6 ++-- .../olm_v1_clusterobjectdeployment.yaml | 2 +- docs/api-reference/olmv1-api-reference.md | 4 +-- ...framework.io_clusterobjectdeployments.yaml | 31 ++++++++++--------- .../clusterobjectdeployment_controller.go | 2 +- ...clusterobjectdeployment_controller_test.go | 23 ++++++++++++-- .../clusterobjectdeployment_deadline_test.go | 12 +++---- .../controllers/progress_deadline_test.go | 4 +-- .../applier/clusterobjectdeployment.go | 8 ++--- .../applier/clusterobjectdeployment_test.go | 2 +- manifests/experimental-e2e.yaml | 31 ++++++++++--------- manifests/experimental.yaml | 31 ++++++++++--------- .../features/cod_progressing_reasons.feature | 4 +-- .../features/install.feature | 4 +-- 20 files changed, 143 insertions(+), 117 deletions(-) diff --git a/api/v1/clusterobjectdeployment_types.go b/api/v1/clusterobjectdeployment_types.go index cab1524481..f34bb7f3a3 100644 --- a/api/v1/clusterobjectdeployment_types.go +++ b/api/v1/clusterobjectdeployment_types.go @@ -63,6 +63,22 @@ type ClusterObjectDeploymentSpec struct { // +optional RevisionHistoryLimit *int32 `json:"revisionHistoryLimit,omitempty"` + // progressDeadlineMinutes is the maximum number of minutes a revision may take + // to roll out, measured from the ClusterObjectSet's creation timestamp. Time + // spent waiting for the ClusterObjectSet to be created is not counted. The + // deadline covers all phases together; completing a phase does not reset it. + // Once a revision has completed, the deadline no longer applies, even if it + // later becomes unhealthy. When omitted, no deadline is enforced. The minimum + // period is 1 minute, and the maximum is 720 minutes (12 hours). + // + // The deadline is rollout policy, not revision content: changing it does not + // create a new revision and applies immediately to the latest active revision. + // + // +kubebuilder:validation:Minimum:=1 + // +kubebuilder:validation:Maximum:=720 + // +optional + ProgressDeadlineMinutes int32 `json:"progressDeadlineMinutes,omitempty"` + // template is the desired content of newly created ClusterObjectSets. // Changes to template create a new revision; existing revisions remain immutable. // +required @@ -119,19 +135,6 @@ type ClusterObjectDeploymentTemplateSpec struct { // +listMapKey=name Phases []ClusterObjectSetPhase `json:"phases,omitempty"` // These are ordered groups of objects, not an enum describing lifecycle state. - // progressDeadlineMinutes is the maximum number of minutes a revision may take - // to roll out, measured from the ClusterObjectSet's creation timestamp. Time - // spent waiting for the ClusterObjectSet to be created is not counted. The - // deadline covers all phases together; completing a phase does not reset it. - // Once a revision has completed, the deadline no longer applies, even if it - // later becomes unhealthy. When omitted, no deadline is enforced. The minimum - // period is 1 minute, and the maximum is 720 minutes (12 hours). - // - // +kubebuilder:validation:Minimum:=1 - // +kubebuilder:validation:Maximum:=720 - // +optional - ProgressDeadlineMinutes int32 `json:"progressDeadlineMinutes,omitempty"` - // progressionProbes supplies custom readiness probes for objects in every phase. // The same selector and assertion semantics as ClusterObjectSet.spec.progressionProbes apply. // When set, must contain 1 to 20 probes. @@ -156,7 +159,7 @@ type ClusterObjectDeploymentStatus struct { // rollout is incomplete, or False with reason NewClusterObjectSetProgressed after // completion. Completion remains recorded even if the completed revision later // becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when - // the latest revision does not roll out within spec.template.spec.progressDeadlineMinutes. + // the latest revision does not roll out within spec.progressDeadlineMinutes. // Progressing is False with reason InvalidSpecification when spec.template.metadata // contains labels or annotations that are not valid for the resulting // ClusterObjectSet; no new revision is created until the metadata is corrected. diff --git a/api/v1/clusterobjectdeployment_types_test.go b/api/v1/clusterobjectdeployment_types_test.go index dd464b35fa..e095e8b64d 100644 --- a/api/v1/clusterobjectdeployment_types_test.go +++ b/api/v1/clusterobjectdeployment_types_test.go @@ -256,13 +256,13 @@ func TestClusterObjectDeploymentTemplateSchema(t *testing.T) { cod := newTestClusterObjectDeployment("cod-template-progress-deadline") raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cod) require.NoError(t, err) - require.NoError(t, unstructured.SetNestedField(raw, int64(10), "spec", "template", "spec", "progressDeadlineMinutes")) + require.NoError(t, unstructured.SetNestedField(raw, int64(10), "spec", "progressDeadlineMinutes")) obj := &unstructured.Unstructured{Object: raw} require.NoError(t, c.Create(t.Context(), obj)) require.NoError(t, c.Get(t.Context(), client.ObjectKeyFromObject(obj), obj)) - value, found, err := unstructured.NestedInt64(obj.Object, "spec", "template", "spec", "progressDeadlineMinutes") + value, found, err := unstructured.NestedInt64(obj.Object, "spec", "progressDeadlineMinutes") require.NoError(t, err) - require.True(t, found, "template must expose progressDeadlineMinutes") + require.True(t, found, "spec must expose progressDeadlineMinutes") require.Equal(t, int64(10), value) }) } @@ -282,11 +282,11 @@ func TestClusterObjectDeploymentDeferredFieldsArePruned(t *testing.T) { cod := newTestClusterObjectDeployment("cod-deferred-fields") raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cod) require.NoError(t, err) - require.NoError(t, unstructured.SetNestedField(raw, int64(10), "spec", "progressDeadlineMinutes")) + require.NoError(t, unstructured.SetNestedField(raw, int64(10), "spec", "template", "spec", "group")) obj := &unstructured.Unstructured{Object: raw} require.NoError(t, c.Create(t.Context(), obj)) require.NoError(t, c.Get(t.Context(), client.ObjectKeyFromObject(obj), obj)) - for _, path := range [][]string{{"spec", "progressDeadlineMinutes"}} { + for _, path := range [][]string{{"spec", "template", "spec", "group"}} { _, found, err := unstructured.NestedFieldNoCopy(obj.Object, path...) require.NoError(t, err) require.False(t, found, "deployment must not expose %s", strings.Join(path, ".")) diff --git a/api/v1/validation_test.go b/api/v1/validation_test.go index a31c01d1b1..3dbee5c68f 100644 --- a/api/v1/validation_test.go +++ b/api/v1/validation_test.go @@ -117,9 +117,7 @@ func TestValidate(t *testing.T) { "ClusterObjectDeployment: valid progress deadline = 1": { args: args{ object: ClusterObjectDeploymentSpec{ - Template: ClusterObjectDeploymentTemplate{ - Spec: ClusterObjectDeploymentTemplateSpec{ProgressDeadlineMinutes: 1}, - }, + ProgressDeadlineMinutes: 1, }, }, want: want{valid: true}, @@ -127,9 +125,7 @@ func TestValidate(t *testing.T) { "ClusterObjectDeployment: valid progress deadline = 10": { args: args{ object: ClusterObjectDeploymentSpec{ - Template: ClusterObjectDeploymentTemplate{ - Spec: ClusterObjectDeploymentTemplateSpec{ProgressDeadlineMinutes: 10}, - }, + ProgressDeadlineMinutes: 10, }, }, want: want{valid: true}, @@ -137,9 +133,7 @@ func TestValidate(t *testing.T) { "ClusterObjectDeployment: valid progress deadline = 720": { args: args{ object: ClusterObjectDeploymentSpec{ - Template: ClusterObjectDeploymentTemplate{ - Spec: ClusterObjectDeploymentTemplateSpec{ProgressDeadlineMinutes: 720}, - }, + ProgressDeadlineMinutes: 720, }, }, want: want{valid: true}, @@ -147,9 +141,7 @@ func TestValidate(t *testing.T) { "ClusterObjectDeployment: invalid progress deadline > 720": { args: args{ object: ClusterObjectDeploymentSpec{ - Template: ClusterObjectDeploymentTemplate{ - Spec: ClusterObjectDeploymentTemplateSpec{ProgressDeadlineMinutes: 721}, - }, + ProgressDeadlineMinutes: 721, }, }, want: want{valid: false, errContains: "spec.progressDeadlineMinutes"}, diff --git a/applyconfigurations/api/v1/clusterobjectdeploymentspec.go b/applyconfigurations/api/v1/clusterobjectdeploymentspec.go index 79e73bdf70..64ecc7f281 100644 --- a/applyconfigurations/api/v1/clusterobjectdeploymentspec.go +++ b/applyconfigurations/api/v1/clusterobjectdeploymentspec.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Code generated by controller-gen-v0.21. DO NOT EDIT. +// Code generated by controller-gen. DO NOT EDIT. package v1 @@ -33,6 +33,17 @@ type ClusterObjectDeploymentSpecApplyConfiguration struct { // A value of 0 retains no archived revisions. The minimum is 0, the maximum // is 50, and the default is 5. RevisionHistoryLimit *int32 `json:"revisionHistoryLimit,omitempty"` + // progressDeadlineMinutes is the maximum number of minutes a revision may take + // to roll out, measured from the ClusterObjectSet's creation timestamp. Time + // spent waiting for the ClusterObjectSet to be created is not counted. The + // deadline covers all phases together; completing a phase does not reset it. + // Once a revision has completed, the deadline no longer applies, even if it + // later becomes unhealthy. When omitted, no deadline is enforced. The minimum + // period is 1 minute, and the maximum is 720 minutes (12 hours). + // + // The deadline is rollout policy, not revision content: changing it does not + // create a new revision and applies immediately to the latest active revision. + ProgressDeadlineMinutes *int32 `json:"progressDeadlineMinutes,omitempty"` // template is the desired content of newly created ClusterObjectSets. // Changes to template create a new revision; existing revisions remain immutable. Template *ClusterObjectDeploymentTemplateApplyConfiguration `json:"template,omitempty"` @@ -52,6 +63,14 @@ func (b *ClusterObjectDeploymentSpecApplyConfiguration) WithRevisionHistoryLimit return b } +// WithProgressDeadlineMinutes sets the ProgressDeadlineMinutes 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 ProgressDeadlineMinutes field is set to the value of the last call. +func (b *ClusterObjectDeploymentSpecApplyConfiguration) WithProgressDeadlineMinutes(value int32) *ClusterObjectDeploymentSpecApplyConfiguration { + b.ProgressDeadlineMinutes = &value + return b +} + // WithTemplate sets the Template 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 Template field is set to the value of the last call. diff --git a/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go b/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go index 33818f6065..dc9e6b2029 100644 --- a/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go +++ b/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go @@ -33,7 +33,7 @@ type ClusterObjectDeploymentStatusApplyConfiguration struct { // rollout is incomplete, or False with reason NewClusterObjectSetProgressed after // completion. Completion remains recorded even if the completed revision later // becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when - // the latest revision does not roll out within spec.template.spec.progressDeadlineMinutes. + // the latest revision does not roll out within spec.progressDeadlineMinutes. // Progressing is False with reason InvalidSpecification when spec.template.metadata // contains labels or annotations that are not valid for the resulting // ClusterObjectSet; no new revision is created until the metadata is corrected. diff --git a/applyconfigurations/api/v1/clusterobjectdeploymenttemplatespec.go b/applyconfigurations/api/v1/clusterobjectdeploymenttemplatespec.go index 978f0b6845..8f3010dc52 100644 --- a/applyconfigurations/api/v1/clusterobjectdeploymenttemplatespec.go +++ b/applyconfigurations/api/v1/clusterobjectdeploymenttemplatespec.go @@ -32,14 +32,6 @@ type ClusterObjectDeploymentTemplateSpecApplyConfiguration struct { // Kubernetes object) or a secretRef (a reference to an immutable Secret holding // a serialized manifest). Phase, object, and probe order participate in template identity. Phases []ClusterObjectSetPhaseApplyConfiguration `json:"phases,omitempty"` - // progressDeadlineMinutes is the maximum number of minutes a revision may take - // to roll out, measured from the ClusterObjectSet's creation timestamp. Time - // spent waiting for the ClusterObjectSet to be created is not counted. The - // deadline covers all phases together; completing a phase does not reset it. - // Once a revision has completed, the deadline no longer applies, even if it - // later becomes unhealthy. When omitted, no deadline is enforced. The minimum - // period is 1 minute, and the maximum is 720 minutes (12 hours). - ProgressDeadlineMinutes *int32 `json:"progressDeadlineMinutes,omitempty"` // progressionProbes supplies custom readiness probes for objects in every phase. // The same selector and assertion semantics as ClusterObjectSet.spec.progressionProbes apply. // When set, must contain 1 to 20 probes. @@ -69,14 +61,6 @@ func (b *ClusterObjectDeploymentTemplateSpecApplyConfiguration) WithPhases(value return b } -// WithProgressDeadlineMinutes sets the ProgressDeadlineMinutes 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 ProgressDeadlineMinutes field is set to the value of the last call. -func (b *ClusterObjectDeploymentTemplateSpecApplyConfiguration) WithProgressDeadlineMinutes(value int32) *ClusterObjectDeploymentTemplateSpecApplyConfiguration { - b.ProgressDeadlineMinutes = &value - return b -} - // WithProgressionProbes adds the given value to the ProgressionProbes 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 ProgressionProbes field. diff --git a/applyconfigurations/internal/internal.go b/applyconfigurations/internal/internal.go index 820c9aa5f8..48437f4dcc 100644 --- a/applyconfigurations/internal/internal.go +++ b/applyconfigurations/internal/internal.go @@ -262,6 +262,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: com.github.operator-framework.operator-controller.api.v1.ClusterObjectDeploymentSpec map: fields: + - name: progressDeadlineMinutes + type: + scalar: numeric - name: revisionHistoryLimit type: scalar: numeric @@ -316,9 +319,6 @@ var schemaYAML = typed.YAMLObject(`types: elementRelationship: associative keys: - name - - name: progressDeadlineMinutes - type: - scalar: numeric - name: progressionProbes type: list: diff --git a/config/samples/olm_v1_clusterobjectdeployment.yaml b/config/samples/olm_v1_clusterobjectdeployment.yaml index ab231f1f1a..8dd595b281 100644 --- a/config/samples/olm_v1_clusterobjectdeployment.yaml +++ b/config/samples/olm_v1_clusterobjectdeployment.yaml @@ -9,6 +9,7 @@ metadata: name: cod-example spec: revisionHistoryLimit: 2 + progressDeadlineMinutes: 10 template: metadata: labels: @@ -17,7 +18,6 @@ spec: example.com/description: Configuration managed by a standalone COD spec: collisionProtection: Prevent - progressDeadlineMinutes: 10 phases: - name: configuration objects: diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 344c7cdd94..73bd8af5be 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -442,6 +442,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `revisionHistoryLimit` _integer_ | revisionHistoryLimit is the maximum number of archived (superseded)
ClusterObjectSet revisions to retain, so that rollout history can be
inspected and earlier revisions rolled back to. Active revisions are never
removed and do not count against this limit. When the number of archived
revisions exceeds this value, the oldest archived revisions are deleted
until the limit is met.
A value of 0 retains no archived revisions. The minimum is 0, the maximum
is 50, and the default is 5. | 5 | Maximum: 50
Minimum: 0
Optional: \{\}
| +| `progressDeadlineMinutes` _integer_ | progressDeadlineMinutes is the maximum number of minutes a revision may take
to roll out, measured from the ClusterObjectSet's creation timestamp. Time
spent waiting for the ClusterObjectSet to be created is not counted. The
deadline covers all phases together; completing a phase does not reset it.
Once a revision has completed, the deadline no longer applies, even if it
later becomes unhealthy. When omitted, no deadline is enforced. The minimum
period is 1 minute, and the maximum is 720 minutes (12 hours).
The deadline is rollout policy, not revision content: changing it does not
create a new revision and applies immediately to the latest active revision. | | Maximum: 720
Minimum: 1
Optional: \{\}
| | `template` _[ClusterObjectDeploymentTemplate](#clusterobjectdeploymenttemplate)_ | template is the desired content of newly created ClusterObjectSets.
Changes to template create a new revision; existing revisions remain immutable. | | Required: \{\}
| @@ -458,7 +459,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | conditions describes rollout progress and availability for the observed generation.
Progressing is True with reason NewClusterObjectSetProgressing while the desired
rollout is incomplete, or False with reason NewClusterObjectSetProgressed after
completion. Completion remains recorded even if the completed revision later
becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
the latest revision does not roll out within spec.template.spec.progressDeadlineMinutes.
Progressing is False with reason InvalidSpecification when spec.template.metadata
contains labels or annotations that are not valid for the resulting
ClusterObjectSet; no new revision is created until the metadata is corrected.
Progressing is False with reason OwnerConflict when the highest active,
non-deleting revision in the deployment's group is controlled by another
controller. No new revision is created while the conflict persists, even if
an owned revision has completed or exceeded its progress deadline.
Progressing is False with reason UnmanagedRevision when the highest active,
non-deleting revision in the deployment's group has no controller owner and is
not labeled apps.kubernetes.io/managed-by=object-controller, the proof that the
object-controller created it; such a revision is not adopted and no higher
revision is created while it persists. Recover by removing the revision or by
restoring the managed-by label on it. A revision that instead still references an
older UID of this deployment (for example after a restore) is left to garbage
collection rather than adopted, and no higher revision is created until GC removes
it; this resolves without user action and is not reported as a distinct condition.
While a rollout is incomplete, Progressing also reflects why the latest (head
active) ClusterObjectSet revision is not yet Ready. It is True with reason
RevisionPreconditionsNotMet when the revision's preconditions are not yet met
(for example RBAC or a dependency CRD that has not propagated); the controller
keeps retrying. It is False with reason RevisionObjectCollision when the
revision's objects are owned by another controller or already exist, which the
user must resolve. It is False with reason RevisionInvalidSpecification when the
revision's own objects are invalid; this is distinct from InvalidSpecification
above, which refers to the deployment's spec.template.metadata. Inspect the
named ClusterObjectSet revision for the specific cause.
Available is True with reason Available when at least one active (non-archived)
owned ClusterObjectSet revision is Ready, so the deployment remains available across
rollouts while a previous revision is still Ready. It is False with reason
Unavailable when owned active revisions exist but none are Ready yet, or with reason
NoActiveRevision when no owned active revision exists. Only an ownerless revision
is adopted before status is calculated, and only when it carries the
apps.kubernetes.io/managed-by=object-controller label that proves the
object-controller created it; an ownerless revision missing that label is left
untouched and, when it leads the group, blocks the rollout and is reported on the
Progressing condition with reason UnmanagedRevision. A revision that still carries a
controller owner reference is never adopted: foreign-owned revisions are excluded,
and a stale reference to an older UID of this deployment (for example after a
restore) is left to garbage collection rather than rewriting the reference to race
it. | | Optional: \{\}
| +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | conditions describes rollout progress and availability for the observed generation.
Progressing is True with reason NewClusterObjectSetProgressing while the desired
rollout is incomplete, or False with reason NewClusterObjectSetProgressed after
completion. Completion remains recorded even if the completed revision later
becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
the latest revision does not roll out within spec.progressDeadlineMinutes.
Progressing is False with reason InvalidSpecification when spec.template.metadata
contains labels or annotations that are not valid for the resulting
ClusterObjectSet; no new revision is created until the metadata is corrected.
Progressing is False with reason OwnerConflict when the highest active,
non-deleting revision in the deployment's group is controlled by another
controller. No new revision is created while the conflict persists, even if
an owned revision has completed or exceeded its progress deadline.
Progressing is False with reason UnmanagedRevision when the highest active,
non-deleting revision in the deployment's group has no controller owner and is
not labeled apps.kubernetes.io/managed-by=object-controller, the proof that the
object-controller created it; such a revision is not adopted and no higher
revision is created while it persists. Recover by removing the revision or by
restoring the managed-by label on it. A revision that instead still references an
older UID of this deployment (for example after a restore) is left to garbage
collection rather than adopted, and no higher revision is created until GC removes
it; this resolves without user action and is not reported as a distinct condition.
While a rollout is incomplete, Progressing also reflects why the latest (head
active) ClusterObjectSet revision is not yet Ready. It is True with reason
RevisionPreconditionsNotMet when the revision's preconditions are not yet met
(for example RBAC or a dependency CRD that has not propagated); the controller
keeps retrying. It is False with reason RevisionObjectCollision when the
revision's objects are owned by another controller or already exist, which the
user must resolve. It is False with reason RevisionInvalidSpecification when the
revision's own objects are invalid; this is distinct from InvalidSpecification
above, which refers to the deployment's spec.template.metadata. Inspect the
named ClusterObjectSet revision for the specific cause.
Available is True with reason Available when at least one active (non-archived)
owned ClusterObjectSet revision is Ready, so the deployment remains available across
rollouts while a previous revision is still Ready. It is False with reason
Unavailable when owned active revisions exist but none are Ready yet, or with reason
NoActiveRevision when no owned active revision exists. Only an ownerless revision
is adopted before status is calculated, and only when it carries the
apps.kubernetes.io/managed-by=object-controller label that proves the
object-controller created it; an ownerless revision missing that label is left
untouched and, when it leads the group, blocks the rollout and is reported on the
Progressing condition with reason UnmanagedRevision. A revision that still carries a
controller owner reference is never adopted: foreign-owned revisions are excluded,
and a stale reference to an older UID of this deployment (for example after a
restore) is left to garbage collection rather than rewriting the reference to race
it. | | Optional: \{\}
| #### ClusterObjectDeploymentTemplate @@ -512,7 +513,6 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `phases` _[ClusterObjectSetPhase](#clusterobjectsetphase) array_ | phases is an ordered list of 1 to 20 uniquely named phases. Each phase contains
at most 50 objects, each expressed either as an inlineManifest (an embedded
Kubernetes object) or a secretRef (a reference to an immutable Secret holding
a serialized manifest). Phase, object, and probe order participate in template identity. | | MaxItems: 20
MinItems: 1
Required: \{\}
| -| `progressDeadlineMinutes` _integer_ | progressDeadlineMinutes is the maximum number of minutes a revision may take
to roll out, measured from the ClusterObjectSet's creation timestamp. Time
spent waiting for the ClusterObjectSet to be created is not counted. The
deadline covers all phases together; completing a phase does not reset it.
Once a revision has completed, the deadline no longer applies, even if it
later becomes unhealthy. When omitted, no deadline is enforced. The minimum
period is 1 minute, and the maximum is 720 minutes (12 hours). | | Maximum: 720
Minimum: 1
Optional: \{\}
| | `progressionProbes` _[ProgressionProbe](#progressionprobe) array_ | progressionProbes supplies custom readiness probes for objects in every phase.
The same selector and assertion semantics as ClusterObjectSet.spec.progressionProbes apply.
When set, must contain 1 to 20 probes. | | MaxItems: 20
MinItems: 1
Optional: \{\}
| | `collisionProtection` _[CollisionProtection](#collisionprotection)_ | collisionProtection is the required default collision protection strategy for all objects
in the revision. Individual phases or objects may override this value.
The resolution order is: object > phase > spec. | | Enum: [Prevent IfNoController None]
Required: \{\}
| diff --git a/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml index 4e31d48c94..138c5675f4 100644 --- a/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml +++ b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml @@ -54,6 +54,22 @@ spec: spec: description: spec defines the desired deployment template. properties: + progressDeadlineMinutes: + description: |- + progressDeadlineMinutes is the maximum number of minutes a revision may take + to roll out, measured from the ClusterObjectSet's creation timestamp. Time + spent waiting for the ClusterObjectSet to be created is not counted. The + deadline covers all phases together; completing a phase does not reset it. + Once a revision has completed, the deadline no longer applies, even if it + later becomes unhealthy. When omitted, no deadline is enforced. The minimum + period is 1 minute, and the maximum is 720 minutes (12 hours). + + The deadline is rollout policy, not revision content: changing it does not + create a new revision and applies immediately to the latest active revision. + format: int32 + maximum: 720 + minimum: 1 + type: integer revisionHistoryLimit: default: 5 description: |- @@ -308,19 +324,6 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - progressDeadlineMinutes: - description: |- - progressDeadlineMinutes is the maximum number of minutes a revision may take - to roll out, measured from the ClusterObjectSet's creation timestamp. Time - spent waiting for the ClusterObjectSet to be created is not counted. The - deadline covers all phases together; completing a phase does not reset it. - Once a revision has completed, the deadline no longer applies, even if it - later becomes unhealthy. When omitted, no deadline is enforced. The minimum - period is 1 minute, and the maximum is 720 minutes (12 hours). - format: int32 - maximum: 720 - minimum: 1 - type: integer progressionProbes: description: |- progressionProbes supplies custom readiness probes for objects in every phase. @@ -604,7 +607,7 @@ spec: rollout is incomplete, or False with reason NewClusterObjectSetProgressed after completion. Completion remains recorded even if the completed revision later becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when - the latest revision does not roll out within spec.template.spec.progressDeadlineMinutes. + the latest revision does not roll out within spec.progressDeadlineMinutes. Progressing is False with reason InvalidSpecification when spec.template.metadata contains labels or annotations that are not valid for the resulting ClusterObjectSet; no new revision is created until the metadata is corrected. diff --git a/internal/object-controller/controllers/clusterobjectdeployment_controller.go b/internal/object-controller/controllers/clusterobjectdeployment_controller.go index f5f542bce2..2c1a1a1da9 100644 --- a/internal/object-controller/controllers/clusterobjectdeployment_controller.go +++ b/internal/object-controller/controllers/clusterobjectdeployment_controller.go @@ -413,7 +413,7 @@ func durationUntilDeploymentDeadline(clk clock.Clock, cod *ocv1.ClusterObjectDep cos.Spec.LifecycleState == ocv1.ClusterObjectSetLifecycleStateArchived || !cos.DeletionTimestamp.IsZero() { return 0, false } - pd := cod.Spec.Template.Spec.ProgressDeadlineMinutes + pd := cod.Spec.ProgressDeadlineMinutes if pd <= 0 { return 0, false } diff --git a/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go b/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go index ddc4f1de7e..94f0298243 100644 --- a/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go +++ b/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go @@ -37,10 +37,10 @@ func codFixture() *ocv1.ClusterObjectDeployment { return &ocv1.ClusterObjectDeployment{ ObjectMeta: metav1.ObjectMeta{Name: clusterObjectDeploymentName, UID: "test-cod-uid"}, Spec: ocv1.ClusterObjectDeploymentSpec{ + ProgressDeadlineMinutes: 15, Template: ocv1.ClusterObjectDeploymentTemplate{ Spec: ocv1.ClusterObjectDeploymentTemplateSpec{ - CollisionProtection: ocv1.CollisionProtectionPrevent, - ProgressDeadlineMinutes: 15, + CollisionProtection: ocv1.CollisionProtectionPrevent, Phases: []ocv1.ClusterObjectSetPhase{{ Name: "resources", Objects: []ocv1.ClusterObjectSetObject{{ @@ -549,6 +549,25 @@ func Test_ClusterObjectDeploymentReconciler_CreatesNewRevisionOnTemplateChange(t require.Equal(t, metav1.ConditionTrue, cond.Status) } +func Test_ClusterObjectDeploymentReconciler_ProgressDeadlineChangeDoesNotCreateRevision(t *testing.T) { + c := newCODClient(t, codFixture()) + + _, err := reconcileCOD(t, c) + require.NoError(t, err) + + // Change only the progress deadline, which is rollout policy, not revision content. + cod := &ocv1.ClusterObjectDeployment{} + require.NoError(t, c.Get(t.Context(), types.NamespacedName{Name: clusterObjectDeploymentName}, cod)) + cod.Spec.ProgressDeadlineMinutes = 60 + require.NoError(t, c.Update(t.Context(), cod)) + + _, err = reconcileCOD(t, c) + require.NoError(t, err) + + _, ok := getCOS(t, c, clusterObjectDeploymentName+"-2") + require.False(t, ok, "changing only the progress deadline must not create a new revision") +} + func Test_ClusterObjectDeploymentReconciler_CreatesNewRevisionWhenLatestArchived(t *testing.T) { c := newCODClient(t, codFixture()) diff --git a/internal/object-controller/controllers/clusterobjectdeployment_deadline_test.go b/internal/object-controller/controllers/clusterobjectdeployment_deadline_test.go index 835d740ec8..a41a519905 100644 --- a/internal/object-controller/controllers/clusterobjectdeployment_deadline_test.go +++ b/internal/object-controller/controllers/clusterobjectdeployment_deadline_test.go @@ -62,7 +62,7 @@ func TestClusterObjectDeploymentReconcilerDeadlineCoversEntireRevision(t *testin // Time before the COS exists is outside the revision's budget, even when // creating it took longer than the configured deadline. cod.CreationTimestamp = metav1.NewTime(creation.Add(-30 * time.Minute)) - cod.Spec.Template.Spec.ProgressDeadlineMinutes = 10 + cod.Spec.ProgressDeadlineMinutes = 10 phaseTemplate := cod.Spec.Template.Spec.Phases[0] cod.Spec.Template.Spec.Phases = nil for i := range 5 { @@ -225,7 +225,7 @@ func TestClusterObjectDeploymentReconcilerDeadlineWithoutCOSDeadlineCondition(t } { t.Run(tc.name, func(t *testing.T) { cod := codFixture() - cod.Spec.Template.Spec.ProgressDeadlineMinutes = 10 + cod.Spec.ProgressDeadlineMinutes = 10 c := newCODClient(t, cod) _, err := reconcileCOD(t, c) require.NoError(t, err) @@ -267,7 +267,7 @@ func TestClusterObjectDeploymentReconcilerIgnoresCOSDeadlineCondition(t *testing t.Run(fmt.Sprintf("deadline %d", deadline), func(t *testing.T) { clk := clocktesting.NewFakeClock(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)) cod := codFixture() - cod.Spec.Template.Spec.ProgressDeadlineMinutes = deadline + cod.Spec.ProgressDeadlineMinutes = deadline c := newDeadlineDeploymentClient(t, cod, clk) _, err := reconcileDeadlineDeployment(t, c, clk) require.NoError(t, err) @@ -293,7 +293,7 @@ func TestClusterObjectDeploymentReconcilerDeadlineSchedulingAndRecovery(t *testi start := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) clk := clocktesting.NewFakeClock(start) cod := codFixture() - cod.Spec.Template.Spec.ProgressDeadlineMinutes = 10 + cod.Spec.ProgressDeadlineMinutes = 10 c := newDeadlineDeploymentClient(t, cod, clk) result, err := reconcileDeadlineDeployment(t, c, clk) require.NoError(t, err) @@ -354,7 +354,7 @@ func TestClusterObjectDeploymentReconcilerDeadlineSchedulingAndRecovery(t *testi func TestClusterObjectDeploymentReconcilerDeadlineExcludesCreationRetries(t *testing.T) { clk := clocktesting.NewFakeClock(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)) cod := codFixture() - cod.Spec.Template.Spec.ProgressDeadlineMinutes = 10 + cod.Spec.ProgressDeadlineMinutes = 10 cod.CreationTimestamp = metav1.NewTime(clk.Now()) creationErr := errors.New("revision creation denied") denied := true @@ -383,7 +383,7 @@ func TestClusterObjectDeploymentReconcilerDeadlineExcludesCreationRetries(t *tes func TestClusterObjectDeploymentReconcilerDeadlineRetainsAvailability(t *testing.T) { clk := clocktesting.NewFakeClock(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)) cod := codFixture() - cod.Spec.Template.Spec.ProgressDeadlineMinutes = 10 + cod.Spec.ProgressDeadlineMinutes = 10 c := newDeadlineDeploymentClient(t, cod, clk) _, err := reconcileDeadlineDeployment(t, c, clk) require.NoError(t, err) diff --git a/internal/object-controller/controllers/progress_deadline_test.go b/internal/object-controller/controllers/progress_deadline_test.go index c02e37fc5e..3a9f3a29e1 100644 --- a/internal/object-controller/controllers/progress_deadline_test.go +++ b/internal/object-controller/controllers/progress_deadline_test.go @@ -73,7 +73,7 @@ func TestCODDeadlineAwareRateLimiter(t *testing.T) { { name: "disabled deadline", elapsed: time.Hour, backoff: 15 * time.Minute, expectWait: 15 * time.Minute, mutate: func(cod *ocv1.ClusterObjectDeployment, cos *ocv1.ClusterObjectSet) { - cod.Spec.Template.Spec.ProgressDeadlineMinutes = 0 + cod.Spec.ProgressDeadlineMinutes = 0 cos.Labels[labels.TemplateHashKey] = templateHash(cod.Spec.Template) }, }, @@ -269,7 +269,7 @@ func deadlineRateLimiterObjects(creation time.Time) (*ocv1.ClusterObjectDeployme ObjectMeta: metav1.ObjectMeta{Name: "test-cod", UID: "test-cod-uid"}, Spec: ocv1.ClusterObjectDeploymentSpec{Template: testTemplate()}, } - cod.Spec.Template.Spec.ProgressDeadlineMinutes = 10 + cod.Spec.ProgressDeadlineMinutes = 10 cos := buildClusterObjectSet(cod, 1, templateHash(cod.Spec.Template)) cos.OwnerReferences = []metav1.OwnerReference{*metav1.NewControllerRef(cod, ocv1.GroupVersion.WithKind(ocv1.ClusterObjectDeploymentKind))} cos.CreationTimestamp = metav1.NewTime(creation) diff --git a/internal/operator-controller/applier/clusterobjectdeployment.go b/internal/operator-controller/applier/clusterobjectdeployment.go index 79f965032d..658e71baf4 100644 --- a/internal/operator-controller/applier/clusterobjectdeployment.go +++ b/internal/operator-controller/applier/clusterobjectdeployment.go @@ -33,10 +33,6 @@ func buildClusterObjectDeployment( WithProgressionProbes(defaultProgressionProbes...). WithCollisionProtection(collisionProtection) - if p := ext.Spec.ProgressDeadlineMinutes; p > 0 { - templateSpec.WithProgressDeadlineMinutes(p) - } - // Propagate the owner labels and the bundle-metadata annotations to each revision // through the template metadata. The COD controller copies template.metadata onto every // ClusterObjectSet it creates verbatim, without knowing any specific key, so OLM-specific @@ -55,6 +51,10 @@ func buildClusterObjectDeployment( spec := ocv1ac.ClusterObjectDeploymentSpec(). WithTemplate(template) + if p := ext.Spec.ProgressDeadlineMinutes; p > 0 { + spec.WithProgressDeadlineMinutes(p) + } + return ocv1ac.ClusterObjectDeployment(ext.Name). WithLabels(ownerLabels). WithLabels(map[string]string{labels.ManagedByKey: labels.ManagedByOperatorController}). diff --git a/internal/operator-controller/applier/clusterobjectdeployment_test.go b/internal/operator-controller/applier/clusterobjectdeployment_test.go index d735ab3522..be16bf91c3 100644 --- a/internal/operator-controller/applier/clusterobjectdeployment_test.go +++ b/internal/operator-controller/applier/clusterobjectdeployment_test.go @@ -89,7 +89,7 @@ func TestBuildClusterObjectDeployment(t *testing.T) { templateSpec := result.Spec.Template.Spec assert.Len(t, templateSpec.Phases, 1) assert.Equal(t, ocv1.CollisionProtectionPrevent, *templateSpec.CollisionProtection) - assert.Equal(t, int32(15), *templateSpec.ProgressDeadlineMinutes) + assert.Equal(t, int32(15), *result.Spec.ProgressDeadlineMinutes) assert.NotEmpty(t, templateSpec.ProgressionProbes) } diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index ee65bd28d4..7d269d17b1 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1383,6 +1383,22 @@ spec: spec: description: spec defines the desired deployment template. properties: + progressDeadlineMinutes: + description: |- + progressDeadlineMinutes is the maximum number of minutes a revision may take + to roll out, measured from the ClusterObjectSet's creation timestamp. Time + spent waiting for the ClusterObjectSet to be created is not counted. The + deadline covers all phases together; completing a phase does not reset it. + Once a revision has completed, the deadline no longer applies, even if it + later becomes unhealthy. When omitted, no deadline is enforced. The minimum + period is 1 minute, and the maximum is 720 minutes (12 hours). + + The deadline is rollout policy, not revision content: changing it does not + create a new revision and applies immediately to the latest active revision. + format: int32 + maximum: 720 + minimum: 1 + type: integer revisionHistoryLimit: default: 5 description: |- @@ -1637,19 +1653,6 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - progressDeadlineMinutes: - description: |- - progressDeadlineMinutes is the maximum number of minutes a revision may take - to roll out, measured from the ClusterObjectSet's creation timestamp. Time - spent waiting for the ClusterObjectSet to be created is not counted. The - deadline covers all phases together; completing a phase does not reset it. - Once a revision has completed, the deadline no longer applies, even if it - later becomes unhealthy. When omitted, no deadline is enforced. The minimum - period is 1 minute, and the maximum is 720 minutes (12 hours). - format: int32 - maximum: 720 - minimum: 1 - type: integer progressionProbes: description: |- progressionProbes supplies custom readiness probes for objects in every phase. @@ -1933,7 +1936,7 @@ spec: rollout is incomplete, or False with reason NewClusterObjectSetProgressed after completion. Completion remains recorded even if the completed revision later becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when - the latest revision does not roll out within spec.template.spec.progressDeadlineMinutes. + the latest revision does not roll out within spec.progressDeadlineMinutes. Progressing is False with reason InvalidSpecification when spec.template.metadata contains labels or annotations that are not valid for the resulting ClusterObjectSet; no new revision is created until the metadata is corrected. diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index db53fd2df0..bbe1fb8ef3 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1344,6 +1344,22 @@ spec: spec: description: spec defines the desired deployment template. properties: + progressDeadlineMinutes: + description: |- + progressDeadlineMinutes is the maximum number of minutes a revision may take + to roll out, measured from the ClusterObjectSet's creation timestamp. Time + spent waiting for the ClusterObjectSet to be created is not counted. The + deadline covers all phases together; completing a phase does not reset it. + Once a revision has completed, the deadline no longer applies, even if it + later becomes unhealthy. When omitted, no deadline is enforced. The minimum + period is 1 minute, and the maximum is 720 minutes (12 hours). + + The deadline is rollout policy, not revision content: changing it does not + create a new revision and applies immediately to the latest active revision. + format: int32 + maximum: 720 + minimum: 1 + type: integer revisionHistoryLimit: default: 5 description: |- @@ -1598,19 +1614,6 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - progressDeadlineMinutes: - description: |- - progressDeadlineMinutes is the maximum number of minutes a revision may take - to roll out, measured from the ClusterObjectSet's creation timestamp. Time - spent waiting for the ClusterObjectSet to be created is not counted. The - deadline covers all phases together; completing a phase does not reset it. - Once a revision has completed, the deadline no longer applies, even if it - later becomes unhealthy. When omitted, no deadline is enforced. The minimum - period is 1 minute, and the maximum is 720 minutes (12 hours). - format: int32 - maximum: 720 - minimum: 1 - type: integer progressionProbes: description: |- progressionProbes supplies custom readiness probes for objects in every phase. @@ -1894,7 +1897,7 @@ spec: rollout is incomplete, or False with reason NewClusterObjectSetProgressed after completion. Completion remains recorded even if the completed revision later becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when - the latest revision does not roll out within spec.template.spec.progressDeadlineMinutes. + the latest revision does not roll out within spec.progressDeadlineMinutes. Progressing is False with reason InvalidSpecification when spec.template.metadata contains labels or annotations that are not valid for the resulting ClusterObjectSet; no new revision is created until the metadata is corrected. diff --git a/test/e2e/object-controller/features/cod_progressing_reasons.feature b/test/e2e/object-controller/features/cod_progressing_reasons.feature index 98e53f8f53..59eedc850b 100644 --- a/test/e2e/object-controller/features/cod_progressing_reasons.feature +++ b/test/e2e/object-controller/features/cod_progressing_reasons.feature @@ -153,10 +153,10 @@ Feature: ClusterObjectDeployment impeded-rollout Progressing reasons metadata: name: ${NAME} spec: + progressDeadlineMinutes: 1 template: spec: collisionProtection: Prevent - progressDeadlineMinutes: 1 progressionProbes: - selector: type: GroupKind @@ -221,10 +221,10 @@ Feature: ClusterObjectDeployment impeded-rollout Progressing reasons metadata: name: ${NAME} spec: + progressDeadlineMinutes: 1 template: spec: collisionProtection: Prevent - progressDeadlineMinutes: 1 phases: - name: resources objects: diff --git a/test/e2e/operator-controller/features/install.feature b/test/e2e/operator-controller/features/install.feature index 4eae25fa44..0042cd51e2 100644 --- a/test/e2e/operator-controller/features/install.feature +++ b/test/e2e/operator-controller/features/install.feature @@ -350,7 +350,7 @@ Feature: Install ClusterExtension Given a catalog "test" with packages: | package | version | channel | replaces | contents | | test | 1.0.2 | alpha | | BadImage | - # The applier routes progressDeadlineMinutes through the ClusterObjectDeployment template; the + # The applier routes progressDeadlineMinutes onto the ClusterObjectDeployment spec; the # deployment enforces the rollout deadline itself, so relax its CRD minimum to accept the value of 1. # TODO: We should also permanently relax the ClusterExtension's field to allow a minimum of 1 And min value for ClusterExtension .spec.progressDeadlineMinutes is set to 1 @@ -387,7 +387,7 @@ Feature: Install ClusterExtension Given a catalog "test" with packages: | package | version | channel | replaces | contents | | test | 1.0.3 | alpha | | BadImage | - # The applier routes progressDeadlineMinutes through the ClusterObjectDeployment template; the + # The applier routes progressDeadlineMinutes onto the ClusterObjectDeployment spec; the # deployment enforces the rollout deadline itself, so relax its CRD minimum to accept the value of 1. And min value for ClusterExtension .spec.progressDeadlineMinutes is set to 1 When ClusterExtension is applied