Skip to content

Commit d67d41c

Browse files
lmicciniclaude
andcommitted
Add credential rotation helpers and EnsureFresh cache bypass
Add shared helpers for transport URL secret rotation across all openstack-k8s-operators consumer operators: - object.ManageSecretConsumerFinalizer: adds a consumer finalizer to a secret so the provider knows consumers still depend on it - object.RemoveSecretConsumerFinalizer: removes the finalizer - object.FinalizeSecretRotation: the rotation guard — if old != new and guard is ready, removes finalizer from old and returns new; otherwise returns old to preserve the reference - condition.CredentialRotationGuardReady: returns true when all sub-CR specs are stable and all mirrored conditions are True - helper.EnsureFresh: re-reads a sub-CR directly from the API server (bypassing the informer cache) when CreateOrPatch returns None during rotation, preventing false-positive guard evaluation from stale cached Generation/ObservedGeneration - helper.SetAPIReader: stores the cache-bypassing reader - statefulset.IsReady / deployment.IsReady: check UpdatedReplicas, CurrentRevision, and ObservedGeneration so DeploymentReady is only True when all pods have rolled to the new config - StatefulSet/Deployment CreateOrPatch cache fix: when op != None, use the server-returned object instead of the stale cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe8e60d commit d67d41c

8 files changed

