mirror of
https://github.com/kubernetes-sigs/kustomize.git
synced 2026-05-17 10:15:22 +00:00
implements nested structure replacements
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
package replacement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -188,6 +189,14 @@ func copyValueToTarget(target *yaml.RNode, value *yaml.RNode, selector *types.Ta
|
||||
if selector.Options != nil && selector.Options.Create {
|
||||
createKind = value.YNode().Kind
|
||||
}
|
||||
|
||||
// Check if this fieldPath contains structured data access
|
||||
if err := setValueInStructuredData(target, value, fp, createKind); err == nil {
|
||||
// Successfully handled as structured data
|
||||
continue
|
||||
}
|
||||
|
||||
// Fall back to normal path handling
|
||||
targetFieldList, err := target.Pipe(&yaml.PathMatcher{
|
||||
Path: kyaml_utils.SmarterPathSplitter(fp, "."),
|
||||
Create: createKind})
|
||||
@@ -204,7 +213,7 @@ func copyValueToTarget(target *yaml.RNode, value *yaml.RNode, selector *types.Ta
|
||||
|
||||
for _, t := range targetFields {
|
||||
if err := setFieldValue(selector.Options, t, value); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,3 +256,146 @@ func setFieldValue(options *types.FieldOptions, targetField *yaml.RNode, value *
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setValueInStructuredData handles setting values within structured data (JSON/YAML) in scalar fields
|
||||
func setValueInStructuredData(target *yaml.RNode, value *yaml.RNode, fieldPath string, createKind yaml.Kind) error {
|
||||
pathParts := kyaml_utils.SmarterPathSplitter(fieldPath, ".")
|
||||
if len(pathParts) < 2 {
|
||||
return fmt.Errorf("not a structured data path")
|
||||
}
|
||||
|
||||
// Find the potential scalar field that might contain structured data
|
||||
var scalarFieldPath []string
|
||||
var structuredDataPath []string
|
||||
var foundScalar = false
|
||||
|
||||
// Try to find where the scalar field ends and structured data begins
|
||||
for i := 1; i <= len(pathParts); i++ {
|
||||
potentialScalarPath := pathParts[:i]
|
||||
scalarField, err := target.Pipe(yaml.Lookup(potentialScalarPath...))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if scalarField != nil && scalarField.YNode().Kind == yaml.ScalarNode && i < len(pathParts) {
|
||||
// Try to parse the scalar value as structured data
|
||||
scalarValue := scalarField.YNode().Value
|
||||
var parsedNode yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(scalarValue), &parsedNode); err == nil {
|
||||
// Successfully parsed - this is structured data
|
||||
scalarFieldPath = potentialScalarPath
|
||||
structuredDataPath = pathParts[i:]
|
||||
foundScalar = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundScalar {
|
||||
return fmt.Errorf("no structured data found in path")
|
||||
}
|
||||
|
||||
// Get the scalar field containing structured data
|
||||
scalarField, err := target.Pipe(yaml.Lookup(scalarFieldPath...))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
// Parse the structured data
|
||||
scalarValue := scalarField.YNode().Value
|
||||
var parsedNode yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(scalarValue), &parsedNode); err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
structuredData := yaml.NewRNode(&parsedNode)
|
||||
|
||||
// Navigate to the target location within the structured data
|
||||
targetInStructured, err := structuredData.Pipe(&yaml.PathMatcher{
|
||||
Path: structuredDataPath,
|
||||
Create: createKind,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
targetFields, err := targetInStructured.Elements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
if len(targetFields) == 0 {
|
||||
return fmt.Errorf("unable to find field in structured data")
|
||||
}
|
||||
|
||||
// Set the value in the structured data
|
||||
for _, t := range targetFields {
|
||||
if t.YNode().Kind == yaml.ScalarNode {
|
||||
t.YNode().Value = value.YNode().Value
|
||||
} else {
|
||||
t.SetYNode(value.YNode())
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the modified structured data back to the scalar field
|
||||
// Try to detect if original was JSON or YAML and preserve formatting
|
||||
serializedData, err := serializeStructuredData(structuredData, scalarValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
// Update the original scalar field
|
||||
scalarField.YNode().Value = serializedData
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// serializeStructuredData handles the serialization of structured data back to string format
|
||||
// preserving the original format (JSON vs YAML) and style (pretty vs compact)
|
||||
func serializeStructuredData(structuredData *yaml.RNode, originalValue string) (string, error) {
|
||||
firstChar := rune(strings.TrimSpace(originalValue)[0])
|
||||
if firstChar == '{' || firstChar == '[' {
|
||||
return serializeAsJSON(structuredData, originalValue)
|
||||
}
|
||||
|
||||
// Fallback to YAML format
|
||||
return serializeAsYAML(structuredData)
|
||||
}
|
||||
|
||||
// serializeAsJSON converts structured data back to JSON format
|
||||
func serializeAsJSON(structuredData *yaml.RNode, originalValue string) (string, error) {
|
||||
modifiedData, err := structuredData.String()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize structured data: %w", err)
|
||||
}
|
||||
|
||||
// Parse the YAML output as JSON
|
||||
var jsonData interface{}
|
||||
if err := yaml.Unmarshal([]byte(modifiedData), &jsonData); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal YAML data: %w", err)
|
||||
}
|
||||
|
||||
// Check if original was pretty-printed by looking for newlines and indentation
|
||||
if strings.Contains(originalValue, "\n") && strings.Contains(originalValue, " ") {
|
||||
// Pretty-print the JSON to match original formatting
|
||||
if prettyJSON, err := json.MarshalIndent(jsonData, "", " "); err == nil {
|
||||
return string(prettyJSON), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Compact JSON
|
||||
if compactJSON, err := json.Marshal(jsonData); err == nil {
|
||||
return string(compactJSON), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to marshal JSON data")
|
||||
}
|
||||
|
||||
// serializeAsYAML converts structured data back to YAML format
|
||||
func serializeAsYAML(structuredData *yaml.RNode) (string, error) {
|
||||
modifiedData, err := structuredData.String()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize YAML data: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(modifiedData), nil
|
||||
}
|
||||
|
||||
@@ -4539,31 +4539,489 @@ data:
|
||||
config.json: |-
|
||||
{
|
||||
"config": {
|
||||
"id": "42",
|
||||
"hostname": "target-configmap"
|
||||
"hostname": "target-configmap",
|
||||
"id": "42"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
"replacement json field with source from separate configmap": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-configmap
|
||||
data:
|
||||
HOSTNAME: www.example.com
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-configmap
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"config": {
|
||||
"id": "42",
|
||||
"hostname": "REPLACE_TARGET_HOSTNAME"
|
||||
}
|
||||
}
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-configmap
|
||||
fieldPath: data.HOSTNAME
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: target-configmap
|
||||
fieldPaths:
|
||||
- data.config\.json.config.hostname
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-configmap
|
||||
data:
|
||||
HOSTNAME: www.example.com
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-configmap
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"config": {
|
||||
"hostname": "www.example.com",
|
||||
"id": "42"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
"replacement yaml field in configmap": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: environment-config
|
||||
data:
|
||||
env: dev
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: prometheus-config
|
||||
data:
|
||||
prometheus.yml: |-
|
||||
global:
|
||||
external_labels:
|
||||
prometheus_env: TARGET_ENVIRONMENT
|
||||
scrape_configs:
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: environment-config
|
||||
fieldPath: data.env
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: prometheus-config
|
||||
fieldPaths:
|
||||
- data.prometheus\.yml.global.external_labels.prometheus_env
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: environment-config
|
||||
data:
|
||||
env: dev
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: prometheus-config
|
||||
data:
|
||||
prometheus.yml: |-
|
||||
global:
|
||||
external_labels:
|
||||
prometheus_env: dev
|
||||
scrape_configs:
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]`,
|
||||
},
|
||||
"replacement json field in annotations": {
|
||||
input: `apiVersion: cloud.google.com/v1
|
||||
kind: BackendConfig
|
||||
metadata:
|
||||
name: debug-backend-config
|
||||
spec:
|
||||
securityPolicy:
|
||||
name: "debug-security-policy"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: appA-svc
|
||||
annotations:
|
||||
cloud.google.com/backend-config: '{"ports": {"appA":"gke-default-backend-config"}}'
|
||||
spec:
|
||||
ports:
|
||||
- name: appA
|
||||
port: 1234
|
||||
protocol: TCP
|
||||
targetPort: 8080
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: BackendConfig
|
||||
name: debug-backend-config
|
||||
fieldPath: metadata.name
|
||||
targets:
|
||||
- select:
|
||||
kind: Service
|
||||
name: appA-svc
|
||||
fieldPaths:
|
||||
- metadata.annotations.cloud\.google\.com/backend-config.ports.appA
|
||||
`,
|
||||
expected: `apiVersion: cloud.google.com/v1
|
||||
kind: BackendConfig
|
||||
metadata:
|
||||
name: debug-backend-config
|
||||
spec:
|
||||
securityPolicy:
|
||||
name: "debug-security-policy"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: appA-svc
|
||||
annotations:
|
||||
cloud.google.com/backend-config: '{"ports":{"appA":"debug-backend-config"}}'
|
||||
spec:
|
||||
ports:
|
||||
- name: appA
|
||||
port: 1234
|
||||
protocol: TCP
|
||||
targetPort: 8080`,
|
||||
},
|
||||
"replacement yaml nested value": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
replicas: "3"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: deployment-template
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:latest
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: app-config
|
||||
fieldPath: data.replicas
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: deployment-template
|
||||
fieldPaths:
|
||||
- data.deployment\.yaml.spec.replicas
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
replicas: "3"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: deployment-template
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:latest`,
|
||||
},
|
||||
"replacement json complex nested structure": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-config
|
||||
data:
|
||||
database_host: prod-db.example.com
|
||||
database_port: "5432"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"database": {
|
||||
"connections": {
|
||||
"primary": {
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"ssl": true
|
||||
},
|
||||
"secondary": {
|
||||
"host": "backup-host",
|
||||
"port": 3307
|
||||
}
|
||||
},
|
||||
"pool": {
|
||||
"min": 5,
|
||||
"max": 10
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-config
|
||||
fieldPath: data.database_host
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: app-config
|
||||
fieldPaths:
|
||||
- data.config\.json.database.connections.primary.host
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-config
|
||||
fieldPath: data.database_port
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: app-config
|
||||
fieldPaths:
|
||||
- data.config\.json.database.connections.primary.port
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-config
|
||||
data:
|
||||
database_host: prod-db.example.com
|
||||
database_port: "5432"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"database": {
|
||||
"connections": {
|
||||
"primary": {
|
||||
"host": "prod-db.example.com",
|
||||
"port": 5432,
|
||||
"ssl": true
|
||||
},
|
||||
"secondary": {
|
||||
"host": "backup-host",
|
||||
"port": 3307
|
||||
}
|
||||
},
|
||||
"pool": {
|
||||
"max": 10,
|
||||
"min": 5
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"level": "info"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
"replacement yaml array element": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: image-config
|
||||
data:
|
||||
new_image: nginx:1.20
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: k8s-manifest
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-app
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: nginx:1.18
|
||||
- name: sidecar
|
||||
image: busybox:latest
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: image-config
|
||||
fieldPath: data.new_image
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: k8s-manifest
|
||||
fieldPaths:
|
||||
- data.deployment\.yaml.spec.template.spec.containers.[name=web].image
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: image-config
|
||||
data:
|
||||
new_image: nginx:1.20
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: k8s-manifest
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-app
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: nginx:1.20
|
||||
- name: sidecar
|
||||
image: busybox:latest`,
|
||||
},
|
||||
"replacement yaml flow style (kyaml) field": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-values
|
||||
data:
|
||||
app_name: "my-awesome-app"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-config
|
||||
data:
|
||||
config.yaml: |-
|
||||
labels: {
|
||||
app: "REPLACE_APP_NAME",
|
||||
version: "1.0.0",
|
||||
env: "production",
|
||||
}
|
||||
spec: {
|
||||
replicas: 3,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: "REPLACE_APP_NAME"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-values
|
||||
fieldPath: data.app_name
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: target-config
|
||||
fieldPaths:
|
||||
- data.config\.yaml.labels.app
|
||||
- data.config\.yaml.spec.selector.matchLabels.app
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-values
|
||||
data:
|
||||
app_name: "my-awesome-app"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-config
|
||||
data:
|
||||
config.yaml: |-
|
||||
labels: {app: "my-awesome-app", version: "1.0.0", env: "production"}
|
||||
spec: {replicas: 3, selector: {matchLabels: {app: "my-awesome-app"}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
for tn := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
// Test one case to see if structured data replacement works
|
||||
f := Filter{}
|
||||
err := yaml.Unmarshal([]byte(tc.replacements), &f)
|
||||
err := yaml.Unmarshal([]byte(testCases[tn].replacements), &f)
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
actual, err := filtertest.RunFilterE(t, tc.input, f)
|
||||
actual, err := filtertest.RunFilterE(t, testCases[tn].input, f)
|
||||
if err != nil {
|
||||
if tc.expectedErr == "" {
|
||||
if testCases[tn].expectedErr == "" {
|
||||
t.Errorf("unexpected error: %s\n", err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
if !assert.Contains(t, err.Error(), tc.expectedErr) {
|
||||
if !assert.Contains(t, err.Error(), testCases[tn].expectedErr) {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
if !assert.Equal(t, strings.TrimSpace(tc.expected), strings.TrimSpace(actual)) {
|
||||
if !assert.Equal(t, strings.TrimSpace(testCases[tn].expected), strings.TrimSpace(actual)) {
|
||||
t.FailNow()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -164,6 +164,11 @@ func (p *PathMatcher) doField(rn *RNode) (*RNode, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the field is a scalar and there are remaining path segments
|
||||
if field != nil && field.YNode().Kind == yaml.ScalarNode && len(p.Path) > 1 {
|
||||
return p.handleStructuredDataInScalar(field)
|
||||
}
|
||||
|
||||
// recurse on the field, removing the first element of the path
|
||||
pm := &PathMatcher{Path: p.Path[1:], Create: p.Create}
|
||||
p.val, err = pm.filter(field)
|
||||
@@ -263,12 +268,12 @@ func (p *PathMatcher) doSeq(rn *RNode) (*RNode, error) {
|
||||
func (p *PathMatcher) visitPrimitiveElem(elem *RNode) error {
|
||||
r, err := regexp.Compile(p.matchRegex)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
str, err := elem.String()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
str = strings.TrimSpace(str)
|
||||
if !r.MatchString(str) {
|
||||
@@ -282,7 +287,7 @@ func (p *PathMatcher) visitPrimitiveElem(elem *RNode) error {
|
||||
func (p *PathMatcher) visitElem(elem *RNode) error {
|
||||
r, err := regexp.Compile(p.matchRegex)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
// check if this elements field matches the regex
|
||||
@@ -292,7 +297,7 @@ func (p *PathMatcher) visitElem(elem *RNode) error {
|
||||
}
|
||||
str, err := val.Value.String()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
str = strings.TrimSpace(str)
|
||||
if !r.MatchString(str) {
|
||||
@@ -341,3 +346,69 @@ func cleanPath(path []string) []string {
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// structuredDataNode is a wrapper around RNode that handles serialization back to scalar fields
|
||||
// when the underlying structured data is modified
|
||||
type structuredDataNode struct {
|
||||
RNode
|
||||
structuredRoot *RNode // The root of the structured data
|
||||
scalarField *RNode // The original scalar field containing the structured data
|
||||
}
|
||||
|
||||
// Override SetYNode to handle structured data serialization
|
||||
func (s *structuredDataNode) SetYNode(node *yaml.Node) {
|
||||
// Call the original SetYNode
|
||||
s.RNode.SetYNode(node)
|
||||
|
||||
// Serialize the modified structured data back to the scalar field
|
||||
if modifiedData, err := s.structuredRoot.String(); err == nil {
|
||||
s.scalarField.YNode().Value = strings.TrimSpace(modifiedData)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStructuredDataInScalar processes a scalar field that contains structured data (JSON/YAML)
|
||||
// and allows path navigation within that structured data
|
||||
func (p *PathMatcher) handleStructuredDataInScalar(scalarField *RNode) (*RNode, error) {
|
||||
scalarValue := scalarField.YNode().Value
|
||||
var parsedNode yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(scalarValue), &parsedNode); err != nil {
|
||||
return nil, fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
// Create a structured field from the parsed data
|
||||
structuredField := NewRNode(&parsedNode)
|
||||
|
||||
// Process the remaining path on the structured data
|
||||
pm := &PathMatcher{Path: p.Path[1:], Create: p.Create}
|
||||
result, err := pm.filter(structuredField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Matches = pm.Matches
|
||||
|
||||
// For structured data, we need to create a special result node that knows how to
|
||||
// serialize back to the original scalar when modified
|
||||
if result != nil && len(result.YNode().Content) > 0 {
|
||||
// Wrap the result with structured data context
|
||||
for _, node := range result.YNode().Content {
|
||||
wrappedNode := NewRNode(node)
|
||||
// Add a custom method to handle setting values back to structured data
|
||||
wrappedNode = p.wrapWithStructuredDataHandler(wrappedNode, structuredField, scalarField)
|
||||
result.YNode().Content[0] = wrappedNode.YNode()
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// wrapWithStructuredDataHandler creates a wrapper that will serialize structured data back
|
||||
// to the scalar field when the node is modified
|
||||
func (p *PathMatcher) wrapWithStructuredDataHandler(targetNode, structuredRoot, scalarField *RNode) *RNode {
|
||||
// Create a custom node that overrides SetYNode to handle structured data serialization
|
||||
wrapper := &structuredDataNode{
|
||||
RNode: *targetNode,
|
||||
structuredRoot: structuredRoot,
|
||||
scalarField: scalarField,
|
||||
}
|
||||
return &wrapper.RNode
|
||||
}
|
||||
|
||||
@@ -287,3 +287,236 @@ spec:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathMatcher_StructuredDataInScalar(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
input string
|
||||
path []string
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
"json field access": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: test-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"database": {
|
||||
"host": "localhost",
|
||||
"port": 5432
|
||||
},
|
||||
"app": {
|
||||
"name": "myapp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}`,
|
||||
path: []string{"data", "config.json", "database", "host"},
|
||||
expected: "- \"localhost\"\n",
|
||||
},
|
||||
"json nested field access": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: test-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"database": {
|
||||
"host": "localhost",
|
||||
"port": 5432
|
||||
},
|
||||
"app": {
|
||||
"name": "myapp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}`,
|
||||
path: []string{"data", "config.json", "app", "name"},
|
||||
expected: "- \"myapp\"\n",
|
||||
},
|
||||
"yaml field access": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: prometheus-config
|
||||
data:
|
||||
prometheus.yml: |-
|
||||
global:
|
||||
external_labels:
|
||||
prometheus_env: dev
|
||||
cluster: local
|
||||
scrape_configs:
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]`,
|
||||
path: []string{"data", "prometheus.yml", "global", "external_labels", "prometheus_env"},
|
||||
expected: "- dev\n",
|
||||
},
|
||||
"yaml array access": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: deployment-config
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: nginx:1.18
|
||||
- name: sidecar
|
||||
image: busybox:latest`,
|
||||
path: []string{"data", "deployment.yaml", "spec", "replicas"},
|
||||
expected: "- 3\n",
|
||||
},
|
||||
"yaml container array with selector": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: deployment-config
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: nginx:1.18
|
||||
- name: sidecar
|
||||
image: busybox:latest`,
|
||||
path: []string{"data", "deployment.yaml", "spec", "template", "spec", "containers", "[name=web]", "image"},
|
||||
expected: "- nginx:1.18\n",
|
||||
},
|
||||
"json complex nested structure": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: complex-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"database": {
|
||||
"connections": {
|
||||
"primary": {
|
||||
"host": "primary-db.example.com",
|
||||
"port": 5432,
|
||||
"ssl": true
|
||||
},
|
||||
"secondary": {
|
||||
"host": "secondary-db.example.com",
|
||||
"port": 5433
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
path: []string{"data", "config.json", "database", "connections", "primary", "host"},
|
||||
expected: "- \"primary-db.example.com\"\n",
|
||||
},
|
||||
"yaml flow style (kyaml) field access": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: test-config
|
||||
data:
|
||||
config.yaml: |-
|
||||
labels: {
|
||||
app: "foobar",
|
||||
foo: "bar",
|
||||
something: "12345",
|
||||
}
|
||||
spec: {
|
||||
replicas: 3,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: "foobar"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
path: []string{"data", "config.yaml", "labels", "app"},
|
||||
expected: "- \"foobar\"\n",
|
||||
},
|
||||
"yaml flow style nested field access": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: test-config
|
||||
data:
|
||||
config.yaml: |-
|
||||
labels: {
|
||||
app: "foobar",
|
||||
foo: "bar",
|
||||
something: "12345",
|
||||
}
|
||||
spec: {
|
||||
replicas: 3,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: "foobar"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
path: []string{"data", "config.yaml", "spec", "selector", "matchLabels", "app"},
|
||||
expected: "- \"foobar\"\n",
|
||||
},
|
||||
"invalid json returns field value as-is": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: bad-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"invalid": json
|
||||
}`,
|
||||
path: []string{"data", "config.json"},
|
||||
expected: "- |-\n {\n \"invalid\": json\n }\n",
|
||||
},
|
||||
"access non-existent field": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: test-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"existing": "value"
|
||||
}`,
|
||||
path: []string{"data", "config.json", "nonexistent"},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
node := MustParse(tc.input)
|
||||
result, err := node.Pipe(&PathMatcher{Path: tc.path})
|
||||
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
if tc.expected == "" {
|
||||
assert.True(t, result == nil || result.IsNil() || result.MustString() == "")
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected, result.MustString())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +386,7 @@ replacements:
|
||||
kind: ConfigMap
|
||||
name: prometheus-config
|
||||
fieldPaths:
|
||||
- data.prometheus\.yml.global.external_labels?prometheus_env
|
||||
- data.prometheus\.yml.global.external_labels.prometheus_env
|
||||
```
|
||||
|
||||
```yaml
|
||||
|
||||
Reference in New Issue
Block a user