Skip to content

Commit d88d3f3

Browse files
authored
feat(kustomizer): enable port list mutation in manifests (#102)
Extends the field mutator Kustomize plugin to handle array indices in field paths (e.g., `spec.ports[0].port`, `spec.containers[0].ports[0].containerPort`). This enables dynamic configuration of array-based fields in Kubernetes resources during the migration from programmatic resource creation to declarative YAML manifests, maintaining existing port override capabilities across Services, Deployments, and NetworkPolicies. Approved-by: VaishnaviHire
1 parent 29a26b3 commit d88d3f3

File tree

2 files changed

+206
-27
lines changed

2 files changed

+206
-27
lines changed

pkg/deploy/plugins/field_mutator.go

Lines changed: 183 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package plugins
22

33
import (
44
"fmt"
5+
"regexp"
6+
"strconv"
57
"strings"
68

79
"sigs.k8s.io/kustomize/api/resmap"
@@ -17,6 +19,7 @@ type FieldMapping struct {
1719
// This provides a fallback mechanism, making transformations more robust.
1820
DefaultValue any `json:"defaultValue,omitempty"`
1921
// TargetField is the dot-notation path to the field in the target object.
22+
// Supports array indices like "spec.ports[0].port"
2023
TargetField string `json:"targetField"`
2124
// TargetKind is the kind of resource to apply the transformation to.
2225
TargetKind string `json:"targetKind"`
@@ -40,6 +43,46 @@ type fieldMutator struct {
4043
config FieldMutatorConfig
4144
}
4245

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+
4386
// isEmpty checks if a value is nil or an empty string, slice, or map.
4487
func isEmpty(v any) bool {
4588
if v == nil {
@@ -90,7 +133,7 @@ func (t *fieldMutator) Config(h *resmap.PluginHelpers, _ []byte) error {
90133
}
91134

92135
// setTargetField modifies the resource by setting the specified value at the
93-
// given dot-notation path.
136+
// given dot-notation path with support for array indices.
94137
func setTargetField(res *resource.Resource, value any, mapping FieldMapping) error {
95138
yamlBytes, err := res.AsYAML()
96139
if err != nil {
@@ -102,35 +145,21 @@ func setTargetField(res *resource.Resource, value any, mapping FieldMapping) err
102145
return fmt.Errorf("failed to unmarshal YAML: %w", unmarshalErr)
103146
}
104147

105-
// This loop navigates to the parent of the target field. We must stop one
106-
// level short because Golang does not allow taking a pointer to a map value
107-
// (e.g., `&myMap["key"]`). To mutate the map, we need a reference to the
108-
// parent container to use the `parent[key] = value` syntax.
109-
//
110-
// This "stop at the parent" approach also gives us the `CreateIfNotExists`
111-
// behavior for free, as we can create missing parent maps during traversal.
112-
fields := strings.Split(mapping.TargetField, ".")
113-
current := data
114-
for _, field := range fields[:len(fields)-1] {
115-
next, ok := current[field]
116-
if !ok {
117-
if !mapping.CreateIfNotExists {
118-
return fmt.Errorf("failed to find field %s", field)
119-
}
120-
next = make(map[string]any)
121-
current[field] = next
122-
}
123-
124-
nextMap, ok := next.(map[string]any)
125-
if !ok {
126-
return fmt.Errorf("failed to convert field %s to map", field)
127-
}
148+
segments, err := parseFieldPath(mapping.TargetField)
149+
if err != nil {
150+
return fmt.Errorf("failed to parse field path %q: %w", mapping.TargetField, err)
151+
}
128152

129-
current = nextMap
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
130157
}
131158

132-
lastField := fields[len(fields)-1]
133-
current[lastField] = value
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)
162+
}
134163

135164
// After modifying the map, we must marshal it back to YAML and create a new
136165
// resource object to ensure the internal state is consistent.
@@ -150,3 +179,130 @@ func setTargetField(res *resource.Resource, value any, mapping FieldMapping) err
150179
res.ResetRNode(newRes)
151180
return nil
152181
}
182+
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
207+
}
208+
return current, nil
209+
}
210+
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)
216+
}
217+
218+
next, exists := currentMap[segment.name]
219+
if !exists {
220+
if !createIfNotExists {
221+
return nil, fmt.Errorf("failed to find field %s", segment.name)
222+
}
223+
224+
if segment.isArray {
225+
next = make([]any, segment.index+1)
226+
} else {
227+
next = make(map[string]any)
228+
}
229+
currentMap[segment.name] = next
230+
}
231+
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)
243+
if err != nil {
244+
return nil, err
245+
}
246+
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
255+
}
256+
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)
272+
}
273+
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
285+
}
286+
287+
return arr, nil
288+
}
289+
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)
294+
}
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
301+
}
302+
arr[segment.index] = value
303+
return nil
304+
}
305+
306+
currentMap[segment.name] = value
307+
return nil
308+
}

pkg/deploy/plugins/field_mutator_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,29 @@ func TestTransform(t *testing.T) {
218218
},
219219
expectError: true,
220220
},
221+
{
222+
name: "array index support integration",
223+
transformer: CreateFieldMutator(FieldMutatorConfig{
224+
Mappings: []FieldMapping{
225+
{TargetKind: "Service", TargetField: "spec.ports[0].port", SourceValue: 8080, CreateIfNotExists: true},
226+
{TargetKind: "Service", TargetField: "spec.ports[0].targetPort", SourceValue: 8080, CreateIfNotExists: true},
227+
},
228+
}),
229+
initialResources: []*resource.Resource{
230+
newTestResource(t, "v1", "Service", "my-service", "", map[string]any{
231+
"ports": []any{
232+
map[string]any{"port": 80, "name": "http"},
233+
},
234+
}),
235+
},
236+
expectedSpecs: map[string]map[string]any{
237+
"my-service": {
238+
"ports": []any{
239+
map[string]any{"port": 8080, "targetPort": 8080, "name": "http"},
240+
},
241+
},
242+
},
243+
},
221244
}
222245

223246
for _, tc := range testCases {

0 commit comments

Comments
 (0)