From af51ceb4ffb5d5f560bf2262c0dad57d89985e03 Mon Sep 17 00:00:00 2001 From: gsweene2 Date: Wed, 4 May 2022 06:33:24 -0400 Subject: [PATCH] Add JMESPath Function `items` (#3777) Co-authored-by: Jim Bugwadia Co-authored-by: Sambhav Kothari Co-authored-by: Sambhav Kothari --- pkg/engine/jmespath/functions.go | 49 ++++++++++++++++++ pkg/engine/jmespath/functions_test.go | 50 +++++++++++++++++++ .../test/context-entries/kyverno-test.yaml | 5 ++ test/cli/test/context-entries/policies.yaml | 31 ++++++++++-- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/pkg/engine/jmespath/functions.go b/pkg/engine/jmespath/functions.go index 43d7ad3216..16dd46f121 100644 --- a/pkg/engine/jmespath/functions.go +++ b/pkg/engine/jmespath/functions.go @@ -8,6 +8,7 @@ import ( "path/filepath" "reflect" "regexp" + "sort" "strconv" "strings" "time" @@ -62,6 +63,7 @@ var ( semverCompare = "semver_compare" parseJson = "parse_json" parseYAML = "parse_yaml" + items = "items" ) const errorPrefix = "JMESPath function '%s': " @@ -368,6 +370,18 @@ func GetFunctions() []*FunctionEntry { ReturnType: []JpType{JpAny}, Note: "decodes a valid YAML encoded string to the appropriate type provided it can be represented as JSON", }, + { + Entry: &gojmespath.FunctionEntry{Name: items, + Arguments: []ArgSpec{ + {Types: []JpType{JpObject}}, + {Types: []JpType{JpString}}, + {Types: []JpType{JpString}}, + }, + Handler: jpItems, + }, + ReturnType: []JpType{JpArray}, + Note: "converts a map to an array of objects where each key:value is an item in the array", + }, } } @@ -795,6 +809,41 @@ func jpParseYAML(arguments []interface{}) (interface{}, error) { return output, err } +func jpItems(arguments []interface{}) (interface{}, error) { + input, ok := arguments[0].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(invalidArgumentTypeError, arguments, 0, "Object") + } + keyName, ok := arguments[1].(string) + if !ok { + return nil, fmt.Errorf(invalidArgumentTypeError, arguments, 1, "String") + } + valName, ok := arguments[2].(string) + if !ok { + return nil, fmt.Errorf(invalidArgumentTypeError, arguments, 2, "String") + } + + arrayOfObj := make([]map[string]interface{}, 0) + + keys := []string{} + + // Sort the keys so that the output is deterministic + for key := range input { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + m := make(map[string]interface{}) + m[keyName] = key + m[valName] = input[key] + arrayOfObj = append(arrayOfObj, m) + } + + return arrayOfObj, nil +} + // InterfaceToString casts an interface to a string type func ifaceToString(iface interface{}) (string, error) { switch i := iface.(type) { diff --git a/pkg/engine/jmespath/functions_test.go b/pkg/engine/jmespath/functions_test.go index 0f745290f3..5b421827cf 100644 --- a/pkg/engine/jmespath/functions_test.go +++ b/pkg/engine/jmespath/functions_test.go @@ -1284,3 +1284,53 @@ func Test_SemverCompare(t *testing.T) { }) } } + +func Test_Items(t *testing.T) { + + testCases := []struct { + object string + keyName string + valName string + expectedResult string + }{ + { + object: `{ "key1": "value1" }`, + keyName: `"key"`, + valName: `"value"`, + expectedResult: `[{ "key": "key1", "value": "value1" }]`, + }, + { + object: `{ "key1": "value1", "key2": "value2" }`, + keyName: `"key"`, + valName: `"value"`, + expectedResult: `[{ "key": "key1", "value": "value1" }, { "key": "key2", "value": "value2" }]`, + }, + { + object: `{ "key1": "value1", "key2": "value2" }`, + keyName: `"myKey"`, + valName: `"myValue"`, + expectedResult: `[{ "myKey": "key1", "myValue": "value1" }, { "myKey": "key2", "myValue": "value2" }]`, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) { + + query, err := New("items(`" + tc.object + "`,`" + tc.keyName + "`,`" + tc.valName + "`)") + assert.NilError(t, err) + + res, err := query.Search("") + assert.NilError(t, err) + + result, ok := res.([]map[string]interface{}) + assert.Assert(t, ok) + + var resource []map[string]interface{} + err = json.Unmarshal([]byte(tc.expectedResult), &resource) + assert.NilError(t, err) + + assert.DeepEqual(t, result, resource) + }) + } + +} diff --git a/test/cli/test/context-entries/kyverno-test.yaml b/test/cli/test/context-entries/kyverno-test.yaml index a2463de519..a8158d8fe5 100644 --- a/test/cli/test/context-entries/kyverno-test.yaml +++ b/test/cli/test/context-entries/kyverno-test.yaml @@ -49,3 +49,8 @@ results: resource: example kind: Pod result: pass + - policy: example + rule: items + resource: example + kind: Pod + result: pass diff --git a/test/cli/test/context-entries/policies.yaml b/test/cli/test/context-entries/policies.yaml index 6c95d44497..d88d10900f 100644 --- a/test/cli/test/context-entries/policies.yaml +++ b/test/cli/test/context-entries/policies.yaml @@ -126,11 +126,11 @@ spec: context: - name: obj variable: - value: + value: notName: not-example - name: obj variable: - value: + value: name: example match: resources: @@ -150,7 +150,7 @@ spec: context: - name: obj variable: - value: + value: - A=ATest - B=BTest match: @@ -163,3 +163,28 @@ spec: - key: "A=*" operator: AnyNotIn value: "{{ obj }}" + - name: items + context: + - name: obj + variable: + value: + a: 1 + b: 2 + jmesPath: items(@, 'key', 'value') + - name: expected + variable: + value: + - key: a + value: 1 + - key: b + value: 2 + match: + resources: + kinds: + - Pod + validate: + deny: + conditions: + - key: "{{ obj }}" + operator: NotEqual + value: "{{ expected }}"