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)