Use mergo and add more tests

Signed-off-by: Nemanja Zeljkovic <nocturo@gmail.com>
This commit is contained in:
Nemanja Zeljkovic 2025-07-30 14:22:51 +02:00
parent 23cb164708
commit 2dfd94fae1
9 changed files with 483 additions and 29 deletions

View File

@ -2,6 +2,8 @@ package yaml
import (
"strings"
"dario.cat/mergo"
)
type AppendProcessor struct{}
@ -14,40 +16,73 @@ func (ap *AppendProcessor) MergeWithAppend(dest, src map[string]any) error {
convertToStringMapInPlace(dest)
convertToStringMapInPlace(src)
for key, srcValue := range src {
appendMap := make(map[string]any)
regularMap := make(map[string]any)
for key, value := range src {
if IsAppendKey(key) {
baseKey := GetBaseKey(key)
destValue, exists := dest[baseKey]
if exists {
if isSlice(srcValue) && isSlice(destValue) {
destSlice := destValue.([]any)
srcSlice := srcValue.([]any)
dest[baseKey] = append(destSlice, srcSlice...)
} else {
dest[baseKey] = srcValue
}
} else {
dest[baseKey] = srcValue
}
delete(src, key)
appendMap[baseKey] = value
} else {
regularMap[key] = value
}
}
for key, srcValue := range src {
if isMap(srcValue) {
srcMap := srcValue.(map[string]any)
if len(appendMap) > 0 {
for baseKey, appendValue := range appendMap {
destValue, exists := dest[baseKey]
if exists {
if _, ok := destValue.([]any); !ok {
dest[baseKey] = appendValue
delete(appendMap, baseKey)
}
}
}
if len(appendMap) > 0 {
tempDest := make(map[string]any)
for k, v := range dest {
tempDest[k] = v
}
if err := mergo.Merge(&tempDest, appendMap, mergo.WithAppendSlice, mergo.WithSliceDeepCopy); err != nil {
return err
}
for k, v := range tempDest {
dest[k] = v
}
}
}
for key, value := range regularMap {
if srcMap, ok := value.(map[string]any); ok {
if destMap, ok := dest[key].(map[string]any); ok {
if err := ap.MergeWithAppend(destMap, srcMap); err != nil {
return err
}
dest[key] = destMap
} else {
dest[key] = srcMap
newDestMap := make(map[string]any)
if err := ap.MergeWithAppend(newDestMap, srcMap); err != nil {
return err
}
dest[key] = newDestMap
}
} else {
dest[key] = srcValue
tempDest := make(map[string]any)
tempDest[key] = dest[key]
tempSrc := make(map[string]any)
tempSrc[key] = value
if err := mergo.Merge(&tempDest, tempSrc, mergo.WithOverride); err != nil {
return err
}
dest[key] = tempDest[key]
}
}
return nil
}
@ -76,16 +111,6 @@ func convertToStringMapInPlace(v any) any {
}
}
func isSlice(value any) bool {
_, ok := value.([]any)
return ok
}
func isMap(value any) bool {
_, ok := value.(map[string]any)
return ok
}
func IsAppendKey(key string) bool {
return strings.HasSuffix(key, "+")
}

View File

@ -100,6 +100,38 @@ func TestAppendProcessor_MergeWithAppend(t *testing.T) {
},
},
},
{
name: "nested append with non-existent parent",
dest: map[string]any{},
src: map[string]any{
"kube-state-metrics": map[string]any{
"prometheus": map[string]any{
"monitor": map[string]any{
"metricRelabelings+": []any{
map[string]any{
"action": "labeldrop",
"regex": "info_.*",
},
},
},
},
},
},
expected: map[string]any{
"kube-state-metrics": map[string]any{
"prometheus": map[string]any{
"monitor": map[string]any{
"metricRelabelings": []any{
map[string]any{
"action": "labeldrop",
"regex": "info_.*",
},
},
},
},
},
},
},
{
name: "overwrite non-slice with append",
dest: map[string]any{
@ -120,17 +152,349 @@ func TestAppendProcessor_MergeWithAppend(t *testing.T) {
},
},
},
{
name: "type collision - []string vs []any",
dest: map[string]any{
"values": []string{"a", "b"},
},
src: map[string]any{
"values+": []any{"c", "d"},
},
expected: map[string]any{
"values": []any{"c", "d"},
},
},
{
name: "type collision - []int vs []any",
dest: map[string]any{
"values": []int{1, 2},
},
src: map[string]any{
"values+": []any{3, 4},
},
expected: map[string]any{
"values": []any{3, 4},
},
},
{
name: "append on map - should overwrite",
dest: map[string]any{
"config": map[string]any{"key": "value"},
},
src: map[string]any{
"config+": []any{"new", "values"},
},
expected: map[string]any{
"config": []any{"new", "values"},
},
},
{
name: "append on scalar - should overwrite",
dest: map[string]any{
"version": "1.0.0",
},
src: map[string]any{
"version+": []any{"2.0.0", "3.0.0"},
},
expected: map[string]any{
"version": []any{"2.0.0", "3.0.0"},
},
},
{
name: "nil slice in destination",
dest: map[string]any{
"values": nil,
},
src: map[string]any{
"values+": []any{"a", "b"},
},
expected: map[string]any{
"values": []any{"a", "b"},
},
},
{
name: "empty slice in destination",
dest: map[string]any{
"values": []any{},
},
src: map[string]any{
"values+": []any{"a", "b"},
},
expected: map[string]any{
"values": []any{"a", "b"},
},
},
{
name: "nil slice in source",
dest: map[string]any{
"values": []any{"existing"},
},
src: map[string]any{
"values+": nil,
},
expected: map[string]any{
"values": nil,
},
},
{
name: "mixed types in slices",
dest: map[string]any{
"data": []any{"string", 42, true},
},
src: map[string]any{
"data+": []any{"new", 100, false},
},
expected: map[string]any{
"data": []any{"string", 42, true, "new", 100, false},
},
},
{
name: "multiple append keys",
dest: map[string]any{
"list1": []any{"a"},
"list2": []any{"x"},
},
src: map[string]any{
"list1+": []any{"b"},
"list2+": []any{"y"},
},
expected: map[string]any{
"list1": []any{"a", "b"},
"list2": []any{"x", "y"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srcCopy := make(map[string]any)
for k, v := range tt.src {
srcCopy[k] = v
}
err := processor.MergeWithAppend(tt.dest, tt.src)
require.NoError(t, err)
assert.Equal(t, tt.expected, tt.dest)
assert.Equal(t, srcCopy, tt.src, "source map should not be mutated")
})
}
}
func TestAppendProcessor_EdgeCases(t *testing.T) {
processor := NewAppendProcessor()
t.Run("empty maps", func(t *testing.T) {
dest := make(map[string]any)
src := make(map[string]any)
err := processor.MergeWithAppend(dest, src)
require.NoError(t, err)
assert.Empty(t, dest)
})
t.Run("nil maps", func(t *testing.T) {
var dest map[string]any
var src map[string]any
err := processor.MergeWithAppend(dest, src)
require.NoError(t, err)
assert.Nil(t, dest)
})
t.Run("deep nested with append", func(t *testing.T) {
dest := map[string]any{
"level1": map[string]any{
"level2": map[string]any{
"level3": map[string]any{
"values": []any{"deep"},
},
},
},
}
src := map[string]any{
"level1": map[string]any{
"level2": map[string]any{
"level3": map[string]any{
"values+": []any{"nested"},
},
},
},
}
expected := map[string]any{
"level1": map[string]any{
"level2": map[string]any{
"level3": map[string]any{
"values": []any{"deep", "nested"},
},
},
},
}
err := processor.MergeWithAppend(dest, src)
require.NoError(t, err)
assert.Equal(t, expected, dest)
})
t.Run("complex nested structure", func(t *testing.T) {
dest := map[string]any{
"config": map[string]any{
"services": []any{
map[string]any{"name": "service1"},
},
"settings": map[string]any{
"timeout": 30,
},
},
}
src := map[string]any{
"config": map[string]any{
"services+": []any{
map[string]any{"name": "service2"},
},
"settings": map[string]any{
"retries": 3,
},
},
}
expected := map[string]any{
"config": map[string]any{
"services": []any{
map[string]any{"name": "service1"},
map[string]any{"name": "service2"},
},
"settings": map[string]any{
"timeout": 30,
"retries": 3,
},
},
}
err := processor.MergeWithAppend(dest, src)
require.NoError(t, err)
assert.Equal(t, expected, dest)
})
}
func TestAppendProcessor_TypeConversions(t *testing.T) {
processor := NewAppendProcessor()
t.Run("map[any]any to map[string]any conversion", func(t *testing.T) {
dest := map[string]any{
"values": []any{"a"},
}
src := map[any]any{
"values+": []any{"b"},
}
srcConverted := make(map[string]any)
for k, v := range src {
if ks, ok := k.(string); ok {
srcConverted[ks] = v
}
}
err := processor.MergeWithAppend(dest, srcConverted)
require.NoError(t, err)
assert.Equal(t, []any{"a", "b"}, dest["values"])
})
t.Run("mixed key types", func(t *testing.T) {
dest := map[string]any{
"values": []any{"a"},
}
src := map[any]any{
"values+": []any{"b"},
"other": "value",
42: "number_key",
}
srcConverted := make(map[string]any)
for k, v := range src {
if ks, ok := k.(string); ok {
srcConverted[ks] = v
}
}
err := processor.MergeWithAppend(dest, srcConverted)
require.NoError(t, err)
assert.Equal(t, []any{"a", "b"}, dest["values"])
assert.Equal(t, "value", dest["other"])
assert.NotContains(t, dest, "42")
})
}
func TestAppendProcessor_PropertyBased(t *testing.T) {
processor := NewAppendProcessor()
t.Run("idempotent regular merge", func(t *testing.T) {
dest := map[string]any{
"key1": "value1",
"key2": []any{"a", "b"},
}
src := map[string]any{
"key1": "value1",
"key3": "value3",
}
err := processor.MergeWithAppend(dest, src)
require.NoError(t, err)
err = processor.MergeWithAppend(dest, src)
require.NoError(t, err)
expected := map[string]any{
"key1": "value1",
"key2": []any{"a", "b"},
"key3": "value3",
}
assert.Equal(t, expected, dest)
})
t.Run("append is not idempotent", func(t *testing.T) {
dest := map[string]any{
"values": []any{"a"},
}
src := map[string]any{
"values+": []any{"b"},
}
err := processor.MergeWithAppend(dest, src)
require.NoError(t, err)
assert.Equal(t, []any{"a", "b"}, dest["values"])
err = processor.MergeWithAppend(dest, src)
require.NoError(t, err)
assert.Equal(t, []any{"a", "b", "b"}, dest["values"])
})
t.Run("merge is not commutative", func(t *testing.T) {
map1 := map[string]any{"a": 1, "b": 2}
map2 := map[string]any{"b": 3, "c": 4}
result1 := make(map[string]any)
for k, v := range map1 {
result1[k] = v
}
err := processor.MergeWithAppend(result1, map2)
require.NoError(t, err)
result2 := make(map[string]any)
for k, v := range map2 {
result2[k] = v
}
err = processor.MergeWithAppend(result2, map1)
require.NoError(t, err)
assert.NotEqual(t, result1, result2)
expected1 := map[string]any{"a": 1, "b": 3, "c": 4}
expected2 := map[string]any{"a": 1, "b": 2, "c": 4}
assert.Equal(t, expected1, result1)
assert.Equal(t, expected2, result2)
})
}
func TestIsAppendKey(t *testing.T) {
tests := []struct {
key string

View File

@ -103,6 +103,7 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes
. ${dir}/test-cases/issue-1749.sh
. ${dir}/test-cases/issue-1893.sh
. ${dir}/test-cases/state-values-set-cli-args-in-environments.sh
. ${dir}/test-cases/key_append.sh
# ALL DONE -----------------------------------------------------------------------------------------------------------

View File

@ -0,0 +1,21 @@
test_start "key append feature"
key_append_case_input_dir="${cases_dir}/key_append"
key_append_expected_output="${key_append_case_input_dir}/output.yaml"
key_append_tmp=$(mktemp -d)
key_append_values_file="${key_append_tmp}/key_append.values.yaml"
key_append_generated_metrics_file="${key_append_tmp}/key_append_generated_metrics.yaml"
info "Testing key append functionality with nested structure"
config_file="helmfile.yaml.gotmpl"
info "Running helmfile template for key append test"
${helmfile} -f ${key_append_case_input_dir}/${config_file} template > ${key_append_values_file} || fail "\"helmfile template\" shouldn't fail"
info "Verifying that metricRelabelings+ is properly processed"
yq 'select(.metadata.name=="prometheus-monitoring-kube-state-metrics") | .spec.endpoints[].metricRelabelings' ${key_append_values_file} > ${key_append_generated_metrics_file}
./dyff between -bs ${key_append_expected_output} ${key_append_generated_metrics_file} || fail "\"helmfile template\" should be consistent"
echo code=$?
test_pass "key append feature"

View File

@ -0,0 +1,8 @@
templates:
applications-default:
missingFileHandler: Warn
valuesTemplate:
- values.yaml
- values.yaml.gotmpl
- overrides/values.yaml
- overrides/values.yaml.gotmpl

View File

@ -0,0 +1,13 @@
{{ readFile "common.yaml" }}
repositories:
- name: prometheus-community
url: https://prometheus-community.github.io/helm-charts
releases:
- name: prometheus-monitoring
chart: prometheus-community/kube-prometheus-stack
namespace: monitoring
forceNamespace: monitoring
inherit:
- template: applications-default

View File

@ -0,0 +1,7 @@
- action: labeldrop
regex: info_.*
- action: drop
regex: container_network_.*
sourceLabels:
- namespace
- __name__

View File

@ -0,0 +1,9 @@
kube-state-metrics:
prometheus:
monitor:
metricRelabelings+:
- action: drop
regex: "container_network_.*"
sourceLabels:
- namespace
- __name__

View File

@ -0,0 +1,6 @@
kube-state-metrics:
prometheus:
monitor:
metricRelabelings:
- action: labeldrop
regex: "info_.*"