Skip to content

Commit 102d676

Browse files
lmicciniclaude
andcommitted
Add IsSecretHashInSync for scoped single-secret hash checking
AreSecretHashesInSync checks all secrets across all NodeSets, which makes it unsuitable for controllers that need to track one specific secret without being affected by unrelated hash mismatches. IsSecretHashInSync checks a single named secret against the secretHashes in all NodeSets. Returns inSync=true when the hash matches everywhere the secret is tracked, or when no NodeSet tracks it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fbef9a0 commit 102d676

2 files changed

Lines changed: 247 additions & 0 deletions

File tree

modules/edpm/unstructured/nodeset.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,83 @@ func AreSecretHashesInSync(
134134
return true, "", nil
135135
}
136136

137+
// IsSecretHashInSync checks whether a single named secret's hash matches what
138+
// is deployed across all OpenStackDataPlaneNodeSets in the given namespace.
139+
// Unlike AreSecretHashesInSync, it ignores all other secrets — useful when a
140+
// controller needs to track deployment of one specific secret without being
141+
// affected by unrelated secret changes.
142+
//
143+
// Returns:
144+
// - inSync=true when the secret's hash matches in every NodeSet that tracks
145+
// it, when no NodeSets exist, when no NodeSet tracks this secret, or when
146+
// the OpenStackDataPlaneNodeSet CRD is not installed.
147+
// - inSync=false with info describing the first mismatch.
148+
func IsSecretHashInSync(
149+
ctx context.Context,
150+
c client.Client,
151+
namespace string,
152+
secretName string,
153+
) (inSync bool, info string, err error) {
154+
Log := log.FromContext(ctx)
155+
156+
nodesetList := &k8s_unstructured.UnstructuredList{}
157+
nodesetList.SetGroupVersionKind(schema.GroupVersionKind{
158+
Group: NodeSetGVK.Group,
159+
Version: NodeSetGVK.Version,
160+
Kind: NodeSetGVK.Kind + "List",
161+
})
162+
163+
if err := c.List(ctx, nodesetList, client.InNamespace(namespace)); err != nil {
164+
if meta.IsNoMatchError(err) {
165+
return true, "", nil
166+
}
167+
return false, "", fmt.Errorf("failed to list OpenStackDataPlaneNodeSets: %w", err)
168+
}
169+
170+
for i := range nodesetList.Items {
171+
item := &nodesetList.Items[i]
172+
173+
secretHashes, found, err := k8s_unstructured.NestedStringMap(item.Object, "status", "secretHashes")
174+
if err != nil {
175+
return false, "", fmt.Errorf("failed to read secretHashes from nodeset %s/%s: %w",
176+
item.GetNamespace(), item.GetName(), err)
177+
}
178+
if !found {
179+
continue
180+
}
181+
182+
deployedHash, tracked := secretHashes[secretName]
183+
if !tracked {
184+
continue
185+
}
186+
187+
currentSecret := &corev1.Secret{}
188+
if err := c.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, currentSecret); err != nil {
189+
if k8s_errors.IsNotFound(err) {
190+
info := fmt.Sprintf("nodeset %s/%s: deployed secret %s no longer exists",
191+
item.GetNamespace(), item.GetName(), secretName)
192+
return false, info, nil
193+
}
194+
return false, "", fmt.Errorf("failed to get secret %s: %w", secretName, err)
195+
}
196+
197+
currentHash, hashErr := oko_secret.Hash(currentSecret)
198+
if hashErr != nil {
199+
return false, "", fmt.Errorf("failed to hash secret %s: %w", secretName, hashErr)
200+
}
201+
202+
if currentHash != deployedHash {
203+
info := fmt.Sprintf("nodeset %s/%s: secret %s has changed since last deployment",
204+
item.GetNamespace(), item.GetName(), secretName)
205+
Log.Info("Secret hash out of sync", "secret", secretName,
206+
"nodeset", item.GetName(), "deployed", deployedHash, "current", currentHash)
207+
return false, info, nil
208+
}
209+
}
210+
211+
return true, "", nil
212+
}
213+
137214
// HaveNodeSets returns true if any OpenStackDataPlaneNodeSets with non-empty
138215
// status.secretHashes exist in the given namespace. Returns false when no
139216
// NodeSets exist, none have secretHashes, or the CRD is not installed.

modules/edpm/unstructured/nodeset_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,176 @@ func TestAreSecretHashesInSync(t *testing.T) {
219219
}
220220
}
221221

