diff --git a/pkg/splunk/common/statefulset_util.go b/pkg/splunk/common/statefulset_util.go new file mode 100644 index 000000000..a5a710cf9 --- /dev/null +++ b/pkg/splunk/common/statefulset_util.go @@ -0,0 +1,69 @@ +// Copyright (c) 2018-2022 Splunk Inc. All rights reserved. + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "context" + "reflect" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// MergeStatefulSetMetaUpdates compares and merges StatefulSet ObjectMeta (labels and annotations). +// This does NOT trigger pod restarts since it only touches StatefulSet-level metadata. +// Returns true if there were any changes. +func MergeStatefulSetMetaUpdates(ctx context.Context, current, revised *metav1.ObjectMeta, name string) bool { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("MergeStatefulSetMetaUpdates").WithValues("name", name) + result := false + + // Check Annotations - normalize nil to empty map for comparison + currentAnnotations := current.Annotations + if currentAnnotations == nil { + currentAnnotations = map[string]string{} + } + revisedAnnotations := revised.Annotations + if revisedAnnotations == nil { + revisedAnnotations = map[string]string{} + } + if !reflect.DeepEqual(currentAnnotations, revisedAnnotations) { + scopedLog.Info("StatefulSet Annotations differ", + "current", current.Annotations, + "revised", revised.Annotations) + current.Annotations = revised.Annotations + result = true + } + + // Check Labels - normalize nil to empty map for comparison + currentLabels := current.Labels + if currentLabels == nil { + currentLabels = map[string]string{} + } + revisedLabels := revised.Labels + if revisedLabels == nil { + revisedLabels = map[string]string{} + } + if !reflect.DeepEqual(currentLabels, revisedLabels) { + scopedLog.Info("StatefulSet Labels differ", + "current", current.Labels, + "revised", revised.Labels) + current.Labels = revised.Labels + result = true + } + + return result +} diff --git a/pkg/splunk/common/statefulset_util_test.go b/pkg/splunk/common/statefulset_util_test.go new file mode 100644 index 000000000..70b6e56ad --- /dev/null +++ b/pkg/splunk/common/statefulset_util_test.go @@ -0,0 +1,390 @@ +// Copyright (c) 2018-2022 Splunk Inc. All rights reserved. + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "context" + "reflect" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Helper function to create a base StatefulSet for testing +func newTestStatefulSet(name, namespace string) *appsv1.StatefulSet { + var replicas int32 = 1 + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: make(map[string]string), + Annotations: make(map[string]string), + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: make(map[string]string), + Annotations: make(map[string]string), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "splunk", + Image: "splunk/splunk:8.2.0", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestMergeStatefulSetMetaUpdates(t *testing.T) { + tests := []struct { + name string + current func() *metav1.ObjectMeta + revised func() *metav1.ObjectMeta + expectedReturn bool + expectedLabels map[string]string + expectedAnnotations map[string]string + }{ + { + name: "No changes - same labels and annotations", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "x"}, + Annotations: map[string]string{"note": "value"}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "x"}, + Annotations: map[string]string{"note": "value"}, + } + }, + expectedReturn: false, + expectedLabels: map[string]string{"app": "splunk", "team": "x"}, + expectedAnnotations: map[string]string{"note": "value"}, + }, + { + name: "Label added", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "x"}, + Annotations: map[string]string{}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk", "team": "x"}, + expectedAnnotations: map[string]string{}, + }, + { + name: "Label changed", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "x"}, + Annotations: map[string]string{}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "y"}, + Annotations: map[string]string{}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk", "team": "y"}, + expectedAnnotations: map[string]string{}, + }, + { + name: "Label removed", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "x", "env": "prod"}, + Annotations: map[string]string{}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "x"}, + Annotations: map[string]string{}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk", "team": "x"}, + expectedAnnotations: map[string]string{}, + }, + { + name: "Annotation added", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{"foo": "bar"}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk"}, + expectedAnnotations: map[string]string{"foo": "bar"}, + }, + { + name: "Annotation changed", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{"foo": "bar"}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{"foo": "baz"}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk"}, + expectedAnnotations: map[string]string{"foo": "baz"}, + }, + { + name: "Both labels and annotations changed", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "x"}, + Annotations: map[string]string{"foo": "bar"}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk", "team": "y"}, + Annotations: map[string]string{"foo": "baz", "new": "annotation"}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk", "team": "y"}, + expectedAnnotations: map[string]string{"foo": "baz", "new": "annotation"}, + }, + { + name: "Nil labels in current - handles gracefully", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: nil, + Annotations: map[string]string{}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"team": "x"}, + Annotations: map[string]string{}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"team": "x"}, + expectedAnnotations: map[string]string{}, + }, + { + name: "Nil annotations in current - handles gracefully", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: nil, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{"foo": "bar"}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk"}, + expectedAnnotations: map[string]string{"foo": "bar"}, + }, + { + name: "Nil labels in revised - handles gracefully", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"team": "x"}, + Annotations: map[string]string{}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: nil, + Annotations: map[string]string{}, + } + }, + expectedReturn: true, + expectedLabels: nil, + expectedAnnotations: map[string]string{}, + }, + { + name: "Both nil in current and revised - no change", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: nil, + Annotations: nil, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: nil, + Annotations: nil, + } + }, + expectedReturn: false, + expectedLabels: nil, + expectedAnnotations: nil, + }, + { + name: "Empty maps vs nil - now considered equal (avoids false positive changes)", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{}, + Annotations: map[string]string{}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: nil, + Annotations: nil, + } + }, + expectedReturn: false, // nil and empty map are semantically equivalent + expectedLabels: map[string]string{}, // current not modified when no real change + expectedAnnotations: map[string]string{}, // current not modified when no real change + }, + { + name: "Multiple annotations added and removed", + current: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{"old1": "val1", "old2": "val2"}, + } + }, + revised: func() *metav1.ObjectMeta { + return &metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "default", + Labels: map[string]string{"app": "splunk"}, + Annotations: map[string]string{"new1": "val1", "new2": "val2"}, + } + }, + expectedReturn: true, + expectedLabels: map[string]string{"app": "splunk"}, + expectedAnnotations: map[string]string{"new1": "val1", "new2": "val2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + current := tt.current() + revised := tt.revised() + + result := MergeStatefulSetMetaUpdates(ctx, current, revised, "test-sts") + + if result != tt.expectedReturn { + t.Errorf("MergeStatefulSetMetaUpdates() returned %v, want %v", result, tt.expectedReturn) + } + + if !reflect.DeepEqual(current.Labels, tt.expectedLabels) { + t.Errorf("After merge, Labels = %v, want %v", current.Labels, tt.expectedLabels) + } + + if !reflect.DeepEqual(current.Annotations, tt.expectedAnnotations) { + t.Errorf("After merge, Annotations = %v, want %v", current.Annotations, tt.expectedAnnotations) + } + }) + } +} diff --git a/pkg/splunk/common/util.go b/pkg/splunk/common/util.go index 2515b4c79..e1b25227c 100644 --- a/pkg/splunk/common/util.go +++ b/pkg/splunk/common/util.go @@ -17,6 +17,7 @@ package common import ( "bytes" + "context" "encoding/json" "fmt" "math/rand" @@ -29,6 +30,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/log" ) func init() { @@ -47,23 +49,690 @@ func AsOwner(cr MetaObject, isController bool) metav1.OwnerReference { } } -// AppendParentMeta appends parent's metadata to a child +// Prefix constants for selective metadata propagation. +// These prefixes allow users to specify metadata that should only appear on specific resource types. +// +// Usage: +// - pod-only.prometheus.io/scrape: "true" → appears as prometheus.io/scrape: "true" on pods only +// - sts-only.example.com/priority: "high" → appears as example.com/priority: "high" on StatefulSet only +const ( + // podOnlyPrefix is stripped during Pod Template propagation. + // Example: "pod-only.prometheus.io/scrape" → "prometheus.io/scrape" + podOnlyPrefix = "pod-only." + + // stsOnlyPrefix is stripped during StatefulSet propagation. + // Example: "sts-only.example.com/priority" → "example.com/priority" + stsOnlyPrefix = "sts-only." +) + +// podTemplateExcludedPrefixes defines prefixes excluded from Pod Template metadata propagation. +// Labels/annotations with these prefixes will NOT be copied from the CR to Pod Template. +// +// The exclusion rules are: +// - kubectl.kubernetes.io/*: kubectl-managed metadata (e.g., last-applied-configuration) +// - operator.splunk.com/*: Operator-internal metadata not meant for pods +// - sts-only.*: StatefulSet-only labels that should not propagate to pods +// +// Note: pod-only.* keys are NOT excluded - they are transformed (prefix stripped) +// during propagation, allowing users to specify pod-specific metadata on the CR. +var podTemplateExcludedPrefixes = []string{ + "kubectl.kubernetes.io/", + "operator.splunk.com/", + stsOnlyPrefix, // StatefulSet-only labels don't go to pods +} + +// statefulSetExcludedPrefixes defines prefixes excluded from StatefulSet ObjectMeta propagation. +// Labels/annotations with these prefixes will NOT be copied from the CR to StatefulSet metadata. +// +// The exclusion rules are: +// - kubectl.kubernetes.io/*: kubectl-managed metadata (e.g., last-applied-configuration) +// - operator.splunk.com/*: Operator-internal metadata not meant for StatefulSet +// - pod-only.*: Pod-only labels that should not propagate to StatefulSet +// +// Note: sts-only.* keys are NOT excluded - they are transformed (prefix stripped) +// during propagation, allowing users to specify StatefulSet-specific metadata on the CR. +var statefulSetExcludedPrefixes = []string{ + "kubectl.kubernetes.io/", + "operator.splunk.com/", + podOnlyPrefix, // Pod-only labels don't go to StatefulSet +} + +// Tracking Annotations for Metadata Sync +// +// These annotation keys store JSON arrays of keys that were propagated from the CR (Custom Resource) +// to child resources (StatefulSet, Pod Template). They enable "sync" semantics rather than +// "append-only" semantics: +// +// SYNC BEHAVIOR (new): +// - Keys added to CR are propagated to child resources +// - Keys updated on CR are updated on child resources +// - Keys REMOVED from CR are REMOVED from child resources (if previously managed) +// +// APPEND-ONLY BEHAVIOR (old - used by AppendParentMeta): +// - Keys added to CR are propagated to child resources +// - Keys updated on CR may NOT update child resources (no-clobber) +// - Keys removed from CR are NOT removed from child resources +// +// By tracking which keys were propagated, the operator can distinguish between: +// - CR-managed keys: Can be safely removed when removed from CR +// - External keys: Applied by users/tools, must be preserved +// +// The annotations store sorted JSON arrays for deterministic comparison, e.g.: +// +// ["team","environment","cost-center"] +const ( + // ManagedCRLabelKeysAnnotation tracks which label keys were propagated from CR metadata. + // Value is a JSON array of label key strings, e.g., ["team","environment"]. + // Used by SyncParentMetaToStatefulSet to identify keys that can be removed. + ManagedCRLabelKeysAnnotation = "operator.splunk.com/managed-cr-label-keys" + + // ManagedCRAnnotationKeysAnnotation tracks which annotation keys were propagated from CR metadata. + // Value is a JSON array of annotation key strings. + // Used by SyncParentMetaToStatefulSet to identify keys that can be removed. + ManagedCRAnnotationKeysAnnotation = "operator.splunk.com/managed-cr-annotation-keys" +) + +// GetManagedLabelKeys returns the list of label keys that were previously propagated from CR. +// It parses the JSON array stored in the ManagedCRLabelKeysAnnotation. +// Returns an empty slice if the annotation is missing, empty, or contains invalid JSON. +func GetManagedLabelKeys(annotations map[string]string) []string { + if annotations == nil { + return []string{} + } + value, exists := annotations[ManagedCRLabelKeysAnnotation] + if !exists || value == "" { + return []string{} + } + var keys []string + if err := json.Unmarshal([]byte(value), &keys); err != nil { + return []string{} + } + return keys +} + +// GetManagedAnnotationKeys returns the list of annotation keys that were previously propagated from CR. +// It parses the JSON array stored in the ManagedCRAnnotationKeysAnnotation. +// Returns an empty slice if the annotation is missing, empty, or contains invalid JSON. +func GetManagedAnnotationKeys(annotations map[string]string) []string { + if annotations == nil { + return []string{} + } + value, exists := annotations[ManagedCRAnnotationKeysAnnotation] + if !exists || value == "" { + return []string{} + } + var keys []string + if err := json.Unmarshal([]byte(value), &keys); err != nil { + return []string{} + } + return keys +} + +// SetManagedLabelKeys stores the list of label keys that were propagated from CR. +// It serializes the keys as a sorted JSON array and stores it in ManagedCRLabelKeysAnnotation. +// If keys is nil or empty, the annotation is removed. +// The annotations map must not be nil. +func SetManagedLabelKeys(annotations map[string]string, keys []string) { + if annotations == nil { + return + } + if len(keys) == 0 { + delete(annotations, ManagedCRLabelKeysAnnotation) + return + } + // Sort keys for deterministic output + sortedKeys := make([]string, len(keys)) + copy(sortedKeys, keys) + sort.Strings(sortedKeys) + // Serialize to JSON + data, err := json.Marshal(sortedKeys) + if err != nil { + return + } + annotations[ManagedCRLabelKeysAnnotation] = string(data) +} + +// SetManagedAnnotationKeys stores the list of annotation keys that were propagated from CR. +// It serializes the keys as a sorted JSON array and stores it in ManagedCRAnnotationKeysAnnotation. +// If keys is nil or empty, the annotation is removed. +// The annotations map must not be nil. +func SetManagedAnnotationKeys(annotations map[string]string, keys []string) { + if annotations == nil { + return + } + if len(keys) == 0 { + delete(annotations, ManagedCRAnnotationKeysAnnotation) + return + } + // Sort keys for deterministic output + sortedKeys := make([]string, len(keys)) + copy(sortedKeys, keys) + sort.Strings(sortedKeys) + // Serialize to JSON + data, err := json.Marshal(sortedKeys) + if err != nil { + return + } + annotations[ManagedCRAnnotationKeysAnnotation] = string(data) +} + +// hasExcludedPrefix checks if key starts with any excluded prefix +func hasExcludedPrefix(key string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(key, prefix) { + return true + } + } + return false +} + +// IsManagedKey returns true if a key is "managed" (can be propagated from CR). +// A key is managed if it does NOT have any of the excluded prefixes. +// +// Managed keys are user-defined labels/annotations that should be synced to child resources. +// Examples of managed keys: +// - "team" (plain user label) +// - "environment" (plain user label) +// - "pod.operator.splunk.com/custom" (transformed during propagation) +// +// Examples of non-managed keys: +// - "kubectl.kubernetes.io/last-applied-configuration" (kubectl internal) +// - "operator.splunk.com/finalizer" (operator internal) +func IsManagedKey(key string, excludedPrefixes []string) bool { + return !hasExcludedPrefix(key, excludedPrefixes) +} + +// IsProtectedKey returns true if a key must be preserved and not removed during sync. +// +// A key is protected if it: +// - Is part of the selector labels (used for pod selection by StatefulSet) +// - Has an excluded prefix (operator-managed or system labels) +// +// Protected keys are NEVER removed during sync, even if they were previously managed. +// This prevents breaking StatefulSet pod selection which relies on immutable selectors. +// +// Note: In practice, selector labels shouldn't appear in managed key lists because +// they are set by the operator, not propagated from CR metadata. This is a safety check. +func IsProtectedKey(key string, selectorLabels map[string]string, excludedPrefixes []string) bool { + // Key is protected if it's in selector labels + if _, exists := selectorLabels[key]; exists { + return true + } + // Key is protected if it has an excluded prefix + return hasExcludedPrefix(key, excludedPrefixes) +} + +// stripTargetPrefix removes the target prefix from a key if present. +// Returns the transformed key and true if transformation occurred, +// or the original key and false if no transformation was needed. +// +// This enables users to apply metadata selectively to specific resource types +// using a simple prefix-stripping pattern: +// +// Example transformations: +// - "pod-only.prometheus.io/scrape" → "prometheus.io/scrape" (for Pod Template) +// - "pod-only.istio.io/inject" → "istio.io/inject" (for Pod Template) +// - "sts-only.example.com/priority" → "example.com/priority" (for StatefulSet) +// +// Use case: A user wants a label only on pods, not on the StatefulSet: +// +// apiVersion: enterprise.splunk.com/v4 +// kind: Standalone +// metadata: +// labels: +// pod-only.prometheus.io/scrape: "true" # Only appears on pods as prometheus.io/scrape +func stripTargetPrefix(key, prefix string) (string, bool) { + if strings.HasPrefix(key, prefix) { + return key[len(prefix):], true + } + return key, false +} + +// AppendParentMeta appends parent's metadata to a child (typically Pod Template). +// This function uses APPEND-ONLY semantics - it only adds keys that don't exist on the child. +// +// Behavior: +// - Excludes labels/annotations with excluded prefixes (kubectl.kubernetes.io/*, operator.splunk.com/*, sts-only.*) +// - Transforms pod-only.* keys by stripping the prefix (e.g., pod-only.prometheus.io/scrape → prometheus.io/scrape) +// - Does NOT overwrite existing keys on child (no-clobber) +// - Does NOT remove keys from child that are removed from parent +// +// Conflict Resolution: +// - If both pod-only.XXXX/YYYY and explicit XXXX/YYYY exist on parent, the prefixed key wins (more specific) +// +// For full sync semantics (including key removal), use SyncParentMetaToPodTemplate instead. +// +// Deprecated: Use SyncParentMetaToPodTemplate for new code that needs sync semantics. +// This function is retained for backward compatibility and for cases where +// append-only behavior is explicitly desired. func AppendParentMeta(child, parent metav1.Object) { - // append labels from parent + // append labels from parent (excluding StatefulSet-only prefixes, transforming pod prefix) + for k, v := range parent.GetLabels() { + finalKey := k + wasTransformed := false + + // Transform pod-only.* by stripping the prefix + // Example: pod-only.prometheus.io/scrape → prometheus.io/scrape + if newKey, transformed := stripTargetPrefix(k, podOnlyPrefix); transformed { + finalKey = newKey + wasTransformed = true + // Conflict resolution: prefixed key wins over explicit key (more specific) + // Don't skip - we'll set the value below, overwriting any explicit key + } + + // Skip if child already has this key (no clobber) - but allow transformed keys to win + if _, ok := child.GetLabels()[finalKey]; ok && !wasTransformed { + continue + } + + // For transformed keys, we intentionally propagate them + // For non-transformed keys, apply standard exclusion logic + if !wasTransformed && hasExcludedPrefix(k, podTemplateExcludedPrefixes) { + continue + } + + child.GetLabels()[finalKey] = v + } + + // append annotations from parent (excluding StatefulSet-only prefixes, transforming pod prefix) + for k, v := range parent.GetAnnotations() { + finalKey := k + wasTransformed := false + + // Transform pod-only.* by stripping the prefix + // Example: pod-only.prometheus.io/scrape → prometheus.io/scrape + if newKey, transformed := stripTargetPrefix(k, podOnlyPrefix); transformed { + finalKey = newKey + wasTransformed = true + // Conflict resolution: prefixed key wins over explicit key (more specific) + // Don't skip - we'll set the value below, overwriting any explicit key + } + + // Skip if child already has this key (no clobber) - but allow transformed keys to win + if _, ok := child.GetAnnotations()[finalKey]; ok && !wasTransformed { + continue + } + + // For transformed keys, we intentionally propagate them + // For non-transformed keys, apply standard exclusion logic + if !wasTransformed && hasExcludedPrefix(k, podTemplateExcludedPrefixes) { + continue + } + + child.GetAnnotations()[finalKey] = v + } +} + +// ComputeDesiredPodTemplateKeys calculates the labels and annotations from parent (CR) +// that are eligible for propagation to Pod Template. +// It applies prefix filtering (excludes kubectl.kubernetes.io/*, operator.splunk.com/*, sts-only.*) +// and prefix transformation (pod-only.* → prefix stripped, e.g., pod-only.prometheus.io/scrape → prometheus.io/scrape). +// +// Conflict Resolution: +// - If both pod-only.XXXX/YYYY and explicit XXXX/YYYY exist on parent, the prefixed key wins (more specific) +// +// Returns maps of desired labels and annotations with transformed keys. +func ComputeDesiredPodTemplateKeys(parent metav1.Object) (labels map[string]string, annotations map[string]string) { + labels = make(map[string]string) + annotations = make(map[string]string) + + // Process labels - first pass: collect all non-prefixed keys + for k, v := range parent.GetLabels() { + // Skip keys with excluded prefixes + if hasExcludedPrefix(k, podTemplateExcludedPrefixes) { + continue + } + // Skip pod-only.* keys in first pass (handled in second pass) + if strings.HasPrefix(k, podOnlyPrefix) { + continue + } + labels[k] = v + } + + // Process labels - second pass: add transformed pod-only.* keys (prefixed keys win as more specific) for k, v := range parent.GetLabels() { - // prevent clobber of labels added by operator - if _, ok := child.GetLabels()[k]; !ok { - child.GetLabels()[k] = v + if newKey, transformed := stripTargetPrefix(k, podOnlyPrefix); transformed { + // Conflict resolution: prefixed key wins over explicit key (more specific) + labels[newKey] = v } } - // append annotations from parent + // Process annotations - first pass: collect all non-prefixed keys for k, v := range parent.GetAnnotations() { - // ignore Annotations set by kubectl - // AND prevent clobber of annotations added by operator - if _, ok := child.GetAnnotations()[k]; !ok && !strings.HasPrefix(k, "kubectl.kubernetes.io/") { - child.GetAnnotations()[k] = v + // Skip keys with excluded prefixes + if hasExcludedPrefix(k, podTemplateExcludedPrefixes) { + continue + } + // Skip pod-only.* keys in first pass (handled in second pass) + if strings.HasPrefix(k, podOnlyPrefix) { + continue } + annotations[k] = v + } + + // Process annotations - second pass: add transformed pod-only.* keys (prefixed keys win as more specific) + for k, v := range parent.GetAnnotations() { + if newKey, transformed := stripTargetPrefix(k, podOnlyPrefix); transformed { + // Conflict resolution: prefixed key wins over explicit key (more specific) + annotations[newKey] = v + } + } + + return labels, annotations +} + +// SyncParentMeta synchronizes parent (CR) metadata to child (Pod Template) with full sync semantics. +// Unlike AppendParentMeta which only adds, this function also removes keys that were previously +// managed but no longer exist on the parent. +// +// Parameters: +// - ctx: Context for logging +// - child: The child object (Pod Template) whose metadata will be updated +// - parent: The parent object (CR) that is the source of truth for metadata +// - protectedLabels: Labels that must not be overwritten by parent metadata (typically selector labels). +// These are labels set by the operator that must match the StatefulSet's immutable selector. +// If a parent label key exists in protectedLabels, it will be skipped during propagation. +// - previousManagedLabels: Keys that were previously propagated from CR (for removal detection) +// - previousManagedAnnotations: Keys that were previously propagated from CR (for removal detection) +// +// Returns: +// - newManagedLabels: Keys that are now managed (currently propagated from CR) +// - newManagedAnnotations: Keys that are now managed (currently propagated from CR) +func SyncParentMetaToPodTemplate(ctx context.Context, child, parent metav1.Object, protectedLabels map[string]string, previousManagedLabels, previousManagedAnnotations []string) (newManagedLabels, newManagedAnnotations []string) { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("SyncParentMetaToPodTemplate") + + // Compute desired keys from parent + desiredLabels, desiredAnnotations := ComputeDesiredPodTemplateKeys(parent) + + // Initialize child maps if nil + if child.GetLabels() == nil { + child.SetLabels(make(map[string]string)) + } + if child.GetAnnotations() == nil { + child.SetAnnotations(make(map[string]string)) + } + + childLabels := child.GetLabels() + childAnnotations := child.GetAnnotations() + + // Create sets for efficient lookup + previousLabelSet := make(map[string]bool) + for _, k := range previousManagedLabels { + previousLabelSet[k] = true + } + previousAnnotationSet := make(map[string]bool) + for _, k := range previousManagedAnnotations { + previousAnnotationSet[k] = true + } + + // Track changes for logging + labelsAdded, labelsUpdated, labelsRemoved := 0, 0, 0 + annotationsAdded, annotationsUpdated, annotationsRemoved := 0, 0, 0 + + // Sync labels: add/update desired keys, but skip protected labels + for k, v := range desiredLabels { + // Skip protected labels - these must not be overwritten by CR metadata + // Protected labels are typically selector labels set by the operator + if _, isProtected := protectedLabels[k]; isProtected { + continue + } + if existing, exists := childLabels[k]; exists { + if existing != v { + labelsUpdated++ + } + } else { + labelsAdded++ + } + childLabels[k] = v + } + + // Sync labels: remove previously managed keys that are no longer desired + // Skip protected labels - they must never be removed even if they were in previousManagedLabels + for _, k := range previousManagedLabels { + if _, isProtected := protectedLabels[k]; isProtected { + continue + } + if _, stillDesired := desiredLabels[k]; !stillDesired { + delete(childLabels, k) + labelsRemoved++ + } + } + + // Sync annotations: add/update desired keys + for k, v := range desiredAnnotations { + if existing, exists := childAnnotations[k]; exists { + if existing != v { + annotationsUpdated++ + } + } else { + annotationsAdded++ + } + childAnnotations[k] = v + } + + // Sync annotations: remove previously managed keys that are no longer desired + for _, k := range previousManagedAnnotations { + if _, stillDesired := desiredAnnotations[k]; !stillDesired { + delete(childAnnotations, k) + annotationsRemoved++ + } + } + + // Build list of currently managed keys (excluding protected labels) + newManagedLabels = make([]string, 0, len(desiredLabels)) + for k := range desiredLabels { + // Don't track protected labels as "managed" since we didn't propagate them + if _, isProtected := protectedLabels[k]; isProtected { + continue + } + newManagedLabels = append(newManagedLabels, k) + } + sort.Strings(newManagedLabels) + + newManagedAnnotations = make([]string, 0, len(desiredAnnotations)) + for k := range desiredAnnotations { + newManagedAnnotations = append(newManagedAnnotations, k) + } + sort.Strings(newManagedAnnotations) + + // Log summary of changes (Info for removals, Debug for adds/updates) + if labelsRemoved > 0 || annotationsRemoved > 0 { + scopedLog.Info("Pod template metadata sync removed keys", + "labelsRemoved", labelsRemoved, + "annotationsRemoved", annotationsRemoved) + } + if labelsAdded > 0 || labelsUpdated > 0 || annotationsAdded > 0 || annotationsUpdated > 0 { + scopedLog.V(1).Info("Pod template metadata sync added/updated keys", + "labelsAdded", labelsAdded, + "labelsUpdated", labelsUpdated, + "annotationsAdded", annotationsAdded, + "annotationsUpdated", annotationsUpdated) + } + + return newManagedLabels, newManagedAnnotations +} + +// ComputeDesiredStatefulSetKeys calculates the labels and annotations from parent (CR) +// that are eligible for propagation to StatefulSet ObjectMeta. +// It applies prefix filtering (excludes kubectl.kubernetes.io/*, operator.splunk.com/*, pod-only.*) +// and prefix transformation (sts-only.* → prefix stripped, e.g., sts-only.example.com/priority → example.com/priority). +// +// Conflict Resolution: +// - If both sts-only.XXXX/YYYY and explicit XXXX/YYYY exist on parent, the prefixed key wins (more specific) +// +// Returns maps of desired labels and annotations with transformed keys. +func ComputeDesiredStatefulSetKeys(parent metav1.Object) (labels map[string]string, annotations map[string]string) { + labels = make(map[string]string) + annotations = make(map[string]string) + + // Process labels - first pass: collect all non-prefixed keys + for k, v := range parent.GetLabels() { + // Skip keys with excluded prefixes + if hasExcludedPrefix(k, statefulSetExcludedPrefixes) { + continue + } + // Skip sts-only.* keys in first pass (handled in second pass) + if strings.HasPrefix(k, stsOnlyPrefix) { + continue + } + labels[k] = v + } + + // Process labels - second pass: add transformed sts-only.* keys (prefixed keys win as more specific) + for k, v := range parent.GetLabels() { + if newKey, transformed := stripTargetPrefix(k, stsOnlyPrefix); transformed { + // Conflict resolution: prefixed key wins over explicit key (more specific) + labels[newKey] = v + } + } + + // Process annotations - first pass: collect all non-prefixed keys + for k, v := range parent.GetAnnotations() { + // Skip keys with excluded prefixes + if hasExcludedPrefix(k, statefulSetExcludedPrefixes) { + continue + } + // Skip sts-only.* keys in first pass (handled in second pass) + if strings.HasPrefix(k, stsOnlyPrefix) { + continue + } + annotations[k] = v + } + + // Process annotations - second pass: add transformed sts-only.* keys (prefixed keys win as more specific) + for k, v := range parent.GetAnnotations() { + if newKey, transformed := stripTargetPrefix(k, stsOnlyPrefix); transformed { + // Conflict resolution: prefixed key wins over explicit key (more specific) + annotations[newKey] = v + } + } + + return labels, annotations +} + +// SyncParentMetaToStatefulSet synchronizes parent (CR) metadata to StatefulSet ObjectMeta with full sync semantics. +// Unlike AppendParentMetaToStatefulSet which only adds, this function also removes keys that were previously +// managed but no longer exist on the parent. +// +// Parameters: +// - ctx: Context for logging +// - child: The StatefulSet whose metadata will be updated +// - parent: The parent object (CR) that is the source of truth for metadata +// - selectorLabels: Labels used for pod selection that must never be removed +// +// This function: +// - Reads previous managed keys from child's annotations (using GetManagedLabelKeys/GetManagedAnnotationKeys) +// - Computes desired keys from parent using ComputeDesiredStatefulSetKeys +// - Adds/updates desired keys +// - Removes keys that are in previousManaged but not in desired (respecting protected keys) +// - Updates managed key tracking annotations (using SetManagedLabelKeys/SetManagedAnnotationKeys) +func SyncParentMetaToStatefulSet(ctx context.Context, child, parent metav1.Object, selectorLabels map[string]string) { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("SyncParentMetaToStatefulSet").WithValues( + "namespace", child.GetNamespace(), + "name", child.GetName()) + + // Initialize child maps if nil + if child.GetLabels() == nil { + child.SetLabels(make(map[string]string)) + } + if child.GetAnnotations() == nil { + child.SetAnnotations(make(map[string]string)) + } + + childLabels := child.GetLabels() + childAnnotations := child.GetAnnotations() + + // Read previous managed keys from child's annotations + previousManagedLabels := GetManagedLabelKeys(childAnnotations) + previousManagedAnnotations := GetManagedAnnotationKeys(childAnnotations) + + // Compute desired keys from parent + desiredLabels, desiredAnnotations := ComputeDesiredStatefulSetKeys(parent) + + // Track changes for logging + labelsAdded, labelsUpdated, labelsRemoved := 0, 0, 0 + annotationsAdded, annotationsUpdated, annotationsRemoved := 0, 0, 0 + + // Sync labels: add/update desired keys, but protect selector labels + for k, v := range desiredLabels { + // Skip selector labels - these must not be overwritten by CR metadata + // This prevents the "selector does not match template labels" error + if _, isSelectorLabel := selectorLabels[k]; isSelectorLabel { + continue + } + if existing, exists := childLabels[k]; exists { + if existing != v { + labelsUpdated++ + } + } else { + labelsAdded++ + } + childLabels[k] = v + } + + // Sync labels: remove previously managed keys that are no longer desired + // Only protect selector labels - managed keys are removable even with excluded prefixes + // because they were put there by us from CR metadata (possibly transformed) + for _, k := range previousManagedLabels { + if _, stillDesired := desiredLabels[k]; !stillDesired { + // Only protect selector labels - they must never be removed + if _, isSelectorLabel := selectorLabels[k]; !isSelectorLabel { + delete(childLabels, k) + labelsRemoved++ + } + } + } + + // Sync annotations: add/update desired keys + for k, v := range desiredAnnotations { + if existing, exists := childAnnotations[k]; exists { + if existing != v { + annotationsUpdated++ + } + } else { + annotationsAdded++ + } + childAnnotations[k] = v + } + + // Sync annotations: remove previously managed keys that are no longer desired + // Annotations don't have selector label concerns, so all managed keys are removable + for _, k := range previousManagedAnnotations { + if _, stillDesired := desiredAnnotations[k]; !stillDesired { + delete(childAnnotations, k) + annotationsRemoved++ + } + } + + // Build list of currently managed keys + newManagedLabels := make([]string, 0, len(desiredLabels)) + for k := range desiredLabels { + newManagedLabels = append(newManagedLabels, k) + } + + newManagedAnnotations := make([]string, 0, len(desiredAnnotations)) + for k := range desiredAnnotations { + newManagedAnnotations = append(newManagedAnnotations, k) + } + + // Update managed key tracking annotations + SetManagedLabelKeys(childAnnotations, newManagedLabels) + SetManagedAnnotationKeys(childAnnotations, newManagedAnnotations) + + // Log summary of changes (Info for removals, Debug for adds/updates) + if labelsRemoved > 0 || annotationsRemoved > 0 { + scopedLog.Info("StatefulSet metadata sync removed keys", + "labelsRemoved", labelsRemoved, + "annotationsRemoved", annotationsRemoved) + } + if labelsAdded > 0 || labelsUpdated > 0 || annotationsAdded > 0 || annotationsUpdated > 0 { + scopedLog.V(1).Info("StatefulSet metadata sync added/updated keys", + "labelsAdded", labelsAdded, + "labelsUpdated", labelsUpdated, + "annotationsAdded", annotationsAdded, + "annotationsUpdated", annotationsUpdated) } } diff --git a/pkg/splunk/common/util_test.go b/pkg/splunk/common/util_test.go index c92d00d86..d1a8f7d29 100644 --- a/pkg/splunk/common/util_test.go +++ b/pkg/splunk/common/util_test.go @@ -17,6 +17,7 @@ package common import ( "bytes" + "context" "encoding/json" "fmt" "math" @@ -116,6 +117,1887 @@ func TestAppendParentMeta(t *testing.T) { } +func TestHasExcludedPrefix(t *testing.T) { + tests := []struct { + name string + key string + prefixes []string + expected bool + }{ + { + name: "sts-only prefix excluded from pod template", + key: "sts-only.example.com/priority", + prefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "pod-only prefix not excluded from pod template", + key: "pod-only.prometheus.io/scrape", + prefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "regular label not excluded from pod template", + key: "team", + prefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "pod-only prefix excluded from statefulset", + key: "pod-only.prometheus.io/scrape", + prefixes: statefulSetExcludedPrefixes, + expected: true, + }, + { + name: "sts-only prefix not excluded from statefulset", + key: "sts-only.example.com/priority", + prefixes: statefulSetExcludedPrefixes, + expected: false, + }, + { + name: "kubectl prefix excluded from both", + key: "kubectl.kubernetes.io/last-applied", + prefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "operator prefix excluded from both", + key: "operator.splunk.com/internal", + prefixes: statefulSetExcludedPrefixes, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasExcludedPrefix(tt.key, tt.prefixes) + if got != tt.expected { + t.Errorf("hasExcludedPrefix(%q, %v) = %v; want %v", tt.key, tt.prefixes, got, tt.expected) + } + }) + } +} + +func TestManagedCRAnnotationConstants(t *testing.T) { + // Verify constants have expected values + if ManagedCRLabelKeysAnnotation != "operator.splunk.com/managed-cr-label-keys" { + t.Errorf("ManagedCRLabelKeysAnnotation = %q; want %q", ManagedCRLabelKeysAnnotation, "operator.splunk.com/managed-cr-label-keys") + } + if ManagedCRAnnotationKeysAnnotation != "operator.splunk.com/managed-cr-annotation-keys" { + t.Errorf("ManagedCRAnnotationKeysAnnotation = %q; want %q", ManagedCRAnnotationKeysAnnotation, "operator.splunk.com/managed-cr-annotation-keys") + } +} + +func TestGetManagedLabelKeys(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected []string + }{ + { + name: "nil annotations returns empty slice", + annotations: nil, + expected: []string{}, + }, + { + name: "empty annotations returns empty slice", + annotations: map[string]string{}, + expected: []string{}, + }, + { + name: "missing annotation returns empty slice", + annotations: map[string]string{ + "other-annotation": "value", + }, + expected: []string{}, + }, + { + name: "empty annotation value returns empty slice", + annotations: map[string]string{ + ManagedCRLabelKeysAnnotation: "", + }, + expected: []string{}, + }, + { + name: "invalid JSON returns empty slice", + annotations: map[string]string{ + ManagedCRLabelKeysAnnotation: "not-valid-json", + }, + expected: []string{}, + }, + { + name: "empty JSON array returns empty slice", + annotations: map[string]string{ + ManagedCRLabelKeysAnnotation: "[]", + }, + expected: []string{}, + }, + { + name: "single key", + annotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["team"]`, + }, + expected: []string{"team"}, + }, + { + name: "multiple keys", + annotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["environment","team","version"]`, + }, + expected: []string{"environment", "team", "version"}, + }, + { + name: "keys with special characters", + annotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["mycompany.com/cost-center","app.kubernetes.io/name"]`, + }, + expected: []string{"mycompany.com/cost-center", "app.kubernetes.io/name"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetManagedLabelKeys(tt.annotations) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("GetManagedLabelKeys() = %v; want %v", got, tt.expected) + } + }) + } +} + +func TestGetManagedAnnotationKeys(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected []string + }{ + { + name: "nil annotations returns empty slice", + annotations: nil, + expected: []string{}, + }, + { + name: "empty annotations returns empty slice", + annotations: map[string]string{}, + expected: []string{}, + }, + { + name: "missing annotation returns empty slice", + annotations: map[string]string{ + "other-annotation": "value", + }, + expected: []string{}, + }, + { + name: "empty annotation value returns empty slice", + annotations: map[string]string{ + ManagedCRAnnotationKeysAnnotation: "", + }, + expected: []string{}, + }, + { + name: "invalid JSON returns empty slice", + annotations: map[string]string{ + ManagedCRAnnotationKeysAnnotation: "{invalid}", + }, + expected: []string{}, + }, + { + name: "empty JSON array returns empty slice", + annotations: map[string]string{ + ManagedCRAnnotationKeysAnnotation: "[]", + }, + expected: []string{}, + }, + { + name: "single key", + annotations: map[string]string{ + ManagedCRAnnotationKeysAnnotation: `["description"]`, + }, + expected: []string{"description"}, + }, + { + name: "multiple keys", + annotations: map[string]string{ + ManagedCRAnnotationKeysAnnotation: `["contact","description","owner"]`, + }, + expected: []string{"contact", "description", "owner"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetManagedAnnotationKeys(tt.annotations) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("GetManagedAnnotationKeys() = %v; want %v", got, tt.expected) + } + }) + } +} + +func TestSetManagedLabelKeys(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + keys []string + expectedAnnotation string + shouldExist bool + }{ + { + name: "nil annotations is no-op", + annotations: nil, + keys: []string{"team"}, + shouldExist: false, + }, + { + name: "nil keys removes annotation", + annotations: map[string]string{ManagedCRLabelKeysAnnotation: `["old"]`}, + keys: nil, + shouldExist: false, + }, + { + name: "empty keys removes annotation", + annotations: map[string]string{ManagedCRLabelKeysAnnotation: `["old"]`}, + keys: []string{}, + shouldExist: false, + }, + { + name: "single key", + annotations: map[string]string{}, + keys: []string{"team"}, + expectedAnnotation: `["team"]`, + shouldExist: true, + }, + { + name: "multiple keys are sorted", + annotations: map[string]string{}, + keys: []string{"zebra", "alpha", "middle"}, + expectedAnnotation: `["alpha","middle","zebra"]`, + shouldExist: true, + }, + { + name: "keys with special characters", + annotations: map[string]string{}, + keys: []string{"mycompany.com/team", "app.kubernetes.io/name"}, + expectedAnnotation: `["app.kubernetes.io/name","mycompany.com/team"]`, + shouldExist: true, + }, + { + name: "overwrites existing annotation", + annotations: map[string]string{ManagedCRLabelKeysAnnotation: `["old"]`}, + keys: []string{"new"}, + expectedAnnotation: `["new"]`, + shouldExist: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetManagedLabelKeys(tt.annotations, tt.keys) + if tt.annotations == nil { + return // no-op case + } + got, exists := tt.annotations[ManagedCRLabelKeysAnnotation] + if exists != tt.shouldExist { + t.Errorf("SetManagedLabelKeys() annotation exists = %v; want %v", exists, tt.shouldExist) + } + if tt.shouldExist && got != tt.expectedAnnotation { + t.Errorf("SetManagedLabelKeys() annotation = %q; want %q", got, tt.expectedAnnotation) + } + }) + } +} + +func TestSetManagedAnnotationKeys(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + keys []string + expectedAnnotation string + shouldExist bool + }{ + { + name: "nil annotations is no-op", + annotations: nil, + keys: []string{"description"}, + shouldExist: false, + }, + { + name: "nil keys removes annotation", + annotations: map[string]string{ManagedCRAnnotationKeysAnnotation: `["old"]`}, + keys: nil, + shouldExist: false, + }, + { + name: "empty keys removes annotation", + annotations: map[string]string{ManagedCRAnnotationKeysAnnotation: `["old"]`}, + keys: []string{}, + shouldExist: false, + }, + { + name: "single key", + annotations: map[string]string{}, + keys: []string{"description"}, + expectedAnnotation: `["description"]`, + shouldExist: true, + }, + { + name: "multiple keys are sorted", + annotations: map[string]string{}, + keys: []string{"owner", "contact", "description"}, + expectedAnnotation: `["contact","description","owner"]`, + shouldExist: true, + }, + { + name: "overwrites existing annotation", + annotations: map[string]string{ManagedCRAnnotationKeysAnnotation: `["old"]`}, + keys: []string{"new"}, + expectedAnnotation: `["new"]`, + shouldExist: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetManagedAnnotationKeys(tt.annotations, tt.keys) + if tt.annotations == nil { + return // no-op case + } + got, exists := tt.annotations[ManagedCRAnnotationKeysAnnotation] + if exists != tt.shouldExist { + t.Errorf("SetManagedAnnotationKeys() annotation exists = %v; want %v", exists, tt.shouldExist) + } + if tt.shouldExist && got != tt.expectedAnnotation { + t.Errorf("SetManagedAnnotationKeys() annotation = %q; want %q", got, tt.expectedAnnotation) + } + }) + } +} + +func TestManagedKeysRoundTrip(t *testing.T) { + // Test that Set followed by Get returns the same keys (sorted) + tests := []struct { + name string + keys []string + expected []string + }{ + { + name: "empty keys", + keys: []string{}, + expected: []string{}, + }, + { + name: "single key", + keys: []string{"team"}, + expected: []string{"team"}, + }, + { + name: "multiple keys unsorted", + keys: []string{"zebra", "alpha", "middle"}, + expected: []string{"alpha", "middle", "zebra"}, + }, + { + name: "keys with special characters", + keys: []string{"mycompany.com/team", "app.kubernetes.io/name", "environment"}, + expected: []string{"app.kubernetes.io/name", "environment", "mycompany.com/team"}, + }, + } + + for _, tt := range tests { + t.Run("labels: "+tt.name, func(t *testing.T) { + annotations := map[string]string{} + SetManagedLabelKeys(annotations, tt.keys) + got := GetManagedLabelKeys(annotations) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("Round trip labels: got %v; want %v", got, tt.expected) + } + }) + + t.Run("annotations: "+tt.name, func(t *testing.T) { + annotations := map[string]string{} + SetManagedAnnotationKeys(annotations, tt.keys) + got := GetManagedAnnotationKeys(annotations) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("Round trip annotations: got %v; want %v", got, tt.expected) + } + }) + } +} + +func TestIsManagedKey(t *testing.T) { + tests := []struct { + name string + key string + excludedPrefixes []string + expected bool + }{ + { + name: "user label is managed", + key: "team", + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "user label with domain is managed", + key: "mycompany.com/team", + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "kubectl prefix is not managed", + key: "kubectl.kubernetes.io/last-applied-configuration", + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "operator prefix is not managed", + key: "operator.splunk.com/internal", + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "sts-only prefix is not managed for pod template", + key: "sts-only.example.com/priority", + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "pod-only prefix is managed for pod template", + key: "pod-only.prometheus.io/scrape", + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "pod-only prefix is not managed for statefulset", + key: "pod-only.prometheus.io/scrape", + excludedPrefixes: statefulSetExcludedPrefixes, + expected: false, + }, + { + name: "sts-only prefix is managed for statefulset", + key: "sts-only.example.com/priority", + excludedPrefixes: statefulSetExcludedPrefixes, + expected: true, + }, + { + name: "app.kubernetes.io labels are managed", + key: "app.kubernetes.io/name", + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "empty prefixes means all keys are managed", + key: "operator.splunk.com/internal", + excludedPrefixes: []string{}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsManagedKey(tt.key, tt.excludedPrefixes) + if got != tt.expected { + t.Errorf("IsManagedKey(%q, %v) = %v; want %v", tt.key, tt.excludedPrefixes, got, tt.expected) + } + }) + } +} + +func TestIsProtectedKey(t *testing.T) { + selectorLabels := map[string]string{ + "app.kubernetes.io/name": "indexer", + "app.kubernetes.io/instance": "splunk-test-indexer", + "app.kubernetes.io/managed-by": "splunk-operator", + "app.kubernetes.io/component": "indexer", + "app.kubernetes.io/part-of": "splunk-test-indexer", + } + + tests := []struct { + name string + key string + selectorLabels map[string]string + excludedPrefixes []string + expected bool + }{ + { + name: "selector label is protected", + key: "app.kubernetes.io/name", + selectorLabels: selectorLabels, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "selector label instance is protected", + key: "app.kubernetes.io/instance", + selectorLabels: selectorLabels, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "operator prefix is protected", + key: "operator.splunk.com/internal", + selectorLabels: selectorLabels, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "kubectl prefix is protected", + key: "kubectl.kubernetes.io/last-applied-configuration", + selectorLabels: selectorLabels, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "user label is not protected", + key: "team", + selectorLabels: selectorLabels, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "user label with domain is not protected", + key: "mycompany.com/cost-center", + selectorLabels: selectorLabels, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "empty selector labels - excluded prefix still protected", + key: "operator.splunk.com/internal", + selectorLabels: map[string]string{}, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "empty selector labels - user key not protected", + key: "team", + selectorLabels: map[string]string{}, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "nil selector labels - excluded prefix still protected", + key: "kubectl.kubernetes.io/last-applied", + selectorLabels: nil, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "nil selector labels - user key not protected", + key: "environment", + selectorLabels: nil, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + { + name: "sts-only prefix protected for pod template", + key: "sts-only.example.com/priority", + selectorLabels: selectorLabels, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: true, + }, + { + name: "pod-only prefix not protected for pod template", + key: "pod-only.prometheus.io/scrape", + selectorLabels: map[string]string{}, + excludedPrefixes: podTemplateExcludedPrefixes, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsProtectedKey(tt.key, tt.selectorLabels, tt.excludedPrefixes) + if got != tt.expected { + t.Errorf("IsProtectedKey(%q, %v, %v) = %v; want %v", tt.key, tt.selectorLabels, tt.excludedPrefixes, got, tt.expected) + } + }) + } +} + +func TestStripTargetPrefix(t *testing.T) { + tests := []struct { + name string + key string + prefix string + wantKey string + wantTransformed bool + }{ + { + name: "strips pod-only prefix with prometheus.io domain", + key: "pod-only.prometheus.io/scrape", + prefix: "pod-only.", + wantKey: "prometheus.io/scrape", + wantTransformed: true, + }, + { + name: "strips pod-only prefix with istio.io domain", + key: "pod-only.istio.io/inject", + prefix: "pod-only.", + wantKey: "istio.io/inject", + wantTransformed: true, + }, + { + name: "strips sts-only prefix with example.com domain", + key: "sts-only.example.com/priority", + prefix: "sts-only.", + wantKey: "example.com/priority", + wantTransformed: true, + }, + { + name: "strips sts-only prefix with custom domain", + key: "sts-only.custom.example.org/metric", + prefix: "sts-only.", + wantKey: "custom.example.org/metric", + wantTransformed: true, + }, + { + name: "no-op for non-matching prefix", + key: "team", + prefix: "pod-only.", + wantKey: "team", + wantTransformed: false, + }, + { + name: "no-op for different prefix", + key: "sts-only.example.com/priority", + prefix: "pod-only.", + wantKey: "sts-only.example.com/priority", + wantTransformed: false, + }, + { + name: "strips prefix leaving empty remainder", + key: "pod-only.", + prefix: "pod-only.", + wantKey: "", + wantTransformed: true, + }, + { + name: "empty key", + key: "", + prefix: "pod-only.", + wantKey: "", + wantTransformed: false, + }, + { + name: "partial prefix match should not transform", + key: "pod-only", + prefix: "pod-only.", + wantKey: "pod-only", + wantTransformed: false, + }, + { + name: "preserves multiple slashes in key", + key: "pod-only.company.com/category/subcategory", + prefix: "pod-only.", + wantKey: "company.com/category/subcategory", + wantTransformed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotKey, gotTransformed := stripTargetPrefix(tt.key, tt.prefix) + if gotKey != tt.wantKey { + t.Errorf("stripTargetPrefix() key = %q; want %q", gotKey, tt.wantKey) + } + if gotTransformed != tt.wantTransformed { + t.Errorf("stripTargetPrefix() transformed = %v; want %v", gotTransformed, tt.wantTransformed) + } + }) + } +} + +func TestAppendParentMeta_PrefixFiltering(t *testing.T) { + parent := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "team": "platform", + "sts-only.example.com/priority": "high", // Should be filtered (StatefulSet-only) + "pod-only.prometheus.io/scrape": "true", // Should be TRANSFORMED to prometheus.io/scrape + }, + Annotations: map[string]string{ + "description": "test", + "sts-only.example.com/owner": "ops-team", // Should be filtered + "pod-only.istio.io/inject": "true", // Should be TRANSFORMED to istio.io/inject + }, + }, + } + child := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, + } + + AppendParentMeta(child.GetObjectMeta(), parent.GetObjectMeta()) + + // Verify labels + if child.Labels["team"] != "platform" { + t.Errorf("Expected label 'team' to be 'platform', got %q", child.Labels["team"]) + } + // pod-only.prometheus.io/scrape should be TRANSFORMED to prometheus.io/scrape + if child.Labels["prometheus.io/scrape"] != "true" { + t.Errorf("Expected label 'prometheus.io/scrape' to be 'true', got %q", child.Labels["prometheus.io/scrape"]) + } + // Original key should NOT exist + if _, exists := child.Labels["pod-only.prometheus.io/scrape"]; exists { + t.Errorf("Label 'pod-only.prometheus.io/scrape' should have been transformed, not copied as-is") + } + if _, exists := child.Labels["sts-only.example.com/priority"]; exists { + t.Errorf("Label 'sts-only.example.com/priority' should have been filtered out") + } + + // Verify annotations + if child.Annotations["description"] != "test" { + t.Errorf("Expected annotation 'description' to be 'test', got %q", child.Annotations["description"]) + } + // pod-only.istio.io/inject should be TRANSFORMED to istio.io/inject + if child.Annotations["istio.io/inject"] != "true" { + t.Errorf("Expected annotation 'istio.io/inject' to be 'true', got %q", child.Annotations["istio.io/inject"]) + } + // Original key should NOT exist + if _, exists := child.Annotations["pod-only.istio.io/inject"]; exists { + t.Errorf("Annotation 'pod-only.istio.io/inject' should have been transformed, not copied as-is") + } + if _, exists := child.Annotations["sts-only.example.com/owner"]; exists { + t.Errorf("Annotation 'sts-only.example.com/owner' should have been filtered out") + } +} + +func TestComputeDesiredPodTemplateKeys(t *testing.T) { + tests := []struct { + name string + parentLabels map[string]string + parentAnnotations map[string]string + expectedLabels map[string]string + expectedAnnotations map[string]string + }{ + { + name: "nil parent metadata", + parentLabels: nil, + parentAnnotations: nil, + expectedLabels: map[string]string{}, + expectedAnnotations: map[string]string{}, + }, + { + name: "empty parent metadata", + parentLabels: map[string]string{}, + parentAnnotations: map[string]string{}, + expectedLabels: map[string]string{}, + expectedAnnotations: map[string]string{}, + }, + { + name: "regular user labels and annotations pass through", + parentLabels: map[string]string{ + "team": "platform", + "environment": "production", + }, + parentAnnotations: map[string]string{ + "description": "test service", + "owner": "ops-team", + }, + expectedLabels: map[string]string{ + "team": "platform", + "environment": "production", + }, + expectedAnnotations: map[string]string{ + "description": "test service", + "owner": "ops-team", + }, + }, + { + name: "kubectl prefix is excluded", + parentLabels: map[string]string{ + "team": "platform", + "kubectl.kubernetes.io/last-applied-configuration": "json-data", + }, + parentAnnotations: map[string]string{ + "description": "test", + "kubectl.kubernetes.io/restartedAt": "2024-01-01", + }, + expectedLabels: map[string]string{ + "team": "platform", + }, + expectedAnnotations: map[string]string{ + "description": "test", + }, + }, + { + name: "operator prefix is excluded", + parentLabels: map[string]string{ + "team": "platform", + "operator.splunk.com/status": "active", + }, + parentAnnotations: map[string]string{ + "description": "test", + "operator.splunk.com/internal": "data", + }, + expectedLabels: map[string]string{ + "team": "platform", + }, + expectedAnnotations: map[string]string{ + "description": "test", + }, + }, + { + name: "sts-only prefix is excluded for pod template", + parentLabels: map[string]string{ + "team": "platform", + "sts-only.example.com/priority": "high", + }, + parentAnnotations: map[string]string{ + "description": "test", + "sts-only.example.com/owner": "ops-team", + }, + expectedLabels: map[string]string{ + "team": "platform", + }, + expectedAnnotations: map[string]string{ + "description": "test", + }, + }, + { + name: "pod-only prefix is transformed by stripping prefix", + parentLabels: map[string]string{ + "team": "platform", + "pod-only.prometheus.io/scrape": "true", + }, + parentAnnotations: map[string]string{ + "description": "test", + "pod-only.istio.io/inject": "true", + }, + expectedLabels: map[string]string{ + "team": "platform", + "prometheus.io/scrape": "true", + }, + expectedAnnotations: map[string]string{ + "description": "test", + "istio.io/inject": "true", + }, + }, + { + name: "mixed scenario with all prefix types", + parentLabels: map[string]string{ + "team": "platform", + "kubectl.kubernetes.io/last-applied": "config", + "operator.splunk.com/internal": "data", + "sts-only.example.com/priority": "high", + "pod-only.prometheus.io/scrape": "true", + "mycompany.com/cost-center": "67890", + }, + parentAnnotations: map[string]string{ + "description": "test", + "kubectl.kubernetes.io/restartedAt": "2024-01-01", + "operator.splunk.com/managed": "true", + "sts-only.example.com/owner": "ops", + "pod-only.istio.io/inject": "true", + "mycompany.com/owner": "team-a", + }, + expectedLabels: map[string]string{ + "team": "platform", + "prometheus.io/scrape": "true", + "mycompany.com/cost-center": "67890", + }, + expectedAnnotations: map[string]string{ + "description": "test", + "istio.io/inject": "true", + "mycompany.com/owner": "team-a", + }, + }, + { + name: "app.kubernetes.io labels pass through", + parentLabels: map[string]string{ + "app.kubernetes.io/name": "my-app", + "app.kubernetes.io/version": "1.0.0", + }, + parentAnnotations: map[string]string{}, + expectedLabels: map[string]string{ + "app.kubernetes.io/name": "my-app", + "app.kubernetes.io/version": "1.0.0", + }, + expectedAnnotations: map[string]string{}, + }, + { + name: "conflict resolution: prefixed key wins over explicit key (more specific)", + parentLabels: map[string]string{ + "prometheus.io/scrape": "false", // explicit + "pod-only.prometheus.io/scrape": "true", // would transform to same key + }, + parentAnnotations: map[string]string{ + "istio.io/inject": "false", // explicit + "pod-only.istio.io/inject": "true", // would transform to same key + }, + expectedLabels: map[string]string{ + "prometheus.io/scrape": "true", // prefixed wins (more specific) + }, + expectedAnnotations: map[string]string{ + "istio.io/inject": "true", // prefixed wins (more specific) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.parentLabels, + Annotations: tt.parentAnnotations, + }, + } + + gotLabels, gotAnnotations := ComputeDesiredPodTemplateKeys(parent.GetObjectMeta()) + + if !reflect.DeepEqual(gotLabels, tt.expectedLabels) { + t.Errorf("ComputeDesiredPodTemplateKeys() labels = %v; want %v", gotLabels, tt.expectedLabels) + } + if !reflect.DeepEqual(gotAnnotations, tt.expectedAnnotations) { + t.Errorf("ComputeDesiredPodTemplateKeys() annotations = %v; want %v", gotAnnotations, tt.expectedAnnotations) + } + }) + } +} + +func TestSyncParentMetaToPodTemplate(t *testing.T) { + tests := []struct { + name string + childLabels map[string]string + childAnnotations map[string]string + parentLabels map[string]string + parentAnnotations map[string]string + previousManagedLabels []string + previousManagedAnnotations []string + expectedChildLabels map[string]string + expectedChildAnnotations map[string]string + expectedManagedLabels []string + expectedManagedAnnotations []string + }{ + { + name: "add new labels and annotations to empty child", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{"team": "platform", "environment": "prod"}, + parentAnnotations: map[string]string{"description": "test"}, + previousManagedLabels: []string{}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{"team": "platform", "environment": "prod"}, + expectedChildAnnotations: map[string]string{"description": "test"}, + expectedManagedLabels: []string{"environment", "team"}, + expectedManagedAnnotations: []string{"description"}, + }, + { + name: "add new labels to child with nil maps", + childLabels: nil, + childAnnotations: nil, + parentLabels: map[string]string{"team": "platform"}, + parentAnnotations: map[string]string{"owner": "ops"}, + previousManagedLabels: []string{}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{"team": "platform"}, + expectedChildAnnotations: map[string]string{"owner": "ops"}, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{"owner"}, + }, + { + name: "update existing managed labels", + childLabels: map[string]string{"team": "old-team", "existing": "keep"}, + childAnnotations: map[string]string{"description": "old-desc"}, + parentLabels: map[string]string{"team": "new-team"}, + parentAnnotations: map[string]string{"description": "new-desc"}, + previousManagedLabels: []string{"team"}, + previousManagedAnnotations: []string{"description"}, + expectedChildLabels: map[string]string{"team": "new-team", "existing": "keep"}, + expectedChildAnnotations: map[string]string{"description": "new-desc"}, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{"description"}, + }, + { + name: "remove previously managed label no longer in parent", + childLabels: map[string]string{"team": "platform", "old-key": "to-remove", "external": "keep"}, + childAnnotations: map[string]string{"description": "test", "old-annotation": "to-remove"}, + parentLabels: map[string]string{"team": "platform"}, + parentAnnotations: map[string]string{"description": "test"}, + previousManagedLabels: []string{"team", "old-key"}, + previousManagedAnnotations: []string{"description", "old-annotation"}, + expectedChildLabels: map[string]string{"team": "platform", "external": "keep"}, + expectedChildAnnotations: map[string]string{"description": "test"}, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{"description"}, + }, + { + name: "preserve external labels not in previousManaged", + childLabels: map[string]string{"team": "platform", "external-label": "keep-me"}, + childAnnotations: map[string]string{"external-annotation": "also-keep"}, + parentLabels: map[string]string{"team": "new-team"}, + parentAnnotations: map[string]string{}, + previousManagedLabels: []string{"team"}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{"team": "new-team", "external-label": "keep-me"}, + expectedChildAnnotations: map[string]string{"external-annotation": "also-keep"}, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{}, + }, + { + name: "pod-only prefix transformation during sync", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{"pod-only.prometheus.io/scrape": "true"}, + parentAnnotations: map[string]string{"pod-only.istio.io/inject": "true"}, + previousManagedLabels: []string{}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{"prometheus.io/scrape": "true"}, + expectedChildAnnotations: map[string]string{"istio.io/inject": "true"}, + expectedManagedLabels: []string{"prometheus.io/scrape"}, + expectedManagedAnnotations: []string{"istio.io/inject"}, + }, + { + name: "excluded prefixes are not synced", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{ + "team": "platform", + "kubectl.kubernetes.io/last-applied": "config", + "operator.splunk.com/internal": "data", + "sts-only.example.com/priority": "high", + }, + parentAnnotations: map[string]string{ + "description": "test", + "kubectl.kubernetes.io/restartedAt": "2024-01-01", + }, + previousManagedLabels: []string{}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{"team": "platform"}, + expectedChildAnnotations: map[string]string{"description": "test"}, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{"description"}, + }, + { + name: "remove all managed keys when parent has none", + childLabels: map[string]string{"team": "platform", "env": "prod", "external": "keep"}, + childAnnotations: map[string]string{"description": "test", "owner": "ops"}, + parentLabels: map[string]string{}, + parentAnnotations: map[string]string{}, + previousManagedLabels: []string{"team", "env"}, + previousManagedAnnotations: []string{"description", "owner"}, + expectedChildLabels: map[string]string{"external": "keep"}, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{}, + expectedManagedAnnotations: []string{}, + }, + { + name: "add update and remove in single sync", + childLabels: map[string]string{"keep": "v1", "update": "old", "remove": "gone"}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{"keep": "v1", "update": "new", "add": "fresh"}, + parentAnnotations: map[string]string{}, + previousManagedLabels: []string{"keep", "update", "remove"}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{"keep": "v1", "update": "new", "add": "fresh"}, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{"add", "keep", "update"}, + expectedManagedAnnotations: []string{}, + }, + { + name: "transformed key removal after parent removes pod-only prefix key", + childLabels: map[string]string{"prometheus.io/scrape": "true"}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{}, + parentAnnotations: map[string]string{}, + previousManagedLabels: []string{"prometheus.io/scrape"}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{}, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{}, + expectedManagedAnnotations: []string{}, + }, + { + name: "conflict resolution: prefixed key wins over explicit key (more specific)", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{"prometheus.io/scrape": "false", "pod-only.prometheus.io/scrape": "true"}, + parentAnnotations: map[string]string{}, + previousManagedLabels: []string{}, + previousManagedAnnotations: []string{}, + expectedChildLabels: map[string]string{"prometheus.io/scrape": "true"}, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{"prometheus.io/scrape"}, + expectedManagedAnnotations: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + child := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.childLabels, + Annotations: tt.childAnnotations, + }, + } + parent := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.parentLabels, + Annotations: tt.parentAnnotations, + }, + } + + gotManagedLabels, gotManagedAnnotations := SyncParentMetaToPodTemplate( + context.Background(), + child.GetObjectMeta(), + parent.GetObjectMeta(), + nil, // protectedLabels - tested separately + tt.previousManagedLabels, + tt.previousManagedAnnotations, + ) + + // Verify child labels + if !reflect.DeepEqual(child.Labels, tt.expectedChildLabels) { + t.Errorf("SyncParentMetaToPodTemplate() child labels = %v; want %v", child.Labels, tt.expectedChildLabels) + } + + // Verify child annotations + if !reflect.DeepEqual(child.Annotations, tt.expectedChildAnnotations) { + t.Errorf("SyncParentMetaToPodTemplate() child annotations = %v; want %v", child.Annotations, tt.expectedChildAnnotations) + } + + // Verify managed labels (sorted) + if !reflect.DeepEqual(gotManagedLabels, tt.expectedManagedLabels) { + t.Errorf("SyncParentMetaToPodTemplate() managed labels = %v; want %v", gotManagedLabels, tt.expectedManagedLabels) + } + + // Verify managed annotations (sorted) + if !reflect.DeepEqual(gotManagedAnnotations, tt.expectedManagedAnnotations) { + t.Errorf("SyncParentMetaToPodTemplate() managed annotations = %v; want %v", gotManagedAnnotations, tt.expectedManagedAnnotations) + } + }) + } +} + +func TestSyncParentMetaToPodTemplate_ProtectedLabels(t *testing.T) { + tests := []struct { + name string + childLabels map[string]string + parentLabels map[string]string + protectedLabels map[string]string + expectedChildLabels map[string]string + expectedManagedLabels []string + }{ + { + name: "protected labels are not overwritten", + childLabels: map[string]string{"app.kubernetes.io/name": "protected-name"}, + parentLabels: map[string]string{"app.kubernetes.io/name": "overwritten-name", "other": "val"}, + protectedLabels: map[string]string{"app.kubernetes.io/name": "protected-name"}, + expectedChildLabels: map[string]string{"app.kubernetes.io/name": "protected-name", "other": "val"}, + expectedManagedLabels: []string{"other"}, // protected label is NOT in managed list + }, + { + name: "multiple protected labels are preserved", + childLabels: map[string]string{"app.kubernetes.io/name": "splunk", "app.kubernetes.io/instance": "test-cr"}, + parentLabels: map[string]string{"app.kubernetes.io/name": "bad-name", "app.kubernetes.io/instance": "bad-instance", "team": "platform"}, + protectedLabels: map[string]string{"app.kubernetes.io/name": "splunk", "app.kubernetes.io/instance": "test-cr"}, + expectedChildLabels: map[string]string{"app.kubernetes.io/name": "splunk", "app.kubernetes.io/instance": "test-cr", "team": "platform"}, + expectedManagedLabels: []string{"team"}, + }, + { + name: "nil protected labels allows all sync", + childLabels: map[string]string{"app.kubernetes.io/name": "child-name"}, + parentLabels: map[string]string{"app.kubernetes.io/name": "parent-name"}, + protectedLabels: nil, + expectedChildLabels: map[string]string{"app.kubernetes.io/name": "parent-name"}, + expectedManagedLabels: []string{"app.kubernetes.io/name"}, + }, + { + name: "empty protected labels allows all sync", + childLabels: map[string]string{"app.kubernetes.io/name": "child-name"}, + parentLabels: map[string]string{"app.kubernetes.io/name": "parent-name"}, + protectedLabels: map[string]string{}, + expectedChildLabels: map[string]string{"app.kubernetes.io/name": "parent-name"}, + expectedManagedLabels: []string{"app.kubernetes.io/name"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + child := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.childLabels, + }, + } + parent := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.parentLabels, + }, + } + + gotManagedLabels, _ := SyncParentMetaToPodTemplate( + context.Background(), + child.GetObjectMeta(), + parent.GetObjectMeta(), + tt.protectedLabels, + nil, // previousManagedLabels + nil, // previousManagedAnnotations + ) + + // Verify child labels + if !reflect.DeepEqual(child.Labels, tt.expectedChildLabels) { + t.Errorf("SyncParentMetaToPodTemplate() child labels = %v; want %v", child.Labels, tt.expectedChildLabels) + } + + // Verify managed labels (sorted) + if !reflect.DeepEqual(gotManagedLabels, tt.expectedManagedLabels) { + t.Errorf("SyncParentMetaToPodTemplate() managed labels = %v; want %v", gotManagedLabels, tt.expectedManagedLabels) + } + }) + } +} + +// TestSyncParentMetaToPodTemplate_ProtectedLabelsNotRemoved verifies that protected labels +// in previousManagedLabels are not removed, even if they're no longer in parent labels. +// This guards against future call sites that might inadvertently track selector labels as managed. +func TestSyncParentMetaToPodTemplate_ProtectedLabelsNotRemoved(t *testing.T) { + // Scenario: A protected label (e.g., selector label) was somehow tracked in previousManagedLabels. + // When it's no longer in the CR, the sync should NOT remove it because it's protected. + child := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app.kubernetes.io/name": "splunk-indexer", // protected selector label + "app.kubernetes.io/instance": "test-cr", // protected selector label + "team": "platform", // previously managed, now removed from CR + }, + }, + } + parent := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + // Note: "team" is intentionally absent (removed from CR) + // Selector labels not present in parent either + }, + }, + } + protectedLabels := map[string]string{ + "app.kubernetes.io/name": "splunk-indexer", + "app.kubernetes.io/instance": "test-cr", + } + // Simulate a scenario where protected labels were mistakenly added to previousManagedLabels + previousManagedLabels := []string{"app.kubernetes.io/instance", "app.kubernetes.io/name", "team"} + + gotManagedLabels, _ := SyncParentMetaToPodTemplate( + context.Background(), + child.GetObjectMeta(), + parent.GetObjectMeta(), + protectedLabels, + previousManagedLabels, + nil, // previousManagedAnnotations + ) + + // Expected: protected labels are preserved, "team" is removed (not protected, not in parent) + expectedChildLabels := map[string]string{ + "app.kubernetes.io/name": "splunk-indexer", + "app.kubernetes.io/instance": "test-cr", + // "team" should be removed + } + if !reflect.DeepEqual(child.Labels, expectedChildLabels) { + t.Errorf("SyncParentMetaToPodTemplate() child labels = %v; want %v", child.Labels, expectedChildLabels) + } + + // Expected: no managed labels since parent has no eligible labels + expectedManagedLabels := []string{} + if !reflect.DeepEqual(gotManagedLabels, expectedManagedLabels) { + t.Errorf("SyncParentMetaToPodTemplate() managed labels = %v; want %v", gotManagedLabels, expectedManagedLabels) + } +} + +func TestComputeDesiredStatefulSetKeys(t *testing.T) { + tests := []struct { + name string + parentLabels map[string]string + parentAnnotations map[string]string + expectedLabels map[string]string + expectedAnnotations map[string]string + }{ + { + name: "nil parent metadata", + parentLabels: nil, + parentAnnotations: nil, + expectedLabels: map[string]string{}, + expectedAnnotations: map[string]string{}, + }, + { + name: "empty parent metadata", + parentLabels: map[string]string{}, + parentAnnotations: map[string]string{}, + expectedLabels: map[string]string{}, + expectedAnnotations: map[string]string{}, + }, + { + name: "regular user labels and annotations pass through", + parentLabels: map[string]string{ + "team": "platform", + "environment": "production", + }, + parentAnnotations: map[string]string{ + "description": "test service", + "owner": "ops-team", + }, + expectedLabels: map[string]string{ + "team": "platform", + "environment": "production", + }, + expectedAnnotations: map[string]string{ + "description": "test service", + "owner": "ops-team", + }, + }, + { + name: "kubectl prefix is excluded", + parentLabels: map[string]string{ + "team": "platform", + "kubectl.kubernetes.io/last-applied-configuration": "json-data", + }, + parentAnnotations: map[string]string{ + "description": "test", + "kubectl.kubernetes.io/restartedAt": "2024-01-01", + }, + expectedLabels: map[string]string{ + "team": "platform", + }, + expectedAnnotations: map[string]string{ + "description": "test", + }, + }, + { + name: "operator prefix is excluded", + parentLabels: map[string]string{ + "team": "platform", + "operator.splunk.com/status": "active", + }, + parentAnnotations: map[string]string{ + "description": "test", + "operator.splunk.com/internal": "data", + }, + expectedLabels: map[string]string{ + "team": "platform", + }, + expectedAnnotations: map[string]string{ + "description": "test", + }, + }, + { + name: "pod-only prefix is excluded for statefulset", + parentLabels: map[string]string{ + "team": "platform", + "pod-only.prometheus.io/scrape": "true", + }, + parentAnnotations: map[string]string{ + "description": "test", + "pod-only.istio.io/inject": "true", + }, + expectedLabels: map[string]string{ + "team": "platform", + }, + expectedAnnotations: map[string]string{ + "description": "test", + }, + }, + { + name: "sts-only prefix is transformed by stripping prefix", + parentLabels: map[string]string{ + "team": "platform", + "sts-only.example.com/priority": "high", + }, + parentAnnotations: map[string]string{ + "description": "test", + "sts-only.example.com/owner": "ops-team", + }, + expectedLabels: map[string]string{ + "team": "platform", + "example.com/priority": "high", + }, + expectedAnnotations: map[string]string{ + "description": "test", + "example.com/owner": "ops-team", + }, + }, + { + name: "mixed scenario with all prefix types", + parentLabels: map[string]string{ + "team": "platform", + "kubectl.kubernetes.io/last-applied": "config", + "operator.splunk.com/internal": "data", + "pod-only.prometheus.io/scrape": "true", + "sts-only.example.com/priority": "high", + "mycompany.com/cost-center": "67890", + }, + parentAnnotations: map[string]string{ + "description": "test", + "kubectl.kubernetes.io/restartedAt": "2024-01-01", + "operator.splunk.com/managed": "true", + "pod-only.istio.io/inject": "true", + "sts-only.example.com/owner": "ops", + "mycompany.com/owner": "team-a", + }, + expectedLabels: map[string]string{ + "team": "platform", + "example.com/priority": "high", + "mycompany.com/cost-center": "67890", + }, + expectedAnnotations: map[string]string{ + "description": "test", + "example.com/owner": "ops", + "mycompany.com/owner": "team-a", + }, + }, + { + name: "app.kubernetes.io labels pass through", + parentLabels: map[string]string{ + "app.kubernetes.io/name": "my-app", + "app.kubernetes.io/version": "1.0.0", + }, + parentAnnotations: map[string]string{}, + expectedLabels: map[string]string{ + "app.kubernetes.io/name": "my-app", + "app.kubernetes.io/version": "1.0.0", + }, + expectedAnnotations: map[string]string{}, + }, + { + name: "conflict resolution: prefixed key wins over explicit key (more specific)", + parentLabels: map[string]string{ + "example.com/priority": "low", // explicit + "sts-only.example.com/priority": "high", // would transform to same key + }, + parentAnnotations: map[string]string{ + "example.com/owner": "team-a", // explicit + "sts-only.example.com/owner": "team-b", // would transform to same key + }, + expectedLabels: map[string]string{ + "example.com/priority": "high", // prefixed wins (more specific) + }, + expectedAnnotations: map[string]string{ + "example.com/owner": "team-b", // prefixed wins (more specific) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.parentLabels, + Annotations: tt.parentAnnotations, + }, + } + + gotLabels, gotAnnotations := ComputeDesiredStatefulSetKeys(parent.GetObjectMeta()) + + if !reflect.DeepEqual(gotLabels, tt.expectedLabels) { + t.Errorf("ComputeDesiredStatefulSetKeys() labels = %v; want %v", gotLabels, tt.expectedLabels) + } + if !reflect.DeepEqual(gotAnnotations, tt.expectedAnnotations) { + t.Errorf("ComputeDesiredStatefulSetKeys() annotations = %v; want %v", gotAnnotations, tt.expectedAnnotations) + } + }) + } +} + +func TestSyncParentMetaToStatefulSet(t *testing.T) { + selectorLabels := map[string]string{ + "app.kubernetes.io/name": "indexer", + "app.kubernetes.io/instance": "splunk-test-indexer", + "app.kubernetes.io/managed-by": "splunk-operator", + "app.kubernetes.io/component": "indexer", + "app.kubernetes.io/part-of": "splunk-test-indexer", + } + + tests := []struct { + name string + childLabels map[string]string + childAnnotations map[string]string + parentLabels map[string]string + parentAnnotations map[string]string + selectorLabels map[string]string + expectedChildLabels map[string]string + expectedChildAnnotations map[string]string + expectedManagedLabels []string + expectedManagedAnnotations []string + }{ + { + name: "add new labels and annotations to empty child", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{"team": "platform", "environment": "prod"}, + parentAnnotations: map[string]string{"description": "test"}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "team": "platform", + "environment": "prod", + }, + expectedChildAnnotations: map[string]string{ + "description": "test", + ManagedCRLabelKeysAnnotation: `["environment","team"]`, + ManagedCRAnnotationKeysAnnotation: `["description"]`, + }, + expectedManagedLabels: []string{"environment", "team"}, + expectedManagedAnnotations: []string{"description"}, + }, + { + name: "add new labels to child with nil maps", + childLabels: nil, + childAnnotations: nil, + parentLabels: map[string]string{"team": "platform"}, + parentAnnotations: map[string]string{"owner": "ops"}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "team": "platform", + }, + expectedChildAnnotations: map[string]string{ + "owner": "ops", + ManagedCRLabelKeysAnnotation: `["team"]`, + ManagedCRAnnotationKeysAnnotation: `["owner"]`, + }, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{"owner"}, + }, + { + name: "update existing managed labels", + childLabels: map[string]string{ + "team": "old-team", + "existing": "keep", + }, + childAnnotations: map[string]string{ + "description": ManagedCRLabelKeysAnnotation, + ManagedCRLabelKeysAnnotation: `["team"]`, + }, + parentLabels: map[string]string{"team": "new-team"}, + parentAnnotations: map[string]string{}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "team": "new-team", + "existing": "keep", + }, + expectedChildAnnotations: map[string]string{ + "description": ManagedCRLabelKeysAnnotation, + ManagedCRLabelKeysAnnotation: `["team"]`, + }, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{}, + }, + { + name: "remove previously managed label no longer in parent", + childLabels: map[string]string{ + "team": "platform", + "old-key": "to-remove", + "external": "keep", + }, + childAnnotations: map[string]string{ + "description": "test", + "old-annotation": "to-remove", + ManagedCRLabelKeysAnnotation: `["old-key","team"]`, + ManagedCRAnnotationKeysAnnotation: `["description","old-annotation"]`, + }, + parentLabels: map[string]string{"team": "platform"}, + parentAnnotations: map[string]string{"description": "test"}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "team": "platform", + "external": "keep", + }, + expectedChildAnnotations: map[string]string{ + "description": "test", + ManagedCRLabelKeysAnnotation: `["team"]`, + ManagedCRAnnotationKeysAnnotation: `["description"]`, + }, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{"description"}, + }, + { + name: "preserve external labels not in previousManaged", + childLabels: map[string]string{ + "team": "platform", + "external-label": "keep-me", + }, + childAnnotations: map[string]string{ + "external-annotation": "also-keep", + ManagedCRLabelKeysAnnotation: `["team"]`, + }, + parentLabels: map[string]string{"team": "new-team"}, + parentAnnotations: map[string]string{}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "team": "new-team", + "external-label": "keep-me", + }, + expectedChildAnnotations: map[string]string{ + "external-annotation": "also-keep", + ManagedCRLabelKeysAnnotation: `["team"]`, + }, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{}, + }, + { + name: "sts-only prefix transformation during sync", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{ + "sts-only.example.com/priority": "high", + }, + parentAnnotations: map[string]string{ + "sts-only.example.com/owner": "ops", + }, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "example.com/priority": "high", + }, + expectedChildAnnotations: map[string]string{ + "example.com/owner": "ops", + ManagedCRLabelKeysAnnotation: `["example.com/priority"]`, + ManagedCRAnnotationKeysAnnotation: `["example.com/owner"]`, + }, + expectedManagedLabels: []string{"example.com/priority"}, + expectedManagedAnnotations: []string{"example.com/owner"}, + }, + { + name: "excluded prefixes are not synced", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{ + "team": "platform", + "kubectl.kubernetes.io/last-applied": "config", + "operator.splunk.com/internal": "data", + "pod-only.prometheus.io/scrape": "true", + }, + parentAnnotations: map[string]string{ + "description": "test", + "kubectl.kubernetes.io/restartedAt": "2024-01-01", + }, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "team": "platform", + }, + expectedChildAnnotations: map[string]string{ + "description": "test", + ManagedCRLabelKeysAnnotation: `["team"]`, + ManagedCRAnnotationKeysAnnotation: `["description"]`, + }, + expectedManagedLabels: []string{"team"}, + expectedManagedAnnotations: []string{"description"}, + }, + { + name: "selector labels are never removed even if previously managed", + childLabels: map[string]string{ + "app.kubernetes.io/name": "indexer", + "app.kubernetes.io/instance": "splunk-test-indexer", + "team": "old-team", + }, + childAnnotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["app.kubernetes.io/name","app.kubernetes.io/instance","team"]`, + }, + parentLabels: map[string]string{}, // Remove all - but selector labels must stay + parentAnnotations: map[string]string{}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "app.kubernetes.io/name": "indexer", + "app.kubernetes.io/instance": "splunk-test-indexer", + // "team" is removed because it's not a selector label + }, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{}, + expectedManagedAnnotations: []string{}, + }, + { + name: "remove all managed keys when parent has none", + childLabels: map[string]string{ + "team": "platform", + "env": "prod", + "external": "keep", + }, + childAnnotations: map[string]string{ + "description": "test", + "owner": "ops", + ManagedCRLabelKeysAnnotation: `["env","team"]`, + ManagedCRAnnotationKeysAnnotation: `["description","owner"]`, + }, + parentLabels: map[string]string{}, + parentAnnotations: map[string]string{}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "external": "keep", + }, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{}, + expectedManagedAnnotations: []string{}, + }, + { + name: "add update and remove in single sync", + childLabels: map[string]string{ + "keep": "v1", + "update": "old", + "remove": "gone", + }, + childAnnotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["keep","remove","update"]`, + }, + parentLabels: map[string]string{ + "keep": "v1", + "update": "new", + "add": "fresh", + }, + parentAnnotations: map[string]string{}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{ + "keep": "v1", + "update": "new", + "add": "fresh", + }, + expectedChildAnnotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["add","keep","update"]`, + }, + expectedManagedLabels: []string{"add", "keep", "update"}, + expectedManagedAnnotations: []string{}, + }, + { + name: "transformed key removal after parent removes sts-only prefix key", + childLabels: map[string]string{ + "example.com/priority": "high", + }, + childAnnotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["example.com/priority"]`, + }, + parentLabels: map[string]string{}, + parentAnnotations: map[string]string{}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{}, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{}, + expectedManagedAnnotations: []string{}, + }, + { + name: "nil selector labels - still works", + childLabels: map[string]string{ + "team": "platform", + }, + childAnnotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["team"]`, + }, + parentLabels: map[string]string{}, + parentAnnotations: map[string]string{}, + selectorLabels: nil, + expectedChildLabels: map[string]string{}, + expectedChildAnnotations: map[string]string{}, + expectedManagedLabels: []string{}, + expectedManagedAnnotations: []string{}, + }, + { + name: "conflict resolution: prefixed key wins over explicit key (more specific)", + childLabels: map[string]string{}, + childAnnotations: map[string]string{}, + parentLabels: map[string]string{ + "example.com/priority": "low", + "sts-only.example.com/priority": "high", + }, + parentAnnotations: map[string]string{}, + selectorLabels: selectorLabels, + expectedChildLabels: map[string]string{"example.com/priority": "high"}, + expectedChildAnnotations: map[string]string{ + ManagedCRLabelKeysAnnotation: `["example.com/priority"]`, + }, + expectedManagedLabels: []string{"example.com/priority"}, + expectedManagedAnnotations: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + child := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.childLabels, + Annotations: tt.childAnnotations, + }, + } + parent := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.parentLabels, + Annotations: tt.parentAnnotations, + }, + } + + SyncParentMetaToStatefulSet(context.Background(), child.GetObjectMeta(), parent.GetObjectMeta(), tt.selectorLabels) + + // Verify child labels + if !reflect.DeepEqual(child.Labels, tt.expectedChildLabels) { + t.Errorf("SyncParentMetaToStatefulSet() child labels = %v; want %v", child.Labels, tt.expectedChildLabels) + } + + // Verify managed labels (sorted) + gotManagedLabels := GetManagedLabelKeys(child.Annotations) + gotManagedAnnotations := GetManagedAnnotationKeys(child.Annotations) + + // Verify managed labels (sorted) + if !reflect.DeepEqual(gotManagedLabels, tt.expectedManagedLabels) { + t.Errorf("SyncParentMetaToStatefulSet() managed labels = %v; want %v", gotManagedLabels, tt.expectedManagedLabels) + } + + // Verify managed annotations (sorted) + if !reflect.DeepEqual(gotManagedAnnotations, tt.expectedManagedAnnotations) { + t.Errorf("SyncParentMetaToStatefulSet() managed annotations = %v; want %v", gotManagedAnnotations, tt.expectedManagedAnnotations) + } + }) + } +} + +// TestSyncParentMetaToStatefulSet_SelectorLabelProtection verifies that selector labels +// on the StatefulSet are not overwritten by CR metadata with matching keys but different values. +func TestSyncParentMetaToStatefulSet_SelectorLabelProtection(t *testing.T) { + ctx := context.Background() + + // Selector labels that must be protected + selectorLabels := map[string]string{ + "app.kubernetes.io/instance": "splunk-test-cr-monitoring-console", + "app.kubernetes.io/part-of": "splunk-test-cr-monitoring-console", + "app.kubernetes.io/name": "monitoring-console", + } + + // CR has same keys but different values + parent := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app.kubernetes.io/instance": "monitoring-console", // Different value! + "app.kubernetes.io/part-of": "my-app", // Different value! + "team": "platform", // Should be added + }, + }, + } + + // StatefulSet starts with selector label values + child := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app.kubernetes.io/instance": "splunk-test-cr-monitoring-console", + "app.kubernetes.io/part-of": "splunk-test-cr-monitoring-console", + "app.kubernetes.io/name": "monitoring-console", + }, + Annotations: map[string]string{}, + }, + } + + SyncParentMetaToStatefulSet(ctx, child.GetObjectMeta(), parent.GetObjectMeta(), selectorLabels) + + // Verify selector labels are NOT overwritten + if child.Labels["app.kubernetes.io/instance"] != "splunk-test-cr-monitoring-console" { + t.Errorf("Selector label app.kubernetes.io/instance was overwritten: got %q, want %q", + child.Labels["app.kubernetes.io/instance"], "splunk-test-cr-monitoring-console") + } + if child.Labels["app.kubernetes.io/part-of"] != "splunk-test-cr-monitoring-console" { + t.Errorf("Selector label app.kubernetes.io/part-of was overwritten: got %q, want %q", + child.Labels["app.kubernetes.io/part-of"], "splunk-test-cr-monitoring-console") + } + + // Verify non-selector labels ARE added + if child.Labels["team"] != "platform" { + t.Errorf("Non-selector label 'team' was not added: got %q, want %q", + child.Labels["team"], "platform") + } +} + func TestParseResourceQuantity(t *testing.T) { resourceQuantityTester := func(t *testing.T, str string, defaultStr string, want int64) { q, err := ParseResourceQuantity(str, defaultStr) diff --git a/pkg/splunk/enterprise/configuration.go b/pkg/splunk/enterprise/configuration.go index c9cc6838b..dc8ca01d4 100644 --- a/pkg/splunk/enterprise/configuration.go +++ b/pkg/splunk/enterprise/configuration.go @@ -699,10 +699,16 @@ func getSplunkStatefulSet(ctx context.Context, client splcommon.ControllerClient } affinity := splcommon.AppendPodAntiAffinity(&spec.Affinity, cr.GetName(), instanceType.ToString()) - // start with same labels as selector; note that this object gets modified by splcommon.AppendParentMeta() - labels := make(map[string]string) + // Create separate label maps for StatefulSet ObjectMeta and Pod Template + // to prevent SyncParentMetaToStatefulSet from modifying Template labels via shared reference + stsLabels := make(map[string]string) for k, v := range selectLabels { - labels[k] = v + stsLabels[k] = v + } + + templateLabels := make(map[string]string) + for k, v := range selectLabels { + templateLabels[k] = v } namespacedName := types.NamespacedName{ @@ -725,7 +731,7 @@ func getSplunkStatefulSet(ctx context.Context, client splcommon.ControllerClient ObjectMeta: metav1.ObjectMeta{ Name: GetSplunkStatefulsetName(instanceType, cr.GetName()), Namespace: cr.GetNamespace(), - Labels: labels, + Labels: stsLabels, }, } } @@ -742,7 +748,7 @@ func getSplunkStatefulSet(ctx context.Context, client splcommon.ControllerClient }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ - Labels: labels, + Labels: templateLabels, Annotations: annotations, }, Spec: corev1.PodSpec{ @@ -764,7 +770,7 @@ func getSplunkStatefulSet(ctx context.Context, client splcommon.ControllerClient } // Add storage volumes - err = addStorageVolumes(ctx, cr, client, spec, statefulSet, labels) + err = addStorageVolumes(ctx, cr, client, spec, statefulSet, templateLabels) if err != nil { return statefulSet, err } @@ -779,8 +785,14 @@ func getSplunkStatefulSet(ctx context.Context, client splcommon.ControllerClient } } - // append labels and annotations from parent - splcommon.AppendParentMeta(statefulSet.Spec.Template.GetObjectMeta(), cr.GetObjectMeta()) + // sync labels and annotations from parent to Pod Template + // Pass selectLabels as protectedLabels to prevent CR labels from overwriting selector labels + // Pass empty slices for previousManaged since Pod Template is rebuilt fresh each reconcile + splcommon.SyncParentMetaToPodTemplate(ctx, statefulSet.Spec.Template.GetObjectMeta(), cr.GetObjectMeta(), selectLabels, nil, nil) + + // sync labels and annotations from parent to StatefulSet ObjectMeta + // Pass selectLabels to protect selector labels from removal + splcommon.SyncParentMetaToStatefulSet(ctx, statefulSet.GetObjectMeta(), cr.GetObjectMeta(), selectLabels) // retrieve the secret to upload to the statefulSet pod statefulSetSecret, err := splutil.GetLatestVersionedSecret(ctx, client, cr, cr.GetNamespace(), statefulSet.GetName()) diff --git a/pkg/splunk/enterprise/configuration_test.go b/pkg/splunk/enterprise/configuration_test.go index 2c92e3ec4..57c7b6c9c 100644 --- a/pkg/splunk/enterprise/configuration_test.go +++ b/pkg/splunk/enterprise/configuration_test.go @@ -1831,3 +1831,55 @@ func TestGetSplunkPorts(t *testing.T) { test(SplunkIngestor) test(SplunkMonitoringConsole) } + +// TestGetSplunkStatefulSet_SeparateLabelMaps verifies that StatefulSet.ObjectMeta.Labels +// and StatefulSet.Spec.Template.ObjectMeta.Labels are separate maps that don't share memory. +// This is critical because modifications to one should not affect the other. +func TestGetSplunkStatefulSet_SeparateLabelMaps(t *testing.T) { + os.Setenv("SPLUNK_GENERAL_TERMS", "--accept-sgt-current-at-splunk-com") + ctx := context.TODO() + + cr := enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-standalone", + Namespace: "test", + }, + } + + c := spltest.NewMockClient() + + // Create namespace scoped secret + _, err := splutil.ApplyNamespaceScopedSecretObject(ctx, c, "test") + if err != nil { + t.Fatalf("Failed to create namespace scoped object: %v", err) + } + + statefulSet, err := getSplunkStatefulSet(ctx, c, &cr, &cr.Spec.CommonSplunkSpec, SplunkStandalone, 1, nil) + if err != nil { + t.Fatalf("getSplunkStatefulSet failed: %v", err) + } + + // Verify that both maps exist + if statefulSet.ObjectMeta.Labels == nil { + t.Fatal("StatefulSet.ObjectMeta.Labels is nil") + } + if statefulSet.Spec.Template.ObjectMeta.Labels == nil { + t.Fatal("StatefulSet.Spec.Template.ObjectMeta.Labels is nil") + } + + // Modify StatefulSet.ObjectMeta.Labels + statefulSet.ObjectMeta.Labels["test-sts-only-label"] = "should-not-appear-in-template" + + // Verify Template.Labels was NOT affected + if _, exists := statefulSet.Spec.Template.ObjectMeta.Labels["test-sts-only-label"]; exists { + t.Error("Label added to StatefulSet.ObjectMeta.Labels incorrectly appeared in Template.Labels - maps share memory!") + } + + // Modify Template.Labels + statefulSet.Spec.Template.ObjectMeta.Labels["test-template-only-label"] = "should-not-appear-in-sts" + + // Verify StatefulSet.ObjectMeta.Labels was NOT affected + if _, exists := statefulSet.ObjectMeta.Labels["test-template-only-label"]; exists { + t.Error("Label added to Template.Labels incorrectly appeared in StatefulSet.ObjectMeta.Labels - maps share memory!") + } +} diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_ingestor_with_labels.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_ingestor_with_labels.json index 9a35ffab7..a6096970e 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_ingestor_with_labels.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_ingestor_with_labels.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-test-ingestor", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_manager_with_service_account_2.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_manager_with_service_account_2.json index 1577cbcac..70df35dac 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_manager_with_service_account_2.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_manager_with_service_account_2.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-stack1-indexer", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_master_with_service_account_2.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_master_with_service_account_2.json index b1742607e..a0abd56d5 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_master_with_service_account_2.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_cluster_master_with_service_account_2.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-stack1-indexer", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_indexer_with_service_account_2.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_indexer_with_service_account_2.json index ec2bb71d7..ff0f8401d 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_indexer_with_service_account_2.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_indexer_with_service_account_2.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-manager1-indexer", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_manager_with_service_account_2.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_manager_with_service_account_2.json index 00904a1f2..c6970d89c 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_manager_with_service_account_2.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_manager_with_service_account_2.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-stack1-license-manager", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_master_with_service_account_2.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_master_with_service_account_2.json index 0da2339fe..889a9722d 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_master_with_service_account_2.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_license_master_with_service_account_2.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-stack1-license-master", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_monitoring_console_with_service_account_1.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_monitoring_console_with_service_account_1.json index e3fb99601..7464fdea2 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_monitoring_console_with_service_account_1.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_monitoring_console_with_service_account_1.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-stack1-monitoring-console", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_search_head_with_service_account_2.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_search_head_with_service_account_2.json index bfeece12b..cc2fe0738 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_search_head_with_service_account_2.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_search_head_with_service_account_2.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-stack1-search-head", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_standalone_with_service_account_2.json b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_standalone_with_service_account_2.json index d94fc0819..c23eef6ed 100644 --- a/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_standalone_with_service_account_2.json +++ b/pkg/splunk/enterprise/testdata/fixtures/statefulset_stack1_standalone_with_service_account_2.json @@ -13,6 +13,9 @@ "app.kubernetes.io/part-of": "splunk-stack1-standalone", "app.kubernetes.io/test-extra-label": "test-extra-label-value" }, + "annotations": { + "operator.splunk.com/managed-cr-label-keys": "[\"app.kubernetes.io/test-extra-label\"]" + }, "ownerReferences": [ { "apiVersion": "", diff --git a/pkg/splunk/splkcontroller/statefulset.go b/pkg/splunk/splkcontroller/statefulset.go index 3028efbdd..4243eee15 100644 --- a/pkg/splunk/splkcontroller/statefulset.go +++ b/pkg/splunk/splkcontroller/statefulset.go @@ -90,6 +90,10 @@ func ApplyStatefulSet(ctx context.Context, c splcommon.ControllerClient, revised // check for changes in Pod template hasUpdates := MergePodUpdates(ctx, ¤t.Spec.Template, &revised.Spec.Template, current.GetObjectMeta().GetName()) + + // check for changes in StatefulSet-level metadata (labels and annotations) + hasUpdates = hasUpdates || splcommon.MergeStatefulSetMetaUpdates(ctx, ¤t.ObjectMeta, &revised.ObjectMeta, current.GetName()) + *revised = current // caller expects that object passed represents latest state // only update if there are material differences, as determined by comparison function diff --git a/pkg/splunk/splkcontroller/statefulset_metadata_test.go b/pkg/splunk/splkcontroller/statefulset_metadata_test.go new file mode 100644 index 000000000..62ca17d06 --- /dev/null +++ b/pkg/splunk/splkcontroller/statefulset_metadata_test.go @@ -0,0 +1,141 @@ +// Copyright (c) 2018-2022 Splunk Inc. All rights reserved. + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package splkcontroller + +import ( + "context" + "testing" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" + spltest "github.com/splunk/splunk-operator/pkg/splunk/test" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestApplyStatefulSet_STSOnlyAnnotationNotPersisted verifies that sts-only.* annotations +// (transformed to example.com/* format by SyncParentMetaToStatefulSet) are properly +// persisted after ApplyStatefulSet is called. +func TestApplyStatefulSet_STSOnlyAnnotationNotPersisted(t *testing.T) { + ctx := context.TODO() + c := spltest.NewMockClient() + + var replicas int32 = 1 + + // Current StatefulSet (in cluster) - has only existing annotation + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "splunk-test-indexer", + Namespace: "test", + Annotations: map[string]string{ + "existing-annotation": "existing-value", + }, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + }, + } + + // Add current to mock client (simulates existing StatefulSet in cluster) + c.Create(ctx, current) + + // Revised StatefulSet (after SyncParentMetaToStatefulSet was called) + // This simulates the state after sts-only.example.com/aaa annotation was transformed + revised := current.DeepCopy() + revised.Annotations = map[string]string{ + "existing-annotation": "existing-value", + "example.com/aaa": "value1", // Added by SyncParentMetaToStatefulSet (prefix stripped) + } + + t.Logf("Before ApplyStatefulSet - revised has annotation 'example.com/aaa': %s", + revised.Annotations["example.com/aaa"]) + + // Apply the StatefulSet + phase, err := ApplyStatefulSet(ctx, c, revised) + if err != nil { + t.Fatalf("ApplyStatefulSet failed: %v", err) + } + + // Verify phase indicates an update was triggered + if phase != enterpriseApi.PhaseUpdating { + t.Errorf("Expected PhaseUpdating due to annotation change, got %v", phase) + } + + t.Logf("After ApplyStatefulSet - revised has annotation 'example.com/aaa': %s", + revised.Annotations["example.com/aaa"]) + + // Verify the annotation is preserved in revised (which now reflects current after merge) + if val, ok := revised.Annotations["example.com/aaa"]; !ok || val != "value1" { + t.Errorf("Annotation 'example.com/aaa' was lost after ApplyStatefulSet.\n"+ + "Expected: value1\nGot: %s", val) + } else { + t.Log("Annotation 'example.com/aaa' is preserved after ApplyStatefulSet") + } +} + +// TestApplyStatefulSet_MetadataOnlyChangeNoUpdate verifies that metadata-only changes +// (no Pod Template changes) are properly detected and trigger a StatefulSet update. +// +// This test ensures that MergeStatefulSetMetaUpdates is called and hasUpdates is set +// to true even when there are no Pod Template changes. +func TestApplyStatefulSet_MetadataOnlyChangeNoUpdate(t *testing.T) { + ctx := context.TODO() + c := spltest.NewMockClient() + + var replicas int32 = 1 + + // Current StatefulSet (in cluster) + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "splunk-test-indexer", + Namespace: "test", + Annotations: map[string]string{ + "existing-annotation": "existing-value", + }, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + }, + } + + // Add current to mock client + c.Create(ctx, current) + + // Revised StatefulSet - ONLY metadata changed, Pod Template is identical + revised := current.DeepCopy() + revised.Annotations = map[string]string{ + "existing-annotation": "existing-value", + "new-sts-annotation": "new-value", // Only metadata changed + } + + // Apply the StatefulSet + phase, err := ApplyStatefulSet(ctx, c, revised) + if err != nil { + t.Fatalf("ApplyStatefulSet failed: %v", err) + } + + // Verify phase indicates an update was triggered (metadata-only change should still trigger update) + if phase != enterpriseApi.PhaseUpdating { + t.Errorf("Expected PhaseUpdating for metadata-only change, got %v", phase) + } + + // Verify the new annotation is preserved + if val, ok := revised.Annotations["new-sts-annotation"]; !ok || val != "new-value" { + t.Errorf("Annotation 'new-sts-annotation' was lost after ApplyStatefulSet.\n"+ + "Expected: new-value\nGot: %s", val) + } else { + t.Log("Annotation 'new-sts-annotation' is correctly preserved after ApplyStatefulSet") + } +} diff --git a/pkg/splunk/test/controller.go b/pkg/splunk/test/controller.go index 6d43fa149..c75ef7c7a 100644 --- a/pkg/splunk/test/controller.go +++ b/pkg/splunk/test/controller.go @@ -861,6 +861,9 @@ func PodManagerTester(t *testing.T, method string, mgr splcommon.StatefulSetPodM PodManagerUpdateTester(t, methodPlus, mgr, 1, enterpriseApi.PhasePending, revised, scaleUpCalls, nil, current) // test scale up (1 ready scaling to 2; wait for ready) + // Now includes Update+Get for setScaleUpWaitStarted annotation tracking + // Reset revised to avoid carrying annotations from previous test + revised = current.DeepCopy() replicas = 2 current.Status.Replicas = 2 current.Status.ReadyReplicas = 1 @@ -868,6 +871,8 @@ func PodManagerTester(t *testing.T, method string, mgr splcommon.StatefulSetPodM PodManagerUpdateTester(t, methodPlus, mgr, 2, enterpriseApi.PhaseScalingUp, revised, scaleUpCalls, nil, current, pod) // test scale up (1 ready scaling to 2) + // Reset revised to avoid carrying annotations from previous test + revised = current.DeepCopy() replicas = 1 current.Status.Replicas = 1 current.Status.ReadyReplicas = 1 @@ -876,6 +881,8 @@ func PodManagerTester(t *testing.T, method string, mgr splcommon.StatefulSetPodM PodManagerUpdateTester(t, methodPlus, mgr, 2, enterpriseApi.PhaseScalingUp, revised, updateCalls, nil, current, pod) // test scale down (2 ready, 1 desired) + // Reset revised to avoid carrying annotations from previous test + revised = current.DeepCopy() replicas = 1 current.Status.Replicas = 1 current.Status.ReadyReplicas = 2 @@ -884,6 +891,8 @@ func PodManagerTester(t *testing.T, method string, mgr splcommon.StatefulSetPodM PodManagerUpdateTester(t, methodPlus, mgr, 1, enterpriseApi.PhaseScalingDown, revised, scaleUpCalls, nil, current, pod) // test scale down (2 ready scaling down to 1) + // Reset revised to avoid carrying annotations from previous test + revised = current.DeepCopy() pvcCalls := []MockFuncCall{ {MetaName: "*v1.PersistentVolumeClaim-test-pvc-etc-splunk-stack1-1"}, {MetaName: "*v1.PersistentVolumeClaim-test-pvc-var-splunk-stack1-1"}, @@ -904,6 +913,8 @@ func PodManagerTester(t *testing.T, method string, mgr splcommon.StatefulSetPodM PodManagerUpdateTester(t, methodPlus, mgr, 1, enterpriseApi.PhaseScalingDown, revised, scaleDownCalls, nil, current, pod, pvcList[0], pvcList[1]) // test pod not found + // Reset revised to avoid carrying annotations from previous test + revised = current.DeepCopy() replicas = 1 current.Status.Replicas = 1 current.Status.ReadyReplicas = 1