Skip to content

Commit fc945e3

Browse files
authored
fix(projection): nested value retrieval with jsonpath selectors (#81)
* fix(projection): nested value retrieval with jsonpath selectors * docs(projections): selector syntax overview On-behalf-of: @SAP christopher.junk@sap.com Signed-off-by: Christopher Junk <christopher.junk@sap.com> --------- Signed-off-by: Christopher Junk <christopher.junk@sap.com>
1 parent fb73197 commit fc945e3

File tree

7 files changed

+231
-10
lines changed

7 files changed

+231
-10
lines changed

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ The Metrics Operator is a powerful tool designed to monitor and provide insights
2626
- [Managed Metric](#managed-metric)
2727
- [Federated Metric](#federated-metric)
2828
- [Federated Managed Metric](#federated-managed-metric)
29+
- [Projection Selector Syntax Overview](#projection-selector-syntax-overview)
2930
- [Remote Cluster Access](#remote-cluster-access)
3031
- [Remote Cluster Access](#remote-cluster-access-1)
3132
- [Federated Cluster Access](#federated-cluster-access)
@@ -211,7 +212,8 @@ To get a full list of the supported tasks, you can run the `task` command with n
211212
### Metric
212213

213214
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.
215+
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](#projection-selector-syntax-overview) to access nested fields.
216+
Note that a single projection has to select a primitive value, collection type results are not supported.
215217
The projections are then translated to dimensions in the metric.
216218

217219
```yaml
@@ -298,6 +300,25 @@ spec:
298300
---
299301
```
300302

303+
### Projection Selector Syntax Overview
304+
305+
The following examples demonstrate the usage of different [JSONPath selectors](https://www.rfc-editor.org/rfc/rfc9535.html#name-selectors):
306+
307+
```yaml
308+
projections:
309+
- name: pod-namespace
310+
# name selector: selects the namespace value
311+
fieldPath: "metadata.namespace"
312+
- name: pod-condition-ready-status
313+
# filter selector: selects the status value of the conditions with type='Ready'
314+
fieldPath: "status.conditions[?(@.type=='Ready')].status"
315+
- name: pod-condition-last-transition-time
316+
# index selector: selects the lastTransitionTime value of the first condition
317+
fieldPath: "status.conditions[0].lastTransitionTime"
318+
```
319+
320+
Note: Array slice `start:end:step` syntax and wildcard selectors are technically supported but left out in the examples due to the restriction that a projection is expected to result in a single primitive value. It is also important to point out that even though `projections` is an array type, the operator evaluates only the first projection of a metric for now. Support for multiple projections will be added in a future release.
321+
301322
## Remote Cluster Access
302323

303324

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-20250820121507-0af2bda4dd1d
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+
jp := jsonpath.New("projection").AllowMissingKeys(true)
21+
if err := jp.Parse(fmt.Sprintf("{.%s}", path)); err != nil {
22+
return "", false, fmt.Errorf("failed to parse path: %v", err)
23+
}
24+
results, err := jp.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+
value := results[0][0]
35+
switch value.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", value.Interface()), true, nil
40+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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: "nested value retrieval with wildcard selector; collection results are not supported",
91+
resourceYaml: subaccountCR,
92+
path: "status.conditions[*].status",
93+
wantValue: "",
94+
wantFound: true,
95+
wantError: true,
96+
},
97+
{
98+
name: "non-existent value",
99+
resourceYaml: subaccountCR,
100+
path: "metadata.labels.app",
101+
wantValue: "",
102+
wantFound: false,
103+
wantError: false,
104+
},
105+
{
106+
name: "nested non-string value retrieval with default print format",
107+
resourceYaml: subaccountCR,
108+
path: "status.conditions[0].observedGeneration",
109+
wantValue: "1",
110+
wantFound: true,
111+
wantError: false,
112+
},
113+
{
114+
name: "retrieval of collection types is not supported",
115+
resourceYaml: subaccountCR,
116+
path: "status.conditions[0]",
117+
wantValue: "",
118+
wantFound: true,
119+
wantError: true,
120+
},
121+
{
122+
name: "invalid array index returns an error",
123+
resourceYaml: subaccountCR,
124+
path: "status.conditions[abc].status",
125+
wantValue: "",
126+
wantFound: false,
127+
wantError: true,
128+
},
129+
{
130+
name: "invalid path syntax returns an error",
131+
resourceYaml: subaccountCR,
132+
path: "$.[status.conditions[0].status]",
133+
wantValue: "",
134+
wantFound: false,
135+
wantError: true,
136+
},
137+
}
138+
139+
for _, tt := range tests {
140+
t.Run(tt.name, func(t *testing.T) {
141+
obj := toUnstructured(t, tt.resourceYaml)
142+
value, ok, err := nestedPrimitiveValue(obj, tt.path)
143+
144+
if (err != nil) != tt.wantError {
145+
t.Errorf("unexpected error: got %v, wantErr %v", err, tt.wantError)
146+
}
147+
if ok != tt.wantFound {
148+
t.Errorf("unexpected ok result: got %v, want %v", ok, tt.wantFound)
149+
}
150+
if value != tt.wantValue {
151+
t.Errorf("unexpected value: got %v, want %v", value, tt.wantValue)
152+
}
153+
})
154+
}
155+
}
156+
157+
func toUnstructured(t *testing.T, resourceYaml string) unstructured.Unstructured {
158+
t.Helper()
159+
var object map[string]interface{}
160+
if err := yaml.Unmarshal([]byte(resourceYaml), &object); err != nil {
161+
t.Fatalf("failed to unmarshal YAML: %v", err)
162+
}
163+
return unstructured.Unstructured{Object: object}
164+
}

0 commit comments

Comments
 (0)