mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-05 22:43:51 +00:00
1404 lines
35 KiB
Go
1404 lines
35 KiB
Go
package transformer
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
)
|
|
|
|
func TestTransformer_Transform(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source runtime.Object
|
|
ctx TransformContext
|
|
options TransformOptions
|
|
wantErr bool
|
|
errMsg string
|
|
validate func(t *testing.T, result runtime.Object)
|
|
}{
|
|
// Good cases - Value rules
|
|
{
|
|
name: "value rule - simple data field",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: data.LOG_LEVEL
|
|
value: "error"
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{
|
|
"LOG_LEVEL": "debug",
|
|
},
|
|
},
|
|
ctx: TransformContext{
|
|
TargetNamespace: "prod",
|
|
},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
value, found, err := unstructured.NestedString(u.Object, "data", "LOG_LEVEL")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
assert.Equal(t, "error", value)
|
|
},
|
|
},
|
|
|
|
// Good cases - Template rules
|
|
{
|
|
name: "template rule - namespace substitution",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: data.API_URL
|
|
template: "https://{{.TargetNamespace}}.api.example.com"
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
},
|
|
ctx: TransformContext{
|
|
TargetNamespace: "prod-app",
|
|
SourceNamespace: "default",
|
|
},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
value, found, err := unstructured.NestedString(u.Object, "data", "API_URL")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
assert.Equal(t, "https://prod-app.api.example.com", value)
|
|
},
|
|
},
|
|
{
|
|
name: "template rule - with template functions",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: data.NAMESPACE_UPPER
|
|
template: "{{upper .TargetNamespace}}"
|
|
- path: data.SOURCE_LOWER
|
|
template: "{{lower .SourceName}}"
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
},
|
|
ctx: TransformContext{
|
|
TargetNamespace: "prod-app",
|
|
SourceName: "TEST-CONFIG",
|
|
},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
|
|
upperValue, found, err := unstructured.NestedString(u.Object, "data", "NAMESPACE_UPPER")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
assert.Equal(t, "PROD-APP", upperValue)
|
|
|
|
lowerValue, found, err := unstructured.NestedString(u.Object, "data", "SOURCE_LOWER")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
assert.Equal(t, "test-config", lowerValue)
|
|
},
|
|
},
|
|
|
|
// Good cases - Merge rules
|
|
{
|
|
name: "merge rule - add labels",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Labels: map[string]string{
|
|
"app": "myapp",
|
|
},
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: metadata.labels
|
|
merge:
|
|
environment: "production"
|
|
tier: "frontend"
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
},
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
labels := u.GetLabels()
|
|
assert.Equal(t, "myapp", labels["app"], "original label should be preserved")
|
|
assert.Equal(t, "production", labels["environment"])
|
|
assert.Equal(t, "frontend", labels["tier"])
|
|
},
|
|
},
|
|
{
|
|
name: "merge rule - create new map",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: metadata.labels
|
|
merge:
|
|
new-label: "new-value"
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
},
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
labels := u.GetLabels()
|
|
assert.Equal(t, "new-value", labels["new-label"])
|
|
},
|
|
},
|
|
|
|
// Good cases - Delete rules
|
|
{
|
|
name: "delete rule - remove data field",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: data.DEBUG_MODE
|
|
delete: true
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{
|
|
"DEBUG_MODE": "true",
|
|
"LOG_LEVEL": "info",
|
|
},
|
|
},
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
data, found, err := unstructured.NestedMap(u.Object, "data")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
_, exists := data["DEBUG_MODE"]
|
|
assert.False(t, exists, "DEBUG_MODE should be deleted")
|
|
assert.Equal(t, "info", data["LOG_LEVEL"], "LOG_LEVEL should remain")
|
|
},
|
|
},
|
|
|
|
// Bad cases - Invalid YAML
|
|
{
|
|
name: "invalid YAML in transform annotation",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: "invalid: yaml: [[[",
|
|
constants.AnnotationTransformStrict: "true",
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
},
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: true,
|
|
errMsg: "failed to parse",
|
|
},
|
|
|
|
// Bad cases - Invalid rules
|
|
{
|
|
name: "empty path in strict mode",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- value: "something"
|
|
`,
|
|
constants.AnnotationTransformStrict: "true",
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
},
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: true,
|
|
errMsg: "invalid transformation rules",
|
|
},
|
|
{
|
|
name: "too many rules in strict mode",
|
|
source: func() runtime.Object {
|
|
rules := "rules:\n"
|
|
for i := 0; i < 100; i++ {
|
|
rules += fmt.Sprintf(" - path: data.KEY%d\n value: \"val\"\n", i)
|
|
}
|
|
return &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: rules,
|
|
constants.AnnotationTransformStrict: "true",
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
}
|
|
}(),
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: true,
|
|
errMsg: "too many rules",
|
|
},
|
|
|
|
// Edge cases
|
|
{
|
|
name: "no transformation rules",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
},
|
|
Data: map[string]string{
|
|
"KEY": "value",
|
|
},
|
|
},
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
// When no rules, returns original typed object - convert to unstructured for checking
|
|
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(result)
|
|
require.NoError(t, err)
|
|
u := &unstructured.Unstructured{Object: unstructuredObj}
|
|
value, found, err := unstructured.NestedString(u.Object, "data", "KEY")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
assert.Equal(t, "value", value, "original value should be unchanged")
|
|
},
|
|
},
|
|
{
|
|
name: "non-strict mode ignores errors",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: "invalid yaml [[[",
|
|
},
|
|
},
|
|
Data: map[string]string{
|
|
"KEY": "value",
|
|
},
|
|
},
|
|
ctx: TransformContext{},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
// Should return original unchanged - check via unstructured conversion
|
|
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(result)
|
|
require.NoError(t, err)
|
|
u := &unstructured.Unstructured{Object: unstructuredObj}
|
|
value, found, err := unstructured.NestedString(u.Object, "data", "KEY")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
assert.Equal(t, "value", value)
|
|
},
|
|
},
|
|
{
|
|
name: "multiple rules applied in order",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: data.KEY1
|
|
value: "first"
|
|
- path: data.KEY2
|
|
template: "{{.TargetNamespace}}-value"
|
|
- path: metadata.labels
|
|
merge:
|
|
env: "prod"
|
|
- path: data.TO_DELETE
|
|
delete: true
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{
|
|
"TO_DELETE": "remove-me",
|
|
},
|
|
},
|
|
ctx: TransformContext{
|
|
TargetNamespace: "production",
|
|
},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
|
|
key1, found, _ := unstructured.NestedString(u.Object, "data", "KEY1")
|
|
assert.True(t, found)
|
|
assert.Equal(t, "first", key1)
|
|
|
|
key2, found, _ := unstructured.NestedString(u.Object, "data", "KEY2")
|
|
assert.True(t, found)
|
|
assert.Equal(t, "production-value", key2)
|
|
|
|
labels := u.GetLabels()
|
|
assert.Equal(t, "prod", labels["env"])
|
|
|
|
data, found, _ := unstructured.NestedMap(u.Object, "data")
|
|
assert.True(t, found)
|
|
_, exists := data["TO_DELETE"]
|
|
assert.False(t, exists)
|
|
},
|
|
},
|
|
|
|
// Array indexing cases
|
|
{
|
|
name: "array indexing - modify container image",
|
|
source: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Pod",
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-pod",
|
|
"namespace": "default",
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: spec.containers[0].image
|
|
template: "registry.{{.TargetNamespace}}.example.com/app:v1"
|
|
- path: spec.containers[0].env[1].value
|
|
value: "production"
|
|
`,
|
|
},
|
|
},
|
|
"spec": map[string]interface{}{
|
|
"containers": []interface{}{
|
|
map[string]interface{}{
|
|
"name": "app",
|
|
"image": "app:latest",
|
|
"env": []interface{}{
|
|
map[string]interface{}{"name": "VAR1", "value": "val1"},
|
|
map[string]interface{}{"name": "VAR2", "value": "val2"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ctx: TransformContext{
|
|
TargetNamespace: "prod-app",
|
|
},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false,
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
|
|
// Check container image was transformed
|
|
containers, found, _ := unstructured.NestedSlice(u.Object, "spec", "containers")
|
|
assert.True(t, found)
|
|
assert.Len(t, containers, 1)
|
|
|
|
container := containers[0].(map[string]interface{})
|
|
assert.Equal(t, "registry.prod-app.example.com/app:v1", container["image"])
|
|
|
|
// Check env var was transformed
|
|
env := container["env"].([]interface{})
|
|
assert.Len(t, env, 2)
|
|
envVar := env[1].(map[string]interface{})
|
|
assert.Equal(t, "production", envVar["value"])
|
|
},
|
|
},
|
|
|
|
// Awkward cases
|
|
{
|
|
name: "template with missing context variable",
|
|
source: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-config",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `
|
|
rules:
|
|
- path: data.VALUE
|
|
template: "{{.TargetNamespace}}-empty"
|
|
`,
|
|
},
|
|
},
|
|
Data: map[string]string{},
|
|
},
|
|
ctx: TransformContext{
|
|
TargetNamespace: "",
|
|
},
|
|
options: DefaultTransformOptions(),
|
|
wantErr: false, // Non-strict mode
|
|
validate: func(t *testing.T, result runtime.Object) {
|
|
u := result.(*unstructured.Unstructured)
|
|
value, found, _ := unstructured.NestedString(u.Object, "data", "VALUE")
|
|
// Template with empty context variable produces "-empty"
|
|
assert.True(t, found)
|
|
assert.Equal(t, "-empty", value)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
transformer := NewTransformer(tt.options)
|
|
result, err := transformer.Transform(tt.source, tt.ctx)
|
|
|
|
if tt.wantErr {
|
|
assert.Error(t, err)
|
|
if tt.errMsg != "" {
|
|
assert.Contains(t, err.Error(), tt.errMsg)
|
|
}
|
|
} else {
|
|
require.NoError(t, err)
|
|
if tt.validate != nil {
|
|
tt.validate(t, result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParsePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "simple path",
|
|
path: "data.KEY",
|
|
want: []string{"data", "KEY"},
|
|
},
|
|
{
|
|
name: "nested path",
|
|
path: "metadata.labels.app",
|
|
want: []string{"metadata", "labels", "app"},
|
|
},
|
|
{
|
|
name: "empty path",
|
|
path: "",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "single segment",
|
|
path: "data",
|
|
want: []string{"data"},
|
|
},
|
|
// Array indexing tests
|
|
{
|
|
name: "array index - single",
|
|
path: "containers[0]",
|
|
want: []string{"containers", "[0]"},
|
|
},
|
|
{
|
|
name: "array index - with nested field",
|
|
path: "spec.containers[0].image",
|
|
want: []string{"spec", "containers", "[0]", "image"},
|
|
},
|
|
{
|
|
name: "array index - multiple levels",
|
|
path: "spec.template.spec.containers[0].env[2].value",
|
|
want: []string{"spec", "template", "spec", "containers", "[0]", "env", "[2]", "value"},
|
|
},
|
|
{
|
|
name: "array index - at end",
|
|
path: "data.items[5]",
|
|
want: []string{"data", "items", "[5]"},
|
|
},
|
|
{
|
|
name: "array index - large number",
|
|
path: "list[999].field",
|
|
want: []string{"list", "[999]", "field"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parsePath(tt.path)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetNestedField(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
obj map[string]interface{}
|
|
path []string
|
|
value interface{}
|
|
wantErr bool
|
|
want map[string]interface{}
|
|
}{
|
|
{
|
|
name: "set top-level field",
|
|
obj: map[string]interface{}{},
|
|
path: []string{"key"},
|
|
value: "value",
|
|
wantErr: false,
|
|
want: map[string]interface{}{
|
|
"key": "value",
|
|
},
|
|
},
|
|
{
|
|
name: "set nested field - creates intermediate maps",
|
|
obj: map[string]interface{}{},
|
|
path: []string{"data", "key"},
|
|
value: "value",
|
|
wantErr: false,
|
|
want: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"key": "value",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "set deeply nested field",
|
|
obj: map[string]interface{}{},
|
|
path: []string{"a", "b", "c", "d"},
|
|
value: "deep",
|
|
wantErr: false,
|
|
want: map[string]interface{}{
|
|
"a": map[string]interface{}{
|
|
"b": map[string]interface{}{
|
|
"c": map[string]interface{}{
|
|
"d": "deep",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "empty path",
|
|
obj: map[string]interface{}{},
|
|
path: []string{},
|
|
value: "value",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "path segment is not a map",
|
|
obj: map[string]interface{}{
|
|
"key": "string-value",
|
|
},
|
|
path: []string{"key", "nested"},
|
|
value: "value",
|
|
wantErr: true,
|
|
},
|
|
// Array indexing tests
|
|
{
|
|
name: "set array element value",
|
|
obj: map[string]interface{}{
|
|
"items": []interface{}{"a", "b", "c"},
|
|
},
|
|
path: []string{"items", "[1]"},
|
|
value: "modified",
|
|
wantErr: false,
|
|
want: map[string]interface{}{
|
|
"items": []interface{}{"a", "modified", "c"},
|
|
},
|
|
},
|
|
{
|
|
name: "set nested field in array element",
|
|
obj: map[string]interface{}{
|
|
"containers": []interface{}{
|
|
map[string]interface{}{
|
|
"name": "app",
|
|
"image": "old-image",
|
|
},
|
|
},
|
|
},
|
|
path: []string{"containers", "[0]", "image"},
|
|
value: "new-image",
|
|
wantErr: false,
|
|
want: map[string]interface{}{
|
|
"containers": []interface{}{
|
|
map[string]interface{}{
|
|
"name": "app",
|
|
"image": "new-image",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "set deeply nested array access",
|
|
obj: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"containers": []interface{}{
|
|
map[string]interface{}{
|
|
"env": []interface{}{
|
|
map[string]interface{}{"name": "VAR1", "value": "val1"},
|
|
map[string]interface{}{"name": "VAR2", "value": "val2"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
path: []string{"spec", "containers", "[0]", "env", "[1]", "value"},
|
|
value: "new-val2",
|
|
wantErr: false,
|
|
want: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"containers": []interface{}{
|
|
map[string]interface{}{
|
|
"env": []interface{}{
|
|
map[string]interface{}{"name": "VAR1", "value": "val1"},
|
|
map[string]interface{}{"name": "VAR2", "value": "new-val2"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "array index out of bounds",
|
|
obj: map[string]interface{}{
|
|
"items": []interface{}{"a", "b"},
|
|
},
|
|
path: []string{"items", "[5]"},
|
|
value: "value",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "array index on non-array",
|
|
obj: map[string]interface{}{
|
|
"items": "not-an-array",
|
|
},
|
|
path: []string{"items", "[0]"},
|
|
value: "value",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := setNestedField(tt.obj, tt.path, tt.value)
|
|
|
|
if tt.wantErr {
|
|
assert.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, tt.obj)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTransformer_TemplateTimeout(t *testing.T) {
|
|
// Test that template execution times out
|
|
// Since we can't easily create a template that times out reliably in tests,
|
|
// we'll skip this test or use a very aggressive timeout
|
|
// The timeout mechanism is implemented via context in the transformer
|
|
t.Skip("Template timeout testing is unreliable in unit tests - covered by integration tests")
|
|
}
|
|
|
|
func TestMatchGlob(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
text string
|
|
expected bool
|
|
}{
|
|
// Exact matches
|
|
{
|
|
name: "exact match",
|
|
pattern: "production",
|
|
text: "production",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "exact match - no match",
|
|
pattern: "production",
|
|
text: "staging",
|
|
expected: false,
|
|
},
|
|
|
|
// Wildcard * patterns
|
|
{
|
|
name: "wildcard all",
|
|
pattern: "*",
|
|
text: "anything",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "prefix wildcard",
|
|
pattern: "prod-*",
|
|
text: "prod-app-1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "prefix wildcard - no match",
|
|
pattern: "prod-*",
|
|
text: "staging-app-1",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "suffix wildcard",
|
|
pattern: "*-staging",
|
|
text: "app-staging",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "suffix wildcard - no match",
|
|
pattern: "*-staging",
|
|
text: "app-production",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "middle wildcard",
|
|
pattern: "app-*-db",
|
|
text: "app-prod-db",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "middle wildcard - no match",
|
|
pattern: "app-*-db",
|
|
text: "app-prod-cache",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "multiple wildcards",
|
|
pattern: "*-prod-*",
|
|
text: "service-prod-v1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "wildcard matches empty",
|
|
pattern: "app-*",
|
|
text: "app-",
|
|
expected: true,
|
|
},
|
|
|
|
// Single character wildcard ?
|
|
{
|
|
name: "single char wildcard",
|
|
pattern: "app-?",
|
|
text: "app-1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "single char wildcard - no match (too long)",
|
|
pattern: "app-?",
|
|
text: "app-12",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "single char wildcard - no match (too short)",
|
|
pattern: "app-?",
|
|
text: "app-",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "multiple single char wildcards",
|
|
pattern: "app-??",
|
|
text: "app-12",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "mixed wildcards",
|
|
pattern: "app-?-*",
|
|
text: "app-1-prod",
|
|
expected: true,
|
|
},
|
|
|
|
// Edge cases
|
|
{
|
|
name: "empty pattern and text",
|
|
pattern: "",
|
|
text: "",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "empty pattern non-empty text",
|
|
pattern: "",
|
|
text: "text",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "pattern longer than text",
|
|
pattern: "production",
|
|
text: "prod",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "text longer than pattern",
|
|
pattern: "prod",
|
|
text: "production",
|
|
expected: false,
|
|
},
|
|
|
|
// Real-world examples
|
|
{
|
|
name: "preprod namespaces",
|
|
pattern: "preprod-*",
|
|
text: "preprod-api",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "staging environments",
|
|
pattern: "*-staging",
|
|
text: "app-staging",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "numbered namespaces",
|
|
pattern: "namespace-?",
|
|
text: "namespace-1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "versioned services",
|
|
pattern: "service-v*",
|
|
text: "service-v1.2.3",
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := matchGlob(tt.pattern, tt.text)
|
|
assert.Equal(t, tt.expected, result, "matchGlob(%q, %q)", tt.pattern, tt.text)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchesNamespacePattern(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern *string
|
|
targetNamespace string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "no pattern - matches all",
|
|
pattern: nil,
|
|
targetNamespace: "any-namespace",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "empty pattern - matches all",
|
|
pattern: stringPtr(""),
|
|
targetNamespace: "any-namespace",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "exact match",
|
|
pattern: stringPtr("production"),
|
|
targetNamespace: "production",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "exact match - no match",
|
|
pattern: stringPtr("production"),
|
|
targetNamespace: "staging",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "prefix pattern match",
|
|
pattern: stringPtr("preprod-*"),
|
|
targetNamespace: "preprod-api",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "prefix pattern no match",
|
|
pattern: stringPtr("preprod-*"),
|
|
targetNamespace: "prod-api",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "suffix pattern match",
|
|
pattern: stringPtr("*-staging"),
|
|
targetNamespace: "app-staging",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "suffix pattern no match",
|
|
pattern: stringPtr("*-staging"),
|
|
targetNamespace: "app-prod",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rule := Rule{
|
|
Path: "data.test",
|
|
Value: stringPtr("value"),
|
|
NamespacePattern: tt.pattern,
|
|
}
|
|
result := matchesNamespacePattern(rule, tt.targetNamespace)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTransformer_NamespacePatternFiltering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sourceData map[string]interface{}
|
|
rules string
|
|
targetNamespace string
|
|
expectedData map[string]interface{}
|
|
description string
|
|
}{
|
|
{
|
|
name: "rule applies to matching namespace",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"HOST": "default.example.com",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.HOST
|
|
value: "preprod.example.com"
|
|
namespacePattern: "preprod-*"
|
|
`,
|
|
targetNamespace: "preprod-api",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"HOST": "preprod.example.com",
|
|
},
|
|
},
|
|
description: "Rule with pattern 'preprod-*' should apply to 'preprod-api'",
|
|
},
|
|
{
|
|
name: "rule skipped for non-matching namespace",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"HOST": "default.example.com",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.HOST
|
|
value: "preprod.example.com"
|
|
namespacePattern: "preprod-*"
|
|
`,
|
|
targetNamespace: "production",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"HOST": "default.example.com",
|
|
},
|
|
},
|
|
description: "Rule with pattern 'preprod-*' should NOT apply to 'production'",
|
|
},
|
|
{
|
|
name: "multiple rules with different patterns",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"HOST": "default.example.com",
|
|
"LOG_LEVEL": "info",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.HOST
|
|
value: "preprod.example.com"
|
|
namespacePattern: "preprod-*"
|
|
- path: data.HOST
|
|
value: "prod.example.com"
|
|
namespacePattern: "prod-*"
|
|
- path: data.LOG_LEVEL
|
|
value: "debug"
|
|
namespacePattern: "preprod-*"
|
|
`,
|
|
targetNamespace: "preprod-api",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"HOST": "preprod.example.com",
|
|
"LOG_LEVEL": "debug",
|
|
},
|
|
},
|
|
description: "Only rules matching 'preprod-*' should apply to 'preprod-api'",
|
|
},
|
|
{
|
|
name: "rule without pattern applies to all namespaces",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"GLOBAL": "old",
|
|
"SCOPED": "old",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.GLOBAL
|
|
value: "applied-to-all"
|
|
- path: data.SCOPED
|
|
value: "applied-to-prod"
|
|
namespacePattern: "prod-*"
|
|
`,
|
|
targetNamespace: "staging",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"GLOBAL": "applied-to-all",
|
|
"SCOPED": "old",
|
|
},
|
|
},
|
|
description: "Rule without pattern should apply, rule with non-matching pattern should not",
|
|
},
|
|
{
|
|
name: "template rule with namespace pattern",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"API_URL": "default.api.com",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.API_URL
|
|
template: "https://{{.TargetNamespace}}.api.com"
|
|
namespacePattern: "preprod-*"
|
|
`,
|
|
targetNamespace: "preprod-service",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"API_URL": "https://preprod-service.api.com",
|
|
},
|
|
},
|
|
description: "Template rule with namespace pattern should apply when pattern matches",
|
|
},
|
|
{
|
|
name: "suffix pattern matching (*-staging)",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"LOG_LEVEL": "info",
|
|
"GRAPHQL_HOST": "default.example.com",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.LOG_LEVEL
|
|
value: "warn"
|
|
namespacePattern: "*-staging"
|
|
- path: data.GRAPHQL_HOST
|
|
value: "https://staging.example.com/v1/graphql"
|
|
namespacePattern: "*-staging"
|
|
`,
|
|
targetNamespace: "app-staging",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"LOG_LEVEL": "warn",
|
|
"GRAPHQL_HOST": "https://staging.example.com/v1/graphql",
|
|
},
|
|
},
|
|
description: "Suffix pattern *-staging should match app-staging",
|
|
},
|
|
{
|
|
name: "suffix pattern non-matching",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"LOG_LEVEL": "info",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.LOG_LEVEL
|
|
value: "warn"
|
|
namespacePattern: "*-staging"
|
|
`,
|
|
targetNamespace: "production",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"LOG_LEVEL": "info",
|
|
},
|
|
},
|
|
description: "Suffix pattern *-staging should NOT match production",
|
|
},
|
|
{
|
|
name: "single character wildcard (namespace-?)",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"ENVIRONMENT": "unknown",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.ENVIRONMENT
|
|
value: "development"
|
|
namespacePattern: "namespace-?"
|
|
`,
|
|
targetNamespace: "namespace-2",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"ENVIRONMENT": "development",
|
|
},
|
|
},
|
|
description: "Single char wildcard namespace-? should match namespace-2",
|
|
},
|
|
{
|
|
name: "single character wildcard non-matching (too long)",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"ENVIRONMENT": "unknown",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.ENVIRONMENT
|
|
value: "development"
|
|
namespacePattern: "namespace-?"
|
|
`,
|
|
targetNamespace: "namespace-10",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"ENVIRONMENT": "unknown",
|
|
},
|
|
},
|
|
description: "Single char wildcard namespace-? should NOT match namespace-10 (too many chars)",
|
|
},
|
|
{
|
|
name: "prod-* pattern with value rule",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"LOG_LEVEL": "info",
|
|
"GRAPHQL_HOST": "default.example.com",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.LOG_LEVEL
|
|
value: "error"
|
|
namespacePattern: "prod-*"
|
|
- path: data.GRAPHQL_HOST
|
|
value: "https://api.example.com/v1/graphql"
|
|
namespacePattern: "prod-*"
|
|
`,
|
|
targetNamespace: "prod-api",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"LOG_LEVEL": "error",
|
|
"GRAPHQL_HOST": "https://api.example.com/v1/graphql",
|
|
},
|
|
},
|
|
description: "Pattern prod-* should match prod-api",
|
|
},
|
|
{
|
|
name: "merge rule with namespace pattern",
|
|
sourceData: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"labels": map[string]interface{}{
|
|
"app": "myapp",
|
|
},
|
|
},
|
|
"data": map[string]interface{}{
|
|
"config": "value",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: metadata.labels
|
|
merge:
|
|
security-tier: "high"
|
|
compliance: "required"
|
|
namespacePattern: "prod-*"
|
|
`,
|
|
targetNamespace: "prod-api",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"config": "value",
|
|
},
|
|
"metadata": map[string]interface{}{
|
|
"labels": map[string]interface{}{
|
|
"app": "myapp",
|
|
"security-tier": "high",
|
|
"compliance": "required",
|
|
},
|
|
},
|
|
},
|
|
description: "Merge rule with prod-* pattern should add labels to prod-api",
|
|
},
|
|
{
|
|
name: "delete rule with namespace pattern",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"DEBUG_MODE": "true",
|
|
"ADMIN_KEY": "secret",
|
|
"PUBLIC_VALUE": "safe",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
- path: data.DEBUG_MODE
|
|
delete: true
|
|
namespacePattern: "prod-*"
|
|
- path: data.ADMIN_KEY
|
|
delete: true
|
|
namespacePattern: "prod-*"
|
|
`,
|
|
targetNamespace: "prod-api",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"PUBLIC_VALUE": "safe",
|
|
},
|
|
},
|
|
description: "Delete rules with prod-* pattern should remove debug fields from prod-api",
|
|
},
|
|
{
|
|
name: "complex multi-environment pattern (like example 10)",
|
|
sourceData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"APP_NAME": "default-app",
|
|
"ENVIRONMENT": "unknown",
|
|
"LOG_LEVEL": "info",
|
|
},
|
|
},
|
|
rules: `
|
|
rules:
|
|
# Global rule - no pattern
|
|
- path: data.APP_NAME
|
|
value: "universal-app"
|
|
|
|
# Numbered namespaces
|
|
- path: data.ENVIRONMENT
|
|
value: "development"
|
|
namespacePattern: "namespace-?"
|
|
|
|
# Preprod
|
|
- path: data.ENVIRONMENT
|
|
value: "preproduction"
|
|
namespacePattern: "preprod-*"
|
|
|
|
- path: data.LOG_LEVEL
|
|
value: "debug"
|
|
namespacePattern: "preprod-*"
|
|
|
|
# Production
|
|
- path: data.ENVIRONMENT
|
|
value: "production"
|
|
namespacePattern: "prod-*"
|
|
|
|
- path: data.LOG_LEVEL
|
|
value: "error"
|
|
namespacePattern: "prod-*"
|
|
`,
|
|
targetNamespace: "preprod-api",
|
|
expectedData: map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"APP_NAME": "universal-app",
|
|
"ENVIRONMENT": "preproduction",
|
|
"LOG_LEVEL": "debug",
|
|
},
|
|
},
|
|
description: "Complex multi-pattern rules should apply global + preprod-specific rules to preprod-api",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create source object with transformation rules
|
|
source := &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-config",
|
|
"namespace": "source-namespace",
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationTransform: tt.rules,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Merge source data
|
|
for k, v := range tt.sourceData {
|
|
if k == "metadata" {
|
|
// Merge metadata instead of replacing it
|
|
existingMeta := source.Object["metadata"].(map[string]interface{})
|
|
newMeta := v.(map[string]interface{})
|
|
for mk, mv := range newMeta {
|
|
existingMeta[mk] = mv
|
|
}
|
|
} else {
|
|
source.Object[k] = v
|
|
}
|
|
}
|
|
|
|
// Transform
|
|
transformer := NewDefaultTransformer()
|
|
ctx := TransformContext{
|
|
TargetNamespace: tt.targetNamespace,
|
|
SourceNamespace: "source-namespace",
|
|
SourceName: "test-config",
|
|
TargetName: "test-config",
|
|
}
|
|
|
|
result, err := transformer.Transform(source, ctx)
|
|
require.NoError(t, err, tt.description)
|
|
|
|
resultU, ok := result.(*unstructured.Unstructured)
|
|
require.True(t, ok)
|
|
|
|
// Check that the expected fields have the expected values
|
|
for key, expectedValue := range tt.expectedData {
|
|
if key == "data" {
|
|
dataMap, found, err := unstructured.NestedMap(resultU.Object, "data")
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
|
|
expectedDataMap := expectedValue.(map[string]interface{})
|
|
for dataKey, dataValue := range expectedDataMap {
|
|
assert.Equal(t, dataValue, dataMap[dataKey], "%s: data.%s should be %v", tt.description, dataKey, dataValue)
|
|
}
|
|
}
|
|
if key == "metadata" {
|
|
metadataMap := expectedValue.(map[string]interface{})
|
|
if labelsExpected, ok := metadataMap["labels"]; ok {
|
|
labelsMap, found, err := unstructured.NestedMap(resultU.Object, "metadata", "labels")
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
|
|
expectedLabels := labelsExpected.(map[string]interface{})
|
|
for labelKey, labelValue := range expectedLabels {
|
|
assert.Equal(t, labelValue, labelsMap[labelKey], "%s: metadata.labels.%s should be %v", tt.description, labelKey, labelValue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|