Lines changed: 468 additions & 14 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Credential rotation consumer finalizer guard
2+
3+
OpenStack service operators that consume rotating credential secrets
4+
(transport URL, application credentials, or similar) attach a consumer
5+
finalizer to the old secret so it is not deleted until every sub-service
6+
has rolled out with the new credential.
7+
8+
## Shared helpers
9+
10+
Use helpers from these packages instead of duplicating guard logic in each
11+
operator:
12+
13+
- `object.ManageSecretConsumerFinalizer` — add finalizer to current secret
14+
- `object.RemoveSecretConsumerFinalizer` — remove finalizer from old secret
15+
- `object.FinalizeSecretRotation` — rotation guard: hold old finalizer
16+
until all sub-services are ready, then release
17+
- `condition.CredentialRotationGuardReady` — compute `guardReady` for the
18+
rotation guard
19+
- `helper.EnsureFresh` — bypass informer cache during rotation so the
20+
parent reads accurate sub-CR conditions from the API server
21+
- `helper.SetAPIReader` — configure the cache-bypassing reader
22+
- `statefulset.IsReady` / `deployment.IsReady` — check UpdatedReplicas,
23+
CurrentRevision, and ObservedGeneration so DeploymentReady is only
24+
True when all pods have rolled to the new config
25+
26+
## Parent controller integration
27+
28+
### 1. Wire up APIReader
29+
30+
Add `APIReader client.Reader` to the reconciler struct and pass
31+
`mgr.GetAPIReader()` from `cmd/main.go`. In `Reconcile`, call
32+
`helper.SetAPIReader(r.APIReader)` after creating the helper.
33+
34+
### 2. Pass transport URL directly — never through Status
35+
36+
Pass `transportURL.Status.SecretName` as a parameter to sub-CR creation
37+
functions and config generation. Never read from
38+
`instance.Status.TransportURLSecret` when building sub-CR specs — the
39+
status field is only used as the "old" value for `FinalizeSecretRotation`.
40+
41+
Set `instance.Status.TransportURLSecret` early only for first-time setup:
42+
43+
```go
44+
if instance.Status.TransportURLSecret == "" ||
45+
instance.Status.TransportURLSecret == transportURL.Status.SecretName {
46+
instance.Status.TransportURLSecret = transportURL.Status.SecretName
47+
}
48+
```
49+
50+
During rotation (old != current), the status is updated solely by
51+
`FinalizeSecretRotation` at the end of reconcile.
52+
53+
### 3. Track stability and call EnsureFresh
54+
55+
Use a simple boolean instead of StabilityTracker:
56+
57+
```go
58+
allSubCRsStable := true
59+
rotationInProgress := instance.Status.TransportURLSecret != "" &&
60+
instance.Status.TransportURLSecret != transportURL.Status.SecretName
61+
62+
// After each sub-CR CreateOrPatch:
63+
subCR, op, err := r.subCRCreateOrUpdate(ctx, instance, transportURL.Status.SecretName)
64+
if err != nil { return ctrl.Result{}, err }
65+
if err := helper.EnsureFresh(ctx, op, subCR, rotationInProgress); err != nil {
66+
return ctrl.Result{}, err
67+
}
68+
if op != controllerutil.OperationResultNone {
69+
allSubCRsStable = false
70+
}
71+
```
72+
73+
`EnsureFresh` re-reads the sub-CR directly from the API server when
74+
`op == None` and rotation is in progress. Without this, the informer
75+
cache may return stale data where `Generation == ObservedGeneration`
76+
from before the spec change, causing the guard to pass prematurely.
77+
78+
### 4. Transport URL annotation (operators without TransportURLSecret in sub-CR spec)
79+
80+
For operators whose sub-CR specs do not include a `TransportURLSecret`
81+
field (e.g. nova, watcher, glance, designate, octavia, telemetry), add
82+
an annotation inside the `CreateOrPatch` mutate function to force a
83+
spec change when the transport URL rotates:
84+
85+
```go
86+
op, err := controllerutil.CreateOrPatch(ctx, r.Client, subCR, func() error {
87+
subCR.Spec = spec
88+
if subCR.Annotations == nil {
89+
subCR.Annotations = map[string]string{}
90+
}
91+
subCR.Annotations["openstack.org/transport-url-secret"] = transportURLSecretName
92+
return controllerutil.SetControllerReference(instance, subCR, r.Scheme)
93+
})
94+
```
95+
96+
This is not needed for operators that pass `TransportURLSecret` directly
97+
in the sub-CR spec (cinder, manila, ironic, barbican, heat) — the spec
98+
field change already makes `CreateOrPatch` return `"updated"`.
99+
100+
### 5. Compute the rotation guard and finalize
101+
102+
```go
103+
guardReady := condition.CredentialRotationGuardReady(
104+
allSubCRsStable,
105+
&instance.Status.Conditions,
106+
)
107+
108+
instance.Status.TransportURLSecret, err = object.FinalizeSecretRotation(
109+
ctx, helper, instance.Namespace,
110+
instance.Status.TransportURLSecret,
111+
transportURL.Status.SecretName,
112+
myTransportConsumerFinalizer,
113+
guardReady,
114+
)
115+
```
116+
117+
The same pattern works for application credential secrets:
118+
119+
```go
120+
instance.Status.ApplicationCredentialSecret, err = object.FinalizeSecretRotation(
121+
ctx, helper, instance.Namespace,
122+
instance.Status.ApplicationCredentialSecret,
123+
instance.Spec.Auth.ApplicationCredentialSecret,
124+
myACConsumerFinalizer,
125+
guardReady,
126+
)
127+
```
128+
129+
## Sub-CR integration
130+
131+
Sub-CR controllers should use `statefulset.IsReady()` or
132+
`deployment.IsReady()` for the `DeploymentReadyCondition` check:
133+
134+
```go
135+
if statefulset.IsReady(ssData) {
136+
instance.Status.Conditions.MarkTrue(
137+
condition.DeploymentReadyCondition,
138+
condition.DeploymentReadyMessage)
139+
} else if *instance.Spec.Replicas > 0 {
140+
instance.Status.Conditions.Set(condition.FalseCondition(
141+
condition.DeploymentReadyCondition,
142+
condition.RequestedReason,
143+
condition.SeverityInfo,
144+
condition.DeploymentReadyRunningMessage))
145+
}
146+
```
147+
148+
These helpers check `*Replicas == ReadyReplicas`, `*Replicas == UpdatedReplicas`,
149+
`Generation == ObservedGeneration`, and `CurrentRevision == UpdateRevision`,
150+
ensuring DeploymentReady is only True when all pods have rolled to the
151+
new config.

modules/common/condition/funcs.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,34 @@ func (conditions *Conditions) IsUnknown(t Type) bool {
166166
return true
167167
}
168168