222+
func TestIsSecretHashInSync_CRDNotInstalled(t *testing.T) {
223+
s := runtime.NewScheme()
224+
_ = corev1.AddToScheme(s)
225+
mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{})
226+
227+
c := fake.NewClientBuilder().
228+
WithScheme(s).
229+
WithRESTMapper(mapper).
230+
Build()
231+
232+
inSync, info, err := IsSecretHashInSync(context.Background(), c, "test", "any-secret")
233+
if err != nil {
234+
t.Errorf("IsSecretHashInSync() unexpected error: %v", err)
235+
}
236+
if !inSync {
237+
t.Errorf("IsSecretHashInSync() inSync = false, want true when CRD not installed (info: %s)", info)
238+
}
239+
}
240+
241+
func TestIsSecretHashInSync(t *testing.T) {
242+
hmacSecret := &corev1.Secret{
243+
ObjectMeta: metav1.ObjectMeta{
244+
Name: "instanceha-0-heartbeat-hmac",
245+
Namespace: "test",
246+
},
247+
Data: map[string][]byte{"hmac-key": []byte("current-key")},
248+
}
249+
hmacHash, _ := oko_secret.Hash(hmacSecret)
250+
251+
otherSecret := &corev1.Secret{
252+
ObjectMeta: metav1.ObjectMeta{
253+
Name: "nova-cell1-compute-config",
254+
Namespace: "test",
255+
},
256+
Data: map[string][]byte{"config": []byte("nova-config")},
257+
}
258+
259+
tests := []struct {
260+
name string
261+
secretName string
262+
nodesets []*k8s_unstructured.Unstructured
263+
secrets []*corev1.Secret
264+
wantInSync bool
265+
wantInfoSubstr string
266+
}{
267+
{
268+
name: "no nodesets exist",
269+
secretName: "instanceha-0-heartbeat-hmac",
270+
wantInSync: true,
271+
},
272+
{
273+
name: "secret not tracked by any nodeset",
274+
secretName: "instanceha-0-heartbeat-hmac",
275+
nodesets: []*k8s_unstructured.Unstructured{
276+
makeNodeSet("ns1", "test", map[string]string{
277+
"nova-cell1-compute-config": "some-hash",
278+
}),
279+
},
280+
secrets: []*corev1.Secret{hmacSecret, otherSecret},
281+
wantInSync: true,
282+
},
283+
{
284+
name: "secret in sync",
285+
secretName: "instanceha-0-heartbeat-hmac",
286+
nodesets: []*k8s_unstructured.Unstructured{
287+
makeNodeSet("ns1", "test", map[string]string{
288+
"instanceha-0-heartbeat-hmac": hmacHash,
289+
"nova-cell1-compute-config": "stale-hash",
290+
}),
291+
},
292+
secrets: []*corev1.Secret{hmacSecret, otherSecret},
293+
wantInSync: true,
294+
},
295+
{
296+
name: "secret out of sync",
297+
secretName: "instanceha-0-heartbeat-hmac",
298+
nodesets: []*k8s_unstructured.Unstructured{
299+
makeNodeSet("ns1", "test", map[string]string{
300+
"instanceha-0-heartbeat-hmac": "old-hash",
301+
}),
302+
},
303+
secrets: []*corev1.Secret{hmacSecret},
304+
wantInSync: false,
305+
wantInfoSubstr: "has changed since last deployment",
306+
},
307+
{
308+
name: "secret deleted",
309+
secretName: "instanceha-0-heartbeat-hmac",
310+
nodesets: []*k8s_unstructured.Unstructured{
311+
makeNodeSet("ns1", "test", map[string]string{
312+
"instanceha-0-heartbeat-hmac": "some-hash",
313+
}),
314+
},
315+
secrets: []*corev1.Secret{},
316+
wantInSync: false,
317+
wantInfoSubstr: "no longer exists",
318+
},
319+
{
320+
name: "multiple nodesets - one stale for this secret",
321+
secretName: "instanceha-0-heartbeat-hmac",
322+
nodesets: []*k8s_unstructured.Unstructured{
323+
makeNodeSet("up-to-date", "test", map[string]string{
324+
"instanceha-0-heartbeat-hmac": hmacHash,
325+
}),
326+
makeNodeSet("stale", "test", map[string]string{
327+
"instanceha-0-heartbeat-hmac": "old-hash",
328+
}),
329+
},
330+
secrets: []*corev1.Secret{hmacSecret},
331+
wantInSync: false,
332+
wantInfoSubstr: "has changed since last deployment",
333+
},
334+
{
335+
name: "other secret stale does not affect this secret",
336+
secretName: "instanceha-0-heartbeat-hmac",
337+
nodesets: []*k8s_unstructured.Unstructured{
338+
makeNodeSet("ns1", "test", map[string]string{
339+
"instanceha-0-heartbeat-hmac": hmacHash,
340+
"unrelated-secret": "stale-hash",
341+
}),
342+
},
343+
secrets: []*corev1.Secret{hmacSecret},
344+
wantInSync: true,
345+
},
346+
}
347+
348+
for _, tt := range tests {
349+
t.Run(tt.name, func(t *testing.T) {
350+
s, mapper := newTestSchemeAndMapper()
351+
352+
builder := fake.NewClientBuilder().
353+
WithScheme(s).
354+
WithRESTMapper(mapper)
355+
356+
for _, ns := range tt.nodesets {
357+
builder = builder.WithObjects(ns)
358+
}
359+
for _, sec := range tt.secrets {
360+
builder = builder.WithObjects(sec)
361+
}
362+
363+
c := builder.Build()
364+
365+
inSync, info, err := IsSecretHashInSync(
366+
context.Background(),
367+
c,
368+
"test",
369+
tt.secretName,
370+
)
371+
372+
if err != nil {
373+
t.Errorf("IsSecretHashInSync() unexpected error: %v", err)
374+
return
375+
}
376+
377+
if inSync != tt.wantInSync {
378+
t.Errorf("IsSecretHashInSync() inSync = %v, want %v (info: %s)", inSync, tt.wantInSync, info)
379+
}
380+
381+
if tt.wantInfoSubstr != "" {
382+
if info == "" {
383+
t.Errorf("IsSecretHashInSync() info is empty, want substring %q", tt.wantInfoSubstr)
384+
} else if !strings.Contains(info, tt.wantInfoSubstr) {
385+
t.Errorf("IsSecretHashInSync() info = %q, want substring %q", info, tt.wantInfoSubstr)
386+
}
387+
}
388+
})
389+
}
390+
}
391+
222392
func TestHaveNodeSets_CRDNotInstalled(t *testing.T) {
223393
s := runtime.NewScheme()
224394
_ = corev1.AddToScheme(s)

0 commit comments

Comments
 (0)