Merge pull request #5679 from koba1t/implements_to_replacements_value_in_the_structured_data

implements to replacements value in the structured data
This commit is contained in:
Kubernetes Prow Robot
2025-10-24 20:15:34 -07:00
committed by GitHub
6 changed files with 963 additions and 9 deletions

View File

@@ -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
}

View File

@@ -2779,7 +2779,7 @@ spec:
name: myingress
fieldPaths:
- spec.tls.0.hosts.0
- spec.tls.0.secretName
- spec.tls.0.secretName
options:
create: true
`,
@@ -4498,3 +4498,536 @@ metadata:
})
}
}
func TestValueInlineStructuredData(t *testing.T) {
testCases := map[string]struct {
input string
replacements string
expected string
expectedErr string
}{
"replacement contain jsonfield": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: target-configmap
annotations:
hostname: www.example.com
data:
config.json: |-
{
"config": {
"id": "42",
"hostname": "REPLACE_TARGET_HOSTNAME"
}
}
`,
replacements: `replacements:
- source:
kind: ConfigMap
name: target-configmap
fieldPath: metadata.annotations.hostname
targets:
- select:
kind: ConfigMap
fieldPaths:
- data.config\.json.config.hostname
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: target-configmap
annotations:
hostname: www.example.com
data:
config.json: |-
{
"config": {
"hostname": "www.example.com",
"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 := 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(testCases[tn].replacements), &f)
if !assert.NoError(t, err) {
t.FailNow()
}
actual, err := filtertest.RunFilterE(t, testCases[tn].input, f)
if err != nil {
if testCases[tn].expectedErr == "" {
t.Errorf("unexpected error: %s\n", err.Error())
t.FailNow()
}
if !assert.Contains(t, err.Error(), testCases[tn].expectedErr) {
t.FailNow()
}
}
if !assert.Equal(t, strings.TrimSpace(testCases[tn].expected), strings.TrimSpace(actual)) {
t.FailNow()
}
})
}
}

View File

@@ -830,6 +830,10 @@ func (e *InvalidNodeKindError) Error() string {
return msg
}
func (e *InvalidNodeKindError) Unwrap() error {
return errors.Errorf("InvalidNodeKindError")
}
func (e *InvalidNodeKindError) ActualNodeKind() Kind {
return e.node.YNode().Kind
}

View File

@@ -14,7 +14,7 @@ import (
)
// PathMatcher returns all RNodes matching the path wrapped in a SequenceNode.
// Lists may have multiple elements matching the path, and each matching element
// Lists may have multiple elements matching the pafunc cleanPath(path []string) []string {g element
// is added to the return result.
// If Path points to a SequenceNode, the SequenceNode is wrapped in another SequenceNode
// If Path does not contain any lists, the result is still wrapped in a SequenceNode of len == 1
@@ -137,10 +137,14 @@ func (p *PathMatcher) visitEveryElem(elem *RNode) error {
func (p *PathMatcher) doField(rn *RNode) (*RNode, error) {
// lookup the field
field, err := rn.Pipe(Get(p.Path[0]))
if err != nil || (!IsCreate(p.Create) && field == nil) {
if err != nil {
return nil, err
}
if !IsCreate(p.Create) && field == nil {
return nil, nil
}
if IsCreate(p.Create) && field == nil {
var nextPart string
if len(p.Path) > 1 {
@@ -154,6 +158,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)
@@ -253,12 +262,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) {
@@ -272,7 +281,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
@@ -282,7 +291,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) {
@@ -331,3 +340,26 @@ func cleanPath(path []string) []string {
}
return p
}
// 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
return result, nil
}

View File

@@ -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())
})
}
}

View File

@@ -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