Skip to content

Commit

Permalink
Add jsonpath support for get/set multiple values
Browse files Browse the repository at this point in the history
- Move only-one-match logic into mutator impl
  • Loading branch information
karlkfi committed Sep 21, 2021
1 parent 6296084 commit 2ccd195
Show file tree
Hide file tree
Showing 4 changed files with 241 additions and 118 deletions.
12 changes: 9 additions & 3 deletions pkg/apply/mutator/apply_time_mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,22 +202,28 @@ func readFieldValue(obj *unstructured.Unstructured, path string) (interface{}, b
return nil, false, errors.New("empty path expression")
}

value, found, err := jsonpath.Get(obj.Object, path)
values, err := jsonpath.Get(obj.Object, path)
if err != nil {
return nil, false, err
}
return value, found, nil
if len(values) != 1 {
return nil, false, fmt.Errorf("expected 1 match, but found %d)", len(values))
}
return values[0], true, nil
}

func writeFieldValue(obj *unstructured.Unstructured, path string, value interface{}) error {
if path == "" {
return errors.New("empty path expression")
}

err := jsonpath.Set(obj.Object, path, value)
found, err := jsonpath.Set(obj.Object, path, value)
if err != nil {
return err
}
if found != 1 {
return fmt.Errorf("expected 1 match, but found %d)", found)
}
return nil
}

