helm-controller/internal/action/diff_test.go

835 lines
24 KiB
Go

/*
Copyright 2023 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"context"
"encoding/base64"
"fmt"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
. "github.com/onsi/gomega"
extjsondiff "github.com/wI2L/jsondiff"
helmaction "helm.sh/helm/v3/pkg/action"
helmrelease "helm.sh/helm/v3/pkg/release"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
apierrutil "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/rand"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
"github.com/fluxcd/pkg/apis/kustomize"
"github.com/fluxcd/pkg/ssa"
"github.com/fluxcd/pkg/ssa/jsondiff"
ssanormalize "github.com/fluxcd/pkg/ssa/normalize"
ssautil "github.com/fluxcd/pkg/ssa/utils"
v2 "github.com/fluxcd/helm-controller/api/v2"
"github.com/fluxcd/helm-controller/internal/kube"
)
func TestDiff(t *testing.T) {
// Normally, we would create e.g. a `suite_test.go` file with a `TestMain`
// function. As this is one of the few tests in this package which needs a
// test cluster, we create it here instead.
config, cleanup := newTestCluster(t)
t.Cleanup(func() {
t.Log("Stopping the test environment")
if err := cleanup(); err != nil {
t.Logf("Failed to stop the test environment: %v", err)
}
})
// Construct a REST client getter for Helm's action configuration.
getter := kube.NewMemoryRESTClientGetter(config)
// Construct a client for to be able to mutate the cluster.
c, err := client.New(config, client.Options{})
if err != nil {
t.Fatalf("Failed to create client for test environment: %v", err)
}
const testOwner = "helm-controller"
tests := []struct {
name string
manifest string
ignoreRules []v2.IgnoreRule
mutateCluster func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error)
want func(namespace string, desired, cluster []*unstructured.Unstructured) jsondiff.DiffSet
wantErr bool
}{
{
name: "detects drift",
manifest: `---
apiVersion: v1
kind: ConfigMap
metadata:
name: changed
data:
key: value
---
apiVersion: v1
kind: ConfigMap
metadata:
name: deleted
data:
key: value
---
apiVersion: v1
kind: ConfigMap
metadata:
name: unchanged
data:
key: value`,
mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) {
var clusterObjs []*unstructured.Unstructured
for _, obj := range objs {
if obj.GetName() == "deleted" {
continue
}
obj := obj.DeepCopy()
obj.SetNamespace(namespace)
if obj.GetName() == "changed" {
if err := unstructured.SetNestedField(obj.Object, "changed", "data", "key"); err != nil {
return nil, fmt.Errorf("failed to set nested field: %w", err)
}
}
clusterObjs = append(clusterObjs, obj)
}
return clusterObjs, nil
},
want: func(namespace string, desired, cluster []*unstructured.Unstructured) jsondiff.DiffSet {
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeUpdate,
DesiredObject: namespacedUnstructured(desired[0], namespace),
ClusterObject: cluster[0],
Patch: extjsondiff.Patch{
{
Type: extjsondiff.OperationReplace,
OldValue: "changed",
Value: "value",
Path: "/data/key",
},
},
},
{
Type: jsondiff.DiffTypeCreate,
DesiredObject: namespacedUnstructured(desired[1], namespace),
},
{
Type: jsondiff.DiffTypeNone,
DesiredObject: namespacedUnstructured(desired[2], namespace),
ClusterObject: cluster[1],
},
}
},
},
{
name: "empty release manifest",
manifest: "",
},
{
name: "manifest with disabled annotation",
manifest: fmt.Sprintf(`---
apiVersion: v1
kind: ConfigMap
metadata:
name: disabled
annotations:
%[1]s: %[2]s
data:
key: value`, v2.DriftDetectionMetadataKey, v2.DriftDetectionDisabledValue),
mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) {
var clusterObjs []*unstructured.Unstructured
for _, obj := range objs {
obj := obj.DeepCopy()
obj.SetNamespace(namespace)
if err := unstructured.SetNestedField(obj.Object, "changed", "data", "key"); err != nil {
return nil, fmt.Errorf("failed to set nested field: %w", err)
}
clusterObjs = append(clusterObjs, obj)
}
return clusterObjs, nil
},
want: func(namespace string, desired, cluster []*unstructured.Unstructured) jsondiff.DiffSet {
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeExclude,
DesiredObject: namespacedUnstructured(desired[0], namespace),
},
}
},
},
{
name: "adheres to ignore rules",
manifest: `---
apiVersion: v1
kind: ConfigMap
metadata:
name: fully-ignored
data:
key: value
---
apiVersion: v1
kind: Secret
metadata:
name: partially-ignored
stringData:
key: value
otherKey: otherValue
---
apiVersion: v1
kind: Secret
metadata:
name: globally-ignored
stringData:
globalKey: globalValue
---
apiVersion: v1
kind: ConfigMap
metadata:
name: not-ignored
data:
key: value`,
mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) {
var clusterObjs []*unstructured.Unstructured
for _, obj := range objs {
obj := obj.DeepCopy()
obj.SetNamespace(namespace)
switch obj.GetName() {
case "fully-ignored", "not-ignored":
if err := unstructured.SetNestedField(obj.Object, "changed", "data", "key"); err != nil {
return nil, fmt.Errorf("failed to set nested field: %w", err)
}
case "partially-ignored":
if err := unstructured.SetNestedField(obj.Object, "changed", "stringData", "key"); err != nil {
return nil, fmt.Errorf("failed to set nested field: %w", err)
}
if err := unstructured.SetNestedField(obj.Object, "changed", "stringData", "otherKey"); err != nil {
return nil, fmt.Errorf("failed to set nested field: %w", err)
}
case "globally-ignored":
if err := unstructured.SetNestedField(obj.Object, "changed", "stringData", "globalKey"); err != nil {
return nil, fmt.Errorf("failed to set nested field: %w", err)
}
}
clusterObjs = append(clusterObjs, obj)
}
return clusterObjs, nil
},
ignoreRules: []v2.IgnoreRule{
{Target: &kustomize.Selector{Name: "fully-ignored"}, Paths: []string{""}},
{Target: &kustomize.Selector{Name: "partially-ignored"}, Paths: []string{"/data/key"}},
{Paths: []string{"/data/globalKey"}},
},
want: func(namespace string, desired, cluster []*unstructured.Unstructured) jsondiff.DiffSet {
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeExclude,
DesiredObject: namespacedUnstructured(desired[0], namespace),
},
{
Type: jsondiff.DiffTypeUpdate,
DesiredObject: namespacedUnstructured(desired[1], namespace),
ClusterObject: cluster[1],
Patch: extjsondiff.Patch{
{
Type: extjsondiff.OperationReplace,
Path: "/data/otherKey",
OldValue: base64.StdEncoding.EncodeToString([]byte("changed")),
Value: base64.StdEncoding.EncodeToString([]byte("otherValue")),
},
},
},
{
Type: jsondiff.DiffTypeNone,
DesiredObject: namespacedUnstructured(desired[2], namespace),
ClusterObject: cluster[2],
},
{
Type: jsondiff.DiffTypeUpdate,
DesiredObject: namespacedUnstructured(desired[3], namespace),
ClusterObject: cluster[3],
Patch: extjsondiff.Patch{
{
Type: extjsondiff.OperationReplace,
Path: "/data/key",
OldValue: "changed",
Value: "value",
},
},
},
}
},
},
{
name: "configures namespace",
manifest: `---
apiVersion: v1
kind: ConfigMap
metadata:
name: without-namespace
data:
key: value
---
apiVersion: v1
kind: ConfigMap
metadata:
name: with-namespace
namespace: diff-fixed-ns
data:
key: value`,
mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) {
var clusterObjs []*unstructured.Unstructured
otherNS := unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": map[string]interface{}{
"name": "diff-fixed-ns",
},
}}
clusterObjs = append(clusterObjs, &otherNS)
for _, obj := range objs {
obj := obj.DeepCopy()
if obj.GetNamespace() == "" {
obj.SetNamespace(namespace)
}
clusterObjs = append(clusterObjs, obj)
}
return clusterObjs, nil
},
want: func(namespace string, desired, cluster []*unstructured.Unstructured) jsondiff.DiffSet {
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeNone,
DesiredObject: namespacedUnstructured(desired[0], namespace),
ClusterObject: cluster[1],
},
{
Type: jsondiff.DiffTypeNone,
DesiredObject: namespacedUnstructured(desired[1], desired[1].GetNamespace()),
ClusterObject: cluster[2],
},
}
},
},
{
name: "configures Helm metadata",
manifest: `---
apiVersion: v1
kind: ConfigMap
metadata:
name: without-helm-metadata
data:
key: value`,
mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) {
var clusterObjs []*unstructured.Unstructured
for _, obj := range objs {
obj := obj.DeepCopy()
if obj.GetNamespace() == "" {
obj.SetNamespace(namespace)
}
obj.SetAnnotations(nil)
obj.SetLabels(nil)
clusterObjs = append(clusterObjs, obj)
}
return clusterObjs, nil
},
want: func(namespace string, desired, cluster []*unstructured.Unstructured) jsondiff.DiffSet {
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeUpdate,
DesiredObject: namespacedUnstructured(desired[0], namespace),
ClusterObject: cluster[0],
Patch: extjsondiff.Patch{
{
Type: extjsondiff.OperationAdd,
Path: "/metadata",
Value: map[string]interface{}{
"labels": map[string]interface{}{
appManagedByLabel: appManagedByHelm,
},
"annotations": map[string]interface{}{
helmReleaseNameAnnotation: "configures Helm metadata",
helmReleaseNamespaceAnnotation: namespace,
},
},
},
},
},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
ns, err := generateNamespace(ctx, c, "diff-action")
if err != nil {
t.Fatalf("Failed to generate namespace: %v", err)
}
t.Cleanup(func() {
if err := c.Delete(context.Background(), ns); client.IgnoreNotFound(err) != nil {
t.Logf("Failed to delete generated namespace: %v", err)
}
})
rls := &helmrelease.Release{Name: tt.name, Namespace: ns.Name, Manifest: tt.manifest}
objs, err := ssautil.ReadObjects(strings.NewReader(tt.manifest))
if err != nil {
t.Fatalf("Failed to read release objects: %v", err)
}
for _, obj := range objs {
setHelmMetadata(obj, rls)
}
clusterObjs := objs
if tt.mutateCluster != nil {
if clusterObjs, err = tt.mutateCluster(objs, ns.Name); err != nil {
t.Fatalf("Failed to modify cluster resource: %v", err)
}
}
t.Cleanup(func() {
for _, obj := range clusterObjs {
if err := c.Delete(context.Background(), obj); client.IgnoreNotFound(err) != nil {
t.Logf("Failed to delete object: %v", err)
}
}
})
for _, obj := range clusterObjs {
if err = ssanormalize.Unstructured(obj); err != nil {
t.Fatalf("Failed to normalize cluster manifest: %v", err)
}
if err := c.Create(ctx, obj, client.FieldOwner(testOwner)); err != nil {
t.Fatalf("Failed to create object: %v", err)
}
}
got, err := Diff(ctx, &helmaction.Configuration{RESTClientGetter: getter}, rls, testOwner, tt.ignoreRules...)
if (err != nil) != tt.wantErr {
t.Errorf("Diff() error = %v, wantErr %v", err, tt.wantErr)
return
}
var want jsondiff.DiffSet
if tt.want != nil {
want = tt.want(ns.Name, objs, clusterObjs)
}
if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(extjsondiff.Operation{})); diff != "" {
t.Errorf("Diff() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestApplyDiff(t *testing.T) {
// Normally, we would create e.g. a `suite_test.go` file with a `TestMain`
// function. As this is one of the few tests in this package which needs a
// test cluster, we create it here instead.
config, cleanup := newTestCluster(t)
t.Cleanup(func() {
t.Log("Stopping the test environment")
if err := cleanup(); err != nil {
t.Logf("Failed to stop the test environment: %v", err)
}
})
// Construct a REST client getter for Helm's action configuration.
getter := kube.NewMemoryRESTClientGetter(config)
// Construct a client for to be able to mutate the cluster.
c, err := client.New(config, client.Options{})
if err != nil {
t.Fatalf("Failed to create client for test environment: %v", err)
}
const testOwner = "helm-controller"
tests := []struct {
name string
diffSet func(namespace string) jsondiff.DiffSet
expect func(g *GomegaWithT, namespace string, got *ssa.ChangeSet, err error)
}{
{
name: "creates and updates resources",
diffSet: func(namespace string) jsondiff.DiffSet {
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeCreate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": namespace,
},
"stringData": map[string]interface{}{
"key": "value",
},
},
},
},
{
Type: jsondiff.DiffTypeUpdate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm",
"namespace": namespace,
},
"data": map[string]interface{}{
"key": "value",
},
},
},
ClusterObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm",
"namespace": namespace,
},
"data": map[string]interface{}{
"key": "changed",
},
},
},
Patch: extjsondiff.Patch{
{
Type: extjsondiff.OperationReplace,
Path: "/data/key",
Value: "value",
},
},
},
}
},
expect: func(g *GomegaWithT, namespace string, got *ssa.ChangeSet, err error) {
g.THelper()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(got).NotTo(BeNil())
g.Expect(got.Entries).To(HaveLen(2))
g.Expect(got.Entries[0].Subject).To(Equal("Secret/" + namespace + "/test-secret"))
g.Expect(got.Entries[0].Action).To(Equal(ssa.CreatedAction))
g.Expect(c.Get(context.TODO(), types.NamespacedName{
Namespace: namespace,
Name: "test-secret",
}, &corev1.Secret{})).To(Succeed())
g.Expect(got.Entries[1].Subject).To(Equal("ConfigMap/" + namespace + "/test-cm"))
g.Expect(got.Entries[1].Action).To(Equal(ssa.ConfiguredAction))
cm := &corev1.ConfigMap{}
g.Expect(c.Get(context.TODO(), types.NamespacedName{
Namespace: namespace,
Name: "test-cm",
}, cm)).To(Succeed())
g.Expect(cm.Data).To(HaveKeyWithValue("key", "value"))
},
},
{
name: "continues on error",
diffSet: func(namespace string) jsondiff.DiffSet {
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeCreate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "invalid-test-secret",
"namespace": namespace,
},
"data": map[string]interface{}{
// Illegal base64 encoded data.
"key": "secret value",
},
},
},
},
{
Type: jsondiff.DiffTypeCreate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm",
"namespace": namespace,
},
},
},
},
{
Type: jsondiff.DiffTypeUpdate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "invalid-test-secret-update",
"namespace": namespace,
},
"data": map[string]interface{}{
// Illegal base64 encoded data.
"key": "secret value2",
},
},
},
ClusterObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "invalid-test-secret-update",
"namespace": namespace,
},
"stringData": map[string]interface{}{
"key": "value",
},
},
},
Patch: extjsondiff.Patch{
{
Type: extjsondiff.OperationReplace,
Path: "/data/key",
// Illegal base64 encoded data.
Value: "value",
},
},
},
{
Type: jsondiff.DiffTypeUpdate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm-2",
"namespace": namespace,
},
"data": map[string]interface{}{
"key": "value",
},
},
},
ClusterObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm-2",
"namespace": namespace,
},
"data": map[string]interface{}{
"key": "changed",
},
},
},
Patch: extjsondiff.Patch{
{
Type: extjsondiff.OperationReplace,
Path: "/data/key",
Value: "value",
},
},
},
}
},
expect: func(g *GomegaWithT, namespace string, got *ssa.ChangeSet, err error) {
g.THelper()
g.Expect(err).To(HaveOccurred())
g.Expect(err.(apierrutil.Aggregate).Errors()).To(HaveLen(2))
g.Expect(err.Error()).To(ContainSubstring("invalid-test-secret creation failure"))
g.Expect(err.Error()).To(ContainSubstring("invalid-test-secret-update patch failure"))
// Verify that the error message does not contain the secret data.
g.Expect(err.Error()).ToNot(ContainSubstring("secret value"))
g.Expect(err.Error()).ToNot(ContainSubstring("secret value2"))
g.Expect(got).NotTo(BeNil())
g.Expect(got.Entries).To(HaveLen(2))
g.Expect(got.Entries[0].Subject).To(Equal("ConfigMap/" + namespace + "/test-cm"))
g.Expect(got.Entries[0].Action).To(Equal(ssa.CreatedAction))
g.Expect(c.Get(context.TODO(), types.NamespacedName{
Namespace: namespace,
Name: "test-cm",
}, &corev1.ConfigMap{})).To(Succeed())
g.Expect(got.Entries[1].Subject).To(Equal("ConfigMap/" + namespace + "/test-cm-2"))
g.Expect(got.Entries[1].Action).To(Equal(ssa.ConfiguredAction))
cm2 := &corev1.ConfigMap{}
g.Expect(c.Get(context.TODO(), types.NamespacedName{
Namespace: namespace,
Name: "test-cm-2",
}, cm2)).To(Succeed())
g.Expect(cm2.Data).To(HaveKeyWithValue("key", "value"))
},
},
{
name: "creates namespace before dependent resources",
diffSet: func(namespace string) jsondiff.DiffSet {
otherNS := generateName("test-ns")
return jsondiff.DiffSet{
{
Type: jsondiff.DiffTypeCreate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm",
"namespace": otherNS,
},
},
},
},
{
Type: jsondiff.DiffTypeCreate,
DesiredObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": map[string]interface{}{
"name": otherNS,
},
},
},
},
}
},
expect: func(g *GomegaWithT, namespace string, got *ssa.ChangeSet, err error) {
g.THelper()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(got).NotTo(BeNil())
g.Expect(got.Entries).To(HaveLen(2))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
ns, err := generateNamespace(ctx, c, "diff-action")
if err != nil {
t.Fatalf("Failed to generate namespace: %v", err)
}
t.Cleanup(func() {
if err := c.Delete(context.Background(), ns); client.IgnoreNotFound(err) != nil {
t.Logf("Failed to delete generated namespace: %v", err)
}
})
diff := tt.diffSet(ns.Name)
for _, d := range diff {
if d.ClusterObject != nil {
if err := c.Create(ctx, d.ClusterObject, client.FieldOwner(testOwner)); err != nil {
t.Fatalf("Failed to create cluster object: %v", err)
}
t.Cleanup(func() {
if err := c.Delete(ctx, d.ClusterObject); client.IgnoreNotFound(err) != nil {
t.Logf("Failed to delete cluster object: %v", err)
}
})
}
}
got, err := ApplyDiff(context.Background(), &helmaction.Configuration{RESTClientGetter: getter}, diff, testOwner)
tt.expect(g, ns.Name, got, err)
})
}
}
// newTestCluster creates a new test cluster and returns a rest.Config and a
// function to stop the test cluster.
func newTestCluster(t *testing.T) (*rest.Config, func() error) {
t.Helper()
testEnv := &envtest.Environment{}
t.Log("Starting the test environment")
if _, err := testEnv.Start(); err != nil {
t.Fatalf("Failed to start the test environment: %v", err)
}
return testEnv.Config, testEnv.Stop
}
// generateNamespace creates a new namespace with the given generateName and
// returns the namespace object.
func generateNamespace(ctx context.Context, c client.Client, generateName string) (*corev1.Namespace, error) {
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
GenerateName: fmt.Sprintf("%s-", generateName),
},
}
if err := c.Create(ctx, ns); err != nil {
return nil, err
}
return ns, nil
}
// generateName generates a name with the given name and a random suffix.
func generateName(name string) string {
return fmt.Sprintf("%s-%s", name, rand.String(5))
}
func namespacedUnstructured(obj *unstructured.Unstructured, namespace string) *unstructured.Unstructured {
obj = obj.DeepCopy()
obj.SetNamespace(namespace)
_ = ssanormalize.Unstructured(obj)
return obj
}