Skip to content

Commit 16998a5

Browse files
committed
Add minor-update target-stage gate annotation
Introduce a core.openstack.org/update-target-stage annotation on OpenStackVersion. When set, the update controller completes all stages up to and including the named stage, marks the next stage as blocked (FalseCondition/RequestedReason), and pauses reconciliation. Removing the annotation or advancing it to a later stage resumes the update. Includes stage-name constants, the gated-message format string, controller logic for all seven stages, functional tests for block/resume/ advance scenarios, webhooks and updated operator documentation. AI-assisted: Cursor (Claude Sonnet 4.6 by Anthropic)
1 parent a0953cd commit 16998a5

8 files changed

Lines changed: 1302 additions & 56 deletions

File tree

api/core/v1beta1/conditions.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,4 +605,7 @@ const (
605605

606606
// OpenStackVersionMinorUpdateAvailableMessage
607607
OpenStackVersionMinorUpdateAvailableMessage = "update available"
608+
609+
// OpenStackVersionMinorUpdateReadyGatedMessage - format string; arg is the target stage name
610+
OpenStackVersionMinorUpdateReadyGatedMessage = "Minor update progression stopped after stage: %s. Set annotation to any stage after %s to resume OpenStack update or remove the annotation to run to completion."
608611
)

api/core/v1beta1/openstackversion_types.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,128 @@ const (
3434
MinorUpdateControlPlane string = "Minor Update Controlplane In Progress"
3535
// MinorUpdateComplete -
3636
MinorUpdateComplete string = "Complete"
37+
38+
// MinorUpdateTargetStageAnnotation - specifies the update stage after which the minor update
39+
// should pause. All stages up to and including the named stage will be completed; subsequent
40+
// stages will be blocked until the annotation is removed or updated to a later stage.
41+
// During an update, the webhook rejects moving this annotation to an earlier stage, and
42+
// rejects adding it behind stages already completed when it was absent at update start.
43+
// Valid values: "ovn-controlplane", "ovn-dataplane", "rabbitmq", "mariadb", "memcached",
44+
// "keystone", "controlplane". Remove the annotation to let the update proceed to completion.
45+
MinorUpdateTargetStageAnnotation string = "core.openstack.org/update-target-stage"
46+
47+
// MinorUpdateStageOVNControlplane - stage name for OVN controlplane update
48+
MinorUpdateStageOVNControlplane string = "ovn-controlplane"
49+
// MinorUpdateStageOVNDataplane - stage name for OVN dataplane update
50+
MinorUpdateStageOVNDataplane string = "ovn-dataplane"
51+
// MinorUpdateStageRabbitMQ - stage name for RabbitMQ update
52+
MinorUpdateStageRabbitMQ string = "rabbitmq"
53+
// MinorUpdateStageMariaDB - stage name for MariaDB update
54+
MinorUpdateStageMariaDB string = "mariadb"
55+
// MinorUpdateStageMemcached - stage name for Memcached update
56+
MinorUpdateStageMemcached string = "memcached"
57+
// MinorUpdateStageKeystone - stage name for Keystone update
58+
MinorUpdateStageKeystone string = "keystone"
59+
// MinorUpdateStageControlplane - stage name for full controlplane update
60+
MinorUpdateStageControlplane string = "controlplane"
3761
)
3862

