From 102d67677024af8c7698eef114ee95eb4c9c33c7 Mon Sep 17 00:00:00 2001 From: Luca Miccini Date: Mon, 1 Jun 2026 09:21:24 +0200 Subject: [PATCH] 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 --- modules/edpm/unstructured/nodeset.go | 77 ++++++++++ modules/edpm/unstructured/nodeset_test.go | 170 ++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/modules/edpm/unstructured/nodeset.go b/modules/edpm/unstructured/nodeset.go index e68ed0a5..01f138b3 100644 --- a/modules/edpm/unstructured/nodeset.go +++ b/modules/edpm/unstructured/nodeset.go @@ -134,6 +134,83 @@ func AreSecretHashesInSync( return true, "", nil } +// IsSecretHashInSync checks whether a single named secret's hash matches what +// is deployed across all OpenStackDataPlaneNodeSets in the given namespace. +// Unlike AreSecretHashesInSync, it ignores all other secrets — useful when a +// controller needs to track deployment of one specific secret without being +// affected by unrelated secret changes. +// +// Returns: +// - inSync=true when the secret's hash matches in every NodeSet that tracks +// it, when no NodeSets exist, when no NodeSet tracks this secret, or when +// the OpenStackDataPlaneNodeSet CRD is not installed. +// - inSync=false with info describing the first mismatch. +func IsSecretHashInSync( + ctx context.Context, + c client.Client, + namespace string, + secretName string, +) (inSync bool, info string, err error) { + Log := log.FromContext(ctx) + + nodesetList := &k8s_unstructured.UnstructuredList{} + nodesetList.SetGroupVersionKind(schema.GroupVersionKind{ + Group: NodeSetGVK.Group, + Version: NodeSetGVK.Version, + Kind: NodeSetGVK.Kind + "List", + }) + + if err := c.List(ctx, nodesetList, client.InNamespace(namespace)); err != nil { + if meta.IsNoMatchError(err) { + return true, "", nil + } + return false, "", fmt.Errorf("failed to list OpenStackDataPlaneNodeSets: %w", err) + } + + for i := range nodesetList.Items { + item := &nodesetList.Items[i] + + secretHashes, found, err := k8s_unstructured.NestedStringMap(item.Object, "status", "secretHashes") + if err != nil { + return false, "", fmt.Errorf("failed to read secretHashes from nodeset %s/%s: %w", + item.GetNamespace(), item.GetName(), err) + } + if !found { + continue + } + + deployedHash, tracked := secretHashes[secretName] + if !tracked { + continue + } + + currentSecret := &corev1.Secret{} + if err := c.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, currentSecret); err != nil { + if k8s_errors.IsNotFound(err) { + info := fmt.Sprintf("nodeset %s/%s: deployed secret %s no longer exists", + item.GetNamespace(), item.GetName(), secretName) + return false, info, nil + } + return false, "", fmt.Errorf("failed to get secret %s: %w", secretName, err) + } + + currentHash, hashErr := oko_secret.Hash(currentSecret) + if hashErr != nil { + return false, "", fmt.Errorf("failed to hash secret %s: %w", secretName, hashErr) + } + + if currentHash != deployedHash { + info := fmt.Sprintf("nodeset %s/%s: secret %s has changed since last deployment", + item.GetNamespace(), item.GetName(), secretName) + Log.Info("Secret hash out of sync", "secret", secretName, + "nodeset", item.GetName(), "deployed", deployedHash, "current", currentHash) + return false, info, nil + } + } + + return true, "", nil +} + // HaveNodeSets returns true if any OpenStackDataPlaneNodeSets with non-empty // status.secretHashes exist in the given namespace. Returns false when no // NodeSets exist, none have secretHashes, or the CRD is not installed. diff --git a/modules/edpm/unstructured/nodeset_test.go b/modules/edpm/unstructured/nodeset_test.go index 40707d6f..fd21b867 100644 --- a/modules/edpm/unstructured/nodeset_test.go +++ b/modules/edpm/unstructured/nodeset_test.go @@ -219,6 +219,176 @@ func TestAreSecretHashesInSync(t *testing.T) { } } +func TestIsSecretHashInSync_CRDNotInstalled(t *testing.T) { + s := runtime.NewScheme() + _ = corev1.AddToScheme(s) + mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{}) + + c := fake.NewClientBuilder(). + WithScheme(s). + WithRESTMapper(mapper). + Build() + + inSync, info, err := IsSecretHashInSync(context.Background(), c, "test", "any-secret") + if err != nil { + t.Errorf("IsSecretHashInSync() unexpected error: %v", err) + } + if !inSync { + t.Errorf("IsSecretHashInSync() inSync = false, want true when CRD not installed (info: %s)", info) + } +} + +func TestIsSecretHashInSync(t *testing.T) { + hmacSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "instanceha-0-heartbeat-hmac", + Namespace: "test", + }, + Data: map[string][]byte{"hmac-key": []byte("current-key")}, + } + hmacHash, _ := oko_secret.Hash(hmacSecret) + + otherSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nova-cell1-compute-config", + Namespace: "test", + }, + Data: map[string][]byte{"config": []byte("nova-config")}, + } + + tests := []struct { + name string + secretName string + nodesets []*k8s_unstructured.Unstructured + secrets []*corev1.Secret + wantInSync bool + wantInfoSubstr string + }{ + { + name: "no nodesets exist", + secretName: "instanceha-0-heartbeat-hmac", + wantInSync: true, + }, + { + name: "secret not tracked by any nodeset", + secretName: "instanceha-0-heartbeat-hmac", + nodesets: []*k8s_unstructured.Unstructured{ + makeNodeSet("ns1", "test", map[string]string{ + "nova-cell1-compute-config": "some-hash", + }), + }, + secrets: []*corev1.Secret{hmacSecret, otherSecret}, + wantInSync: true, + }, + { + name: "secret in sync", + secretName: "instanceha-0-heartbeat-hmac", + nodesets: []*k8s_unstructured.Unstructured{ + makeNodeSet("ns1", "test", map[string]string{ + "instanceha-0-heartbeat-hmac": hmacHash, + "nova-cell1-compute-config": "stale-hash", + }), + }, + secrets: []*corev1.Secret{hmacSecret, otherSecret}, + wantInSync: true, + }, + { + name: "secret out of sync", + secretName: "instanceha-0-heartbeat-hmac", + nodesets: []*k8s_unstructured.Unstructured{ + makeNodeSet("ns1", "test", map[string]string{ + "instanceha-0-heartbeat-hmac": "old-hash", + }), + }, + secrets: []*corev1.Secret{hmacSecret}, + wantInSync: false, + wantInfoSubstr: "has changed since last deployment", + }, + { + name: "secret deleted", + secretName: "instanceha-0-heartbeat-hmac", + nodesets: []*k8s_unstructured.Unstructured{ + makeNodeSet("ns1", "test", map[string]string{ + "instanceha-0-heartbeat-hmac": "some-hash", + }), + }, + secrets: []*corev1.Secret{}, + wantInSync: false, + wantInfoSubstr: "no longer exists", + }, + { + name: "multiple nodesets - one stale for this secret", + secretName: "instanceha-0-heartbeat-hmac", + nodesets: []*k8s_unstructured.Unstructured{ + makeNodeSet("up-to-date", "test", map[string]string{ + "instanceha-0-heartbeat-hmac": hmacHash, + }), + makeNodeSet("stale", "test", map[string]string{ + "instanceha-0-heartbeat-hmac": "old-hash", + }), + }, + secrets: []*corev1.Secret{hmacSecret}, + wantInSync: false, + wantInfoSubstr: "has changed since last deployment", + }, + { + name: "other secret stale does not affect this secret", + secretName: "instanceha-0-heartbeat-hmac", + nodesets: []*k8s_unstructured.Unstructured{ + makeNodeSet("ns1", "test", map[string]string{ + "instanceha-0-heartbeat-hmac": hmacHash, + "unrelated-secret": "stale-hash", + }), + }, + secrets: []*corev1.Secret{hmacSecret}, + wantInSync: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, mapper := newTestSchemeAndMapper() + + builder := fake.NewClientBuilder(). + WithScheme(s). + WithRESTMapper(mapper) + + for _, ns := range tt.nodesets { + builder = builder.WithObjects(ns) + } + for _, sec := range tt.secrets { + builder = builder.WithObjects(sec) + } + + c := builder.Build() + + inSync, info, err := IsSecretHashInSync( + context.Background(), + c, + "test", + tt.secretName, + ) + + if err != nil { + t.Errorf("IsSecretHashInSync() unexpected error: %v", err) + return + } + + if inSync != tt.wantInSync { + t.Errorf("IsSecretHashInSync() inSync = %v, want %v (info: %s)", inSync, tt.wantInSync, info) + } + + if tt.wantInfoSubstr != "" { + if info == "" { + t.Errorf("IsSecretHashInSync() info is empty, want substring %q", tt.wantInfoSubstr) + } else if !strings.Contains(info, tt.wantInfoSubstr) { + t.Errorf("IsSecretHashInSync() info = %q, want substring %q", info, tt.wantInfoSubstr) + } + } + }) + } +} + func TestHaveNodeSets_CRDNotInstalled(t *testing.T) { s := runtime.NewScheme() _ = corev1.AddToScheme(s)