diff --git a/api/filters/replacement/replacement.go b/api/filters/replacement/replacement.go index c6181060f..d4fea260b 100644 --- a/api/filters/replacement/replacement.go +++ b/api/filters/replacement/replacement.go @@ -179,63 +179,38 @@ func rejectId(rejects []*types.Selector, id *resid.ResId) bool { func copyValueToTarget(target *yaml.RNode, value *yaml.RNode, selector *types.TargetSelector) error { for _, fp := range selector.FieldPaths { - mutator := updateMatchingFields + createKind := yaml.Kind(0) // do not create if selector.Options != nil && selector.Options.Create { - mutator = createOrUpdateOneField + createKind = value.YNode().Kind } - if err := mutator(target, value, selector.Options, fp); err != nil { - return err + targetFieldList, err := target.Pipe(&yaml.PathMatcher{ + Path: kyaml_utils.SmarterPathSplitter(fp, "."), + Create: createKind}) + if err != nil { + return errors.WrapPrefixf(err, fieldRetrievalError(fp, createKind != 0)) + } + targetFields, err := targetFieldList.Elements() + if err != nil { + return errors.WrapPrefixf(err, fieldRetrievalError(fp, createKind != 0)) + } + if len(targetFields) == 0 { + return errors.Errorf(fieldRetrievalError(fp, createKind != 0)) + } + + for _, t := range targetFields { + if err := setFieldValue(selector.Options, t, value); err != nil { + return err + } } } return nil } -// updateMatchingFields updates all fields in target that match the given field path. -// If the field path does not already exist in the target, an error is returned. -func updateMatchingFields(target *yaml.RNode, value *yaml.RNode, opts *types.FieldOptions, fp string) error { - fieldPath := kyaml_utils.SmarterPathSplitter(fp, ".") - // may return multiple fields, always wrapped in a sequence node - foundFieldSequence, lookupErr := target.Pipe(&yaml.PathMatcher{Path: fieldPath}) - if lookupErr != nil { - return fmt.Errorf("error finding field in replacement target: %w", lookupErr) +func fieldRetrievalError(fieldPath string, isCreate bool) string { + if isCreate { + return fmt.Sprintf("unable to find or create field %q in replacement target", fieldPath) } - targetFields, err := foundFieldSequence.Elements() - if err != nil { - return fmt.Errorf("error fetching elements in replacement target: %w", err) - } - if len(targetFields) == 0 { - return errors.Errorf("unable to find field %s in replacement target", fp) - } - - for _, t := range targetFields { - if err := setFieldValue(opts, t, value); err != nil { - return err - } - } - return nil -} - -// createOrUpdateOneField updates the field in the target that matches the given field path. -// If the field path does not already exist in the target, it is created. -// Wildcard matching is not supported, nor is creating intermediate list nodes. -func createOrUpdateOneField(target *yaml.RNode, value *yaml.RNode, opts *types.FieldOptions, fp string) error { - fieldPath := kyaml_utils.SmarterPathSplitter(fp, ".") - for _, f := range fieldPath { - if f == "*" { - return fmt.Errorf("cannot support create option in a multi-value target") - } - } - createdField, createErr := target.Pipe(yaml.LookupCreate(value.YNode().Kind, fieldPath...)) - if createErr != nil { - return fmt.Errorf("error creating replacement node: %w", createErr) - } - if createdField == nil { - return errors.Errorf("unable to find or create field %s in replacement target", fp) - } - if err := setFieldValue(opts, createdField, value); err != nil { - return errors.WrapPrefixf(err, "unable to set field %s in replacement target", fp) - } - return nil + return fmt.Sprintf("unable to find field %q in replacement target", fieldPath) } func setFieldValue(options *types.FieldOptions, targetField *yaml.RNode, value *yaml.RNode) error { diff --git a/api/filters/replacement/replacement_test.go b/api/filters/replacement/replacement_test.go index 9ce95e006..7619e5fc9 100644 --- a/api/filters/replacement/replacement_test.go +++ b/api/filters/replacement/replacement_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" filtertest "sigs.k8s.io/kustomize/api/testutils/filtertest" - "sigs.k8s.io/yaml" + "sigs.k8s.io/kustomize/kyaml/yaml" ) func TestFilter(t *testing.T) { @@ -1479,6 +1479,85 @@ spec: value: sample-deploy - name: foo value: bar + - image: nginx + name: sidecar + env: + - name: deployment-name + value: sample-deploy`, + }, + "one replacements target should create multiple values": { + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: sample-deploy + name: sample-deploy +spec: + replicas: 1 + selector: + matchLabels: + app: sample-deploy + template: + metadata: + labels: + app: sample-deploy + spec: + containers: + - image: other + name: do-not-modify-me + env: + - name: foo + value: bar + - image: nginx + name: main + env: + - name: foo + value: bar + - image: nginx + name: sidecar +`, + replacements: `replacements: +- source: + kind: Deployment + name: sample-deploy + fieldPath: metadata.name + targets: + - select: + kind: Deployment + options: + create: true + fieldPaths: + - spec.template.spec.containers.[image=nginx].env.[name=deployment-name].value +`, + expected: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: sample-deploy + name: sample-deploy +spec: + replicas: 1 + selector: + matchLabels: + app: sample-deploy + template: + metadata: + labels: + app: sample-deploy + spec: + containers: + - image: other + name: do-not-modify-me + env: + - name: foo + value: bar + - image: nginx + name: main + env: + - name: foo + value: bar + - name: deployment-name + value: sample-deploy - image: nginx name: sidecar env: @@ -1652,7 +1731,110 @@ spec: options: create: true `, - expectedErr: "cannot support create option in a multi-value target", + expected: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: sample-deploy + name: sample-deploy +spec: + replicas: 1 + selector: + matchLabels: + app: sample-deploy + template: + metadata: + labels: + app: sample-deploy + spec: + containers: + - image: nginx + name: main + env: + - name: other-env + value: YYYYY + - name: deployment-name + value: sample-deploy +`, + }, + "Issue 1493: wildcard to create or replace field in all containers in all workloads": { + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: policy +data: + restart: OnFailure +--- +apiVersion: v1 +kind: Pod +metadata: + name: pod1 +spec: + containers: + - image: nginx + name: main + - image: nginx + name: sidecar + imagePullPolicy: Always +--- +apiVersion: v1 +kind: Pod +metadata: + name: pod2 +spec: + containers: + - image: nginx + name: main + imagePullPolicy: Always + - image: nginx + name: sidecar +`, + replacements: `replacements: +- source: + kind: ConfigMap + name: policy + fieldPath: data.restart + targets: + - select: + kind: Pod + fieldPaths: + - spec.containers.*.imagePullPolicy + options: + create: true +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: policy +data: + restart: OnFailure +--- +apiVersion: v1 +kind: Pod +metadata: + name: pod1 +spec: + containers: + - image: nginx + name: main + imagePullPolicy: OnFailure + - image: nginx + name: sidecar + imagePullPolicy: OnFailure +--- +apiVersion: v1 +kind: Pod +metadata: + name: pod2 +spec: + containers: + - image: nginx + name: main + imagePullPolicy: OnFailure + - image: nginx + name: sidecar + imagePullPolicy: OnFailure +`, }, "multiple field paths in target": { input: `apiVersion: v1 @@ -2435,7 +2617,37 @@ replacements: options: create: true `, - expectedErr: "unable to find or create field spec.configPatches.2.match.proxy.proxyVersion in replacement target", + expected: ` +apiVersion: networking.istio.io/v1alpha3 +kind: EnvoyFilter +metadata: + name: request-id +spec: + configPatches: + - applyTo: NETWORK_FILTER + match: + proxy: + proxyVersion: ^1\.14.* + - applyTo: NETWORK_FILTER + match: + proxy: + proxyVersion: ^1\.14.* + - match: + proxy: + proxyVersion: ^1\.14.* + - match: + proxy: + proxyVersion: ^1\.14.* +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: istio-version + annotations: + config.kubernetes.io/local-config: true +data: + ISTIO_REGEX: '^1\.14.*' +`, }, "issue4761 - path not in target with create: false": { input: ` @@ -2475,7 +2687,148 @@ replacements: options: create: false `, - expectedErr: "unable to find field spec.configPatches.0.match.proxy.proxyVersion in replacement target", + expectedErr: "unable to find field \"spec.configPatches.0.match.proxy.proxyVersion\" in replacement target", + }, + "issue4761 - wildcard solution": { + input: ` +--- +apiVersion: networking.istio.io/v1alpha3 +kind: EnvoyFilter +metadata: + name: request-id +spec: + configPatches: + - applyTo: NETWORK_FILTER + - applyTo: NETWORK_FILTER +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: istio-version + annotations: + config.kubernetes.io/local-config: true +data: + ISTIO_REGEX: '^1\.14.*' +`, + replacements: ` +replacements: + - source: + kind: ConfigMap + name: istio-version + fieldPath: data.ISTIO_REGEX + targets: + - select: + kind: EnvoyFilter + fieldPaths: + - spec.configPatches.*.match.proxy.proxyVersion + options: + create: true +`, + expected: ` +apiVersion: networking.istio.io/v1alpha3 +kind: EnvoyFilter +metadata: + name: request-id +spec: + configPatches: + - applyTo: NETWORK_FILTER + match: + proxy: + proxyVersion: ^1\.14.* + - applyTo: NETWORK_FILTER + match: + proxy: + proxyVersion: ^1\.14.* +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: istio-version + annotations: + config.kubernetes.io/local-config: true +data: + ISTIO_REGEX: '^1\.14.*' +`, + }, + "append to sequence using index": { + input: `apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: myingress +spec: + rules: + - host: myingress.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-svc + port: + number: 80 +`, + replacements: `replacements: + - source: + kind: Ingress + name: myingress + fieldPath: spec.rules.0.host + targets: + - select: + kind: Ingress + name: myingress + fieldPaths: + - spec.tls.0.hosts.0 + - spec.tls.0.secretName + options: + create: true +`, + expected: `apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: myingress +spec: + rules: + - host: myingress.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-svc + port: + number: 80 + tls: + - hosts: + - myingress.example.com + secretName: myingress.example.com`, + }, + "fail to append to sequence using a distant index": { + input: `apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: myingress +spec: + rules: + - host: myingress.example.com +`, + replacements: `replacements: + - source: + kind: Ingress + name: myingress + fieldPath: spec.rules.0.host + targets: + - select: + kind: Ingress + name: myingress + fieldPaths: + - spec.tls.5.hosts.5 + - spec.tls.5.secretName + options: + create: true +`, + expectedErr: "unable to find or create field \"spec.tls.5.hosts.5\" in replacement target: index 5 specified but only 0 elements found", }, } diff --git a/kyaml/yaml/fns.go b/kyaml/yaml/fns.go index b2ba181f7..070255865 100644 --- a/kyaml/yaml/fns.go +++ b/kyaml/yaml/fns.go @@ -550,7 +550,7 @@ func (l PathGetter) getFilter(part, nextPart string, fieldPath *[]string) (Filte default: // mapping node *fieldPath = append(*fieldPath, part) - return l.fieldFilter(part, l.getKind(nextPart)) + return l.fieldFilter(part, getPathPartKind(nextPart, l.Create)) } } @@ -590,15 +590,18 @@ func (l PathGetter) fieldFilter( return FieldMatcher{Name: name, Create: &RNode{value: &yaml.Node{Kind: kind, Style: l.Style}}}, nil } -func (l PathGetter) getKind(nextPart string) yaml.Kind { +func getPathPartKind(nextPart string, defaultKind yaml.Kind) yaml.Kind { if IsListIndex(nextPart) { // if nextPart is of the form [a=b], then it is an index into a Sequence // so the current part must be a SequenceNode return yaml.SequenceNode } + if IsIdxNumber(nextPart) { + return yaml.SequenceNode + } if nextPart == "" { - // final name in the path, use the l.Create defined Kind - return l.Create + // final name in the path, use the default kind provided + return defaultKind } // non-sequence intermediate Node diff --git a/kyaml/yaml/match.go b/kyaml/yaml/match.go index d53f563d6..1749d3872 100644 --- a/kyaml/yaml/match.go +++ b/kyaml/yaml/match.go @@ -4,9 +4,13 @@ package yaml import ( + "fmt" "regexp" "strconv" "strings" + + "sigs.k8s.io/kustomize/kyaml/errors" + "sigs.k8s.io/kustomize/kyaml/internal/forked/github.com/go-yaml/yaml" ) // PathMatcher returns all RNodes matching the path wrapped in a SequenceNode. @@ -21,7 +25,7 @@ type PathMatcher struct { // Each path part may be one of: // * FieldMatcher -- e.g. "spec" // * Map Key -- e.g. "app.k8s.io/version" - // * List Entry -- e.g. "[name=nginx]" or "[=-jar]" + // * List Entry -- e.g. "[name=nginx]" or "[=-jar]" or "0" // // Map Keys and Fields are equivalent. // See FieldMatcher for more on Fields and Map Keys. @@ -43,10 +47,18 @@ type PathMatcher struct { // This is useful for if the nodes are to be printed in FlowStyle. StripComments bool - val *RNode - field string - matchRegex string - indexNumber int + // Create will cause missing path parts to be created as they are walked. + // + // * The leaf Node (final path) will be created with a Kind matching Create + // * Intermediary Nodes will be created as either a MappingNodes or + // SequenceNodes as appropriate for each's Path location. + // * Nodes identified by an index will only be created if the index indicates + // an append operation (i.e. index=len(list)) + Create yaml.Kind `yaml:"create,omitempty"` + + val *RNode + field string + matchRegex string } func (p *PathMatcher) stripComments(n *Node) { @@ -109,7 +121,7 @@ func (p *PathMatcher) doMatchEvery(rn *RNode) (*RNode, error) { func (p *PathMatcher) visitEveryElem(elem *RNode) error { fieldName := p.Path[0] // recurse on the matching element - pm := &PathMatcher{Path: p.Path[1:]} + pm := &PathMatcher{Path: p.Path[1:], Create: p.Create} add, err := pm.filter(elem) for k, v := range pm.Matches { p.Matches[k] = v @@ -125,13 +137,25 @@ 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 || field == nil { - // if the field doesn't exist, return nil + if err != nil || (!IsCreate(p.Create) && field == nil) { return nil, err } + if IsCreate(p.Create) && field == nil { + var nextPart string + if len(p.Path) > 1 { + nextPart = p.Path[1] + } + nextPartKind := getPathPartKind(nextPart, p.Create) + field = &RNode{value: &yaml.Node{Kind: nextPartKind}} + err := rn.PipeE(SetField(p.Path[0], field)) + if err != nil { + return nil, err + } + } + // recurse on the field, removing the first element of the path - pm := &PathMatcher{Path: p.Path[1:]} + pm := &PathMatcher{Path: p.Path[1:], Create: p.Create} p.val, err = pm.filter(field) p.Matches = pm.Matches return p.val, err @@ -144,18 +168,33 @@ func (p *PathMatcher) doIndexSeq(rn *RNode) (*RNode, error) { if err != nil { return nil, err } - p.indexNumber = idx elements, err := rn.Elements() if err != nil { return nil, err } + if len(elements) == idx && IsCreate(p.Create) { + var nextPart string + if len(p.Path) > 1 { + nextPart = p.Path[1] + } + elem := &yaml.Node{Kind: getPathPartKind(nextPart, p.Create)} + err = rn.PipeE(Append(elem)) + if err != nil { + return nil, errors.WrapPrefixf(err, "failed to append element for %q", p.Path[0]) + } + elements = append(elements, NewRNode(elem)) + } + + if len(elements) < idx+1 { + return nil, fmt.Errorf("index %d specified but only %d elements found", idx, len(elements)) + } // get target element element := elements[idx] // recurse on the matching element - pm := &PathMatcher{Path: p.Path[1:]} + pm := &PathMatcher{Path: p.Path[1:], Create: p.Create} add, err := pm.filter(element) for k, v := range pm.Matches { p.Matches[k] = v @@ -176,16 +215,39 @@ func (p *PathMatcher) doSeq(rn *RNode) (*RNode, error) { return nil, err } - if p.field == "" { + primitiveElement := len(p.field) == 0 + if primitiveElement { err = rn.VisitElements(p.visitPrimitiveElem) } else { err = rn.VisitElements(p.visitElem) } - if err != nil || p.val == nil || len(p.val.YNode().Content) == 0 { + if err != nil { return nil, err } + if !p.val.IsNil() && len(p.val.YNode().Content) == 0 { + p.val = nil + } - return p.val, nil + if !IsCreate(p.Create) || p.val != nil { + return p.val, nil + } + + var elem *yaml.Node + valueNode := NewScalarRNode(p.matchRegex).YNode() + if primitiveElement { + elem = valueNode + } else { + elem = &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{{Kind: yaml.ScalarNode, Value: p.field}, valueNode}, + } + } + err = rn.PipeE(Append(elem)) + if err != nil { + return nil, errors.WrapPrefixf(err, "failed to create element for %q", p.Path[0]) + } + // re-do the sequence search; this time we'll find the element we just created + return p.doSeq(rn) } func (p *PathMatcher) visitPrimitiveElem(elem *RNode) error { @@ -228,7 +290,7 @@ func (p *PathMatcher) visitElem(elem *RNode) error { } // recurse on the matching element - pm := &PathMatcher{Path: p.Path[1:]} + pm := &PathMatcher{Path: p.Path[1:], Create: p.Create} add, err := pm.filter(elem) for k, v := range pm.Matches { p.Matches[k] = v diff --git a/kyaml/yaml/match_test.go b/kyaml/yaml/match_test.go index abe51c601..83baa834a 100644 --- a/kyaml/yaml/match_test.go +++ b/kyaml/yaml/match_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/kustomize/kyaml/internal/forked/github.com/go-yaml/yaml" ) func TestPathMatcher_Filter(t *testing.T) { @@ -30,6 +32,7 @@ spec: containers: - name: nginx image: nginx:1.7.9 + args: [-c, conf.yaml] ports: - containerPort: 80 - name: sidecar @@ -45,7 +48,7 @@ spec: }{ {[]string{ "spec", "template", "spec", "containers", "[name=.*]"}, - "- name: nginx\n image: nginx:1.7.9\n ports:\n - containerPort: 80\n" + + "- name: nginx\n image: nginx:1.7.9\n args: [-c, conf.yaml]\n ports:\n - containerPort: 80\n" + "- name: sidecar\n image: sidecar:1.0.0\n ports:\n - containerPort: 8081\n - containerPort: 9090\n"}, {[]string{ "spec", "template", "spec", "containers", "[name=.*]", "image"}, @@ -83,6 +86,9 @@ spec: {[]string{ "spec", "template", "spec", "containers", "*", "ports", "*"}, "- containerPort: 80\n- containerPort: 8081\n- containerPort: 9090\n"}, + {[]string{ + "spec", "template", "spec", "containers", "[name=.*]", "args", "1"}, + "- conf.yaml\n"}, } for i, u := range updates { result, err := node.Pipe(&PathMatcher{Path: u.path}) @@ -92,3 +98,192 @@ spec: assert.Equal(t, u.value, result.MustString(), fmt.Sprintf("%d", i)) } } + +func TestPathMatcher_Filter_Create(t *testing.T) { + testCases := map[string]struct { + path []string + matches []string + modifiedNodeMustContain string + create yaml.Kind + expectErr string + }{ + "create non-primitive sequence item that does not exist": { + path: []string{"spec", "template", "spec", "containers", "[name=please-create-me]"}, + matches: []string{ + "name: please-create-me\n", + }, + modifiedNodeMustContain: "- name: please-create-me", + create: yaml.MappingNode, + }, + "create non-primitive item in empty sequence by index": { + path: []string{"spec", "template", "spec", "containers", "[name=nginx]", "envFrom", "0"}, + matches: []string{"{}\n"}, + modifiedNodeMustContain: "envFrom:\n - {}\n", + create: yaml.MappingNode, + }, + "create primitive item in empty sequence by index": { + path: []string{"spec", "template", "spec", "containers", "[name=sidecar]", "args", "0"}, + matches: []string{"\n"}, + modifiedNodeMustContain: "args:\n -\n", + create: yaml.ScalarNode, + }, + "append primitive item to sequence by index": { + path: []string{"spec", "template", "spec", "containers", "[name=nginx]", "args", "2"}, + matches: []string{"\n"}, + create: yaml.ScalarNode, + modifiedNodeMustContain: "args: [-c, conf.yaml, '']", + }, + "append non-primitive item to sequence by index": { + path: []string{"spec", "template", "spec", "containers", "[name=nginx]", "ports", "1"}, + matches: []string{"{}\n"}, + modifiedNodeMustContain: "ports: [{containerPort: 80}, {}]", + create: yaml.MappingNode, + }, + "appending non-primitive element in middle of sequence": { + path: []string{"spec", "template", "spec", "containers", "2", "imagePullPolicy"}, + matches: []string{"\n"}, + create: yaml.ScalarNode, + modifiedNodeMustContain: "\n - imagePullPolicy:\n", + }, + "fail to create non-primitive item by non-zero index in created sequence": { + path: []string{"spec", "template", "spec", "containers", "[name=nginx]", "envFrom", "1"}, + matches: []string{}, + create: yaml.MappingNode, + expectErr: "index 1 specified but only 0 elements found", + }, + "fail to create primitive item by non-zero index in created sequence": { + path: []string{"spec", "template", "spec", "containers", "[name=sidecar]", "args", "1"}, + matches: []string{}, + create: yaml.ScalarNode, + expectErr: "index 1 specified but only 0 elements found", + }, + "fail to create non-primitive item by distant index in existing sequence": { + path: []string{"spec", "template", "spec", "containers", "3"}, + matches: []string{}, + create: yaml.MappingNode, + expectErr: "index 3 specified but only 2 elements found", + }, + "fail to create primitive item by distant index in existing sequence": { + path: []string{"spec", "template", "spec", "containers", "[name=nginx]", "args", "3"}, + matches: []string{}, + create: yaml.ScalarNode, + expectErr: "index 3 specified but only 2 elements found", + }, + "create primitive sequence item that does not exist": { + path: []string{"metadata", "finalizers", "[=create-me]"}, + matches: []string{ + "create-me\n", + }, + modifiedNodeMustContain: "finalizers:\n - create-me\n", + create: yaml.ScalarNode, + }, + "create series of maps that do not exist": { + path: []string{"spec", "selector", "matchLabels", "does-not-exist"}, + matches: []string{ + "{}\n", + }, + modifiedNodeMustContain: "selector:\n matchLabels:\n app: nginx\n does-not-exist: {}\n", + create: yaml.MappingNode, + }, + "create scalar below series of maps and sequences that do not exist": { + path: []string{"spec", "template", "spec", "containers", "[name=please-create-me]", "env", "[key=please-create-me]", "value"}, + matches: []string{ + "\n", + }, + modifiedNodeMustContain: "- name: please-create-me\n env:\n - key: please-create-me\n value:\n", + create: yaml.ScalarNode, + }, + "find sequence items that already exist": { + path: []string{"spec", "template", "spec", "containers", "[name=.*]"}, + matches: []string{ + "name: nginx\nimage: nginx:1.7.9\nargs: [-c, conf.yaml]\nports: [{containerPort: 80}]\nenv:\n- key: CONTAINER_NAME\n value: nginx\n", + "name: sidecar\nimage: sidecar:1.0.0\nports:\n- containerPort: 8081\n- containerPort: 9090\n", + }, + create: yaml.MappingNode, + }, + "find and create sequence below wildcard that exists on some sequence items": { + path: []string{"spec", "template", "spec", "containers", "[name=.*]", "env"}, + matches: []string{ + "- key: CONTAINER_NAME\n value: nginx\n", + "[]\n", + }, + create: yaml.SequenceNode, + }, + "find field below wildcard that exists on all sequence items": { + path: []string{"spec", "template", "spec", "containers", "[name=.*]", "ports"}, + matches: []string{ + "[{containerPort: 80}]\n", + "- containerPort: 8081\n- containerPort: 9090\n", + }, + create: yaml.SequenceNode, + }, + "find field below query that targets a specific item": { + path: []string{"spec", "template", "spec", "containers", "[name=nginx]", "env"}, + matches: []string{ + "- key: CONTAINER_NAME\n value: nginx\n", + }, + create: yaml.SequenceNode, + }, + "create field below query that targets any value of a field that does not exist": { + path: []string{"spec", "template", "spec", "containers", "[foo=.*]", "env"}, + matches: []string{ + "[]\n", + }, + // This is kinda weird. The query doesn't match anything, and we can't tell that it is a + // wildcard rather than a literal, so we use the value to create the field. + modifiedNodeMustContain: "- foo: .*\n env: []\n", + create: yaml.SequenceNode, + }, + } + nodeStr := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.7.9 + args: [-c, conf.yaml] + ports: [{containerPort: 80}] + env: + - key: CONTAINER_NAME + value: nginx + - name: sidecar + image: sidecar:1.0.0 + ports: + - containerPort: 8081 + - containerPort: 9090 +` + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + node := MustParse(nodeStr) + result, err := node.Pipe(&PathMatcher{Path: tc.path, Create: tc.create}) + if tc.expectErr != "" { + require.EqualError(t, err, tc.expectErr) + return + } + require.NoError(t, err) + matches, err := result.Elements() + require.NoError(t, err) + require.Equalf(t, len(tc.matches), len(matches), "Full sequence wrapper of result:\n%s", result.MustString()) + + modifiedNode := node.MustString() + for i, expected := range tc.matches { + assert.Equal(t, tc.create, matches[i].YNode().Kind) + assert.Equal(t, expected, matches[i].MustString()) + assert.Contains(t, modifiedNode, tc.modifiedNodeMustContain) + } + }) + } +}