// Copyright 2019 The Kubernetes Authors. // SPDX-License-Identifier: Apache-2.0 package kio_test import ( "bytes" "reflect" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "sigs.k8s.io/kustomize/kyaml/kio/kioutil" "sigs.k8s.io/kustomize/kyaml/yaml" . "sigs.k8s.io/kustomize/kyaml/kio" ) func TestPipe(t *testing.T) { p := Pipeline{ Inputs: []Reader{}, Filters: []Filter{}, Outputs: []Writer{}, } err := p.Execute() if !assert.NoError(t, err) { assert.FailNow(t, err.Error()) } } type mockCallback struct { mock.Mock } func (c *mockCallback) Callback(op Filter) { c.Called(op) } func TestPipelineWithCallback(t *testing.T) { input := ResourceNodeSlice{yaml.MakeNullNode()} noopFilter1 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil } noopFilter2 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil } filters := []Filter{ FilterFunc(noopFilter1), FilterFunc(noopFilter2), } p := Pipeline{ Inputs: []Reader{input}, Filters: filters, Outputs: []Writer{}, } callback := mockCallback{} // setup expectations. `Times` means the function is called no more than `times`. callback.On("Callback", mock.Anything).Times(len(filters)) err := p.ExecuteWithCallback(callback.Callback) if !assert.NoError(t, err) { assert.FailNow(t, err.Error()) } callback.AssertNumberOfCalls(t, "Callback", len(filters)) // assert filters are called in the order they are defined. for i, filter := range filters { assert.Equal( t, reflect.ValueOf(callback.Calls[i].Arguments[0]).Pointer(), reflect.ValueOf(filter).Pointer(), ) } } func TestEmptyInput(t *testing.T) { actual := &bytes.Buffer{} output := ByteWriter{ Sort: true, WrappingKind: ResourceListKind, WrappingAPIVersion: ResourceListAPIVersion, } output.Writer = actual p := Pipeline{ Outputs: []Writer{output}, } if err := p.Execute(); err != nil { t.Fatal(err) } expected := ` apiVersion: config.kubernetes.io/v1 kind: ResourceList items: [] ` if !assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(actual.String())) { t.FailNow() } } func TestEmptyInputWithFilter(t *testing.T) { actual := &bytes.Buffer{} output := ByteWriter{ Sort: true, WrappingKind: ResourceListKind, WrappingAPIVersion: ResourceListAPIVersion, } output.Writer = actual filters := []Filter{ FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { nodes = append(nodes, yaml.NewMapRNode(&map[string]string{ "foo": "bar", })) return nodes, nil }), FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil }), } p := Pipeline{ Outputs: []Writer{output}, Filters: filters, } if err := p.Execute(); err != nil { t.Fatal(err) } expected := ` apiVersion: config.kubernetes.io/v1 kind: ResourceList items: - foo: bar ` if !assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(actual.String())) { t.FailNow() } } func TestContinueOnEmptyBehavior(t *testing.T) { cases := map[string]struct { continueOnEmptyResult bool expected string }{ "quit on empty": {continueOnEmptyResult: false, expected: ""}, "continue on empty": {continueOnEmptyResult: true, expected: "foo: bar"}, } for _, tc := range cases { actual := &bytes.Buffer{} output := ByteWriter{Writer: actual} generatorFunc := FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { nodes = append(nodes, yaml.NewMapRNode(&map[string]string{ "foo": "bar", })) return nodes, nil }) emptyFunc := FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil }) p := Pipeline{ Outputs: []Writer{output}, Filters: []Filter{emptyFunc, generatorFunc}, ContinueOnEmptyResult: tc.continueOnEmptyResult, } err := p.Execute() if err != nil { t.Fatal(err) } if !assert.Equal(t, tc.expected, strings.TrimSpace(actual.String())) { t.Fail() } } } func TestLegacyAnnotationReconciliation(t *testing.T) { noopFilter1 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil } noopFilter2 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil } changeInternalAnnos := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { for _, rn := range nodes { if err := rn.PipeE(yaml.SetAnnotation(kioutil.PathAnnotation, "new")); err != nil { return nil, err } if err := rn.PipeE(yaml.SetAnnotation(kioutil.IndexAnnotation, "new")); err != nil { return nil, err } } return nodes, nil } changeLegacyAnnos := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { for _, rn := range nodes { if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyPathAnnotation, "new")); err != nil { return nil, err } if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyIndexAnnotation, "new")); err != nil { return nil, err } } return nodes, nil } changeBothPathAnnosMatch := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { for _, rn := range nodes { if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyPathAnnotation, "new")); err != nil { return nil, err } if err := rn.PipeE(yaml.SetAnnotation(kioutil.PathAnnotation, "new")); err != nil { return nil, err } } return nodes, nil } changeBothPathAnnosMismatch := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { for _, rn := range nodes { if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyPathAnnotation, "foo")); err != nil { return nil, err } if err := rn.PipeE(yaml.SetAnnotation(kioutil.PathAnnotation, "bar")); err != nil { return nil, err } } return nodes, nil } noops := []Filter{ FilterFunc(noopFilter1), FilterFunc(noopFilter2), } internal := []Filter{FilterFunc(changeInternalAnnos)} legacy := []Filter{FilterFunc(changeLegacyAnnos)} changeBothMatch := []Filter{FilterFunc(changeBothPathAnnosMatch), FilterFunc(noopFilter1)} changeBothMismatch := []Filter{FilterFunc(changeBothPathAnnosMismatch), FilterFunc(noopFilter1)} testCases := map[string]struct { input string filters []Filter expected string expectedErr string }{ // the orchestrator should copy the legacy annotations to the new // annotations "legacy annotations only": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'configmap.yaml' config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: noops, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'configmap.yaml' config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' data: grpcPort: 8081 `, }, // the orchestrator should copy the new annotations to the // legacy annotations "new annotations only": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: noops, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, }, // the orchestrator should detect that the legacy annotations // have been changed by the function "change only legacy annotations": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'configmap.yaml' config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: legacy, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'new' config.kubernetes.io/index: 'new' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: 'new' data: grpcPort: 8081 `, }, // the orchestrator should detect that the new internal annotations // have been changed by the function "change only internal annotations": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: internal, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'new' internal.config.kubernetes.io/index: 'new' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "new" internal.config.kubernetes.io/index: 'new' data: grpcPort: 8081 `, }, // the orchestrator should detect that the legacy annotations // have been changed by the function "change only internal annotations while input is legacy annotations": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'configmap.yaml' config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: internal, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'new' config.kubernetes.io/index: 'new' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: 'new' data: grpcPort: 8081 `, }, // the orchestrator should detect that the new internal annotations // have been changed by the function "change only legacy annotations while input is internal annotations": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: legacy, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'new' internal.config.kubernetes.io/index: 'new' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "new" internal.config.kubernetes.io/index: 'new' data: grpcPort: 8081 `, }, // the orchestrator should detect that the legacy annotations // have been changed by the function "change only legacy annotations while input has both": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'configmap.yaml' config.kubernetes.io/index: '0' internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: legacy, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'new' config.kubernetes.io/index: 'new' internal.config.kubernetes.io/path: 'new' internal.config.kubernetes.io/index: 'new' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: 'new' internal.config.kubernetes.io/path: 'new' internal.config.kubernetes.io/index: 'new' data: grpcPort: 8081 `, }, // the orchestrator should detect that the new internal annotations // have been changed by the function "change only internal annotations while input has both": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '0' internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: internal, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: 'new' internal.config.kubernetes.io/path: 'new' internal.config.kubernetes.io/index: 'new' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: 'new' internal.config.kubernetes.io/path: "new" internal.config.kubernetes.io/index: 'new' data: grpcPort: 8081 `, }, // the orchestrator should detect that the new internal annotations // have been changed by the function "change both to matching value while input has both": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '0' internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: changeBothMatch, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: '0' internal.config.kubernetes.io/path: 'new' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: '1' internal.config.kubernetes.io/path: "new" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, }, // the orchestrator should detect that the new internal annotations // have been changed by the function "change both to matching value while input is legacy": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: changeBothMatch, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "new" config.kubernetes.io/index: '1' data: grpcPort: 8081 `, }, // the orchestrator should detect that the new internal annotations // have been changed by the function "change both to matching value while input is internal": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'configmap.yaml' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: changeBothMatch, expected: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: 'new' internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "new" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, }, // the function changes both the legacy and new path annotation, and they mismatch, // so we should get an error "change both but mismatch while input is legacy": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'configmap.yaml' config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: changeBothMismatch, expectedErr: "resource input to function has mismatched legacy and internal path annotations", }, // the function changes both the legacy and new path annotation, and they mismatch, // so we should get an error "change both but mismatch while input is internal": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: changeBothMismatch, expectedErr: "resource input to function has mismatched legacy and internal path annotations", }, // the function changes both the legacy and new path annotation, and they mismatch, // so we should get an error "change both but mismatch while input has both": { input: `apiVersion: v1 kind: ConfigMap metadata: name: ports-from annotations: config.kubernetes.io/path: 'configmap.yaml' config.kubernetes.io/index: '0' config.k8s.io/id: '1' internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '0' data: grpcPort: 8080 --- apiVersion: v1 kind: ConfigMap metadata: name: ports-to annotations: config.kubernetes.io/path: "configmap.yaml" config.kubernetes.io/index: '1' config.k8s.io/id: '2' internal.config.kubernetes.io/path: "configmap.yaml" internal.config.kubernetes.io/index: '1' data: grpcPort: 8081 `, filters: changeBothMismatch, expectedErr: "resource input to function has mismatched legacy and internal path annotations", }, } for tn, tc := range testCases { t.Run(tn, func(t *testing.T) { var out bytes.Buffer input := ByteReadWriter{ Reader: bytes.NewBufferString(tc.input), Writer: &out, OmitReaderAnnotations: true, KeepReaderAnnotations: true, } p := Pipeline{ Inputs: []Reader{&input}, Filters: tc.filters, Outputs: []Writer{&input}, } err := p.Execute() if tc.expectedErr == "" { require.NoError(t, err) assert.Equal(t, tc.expected, out.String()) } else { require.Error(t, err) assert.Equal(t, tc.expectedErr, err.Error()) } }) } }