-
Notifications
You must be signed in to change notification settings - Fork 189
feat(test): add utilities for JSONPath-like access to unstructured Kubernetes objects #519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| // Package test provides utilities for testing unstructured Kubernetes objects. | ||
| // | ||
| // The primary functionality is JSONPath-like field access for unstructured.Unstructured objects, | ||
| // making test assertions more readable and maintainable. | ||
| package test | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||
| ) | ||
|
|
||
| // FieldString retrieves a string field from an unstructured object using JSONPath-like notation. | ||
| // Examples: | ||
| // - "spec.runStrategy" | ||
| // - "spec.template.spec.volumes[0].containerDisk.image" | ||
| // - "spec.dataVolumeTemplates[0].spec.sourceRef.kind" | ||
| func FieldString(obj *unstructured.Unstructured, path string) string { | ||
| if obj == nil { | ||
| return "" | ||
| } | ||
| value, _ := Field(obj.Object, path) | ||
| if str, ok := value.(string); ok { | ||
| return str | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| // FieldExists checks if a field exists at the given JSONPath-like path. | ||
| func FieldExists(obj *unstructured.Unstructured, path string) bool { | ||
| if obj == nil { | ||
| return false | ||
| } | ||
| _, found := Field(obj.Object, path) | ||
| return found | ||
| } | ||
|
|
||
| // FieldInt retrieves an integer field from an unstructured object using JSONPath-like notation. | ||
| // Returns 0 if the field is not found or is not an integer type (int, int64, int32). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @manusa I'm not 100% sure this behaviour is ideal - there could be scenarios where I'm testing and expecting to receive a value of 0 - for example status.replicas. How would we be able to distinguish between the value actually being 0 vs. not being set/not being an int? Or do you think that's fine because this is for tests? In that case I'm fine with it but let's add a note in the docstring about ensuring that you don't assert expecting an actual value to be 0
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For that purpose you would use
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll need to revisit for this case for the expecting to be 0 |
||
| // Examples: | ||
| // - "spec.replicas" | ||
| // - "spec.ports[0].containerPort" | ||
| func FieldInt(obj *unstructured.Unstructured, path string) int64 { | ||
| if obj == nil { | ||
| return 0 | ||
| } | ||
| value, _ := Field(obj.Object, path) | ||
| switch v := value.(type) { | ||
| case int64: | ||
| return v | ||
| case int: | ||
| return int64(v) | ||
| case int32: | ||
| return int64(v) | ||
| default: | ||
| return 0 | ||
| } | ||
| } | ||
|
|
||
| // FieldValue retrieves any field value from an unstructured object using JSONPath-like notation. | ||
| // Returns nil if the field is not found. This is useful when you need the raw value | ||
| // without type conversion. | ||
| // Examples: | ||
| // - "spec.template.spec.containers[0]" - returns map[string]interface{} | ||
| // - "metadata.labels" - returns map[string]interface{} | ||
| func FieldValue(obj *unstructured.Unstructured, path string) interface{} { | ||
| if obj == nil { | ||
| return nil | ||
| } | ||
| value, _ := Field(obj.Object, path) | ||
| return value | ||
| } | ||
|
|
||
| // Field is the core helper that traverses an unstructured object using JSONPath-like notation. | ||
| // It supports both dot notation (foo.bar) and array indexing (foo[0].bar). | ||
| func Field(obj interface{}, path string) (interface{}, bool) { | ||
| if obj == nil || path == "" { | ||
| return nil, false | ||
| } | ||
|
|
||
| // Parse the path into segments | ||
| segments := parsePath(path) | ||
| current := obj | ||
|
|
||
| for _, segment := range segments { | ||
| if segment.isArray { | ||
| // Handle array indexing | ||
| slice, ok := current.([]interface{}) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| if segment.index >= len(slice) || segment.index < 0 { | ||
| return nil, false | ||
| } | ||
| current = slice[segment.index] | ||
| } else { | ||
| // Handle map field access | ||
| m, ok := current.(map[string]interface{}) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| val, exists := m[segment.field] | ||
| if !exists { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @manusa I think you still need a |
||
| return nil, false | ||
| } | ||
| current = val | ||
| } | ||
| } | ||
|
|
||
| return current, true | ||
| } | ||
|
|
||
| type pathSegment struct { | ||
| field string | ||
| isArray bool | ||
| index int | ||
| } | ||
|
|
||
| // parsePath converts a JSONPath-like string into segments. | ||
| // Examples: | ||
| // - "spec.runStrategy" -> [{field: "spec"}, {field: "runStrategy"}] | ||
| // - "spec.volumes[0].name" -> [{field: "spec"}, {field: "volumes"}, {isArray: true, index: 0}, {field: "name"}] | ||
| func parsePath(path string) []pathSegment { | ||
| var segments []pathSegment | ||
| current := "" | ||
| inBracket := false | ||
| indexStr := "" | ||
|
|
||
| for i := 0; i < len(path); i++ { | ||
| ch := path[i] | ||
| switch ch { | ||
| case '.': | ||
| if inBracket { | ||
| indexStr += string(ch) | ||
| } else if current != "" { | ||
| segments = append(segments, pathSegment{field: current}) | ||
| current = "" | ||
| } | ||
| case '[': | ||
| if current != "" { | ||
| segments = append(segments, pathSegment{field: current}) | ||
| current = "" | ||
| } | ||
| inBracket = true | ||
| indexStr = "" | ||
| case ']': | ||
| if inBracket { | ||
| // Parse the index | ||
| var idx int | ||
| if _, err := fmt.Sscanf(indexStr, "%d", &idx); err != nil { | ||
| // If parsing fails, use -1 as invalid index | ||
| idx = -1 | ||
| } | ||
| segments = append(segments, pathSegment{isArray: true, index: idx}) | ||
| inBracket = false | ||
| indexStr = "" | ||
| } | ||
| default: | ||
| if inBracket { | ||
| indexStr += string(ch) | ||
| } else { | ||
| current += string(ch) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if current != "" { | ||
| segments = append(segments, pathSegment{field: current}) | ||
| } | ||
|
|
||
| return segments | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as my comment about
FieldInt- it would be nice to be able to tell the difference between not set and set to""There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For that purpose you would use
test.FieldExists(vm, "spec.instancetype")and expect it to be falsehttps://github.com/marcnuri-forks/kubernetes-mcp-server/blob/8e5f6df7d009ebb7785c12d46faed9e73048be23/pkg/mcp/kubevirt_test.go#L283