Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add tests for kustomize's sortOptions configuration #1615

Merged
merged 1 commit into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions pkg/manifests/kustomize/kustomize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,139 @@ func TestEngine(t *testing.T) {
)),
))
}

const testEngineKustomizationOrderLegacy = `
apiVersion: kustomize.config.k8s.io/v1beta1
sortOptions:
order: legacy
resources:
- test-engine-cm.yaml
- test-engine-deployment.yaml
- test-engine-secrets.yaml
`
const testEngineKustomizationOrderLegacyCustom = `
apiVersion: kustomize.config.k8s.io/v1beta1
sortOptions:
order: legacy
legacySortOptions:
orderFirst:
- Secret
- Deployment
orderLast:
- ConfigMap
resources:
- test-engine-cm.yaml
- test-engine-deployment.yaml
- test-engine-secrets.yaml
`

const testEngineKustomizationOrderFifo = `
apiVersion: kustomize.config.k8s.io/v1beta1
sortOptions:
order: fifo
resources:
- test-engine-cm.yaml
- test-engine-deployment.yaml
- test-engine-secrets.yaml
`

const testEngineOrderConfigMap = `
apiVersion: v1
kind: ConfigMap
metadata:
name: test-cm
data:
foo: bar
`

//nolint:gosec
const testEngineOrderSecret = `
apiVersion: v1
kind: Secret
metadata:
name: test-secrets
stringData:
bar: baz
`

const testEngineOrderDeployment = `
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-deployment
spec:
replicas: 3
template:
spec:
containers:
- name: nginx
image: nginx:1.14.2
volumeMounts:
- name: config-volume
mountPath: /etc/config
- name: secrets-volume
mountPath: /etc/secrets
volumes:
- name: config-volume
configMap:
name: test-cm
- name: secrets-volume
secret:
name: test-secrets
`

func TestEngineOrder(t *testing.T) {
root := xid.New().String()

fs := filesys.MakeFsInMemory()

kustomizations := map[string]string{
"legacy": testEngineKustomizationOrderLegacy,
"ordered": testEngineKustomizationOrderLegacyCustom,
"fifo": testEngineKustomizationOrderFifo,
}

for k, v := range kustomizations {
t.Run(k, func(t *testing.T) {
g := NewWithT(t)

e := kustomize.NewEngine(
kustomize.WithEngineFS(fs),
)

_ = fs.MkdirAll(path.Join(root, kustomize.DefaultKustomizationFilePath))
_ = fs.WriteFile(path.Join(root, kustomize.DefaultKustomizationFileName), []byte(v))
_ = fs.WriteFile(path.Join(root, "test-engine-cm.yaml"), []byte(testEngineOrderConfigMap))
_ = fs.WriteFile(path.Join(root, "test-engine-secrets.yaml"), []byte(testEngineOrderSecret))
_ = fs.WriteFile(path.Join(root, "test-engine-deployment.yaml"), []byte(testEngineOrderDeployment))

r, err := e.Render(root)

g.Expect(err).NotTo(HaveOccurred())

switch k {
case "legacy":
g.Expect(r).Should(And(
HaveLen(3),
jq.Match(`.[0] | .kind == "ConfigMap"`),
jq.Match(`.[1] | .kind == "Secret"`),
jq.Match(`.[2] | .kind == "Deployment"`),
))
case "ordered":
g.Expect(r).Should(And(
HaveLen(3),
jq.Match(`.[0] | .kind == "Secret"`),
jq.Match(`.[1] | .kind == "Deployment"`),
jq.Match(`.[2] | .kind == "ConfigMap"`),
))
case "fifo":
g.Expect(r).Should(And(
HaveLen(3),
jq.Match(`.[0] | .kind == "ConfigMap"`),
jq.Match(`.[1] | .kind == "Deployment"`),
jq.Match(`.[2] | .kind == "Secret"`),
))
}
})
}
}
20 changes: 20 additions & 0 deletions pkg/utils/test/matchers/jq/jq_matcher_test.go
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can optimize it like this:

package jq_test

import (
	"encoding/json"
	"testing"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

	"github.com/opendatahub-io/opendatahub-operator/v2/pkg/utils/test/matchers/jq"

	. "github.com/onsi/gomega"
)