63+
// validMinorUpdateTargetStagesOrdered is the single source of truth for allowed
64+
// MinorUpdateTargetStageAnnotation values, listed in rollout order.
65+
var validMinorUpdateTargetStagesOrdered = []string{
66+
MinorUpdateStageOVNControlplane,
67+
MinorUpdateStageOVNDataplane,
68+
MinorUpdateStageRabbitMQ,
69+
MinorUpdateStageMariaDB,
70+
MinorUpdateStageMemcached,
71+
MinorUpdateStageKeystone,
72+
MinorUpdateStageControlplane,
73+
}
74+
75+
// minorUpdateTargetStageConditionTypes maps each validMinorUpdateTargetStagesOrdered entry to its status condition.
76+
var minorUpdateTargetStageConditionTypes = map[string]condition.Type{
77+
MinorUpdateStageOVNControlplane: OpenStackVersionMinorUpdateOVNControlplane,
78+
MinorUpdateStageOVNDataplane: OpenStackVersionMinorUpdateOVNDataplane,
79+
MinorUpdateStageRabbitMQ: OpenStackVersionMinorUpdateRabbitMQ,
80+
MinorUpdateStageMariaDB: OpenStackVersionMinorUpdateMariaDB,
81+
MinorUpdateStageMemcached: OpenStackVersionMinorUpdateMemcached,
82+
MinorUpdateStageKeystone: OpenStackVersionMinorUpdateKeystone,
83+
MinorUpdateStageControlplane: OpenStackVersionMinorUpdateControlplane,
84+
}
85+
86+
// validMinorUpdateTargetStages is a set derived from validMinorUpdateTargetStagesOrdered for O(1) lookup.
87+
var validMinorUpdateTargetStages = func() map[string]struct{} {
88+
m := make(map[string]struct{}, len(validMinorUpdateTargetStagesOrdered))
89+
for _, s := range validMinorUpdateTargetStagesOrdered {
90+
m[s] = struct{}{}
91+
}
92+
return m
93+
}()
94+
95+
// IsValidMinorUpdateTargetStage reports whether v is a supported minor-update target stage name.
96+
func IsValidMinorUpdateTargetStage(v string) bool {
97+
if v == "" {
98+
return false
99+
}
100+
_, ok := validMinorUpdateTargetStages[v]
101+
return ok
102+
}
103+
104+
// ValidMinorUpdateTargetStages returns allowed annotation values in rollout order.
105+
func ValidMinorUpdateTargetStages() []string {
106+
return validMinorUpdateTargetStagesOrdered
107+
}
108+
109+
// MinorUpdateTargetStageIndex returns the rollout order index for stage.
110+
func MinorUpdateTargetStageIndex(stage string) (int, bool) {
111+
for i, s := range validMinorUpdateTargetStagesOrdered {
112+
if s == stage {
113+
return i, true
114+
}
115+
}
116+
return -1, false
117+
}
118+
119+
// MinorUpdateTargetStageFromAnnotations returns the target-stage annotation value when set and valid.
120+
func MinorUpdateTargetStageFromAnnotations(annotations map[string]string) (string, bool) {
121+
if annotations == nil {
122+
return "", false
123+
}
124+
stage, ok := annotations[MinorUpdateTargetStageAnnotation]
125+
if !ok || !IsValidMinorUpdateTargetStage(stage) {
126+
return "", false
127+
}
128+
return stage, true
129+
}
130+
131+
// MinorUpdateStageAllowedForReconcile reports whether the control plane may patch resources
132+
// for rollout stage during a minor update. When the target-stage annotation is absent, all
133+
// stages are allowed. When set, only stages up to and including the target may be reconciled.
134+
func MinorUpdateStageAllowedForReconcile(annotations map[string]string, stage string) bool {
135+
target, ok := MinorUpdateTargetStageFromAnnotations(annotations)
136+
if !ok {
137+
return true
138+
}
139+
stageIdx, okStage := MinorUpdateTargetStageIndex(stage)
140+
targetIdx, okTarget := MinorUpdateTargetStageIndex(target)
141+
if !okStage || !okTarget {
142+
return true
143+
}
144+
return stageIdx <= targetIdx
145+
}
146+
147+
// LatestCompletedMinorUpdateTargetStageIndex returns the rollout index of the furthest
148+
// minor-update stage marked True in status, or -1 when no annotated rollout stage has completed.
149+
func LatestCompletedMinorUpdateTargetStageIndex(status OpenStackVersionStatus) int {
150+
latest := -1
151+
for i, stage := range validMinorUpdateTargetStagesOrdered {
152+
if status.Conditions.IsTrue(minorUpdateTargetStageConditionTypes[stage]) {
153+
latest = i
154+
}
155+
}
156+
return latest
157+
}
158+
39159
// OpenStackVersionSpec - defines the desired state of OpenStackVersion
40160
type OpenStackVersionSpec struct {
41161

api/core/v1beta1/openstackversion_webhook.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"os"
2323
"reflect"
24+
"strings"
2425

2526
apierrors "k8s.io/apimachinery/pkg/api/errors"
2627
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -74,6 +75,10 @@ func (r *OpenStackVersion) ValidateCreate(ctx context.Context, c goClient.Client
7475
)
7576
}
7677

78+
if err := validateMinorUpdateTargetStageAnnotation(r.Annotations, r.GetName()); err != nil {
79+
return nil, err
80+
}
81+
7782
versionList, err := GetOpenStackVersions(r.Namespace, c)
7883