169+
// ServiceInstanceIsReady reports whether a service sub-CR has finished
170+
// reconciling its current spec and its deployment is ready to serve
171+
// requests. It implements the generation/observedGeneration guard used
172+
// by OpenStack service operators during credential rotation rollouts.
173+
func ServiceInstanceIsReady(generation, observedGeneration int64, readyCount, replicas int32, conditions *Conditions) bool {
174+
if conditions == nil {
175+
return false
176+
}
177+
return generation == observedGeneration &&
178+
readyCount == replicas &&
179+
(conditions.IsTrue(DeploymentReadyCondition) ||
180+
(conditions.IsFalse(DeploymentReadyCondition) && replicas == 0))
181+
}
182+
183+
// CredentialRotationGuardReady reports whether it is safe to remove a
184+
// consumer finalizer from an old transport URL or application credential
185+
// secret. The guard requires all sub-CR specs to be stable (no pending
186+
// CreateOrPatch updates) and all parent sub-conditions to be True.
187+
// Note: ReadyCondition is NOT checked because operators reset conditions
188+
// via Init() at the top of each Reconcile, so ReadyCondition is Unknown
189+
// until the very end. AllSubConditionIsTrue covers the same safety.
190+
func CredentialRotationGuardReady(allSubCRsStable bool, conditions *Conditions) bool {
191+
if conditions == nil {
192+
return false
193+
}
194+
return allSubCRsStable && conditions.AllSubConditionIsTrue()
195+
}
196+
169197
// AllSubConditionIsTrue validates if all subconditions are True
170198
// It assumes that all conditions report success via the True status
171199
func (conditions *Conditions) AllSubConditionIsTrue() bool {

modules/common/condition/funcs_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,41 @@ func TestIsMethods(t *testing.T) {
351351
g.Expect(conditions.IsUnknown("unknownB")).To(BeTrue())
352352
}
353353

354+
func TestServiceInstanceIsReady(t *testing.T) {
355+
g := NewWithT(t)
356+
357+
readyConditions := CreateList(
358+
TrueCondition(DeploymentReadyCondition, "deployment ready"),
359+
)
360+
361+
notReadyConditions := CreateList(
362+
FalseCondition(DeploymentReadyCondition, InitReason, SeverityInfo, "creating"),
363+
)
364+
365+
g.Expect(ServiceInstanceIsReady(2, 2, 3, 3, &readyConditions)).To(BeTrue())
366+
g.Expect(ServiceInstanceIsReady(2, 1, 3, 3, &readyConditions)).To(BeFalse())
367+
g.Expect(ServiceInstanceIsReady(2, 2, 2, 3, &readyConditions)).To(BeFalse())
368+
g.Expect(ServiceInstanceIsReady(2, 2, 3, 3, &notReadyConditions)).To(BeFalse())
369+
g.Expect(ServiceInstanceIsReady(1, 1, 0, 0, &notReadyConditions)).To(BeTrue())
370+
g.Expect(ServiceInstanceIsReady(1, 1, 0, 0, nil)).To(BeFalse())
371+
}
372+
373+
func TestCredentialRotationGuardReady(t *testing.T) {
374+
g := NewWithT(t)
375+
376+
conditions := Conditions{}
377+
conditions.Init(nil)
378+
conditions.MarkTrue("a", "ready")
379+
conditions.MarkTrue("b", "ready")
380+
381+
g.Expect(CredentialRotationGuardReady(true, &conditions)).To(BeTrue())
382+
conditions.MarkFalse("a", InitReason, SeverityInfo, "not ready")
383+
g.Expect(CredentialRotationGuardReady(true, &conditions)).To(BeFalse())
384+
conditions.MarkTrue("a", "ready")
385+
g.Expect(CredentialRotationGuardReady(false, &conditions)).To(BeFalse())
386+
g.Expect(CredentialRotationGuardReady(true, nil)).To(BeFalse())
387+
}
388+
354389
func TestAllSubConditionIsTrue(t *testing.T) {
355390
conditions := Conditions{}
356391

modules/common/deployment/deployment.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,20 @@ func (d *Deployment) CreateOrPatch(
8787
h.GetLogger().Info(fmt.Sprintf("Deployment %s - %s", deployment.Name, op))
8888
}
8989

90-
// update the deployment object of the deployment type
91-
d.deployment, err = GetDeploymentWithName(ctx, h, deployment.GetName(), deployment.GetNamespace())
92-
if err != nil {
93-
return ctrl.Result{}, err
90+
if op == controllerutil.OperationResultNone {
91+
// Re-read from cache to pick up status updates from the
92+
// Deployment controller (e.g. updated ReadyReplicas).
93+
d.deployment, err = GetDeploymentWithName(ctx, h, deployment.GetName(), deployment.GetNamespace())
94+
if err != nil {
95+
return ctrl.Result{}, err
96+
}
97+
} else {
98+
// After a create/update the informer cache may still hold the
99+
// previous version where Generation == ObservedGeneration. Using
100+
// the server-returned object preserves the correct (bumped)
101+
// Generation so that callers' readiness checks do not pass on
102+
// stale data.
103+
d.deployment = deployment
94104
}
95105

96106
return ctrl.Result{}, nil

modules/common/helper/helper.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ type Helper struct {
4747
after *unstructured.Unstructured
4848
changes map[string]bool
4949
finalizer string
50-
51-
logger logr.Logger
50+
logger logr.Logger
5251
}
5352

5453
// NewHelper returns an initialized Helper.

0 commit comments

Comments
 (0)