diff --git a/internal/gnmic/plan.go b/internal/gnmic/plan.go index 020bb05..9abb03b 100644 --- a/internal/gnmic/plan.go +++ b/internal/gnmic/plan.go @@ -320,13 +320,7 @@ func (b *PlanBuilder) buildTargets(plan *ApplyPlan, pipelineData *PipelineData) } } - var subscriptions []string - if subscriptionsMap, ok := b.relationships.targetSubscriptions[targetNN]; ok { - subscriptions = make([]string, 0, len(subscriptionsMap)) - for subscriptionName := range subscriptionsMap { - subscriptions = append(subscriptions, subscriptionName) - } - } + subscriptions := sortedKeys(b.relationships.targetSubscriptions[targetNN]) targetConfig := buildTargetConfig(&target, &profileSpec, creds, b.clientTLS) targetConfig.Subscriptions = subscriptions @@ -343,14 +337,7 @@ func (b *PlanBuilder) buildSubscriptions(plan *ApplyPlan, pipelineData *Pipeline continue } - var outputs []string - if outputsMap, ok := b.relationships.subscriptionOutputs[subNN]; ok { - outputs = make([]string, 0, len(outputsMap)) - for outputName := range outputsMap { - outputs = append(outputs, outputName) - } - } - + outputs := sortedKeys(b.relationships.subscriptionOutputs[subNN]) subConfig := buildSubscriptionConfig(subNN, &subSpec, outputs, pipelineData.Subscriptions) plan.Subscriptions[subNN] = subConfig @@ -387,15 +374,7 @@ func (b *PlanBuilder) buildInputs(plan *ApplyPlan, pipelineData *PipelineData) e continue } - // collect outputs for this input - var outputs []string - if outputSet, ok := b.relationships.inputOutputs[inputNN]; ok { - outputs = make([]string, 0, len(outputSet)) - for outputName := range outputSet { - outputs = append(outputs, outputName) - } - } - + outputs := sortedKeys(b.relationships.inputOutputs[inputNN]) processors := b.relationships.inputProcessors[inputNN] inputConfig, err := buildInputConfig(&inputSpec, outputs, processors) if err != nil { @@ -408,6 +387,21 @@ func (b *PlanBuilder) buildInputs(plan *ApplyPlan, pipelineData *PipelineData) e return nil } +// sortedKeys returns the keys of m in lexicographic order so set-like +// relationship slices (outputs, subscriptions) in the apply plan are stable +// across reconciles. +func sortedKeys(m map[string]struct{}) []string { + if len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func (b *PlanBuilder) buildProcessors(plan *ApplyPlan, pipelineData *PipelineData) error { // process output processors for processorNN, processorSpec := range pipelineData.OutputProcessors { diff --git a/internal/gnmic/plan_test.go b/internal/gnmic/plan_test.go index bf8dadf..15db96d 100644 --- a/internal/gnmic/plan_test.go +++ b/internal/gnmic/plan_test.go @@ -2,6 +2,8 @@ package gnmic import ( "errors" + "reflect" + "slices" "testing" "time" @@ -108,6 +110,54 @@ func TestPlanBuilder_CredentialsError(t *testing.T) { } } +func TestPlanBuilder_RelationshipSlicesSorted(t *testing.T) { + // Multiple names force map-iteration order to matter if slices are left unsorted. + pipeline := NewPipelineData() + pipeline.Targets["default/t1"] = gnmicv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "t1"}, + Spec: gnmicv1alpha1.TargetSpec{Address: "10.0.0.1:57400", Profile: "default"}, + } + pipeline.TargetProfiles["default/default"] = gnmicv1alpha1.TargetProfileSpec{Encoding: "JSON"} + pipeline.Subscriptions["default/sub-z"] = gnmicv1alpha1.SubscriptionSpec{Paths: []string{"/z"}, Mode: "ONCE"} + pipeline.Subscriptions["default/sub-a"] = gnmicv1alpha1.SubscriptionSpec{Paths: []string{"/a"}, Mode: "ONCE"} + pipeline.Subscriptions["default/sub-m"] = gnmicv1alpha1.SubscriptionSpec{Paths: []string{"/m"}, Mode: "ONCE"} + pipeline.Outputs["default/out-z"] = gnmicv1alpha1.OutputSpec{Type: "file"} + pipeline.Outputs["default/out-a"] = gnmicv1alpha1.OutputSpec{Type: "file"} + pipeline.Outputs["default/out-m"] = gnmicv1alpha1.OutputSpec{Type: "file"} + pipeline.Inputs["default/in1"] = gnmicv1alpha1.InputSpec{Type: "kafka"} + + for i := 0; i < 20; i++ { + plan, err := NewPlanBuilder("cluster-a", nil).AddPipeline("pipe1", pipeline).Build() + if err != nil { + t.Fatal(err) + } + + subs := plan.Targets["default/t1"].Subscriptions + if !slices.IsSorted(subs) { + t.Fatalf("target subscriptions not sorted: %v", subs) + } + outs := plan.Subscriptions["default/sub-a"].Outputs + if !slices.IsSorted(outs) { + t.Fatalf("subscription outputs not sorted: %v", outs) + } + inOuts := plan.Inputs["default/in1"]["outputs"].([]string) + if !slices.IsSorted(inOuts) { + t.Fatalf("input outputs not sorted: %v", inOuts) + } + } +} + +func TestSortedKeys(t *testing.T) { + if sortedKeys(nil) != nil { + t.Fatal("expected nil for nil map") + } + got := sortedKeys(map[string]struct{}{"c": {}, "a": {}, "b": {}}) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("sortedKeys = %v, want %v", got, want) + } +} + func TestAssignPorts(t *testing.T) { ports, err := assignPorts([]string{"a", "b", "c"}, PrometheusDefaultPort, PrmetheusPortPoolSize) if err != nil {