Skip to content

Commit 4eaad24

Browse files
committed
Support ClusterExtension progress deadline detection
Adds optional `.spec.progressDeadlineMinutes` field to `ClusterExtension` and `ClusterExtensionRevision` that defines the maximum time an extension version can take to roll out before being marked as failed. When configured, if a `ClusterExtensionRevision` fails to roll out within the specified duration, the `Progressing` condition is set to `False` with reason `ProgressDeadlineExceeded`. This signals that manual intervention is required and stops automatic retry attempts. Added unit and e2e test asserting the added behavior.
1 parent dc20dfb commit 4eaad24

File tree

19 files changed

+635
-4
lines changed

19 files changed

+635
-4
lines changed

api/v1/clusterextension_types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ type ClusterExtensionSpec struct {
107107
//
108108
// +optional
109109
Config *ClusterExtensionConfig `json:"config,omitempty"`
110+
111+
// progressDeadlineMinutes is an optional field that defines the maximum period
112+
// of time in minutes after which an installation should be considered failed and
113+
// require manual intervention. This functionality is disabled when no value
114+
// is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours).
115+
//
116+
// +kubebuilder:validation:Minimum:=10
117+
// +kubebuilder:validation:Maximum:=720
118+
// +optional
119+
// <opcon:experimental>
120+
ProgressDeadlineMinutes int32 `json:"progressDeadlineMinutes,omitempty"`
110121
}
111122

112123
const SourceTypeCatalog = "Catalog"

api/v1/clusterextensionrevision_types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ type ClusterExtensionRevisionSpec struct {
8787
// +listMapKey=name
8888
// +optional
8989
Phases []ClusterExtensionRevisionPhase `json:"phases,omitempty"`
90+
91+
// progressDeadlineMinutes is an optional field that defines the maximum period
92+
// of time in minutes after which an installation should be considered failed and
93+
// require manual intervention. This functionality is disabled when no value
94+
// is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours).
95+
//
96+
// +kubebuilder:validation:Minimum:=10
97+
// +kubebuilder:validation:Maximum:=720
98+
// +optional
99+
// <opcon:experimental>
100+
ProgressDeadlineMinutes int32 `json:"progressDeadlineMinutes,omitempty"`
90101
}
91102

92103
// ClusterExtensionRevisionLifecycleState specifies the lifecycle state of the ClusterExtensionRevision.

api/v1/common_types.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const (
3232
ReasonDeprecated = "Deprecated"
3333

3434
// Common reasons
35-
ReasonSucceeded = "Succeeded"
36-
ReasonFailed = "Failed"
35+
ReasonSucceeded = "Succeeded"
36+
ReasonFailed = "Failed"
37+
ReasonProgressDeadlineExceeded = "ProgressDeadlineExceeded"
3738
)