// TestMatcher verifies the jq.Match function against various JSON strings.
// It ensures correct behavior for simple key-value matches, array value checks, 
// handling of null values, and nested object conditions.
func TestMatcher(t *testing.T) {
	t.Parallel()

	g := NewWithT(t)

	// Define multiple test cases for jq matching
	testCases := []struct {
		input    string
		jqQuery  string
		expected bool
	}{
		{
			// Test matching a simple value
			input:    `{"a":1}`,
			jqQuery:  `.a == 1`,
			expected: true,
		},
		{
			// Test when the value doesn't match
			input:    `{"a":1}`,
			jqQuery:  `.a == 2`,
			expected: false,
		},
		{
			// Test array with values matching
			input:    `{"Values":[ "foo" ]}`,
			jqQuery:  `.Values | if . then any(. == "foo") else false end`,
			expected: true,
		},
		{
			// Test array with non-matching value
			input:    `{"Values":[ "foo" ]}`,
			jqQuery:  `.Values | if . then any(. == "bar") else false end`,
			expected: false,
		},
		{
			// Test when the value is null
			input:    `{"Values": null}`,
			jqQuery:  `.Values | if . then any(. == "foo") else false end`,
			expected: false,
		},
		{
			// Test multiple matching conditions
			input:    `{ "status": { "foo": { "bar": "fr", "baz": "fb" } } }`,
			jqQuery: `(.status.foo.bar == "fr") and (.status.foo.baz == "fb")`
			expected: true,
		},
	}

	// Run the test cases
	for _, tc := range testCases {
		t.Run(tc.jqQuery, func(t *testing.T) {
			// Assert based on the expected result
			if tc.expected {
				g.Expect(tc.input).Should(jq.Match(tc.jqQuery))
			} else {
				g.Expect(tc.input).ShouldNot(jq.Match(tc.jqQuery))
			}
		})
	}
}
// TestMatcherWithType validates jq.Match against different Go data types such as maps and structs.
// It ensures that jq.Match works correctly after serializing Go types to JSON.
func TestMatcherWithType(t *testing.T) {
	t.Parallel()

	g := NewWithT(t)

	// Define multiple test cases for jq matching
	testCases := []struct {
		name     string
		input    interface{}
		jqQuery  string
		useTransform bool
	}{
		{
			name:        "Simple map match",
			input:       map[string]any{"a": 1},
			jqQuery:     `.a == 1`,
			useTransform: false,
		},
		{
			name:        "Map match with JSON transformation",
			input:       map[string]any{"a": 1},
			jqQuery:     `.a == 1`,
			useTransform: true,
		},
		{
			name: "Nested map match with JSON transformation",
			input: map[string]any{
				"status": map[string]any{
					"foo": map[string]any{
						"bar": "fr",
						"baz": "fb",
					},
				},
			},
			jqQuery: `(.status.foo.bar == "fr") and (.status.foo.baz == "fb"`),
			useTransform: true,
		},
		{
			name:        "Struct match with JSON transformation",
			input:       struct { A int `json:"a"` }{A: 1},
			jqQuery:     `.a == 1`,
			useTransform: true,
		},
	}

	// Run the test cases
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			marshaled, _ := json.Marshal(tc.input)
			// If `useTransform` is true, we first transform the input to JSON before running jq.Match
			// If `useTransform` is false, we directly pass the input to jq.Match.
			if tc.useTransform {
				g.Expect(tc.input).Should(WithTransform(json.Marshal, jq.Match(tc.jqQuery)))
			} else {
				g.Expect(tc.input).Should(jq.Match(tc.jqQuery))
			}
		})
	}
}

func TestUnstructuredSliceMatcher(t *testing.T) {
	t.Parallel()

	g := NewWithT(t)

	u := []unstructured.Unstructured{{
		Object: map[string]interface{}{
			"a": 1,
		}},
	}

	g.Expect(u).Should(
		jq.Match(`.[0] | .a == 1`))

	g.Expect(unstructured.UnstructuredList{Items: u}).Should(
		jq.Match(`.[0] | .a == 1`))
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"testing"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

"github.com/opendatahub-io/opendatahub-operator/v2/pkg/utils/test/matchers/jq"

. "github.com/onsi/gomega"
Expand Down Expand Up @@ -87,3 +89,21 @@ func TestMatcherWithType(t *testing.T) {
WithTransform(json.Marshal, jq.Match(`.a == 1`)),
)
}

func TestUnstructuredSliceMatcher(t *testing.T) {
t.Parallel()

g := NewWithT(t)

u := []unstructured.Unstructured{{
Object: map[string]interface{}{
"a": 1,
}},
}

g.Expect(u).Should(
jq.Match(`.[0] | .a == 1`))

g.Expect(unstructured.UnstructuredList{Items: u}).Should(
jq.Match(`.[0] | .a == 1`))
}
18 changes: 18 additions & 0 deletions pkg/utils/test/matchers/jq/jq_support.go
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can optimize it like this:

