Skip to content

Commit fae4854

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 7053bc4 commit fae4854

8 files changed

Lines changed: 1257 additions & 53 deletions

File tree

api/core/v1beta1/conditions.go

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

597597
// OpenStackVersionMinorUpdateAvailableMessage
598598
OpenStackVersionMinorUpdateAvailableMessage = "update available"
599+
600+
// OpenStackVersionMinorUpdateReadyGatedMessage - format string; arg is the target stage name
601+
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."
599602
)

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: 128 additions & 1 deletion
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"
@@ -59,7 +60,6 @@ func (r *OpenStackVersion) Default() {
5960
// ValidateCreate validates the OpenStackVersion on creation
6061
func (r *OpenStackVersion) ValidateCreate(ctx context.Context, c goClient.Client) (admission.Warnings, error) {
6162
openstackversionlog.Info("validate create", "name", r.Name)
62-
6363
if r.Spec.TargetVersion != openstackVersionDefaults.AvailableVersion {
6464
return nil, apierrors.NewForbidden(
6565
schema.GroupResource{
@@ -114,6 +114,10 @@ func (r *OpenStackVersion) ValidateCreate(ctx context.Context, c goClient.Client
114114
func (r *OpenStackVersion) ValidateUpdate(ctx context.Context, old runtime.Object, c goClient.Client) (admission.Warnings, error) {
115115
openstackversionlog.Info("validate update", "name", r.Name)
116116

117+
if err := validateMinorUpdateTargetStageAnnotation(r.Annotations, r.GetName()); err != nil {
118+
return nil, err
119+
}
120+
117121
_, ok := r.Status.ContainerImageVersionDefaults[r.Spec.TargetVersion]
118122
if r.Spec.TargetVersion != openstackVersionDefaults.AvailableVersion && !ok {
119123
return nil, apierrors.NewForbidden(
@@ -135,6 +139,11 @@ func (r *OpenStackVersion) ValidateUpdate(ctx context.Context, old runtime.Objec
135139
return nil, apierrors.NewInternalError(fmt.Errorf("failed to convert old object to OpenStackVersion"))
136140
}
137141

142+
// Validate that the target stage annotation is not from earlier stage while a minor update is in progress
143+
if err := validateMinorUpdateTargetStageAnnotationProgress(oldVersion, r); err != nil {
144+
return nil, err
145+
}
146+
138147
// Check if targetVersion is changing and this is a minor update
139148
if oldVersion.Spec.TargetVersion != r.Spec.TargetVersion && oldVersion.Status.DeployedVersion != nil {
140149
// Check if the skip annotation is present
@@ -174,6 +183,124 @@ func (r *OpenStackVersion) ValidateUpdate(ctx context.Context, old runtime.Objec
174183
return nil, nil
175184
}
176185

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

0 commit comments

Comments
 (0)