Skip to content

Commit 3e4c3d2

Browse files
committed
fix(projection): nested value retrieval with jsonpath selectors
On-behalf-of: @SAP [email protected] Signed-off-by: Christopher Junk <[email protected]>
1 parent 1798cee commit 3e4c3d2

File tree

7 files changed

+203
-10
lines changed

7 files changed

+203
-10
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@ To get a full list of the supported tasks, you can run the `task` command with n
211211
### Metric
212212

213213
Metrics have additional capabilities, such as projections. Projections allow you to extract specific fields from the target resource and include them in the metric data.
214-
This can be useful for tracking additional dimensions of the resource, such as fields, labels or annotations. It uses the dot notation to access nested fields.
214+
This can be useful for tracking additional dimensions of the resource, such as fields, labels or annotations. It uses the dot notation and supports [JSONPath selectors](https://www.rfc-editor.org/rfc/rfc9535.html#name-selectors) to access nested fields.
215+
Note that a single projection has to select a primitive value, collection type results are not supported.
215216
The projections are then translated to dimensions in the metric.
216217

217218
```yaml

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
k8s.io/client-go v0.33.2
2020
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
2121
sigs.k8s.io/controller-runtime v0.21.0
22+
sigs.k8s.io/yaml v1.6.0
2223
)
2324

2425
require (
@@ -84,5 +85,4 @@ require (
8485
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
8586
sigs.k8s.io/randfill v1.0.0 // indirect
8687
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
87-
sigs.k8s.io/yaml v1.5.0 // indirect
8888
)

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,5 +228,5 @@ sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxO
228228
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI=
229229
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
230230
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
231-
sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
232-
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
231+
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
232+
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

internal/orchestrator/federatedhandler.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,7 @@ func (h *FederatedHandler) extractProjectionGroupsFrom(list *unstructured.Unstru
130130

131131
if projection.Name != "" && projection.FieldPath != "" {
132132
name := projection.Name
133-
fieldPath := projection.FieldPath
134-
fields := strings.Split(fieldPath, ".")
135-
value, found, err := unstructured.NestedString(obj.Object, fields...)
133+
value, found, err := nestedPrimitiveValue(obj, projection.FieldPath)
136134
collection = append(collection, projectedField{name: name, value: value, found: found, error: err})
137135
}
138136
}

internal/orchestrator/metrichandler.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,7 @@ func (h *MetricHandler) extractProjectionGroupsFrom(list *unstructured.Unstructu
170170

171171
if projection.Name != "" && projection.FieldPath != "" {
172172
name := projection.Name
173-
fieldPath := projection.FieldPath
174-
fields := strings.Split(fieldPath, ".")
175-
value, found, err := unstructured.NestedString(obj.Object, fields...)
173+
value, found, err := nestedPrimitiveValue(obj, projection.FieldPath)
176174
collection = append(collection, projectedField{name: name, value: value, found: found, error: err})
177175
}
178176
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package orchestrator
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
8+
"k8s.io/client-go/util/jsonpath"
9+
)
10+
11+
// nestedPrimitiveValue returns a string value based on the result of the client-go JSONPath parser.
12+
// Returns false if the value is not found.
13+
// Returns an error if the value is ambiguous or a collection type.
14+
// Returns an error if the given path can't be parsed.
15+
//
16+
// String conversion of non-string primitives relies on the default format when printing the value.
17+
// The input path is expected to be passed in dot-notation without brackets or a leading dot.
18+
// The implementation is based on similar internal client-go jsonpath usages, like kubectl
19+
func nestedPrimitiveValue(obj unstructured.Unstructured, path string) (string, bool, error) {
20+
jsonPath := jsonpath.New("projection").AllowMissingKeys(true)
21+
if err := jsonPath.Parse(fmt.Sprintf("{.%s}", path)); err != nil {
22+
return "", false, fmt.Errorf("failed to parse path: %v", err)
23+
}
24+
results, err := jsonPath.FindResults(obj.UnstructuredContent())
25+
if err != nil {
26+
return "", false, fmt.Errorf("failed to find results: %v", err)
27+
}
28+
if len(results) == 0 || len(results[0]) == 0 {
29+
return "", false, nil
30+
}
31+
if len(results) > 1 || len(results[0]) > 1 {
32+
return "", true, errors.New("fieldPath matches more than one value which is not supported")
33+
}
34+
resultValue := results[0][0]
35+
switch resultValue.Interface().(type) {
36+
case map[string]interface{}, []interface{}:
37+
return "", true, errors.New("fieldPath results in collection type which is not supported")
38+
}
39+
return fmt.Sprintf("%v", resultValue.Interface()), true, nil
40+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package orchestrator
2+
3+
import (
4+
"testing"
5+
6+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
7+
"sigs.k8s.io/yaml"
8+
)
9+
10+
const subaccountCR = `
11+
apiVersion: account.btp.sap.crossplane.io/v1alpha1
12+
kind: Subaccount
13+
metadata:
14+
annotations:
15+
crossplane.io/external-name: test-subaccount
16+
name: test-subaccount
17+
spec:
18+
deletionPolicy: Delete
19+
status:
20+
conditions:
21+
- lastTransitionTime: "2025-09-12T15:57:41Z"
22+
observedGeneration: 1
23+
reason: ReconcileSuccess
24+
status: "True"
25+
type: Synced
26+
- lastTransitionTime: "2025-09-09T14:33:38Z"
27+
reason: Available
28+
status: "True"
29+
type: Ready
30+
`
31+
32+
func TestNestedPrimitiveValue(t *testing.T) {
33+
tests := []struct {
34+
name string
35+
resourceYaml string
36+
path string
37+
wantValue string
38+
wantFound bool
39+
wantError bool
40+
}{
41+
{
42+
name: "top level value retrieval",
43+
resourceYaml: subaccountCR,
44+
path: "kind",
45+
wantValue: "Subaccount",
46+
wantFound: true,
47+
wantError: false,
48+
},
49+
{
50+
name: "nested value retrieval with name selector",
51+
resourceYaml: subaccountCR,
52+
path: "spec.deletionPolicy",
53+
wantValue: "Delete",
54+
wantFound: true,
55+
wantError: false,
56+
},
57+
{
58+
name: "nested value retrieval with escaped name selector",
59+
resourceYaml: subaccountCR,
60+
path: "metadata.annotations.crossplane\\.io/external-name",
61+
wantValue: "test-subaccount",
62+
wantFound: true,
63+
wantError: false,
64+
},
65+
{
66+
name: "nested value retrieval with index selector",
67+
resourceYaml: subaccountCR,
68+
path: "status.conditions[1].status",
69+
wantValue: "True",
70+
wantFound: true,
71+
wantError: false,
72+
},
73+
{
74+
name: "nested value retrieval with filter selector",
75+
resourceYaml: subaccountCR,
76+
path: "status.conditions[?(@.type=='Ready')].status",
77+
wantValue: "True",
78+
wantFound: true,
79+
wantError: false,
80+
},
81+
{
82+
name: "nested value retrieval with array slice selector",
83+
resourceYaml: subaccountCR,
84+
path: "status.conditions[0:1].status",
85+
wantValue: "True",
86+
wantFound: true,
87+
wantError: false,
88+
},
89+
{
90+
name: "non-existent value",
91+
resourceYaml: subaccountCR,
92+
path: "metadata.labels.app",
93+
wantValue: "",
94+
wantFound: false,
95+
wantError: false,
96+
},
97+
{
98+
name: "nested non-string value retrieval with default print format",
99+
resourceYaml: subaccountCR,
100+
path: "status.conditions[0].observedGeneration",
101+
wantValue: "1",
102+
wantFound: true,
103+
wantError: false,
104+
},
105+
{
106+
name: "retrieval of collection types is not supported",
107+
resourceYaml: subaccountCR,
108+
path: "status.conditions[0]",
109+
wantValue: "",
110+
wantFound: true,
111+
wantError: true,
112+
},
113+
{
114+
name: "invalid array index returns an error",
115+
resourceYaml: subaccountCR,
116+
path: "status.conditions[abc].status",
117+
wantValue: "",
118+
wantFound: false,
119+
wantError: true,
120+
},
121+
{
122+
name: "invalid path syntax returns an error",
123+
resourceYaml: subaccountCR,
124+
path: "$.[status.conditions[0].status]",
125+
wantValue: "",
126+
wantFound: false,
127+
wantError: true,
128+
},
129+
}
130+
131+
for _, tt := range tests {
132+
t.Run(tt.name, func(t *testing.T) {
133+
obj := toUnstructured(t, tt.resourceYaml)
134+
value, ok, err := nestedPrimitiveValue(obj, tt.path)
135+
136+
if (err != nil) != tt.wantError {
137+
t.Errorf("unexpected error: got %v, wantErr %v", err, tt.wantError)
138+
}
139+
if ok != tt.wantFound {
140+
t.Errorf("unexpected ok result: got %v, want %v", ok, tt.wantFound)
141+
}
142+
if value != tt.wantValue {
143+
t.Errorf("unexpected value: got %v, want %v", value, tt.wantValue)
144+
}
145+
})
146+
}
147+
}
148+
149+
func toUnstructured(t *testing.T, resourceYaml string) unstructured.Unstructured {
150+
t.Helper()
151+
var object map[string]interface{}
152+
if err := yaml.Unmarshal([]byte(resourceYaml), &object); err != nil {
153+
t.Fatalf("failed to unmarshal YAML: %v", err)
154+
}
155+
return unstructured.Unstructured{Object: object}
156+
}

0 commit comments

Comments
 (0)