diff --git a/README.md b/README.md index f7bb5b9..b668585 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The Metrics Operator is a powerful tool designed to monitor and provide insights - [Managed Metric](#managed-metric) - [Federated Metric](#federated-metric) - [Federated Managed Metric](#federated-managed-metric) + - [Projection Selector Syntax Overview](#projection-selector-syntax-overview) - [Remote Cluster Access](#remote-cluster-access) - [Remote Cluster Access](#remote-cluster-access-1) - [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 ### Metric 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. -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. +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. +Note that a single projection has to select a primitive value, collection type results are not supported. The projections are then translated to dimensions in the metric. ```yaml @@ -298,6 +300,25 @@ spec: --- ``` +### Projection Selector Syntax Overview + +The following examples demonstrate the usage of different [JSONPath selectors](https://www.rfc-editor.org/rfc/rfc9535.html#name-selectors): + +```yaml + projections: + - name: pod-namespace + # name selector: selects the namespace value + fieldPath: "metadata.namespace" + - name: pod-condition-ready-status + # filter selector: selects the status value of the conditions with type='Ready' + fieldPath: "status.conditions[?(@.type=='Ready')].status" + - name: pod-condition-last-transition-time + # index selector: selects the lastTransitionTime value of the first condition + fieldPath: "status.conditions[0].lastTransitionTime" +``` + +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. + ## Remote Cluster Access diff --git a/go.mod b/go.mod index dca7aec..704d2f6 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( k8s.io/client-go v0.33.2 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 sigs.k8s.io/controller-runtime v0.21.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -84,5 +85,4 @@ require ( sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect - sigs.k8s.io/yaml v1.5.0 // indirect ) diff --git a/go.sum b/go.sum index 0315cbb..2c88a88 100644 --- a/go.sum +++ b/go.sum @@ -228,5 +228,5 @@ sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxO sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= -sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= -sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/orchestrator/federatedhandler.go b/internal/orchestrator/federatedhandler.go index 95d6c3e..794c064 100644 --- a/internal/orchestrator/federatedhandler.go +++ b/internal/orchestrator/federatedhandler.go @@ -130,9 +130,7 @@ func (h *FederatedHandler) extractProjectionGroupsFrom(list *unstructured.Unstru if projection.Name != "" && projection.FieldPath != "" { name := projection.Name - fieldPath := projection.FieldPath - fields := strings.Split(fieldPath, ".") - value, found, err := unstructured.NestedString(obj.Object, fields...) + value, found, err := nestedPrimitiveValue(obj, projection.FieldPath) collection = append(collection, projectedField{name: name, value: value, found: found, error: err}) } } diff --git a/internal/orchestrator/metrichandler.go b/internal/orchestrator/metrichandler.go index a63e21d..20784bc 100644 --- a/internal/orchestrator/metrichandler.go +++ b/internal/orchestrator/metrichandler.go @@ -170,9 +170,7 @@ func (h *MetricHandler) extractProjectionGroupsFrom(list *unstructured.Unstructu if projection.Name != "" && projection.FieldPath != "" { name := projection.Name - fieldPath := projection.FieldPath - fields := strings.Split(fieldPath, ".") - value, found, err := unstructured.NestedString(obj.Object, fields...) + value, found, err := nestedPrimitiveValue(obj, projection.FieldPath) collection = append(collection, projectedField{name: name, value: value, found: found, error: err}) } } diff --git a/internal/orchestrator/projectionhelper.go b/internal/orchestrator/projectionhelper.go new file mode 100644 index 0000000..fb862a2 --- /dev/null +++ b/internal/orchestrator/projectionhelper.go @@ -0,0 +1,40 @@ +package orchestrator + +import ( + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/util/jsonpath" +) + +// nestedPrimitiveValue returns a string value based on the result of the client-go JSONPath parser. +// Returns false if the value is not found. +// Returns an error if the value is ambiguous or a collection type. +// Returns an error if the given path can't be parsed. +// +// String conversion of non-string primitives relies on the default format when printing the value. +// The input path is expected to be passed in dot-notation without brackets or a leading dot. +// The implementation is based on similar internal client-go jsonpath usages, like kubectl +func nestedPrimitiveValue(obj unstructured.Unstructured, path string) (string, bool, error) { + jp := jsonpath.New("projection").AllowMissingKeys(true) + if err := jp.Parse(fmt.Sprintf("{.%s}", path)); err != nil { + return "", false, fmt.Errorf("failed to parse path: %v", err) + } + results, err := jp.FindResults(obj.UnstructuredContent()) + if err != nil { + return "", false, fmt.Errorf("failed to find results: %v", err) + } + if len(results) == 0 || len(results[0]) == 0 { + return "", false, nil + } + if len(results) > 1 || len(results[0]) > 1 { + return "", true, errors.New("fieldPath matches more than one value which is not supported") + } + value := results[0][0] + switch value.Interface().(type) { + case map[string]interface{}, []interface{}: + return "", true, errors.New("fieldPath results in collection type which is not supported") + } + return fmt.Sprintf("%v", value.Interface()), true, nil +} diff --git a/internal/orchestrator/projectionhelper_test.go b/internal/orchestrator/projectionhelper_test.go new file mode 100644 index 0000000..4a9c9d6 --- /dev/null +++ b/internal/orchestrator/projectionhelper_test.go @@ -0,0 +1,164 @@ +package orchestrator + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +const subaccountCR = ` +apiVersion: account.btp.sap.crossplane.io/v1alpha1 +kind: Subaccount +metadata: + annotations: + crossplane.io/external-name: test-subaccount + name: test-subaccount +spec: + deletionPolicy: Delete +status: + conditions: + - lastTransitionTime: "2025-09-12T15:57:41Z" + observedGeneration: 1 + reason: ReconcileSuccess + status: "True" + type: Synced + - lastTransitionTime: "2025-09-09T14:33:38Z" + reason: Available + status: "True" + type: Ready +` + +func TestNestedPrimitiveValue(t *testing.T) { + tests := []struct { + name string + resourceYaml string + path string + wantValue string + wantFound bool + wantError bool + }{ + { + name: "top level value retrieval", + resourceYaml: subaccountCR, + path: "kind", + wantValue: "Subaccount", + wantFound: true, + wantError: false, + }, + { + name: "nested value retrieval with name selector", + resourceYaml: subaccountCR, + path: "spec.deletionPolicy", + wantValue: "Delete", + wantFound: true, + wantError: false, + }, + { + name: "nested value retrieval with escaped name selector", + resourceYaml: subaccountCR, + path: "metadata.annotations.crossplane\\.io/external-name", + wantValue: "test-subaccount", + wantFound: true, + wantError: false, + }, + { + name: "nested value retrieval with index selector", + resourceYaml: subaccountCR, + path: "status.conditions[1].status", + wantValue: "True", + wantFound: true, + wantError: false, + }, + { + name: "nested value retrieval with filter selector", + resourceYaml: subaccountCR, + path: "status.conditions[?(@.type=='Ready')].status", + wantValue: "True", + wantFound: true, + wantError: false, + }, + { + name: "nested value retrieval with array slice selector", + resourceYaml: subaccountCR, + path: "status.conditions[0:1].status", + wantValue: "True", + wantFound: true, + wantError: false, + }, + { + name: "nested value retrieval with wildcard selector; collection results are not supported", + resourceYaml: subaccountCR, + path: "status.conditions[*].status", + wantValue: "", + wantFound: true, + wantError: true, + }, + { + name: "non-existent value", + resourceYaml: subaccountCR, + path: "metadata.labels.app", + wantValue: "", + wantFound: false, + wantError: false, + }, + { + name: "nested non-string value retrieval with default print format", + resourceYaml: subaccountCR, + path: "status.conditions[0].observedGeneration", + wantValue: "1", + wantFound: true, + wantError: false, + }, + { + name: "retrieval of collection types is not supported", + resourceYaml: subaccountCR, + path: "status.conditions[0]", + wantValue: "", + wantFound: true, + wantError: true, + }, + { + name: "invalid array index returns an error", + resourceYaml: subaccountCR, + path: "status.conditions[abc].status", + wantValue: "", + wantFound: false, + wantError: true, + }, + { + name: "invalid path syntax returns an error", + resourceYaml: subaccountCR, + path: "$.[status.conditions[0].status]", + wantValue: "", + wantFound: false, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := toUnstructured(t, tt.resourceYaml) + value, ok, err := nestedPrimitiveValue(obj, tt.path) + + if (err != nil) != tt.wantError { + t.Errorf("unexpected error: got %v, wantErr %v", err, tt.wantError) + } + if ok != tt.wantFound { + t.Errorf("unexpected ok result: got %v, want %v", ok, tt.wantFound) + } + if value != tt.wantValue { + t.Errorf("unexpected value: got %v, want %v", value, tt.wantValue) + } + }) + } +} + +func toUnstructured(t *testing.T, resourceYaml string) unstructured.Unstructured { + t.Helper() + var object map[string]interface{} + if err := yaml.Unmarshal([]byte(resourceYaml), &object); err != nil { + t.Fatalf("failed to unmarshal YAML: %v", err) + } + return unstructured.Unstructured{Object: object} +}