Expand Down
51 changes: 51 additions & 0 deletions pkg/apply/mutator/apply_time_mutator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,43 @@ spec:
- containerPort: 8081
`

var clusterrole1y = `
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: example-role
labels:
domain: example.com
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "watch", "list"]
`

var clusterrolebinding1y = `
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: read-secrets
annotations:
config.kubernetes.io/apply-time-mutation: |
- sourceRef:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
name: example-role
sourcePath: $.metadata.labels.domain
targetPath: $.subjects[0].name
token: ${domain}
subjects:
- kind: User
name: "bob@${domain}"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: secret-reader
apiGroup: rbac.authorization.k8s.io
`

type nestedFieldValue struct {
Field []interface{}
Value interface{}
Expand All @@ -344,6 +381,8 @@ func TestMutate(t *testing.T) {
ingress2 := ktestutil.YamlToUnstructured(t, ingress2y)
service1 := ktestutil.YamlToUnstructured(t, service1y)
deployment1 := ktestutil.YamlToUnstructured(t, deployment1y)
clusterrole1 := ktestutil.YamlToUnstructured(t, clusterrole1y)
clusterrolebinding1 := ktestutil.YamlToUnstructured(t, clusterrolebinding1y)

joinedPaths := make([]interface{}, 0)
err := yaml.Unmarshal([]byte(joinedPathsYaml), &joinedPaths)
Expand Down Expand Up @@ -510,6 +549,18 @@ func TestMutate(t *testing.T) {
},
},
},
"cluster-scoped": {
target: clusterrolebinding1,
sources: []*unstructured.Unstructured{clusterrole1},
mutated: true,
reason: expectedReason,
expected: []nestedFieldValue{
{
Field: []interface{}{"subjects", 0, "name"},
Value: "[email protected]",
},
},
},
}

for name, tc := range tests {
Expand Down
140 changes: 70 additions & 70 deletions pkg/jsonpath/jsonpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,134 +17,134 @@ import (
"github.com/spyzhov/ajson"
)

// Get evaluates the yq expression to extract a value from the input map.
// Get evaluates the yq expression to extract values from the input map.
// Returns the node values that were found (zero or more), or an error.
// For details about the yq expression language, see: https://mikefarah.gitbook.io/yq/
func Get(obj map[string]interface{}, expression string) (interface{}, bool, error) {
func Get(obj map[string]interface{}, expression string) ([]interface{}, error) {
// format input object as json for input into jsonpath library
jsonBytes, err := json.Marshal(obj)
if err != nil {
return nil, false, fmt.Errorf("failed to marshal input to json: %w", err)
return nil, fmt.Errorf("failed to marshal input to json: %w", err)
}

klog.V(7).Info("jsonpath.Get input as json:\n%s", jsonBytes)

// parse json into an ajson node
root, err := ajson.Unmarshal(jsonBytes)
if err != nil {
return nil, false, fmt.Errorf("failed to unmarshal input json: %w", err)
return nil, fmt.Errorf("failed to unmarshal input json: %w", err)
}

// find nodes that match the expression
nodes, err := root.JSONPath(expression)
if err != nil {
return nil, false, fmt.Errorf("failed to evaluate jsonpath expression (%s): %w", expression, err)
return nil, fmt.Errorf("failed to evaluate jsonpath expression (%s): %w", expression, err)
}
if len(nodes) < 1 {
return nil, false, fmt.Errorf("no node found with jsonpath (expected: 1, found: %d, expression: %s)", len(nodes), expression)
}
if len(nodes) > 1 {
return nil, false, fmt.Errorf("too many nodes found with jsonpath (expected: 1, found: %d, expression: %s)", len(nodes), expression)
}
node := nodes[0]

// find nodes that match the expression
jsonBytes, err = ajson.Marshal(node)
if err != nil {
return nil, false, fmt.Errorf("failed to marshal jsonpath result to json: %w", err)
}
result := make([]interface{}, len(nodes))

klog.V(7).Info("jsonpath.Get output as json:\n%s", jsonBytes)
// get value of all matching nodes
for i, node := range nodes {
// format node value as json
jsonBytes, err = ajson.Marshal(node)
if err != nil {
return nil, fmt.Errorf("failed to marshal jsonpath result to json: %w", err)
}

// parse json back into a Go primitive
var out interface{}
err = yaml.Unmarshal(jsonBytes, &out)
if err != nil {
return nil, false, fmt.Errorf("failed to unmarshal jsonpath result: %w", err)
klog.V(7).Info("jsonpath.Get output as json:\n%s", jsonBytes)

// parse json back into a Go primitive
var value interface{}
err = yaml.Unmarshal(jsonBytes, &value)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal jsonpath result: %w", err)
}
result[i] = value
}

return out, true, nil
return result, nil
}

// Set evaluates the yq expression to set a value in the input map.
// Returns the number of matching nodes that were updated, or an error.
// For details about the yq expression language, see: https://mikefarah.gitbook.io/yq/
func Set(obj map[string]interface{}, expression string, value interface{}) error {
func Set(obj map[string]interface{}, expression string, value interface{}) (int, error) {
// format input object as json for input into jsonpath library
jsonBytes, err := json.Marshal(obj)
if err != nil {
return fmt.Errorf("failed to marshal input to json: %w", err)
return 0, fmt.Errorf("failed to marshal input to json: %w", err)
}

klog.V(7).Info("jsonpath.Set input as json:\n%s", jsonBytes)

// parse json into an ajson node
root, err := ajson.Unmarshal(jsonBytes)
if err != nil {
return fmt.Errorf("failed to unmarshal input json: %w", err)
return 0, fmt.Errorf("failed to unmarshal input json: %w", err)
}

// retrieve nodes that match the expression
nodes, err := root.JSONPath(expression)
if err != nil {
return fmt.Errorf("failed to evaluate jsonpath expression (%s): %w", expression, err)
}
if len(nodes) < 1 {
return fmt.Errorf("no node found with jsonpath (expected: 1, found: %d, expression: %s)", len(nodes), expression)
}
if len(nodes) > 1 {
return fmt.Errorf("too many nodes found with jsonpath (expected: 1, found: %d, expression: %s)", len(nodes), expression)
}

node := nodes[0]

switch typedValue := value.(type) {
case bool:
err = node.SetBool(typedValue)
case string:
err = node.SetString(typedValue)
case int:
err = node.SetNumeric(float64(typedValue))
case float64:
err = node.SetNumeric(typedValue)
case []interface{}:
var arrayValue []*ajson.Node
arrayValue, err = toArrayOfNodes(typedValue)
if err != nil {
break
return 0, fmt.Errorf("failed to evaluate jsonpath expression (%s): %w", expression, err)
}
if len(nodes) == 0 {
// zero nodes found, none updated
return 0, nil
}

// set value of all matching nodes
for _, node := range nodes {
switch typedValue := value.(type) {
case bool:
err = node.SetBool(typedValue)
case string:
err = node.SetString(typedValue)
case int:
err = node.SetNumeric(float64(typedValue))
case float64:
err = node.SetNumeric(typedValue)
case []interface{}:
var arrayValue []*ajson.Node
arrayValue, err = toArrayOfNodes(typedValue)
if err != nil {
break
}
err = node.SetArray(arrayValue)
case map[string]interface{}:
var mapValue map[string]*ajson.Node
mapValue, err = toMapOfNodes(typedValue)
if err != nil {
break
}
err = node.SetObject(mapValue)
default:
if value == nil {
err = node.SetNull()
} else {
err = fmt.Errorf("unsupported value type: %T", value)
}
}
err = node.SetArray(arrayValue)
case map[string]interface{}:
var mapValue map[string]*ajson.Node
mapValue, err = toMapOfNodes(typedValue)
if err != nil {
break
}
err = node.SetObject(mapValue)
default:
if value == nil {
err = node.SetNull()
} else {
err = fmt.Errorf("unsupported value type: %T", value)
return 0, err
}
}
if err != nil {
return err
}

// format into an ajson node
jsonBytes, err = ajson.Marshal(root)
if err != nil {
return fmt.Errorf("failed to marshal jsonpath result to json: %w", err)
return 0, fmt.Errorf("failed to marshal jsonpath result to json: %w", err)
}

klog.V(7).Info("jsonpath.Set output as json:\n%s", jsonBytes)

// parse json back into the input map
err = yaml.Unmarshal(jsonBytes, &obj)
if err != nil {
return fmt.Errorf("failed to unmarshal jsonpath result: %w", err)
return 0, fmt.Errorf("failed to unmarshal jsonpath result: %w", err)
}

return nil
return len(nodes), nil
}

func toArrayOfNodes(obj []interface{}) ([]*ajson.Node, error) {
Expand Down
Loading

0 comments on commit 2ccd195

Please sign in to comment.