api/v1/validation_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package v1
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"k8s.io/apimachinery/pkg/api/errors"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
"sigs.k8s.io/controller-runtime/pkg/client"
10+
)
11+
12+
func TestValidate(t *testing.T) {
13+
type args struct {
14+
object any
15+
skipDefaulting bool
16+
}
17+
type want struct {
18+
valid bool
19+
}
20+
type testCase struct {
21+
args args
22+
want want
23+
}
24+
defaultExtensionSpec := func(s *ClusterExtensionSpec) *ClusterExtensionSpec {
25+
s.Namespace = "ns"
26+
s.ServiceAccount = ServiceAccountReference{
27+
Name: "sa",
28+
}
29+
s.Source = SourceConfig{
30+
SourceType: SourceTypeCatalog,
31+
Catalog: &CatalogFilter{
32+
PackageName: "test",
33+
},
34+
}
35+
return s
36+
}
37+
defaultRevisionSpec := func(s *ClusterExtensionRevisionSpec) *ClusterExtensionRevisionSpec {
38+
s.Revision = 1
39+
return s
40+
}
41+
c := newClient(t)
42+
i := 0
43+
44+
for name, tc := range map[string]testCase{
45+
"ClusterExtension: invalid progress deadline < 10": {
46+
args: args{
47+
object: ClusterExtensionSpec{
48+
ProgressDeadlineMinutes: 9,
49+
},
50+
},
51+
want: want{valid: false},
52+
},
53+
"ClusterExtension: valid progress deadline = 10": {
54+
args: args{
55+
object: ClusterExtensionSpec{
56+
ProgressDeadlineMinutes: 10,
57+
},
58+
},
59+
want: want{valid: true},
60+
},
61+
"ClusterExtension: valid progress deadline = 360": {
62+
args: args{
63+
object: ClusterExtensionSpec{
64+
ProgressDeadlineMinutes: 360,
65+
},
66+
},
67+
want: want{valid: true},
68+
},
69+
"ClusterExtension: valid progress deadline = 720": {
70+
args: args{
71+
object: ClusterExtensionSpec{
72+
ProgressDeadlineMinutes: 720,
73+
},
74+
},
75+
want: want{valid: true},
76+
},
77+
"ClusterExtension: invalid progress deadline > 720": {
78+
args: args{
79+
object: ClusterExtensionSpec{
80+
ProgressDeadlineMinutes: 721,
81+
},
82+
},
83+
want: want{valid: false},
84+
},
85+
"ClusterExtension: no progress deadline set": {
86+
args: args{
87+
object: ClusterExtensionSpec{},
88+
},
89+
want: want{valid: true},
90+
},
91+
"ClusterExtensionRevision: invalid progress deadline < 10": {
92+
args: args{
93+
object: ClusterExtensionRevisionSpec{
94+
ProgressDeadlineMinutes: 9,
95+
},
96+
},
97+
want: want{valid: false},
98+
},
99+
"ClusterExtensionRevision: valid progress deadline = 10": {
100+
args: args{
101+
object: ClusterExtensionRevisionSpec{
102+
ProgressDeadlineMinutes: 10,
103+
},
104+
},
105+
want: want{valid: true},
106+
},
107+
"ClusterExtensionRevision: valid progress deadline = 360": {
108+
args: args{
109+
object: ClusterExtensionRevisionSpec{
110+
ProgressDeadlineMinutes: 360,
111+
},
112+
},
113+
want: want{valid: true},
114+
},
115+
"ClusterExtensionRevision: valid progress deadline = 720": {
116+
args: args{
117+
object: ClusterExtensionRevisionSpec{
118+
ProgressDeadlineMinutes: 720,
119+
},
120+
},
121+
want: want{valid: true},
122+
},
123+
"ClusterExtensionRevision: invalid progress deadline > 720": {
124+
args: args{
125+
object: ClusterExtensionRevisionSpec{
126+
ProgressDeadlineMinutes: 721,
127+
},
128+
},
129+
want: want{valid: false},
130+
},
131+
"ClusterExtensionRevision: no progress deadline set": {
132+
args: args{
133+
object: ClusterExtensionRevisionSpec{},
134+
},
135+
want: want{valid: true},
136+
},
137+
} {
138+
t.Run(name, func(t *testing.T) {
139+
var obj client.Object
140+
switch s := tc.args.object.(type) {
141+
case ClusterExtensionSpec:
142+
ce := &ClusterExtension{
143+
ObjectMeta: metav1.ObjectMeta{
144+
Name: fmt.Sprintf("ce-%d", i),
145+
},
146+
Spec: s,
147+
}
148+
if !tc.args.skipDefaulting {
149+
defaultExtensionSpec(&ce.Spec)
150+
}
151+
obj = ce
152+
case ClusterExtensionRevisionSpec:
153+
cer := &ClusterExtensionRevision{
154+
ObjectMeta: metav1.ObjectMeta{
155+
Name: fmt.Sprintf("cer-%d", i),
156+
},
157+
Spec: s,
158+
}
159+
if !tc.args.skipDefaulting {
160+
defaultRevisionSpec(&cer.Spec)
161+
}
162+
obj = cer
163+
default:
164+
t.Fatalf("unknown type %T", s)
165+
}
166+
i++
167+
err := c.Create(t.Context(), obj)
168+
if tc.want.valid && err != nil {
169+
t.Fatal("expected create to succeed, but got:", err)
170+
}
171+
if !tc.want.valid && !errors.IsInvalid(err) {
172+
t.Fatal("expected create to fail due to invalid payload, but got:", err)
173+
}
174+
})
175+
}
176+
}