package jq

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"reflect"
	"strings"

	"github.com/onsi/gomega/format"
	"github.com/onsi/gomega/gbytes"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func formattedMessage(comparisonMessage string, failurePath []interface{}) string {
	diffMessage := ""

	if len(failurePath) != 0 {
		diffMessage = "\n\nfirst mismatched key: " + formattedFailurePath(failurePath)
	}

	return comparisonMessage + diffMessage
}

func formattedFailurePath(failurePath []interface{}) string {
	formattedPaths := make([]string, 0)

	for i := len(failurePath) - 1; i >= 0; i-- {
		switch p := failurePath[i].(type) {
		case int:
			val := fmt.Sprintf(`[%d]`, p)
			formattedPaths = append(formattedPaths, val)
		default:
			if i != len(failurePath)-1 {
				formattedPaths = append(formattedPaths, ".")
			}

			val := fmt.Sprintf(`"%s"`, p)
			formattedPaths = append(formattedPaths, val)
		}
	}

	return strings.Join(formattedPaths, "")
}

//nolint:cyclop
func toType(in any) (any, error) {
	switch v := in.(type) {
	case string, []byte, json.RawMessage, *gbytes.Buffer:
		// Handle string, []byte, json.RawMessage, and *gbytes.Buffer by converting them into the appropriate type.
		return convertToJSON(v)
	case io.Reader:
		// Handle io.Reader by reading the content and converting it into the appropriate type.
		data, err := io.ReadAll(v)
		if err != nil {
			return nil, fmt.Errorf("failed to read from reader: %w", err)
		}
		return convertToJSON(data)
	case unstructured.UnstructuredList:
		// Handle UnstructuredList by extracting the objects from the list.
		return convertUnstructuredItemsToType(v.Items)
	case []unstructured.Unstructured:
		// Handle slice of Unstructured items.
		return convertUnstructuredItemsToType(v)
	case unstructured.Unstructured, *unstructured.Unstructured:
		// Handle single Unstructured item or pointer to Unstructured item.
		// Both types can be handled the same way by accessing the `Object` field.
		return v.(unstructured.Unstructured).Object, nil
	case []*unstructured.Unstructured:
		// Handle slice of pointers to Unstructured items.
		return convertUnstructuredItemsToType(v)
	}

	// Fallback case for maps and slices.
	switch reflect.TypeOf(in).Kind() {
	case reflect.Map:
		return in, nil
	case reflect.Slice:
		return in, nil
	default:
		return nil, fmt.Errorf("unsuported type:\n%s", format.Object(in, 1))
	}
}

// convertToJSON converts various types into a structured JSON object or slice.
func convertToJSON(v any) (any, error) {
	var data []byte
	switch value := v.(type) {
	case string:
		data = []byte(value)
	case []byte:
		data = value
	case json.RawMessage:
		data = []byte(value)
	case *gbytes.Buffer:
		data = value.Contents()
	}

	switch data[0] {
	case '{':
		var obj map[string]any
		if err := json.Unmarshal(data, &obj); err != nil {
			return nil, fmt.Errorf("unable to unmarshal JSON object, %w", err)
		}
		return obj, nil
	case '[':
		var arr []any
		if err := json.Unmarshal(data, &arr); err != nil {
			return nil, fmt.Errorf("unable to unmarshal JSON array, %w", err)
		}
		return arr, nil
	default:
		return nil, errors.New("JSON must be an array or object")
	}
}

// convertUnstructuredItemsToType converts Unstructured items into their map representation.
func convertUnstructuredItemsToType(items []unstructured.Unstructured) ([]any, error) {
	res := make([]any, 0, len(items))
	for i := range items {
		res = append(res, items[i].Object)
	}
	return res, nil
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel free to send a pr :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do!

Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,26 @@
}

return d, nil
case unstructured.UnstructuredList:
res := make([]any, 0, len(v.Items))
for i := range v.Items {
res = append(res, v.Items[i].Object)
}
return res, nil
case []unstructured.Unstructured:
res := make([]any, 0, len(v))
for i := range v {
res = append(res, v[i].Object)
}
return res, nil
case unstructured.Unstructured:
return v.Object, nil
case []*unstructured.Unstructured:
res := make([]any, 0, len(v))
for i := range v {
res = append(res, v[i].Object)
}
return res, nil

Check warning on line 109 in pkg/utils/test/matchers/jq/jq_support.go

View check run for this annotation

Codecov / codecov/patch

pkg/utils/test/matchers/jq/jq_support.go#L104-L109

Added lines #L104 - L109 were not covered by tests
case *unstructured.Unstructured:
return v.Object, nil
}
Expand Down