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
|
package replacement
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -188,6 +189,14 @@ func copyValueToTarget(target *yaml.RNode, value *yaml.RNode, selector *types.Ta
|
|||||||
if selector.Options != nil && selector.Options.Create {
|
if selector.Options != nil && selector.Options.Create {
|
||||||
createKind = value.YNode().Kind
|
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{
|
targetFieldList, err := target.Pipe(&yaml.PathMatcher{
|
||||||
Path: kyaml_utils.SmarterPathSplitter(fp, "."),
|
Path: kyaml_utils.SmarterPathSplitter(fp, "."),
|
||||||
Create: createKind})
|
Create: createKind})
|
||||||
@@ -204,7 +213,7 @@ func copyValueToTarget(target *yaml.RNode, value *yaml.RNode, selector *types.Ta
|
|||||||
|
|
||||||
for _, t := range targetFields {
|
for _, t := range targetFields {
|
||||||
if err := setFieldValue(selector.Options, t, value); err != nil {
|
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
|
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.json: |-
|
||||||
{
|
{
|
||||||
"config": {
|
"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) {
|
t.Run(tn, func(t *testing.T) {
|
||||||
|
// Test one case to see if structured data replacement works
|
||||||
f := Filter{}
|
f := Filter{}
|
||||||
err := yaml.Unmarshal([]byte(tc.replacements), &f)
|
err := yaml.Unmarshal([]byte(testCases[tn].replacements), &f)
|
||||||
if !assert.NoError(t, err) {
|
if !assert.NoError(t, err) {
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
}
|
}
|
||||||
actual, err := filtertest.RunFilterE(t, tc.input, f)
|
actual, err := filtertest.RunFilterE(t, testCases[tn].input, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if tc.expectedErr == "" {
|
if testCases[tn].expectedErr == "" {
|
||||||
t.Errorf("unexpected error: %s\n", err.Error())
|
t.Errorf("unexpected error: %s\n", err.Error())
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
}
|
}
|
||||||
if !assert.Contains(t, err.Error(), tc.expectedErr) {
|
if !assert.Contains(t, err.Error(), testCases[tn].expectedErr) {
|
||||||
t.FailNow()
|
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()
|
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
|
// recurse on the field, removing the first element of the path
|
||||||
pm := &PathMatcher{Path: p.Path[1:], Create: p.Create}
|
pm := &PathMatcher{Path: p.Path[1:], Create: p.Create}
|
||||||
p.val, err = pm.filter(field)
|
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 {
|
func (p *PathMatcher) visitPrimitiveElem(elem *RNode) error {
|
||||||
r, err := regexp.Compile(p.matchRegex)
|
r, err := regexp.Compile(p.matchRegex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
str, err := elem.String()
|
str, err := elem.String()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("%w", err)
|
||||||
}
|
}
|
||||||
str = strings.TrimSpace(str)
|
str = strings.TrimSpace(str)
|
||||||
if !r.MatchString(str) {
|
if !r.MatchString(str) {
|
||||||
@@ -282,7 +287,7 @@ func (p *PathMatcher) visitPrimitiveElem(elem *RNode) error {
|
|||||||
func (p *PathMatcher) visitElem(elem *RNode) error {
|
func (p *PathMatcher) visitElem(elem *RNode) error {
|
||||||
r, err := regexp.Compile(p.matchRegex)
|
r, err := regexp.Compile(p.matchRegex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if this elements field matches the regex
|
// check if this elements field matches the regex
|
||||||
@@ -292,7 +297,7 @@ func (p *PathMatcher) visitElem(elem *RNode) error {
|
|||||||
}
|
}
|
||||||
str, err := val.Value.String()
|
str, err := val.Value.String()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("%w", err)
|
||||||
}
|
}
|
||||||
str = strings.TrimSpace(str)
|
str = strings.TrimSpace(str)
|
||||||
if !r.MatchString(str) {
|
if !r.MatchString(str) {
|
||||||
@@ -341,3 +346,69 @@ func cleanPath(path []string) []string {
|
|||||||
}
|
}
|
||||||
return p
|
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
|
kind: ConfigMap
|
||||||
name: prometheus-config
|
name: prometheus-config
|
||||||
fieldPaths:
|
fieldPaths:
|
||||||
- data.prometheus\.yml.global.external_labels?prometheus_env
|
- data.prometheus\.yml.global.external_labels.prometheus_env
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
Reference in New Issue
Block a user