diff --git a/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller.go b/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller.go index b461eed7c..856462305 100644 --- a/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller.go +++ b/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller.go @@ -3,6 +3,7 @@ package klusterletcontroller import ( "context" "fmt" + "sort" "strings" "time" @@ -58,6 +59,19 @@ var operatorOnlyFeatureGates = map[featuregate.Feature]bool{ NetworkPolicies: true, } +// buildClusterAnnotationsString renders the ClusterAnnotations map into the +// comma-separated "key1=value1,key2=value2" form consumed by the registration +// agent's --cluster-annotations flag. Keys are sorted deterministically. +func buildClusterAnnotationsString(annotations map[string]string) string { + filtered := commonhelpers.FilterClusterAnnotations(annotations) + arr := make([]string, 0, len(filtered)) + for k, v := range filtered { + arr = append(arr, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(arr) + return strings.Join(arr, ",") +} + func filterOperatorFeatureGates(features []operatorapiv1.FeatureGate) []operatorapiv1.FeatureGate { var filtered []operatorapiv1.FeatureGate for _, f := range features { @@ -422,12 +436,7 @@ func (n *klusterletController) sync(ctx context.Context, controllerContext facto klusterlet.Spec.RegistrationConfiguration.ClusterClaimConfiguration.ReservedClusterClaimSuffixes, ",") } - // construct cluster annotations string, the final format is "key1=value1,key2=value2" - var annotationsArray []string - for k, v := range commonhelpers.FilterClusterAnnotations(klusterlet.Spec.RegistrationConfiguration.ClusterAnnotations) { - annotationsArray = append(annotationsArray, fmt.Sprintf("%s=%s", k, v)) - } - config.ClusterAnnotationsString = strings.Join(annotationsArray, ",") + config.ClusterAnnotationsString = buildClusterAnnotationsString(klusterlet.Spec.RegistrationConfiguration.ClusterAnnotations) // Set AddOnKubeClientRegistrationAuth from the Klusterlet spec if klusterlet.Spec.RegistrationConfiguration.AddOnKubeClientRegistrationDriver != nil && diff --git a/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller_test.go b/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller_test.go index 8dd730f90..f01226766 100644 --- a/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller_test.go +++ b/pkg/operator/operators/klusterlet/controllers/klusterletcontroller/klusterlet_controller_test.go @@ -1917,3 +1917,70 @@ func TestCleanWithMultipleKlusterletAgentNamespaces(t *testing.T) { }) } } + +// TestBuildClusterAnnotationsString verifies that the annotations flag string has consistent ordering across multiple runs. +func TestBuildClusterAnnotationsString(t *testing.T) { + const prefix = "agent.open-cluster-management.io" + tests := []struct { + name string + annotations map[string]string + want string + }{ + { + name: "nil map", + annotations: nil, + want: "", + }, + { + name: "empty map", + annotations: map[string]string{}, + want: "", + }, + { + name: "single key", + annotations: map[string]string{prefix + "/foo": "bar"}, + want: prefix + "/foo=bar", + }, + { + name: "keys sorted lexicographically", + annotations: map[string]string{ + prefix + "/c": "3", + prefix + "/a": "1", + prefix + "/b": "2", + }, + want: prefix + "/a=1," + prefix + "/b=2," + prefix + "/c=3", + }, + { + name: "non-prefixed keys are filtered out", + annotations: map[string]string{ + prefix + "/keep": "yes", + "other.example/drop": "no", + }, + want: prefix + "/keep=yes", + }, + { + name: "many keys stable across many invocations", + annotations: map[string]string{ + prefix + "/a": "1", + prefix + "/b": "2", + prefix + "/c": "3", + prefix + "/d": "4", + prefix + "/e": "5", + prefix + "/f": "6", + }, + want: prefix + "/a=1," + prefix + "/b=2," + prefix + "/c=3," + prefix + "/d=4," + prefix + "/e=5," + prefix + "/f=6", + }, + } + + const iterations = 1000 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for i := range iterations { + got := buildClusterAnnotationsString(tt.annotations) + if got != tt.want { + t.Fatalf("iteration %d: got %q, want %q (non-deterministic output regresses cluster-annotations flag)", i, got, tt.want) + } + } + }) + } +}