docs/api-reference/olmv1-api-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ _Appears in:_
344344
| `source` _[SourceConfig](#sourceconfig)_ | source is required and selects the installation source of content for this ClusterExtension.<br />Set the sourceType field to perform the selection.<br />Catalog is currently the only implemented sourceType.<br />Setting sourceType to "Catalog" requires the catalog field to also be defined.<br />Below is a minimal example of a source definition (in yaml):<br />source:<br /> sourceType: Catalog<br /> catalog:<br /> packageName: example-package | | Required: \{\} <br /> |
345345
| `install` _[ClusterExtensionInstallConfig](#clusterextensioninstallconfig)_ | install is optional and configures installation options for the ClusterExtension,<br />such as the pre-flight check configuration. | | |
346346
| `config` _[ClusterExtensionConfig](#clusterextensionconfig)_ | config is optional and specifies bundle-specific configuration.<br />Configuration is bundle-specific and a bundle may provide a configuration schema.<br />When not specified, the default configuration of the resolved bundle is used.<br />config is validated against a configuration schema provided by the resolved bundle. If the bundle does not provide<br />a configuration schema the bundle is deemed to not be configurable. More information on how<br />to configure bundles can be found in the OLM documentation associated with your current OLM version. | | |
347+
| `progressDeadlineMinutes` _integer_ | progressDeadlineMinutes is an optional field that defines the maximum period<br />of time in minutes after which an installation should be considered failed and<br />require manual intervention. This functionality is disabled when no value<br />is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours).<br /><opcon:experimental> | | Maximum: 720 <br />Minimum: 10 <br /> |
347348

348349

349350
#### ClusterExtensionStatus

helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ spec:
166166
x-kubernetes-validations:
167167
- message: phases is immutable
168168
rule: self == oldSelf || oldSelf.size() == 0
169+
progressDeadlineMinutes:
170+
description: |-
171+
progressDeadlineMinutes is an optional field that defines the maximum period
172+
of time in minutes after which an installation should be considered failed and
173+
require manual intervention. This functionality is disabled when no value
174+
is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours).
175+
format: int32
176+
maximum: 720
177+
minimum: 10
178+
type: integer
169179
revision:
170180
description: |-
171181
revision is a required, immutable sequence number representing a specific revision

helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,16 @@ spec:
165165
rule: self == oldSelf
166166
- message: namespace must be a valid DNS1123 label
167167
rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
168+
progressDeadlineMinutes:
169+
description: |-
170+
progressDeadlineMinutes is an optional field that defines the maximum period
171+
of time in minutes after which an installation should be considered failed and
172+
require manual intervention. This functionality is disabled when no value
173+
is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours).
174+
format: int32
175+
maximum: 720
176+
minimum: 10
177+
type: integer
168178
serviceAccount:
169179
description: |-
170180
serviceAccount specifies a ServiceAccount used to perform all interactions with the cluster

