From 0209414ca728480a36876eed48ccd288154ff7e8 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 19:57:11 +0100 Subject: [PATCH 01/15] Add envvars package with deduplication and merge-by-name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract env var handling into a dedicated package. Provides: - DeduplicateInUnstructured: deduplicates a Kubernetes-style envVars slice by name (last-one-wins) at an arbitrary path in an unstructured map. - MergeByNameInUnstructured: merges base envVars with current envVars at a path, then deduplicates — used to preserve env vars across config merges where generic slices use replacement semantics. Co-Authored-By: Claude Opus 4.6 --- internal/envvars/envvars.go | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 internal/envvars/envvars.go diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go new file mode 100644 index 00000000..b4f7e7d8 --- /dev/null +++ b/internal/envvars/envvars.go @@ -0,0 +1,58 @@ +package envvars + +import ( + "fmt" + "maps" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// deduplicateEnvVars deduplicates a Kubernetes-style envVars slice by name, +// keeping the last occurrence of each name. Returns a new slice sorted by name. +func deduplicate(envVars []any) []any { + envVarMap := make(map[string]map[string]any) + + for _, item := range envVars { + if envVarItem, ok := item.(map[string]any); ok { + if name, ok := envVarItem["name"].(string); ok { + envVarMap[name] = envVarItem + } + } + } + deduplicatedEnvVars := make([]any, 0, len(envVarMap)) + for _, name := range slices.Sorted(maps.Keys(envVarMap)) { + deduplicatedEnvVars = append(deduplicatedEnvVars, envVarMap[name]) + } + + return deduplicatedEnvVars +} + +// DeduplicateInUnstructured deduplicates the envVars slice at the given +// path inside an unstructured map. No-op if the path does not exist. +func DeduplicateInUnstructured(obj map[string]any, path ...string) error { + envVars, found, err := unstructured.NestedSlice(obj, path...) + if err != nil { + return fmt.Errorf("retrieving nested slice at path %s from unstructured object: %w", strings.Join(path, "."), err) + } + if !found { + return nil + } + return unstructured.SetNestedSlice(obj, deduplicate(envVars), path...) +} + +// MergeByNameInUnstructured prepends baseEnvVars to the envVars already at +// path in obj, then deduplicates by name (last-one-wins, so current entries +// from an overlay take precedence over base). No-op if both sources are empty. +func MergeByNameInUnstructured(obj map[string]any, baseEnvVars []any, path ...string) error { + current, _, err := unstructured.NestedSlice(obj, path...) + if err != nil { + return fmt.Errorf("retrieving nested slice at path %s from unstructured object: %w", strings.Join(path, "."), err) + } + merged := append(baseEnvVars, current...) + if len(merged) == 0 { + return nil + } + return unstructured.SetNestedSlice(obj, deduplicate(merged), path...) +} From 7f264208668a11aaeaed3d3314e3b411c214c8a4 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 19:57:50 +0100 Subject: [PATCH 02/15] Deduplicate env vars after feature flag injection in ConfigureSpec After helpers.DeepMerge appends feature flag env vars into the spec, duplicates can appear when the user has already set the same env var via --config or --set. Call envvars.DeduplicateInUnstructured after the merge so that last-one-wins semantics apply cleanly. Covers both CentralConfig.ConfigureSpec and SecuredClusterConfig.ConfigureSpec. Co-Authored-By: Claude Opus 4.6 --- internal/deployer/config.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 37c2f770..b1b987c6 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -5,6 +5,7 @@ import ( "time" "github.com/stackrox/roxie/internal/constants" + "github.com/stackrox/roxie/internal/envvars" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/imagetag" "github.com/stackrox/roxie/internal/types" @@ -327,8 +328,12 @@ func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { if err != nil { return err } - if err = helpers.DeepMerge(c.Spec, map[string]interface{}{ - "central": map[string]interface{}{ + err = envvars.DeduplicateInUnstructured(c.Spec, "customize", "envVars") + if err != nil { + return fmt.Errorf("deduplicating Central env vars: %w", err) + } + if err = helpers.DeepMerge(c.Spec, map[string]any{ + "central": map[string]any{ "exposure": c.Exposure.ToUnstructuredConfig(), }, }); err != nil { @@ -441,7 +446,9 @@ func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralCo if err := helpers.DeepMerge(s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { return err } - + if err := envvars.DeduplicateInUnstructured(s.Spec, "customize", "envVars"); err != nil { + return fmt.Errorf("deduplicating SecuredCluster env vars: %w", err) + } if _, exists := s.Spec["centralEndpoint"]; !exists { s.Spec["centralEndpoint"] = internalCentralEndpoint(centralConfig.Namespace) } From 6ebb80373e19340afabe47e37c8ea9442c989542 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 19:58:43 +0100 Subject: [PATCH 03/15] Add MergeConfig with envVar-aware merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct mergo.Merge calls in cmd/main.go with deployer.MergeConfig, which preserves customize.envVars across config sources (--config, --set, user defaults). MergeConfig saves base envVars before the merge, then reconciles them with overlay envVars using name-based deduplication. Generic slices (tolerations, imagePullSecrets, etc.) retain replacement semantics — only envVars get merge-by-name. Co-Authored-By: Claude Opus 4.6 --- cmd/main.go | 7 +++---- internal/deployer/config.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 466fb8d5..d1de98a2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,7 +7,6 @@ import ( "os" "reflect" - "dario.cat/mergo" "github.com/fatih/color" "github.com/mattn/go-isatty" "github.com/spf13/cobra" @@ -60,7 +59,7 @@ func tryApplyUserDefaults(config *deployer.Config) error { if err := yaml.Unmarshal(data, &userDefaults); err != nil { return fmt.Errorf("parsing user config %q: %w", path, err) } - if err := mergo.Merge(config, &userDefaults, mergo.WithOverride, mergo.WithoutDereference); err != nil { + if err := deployer.MergeConfig(config, &userDefaults); err != nil { return fmt.Errorf("merging user config %q: %w", path, err) } log.Dimf("Applied user config from %s", path) @@ -111,7 +110,7 @@ func init() { if err := yaml.Unmarshal(data, &configFromFile); err != nil { return fmt.Errorf("failed to unmarshal config from %q: %w", filename, err) } - if err := mergo.Merge(config, &configFromFile, mergo.WithOverride, mergo.WithoutDereference); err != nil { + if err := deployer.MergeConfig(config, &configFromFile); err != nil { return fmt.Errorf("merging config from %q into deployer Config: %w", filename, err) } return nil @@ -135,7 +134,7 @@ func init() { return fmt.Errorf("set expression %q had no effect -- typo?", expr) } - if err := mergo.Merge(config, &patch, mergo.WithOverride, mergo.WithoutDereference); err != nil { + if err := deployer.MergeConfig(config, &patch); err != nil { return fmt.Errorf("merging set-expression %q into deployer Config: %w", expr, err) } diff --git a/internal/deployer/config.go b/internal/deployer/config.go index b1b987c6..ead35638 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -4,12 +4,14 @@ import ( "fmt" "time" + "dario.cat/mergo" "github.com/stackrox/roxie/internal/constants" "github.com/stackrox/roxie/internal/envvars" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/imagetag" "github.com/stackrox/roxie/internal/types" "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // Config is the top-level deployment configuration, combining settings for @@ -39,6 +41,28 @@ func NewConfig() Config { } } +// MergeConfig merges overlay into base with envVar-aware handling. +// Generic slices use replacement semantics (overlay wins), but +// customize.envVars entries are merged by name across sources +// (--config, --set, user defaults) with last-one-wins deduplication. +func MergeConfig(base, overlay *Config) error { + envVarsPath := []string{"customize", "envVars"} + baseCentralEnvVars, _, _ := unstructured.NestedSlice(base.Central.Spec, envVarsPath...) + baseSCEnvVars, _, _ := unstructured.NestedSlice(base.SecuredCluster.Spec, envVarsPath...) + + if err := mergo.Merge(base, overlay, mergo.WithOverride, mergo.WithoutDereference); err != nil { + return err + } + + if err := envvars.MergeByNameInUnstructured(base.Central.Spec, baseCentralEnvVars, envVarsPath...); err != nil { + return fmt.Errorf("reconciling Central env vars: %w", err) + } + if err := envvars.MergeByNameInUnstructured(base.SecuredCluster.Spec, baseSCEnvVars, envVarsPath...); err != nil { + return fmt.Errorf("reconciling SecuredCluster env vars: %w", err) + } + return nil +} + // DeepCopy creates a deep-copy of the provided config using a YAML marshaling/unmarshaling roundtrip. // Due the `omitempty`, this causes empty values (e.g. empty maps) from being discarded (replace with nil // in the resulting copy). From 5539014a62c769f8fe5dd8fe42e86abf81895243 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 19:59:00 +0100 Subject: [PATCH 04/15] Add tests for envvars package Test DeduplicateInUnstructured for last-one-wins dedup, no-op cases, and preservation of valueFrom entries (secretKeyRef, configMapKeyRef). Co-Authored-By: Claude Opus 4.6 --- internal/envvars/envvars_test.go | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 internal/envvars/envvars_test.go diff --git a/internal/envvars/envvars_test.go b/internal/envvars/envvars_test.go new file mode 100644 index 00000000..70386252 --- /dev/null +++ b/internal/envvars/envvars_test.go @@ -0,0 +1,110 @@ +package envvars + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeduplicateEnvVars(t *testing.T) { + tests := []struct { + name string + spec map[string]interface{} + expectedEnvVars map[string]string + }{ + { + name: "duplicates are resolved with last-one-wins", + spec: map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "old"}, + map[string]interface{}{"name": "ROX_KEEP", "value": "me"}, + map[string]interface{}{"name": "ROX_FOO", "value": "new"}, + }, + }, + }, + expectedEnvVars: map[string]string{ + "ROX_FOO": "new", + "ROX_KEEP": "me", + }, + }, + { + name: "no duplicates is a no-op", + spec: map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_A", "value": "a"}, + map[string]interface{}{"name": "ROX_B", "value": "b"}, + }, + }, + }, + expectedEnvVars: map[string]string{ + "ROX_A": "a", + "ROX_B": "b", + }, + }, + { + name: "no envVars is a no-op", + spec: map[string]interface{}{"other": "field"}, + expectedEnvVars: nil, + }, + { + name: "empty spec is a no-op", + spec: map[string]interface{}{}, + expectedEnvVars: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := DeduplicateInUnstructured(tt.spec, "customize", "envVars") + require.NoError(t, err) + + if tt.expectedEnvVars == nil { + return + } + + customize := tt.spec["customize"].(map[string]interface{}) + envVars := customize["envVars"].([]interface{}) + + require.Len(t, envVars, len(tt.expectedEnvVars)) + + resultMap := make(map[string]string) + for _, item := range envVars { + envVar := item.(map[string]interface{}) + resultMap[envVar["name"].(string)] = envVar["value"].(string) + } + + require.Equal(t, tt.expectedEnvVars, resultMap) + }) + } +} + +func TestDeduplicateEnvVars_PreservesValueFrom(t *testing.T) { + valueFrom := map[string]any{ + "secretKeyRef": map[string]any{ + "name": "my-secret", + "key": "password", + }, + } + spec := map[string]any{ + "customize": map[string]any{ + "envVars": []any{ + map[string]any{"name": "SECRET_VAR", "valueFrom": valueFrom}, + map[string]any{"name": "PLAIN_VAR", "value": "hello"}, + }, + }, + } + + require.NoError(t, DeduplicateInUnstructured(spec, "customize", "envVars")) + + customize := spec["customize"].(map[string]any) + envVars := customize["envVars"].([]any) + require.Len(t, envVars, 2) + + secretVar := envVars[1].(map[string]any) // sorted: PLAIN_VAR, SECRET_VAR + require.Equal(t, "SECRET_VAR", secretVar["name"]) + require.Equal(t, valueFrom, secretVar["valueFrom"]) + _, hasValue := secretVar["value"] + require.False(t, hasValue, "valueFrom entry should not have a value field") +} From 5b76122d7bfffb195700d9f91c615320e18408d9 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 19:59:22 +0100 Subject: [PATCH 05/15] Add ConfigureSpec and config merge env var tests Add TestConfigureSpec_EnvVarDeduplication covering both Central and SecuredCluster: non-overlapping vars preserved, overlapping vars deduplicated with last-one-wins, mixed overlap, and clean addition. Add TestNewDeployCmd_ConfigMergePreservesEnvVars verifying that multiple --config files and --set expressions preserve env vars across merges rather than silently replacing them. Co-Authored-By: Claude Opus 4.6 --- cmd/deploy_test.go | 124 +++++++++++++++++ .../deployer/configure_spec_envvars_test.go | 126 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 internal/deployer/configure_spec_envvars_test.go diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index d6a3e842..a8130eed 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -340,6 +340,130 @@ roxie: } } +// TestNewDeployCmd_ConfigMergePreservesEnvVars verifies that spec.customize.envVars +// from multiple --config files and --set expressions are merged by name rather +// than silently replaced. Overlapping names should use the last-specified value; +// non-overlapping names from earlier sources must be preserved. +func TestNewDeployCmd_ConfigMergePreservesEnvVars(t *testing.T) { + firstConfigPath := filepath.Join(t.TempDir(), "first.yaml") + secondConfigPath := filepath.Join(t.TempDir(), "second.yaml") + + tests := []struct { + name string + firstConfig string + secondConfig string + args []string + expectedEnvVars map[string]string + }{ + { + name: "second config preserves first config's non-overlapping envVars", + firstConfig: ` +central: + spec: + customize: + envVars: + - name: FIRST_ONLY + value: "from-first" +`, + secondConfig: ` +central: + spec: + customize: + envVars: + - name: SECOND_ONLY + value: from-second +`, + args: []string{"--config", firstConfigPath, "--config", secondConfigPath}, + expectedEnvVars: map[string]string{ + "FIRST_ONLY": "from-first", + "SECOND_ONLY": "from-second", + }, + }, + { + name: "second config overrides overlapping envVar without losing others", + firstConfig: ` +central: + spec: + customize: + envVars: + - name: SHARED + value: from-first + - name: FIRST_ONLY + value: keep +`, + secondConfig: ` +central: + spec: + customize: + envVars: + - name: SHARED + value: from-second +`, + args: []string{"--config", firstConfigPath, "--config", secondConfigPath}, + expectedEnvVars: map[string]string{ + "SHARED": "from-second", + "FIRST_ONLY": "keep", + }, + }, + { + name: "set expression preserves config file envVars", + firstConfig: ` +central: + spec: + customize: + envVars: + - name: FROM_CONFIG + value: keep +`, + // The [0] index is strvals syntax for "first element of the array + // I'm constructing", not "replace element 0 of the existing array". + // strvals builds a standalone [{FROM_SET: also-keep}] which then + // gets merged at the struct level — the index is irrelevant to the + // merge. Both envVars should survive, merged by name. + args: []string{ + "--config", firstConfigPath, + "--set", "central.spec.customize.envVars[0].name=FROM_SET_1,central.spec.customize.envVars[0].value=one", + "--set", "central.spec.customize.envVars[0].name=FROM_SET_2,central.spec.customize.envVars[0].value=two", + }, + expectedEnvVars: map[string]string{ + "FROM_CONFIG": "keep", + "FROM_SET_1": "one", + "FROM_SET_2": "two", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NoError(t, os.WriteFile(firstConfigPath, []byte(tt.firstConfig), 0o644)) + if tt.secondConfig != "" { + require.NoError(t, os.WriteFile(secondConfigPath, []byte(tt.secondConfig), 0o644)) + } + + // Global variable that we keep reusing for the flags parsing. + deploySettingsFromArgs = deployer.NewConfig() + deployCmd, _, err := rootCmd.Find([]string{"deploy"}) + require.NoError(t, err) + require.NoError(t, deployCmd.ParseFlags(tt.args)) + + spec := deploySettingsFromArgs.Central.Spec + customize, ok := spec["customize"].(map[string]any) + require.True(t, ok, "customize should be present in spec") + envVars, ok := customize["envVars"].([]any) + require.True(t, ok, "envVars should be present in customize") + + got := make(map[string]string) + for _, item := range envVars { + ev := item.(map[string]any) + got[ev["name"].(string)] = ev["value"].(string) + } + assert.Equal(t, tt.expectedEnvVars, got) + assert.Equal(t, len(tt.expectedEnvVars), len(envVars), + "envVars slice should have no duplicates") + }) + } +} + func TestNewDeployCmd_SetRejectsSpec(t *testing.T) { tests := []struct { name string diff --git a/internal/deployer/configure_spec_envvars_test.go b/internal/deployer/configure_spec_envvars_test.go new file mode 100644 index 00000000..cbc9b45e --- /dev/null +++ b/internal/deployer/configure_spec_envvars_test.go @@ -0,0 +1,126 @@ +package deployer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// TestConfigureSpec_EnvVarDeduplication tests that ConfigureSpec correctly merges +// feature flags with pre-existing env vars in the spec, producing no duplicates +// and preserving non-overlapping vars. Covers both Central and SecuredCluster. +func TestConfigureSpec_EnvVarDeduplication(t *testing.T) { + tests := []struct { + name string + existingEnvVars []any + featureFlags map[string]bool + expectedEnvVars map[string]string + }{ + { + name: "config envVars preserved when features add non-overlapping flags", + existingEnvVars: envVarSlice( + map[string]string{"name": "MY_CUSTOM_VAR", "value": "hello"}, + ), + featureFlags: map[string]bool{ + "ROX_NEW": true, + }, + expectedEnvVars: map[string]string{ + "MY_CUSTOM_VAR": "hello", + "ROX_NEW": "true", + }, + }, + { + name: "features override overlapping config envVar without duplicates", + existingEnvVars: envVarSlice( + map[string]string{"name": "ROX_FOO", "value": "true"}, + ), + featureFlags: map[string]bool{ + "ROX_FOO": false, + }, + expectedEnvVars: map[string]string{ + "ROX_FOO": "false", + }, + }, + { + name: "mixed envVars with partial overlap", + existingEnvVars: envVarSlice( + map[string]string{"name": "MY_CUSTOM_VAR", "value": "hello"}, + map[string]string{"name": "ROX_FOO", "value": "true"}, + ), + featureFlags: map[string]bool{ + "ROX_FOO": false, + }, + expectedEnvVars: map[string]string{ + "MY_CUSTOM_VAR": "hello", + "ROX_FOO": "false", + }, + }, + { + name: "no existing envVars, features added cleanly", + existingEnvVars: nil, + featureFlags: map[string]bool{ + "ROX_A": true, + "ROX_B": false, + }, + expectedEnvVars: map[string]string{ + "ROX_A": "true", + "ROX_B": "false", + }, + }, + } + + for _, tt := range tests { + roxie := NewRoxieConfig() + roxie.FeatureFlags = tt.featureFlags + + t.Run("central/"+tt.name, func(t *testing.T) { + cfg := NewCentralConfig() + if tt.existingEnvVars != nil { + cfg.Spec["customize"] = map[string]any{"envVars": tt.existingEnvVars} + } + + require.NoError(t, cfg.ConfigureSpec(&roxie)) + + got := extractEnvVars(t, cfg.Spec) + assert.Equal(t, tt.expectedEnvVars, got) + }) + + t.Run("securedCluster/"+tt.name, func(t *testing.T) { + cfg := SecuredClusterConfig{Namespace: "stackrox", Spec: make(map[string]any)} + if tt.existingEnvVars != nil { + cfg.Spec["customize"] = map[string]any{"envVars": tt.existingEnvVars} + } + + require.NoError(t, cfg.ConfigureSpec(&roxie, &CentralConfig{Namespace: "acs-central"})) + + got := extractEnvVars(t, cfg.Spec) + assert.Equal(t, tt.expectedEnvVars, got) + }) + } +} + +func envVarSlice(vars ...map[string]string) []any { + result := make([]any, len(vars)) + for i, v := range vars { + result[i] = map[string]any{"name": v["name"], "value": v["value"]} + } + return result +} + +func extractEnvVars(t *testing.T, spec map[string]any) map[string]string { + t.Helper() + envVars, found, err := unstructured.NestedSlice(spec, "customize", "envVars") + require.NoError(t, err) + require.True(t, found, "customize.envVars not found in spec") + + result := make(map[string]string) + for _, item := range envVars { + ev := item.(map[string]any) + name := ev["name"].(string) + require.NotContains(t, result, name, "duplicate envVar %q in slice", name) + result[name] = ev["value"].(string) + } + return result +} From cf15ca71eb20fd2668c1f030ef5b9d3ed73d3e2d Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 19:59:40 +0100 Subject: [PATCH 06/15] Replace obsolete mergeEnvVars tests with DeepMerge+dedup test Remove TestMergeEnvVars and TestMergeWithEnvVarSupport which tested the now-unused mergeEnvVars and mergeWithEnvVarSupport functions. Add TestDeepMergeWithDeduplicateEnvVars which tests the actual production code path: helpers.DeepMerge followed by envvars.DeduplicateInUnstructured. Co-Authored-By: Claude Opus 4.6 --- internal/deployer/feature_flags_test.go | 275 +++--------------------- 1 file changed, 24 insertions(+), 251 deletions(-) diff --git a/internal/deployer/feature_flags_test.go b/internal/deployer/feature_flags_test.go index bc6c72ae..1ecd4676 100644 --- a/internal/deployer/feature_flags_test.go +++ b/internal/deployer/feature_flags_test.go @@ -4,6 +4,8 @@ import ( "reflect" "testing" + "github.com/stackrox/roxie/internal/envvars" + "github.com/stackrox/roxie/internal/helpers" "github.com/stretchr/testify/require" ) @@ -142,266 +144,37 @@ func TestParseFeatureFlags(t *testing.T) { } } -func TestMergeEnvVars(t *testing.T) { - tests := []struct { - name string - base []interface{} - overlay []interface{} - expected map[string]string - checkOrdering bool - expectedOrder []string - }{ - { - name: "overlapping names - overlay wins", - base: []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "old"}, - map[string]interface{}{"name": "ROX_BAR", "value": "keep"}, - }, - overlay: []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "new"}, - }, - expected: map[string]string{ - "ROX_FOO": "new", - "ROX_BAR": "keep", - }, - }, - { - name: "no overlapping names", - base: []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, - }, - overlay: []interface{}{ - map[string]interface{}{"name": "ROX_BAR", "value": "bar"}, - }, - expected: map[string]string{ - "ROX_FOO": "foo", - "ROX_BAR": "bar", - }, - }, - { - name: "empty base", - base: []interface{}{}, - overlay: []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, - }, - expected: map[string]string{ - "ROX_FOO": "foo", - }, - }, - { - name: "empty overlay", - base: []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, - }, - overlay: []interface{}{}, - expected: map[string]string{ - "ROX_FOO": "foo", - }, - }, - { - name: "malformed envVar without name field", - base: []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, - }, - overlay: []interface{}{ - map[string]interface{}{"value": "no-name"}, // missing "name" field - map[string]interface{}{"name": "ROX_BAR", "value": "bar"}, +func TestDeepMergeWithDeduplicateEnvVars(t *testing.T) { + spec := map[string]any{ + "customize": map[string]any{ + "envVars": []any{ + map[string]any{"name": "MY_VAR", "value": "keep"}, + map[string]any{"name": "ROX_FOO", "value": "old"}, }, - expected: map[string]string{ - "ROX_FOO": "foo", - "ROX_BAR": "bar", - }, - }, - { - name: "result is sorted alphabetically", - base: []interface{}{ - map[string]interface{}{"name": "ROX_ZZZ", "value": "z"}, - map[string]interface{}{"name": "ROX_AAA", "value": "a"}, - }, - overlay: []interface{}{ - map[string]interface{}{"name": "ROX_MMM", "value": "m"}, - }, - expected: map[string]string{ - "ROX_AAA": "a", - "ROX_MMM": "m", - "ROX_ZZZ": "z", - }, - checkOrdering: true, - expectedOrder: []string{"ROX_AAA", "ROX_MMM", "ROX_ZZZ"}, }, } + overlay := featureFlagsToOverrides(map[string]bool{"ROX_FOO": false, "ROX_NEW": true}) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := mergeEnvVars(tt.base, tt.overlay) - - if len(result) != len(tt.expected) { - t.Fatalf("expected %d env vars, got %d", len(tt.expected), len(result)) - } - - resultMap := make(map[string]string) - for _, item := range result { - envVar := item.(map[string]interface{}) - if name, ok := envVar["name"].(string); ok { - resultMap[name] = envVar["value"].(string) - } - } + err := helpers.DeepMerge(spec, overlay) + require.NoError(t, err) - if !reflect.DeepEqual(resultMap, tt.expected) { - t.Errorf("got %v, want %v", resultMap, tt.expected) - } + err = envvars.DeduplicateInUnstructured(spec, "customize", "envVars") + require.NoError(t, err) - if tt.checkOrdering { - actualOrder := make([]string, len(result)) - for i, item := range result { - envVar := item.(map[string]interface{}) - actualOrder[i] = envVar["name"].(string) - } - if !reflect.DeepEqual(actualOrder, tt.expectedOrder) { - t.Errorf("ordering: got %v, want %v", actualOrder, tt.expectedOrder) - } - } - }) - } -} + customize := spec["customize"].(map[string]any) + envVars := customize["envVars"].([]any) -func TestMergeWithEnvVarSupport(t *testing.T) { - tests := []struct { - name string - base map[string]interface{} - overlay map[string]interface{} - expectedEnvVars map[string]string - checkOtherFields bool - }{ - { - name: "both have envVars with overlap", - base: map[string]interface{}{ - "spec": map[string]interface{}{ - "customize": map[string]interface{}{ - "envVars": []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "old"}, - map[string]interface{}{"name": "ROX_KEEP", "value": "me"}, - }, - }, - }, - }, - overlay: map[string]interface{}{ - "spec": map[string]interface{}{ - "customize": map[string]interface{}{ - "envVars": []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "new"}, - }, - }, - }, - }, - expectedEnvVars: map[string]string{ - "ROX_FOO": "new", - "ROX_KEEP": "me", - }, - }, - { - name: "base has envVars, overlay has none", - base: map[string]interface{}{ - "spec": map[string]interface{}{ - "customize": map[string]interface{}{ - "envVars": []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, - }, - }, - }, - }, - overlay: map[string]interface{}{ - "spec": map[string]interface{}{ - "other": "field", - }, - }, - expectedEnvVars: map[string]string{ - "ROX_FOO": "foo", - }, - }, - { - name: "overlay has envVars, base has none", - base: map[string]interface{}{ - "spec": map[string]interface{}{ - "other": "field", - }, - }, - overlay: map[string]interface{}{ - "spec": map[string]interface{}{ - "customize": map[string]interface{}{ - "envVars": []interface{}{ - map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, - }, - }, - }, - }, - expectedEnvVars: map[string]string{ - "ROX_FOO": "foo", - }, - }, - { - name: "neither has envVars", - base: map[string]interface{}{ - "spec": map[string]interface{}{ - "other": "field", - }, - }, - overlay: map[string]interface{}{ - "spec": map[string]interface{}{ - "another": "field", - }, - }, - expectedEnvVars: nil, - checkOtherFields: true, - }, + resultMap := make(map[string]string) + for _, item := range envVars { + envVar := item.(map[string]any) + resultMap[envVar["name"].(string)] = envVar["value"].(string) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := mergeWithEnvVarSupport(tt.base, tt.overlay) - require.NoError(t, err) - - spec, ok := result["spec"].(map[string]interface{}) - if !ok { - t.Fatal("spec should be a map") - } - - if tt.expectedEnvVars != nil { - customize, ok := spec["customize"].(map[string]interface{}) - if !ok { - t.Fatal("customize should be a map") - } - - envVars, ok := customize["envVars"].([]interface{}) - if !ok { - t.Fatal("envVars should be a slice") - } - - if len(envVars) != len(tt.expectedEnvVars) { - t.Fatalf("expected %d env vars, got %d", len(tt.expectedEnvVars), len(envVars)) - } - - resultMap := make(map[string]string) - for _, item := range envVars { - envVar := item.(map[string]interface{}) - resultMap[envVar["name"].(string)] = envVar["value"].(string) - } - - if !reflect.DeepEqual(resultMap, tt.expectedEnvVars) { - t.Errorf("got %v, want %v", resultMap, tt.expectedEnvVars) - } - } - - if tt.checkOtherFields { - if spec["other"] != "field" { - t.Errorf("expected base field to be preserved") - } - if spec["another"] != "field" { - t.Errorf("expected overlay field to be added") - } - } - }) - } + require.Equal(t, map[string]string{ + "MY_VAR": "keep", + "ROX_FOO": "false", + "ROX_NEW": "true", + }, resultMap) } func TestFeatureFlagsConversion(t *testing.T) { From f6048e95ee724c38de395d0dd21a09d3e9b16181 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 20:00:08 +0100 Subject: [PATCH 07/15] Remove dead mergeEnvVars and mergeWithEnvVarSupport code These functions were decoupled from the production code path in an earlier change and replaced by the envvars package. Remove them along with their now-unused imports. Co-Authored-By: Claude Opus 4.6 --- internal/deployer/feature_flags.go | 50 ------------------------------ 1 file changed, 50 deletions(-) diff --git a/internal/deployer/feature_flags.go b/internal/deployer/feature_flags.go index 5912feb7..701dd2a3 100644 --- a/internal/deployer/feature_flags.go +++ b/internal/deployer/feature_flags.go @@ -2,13 +2,8 @@ package deployer import ( "fmt" - "slices" - "sort" "strconv" "strings" - - "github.com/stackrox/roxie/internal/helpers" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // parseFlagWithEquals parses a feature flag in the format "ROX_FOO=true" or "ROX_FOO=false" @@ -111,48 +106,3 @@ func featureFlagsToOverrides(flags map[string]bool) map[string]interface{} { }, } } - -// mergeEnvVars smartly merges envVars arrays, with newer values overriding older ones by name. -// This allows feature flags to override specific env vars without wiping the entire array. -func mergeEnvVars(base, overlay []interface{}) []interface{} { - envVarMap := make(map[string]interface{}) - - for _, item := range slices.Concat(base, overlay) { - if envVar, ok := item.(map[string]interface{}); ok { - if name, ok := envVar["name"].(string); ok { - envVarMap[name] = envVar - } - } - } - - result := make([]interface{}, 0, len(envVarMap)) - for _, envVar := range envVarMap { - result = append(result, envVar) - } - - sort.Slice(result, func(i, j int) bool { - return result[i].(map[string]interface{})["name"].(string) < - result[j].(map[string]interface{})["name"].(string) - }) - - return result -} - -// mergeWithEnvVarSupport merges two maps, with special handling for spec.customize.envVars arrays. -// Instead of replacing the entire envVars array, it merges individual env vars by name, -// allowing overlay to override specific env vars while preserving others from base. -func mergeWithEnvVarSupport(base, overlay map[string]interface{}) (map[string]interface{}, error) { - result, err := helpers.MergeMaps(base, overlay) - if err != nil { - return nil, err - } - - baseEnvVars, baseFound, _ := unstructured.NestedSlice(base, "spec", "customize", "envVars") - overlayEnvVars, overlayFound, _ := unstructured.NestedSlice(overlay, "spec", "customize", "envVars") - - if baseFound && overlayFound { - _ = unstructured.SetNestedSlice(result, mergeEnvVars(baseEnvVars, overlayEnvVars), "spec", "customize", "envVars") - } - - return result, nil -} From f3e0a06a227e1651252209e585727ca718a84a88 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 20:46:59 +0100 Subject: [PATCH 08/15] Inline helpers.DeepMerge at call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepMerge was a one-line wrapper around mergo.Merge that hid the precise merge semantics (WithOverride, WithAppendSlice) from the caller. Now that we care about which options each call site needs — some need WithAppendSlice (feature flags, CR assembly with overlays), some don't (exposure config) — it is better to make this explicit. Each call site now specifies its own merge options: - Feature flag merges: WithOverride + WithAppendSlice (append envVars, then dedup) - Exposure merge: WithOverride only (maps only, no slices) - CR assembly: WithOverride + WithAppendSlice (resource profiles may contain slice fields like overlays/patches) Co-Authored-By: Claude Opus 4.6 --- internal/deployer/config.go | 20 ++++++------ internal/deployer/feature_flags_test.go | 4 +-- internal/helpers/helpers.go | 8 +---- internal/helpers/helpers_test.go | 42 ------------------------- 4 files changed, 13 insertions(+), 61 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index ead35638..c7928095 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -348,7 +348,7 @@ func (c *CentralConfig) GetExposure() types.Exposure { // ConfigureSpec applies feature flags and exposure settings to the Central spec. func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { - err := helpers.DeepMerge(c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)) + err := mergo.Merge(&c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags), mergo.WithOverride, mergo.WithAppendSlice) if err != nil { return err } @@ -356,11 +356,11 @@ func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { if err != nil { return fmt.Errorf("deduplicating Central env vars: %w", err) } - if err = helpers.DeepMerge(c.Spec, map[string]any{ + if err = mergo.Merge(&c.Spec, map[string]any{ "central": map[string]any{ "exposure": c.Exposure.ToUnstructuredConfig(), }, - }); err != nil { + }, mergo.WithOverride); err != nil { return err } return nil @@ -394,14 +394,14 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { if err != nil { return nil, fmt.Errorf("copying resource profile: %w", err) } - if err := helpers.DeepMerge(cr, profileCopy); err != nil { + if err := mergo.Merge(&cr, profileCopy, mergo.WithOverride, mergo.WithAppendSlice); err != nil { return nil, fmt.Errorf("merging resource profile into Central CR: %w", err) } } - if err := helpers.DeepMerge(cr, map[string]interface{}{ + if err := mergo.Merge(&cr, map[string]interface{}{ "metadata": c.Metadata.toMap(), "spec": c.Spec, - }); err != nil { + }, mergo.WithOverride, mergo.WithAppendSlice); err != nil { return nil, fmt.Errorf("merging metadata and spec into Central CR: %w", err) } return cr, nil @@ -467,7 +467,7 @@ func (s *SecuredClusterConfig) GetWaitConfig() WaitConfig { // ConfigureSpec applies feature flags and the central endpoint to the SecuredCluster spec. func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { - if err := helpers.DeepMerge(s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { + if err := mergo.Merge(&s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags), mergo.WithOverride, mergo.WithAppendSlice); err != nil { return err } if err := envvars.DeduplicateInUnstructured(s.Spec, "customize", "envVars"); err != nil { @@ -501,15 +501,15 @@ func (s *SecuredClusterConfig) CustomResource() (map[string]interface{}, error) if err != nil { return nil, fmt.Errorf("copying resource profile: %w", err) } - if err := helpers.DeepMerge(cr, profileCopy); err != nil { + if err := mergo.Merge(&cr, profileCopy, mergo.WithOverride, mergo.WithAppendSlice); err != nil { return nil, fmt.Errorf("merging resource profile into SecuredCluster CR: %w", err) } } - if err := helpers.DeepMerge(cr, map[string]interface{}{ + if err := mergo.Merge(&cr, map[string]interface{}{ "metadata": s.Metadata.toMap(), "spec": s.Spec, - }); err != nil { + }, mergo.WithOverride, mergo.WithAppendSlice); err != nil { return nil, fmt.Errorf("merging metadata and spec into SecuredCluster CR: %w", err) } return cr, nil diff --git a/internal/deployer/feature_flags_test.go b/internal/deployer/feature_flags_test.go index 1ecd4676..4577927e 100644 --- a/internal/deployer/feature_flags_test.go +++ b/internal/deployer/feature_flags_test.go @@ -4,8 +4,8 @@ import ( "reflect" "testing" + "dario.cat/mergo" "github.com/stackrox/roxie/internal/envvars" - "github.com/stackrox/roxie/internal/helpers" "github.com/stretchr/testify/require" ) @@ -155,7 +155,7 @@ func TestDeepMergeWithDeduplicateEnvVars(t *testing.T) { } overlay := featureFlagsToOverrides(map[string]bool{"ROX_FOO": false, "ROX_NEW": true}) - err := helpers.DeepMerge(spec, overlay) + err := mergo.Merge(&spec, overlay, mergo.WithOverride, mergo.WithAppendSlice) require.NoError(t, err) err = envvars.DeduplicateInUnstructured(spec, "customize", "envVars") diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index b56160bc..c3549362 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -67,7 +67,7 @@ func MergeMaps(base map[string]interface{}, overlays ...map[string]interface{}) result := deepCopy(base) for _, overlay := range overlays { - if err := DeepMerge(result, overlay); err != nil { + if err := mergo.Merge(&result, overlay, mergo.WithOverride, mergo.WithAppendSlice); err != nil { return nil, err } } @@ -107,12 +107,6 @@ func deepCopySlice(s []interface{}) []interface{} { return result } -// DeepMerge recursively merges overlay into base. -// Slices are appended, with values from overlay appearing after values from base. -func DeepMerge(base, overlay map[string]any) error { - return mergo.Merge(&base, &overlay, mergo.WithOverride, mergo.WithAppendSlice) -} - func MapToStruct(m map[string]interface{}, out interface{}) error { bytes, err := yaml.Marshal(m) if err != nil { diff --git a/internal/helpers/helpers_test.go b/internal/helpers/helpers_test.go index 1cbc0bcc..2bd8c73b 100644 --- a/internal/helpers/helpers_test.go +++ b/internal/helpers/helpers_test.go @@ -147,48 +147,6 @@ func TestLoadYAMLFileInvalidYAML(t *testing.T) { } } -func TestDeepMergeAppendsSlices(t *testing.T) { - base := map[string]interface{}{ - "spec": map[string]interface{}{ - "imagePullSecrets": []interface{}{ - map[string]interface{}{"name": "secret-a"}, - }, - "keep": "this", - }, - } - - overlay := map[string]interface{}{ - "spec": map[string]interface{}{ - "imagePullSecrets": []interface{}{ - map[string]interface{}{"name": "secret-b"}, - }, - }, - } - - err := DeepMerge(base, overlay) - require.NoError(t, err) - - spec := base["spec"].(map[string]interface{}) - require.Equal(t, "this", spec["keep"]) - - secrets := spec["imagePullSecrets"].([]interface{}) - require.Len(t, secrets, 2) - require.Equal(t, map[string]interface{}{"name": "secret-a"}, secrets[0]) - require.Equal(t, map[string]interface{}{"name": "secret-b"}, secrets[1]) -} - -func TestDeepMergeOverrideOnTypeMismatch(t *testing.T) { - base := map[string]interface{}{ - "items": []interface{}{"a"}, - } - overlay := map[string]interface{}{ - "items": "not-a-slice", - } - - err := DeepMerge(base, overlay) - require.NoError(t, err) - require.Equal(t, "not-a-slice", base["items"]) -} func TestDeepCopy(t *testing.T) { original := map[string]interface{}{ From f969f58f4ccdbe1bd41f6fb5016be07043cf3d13 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 5 Nov 2026 20:48:13 +0100 Subject: [PATCH 09/15] Replace helpers.MergeMaps with helpers.DeepCopyMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MergeMaps accepted variadic overlays, but every call site passed zero overlays — it was only used as a deep-copy to protect package-level resource profile constants from mutation during mergo.Merge. The merge-with-overlays capability was unused. Export the underlying deepCopy as DeepCopyMap and drop MergeMaps along with its mergo dependency in the helpers package. Co-Authored-By: Claude Opus 4.6 --- internal/deployer/config.go | 10 +---- internal/helpers/helpers.go | 22 ++-------- internal/helpers/helpers_test.go | 75 +------------------------------- 3 files changed, 7 insertions(+), 100 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index c7928095..79f36490 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -390,10 +390,7 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { return nil, fmt.Errorf("resource profile 'auto' should have been resolved before building the CR") } if c.ResourceProfile != types.ResourceProfileAcsDefaults { - profileCopy, err := helpers.MergeMaps(getCentralResourcesOperator(c.ResourceProfile)) - if err != nil { - return nil, fmt.Errorf("copying resource profile: %w", err) - } + profileCopy := helpers.DeepCopyMap(getCentralResourcesOperator(c.ResourceProfile)) if err := mergo.Merge(&cr, profileCopy, mergo.WithOverride, mergo.WithAppendSlice); err != nil { return nil, fmt.Errorf("merging resource profile into Central CR: %w", err) } @@ -497,10 +494,7 @@ func (s *SecuredClusterConfig) CustomResource() (map[string]interface{}, error) return nil, fmt.Errorf("resource profile 'auto' must have been resolved before building the CR") } if s.ResourceProfile != types.ResourceProfileAcsDefaults { - profileCopy, err := helpers.MergeMaps(getSecuredClusterResourcesOperator(s.ResourceProfile)) - if err != nil { - return nil, fmt.Errorf("copying resource profile: %w", err) - } + profileCopy := helpers.DeepCopyMap(getSecuredClusterResourcesOperator(s.ResourceProfile)) if err := mergo.Merge(&cr, profileCopy, mergo.WithOverride, mergo.WithAppendSlice); err != nil { return nil, fmt.Errorf("merging resource profile into SecuredCluster CR: %w", err) } diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index c3549362..7a6cad65 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -7,7 +7,6 @@ import ( "os/exec" "strings" - "dario.cat/mergo" "gopkg.in/yaml.v3" ) @@ -62,26 +61,13 @@ func LoadYAMLFile(path string) (map[string]interface{}, error) { return result, nil } -// MergeMaps deeply merges multiple maps, with later maps taking precedence -func MergeMaps(base map[string]interface{}, overlays ...map[string]interface{}) (map[string]interface{}, error) { - result := deepCopy(base) - - for _, overlay := range overlays { - if err := mergo.Merge(&result, overlay, mergo.WithOverride, mergo.WithAppendSlice); err != nil { - return nil, err - } - } - - return result, nil -} - -// deepCopy creates a deep copy of a map -func deepCopy(m map[string]interface{}) map[string]interface{} { +// DeepCopyMap creates a deep copy of an unstructured map. +func DeepCopyMap(m map[string]interface{}) map[string]interface{} { result := make(map[string]interface{}) for k, v := range m { switch val := v.(type) { case map[string]interface{}: - result[k] = deepCopy(val) + result[k] = DeepCopyMap(val) case []interface{}: result[k] = deepCopySlice(val) default: @@ -97,7 +83,7 @@ func deepCopySlice(s []interface{}) []interface{} { for i, v := range s { switch val := v.(type) { case map[string]interface{}: - result[i] = deepCopy(val) + result[i] = DeepCopyMap(val) case []interface{}: result[i] = deepCopySlice(val) default: diff --git a/internal/helpers/helpers_test.go b/internal/helpers/helpers_test.go index 2bd8c73b..7566b52a 100644 --- a/internal/helpers/helpers_test.go +++ b/internal/helpers/helpers_test.go @@ -4,80 +4,8 @@ import ( "os" "path/filepath" "testing" - - "github.com/stretchr/testify/require" ) -func TestMergeMaps(t *testing.T) { - base := map[string]interface{}{ - "a": "value_a", - "b": map[string]interface{}{ - "c": "value_c", - "d": "value_d", - }, - } - - overlay := map[string]interface{}{ - "b": map[string]interface{}{ - "d": "value_d_override", - "e": "value_e_new", - }, - "f": "value_f", - } - - result, err := MergeMaps(base, overlay) - require.NoError(t, err, "MergeMaps failed") - - // Check that base values are preserved - if result["a"] != "value_a" { - t.Errorf("Expected a='value_a', got '%v'", result["a"]) - } - - // Check deep merge - bMap, ok := result["b"].(map[string]interface{}) - if !ok { - t.Fatal("Expected b to be a map") - } - - if bMap["c"] != "value_c" { - t.Errorf("Expected b.c='value_c', got '%v'", bMap["c"]) - } - - if bMap["d"] != "value_d_override" { - t.Errorf("Expected b.d to be overridden to 'value_d_override', got '%v'", bMap["d"]) - } - - if bMap["e"] != "value_e_new" { - t.Errorf("Expected b.e='value_e_new', got '%v'", bMap["e"]) - } - - // Check new top-level key - if result["f"] != "value_f" { - t.Errorf("Expected f='value_f', got '%v'", result["f"]) - } -} - -func TestMergeMapsMultipleOverlays(t *testing.T) { - base := map[string]interface{}{ - "key": "base", - } - - overlay1 := map[string]interface{}{ - "key": "overlay1", - } - - overlay2 := map[string]interface{}{ - "key": "overlay2", - } - - result, err := MergeMaps(base, overlay1, overlay2) - require.NoError(t, err, "MergeMaps failed") - - if result["key"] != "overlay2" { - t.Errorf("Expected last overlay to win, got '%v'", result["key"]) - } -} - func TestLoadYAMLFileValid(t *testing.T) { tmpDir := t.TempDir() yamlPath := filepath.Join(tmpDir, "test.yaml") @@ -147,7 +75,6 @@ func TestLoadYAMLFileInvalidYAML(t *testing.T) { } } - func TestDeepCopy(t *testing.T) { original := map[string]interface{}{ "a": "value", @@ -156,7 +83,7 @@ func TestDeepCopy(t *testing.T) { }, } - copied := deepCopy(original) + copied := DeepCopyMap(original) // Modify the copy copied["a"] = "modified" From cc7a60ae0fd0fa286185aa972479d395207cc96b Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 9 Nov 2026 10:39:01 +0100 Subject: [PATCH 10/15] Support dependant env vars --- README.md | 31 ++++++++ internal/deployer/config.go | 17 +++++ internal/envvars/envvars.go | 96 +++++++++++++++++++++---- internal/envvars/envvars_test.go | 119 ++++++++++++++++++++++++++++++- 4 files changed, 249 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 255de41a..c128bdec 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,37 @@ Add-ons are installed as Helm releases prefixed with `roxie-addon-` in the Centr During teardown, roxie discovers which add-ons have been installed as part of the most recent deployment and uninstalls those add-ons as part of a Central teardown. +### Environment variables in CR specs + +roxie supports Kubernetes-style `$(VAR)` interpolation in `customize.envVars`. +When env vars come from multiple sources (config files, `--set` flags, feature +flags), roxie merges them by name: the first source to introduce a variable +determines its position in the list, while later sources can override its value. + +This preserves **dependent env var ordering** required by Kubernetes — a variable +that references another via `$(VAR)` must appear after the referent: + +```yaml +central: + spec: + customize: + envVars: + - name: BASE_URL + value: "https://example.com" + - name: API_URL + value: "$(BASE_URL)/api" # resolved by Kubernetes at runtime +``` + +roxie emits a warning when it detects a `$(VAR)` reference that cannot be +resolved because the referent is not defined at that point in the list. + +**Limitation:** if a later config source introduces a new variable that an +earlier source already references via `$(VAR)`, the new variable will appear +after the reference and Kubernetes will not resolve it. To avoid this, define +variables before their dependents within the same config source. This is a +deliberate trade-off to keep the env var merging logic simple and predictable +while still supporting dependent env vars in the common case. + ## Development Enter the dev shell: diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 79f36490..42af8210 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -9,6 +9,7 @@ import ( "github.com/stackrox/roxie/internal/envvars" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/imagetag" + log "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/types" "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -60,9 +61,23 @@ func MergeConfig(base, overlay *Config) error { if err := envvars.MergeByNameInUnstructured(base.SecuredCluster.Spec, baseSCEnvVars, envVarsPath...); err != nil { return fmt.Errorf("reconciling SecuredCluster env vars: %w", err) } + + warnUnresolvedEnvVarRefs("Central", base.Central.Spec, envVarsPath) + warnUnresolvedEnvVarRefs("SecuredCluster", base.SecuredCluster.Spec, envVarsPath) return nil } +func warnUnresolvedEnvVarRefs(component string, spec map[string]any, path []string) { + envVars, found, _ := unstructured.NestedSlice(spec, path...) + if !found { + return + } + for _, ref := range envvars.FindUnresolvedRefs(envVars) { + log.Warningf("%s env var %s references $(%s), which is not defined at that point — Kubernetes will not resolve it", + component, ref.Name, ref.RefName) + } +} + // DeepCopy creates a deep-copy of the provided config using a YAML marshaling/unmarshaling roundtrip. // Due the `omitempty`, this causes empty values (e.g. empty maps) from being discarded (replace with nil // in the resulting copy). @@ -356,6 +371,7 @@ func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { if err != nil { return fmt.Errorf("deduplicating Central env vars: %w", err) } + warnUnresolvedEnvVarRefs("Central", c.Spec, []string{"customize", "envVars"}) if err = mergo.Merge(&c.Spec, map[string]any{ "central": map[string]any{ "exposure": c.Exposure.ToUnstructuredConfig(), @@ -470,6 +486,7 @@ func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralCo if err := envvars.DeduplicateInUnstructured(s.Spec, "customize", "envVars"); err != nil { return fmt.Errorf("deduplicating SecuredCluster env vars: %w", err) } + warnUnresolvedEnvVarRefs("SecuredCluster", s.Spec, []string{"customize", "envVars"}) if _, exists := s.Spec["centralEndpoint"]; !exists { s.Spec["centralEndpoint"] = internalCentralEndpoint(centralConfig.Namespace) } diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index b4f7e7d8..71d4efad 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -2,31 +2,101 @@ package envvars import ( "fmt" - "maps" - "slices" + "regexp" "strings" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -// deduplicateEnvVars deduplicates a Kubernetes-style envVars slice by name, -// keeping the last occurrence of each name. Returns a new slice sorted by name. -func deduplicate(envVars []any) []any { - envVarMap := make(map[string]map[string]any) +var ( + // Note, we match the dollars in a seperate match group to be able to check for escaped dollar characters. + varRefPattern = regexp.MustCompile(`(\$+)\(([^)]+)\)`) +) + +// UnresolvedRef describes an env var that references a variable not +// yet defined at that point in the list. +type UnresolvedRef struct { + Name string + RefName string +} + +// FindUnresolvedRefs checks a Kubernetes-style envVars slice for $(VAR) +// references that Kubernetes cannot resolve because the referent is not +// defined at the point of reference (absent or appears later in the list). +// Also handles escaping such as "$$(NOT_GETTING_INTERPOLATED)". +func FindUnresolvedRefs(envVars []any) []UnresolvedRef { + defined := make(map[string]bool) + var refs []UnresolvedRef for _, item := range envVars { - if envVarItem, ok := item.(map[string]any); ok { - if name, ok := envVarItem["name"].(string); ok { - envVarMap[name] = envVarItem + ev, ok := item.(map[string]any) + if !ok { + continue + } + name, ok := ev["name"].(string) + if !ok { + continue + } + if ev["value"] == nil && ev["valueFrom"] == nil { + continue + } + defined[name] = true + val, ok := ev["value"].(string) + if !ok { + continue + } + for _, match := range varRefPattern.FindAllStringSubmatch(val, -1) { + dollars := match[1] + refName := match[2] + if len(dollars)%2 == 0 { + continue // even number of $ — escaped, e.g. $$(VAR) + } + if !defined[refName] { + refs = append(refs, UnresolvedRef{Name: name, RefName: refName}) } } } - deduplicatedEnvVars := make([]any, 0, len(envVarMap)) - for _, name := range slices.Sorted(maps.Keys(envVarMap)) { - deduplicatedEnvVars = append(deduplicatedEnvVars, envVarMap[name]) + return refs +} + +// deduplicate deduplicates a Kubernetes-style envVars slice by name. +// First occurrence of a name determines its position in the output; +// last occurrence determines its value. This preserves the dependency +// ordering required by Kubernetes' $(VAR) interpolation: a variable +// that references another via $(VAR) must appear after the referent. +// +// Limitation: if a later source introduces a NEW variable that an +// earlier source already references via $(VAR), the new variable will +// appear after the reference and Kubernetes will not resolve it. +func deduplicate(envVars []any) []any { + var result []any + indices := make(map[string]int) // Stores name-keyed indices in result slice. + + // Takes an any-item and returns its name and its complete unstructured obj. + // Returns non-empty name on success. + extractItem := func(item any) (name string, envVarItem map[string]any) { + var ok bool + envVarItem, ok = item.(map[string]any) + if !ok { + return + } + name, _ = envVarItem["name"].(string) + return } - return deduplicatedEnvVars + for _, item := range envVars { + name, envVarItem := extractItem(item) + if name == "" { + continue + } + if i, exists := indices[name]; exists { + result[i] = envVarItem + } else { + indices[name] = len(result) + result = append(result, envVarItem) + } + } + return result } // DeduplicateInUnstructured deduplicates the envVars slice at the given diff --git a/internal/envvars/envvars_test.go b/internal/envvars/envvars_test.go index 70386252..1d6ffe14 100644 --- a/internal/envvars/envvars_test.go +++ b/internal/envvars/envvars_test.go @@ -102,9 +102,126 @@ func TestDeduplicateEnvVars_PreservesValueFrom(t *testing.T) { envVars := customize["envVars"].([]any) require.Len(t, envVars, 2) - secretVar := envVars[1].(map[string]any) // sorted: PLAIN_VAR, SECRET_VAR + secretVar := envVars[0].(map[string]any) require.Equal(t, "SECRET_VAR", secretVar["name"]) require.Equal(t, valueFrom, secretVar["valueFrom"]) _, hasValue := secretVar["value"] require.False(t, hasValue, "valueFrom entry should not have a value field") } + +func TestDeduplicateEnvVars_PreservesDependencyOrder(t *testing.T) { + spec := map[string]any{ + "customize": map[string]any{ + "envVars": []any{ + map[string]any{"name": "BASE_URL", "value": "https://example.com"}, + map[string]any{"name": "API_URL", "value": "$(BASE_URL)/api"}, + map[string]any{"name": "BASE_URL", "value": "https://override.com"}, + }, + }, + } + + require.NoError(t, DeduplicateInUnstructured(spec, "customize", "envVars")) + + customize := spec["customize"].(map[string]any) + envVars := customize["envVars"].([]any) + require.Len(t, envVars, 2) + + require.Equal(t, "BASE_URL", envVars[0].(map[string]any)["name"]) + require.Equal(t, "https://override.com", envVars[0].(map[string]any)["value"]) + require.Equal(t, "API_URL", envVars[1].(map[string]any)["name"]) + require.Equal(t, "$(BASE_URL)/api", envVars[1].(map[string]any)["value"]) +} + +func TestFindUnresolvedRefs(t *testing.T) { + tests := []struct { + name string + envVars []any + expected []UnresolvedRef + }{ + { + name: "no references", + envVars: []any{ + map[string]any{"name": "A", "value": "hello"}, + map[string]any{"name": "B", "value": "world"}, + }, + expected: nil, + }, + { + name: "resolved forward reference", + envVars: []any{ + map[string]any{"name": "BASE", "value": "https://example.com"}, + map[string]any{"name": "URL", "value": "$(BASE)/api"}, + }, + expected: nil, + }, + { + name: "unresolved: referent defined later", + envVars: []any{ + map[string]any{"name": "URL", "value": "$(BASE)/api"}, + map[string]any{"name": "BASE", "value": "https://example.com"}, + }, + expected: []UnresolvedRef{ + {Name: "URL", RefName: "BASE"}, + }, + }, + { + name: "unresolved: referent not defined", + envVars: []any{ + map[string]any{"name": "URL", "value": "$(MISSING)/api"}, + }, + expected: []UnresolvedRef{ + {Name: "URL", RefName: "MISSING"}, + }, + }, + { + name: "multiple references in one value", + envVars: []any{ + map[string]any{"name": "FULL", "value": "$(PROTO)://$(HOST)"}, + map[string]any{"name": "PROTO", "value": "https"}, + }, + expected: []UnresolvedRef{ + {Name: "FULL", RefName: "PROTO"}, + {Name: "FULL", RefName: "HOST"}, + }, + }, + { + name: "escaped $$(VAR) is not a reference", + envVars: []any{ + map[string]any{"name": "LITERAL", "value": "$$(NOT_A_REF)"}, + }, + expected: nil, + }, + { + name: "$$$(VAR) is literal $ followed by a real reference", + envVars: []any{ + map[string]any{"name": "BASE", "value": "x"}, + map[string]any{"name": "COMBO", "value": "$$$(BASE)"}, + }, + expected: nil, + }, + { + name: "$$$(VAR) with undefined referent is flagged", + envVars: []any{ + map[string]any{"name": "COMBO", "value": "$$$(UNDEF)"}, + }, + expected: []UnresolvedRef{ + {Name: "COMBO", RefName: "UNDEF"}, + }, + }, + { + name: "valueFrom entries are skipped", + envVars: []any{ + map[string]any{"name": "SECRET", "valueFrom": map[string]any{"secretKeyRef": map[string]any{"name": "s", "key": "k"}}}, + map[string]any{"name": "PLAIN", "value": "$(SECRET)"}, + }, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindUnresolvedRefs(tt.envVars) + require.Equal(t, tt.expected, got) + }) + } +} From 92326f0ea55be8c11098cd1afcf6f6fb415b13a5 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 9 Nov 2026 10:50:26 +0100 Subject: [PATCH 11/15] Typo --- internal/envvars/envvars.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 71d4efad..50fc03e8 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -9,7 +9,7 @@ import ( ) var ( - // Note, we match the dollars in a seperate match group to be able to check for escaped dollar characters. + // Note, we match the dollars in a separate match group to be able to check for escaped dollar characters. varRefPattern = regexp.MustCompile(`(\$+)\(([^)]+)\)`) ) From 17ae5831f18483edd186a189d76aa545452d9414 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 10 Nov 2026 10:48:16 +0100 Subject: [PATCH 12/15] Improve error wording --- internal/deployer/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 42af8210..765f8aeb 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -56,10 +56,10 @@ func MergeConfig(base, overlay *Config) error { } if err := envvars.MergeByNameInUnstructured(base.Central.Spec, baseCentralEnvVars, envVarsPath...); err != nil { - return fmt.Errorf("reconciling Central env vars: %w", err) + return fmt.Errorf("merging Central env vars: %w", err) } if err := envvars.MergeByNameInUnstructured(base.SecuredCluster.Spec, baseSCEnvVars, envVarsPath...); err != nil { - return fmt.Errorf("reconciling SecuredCluster env vars: %w", err) + return fmt.Errorf("merging SecuredCluster env vars: %w", err) } warnUnresolvedEnvVarRefs("Central", base.Central.Spec, envVarsPath) From c401964f6822c2dfcf364d400538de878ca169e6 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 10 Nov 2026 10:51:04 +0100 Subject: [PATCH 13/15] Use envvars.MergeByNameInUnstructured instead of envvars.DeduplicateInUnstructured in ConfigureSpec() functions --- internal/deployer/config.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 765f8aeb..fdaf3ef2 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -363,16 +363,16 @@ func (c *CentralConfig) GetExposure() types.Exposure { // ConfigureSpec applies feature flags and exposure settings to the Central spec. func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { - err := mergo.Merge(&c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags), mergo.WithOverride, mergo.WithAppendSlice) - if err != nil { + envVarsPath := []string{"customize", "envVars"} + baseEnvVars, _, _ := unstructured.NestedSlice(c.Spec, envVarsPath...) + if err := mergo.Merge(&c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags), mergo.WithOverride); err != nil { return err } - err = envvars.DeduplicateInUnstructured(c.Spec, "customize", "envVars") - if err != nil { - return fmt.Errorf("deduplicating Central env vars: %w", err) + if err := envvars.MergeByNameInUnstructured(c.Spec, baseEnvVars, envVarsPath...); err != nil { + return fmt.Errorf("merging Central env vars by name: %w", err) } warnUnresolvedEnvVarRefs("Central", c.Spec, []string{"customize", "envVars"}) - if err = mergo.Merge(&c.Spec, map[string]any{ + if err := mergo.Merge(&c.Spec, map[string]any{ "central": map[string]any{ "exposure": c.Exposure.ToUnstructuredConfig(), }, @@ -480,11 +480,13 @@ func (s *SecuredClusterConfig) GetWaitConfig() WaitConfig { // ConfigureSpec applies feature flags and the central endpoint to the SecuredCluster spec. func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { - if err := mergo.Merge(&s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags), mergo.WithOverride, mergo.WithAppendSlice); err != nil { + envVarsPath := []string{"customize", "envVars"} + baseEnvVars, _, _ := unstructured.NestedSlice(s.Spec, envVarsPath...) + if err := mergo.Merge(&s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags), mergo.WithOverride); err != nil { return err } - if err := envvars.DeduplicateInUnstructured(s.Spec, "customize", "envVars"); err != nil { - return fmt.Errorf("deduplicating SecuredCluster env vars: %w", err) + if err := envvars.MergeByNameInUnstructured(s.Spec, baseEnvVars, envVarsPath...); err != nil { + return fmt.Errorf("merging SecuredCluster env vars by name: %w", err) } warnUnresolvedEnvVarRefs("SecuredCluster", s.Spec, []string{"customize", "envVars"}) if _, exists := s.Spec["centralEndpoint"]; !exists { From 23861b5fb50dfe639b194e7875567211e33740e8 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 10 Nov 2026 10:56:38 +0100 Subject: [PATCH 14/15] Remove dead code --- internal/envvars/envvars.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 50fc03e8..afb82130 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -99,19 +99,6 @@ func deduplicate(envVars []any) []any { return result } -// DeduplicateInUnstructured deduplicates the envVars slice at the given -// path inside an unstructured map. No-op if the path does not exist. -func DeduplicateInUnstructured(obj map[string]any, path ...string) error { - envVars, found, err := unstructured.NestedSlice(obj, path...) - if err != nil { - return fmt.Errorf("retrieving nested slice at path %s from unstructured object: %w", strings.Join(path, "."), err) - } - if !found { - return nil - } - return unstructured.SetNestedSlice(obj, deduplicate(envVars), path...) -} - // MergeByNameInUnstructured prepends baseEnvVars to the envVars already at // path in obj, then deduplicates by name (last-one-wins, so current entries // from an overlay take precedence over base). No-op if both sources are empty. From c424f4ed9fa9e41e93f8f7b2554a6c7b6ea9001b Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 10 Nov 2026 11:08:44 +0100 Subject: [PATCH 15/15] Adjust tests --- internal/deployer/feature_flags_test.go | 20 ++----- internal/envvars/envvars_test.go | 76 +++++++++++++++++-------- 2 files changed, 57 insertions(+), 39 deletions(-) diff --git a/internal/deployer/feature_flags_test.go b/internal/deployer/feature_flags_test.go index 4577927e..6bd1e0c1 100644 --- a/internal/deployer/feature_flags_test.go +++ b/internal/deployer/feature_flags_test.go @@ -4,7 +4,6 @@ import ( "reflect" "testing" - "dario.cat/mergo" "github.com/stackrox/roxie/internal/envvars" "github.com/stretchr/testify/require" ) @@ -144,21 +143,14 @@ func TestParseFeatureFlags(t *testing.T) { } } -func TestDeepMergeWithDeduplicateEnvVars(t *testing.T) { - spec := map[string]any{ - "customize": map[string]any{ - "envVars": []any{ - map[string]any{"name": "MY_VAR", "value": "keep"}, - map[string]any{"name": "ROX_FOO", "value": "old"}, - }, - }, +func TestFeatureFlagMergeWithEnvVarDedup(t *testing.T) { + base := []any{ + map[string]any{"name": "MY_VAR", "value": "keep"}, + map[string]any{"name": "ROX_FOO", "value": "old"}, } - overlay := featureFlagsToOverrides(map[string]bool{"ROX_FOO": false, "ROX_NEW": true}) - - err := mergo.Merge(&spec, overlay, mergo.WithOverride, mergo.WithAppendSlice) - require.NoError(t, err) + spec := featureFlagsToOverrides(map[string]bool{"ROX_FOO": false, "ROX_NEW": true}) - err = envvars.DeduplicateInUnstructured(spec, "customize", "envVars") + err := envvars.MergeByNameInUnstructured(spec, base, "customize", "envVars") require.NoError(t, err) customize := spec["customize"].(map[string]any) diff --git a/internal/envvars/envvars_test.go b/internal/envvars/envvars_test.go index 1d6ffe14..d4dd8e71 100644 --- a/internal/envvars/envvars_test.go +++ b/internal/envvars/envvars_test.go @@ -1,16 +1,23 @@ package envvars import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" ) -func TestDeduplicateEnvVars(t *testing.T) { +func TestMergeByNameInUnstructured(t *testing.T) { + type envVar struct { + Name string `json:"name"` + Value string `json:"value"` + } + tests := []struct { name string + base []any spec map[string]interface{} - expectedEnvVars map[string]string + expectedEnvVars []envVar }{ { name: "duplicates are resolved with last-one-wins", @@ -23,9 +30,9 @@ func TestDeduplicateEnvVars(t *testing.T) { }, }, }, - expectedEnvVars: map[string]string{ - "ROX_FOO": "new", - "ROX_KEEP": "me", + expectedEnvVars: []envVar{ + {"ROX_FOO", "new"}, + {"ROX_KEEP", "me"}, }, }, { @@ -38,9 +45,9 @@ func TestDeduplicateEnvVars(t *testing.T) { }, }, }, - expectedEnvVars: map[string]string{ - "ROX_A": "a", - "ROX_B": "b", + expectedEnvVars: []envVar{ + {"ROX_A", "a"}, + {"ROX_B", "b"}, }, }, { @@ -53,34 +60,53 @@ func TestDeduplicateEnvVars(t *testing.T) { spec: map[string]interface{}{}, expectedEnvVars: nil, }, + { + name: "overlay wins over base", + base: []any{map[string]any{"name": "ROX_A", "value": "from-base"}}, + spec: map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_A", "value": "from-overlay"}, + }, + }, + }, + expectedEnvVars: []envVar{{"ROX_A", "from-overlay"}}, + }, + { + name: "base entries preserved when not in overlay", + base: []any{map[string]any{"name": "ROX_BASE", "value": "kept"}}, + spec: map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_NEW", "value": "added"}, + }, + }, + }, + expectedEnvVars: []envVar{{"ROX_BASE", "kept"}, {"ROX_NEW", "added"}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := DeduplicateInUnstructured(tt.spec, "customize", "envVars") + err := MergeByNameInUnstructured(tt.spec, tt.base, "customize", "envVars") require.NoError(t, err) if tt.expectedEnvVars == nil { return } - customize := tt.spec["customize"].(map[string]interface{}) - envVars := customize["envVars"].([]interface{}) - - require.Len(t, envVars, len(tt.expectedEnvVars)) - - resultMap := make(map[string]string) - for _, item := range envVars { - envVar := item.(map[string]interface{}) - resultMap[envVar["name"].(string)] = envVar["value"].(string) - } - - require.Equal(t, tt.expectedEnvVars, resultMap) + customize := tt.spec["customize"].(map[string]any) + envVars := customize["envVars"].([]any) + expectedEnvVarsJSON, err := json.Marshal(tt.expectedEnvVars) + require.NoError(t, err) + gotJSON, err := json.Marshal(envVars) + require.NoError(t, err) + require.JSONEq(t, string(expectedEnvVarsJSON), string(gotJSON)) }) } } -func TestDeduplicateEnvVars_PreservesValueFrom(t *testing.T) { +func TestMergeByNameInUnstructured_PreservesValueFrom(t *testing.T) { valueFrom := map[string]any{ "secretKeyRef": map[string]any{ "name": "my-secret", @@ -96,7 +122,7 @@ func TestDeduplicateEnvVars_PreservesValueFrom(t *testing.T) { }, } - require.NoError(t, DeduplicateInUnstructured(spec, "customize", "envVars")) + require.NoError(t, MergeByNameInUnstructured(spec, nil, "customize", "envVars")) customize := spec["customize"].(map[string]any) envVars := customize["envVars"].([]any) @@ -109,7 +135,7 @@ func TestDeduplicateEnvVars_PreservesValueFrom(t *testing.T) { require.False(t, hasValue, "valueFrom entry should not have a value field") } -func TestDeduplicateEnvVars_PreservesDependencyOrder(t *testing.T) { +func TestMergeByNameInUnstructured_PreservesDependencyOrder(t *testing.T) { spec := map[string]any{ "customize": map[string]any{ "envVars": []any{ @@ -120,7 +146,7 @@ func TestDeduplicateEnvVars_PreservesDependencyOrder(t *testing.T) { }, } - require.NoError(t, DeduplicateInUnstructured(spec, "customize", "envVars")) + require.NoError(t, MergeByNameInUnstructured(spec, nil, "customize", "envVars")) customize := spec["customize"].(map[string]any) envVars := customize["envVars"].([]any)