Skip to content

Commit 571f646

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 e2678c1 commit 571f646

8 files changed

Lines changed: 1067 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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,104 @@ 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.
42+
// Valid values: "ovn-controlplane", "ovn-dataplane", "rabbitmq", "mariadb", "memcached",
43+
// "keystone", "controlplane". Remove the annotation to let the update proceed to completion.
44+
MinorUpdateTargetStageAnnotation string = "core.openstack.org/update-target-stage"
45+
46+
// MinorUpdateStageOVNControlplane - stage name for OVN controlplane update
47+
MinorUpdateStageOVNControlplane string = "ovn-controlplane"
48+
// MinorUpdateStageOVNDataplane - stage name for OVN dataplane update
49+
MinorUpdateStageOVNDataplane string = "ovn-dataplane"
50+
// MinorUpdateStageRabbitMQ - stage name for RabbitMQ update
51+
MinorUpdateStageRabbitMQ string = "rabbitmq"
52+
// MinorUpdateStageMariaDB - stage name for MariaDB update
53+
MinorUpdateStageMariaDB string = "mariadb"
54+
// MinorUpdateStageMemcached - stage name for Memcached update
55+
MinorUpdateStageMemcached string = "memcached"
56+
// MinorUpdateStageKeystone - stage name for Keystone update
57+
MinorUpdateStageKeystone string = "keystone"
58+
// MinorUpdateStageControlplane - stage name for full controlplane update
59+
MinorUpdateStageControlplane string = "controlplane"
3760
)
3861

62+
// validMinorUpdateTargetStagesOrdered is the single source of truth for allowed
63+
// MinorUpdateTargetStageAnnotation values, listed in rollout order.
64+
var validMinorUpdateTargetStagesOrdered = []string{
65+
MinorUpdateStageOVNControlplane,
66+
MinorUpdateStageOVNDataplane,
67+
MinorUpdateStageRabbitMQ,
68+
MinorUpdateStageMariaDB,
69+
MinorUpdateStageMemcached,
70+
MinorUpdateStageKeystone,
71+
MinorUpdateStageControlplane,
72+
}
73+
74+
// validMinorUpdateTargetStages is a set derived from validMinorUpdateTargetStagesOrdered for O(1) lookup.
75+
var validMinorUpdateTargetStages = func() map[string]struct{} {
76+
m := make(map[string]struct{}, len(validMinorUpdateTargetStagesOrdered))
77+
for _, s := range validMinorUpdateTargetStagesOrdered {
78+
m[s] = struct{}{}
79+
}
80+
return m
81+
}()
82+
83+
// IsValidMinorUpdateTargetStage reports whether v is a supported minor-update target stage name.
84+
func IsValidMinorUpdateTargetStage(v string) bool {
85+
if v == "" {
86+
return false
87+
}
88+
_, ok := validMinorUpdateTargetStages[v]
89+
return ok
90+
}
91+
92+
// ValidMinorUpdateTargetStages returns allowed annotation values in rollout order.
93+
func ValidMinorUpdateTargetStages() []string {
94+
return validMinorUpdateTargetStagesOrdered
95+
}
96+
97+
// MinorUpdateTargetStageIndex returns the rollout order index for stage.
98+
func MinorUpdateTargetStageIndex(stage string) (int, bool) {
99+
for i, s := range validMinorUpdateTargetStagesOrdered {
100+
if s == stage {
101+
return i, true
102+
}
103+
}
104+
return -1, false
105+
}
106+
107+
// MinorUpdateTargetStageFromAnnotations returns the target-stage annotation value when set and valid.
108+
func MinorUpdateTargetStageFromAnnotations(annotations map[string]string) (string, bool) {
109+
if annotations == nil {
110+
return "", false
111+
}
112+
stage, ok := annotations[MinorUpdateTargetStageAnnotation]
113+
if !ok || !IsValidMinorUpdateTargetStage(stage) {
114+
return "", false
115+
}
116+
return stage, true
117+
}
118+
119+
// MinorUpdateStageAllowedForReconcile reports whether the control plane may patch resources
120+
// for rollout stage during a minor update. When the target-stage annotation is absent, all
121+
// stages are allowed. When set, only stages up to and including the target may be reconciled.
122+
func MinorUpdateStageAllowedForReconcile(annotations map[string]string, stage string) bool {
123+
target, ok := MinorUpdateTargetStageFromAnnotations(annotations)
124+
if !ok {
125+
return true
126+
}
127+
stageIdx, okStage := MinorUpdateTargetStageIndex(stage)
128+
targetIdx, okTarget := MinorUpdateTargetStageIndex(target)
129+
if !okStage || !okTarget {
130+
return true
131+
}
132+
return stageIdx <= targetIdx
133+
}
134+
39135
// OpenStackVersionSpec - defines the desired state of OpenStackVersion
40136
type OpenStackVersionSpec struct {
41137

api/core/v1beta1/openstackversion_webhook.go

Lines changed: 103 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,99 @@ 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.
248+
func validateMinorUpdateTargetStageAnnotationProgress(old, new *OpenStackVersion) error {
249+
if !minorUpdateInProgress(new) {
250+
return nil
251+
}
252+
oldStage, oldOK := minorUpdateTargetStageFromAnnotations(old.Annotations)
253+
newStage, newOK := minorUpdateTargetStageFromAnnotations(new.Annotations)
254+
if !oldOK || !newOK {
255+
return nil
256+
}
257+
oldIdx, _ := MinorUpdateTargetStageIndex(oldStage)
258+
newIdx, _ := MinorUpdateTargetStageIndex(newStage)
259+
if newIdx >= oldIdx {
260+
return nil
261+
}
262+
annotationField := "metadata.annotations[" + MinorUpdateTargetStageAnnotation + "]"
263+
return apierrors.NewForbidden(
264+
schema.GroupResource{
265+
Group: GroupVersion.WithKind("OpenStackVersion").Group,
266+
Resource: GroupVersion.WithKind("OpenStackVersion").Kind,
267+
}, new.GetName(), &field.Error{
268+
Type: field.ErrorTypeForbidden,
269+
Field: annotationField,
270+
BadValue: newStage,
271+
Detail: fmt.Sprintf(
272+
"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 later stage",
273+
oldStage, newStage, new.Spec.TargetVersion, *new.Status.DeployedVersion,
274+
),
275+
},
276+
)
277+
}
278+
177279
// hasAnyCustomImage checks if any image field in CustomContainerImages is set
178280
func hasAnyCustomImage(images CustomContainerImages) bool {
179281
// Check CinderVolumeImages map

0 commit comments

Comments
 (0)