internal/operator-controller/applier/boxcutter.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision(
191191
annotations[labels.ServiceAccountNameKey] = ext.Spec.ServiceAccount.Name
192192
annotations[labels.ServiceAccountNamespaceKey] = ext.Spec.Namespace
193193

194-
return &ocv1.ClusterExtensionRevision{
194+
cer := &ocv1.ClusterExtensionRevision{
195195
ObjectMeta: metav1.ObjectMeta{
196196
Annotations: annotations,
197197
Labels: map[string]string{
@@ -206,6 +206,10 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision(
206206
Phases: PhaseSort(objects),
207207
},
208208
}
209+
if p := ext.Spec.ProgressDeadlineMinutes; p > 0 {
210+
cer.Spec.ProgressDeadlineMinutes = p
211+
}
212+
return cer
209213
}
210214

211215
// BoxcutterStorageMigrator migrates ClusterExtensions from Helm-based storage to

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"k8s.io/apimachinery/pkg/runtime"
2323
"k8s.io/apimachinery/pkg/util/validation/field"
2424
k8scheme "k8s.io/client-go/kubernetes/scheme"
25+
"k8s.io/utils/ptr"
2526
"sigs.k8s.io/controller-runtime/pkg/client"
2627
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2728
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
@@ -327,6 +328,65 @@ func Test_SimpleRevisionGenerator_AppliesObjectLabelsAndRevisionAnnotations(t *t
327328
require.Equal(t, revAnnotations, rev.Annotations)
328329
}
329330

331+
func Test_SimpleRevisionGenerator_PropagatesProgressDeadlineMinutes(t *testing.T) {
332+
r := &FakeManifestProvider{
333+
GetFn: func(b fs.FS, e *ocv1.ClusterExtension) ([]client.Object, error) {
334+
return []client.Object{}, nil
335+
},
336+
}
337+
338+
b := applier.SimpleRevisionGenerator{
339+
Scheme: k8scheme.Scheme,
340+
ManifestProvider: r,
341+
}
342+
343+
type args struct {
344+
progressDeadlineMinutes *int32
345+
}
346+
type want struct {
347+
progressDeadlineMinutes int32
348+
}
349+
type testCase struct {
350+
args args
351+
want want
352+
}
353+
for name, tc := range map[string]testCase{
354+
"propagates when set": {
355+
args: args{
356+
progressDeadlineMinutes: ptr.To(int32(10)),
357+
},
358+
want: want{
359+
progressDeadlineMinutes: 10,
360+
},
361+
},
362+
"do not propagate when unset": {
363+
want: want{
364+
progressDeadlineMinutes: 0,
365+
},
366+
},
367+
} {
368+
ext := &ocv1.ClusterExtension{
369+
ObjectMeta: metav1.ObjectMeta{
370+
Name: "test-extension",
371+
},
372+
Spec: ocv1.ClusterExtensionSpec{
373+
Namespace: "test-namespace",
374+
ServiceAccount: ocv1.ServiceAccountReference{Name: "test-sa"},
375+
},
376+
}
377+
empty := map[string]string{}
378+
t.Run(name, func(t *testing.T) {
379+
if pd := tc.args.progressDeadlineMinutes; pd != nil {
380+
ext.Spec.ProgressDeadlineMinutes = *pd
381+
}
382+
383+
rev, err := b.GenerateRevision(t.Context(), fstest.MapFS{}, ext, empty, empty)
384+
require.NoError(t, err)
385+
require.Equal(t, tc.want.progressDeadlineMinutes, rev.Spec.ProgressDeadlineMinutes)
386+
})
387+
}
388+
}
389+
330390
func Test_SimpleRevisionGenerator_Failure(t *testing.T) {
331391
r := &FakeManifestProvider{
332392
GetFn: func(b fs.FS, e *ocv1.ClusterExtension) ([]client.Object, error) {

internal/operator-controller/conditionsets/conditionsets.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ var ConditionReasons = []string{
4141
ocv1.ReasonRetrying,
4242
ocv1.ReasonAbsent,
4343
ocv1.ReasonRollingOut,
44+
ocv1.ReasonProgressDeadlineExceeded,
4445
}

0 commit comments

Comments
 (0)