Skip to content

Commit e42489a

Browse files
authored
refactor(field_mutator): migrate dot notation to JSON Pointer syntax (#118)
Replaces custom dot notation parser with [RFC 6901 JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) syntax to handle Kubernetes label keys containing dots (e.g., `app.kubernetes.io/instance`). Migration enables proper Service selector transformations where previous dot notation failed on keys like `spec.selector["app.kubernetes.io/instance"]`, unblocking dynamic Service-to-Deployment label matching. Approved-by: leseb Approved-by: rhdedgar
1 parent 5484f16 commit e42489a

File tree

3 files changed

+115
-189
lines changed

3 files changed

+115
-189
lines changed

pkg/deploy/kustomizer.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,19 +211,19 @@ func applyPlugins(resMap *resmap.ResMap, ownerInstance *llamav1alpha1.LlamaStack
211211
{
212212
SourceValue: getStorageSize(ownerInstance),
213213
DefaultValue: llamav1alpha1.DefaultStorageSize.String(),
214-
TargetField: "spec.resources.requests.storage",
214+
TargetField: "/spec/resources/requests/storage",
215215
TargetKind: "PersistentVolumeClaim",
216216
CreateIfNotExists: true,
217217
},
218218
{
219219
SourceValue: ownerInstance.GetNamespace(),
220-
TargetField: "subjects[0].namespace",
220+
TargetField: "/subjects/0/namespace",
221221
TargetKind: "ClusterRoleBinding",
222222
CreateIfNotExists: true,
223223
},
224224
{
225225
SourceValue: ownerInstance.GetName() + "-sa",
226-
TargetField: "subjects[0].name",
226+
TargetField: "/subjects/0/name",
227227
TargetKind: "ClusterRoleBinding",
228228
CreateIfNotExists: true,
229229
},

pkg/deploy/plugins/field_mutator.go

Lines changed: 74 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ package plugins
22

33
import (
44
"fmt"
5-
"regexp"
6-
"strconv"
75
"strings"
86

7+
"github.com/go-openapi/jsonpointer"
98
"sigs.k8s.io/kustomize/api/resmap"
109
"sigs.k8s.io/kustomize/api/resource"
1110
"sigs.k8s.io/yaml"
@@ -18,8 +17,10 @@ type FieldMapping struct {
1817
// DefaultValue is the value to use if SourceValue is empty.
1918
// This provides a fallback mechanism, making transformations more robust.
2019
DefaultValue any `json:"defaultValue,omitempty"`
21-
// TargetField is the dot-notation path to the field in the target object.
22-
// Supports array indices like "spec.ports[0].port"
20+
// TargetField is the JSON Pointer path to the field in the target object.
21+
// Uses RFC 6901 JSON Pointer syntax like "/spec/ports/0/port"
22+
// Special characters in field names are escaped: ~0 for ~ and ~1 for /
23+
// Example: "/spec/selector/app.kubernetes.io~1instance"
2324
TargetField string `json:"targetField"`
2425
// TargetKind is the kind of resource to apply the transformation to.
2526
TargetKind string `json:"targetKind"`
@@ -43,46 +44,6 @@ type fieldMutator struct {
4344
config FieldMutatorConfig
4445
}
4546

46-
type fieldSegment struct {
47-
name string // field name (e.g., "ports")
48-
isArray bool // true if this segment has an array index
49-
index int // array index if isArray is true
50-
original string // original segment string for error messages
51-
}
52-
53-
func parseFieldPath(path string) ([]fieldSegment, error) {
54-
// Regular expression to match field names with optional array indices
55-
// Matches: "fieldname" or "fieldname[123]"
56-
re := regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]$`)
57-
58-
parts := strings.Split(path, ".")
59-
segments := make([]fieldSegment, len(parts))
60-
61-
for i, part := range parts {
62-
if matches := re.FindStringSubmatch(part); matches != nil {
63-
index, err := strconv.Atoi(matches[2])
64-
if err != nil {
65-
return nil, fmt.Errorf("failed to parse array index in field path segment %q: %w", part, err)
66-
}
67-
segments[i] = fieldSegment{
68-
name: matches[1],
69-
isArray: true,
70-
index: index,
71-
original: part,
72-
}
73-
} else {
74-
segments[i] = fieldSegment{
75-
name: part,
76-
isArray: false,
77-
index: -1,
78-
original: part,
79-
}
80-
}
81-
}
82-
83-
return segments, nil
84-
}
85-
8647
// isEmpty checks if a value is nil or an empty string, slice, or map.
8748
func isEmpty(v any) bool {
8849
if v == nil {
@@ -133,176 +94,116 @@ func (t *fieldMutator) Config(h *resmap.PluginHelpers, _ []byte) error {
13394
}
13495

13596
// setTargetField modifies the resource by setting the specified value at the
136-
// given dot-notation path with support for array indices.
97+
// given JSON Pointer path.
13798
func setTargetField(res *resource.Resource, value any, mapping FieldMapping) error {
13899
yamlBytes, err := res.AsYAML()
139100
if err != nil {
140101
return fmt.Errorf("failed to get YAML: %w", err)
141102
}
142103

143-
var data map[string]any
104+
var data any
144105
if unmarshalErr := yaml.Unmarshal(yamlBytes, &data); unmarshalErr != nil {
145106
return fmt.Errorf("failed to unmarshal YAML: %w", unmarshalErr)
146107
}
147108

148-
segments, err := parseFieldPath(mapping.TargetField)
109+
ptr, err := jsonpointer.New(mapping.TargetField)
149110
if err != nil {
150-
return fmt.Errorf("failed to parse field path %q: %w", mapping.TargetField, err)
111+
return fmt.Errorf("failed to parse JSON Pointer path %q: %w", mapping.TargetField, err)
151112
}
152113

153-
// Navigate to parent first, then set value - arrays vs maps need different handling.
154-
current, err := navigateToParent(data, segments, mapping.CreateIfNotExists)
155-
if err != nil {
156-
return err
157-
}
158-
159-
lastSegment := segments[len(segments)-1]
160-
if setErr := setFieldValue(current, lastSegment, value, mapping.CreateIfNotExists); setErr != nil {
161-
return fmt.Errorf("failed to set field %q: %w", lastSegment.original, setErr)
114+
var updatedData any
115+
if mapping.CreateIfNotExists {
116+
updatedData, err = setWithPathCreation(data, ptr, value)
117+
} else {
118+
updatedData, err = ptr.Set(data, value)
162119
}
163120

164-
// After modifying the map, we must marshal it back to YAML and create a new
165-
// resource object to ensure the internal state is consistent.
166-
updatedYAML, err := yaml.Marshal(data)
167-
if err != nil {
168-
return fmt.Errorf("failed to marshal updated YAML: %w", err)
169-
}
170-
171-
rf := resource.NewFactory(nil)
172-
newRes, err := rf.FromBytes(updatedYAML)
173121
if err != nil {
174-
return fmt.Errorf("failed to create resource from updated YAML: %w", err)
122+
return fmt.Errorf("failed to set field at path %q: %w", mapping.TargetField, err)
175123
}
176124

177-
// Atomically replace the old resource content with the new, fully updated content
178-
// to prevent partial updates or data loss.
179-
res.ResetRNode(newRes)
180-
return nil
125+
return updateResource(res, updatedData)
181126
}
182127

183-
// navigateToParent walks through all field segments except the last one,
184-
// returning the parent container where the final field should be set.
185-
func navigateToParent(data map[string]any, segments []fieldSegment, createIfNotExists bool) (map[string]any, error) {
186-
current := data
187-
// This loop navigates to the parent of the target field. We must stop one
188-
// level short because Golang does not allow taking a pointer to a map value
189-
// (e.g., `&myMap["key"]`). To mutate the map, we need a reference to the
190-
// parent container to use the `parent[key] = value` syntax.
191-
//
192-
// This "stop at the parent" approach also gives us the `CreateIfNotExists`
193-
// behavior for free, as we can create missing parent maps during traversal.
194-
for _, segment := range segments[:len(segments)-1] {
195-
next, navErr := navigateToField(current, segment, createIfNotExists)
196-
if navErr != nil {
197-
if strings.Contains(navErr.Error(), "failed to find field") {
198-
return nil, navErr
199-
}
200-
return nil, fmt.Errorf("failed to navigate to field %q: %w", segment.original, navErr)
201-
}
202-
currentMap, ok := next.(map[string]any)
203-
if !ok {
204-
return nil, fmt.Errorf("failed to convert field %s to map", segment.name)
205-
}
206-
current = currentMap
128+
func setWithPathCreation(data any, ptr jsonpointer.Pointer, value any) (any, error) {
129+
// try setting value if the path already exists
130+
if updatedData, err := ptr.Set(data, value); err == nil {
131+
return updatedData, nil
207132
}
208-
return current, nil
209-
}
210133

211-
// navigateToField returns the value at the specified field, creating it if needed.
212-
func navigateToField(current any, segment fieldSegment, createIfNotExists bool) (any, error) {
213-
currentMap, ok := current.(map[string]any)
214-
if !ok {
215-
return nil, fmt.Errorf("failed to convert current value to map[string]any, got %T", current)
134+
// otherwise, we need to create the path first
135+
tokens := ptr.DecodedTokens()
136+
if len(tokens) == 0 {
137+
return value, nil
216138
}
139+
result := data
217140

218-
next, exists := currentMap[segment.name]
219-
if !exists {
220-
if !createIfNotExists {
221-
return nil, fmt.Errorf("failed to find field %s", segment.name)
141+
// JSON Pointer Set() fails if intermediate paths don't exist, so we must
142+
// build the path incrementally from root to target, creating missing containers
143+
for i := 1; i <= len(tokens)-1; i++ {
144+
partialPath := "/" + strings.Join(tokens[:i], "/")
145+
partialPtr, err := jsonpointer.New(partialPath)
146+
if err != nil {
147+
return nil, fmt.Errorf("failed to create partial pointer: %w", err)
222148
}
149+
// Get() is used as existence test - error means path doesn't exist and needs creation
150+
_, _, err = partialPtr.Get(result)
151+
if err != nil {
152+
nextToken := tokens[i]
153+
var newContainer any
154+
// create array if next token is numeric (e.g., "/ports/0")
155+
if isNumericString(nextToken) {
156+
newContainer = make([]any, 0)
157+
} else {
158+
// create map otherwise (e.g., "/spec/strategy")
159+
newContainer = make(map[string]any)
160+
}
223161

224-
if segment.isArray {
225-
next = make([]any, segment.index+1)
226-
} else {
227-
next = make(map[string]any)
162+
// create the missing path segment
163+
result, err = partialPtr.Set(result, newContainer)
164+
if err != nil {
165+
return nil, fmt.Errorf("failed to create intermediate path %q: %w", partialPath, err)
166+
}
228167
}
229-
currentMap[segment.name] = next
230168
}
231169

232-
if segment.isArray {
233-
return handleArrayAccess(currentMap, segment, next, createIfNotExists)
234-
}
235-
236-
return next, nil
237-
}
238-
239-
// handleArrayAccess navigates to a specific array index, expanding the array and
240-
// creating missing elements if needed. Returns the element at the specified index.
241-
func handleArrayAccess(currentMap map[string]any, segment fieldSegment, next any, createIfNotExists bool) (any, error) {
242-
arr, err := ensureArrayWithCapacity(currentMap, segment, next, createIfNotExists)
170+
result, err := ptr.Set(result, value)
243171
if err != nil {
244-
return nil, err
172+
return nil, fmt.Errorf("failed to set final value: %w", err)
245173
}
246174

247-
if arr[segment.index] == nil {
248-
if !createIfNotExists {
249-
return nil, fmt.Errorf("failed to access array element at index %d for field %q", segment.index, segment.name)
250-
}
251-
arr[segment.index] = make(map[string]any)
252-
}
253-
254-
return arr[segment.index], nil
175+
return result, nil
255176
}
256177

257-
// ensureArrayWithCapacity ensures an array exists at the specified field with sufficient capacity.
258-
// If the array doesn't exist or is too small, it creates or expands it as needed.
259-
func ensureArrayWithCapacity(currentMap map[string]any, segment fieldSegment, field any, createIfNotExists bool) ([]any, error) {
260-
if field == nil {
261-
if !createIfNotExists {
262-
return nil, fmt.Errorf("failed to find array field %q", segment.name)
263-
}
264-
arr := make([]any, segment.index+1)
265-
currentMap[segment.name] = arr
266-
return arr, nil
267-
}
268-
269-
arr, ok := field.([]any)
270-
if !ok {
271-
return nil, fmt.Errorf("failed to convert field %q to array, got %T", segment.name, field)
178+
func updateResource(res *resource.Resource, updatedData any) error {
179+
// After modifying the map, we must marshal it back to YAML and create a new
180+
// resource object to ensure the internal state is consistent.
181+
updatedYAML, err := yaml.Marshal(updatedData)
182+
if err != nil {
183+
return fmt.Errorf("failed to marshal updated YAML: %w", err)
272184
}
273185

274-
if segment.index >= len(arr) {
275-
if !createIfNotExists {
276-
return nil, fmt.Errorf("failed to access array index %d for field %q (length: %d)", segment.index, segment.name, len(arr))
277-
}
278-
newArr := make([]any, segment.index+1)
279-
copy(newArr, arr)
280-
for i := len(arr); i <= segment.index; i++ {
281-
newArr[i] = make(map[string]any)
282-
}
283-
currentMap[segment.name] = newArr
284-
return newArr, nil
186+
rf := resource.NewFactory(nil)
187+
newRes, err := rf.FromBytes(updatedYAML)
188+
if err != nil {
189+
return fmt.Errorf("failed to create resource from updated YAML: %w", err)
285190
}
286191

287-
return arr, nil
192+
// Atomically replace the old resource content with the new, fully updated content
193+
// to prevent partial updates or data loss.
194+
res.ResetRNode(newRes)
195+
return nil
288196
}
289197

290-
func setFieldValue(current any, segment fieldSegment, value any, createIfNotExists bool) error {
291-
currentMap, ok := current.(map[string]any)
292-
if !ok {
293-
return fmt.Errorf("failed to convert current value to map[string]any, got %T", current)
198+
// isNumericString checks if a string represents a valid non-negative integer.
199+
func isNumericString(s string) bool {
200+
if s == "" {
201+
return false
294202
}
295-
296-
if segment.isArray {
297-
field := currentMap[segment.name]
298-
arr, err := ensureArrayWithCapacity(currentMap, segment, field, createIfNotExists)
299-
if err != nil {
300-
return err
203+
for _, r := range s {
204+
if r < '0' || r > '9' {
205+
return false
301206
}
302-
arr[segment.index] = value
303-
return nil
304207
}
305-
306-
currentMap[segment.name] = value
307-
return nil
208+
return true
308209
}

0 commit comments

Comments
 (0)