mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-08 22:59:22 +00:00
75f7c18f3c
M7: extractUnstructuredContent only hashed 'spec' when present, dropping all other top-level content fields. Resources with both spec and data (or any non-spec content) silently drifted until the next 10m resync. Now hashes every non-Kubernetes-managed top-level field, matching the fields updateUnstructuredMirror copies. M6: when a source has a transform annotation, also hash the source's labels and annotations (filtered of kubemirror.raczylo.com/* keys to avoid the controller's own bookkeeping churning the hash). Templates read these via TransformContext; without this a label change wouldn't re-render the transformed mirror. H3: text/template.Execute is not context-aware, so applyTemplateRule's timeout cancels the select but leaks the executor goroutine. Added a process-wide semaphore (cap 64) so a runaway template can't spawn an unbounded number of stuck goroutines on every reconcile. M4: zap dev mode (DPanic-on-error, console output, stacktraces on warning) was hardcoded on. Defaulted to production; --zap-devel flag remains for opt-in. M5: WaitForInitialDiscovery was anchored on context.Background() with its own WithTimeout, so SIGTERM during startup couldn't abort the wait. Now anchors on signalCtx.
1442 lines
36 KiB
Go
1442 lines
36 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 {
|
|
ctx TransformContext
|
|
source runtime.Object
|
|
validate func(t *testing.T, result runtime.Object)
|
|
name string
|
|
errMsg string
|
|
options TransformOptions
|
|
wantErr bool
|
|
}{
|
|
// 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 {
|
|
value interface{}
|
|
obj map[string]interface{}
|
|
want map[string]interface{}
|
|
name string
|
|
path []string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
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 TestTransformer_TemplateConcurrencyCap(t *testing.T) {
|
|
// Regression (H3): text/template.Execute is not context-aware, so a
|
|
// timed-out template execution leaves its goroutine running until the
|
|
// template returns on its own. We bound that by a global semaphore;
|
|
// when saturated, applyTemplateRule must fail fast instead of spawning
|
|
// another goroutine.
|
|
//
|
|
// This test saturates the semaphore directly, then asserts the next
|
|
// call returns the cap-exceeded error rather than blocking or panicking.
|
|
for i := 0; i < maxConcurrentTemplateExecutions; i++ {
|
|
templateExecSemaphore <- struct{}{}
|
|
}
|
|
defer func() {
|
|
// Drain whatever the test left in the semaphore so subsequent tests
|
|
// see a clean state.
|
|
for {
|
|
select {
|
|
case <-templateExecSemaphore:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
tmpl := "hello"
|
|
tr := NewDefaultTransformer()
|
|
rule := Rule{Path: "data.greeting", Template: &tmpl}
|
|
u := &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"data": map[string]interface{}{},
|
|
}}
|
|
|
|
err := tr.applyTemplateRule(u, rule, TransformContext{})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "rejected", "saturated semaphore must reject new template executions")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|