7984
if err != nil {
@@ -114,6 +119,10 @@ func (r *OpenStackVersion) ValidateCreate(ctx context.Context, c goClient.Client
114119
func (r *OpenStackVersion) ValidateUpdate(ctx context.Context, old runtime.Object, c goClient.Client) (admission.Warnings, error) {
115120
openstackversionlog.Info("validate update", "name", r.Name)
116121

122+
if err := validateMinorUpdateTargetStageAnnotation(r.Annotations, r.GetName()); err != nil {
123+
return nil, err
124+
}
125+
117126
_, ok := r.Status.ContainerImageVersionDefaults[r.Spec.TargetVersion]
118127
if r.Spec.TargetVersion != openstackVersionDefaults.AvailableVersion && !ok {
119128
return nil, apierrors.NewForbidden(
@@ -135,6 +144,11 @@ func (r *OpenStackVersion) ValidateUpdate(ctx context.Context, old runtime.Objec
135144
return nil, apierrors.NewInternalError(fmt.Errorf("failed to convert old object to OpenStackVersion"))
136145
}
137146

147+
// Validate that the target stage annotation is not from earlier stage while a minor update is in progress
148+
if err := validateMinorUpdateTargetStageAnnotationProgress(oldVersion, r); err != nil {
149+
return nil, err
150+
}
151+
138152
// Check if targetVersion is changing and this is a minor update
139153
if oldVersion.Spec.TargetVersion != r.Spec.TargetVersion && oldVersion.Status.DeployedVersion != nil {
140154
// Check if the skip annotation is present
@@ -174,6 +188,113 @@ func (r *OpenStackVersion) ValidateUpdate(ctx context.Context, old runtime.Objec
174188
return nil, nil
175189
}
176190

191+
func validateMinorUpdateTargetStageAnnotation(annotations map[string]string, resourceName string) error {
192+
if annotations == nil {
193+
return nil
194+
}
195+
stage, ok := annotations[MinorUpdateTargetStageAnnotation]
196+
if !ok {
197+
return nil
198+
}
199+
annotationField := "metadata.annotations[" + MinorUpdateTargetStageAnnotation + "]"
200+
if stage == "" {
201+
return apierrors.NewForbidden(
202+
schema.GroupResource{
203+
Group: GroupVersion.WithKind("OpenStackVersion").Group,
204+
Resource: GroupVersion.WithKind("OpenStackVersion").Kind,
205+
}, resourceName, &field.Error{
206+
Type: field.ErrorTypeForbidden,
207+
Field: annotationField,
208+
BadValue: stage,
209+
Detail: "Annotation value must not be empty. Remove the annotation or set a valid stage name",
210+
},
211+
)
212+
}
213+
if !IsValidMinorUpdateTargetStage(stage) {
214+
return apierrors.NewForbidden(
215+
schema.GroupResource{
216+
Group: GroupVersion.WithKind("OpenStackVersion").Group,
217+
Resource: GroupVersion.WithKind("OpenStackVersion").Kind,
218+
}, resourceName, &field.Error{
219+
Type: field.ErrorTypeForbidden,
220+
Field: annotationField,
221+
BadValue: stage,
222+
Detail: fmt.Sprintf(
223+
"Invalid target stage %q. Must be one of: %s",
224+
stage,
225+
strings.Join(ValidMinorUpdateTargetStages(), ", "),
226+
),
227+
},
228+
)
229+
}
230+
return nil
231+
}
232+
233+
func minorUpdateInProgress(v *OpenStackVersion) bool {
234+
if v.Status.DeployedVersion == nil {
235+
return false
236+
}
237+
return v.Spec.TargetVersion != *v.Status.DeployedVersion
238+
}
239+
240+
// validateMinorUpdateTargetStageAnnotationProgress rejects moving the target-stage
241+
// annotation to an earlier rollout stage while a minor update is in progress, and rejects
242+
// adding the annotation behind stages already completed when it was absent at update start.
243+
func validateMinorUpdateTargetStageAnnotationProgress(old, new *OpenStackVersion) error {
244+
if !minorUpdateInProgress(new) {
245+
return nil
246+
}
247+
oldStage, oldOK := MinorUpdateTargetStageFromAnnotations(old.Annotations)
248+
newStage, newOK := MinorUpdateTargetStageFromAnnotations(new.Annotations)
249+
if !newOK {
250+
return nil
251+
}
252+
newIdx, okNew := MinorUpdateTargetStageIndex(newStage)
253+
if !okNew {
254+
return nil
255+
}
256+
annotationField := "metadata.annotations[" + MinorUpdateTargetStageAnnotation + "]"
257+
gr := schema.GroupResource{
258+
Group: GroupVersion.WithKind("OpenStackVersion").Group,
259+
Resource: GroupVersion.WithKind("OpenStackVersion").Kind,
260+
}
261+
262+
if !oldOK {
263+
latest := LatestCompletedMinorUpdateTargetStageIndex(old.Status)
264+
if latest >= 0 && newIdx < latest {
265+
completedStage := validMinorUpdateTargetStagesOrdered[latest]
266+
return apierrors.NewForbidden(
267+
gr, new.GetName(), &field.Error{
268+
Type: field.ErrorTypeForbidden,
269+
Field: annotationField,
270+
BadValue: newStage,
271+
Detail: fmt.Sprintf(
272+
"Cannot set update target stage to %q while minor update is in progress: update has already completed stage %q (targetVersion %q, deployedVersion %q); choose a further stage",
273+
newStage, completedStage, new.Spec.TargetVersion, *new.Status.DeployedVersion,
274+
),
275+
},
276+
)
277+
}
278+
return nil
279+
}
280+
281+
oldIdx, _ := MinorUpdateTargetStageIndex(oldStage)
282+
if newIdx >= oldIdx {
283+
return nil
284+
}
285+
return apierrors.NewForbidden(
286+
gr, new.GetName(), &field.Error{
287+
Type: field.ErrorTypeForbidden,
288+
Field: annotationField,
289+
BadValue: newStage,
290+
Detail: fmt.Sprintf(
291+
"Cannot move update target stage from %q to earlier stage %q while minor update is in progress (targetVersion %q, deployedVersion %q); remove the annotation or set a further stage",
292+
oldStage, newStage, new.Spec.TargetVersion, *new.Status.DeployedVersion,
293+
),
294+
},
295+
)
296+
}
297+
177298
// hasAnyCustomImage checks if any image field in CustomContainerImages is set
178299
func hasAnyCustomImage(images CustomContainerImages) bool {
179300
// Check CinderVolumeImages map

0 commit comments

Comments
 (0)