mirror of
https://github.com/argoproj/argo-cd.git
synced 2026-02-20 01:28:45 +01:00
1931 lines
60 KiB
Go
1931 lines
60 KiB
Go
package diff
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
openapi_v2 "github.com/google/gnostic-models/openapiv2"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/proto"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/equality"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/util/intstr"
|
|
"k8s.io/apimachinery/pkg/util/managedfields"
|
|
"k8s.io/klog/v2/textlogger"
|
|
openapiproto "k8s.io/kube-openapi/pkg/util/proto"
|
|
"sigs.k8s.io/yaml"
|
|
|
|
"github.com/argoproj/gitops-engine/pkg/diff/mocks"
|
|
"github.com/argoproj/gitops-engine/pkg/diff/testdata"
|
|
)
|
|
|
|
func printDiff(ctx context.Context, result *DiffResult) (string, error) {
|
|
var live unstructured.Unstructured
|
|
if err := json.Unmarshal(result.NormalizedLive, &live); err != nil {
|
|
return "", fmt.Errorf("failed to unmarshal live object: %w", err)
|
|
}
|
|
var target unstructured.Unstructured
|
|
if err := json.Unmarshal(result.PredictedLive, &target); err != nil {
|
|
return "", fmt.Errorf("failed to unmarshal target object: %w", err)
|
|
}
|
|
out, _ := printDiffInternal(ctx, "diff", &live, &target)
|
|
return string(out), nil
|
|
}
|
|
|
|
// printDiffInternal prints a diff between two unstructured objects using an external diff utility and returns the output.
|
|
func printDiffInternal(ctx context.Context, name string, live *unstructured.Unstructured, target *unstructured.Unstructured) ([]byte, error) {
|
|
tempDir, err := os.MkdirTemp("", "argocd-diff")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
|
}
|
|
targetFile := filepath.Join(tempDir, name)
|
|
var targetData []byte
|
|
if target != nil {
|
|
targetData, err = yaml.Marshal(target)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal target object: %w", err)
|
|
}
|
|
}
|
|
err = os.WriteFile(targetFile, targetData, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to write target object: %w", err)
|
|
}
|
|
liveFile := filepath.Join(tempDir, name+"-live.yaml")
|
|
liveData := []byte("")
|
|
if live != nil {
|
|
liveData, err = yaml.Marshal(live)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal live object: %w", err)
|
|
}
|
|
}
|
|
err = os.WriteFile(liveFile, liveData, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to write live object: %w", err)
|
|
}
|
|
cmd := exec.CommandContext(ctx, "diff", liveFile, targetFile)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
// return output even if there's an error
|
|
return out, fmt.Errorf("failed to diff live object: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func toUnstructured(obj any) (*unstructured.Unstructured, error) {
|
|
uObj, err := runtime.NewTestUnstructuredConverter(equality.Semantic).ToUnstructured(obj)
|
|
if err != nil {
|
|
//nolint:wrapcheck // don't wrap, trivial function
|
|
return nil, err
|
|
}
|
|
return &unstructured.Unstructured{Object: uObj}, nil
|
|
}
|
|
|
|
func mustToUnstructured(obj any) *unstructured.Unstructured {
|
|
un, err := toUnstructured(obj)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return un
|
|
}
|
|
|
|
func unmarshalFile(path string) *unstructured.Unstructured {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
var un unstructured.Unstructured
|
|
err = json.Unmarshal(data, &un.Object)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &un
|
|
}
|
|
|
|
func newDeployment() *appsv1.Deployment {
|
|
var two int32 = 2
|
|
return &appsv1.Deployment{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: "apps/v1beta1",
|
|
Kind: "Deployment",
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "demo",
|
|
Namespace: "test",
|
|
},
|
|
Spec: appsv1.DeploymentSpec{
|
|
Replicas: &two,
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{
|
|
"app": "demo",
|
|
},
|
|
},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Labels: map[string]string{
|
|
"app": "demo",
|
|
},
|
|
},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "demo",
|
|
Image: "gcr.io/kuar-demo/kuard-amd64:1",
|
|
Ports: []corev1.ContainerPort{
|
|
{
|
|
ContainerPort: 80,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func diff(t *testing.T, config, live *unstructured.Unstructured, options ...Option) *DiffResult {
|
|
t.Helper()
|
|
res, err := Diff(config, live, options...)
|
|
assert.NoError(t, err)
|
|
return res
|
|
}
|
|
|
|
func TestDiff(t *testing.T) {
|
|
leftDep := newDeployment()
|
|
leftUn := mustToUnstructured(leftDep)
|
|
|
|
diffRes := diff(t, leftUn, leftUn, diffOptionsForTest()...)
|
|
assert.False(t, diffRes.Modified)
|
|
ascii, err := printDiff(t.Context(), diffRes)
|
|
require.NoError(t, err)
|
|
if ascii != "" {
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func TestDiff_KnownTypeInvalidValue(t *testing.T) {
|
|
leftDep := newDeployment()
|
|
leftUn := mustToUnstructured(leftDep)
|
|
require.NoError(t, unstructured.SetNestedField(leftUn.Object, "badValue", "spec", "revisionHistoryLimit"))
|
|
|
|
t.Run("NoDifference", func(t *testing.T) {
|
|
diffRes := diff(t, leftUn, leftUn, diffOptionsForTest()...)
|
|
assert.False(t, diffRes.Modified)
|
|
ascii, err := printDiff(t.Context(), diffRes)
|
|
require.NoError(t, err)
|
|
if ascii != "" {
|
|
t.Log(ascii)
|
|
}
|
|
})
|
|
|
|
t.Run("HasDifference", func(t *testing.T) {
|
|
rightUn := leftUn.DeepCopy()
|
|
require.NoError(t, unstructured.SetNestedField(rightUn.Object, "3", "spec", "revisionHistoryLimit"))
|
|
|
|
diffRes := diff(t, leftUn, rightUn, diffOptionsForTest()...)
|
|
assert.True(t, diffRes.Modified)
|
|
})
|
|
}
|
|
|
|
func TestDiffWithNils(t *testing.T) {
|
|
dep := newDeployment()
|
|
resource := mustToUnstructured(dep)
|
|
|
|
diffRes := diff(t, nil, resource, diffOptionsForTest()...)
|
|
// NOTE: if live is non-nil, and config is nil, this is not considered difference
|
|
// This "difference" is checked at the comparator.
|
|
assert.False(t, diffRes.Modified)
|
|
diffRes, err := TwoWayDiff(nil, resource)
|
|
require.NoError(t, err)
|
|
assert.False(t, diffRes.Modified)
|
|
|
|
diffRes = diff(t, resource, nil, diffOptionsForTest()...)
|
|
assert.True(t, diffRes.Modified)
|
|
diffRes, err = TwoWayDiff(resource, nil)
|
|
require.NoError(t, err)
|
|
assert.True(t, diffRes.Modified)
|
|
}
|
|
|
|
func TestDiffNilFieldInLive(t *testing.T) {
|
|
leftDep := newDeployment()
|
|
rightDep := leftDep.DeepCopy()
|
|
|
|
leftUn := mustToUnstructured(leftDep)
|
|
rightUn := mustToUnstructured(rightDep)
|
|
err := unstructured.SetNestedField(rightUn.Object, nil, "spec")
|
|
require.NoError(t, err)
|
|
|
|
diffRes := diff(t, leftUn, rightUn, diffOptionsForTest()...)
|
|
assert.True(t, diffRes.Modified)
|
|
}
|
|
|
|
func TestDiffArraySame(t *testing.T) {
|
|
leftDep := newDeployment()
|
|
rightDep := leftDep.DeepCopy()
|
|
|
|
leftUn := mustToUnstructured(leftDep)
|
|
rightUn := mustToUnstructured(rightDep)
|
|
|
|
left := []*unstructured.Unstructured{leftUn}
|
|
right := []*unstructured.Unstructured{rightUn}
|
|
diffResList, err := DiffArray(left, right, diffOptionsForTest()...)
|
|
require.NoError(t, err)
|
|
assert.False(t, diffResList.Modified)
|
|
}
|
|
|
|
func TestDiffArrayAdditions(t *testing.T) {
|
|
leftDep := newDeployment()
|
|
rightDep := leftDep.DeepCopy()
|
|
rightDep.Status.Replicas = 1
|
|
|
|
leftUn := mustToUnstructured(leftDep)
|
|
rightUn := mustToUnstructured(rightDep)
|
|
|
|
left := []*unstructured.Unstructured{leftUn}
|
|
right := []*unstructured.Unstructured{rightUn}
|
|
diffResList, err := DiffArray(left, right, diffOptionsForTest()...)
|
|
require.NoError(t, err)
|
|
assert.False(t, diffResList.Modified)
|
|
}
|
|
|
|
func TestDiffArrayModification(t *testing.T) {
|
|
leftDep := newDeployment()
|
|
rightDep := leftDep.DeepCopy()
|
|
ten := int32(10)
|
|
rightDep.Spec.Replicas = &ten
|
|
|
|
leftUn := mustToUnstructured(leftDep)
|
|
rightUn := mustToUnstructured(rightDep)
|
|
|
|
left := []*unstructured.Unstructured{leftUn}
|
|
right := []*unstructured.Unstructured{rightUn}
|
|
diffResList, err := DiffArray(left, right, diffOptionsForTest()...)
|
|
require.NoError(t, err)
|
|
assert.True(t, diffResList.Modified)
|
|
}
|
|
|
|
// TestThreeWayDiff will perform a diff when there is a kubectl.kubernetes.io/last-applied-configuration
|
|
// present in the live object.
|
|
func TestThreeWayDiff(t *testing.T) {
|
|
// 1. get config and live to be the same. Both have a foo annotation.
|
|
configDep := newDeployment()
|
|
configDep.Namespace = ""
|
|
configDep.Annotations = map[string]string{
|
|
"foo": "bar",
|
|
}
|
|
liveDep := configDep.DeepCopy()
|
|
|
|
// 2. add a extra field to the live. this simulates kubernetes adding default values in the
|
|
// object. We should not consider defaulted values as a difference
|
|
liveDep.SetNamespace("default")
|
|
configUn := mustToUnstructured(configDep)
|
|
liveUn := mustToUnstructured(liveDep)
|
|
res := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
if !assert.False(t, res.Modified) {
|
|
ascii, err := printDiff(t.Context(), res)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
|
|
// 3. Add a last-applied-configuration annotation in the live. There should still not be any
|
|
// difference
|
|
configBytes, err := json.Marshal(configDep)
|
|
require.NoError(t, err)
|
|
liveDep.Annotations[corev1.LastAppliedConfigAnnotation] = string(configBytes)
|
|
configUn = mustToUnstructured(configDep)
|
|
liveUn = mustToUnstructured(liveDep)
|
|
res = diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
if !assert.False(t, res.Modified) {
|
|
ascii, err := printDiff(t.Context(), res)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
|
|
// 4. Remove the foo annotation from config and perform the diff again. We should detect a
|
|
// difference since three-way diff detects the removal of a managed field
|
|
delete(configDep.Annotations, "foo")
|
|
configUn = mustToUnstructured(configDep)
|
|
liveUn = mustToUnstructured(liveDep)
|
|
res = diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
assert.True(t, res.Modified)
|
|
|
|
// 5. Just to prove three way diff incorporates last-applied-configuration, remove the
|
|
// last-applied-configuration annotation from the live object, and redo the diff. This time,
|
|
// the diff will report not modified (because we have no way of knowing what was a defaulted
|
|
// field without this annotation)
|
|
delete(liveDep.Annotations, corev1.LastAppliedConfigAnnotation)
|
|
configUn = mustToUnstructured(configDep)
|
|
liveUn = mustToUnstructured(liveDep)
|
|
res = diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
ascii, err := printDiff(t.Context(), res)
|
|
require.NoError(t, err)
|
|
if ascii != "" {
|
|
t.Log(ascii)
|
|
}
|
|
assert.False(t, res.Modified)
|
|
}
|
|
|
|
var demoConfig = `
|
|
{
|
|
"apiVersion": "v1",
|
|
"kind": "ServiceAccount",
|
|
"metadata": {
|
|
"labels": {
|
|
"app.kubernetes.io/instance": "argocd-demo"
|
|
},
|
|
"name": "argocd-application-controller"
|
|
}
|
|
}
|
|
`
|
|
|
|
var demoLive = `
|
|
{
|
|
"apiVersion": "v1",
|
|
"kind": "ServiceAccount",
|
|
"metadata": {
|
|
"annotations": {
|
|
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"ServiceAccount\",\"metadata\":{\"annotations\":{},\"labels\":{\"app.kubernetes.io/instance\":\"argocd-demo\"},\"name\":\"argocd-application-controller\",\"namespace\":\"argocd-demo\"}}\n"
|
|
},
|
|
"creationTimestamp": "2018-04-16T22:08:57Z",
|
|
"labels": {
|
|
"app.kubernetes.io/instance": "argocd-demo"
|
|
},
|
|
"name": "argocd-application-controller",
|
|
"namespace": "argocd-demo",
|
|
"resourceVersion": "7584502",
|
|
"selfLink": "/api/v1/namespaces/argocd-demo/serviceaccounts/argocd-application-controller",
|
|
"uid": "c22bb2b4-41c2-11e8-978a-028445d52ec8"
|
|
},
|
|
"secrets": [
|
|
{
|
|
"name": "argocd-application-controller-token-kfxct"
|
|
}
|
|
]
|
|
}
|
|
`
|
|
|
|
// Tests a real world example
|
|
func TestThreeWayDiffExample1(t *testing.T) {
|
|
var configUn, liveUn unstructured.Unstructured
|
|
// NOTE: it is intentional to unmarshal to Unstructured.Object instead of just Unstructured
|
|
// since it catches a case when we comparison fails due to subtle differences in types
|
|
// (e.g. float vs. int)
|
|
err := json.Unmarshal([]byte(demoConfig), &configUn.Object)
|
|
require.NoError(t, err)
|
|
err = json.Unmarshal([]byte(demoLive), &liveUn.Object)
|
|
require.NoError(t, err)
|
|
dr := diff(t, &configUn, &liveUn, diffOptionsForTest()...)
|
|
assert.False(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
if ascii != "" {
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
// Test for ignoring aggregated cluster roles
|
|
func TestDiffOptionIgnoreAggregateRoles(t *testing.T) {
|
|
// Test case 1: Ignore option is true, the rules in the role should be ignored
|
|
{
|
|
configUn := unmarshalFile("testdata/aggr-clusterrole-config.json")
|
|
liveUn := unmarshalFile("testdata/aggr-clusterrole-live.json")
|
|
dr := diff(t, configUn, liveUn, IgnoreAggregatedRoles(true))
|
|
assert.False(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
// Test case 2: Ignore option is false, the aggregation should produce a diff
|
|
{
|
|
configUn := unmarshalFile("testdata/aggr-clusterrole-config.json")
|
|
liveUn := unmarshalFile("testdata/aggr-clusterrole-live.json")
|
|
dr := diff(t, configUn, liveUn, IgnoreAggregatedRoles(false))
|
|
assert.True(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func TestThreeWayDiffExample2(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/elasticsearch-config.json")
|
|
liveUn := unmarshalFile("testdata/elasticsearch-live.json")
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
assert.False(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
|
|
// Tests a real world example
|
|
func TestThreeWayDiffExample3(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/deployment-config.json")
|
|
liveUn := unmarshalFile("testdata/deployment-live.json")
|
|
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
assert.False(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
if ascii != "" {
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func TestThreeWayDiffExample4(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/mutatingwebhookconfig-config.json")
|
|
liveUn := unmarshalFile("testdata/mutatingwebhookconfig-live.json")
|
|
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
assert.False(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
if ascii != "" {
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
// TestThreeWayDiffExample2WithDifference is same as TestThreeWayDiffExample2 but with differences
|
|
func TestThreeWayDiffExample2WithDifference(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/elasticsearch-config.json")
|
|
liveUn := unmarshalFile("testdata/elasticsearch-live.json")
|
|
labels := configUn.GetLabels()
|
|
// add a new label
|
|
labels["foo"] = "bar"
|
|
// modify a label
|
|
labels["chart"] = "elasticsearch-1.7.1"
|
|
// remove an existing label
|
|
delete(labels, "release")
|
|
configUn.SetLabels(labels)
|
|
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
assert.True(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err, ascii)
|
|
t.Log(ascii)
|
|
|
|
// Check that we indicate missing/extra/changed correctly
|
|
showsMissing := 0
|
|
showsExtra := 0
|
|
showsChanged := 0
|
|
for _, line := range strings.Split(ascii, "\n") {
|
|
if strings.HasPrefix(line, `> foo: bar`) {
|
|
showsMissing++
|
|
}
|
|
if strings.HasPrefix(line, `< release: elasticsearch4`) {
|
|
showsExtra++
|
|
}
|
|
if strings.HasPrefix(line, `> chart: elasticsearch-1.7.1`) {
|
|
showsChanged++
|
|
}
|
|
if strings.HasPrefix(line, `< chart: elasticsearch-1.7.0`) {
|
|
showsChanged++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, showsMissing)
|
|
assert.Equal(t, 1, showsExtra)
|
|
assert.Equal(t, 2, showsChanged)
|
|
}
|
|
|
|
func TestThreeWayDiffExplicitNamespace(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/spinnaker-sa-config.json")
|
|
liveUn := unmarshalFile("testdata/spinnaker-sa-live.json")
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
assert.False(t, dr.Modified)
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
|
|
func TestDiffResourceWithInvalidField(t *testing.T) {
|
|
// Diff(...) should not silently discard invalid fields (fields that are not present in the underlying k8s resource).
|
|
|
|
leftDep := `{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"metadata": {
|
|
"name": "invalid-cm"
|
|
},
|
|
"invalidKey": "asdf"
|
|
}`
|
|
var leftUn unstructured.Unstructured
|
|
err := json.Unmarshal([]byte(leftDep), &leftUn.Object)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
rightUn := leftUn.DeepCopy()
|
|
unstructured.RemoveNestedField(rightUn.Object, "invalidKey")
|
|
|
|
diffRes := diff(t, &leftUn, rightUn, diffOptionsForTest()...)
|
|
assert.True(t, diffRes.Modified)
|
|
ascii, err := printDiff(t.Context(), diffRes)
|
|
require.NoError(t, err)
|
|
|
|
assert.Contains(t, ascii, "invalidKey")
|
|
if ascii != "" {
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func TestRemoveNamespaceAnnotation(t *testing.T) {
|
|
obj := removeNamespaceAnnotation(&unstructured.Unstructured{Object: map[string]any{
|
|
"metadata": map[string]any{
|
|
"name": "test",
|
|
"namespace": "default",
|
|
},
|
|
}})
|
|
assert.Empty(t, obj.GetNamespace())
|
|
|
|
obj = removeNamespaceAnnotation(&unstructured.Unstructured{Object: map[string]any{
|
|
"metadata": map[string]any{
|
|
"name": "test",
|
|
"namespace": "default",
|
|
"annotations": make(map[string]any),
|
|
},
|
|
}})
|
|
assert.Empty(t, obj.GetNamespace())
|
|
assert.Nil(t, obj.GetAnnotations())
|
|
|
|
obj = removeNamespaceAnnotation(&unstructured.Unstructured{Object: map[string]any{
|
|
"metadata": map[string]any{
|
|
"name": "test",
|
|
"namespace": "default",
|
|
"annotations": "wrong value",
|
|
},
|
|
}})
|
|
assert.Empty(t, obj.GetNamespace())
|
|
val, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations")
|
|
assert.Equal(t, "wrong value", val)
|
|
}
|
|
|
|
const customObjConfig = `
|
|
apiVersion: foo.io/v1
|
|
kind: Foo
|
|
metadata:
|
|
name: my-foo
|
|
namespace: kube-system
|
|
spec:
|
|
foo: bar
|
|
`
|
|
|
|
const customObjLive = `
|
|
apiVersion: foo.io/v1
|
|
kind: Foo
|
|
metadata:
|
|
creationTimestamp: 2018-07-17 09:17:05 UTC
|
|
name: my-foo
|
|
resourceVersion: '10308211'
|
|
selfLink: "/apis/rbac.authorization.k8s.io/v1/clusterroles/argocd-manager-role"
|
|
uid: 2c3d5405-89a2-11e8-aff0-42010a8a0fc6
|
|
spec:
|
|
foo: bar
|
|
`
|
|
|
|
func TestIgnoreNamespaceForClusterScopedResources(t *testing.T) {
|
|
var configUn unstructured.Unstructured
|
|
var liveUn unstructured.Unstructured
|
|
err := yaml.Unmarshal([]byte(customObjLive), &liveUn)
|
|
require.NoError(t, err)
|
|
err = yaml.Unmarshal([]byte(customObjConfig), &configUn)
|
|
require.NoError(t, err)
|
|
dr := diff(t, &configUn, &liveUn, diffOptionsForTest()...)
|
|
assert.False(t, dr.Modified)
|
|
}
|
|
|
|
const secretConfig = `
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: my-secret
|
|
type: Opaque
|
|
stringData:
|
|
foo: bar
|
|
bar: "1234"
|
|
data:
|
|
baz: cXV4
|
|
`
|
|
|
|
const secretLive = `
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
creationTimestamp: 2018-11-19T11:30:40Z
|
|
name: my-secret
|
|
namespace: argocd
|
|
resourceVersion: "25848035"
|
|
selfLink: /api/v1/namespaces/argocd/secrets/my-secret
|
|
uid: 8b4a2766-ebee-11e8-93c0-42010a8a0013
|
|
type: Opaque
|
|
data:
|
|
foo: YmFy
|
|
bar: MTIzNA==
|
|
baz: cXV4
|
|
`
|
|
|
|
func TestSecretStringData(t *testing.T) {
|
|
var err error
|
|
var configUn unstructured.Unstructured
|
|
err = yaml.Unmarshal([]byte(secretConfig), &configUn)
|
|
require.NoError(t, err)
|
|
|
|
var liveUn unstructured.Unstructured
|
|
err = yaml.Unmarshal([]byte(secretLive), &liveUn)
|
|
require.NoError(t, err)
|
|
|
|
dr := diff(t, &configUn, &liveUn, diffOptionsForTest()...)
|
|
if !assert.False(t, dr.Modified) {
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
// This is invalid because foo is a number, not a string
|
|
const secretInvalidConfig = `
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: my-secret
|
|
type: Opaque
|
|
stringData:
|
|
foo: 1234
|
|
`
|
|
|
|
const secretInvalidLive = `
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
creationTimestamp: 2018-11-19T11:30:40Z
|
|
name: my-secret
|
|
namespace: argocd
|
|
resourceVersion: "25848035"
|
|
selfLink: /api/v1/namespaces/argocd/secrets/my-secret
|
|
uid: 8b4a2766-ebee-11e8-93c0-42010a8a0013
|
|
type: Opaque
|
|
data:
|
|
foo: MTIzNA==
|
|
`
|
|
|
|
func TestInvalidSecretStringData(t *testing.T) {
|
|
var err error
|
|
var configUn unstructured.Unstructured
|
|
err = yaml.Unmarshal([]byte(secretInvalidConfig), &configUn)
|
|
require.NoError(t, err)
|
|
|
|
var liveUn unstructured.Unstructured
|
|
err = yaml.Unmarshal([]byte(secretInvalidLive), &liveUn)
|
|
require.NoError(t, err)
|
|
|
|
dr := diff(t, &configUn, nil, diffOptionsForTest()...)
|
|
assert.True(t, dr.Modified)
|
|
}
|
|
|
|
func TestNullSecretData(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/wordpress-config.json")
|
|
liveUn := unmarshalFile("testdata/wordpress-live.json")
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
if !assert.False(t, dr.Modified) {
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
// TestRedactedSecretData tests we are able to perform diff on redacted secret data, which has
|
|
// invalid characters (*) for the data byte array field.
|
|
func TestRedactedSecretData(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/wordpress-config.json")
|
|
liveUn := unmarshalFile("testdata/wordpress-live.json")
|
|
configData := configUn.Object["data"].(map[string]any)
|
|
liveData := liveUn.Object["data"].(map[string]any)
|
|
configData["wordpress-password"] = "++++++++"
|
|
configData["smtp-password"] = "++++++++"
|
|
liveData["wordpress-password"] = "++++++++++++"
|
|
liveData["smtp-password"] = "++++++++++++"
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
if !assert.True(t, dr.Modified) {
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func TestNullRoleRule(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/grafana-clusterrole-config.json")
|
|
liveUn := unmarshalFile("testdata/grafana-clusterrole-live.json")
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
if !assert.False(t, dr.Modified) {
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func TestNullCreationTimestamp(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/sealedsecret-config.json")
|
|
liveUn := unmarshalFile("testdata/sealedsecret-live.json")
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
if !assert.False(t, dr.Modified) {
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func TestUnsortedEndpoints(t *testing.T) {
|
|
configUn := unmarshalFile("testdata/endpoints-config.json")
|
|
liveUn := unmarshalFile("testdata/endpoints-live.json")
|
|
dr := diff(t, configUn, liveUn, diffOptionsForTest()...)
|
|
if !assert.False(t, dr.Modified) {
|
|
ascii, err := printDiff(t.Context(), dr)
|
|
require.NoError(t, err)
|
|
t.Log(ascii)
|
|
}
|
|
}
|
|
|
|
func buildGVKParser(t *testing.T) *managedfields.GvkParser {
|
|
t.Helper()
|
|
document := &openapi_v2.Document{}
|
|
require.NoErrorf(t, proto.Unmarshal(testdata.OpenAPIV2Doc, document), "error unmarshaling openapi doc")
|
|
models, err := openapiproto.NewOpenAPIData(document)
|
|
require.NoErrorf(t, err, "error building openapi data: %s", err)
|
|
|
|
gvkParser, err := managedfields.NewGVKParser(models, false)
|
|
require.NoErrorf(t, err, "error building gvkParser: %s", err)
|
|
return gvkParser
|
|
}
|
|
|
|
func TestStructuredMergeDiff(t *testing.T) {
|
|
buildParams := func(live, config *unstructured.Unstructured) *SMDParams {
|
|
gvkParser := buildGVKParser(t)
|
|
manager := "argocd-controller"
|
|
return &SMDParams{
|
|
config: config,
|
|
live: live,
|
|
gvkParser: gvkParser,
|
|
manager: manager,
|
|
}
|
|
}
|
|
|
|
t.Run("will apply default values", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.ServiceLiveYAML)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigYAML)
|
|
params := buildParams(liveState, desiredState)
|
|
|
|
// when
|
|
result, err := structuredMergeDiff(params)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
predictedSVC := YamlToSvc(t, result.PredictedLive)
|
|
liveSVC := YamlToSvc(t, result.NormalizedLive)
|
|
require.NotNil(t, predictedSVC.Spec.InternalTrafficPolicy)
|
|
require.NotNil(t, liveSVC.Spec.InternalTrafficPolicy)
|
|
assert.Equal(t, "Cluster", string(*predictedSVC.Spec.InternalTrafficPolicy))
|
|
assert.Equal(t, "Cluster", string(*liveSVC.Spec.InternalTrafficPolicy))
|
|
assert.Empty(t, predictedSVC.Annotations[AnnotationLastAppliedConfig])
|
|
assert.Empty(t, liveSVC.Annotations[AnnotationLastAppliedConfig])
|
|
})
|
|
t.Run("will remove entries in list", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.ServiceLiveYAML)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigWith2Ports)
|
|
params := buildParams(liveState, desiredState)
|
|
|
|
// when
|
|
result, err := structuredMergeDiff(params)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
svc := YamlToSvc(t, result.PredictedLive)
|
|
assert.Len(t, svc.Spec.Ports, 2)
|
|
})
|
|
t.Run("will remove previously added fields not present in desired state", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.LiveServiceWithTypeYAML)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigYAML)
|
|
params := buildParams(liveState, desiredState)
|
|
|
|
// when
|
|
result, err := structuredMergeDiff(params)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
svc := YamlToSvc(t, result.PredictedLive)
|
|
assert.Equal(t, corev1.ServiceTypeClusterIP, svc.Spec.Type)
|
|
})
|
|
t.Run("will apply service with multiple ports", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.ServiceLiveYAML)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigWithSamePortsYAML)
|
|
params := buildParams(liveState, desiredState)
|
|
|
|
// when
|
|
result, err := structuredMergeDiff(params)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
svc := YamlToSvc(t, result.PredictedLive)
|
|
assert.Len(t, svc.Spec.Ports, 5)
|
|
})
|
|
t.Run("will apply deployment defaults correctly", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.DeploymentLiveYAML)
|
|
desiredState := StrToUnstructured(testdata.DeploymentConfigYAML)
|
|
params := buildParams(liveState, desiredState)
|
|
|
|
// when
|
|
result, err := structuredMergeDiff(params)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.False(t, result.Modified)
|
|
deploy := YamlToDeploy(t, result.PredictedLive)
|
|
assert.Len(t, deploy.Spec.Template.Spec.Containers, 1)
|
|
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String())
|
|
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Requests.Memory().String())
|
|
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Requests.Storage().String())
|
|
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Limits.Cpu().String())
|
|
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Limits.Memory().String())
|
|
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Limits.Storage().String())
|
|
require.NotNil(t, deploy.Spec.Strategy.RollingUpdate)
|
|
expectedMaxSurge := &intstr.IntOrString{
|
|
Type: intstr.String,
|
|
StrVal: "25%",
|
|
}
|
|
assert.Equal(t, expectedMaxSurge, deploy.Spec.Strategy.RollingUpdate.MaxSurge)
|
|
assert.Equal(t, "ClusterFirst", string(deploy.Spec.Template.Spec.DNSPolicy))
|
|
})
|
|
}
|
|
|
|
func TestServerSideDiff(t *testing.T) {
|
|
buildOpts := func(predictedLive string) []Option {
|
|
gvkParser := buildGVKParser(t)
|
|
manager := "argocd-controller"
|
|
dryRunner := mocks.NewServerSideDryRunner(t)
|
|
|
|
dryRunner.EXPECT().Run(mock.Anything, mock.AnythingOfType("*unstructured.Unstructured"), manager).
|
|
Return(predictedLive, nil)
|
|
opts := []Option{
|
|
WithGVKParser(gvkParser),
|
|
WithManager(manager),
|
|
WithServerSideDryRunner(dryRunner),
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
buildOptsWithNormalizer := func(predictedLive string, normalizer Normalizer) []Option {
|
|
opts := buildOpts(predictedLive)
|
|
return append(opts, WithNormalizer(normalizer))
|
|
}
|
|
|
|
t.Run("will ignore modifications done by mutation webhook by default", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.ServiceLiveYAMLSSD)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigYAMLSSD)
|
|
opts := buildOpts(testdata.ServicePredictedLiveJSONSSD)
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
predictedSVC := YamlToSvc(t, result.PredictedLive)
|
|
liveSVC := YamlToSvc(t, result.NormalizedLive)
|
|
require.NotNil(t, predictedSVC.Spec.InternalTrafficPolicy)
|
|
require.NotNil(t, liveSVC.Spec.InternalTrafficPolicy)
|
|
assert.Equal(t, "Cluster", string(*predictedSVC.Spec.InternalTrafficPolicy))
|
|
assert.Equal(t, "Cluster", string(*liveSVC.Spec.InternalTrafficPolicy))
|
|
assert.Empty(t, predictedSVC.Annotations[AnnotationLastAppliedConfig])
|
|
assert.Empty(t, liveSVC.Annotations[AnnotationLastAppliedConfig])
|
|
assert.Empty(t, predictedSVC.Labels["event"])
|
|
})
|
|
|
|
t.Run("will test removing some field with undoing changes done by webhook", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.Deployment2LiveYAML)
|
|
desiredState := StrToUnstructured(testdata.Deployment2ConfigYAML)
|
|
opts := buildOpts(testdata.Deployment2PredictedLiveJSONSSD)
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
predictedDeploy := YamlToDeploy(t, result.PredictedLive)
|
|
liveDeploy := YamlToDeploy(t, result.NormalizedLive)
|
|
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers, 1)
|
|
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers, 1)
|
|
assert.Equal(t, "500m", predictedDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String())
|
|
assert.Equal(t, "512Mi", predictedDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Memory().String())
|
|
assert.Equal(t, "500m", liveDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String())
|
|
assert.Equal(t, "512Mi", liveDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Memory().String())
|
|
})
|
|
|
|
t.Run("will include mutation webhook modifications", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.ServiceLiveYAMLSSD)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigYAMLSSD)
|
|
opts := buildOpts(testdata.ServicePredictedLiveJSONSSD)
|
|
opts = append(opts, WithIgnoreMutationWebhook(false))
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
predictedSVC := YamlToSvc(t, result.PredictedLive)
|
|
liveSVC := YamlToSvc(t, result.NormalizedLive)
|
|
require.NotNil(t, predictedSVC.Spec.InternalTrafficPolicy)
|
|
require.NotNil(t, liveSVC.Spec.InternalTrafficPolicy)
|
|
assert.Equal(t, "Cluster", string(*predictedSVC.Spec.InternalTrafficPolicy))
|
|
assert.Equal(t, "Cluster", string(*liveSVC.Spec.InternalTrafficPolicy))
|
|
assert.Empty(t, predictedSVC.Annotations[AnnotationLastAppliedConfig])
|
|
assert.Empty(t, liveSVC.Annotations[AnnotationLastAppliedConfig])
|
|
assert.NotEmpty(t, predictedSVC.Labels["event"])
|
|
})
|
|
|
|
t.Run("will include nested fields like ports and env", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.DeploymentNestedLiveYAMLSSD)
|
|
desiredState := StrToUnstructured(testdata.DeploymentNestedConfigYAMLSSD)
|
|
opts := buildOpts(testdata.DeploymentNestedPredictedLiveJSONSSD)
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
|
|
predictedDeploy := YamlToDeploy(t, result.PredictedLive)
|
|
liveDeploy := YamlToDeploy(t, result.NormalizedLive)
|
|
|
|
// Check ports
|
|
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers[0].Ports, 2)
|
|
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers[0].Ports, 1)
|
|
assert.Equal(t, int32(80), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort)
|
|
assert.Equal(t, int32(443), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[1].ContainerPort)
|
|
|
|
// Check env
|
|
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers[0].Env, 2)
|
|
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers[0].Env, 1)
|
|
assert.Equal(t, "ENV_VAR1", predictedDeploy.Spec.Template.Spec.Containers[0].Env[0].Name)
|
|
assert.Equal(t, "ENV_VAR2", predictedDeploy.Spec.Template.Spec.Containers[0].Env[1].Name)
|
|
})
|
|
|
|
t.Run("will add an extra container using kubectl apply and include mutation webhook", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.DeploymentApplyLiveYAMLSSD)
|
|
desiredState := StrToUnstructured(testdata.DeploymentApplyConfigYAMLSSD)
|
|
opts := buildOpts(testdata.DeploymentApplyPredictedLiveJSONSSD)
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
|
|
predictedDeploy := YamlToDeploy(t, result.PredictedLive)
|
|
liveDeploy := YamlToDeploy(t, result.NormalizedLive)
|
|
|
|
// Check ports are shown in diff and ensure mutation webhook is not shown
|
|
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers[0].Ports, 2)
|
|
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers[0].Ports, 1)
|
|
assert.Equal(t, int32(80), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort)
|
|
assert.Equal(t, int32(40), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[1].ContainerPort)
|
|
assert.Empty(t, predictedDeploy.Annotations[AnnotationLastAppliedConfig])
|
|
assert.Empty(t, liveDeploy.Annotations[AnnotationLastAppliedConfig])
|
|
})
|
|
|
|
t.Run("will reflect deletion of labels in predicted live", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.ServiceLiveLabelYAMLSSD)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigNoLabelYAMLSSD)
|
|
opts := buildOpts(testdata.ServicePredictedLiveNoLabelJSONSSD)
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
|
|
predictedSvc := YamlToSvc(t, result.PredictedLive)
|
|
liveSvc := YamlToSvc(t, result.NormalizedLive)
|
|
|
|
// Ensure that the deleted label is not present in predicted and exists in live
|
|
_, predictedLabelExists := predictedSvc.Labels["delete-me"]
|
|
_, liveLabelExists := liveSvc.Labels["delete-me"]
|
|
assert.False(t, predictedLabelExists)
|
|
assert.True(t, liveLabelExists)
|
|
})
|
|
|
|
t.Run("will respect ignoreDifferences when full normalization is not skipped", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.ServiceLiveYAMLSSD)
|
|
desiredState := StrToUnstructured(testdata.ServiceConfigYAMLSSD)
|
|
|
|
// Normalizer that ignores sessionAffinity (auto-assigned field that's commonly ignored)
|
|
normalizer := &testIgnoreDifferencesNormalizer{
|
|
fieldsToRemove: [][]string{
|
|
{"spec", "sessionAffinity"},
|
|
},
|
|
}
|
|
|
|
opts := buildOptsWithNormalizer(testdata.ServicePredictedLiveJSONSSD, normalizer)
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
|
|
// Should show diff for other fields but not the ignored sessionAffinity
|
|
assert.True(t, result.Modified, "Should show diff for non-ignored fields")
|
|
|
|
// Convert results to strings for verification
|
|
predictedLiveStr := string(result.PredictedLive)
|
|
normalizedLiveStr := string(result.NormalizedLive)
|
|
|
|
// Ports should appear in diff (not ignored)
|
|
assert.Contains(t, predictedLiveStr, "port", "Port differences should be visible")
|
|
|
|
// The ignored sessionAffinity should NOT appear in final result
|
|
assert.NotContains(t, predictedLiveStr, "sessionAffinity", "sessionAffinity should be removed by normalization")
|
|
assert.NotContains(t, normalizedLiveStr, "sessionAffinity", "sessionAffinity should be removed by normalization")
|
|
|
|
// Other fields should still be visible (not ignored)
|
|
assert.Contains(t, predictedLiveStr, "selector", "Other fields should remain visible")
|
|
})
|
|
|
|
t.Run("will preserve composite key fields during diff", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
liveState := StrToUnstructured(testdata.DeploymentCompositeKeyLiveYAMLSSD)
|
|
desiredState := StrToUnstructured(testdata.DeploymentCompositeKeyConfigYAMLSSD)
|
|
opts := buildOpts(testdata.DeploymentCompositeKeyPredictedLiveJSONSSD)
|
|
|
|
// when
|
|
result, err := serverSideDiff(desiredState, liveState, opts...)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.True(t, result.Modified)
|
|
|
|
predictedDeploy := YamlToDeploy(t, result.PredictedLive)
|
|
liveDeploy := YamlToDeploy(t, result.NormalizedLive)
|
|
|
|
// Verify the nginx container has all 3 ports in predicted live
|
|
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers, 2, "Should have 2 containers")
|
|
nginxContainer := predictedDeploy.Spec.Template.Spec.Containers[0]
|
|
assert.Equal(t, "nginx", nginxContainer.Name)
|
|
assert.Len(t, nginxContainer.Ports, 3, "nginx should have 3 ports in predicted")
|
|
|
|
// Verify live still has only 2 ports for nginx
|
|
liveNginxContainer := liveDeploy.Spec.Template.Spec.Containers[0]
|
|
assert.Len(t, liveNginxContainer.Ports, 2, "nginx should have 2 ports in live")
|
|
|
|
// Check that the new port 8080 has protocol field preserved (composite key field)
|
|
port8080Found := false
|
|
for _, port := range nginxContainer.Ports {
|
|
if port.ContainerPort == 8080 {
|
|
port8080Found = true
|
|
assert.Equal(t, "metrics", port.Name, "Port 8080 should have name 'metrics'")
|
|
assert.Equal(t, corev1.ProtocolTCP, port.Protocol, "Port 8080 protocol (composite key field) must be preserved from webhook")
|
|
}
|
|
}
|
|
assert.True(t, port8080Found, "Port 8080 should be present in predicted live")
|
|
|
|
// Verify existing ports still have their protocol (also composite key fields)
|
|
port80Found := false
|
|
port443Found := false
|
|
for _, port := range nginxContainer.Ports {
|
|
if port.ContainerPort == 80 {
|
|
port80Found = true
|
|
assert.Equal(t, corev1.ProtocolTCP, port.Protocol)
|
|
}
|
|
if port.ContainerPort == 443 {
|
|
port443Found = true
|
|
assert.Equal(t, corev1.ProtocolTCP, port.Protocol)
|
|
}
|
|
}
|
|
assert.True(t, port80Found, "Port 80 should be present")
|
|
assert.True(t, port443Found, "Port 443 should be present")
|
|
|
|
// Verify that mutation webhook changes are still filtered out from diff
|
|
assert.Empty(t, predictedDeploy.Annotations[AnnotationLastAppliedConfig])
|
|
assert.Empty(t, liveDeploy.Annotations[AnnotationLastAppliedConfig])
|
|
})
|
|
}
|
|
|
|
// testIgnoreDifferencesNormalizer implements a simple normalizer that removes specified fields
|
|
type testIgnoreDifferencesNormalizer struct {
|
|
fieldsToRemove [][]string
|
|
}
|
|
|
|
func (n *testIgnoreDifferencesNormalizer) Normalize(un *unstructured.Unstructured) error {
|
|
if un == nil {
|
|
return nil
|
|
}
|
|
for _, fieldPath := range n.fieldsToRemove {
|
|
unstructured.RemoveNestedField(un.Object, fieldPath...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createSecret(data map[string]string) *unstructured.Unstructured {
|
|
secret := corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret"}}
|
|
if data != nil {
|
|
secret.Data = make(map[string][]byte)
|
|
for k, v := range data {
|
|
secret.Data[k] = []byte(v)
|
|
}
|
|
}
|
|
|
|
return mustToUnstructured(&secret)
|
|
}
|
|
|
|
func secretData(obj *unstructured.Unstructured) map[string]any {
|
|
data, _, _ := unstructured.NestedMap(obj.Object, "data")
|
|
return data
|
|
}
|
|
|
|
var (
|
|
replacement1 = strings.Repeat("+", 8)
|
|
replacement2 = strings.Repeat("+", 12)
|
|
replacement3 = strings.Repeat("+", 16)
|
|
)
|
|
|
|
func TestHideSecretDataSameKeysDifferentValues(t *testing.T) {
|
|
target, live, err := HideSecretData(
|
|
createSecret(map[string]string{"key1": "test", "key2": "test"}),
|
|
createSecret(map[string]string{"key1": "test-1", "key2": "test-1"}),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, map[string]any{"key1": replacement1, "key2": replacement1}, secretData(target))
|
|
assert.Equal(t, map[string]any{"key1": replacement2, "key2": replacement2}, secretData(live))
|
|
}
|
|
|
|
func TestHideSecretDataSameKeysSameValues(t *testing.T) {
|
|
target, live, err := HideSecretData(
|
|
createSecret(map[string]string{"key1": "test", "key2": "test"}),
|
|
createSecret(map[string]string{"key1": "test", "key2": "test"}),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, map[string]any{"key1": replacement1, "key2": replacement1}, secretData(target))
|
|
assert.Equal(t, map[string]any{"key1": replacement1, "key2": replacement1}, secretData(live))
|
|
}
|
|
|
|
func TestHideSecretDataDifferentKeysDifferentValues(t *testing.T) {
|
|
target, live, err := HideSecretData(
|
|
createSecret(map[string]string{"key1": "test", "key2": "test"}),
|
|
createSecret(map[string]string{"key2": "test-1", "key3": "test-1"}),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, map[string]any{"key1": replacement1, "key2": replacement1}, secretData(target))
|
|
assert.Equal(t, map[string]any{"key2": replacement2, "key3": replacement1}, secretData(live))
|
|
}
|
|
|
|
func TestHideStringDataInInvalidSecret(t *testing.T) {
|
|
liveUn := &unstructured.Unstructured{
|
|
Object: map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]any{
|
|
"name": "test-secret",
|
|
},
|
|
"type": "Opaque",
|
|
"data": map[string]any{
|
|
"key1": "a2V5MQ==",
|
|
"key2": "a2V5MQ==",
|
|
},
|
|
},
|
|
}
|
|
targetUn := &unstructured.Unstructured{
|
|
Object: map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]any{
|
|
"name": "test-secret",
|
|
},
|
|
"type": "Opaque",
|
|
"data": map[string]any{
|
|
"key1": "a2V5MQ==",
|
|
"key2": "a2V5Mg==",
|
|
"key3": false,
|
|
},
|
|
"stringData": map[string]any{
|
|
"key4": "key4",
|
|
"key5": 5,
|
|
},
|
|
},
|
|
}
|
|
|
|
liveUn = remarshal(liveUn, applyOptions(diffOptionsForTest()))
|
|
targetUn = remarshal(targetUn, applyOptions(diffOptionsForTest()))
|
|
|
|
target, live, err := HideSecretData(targetUn, liveUn, nil)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, map[string]any{"key1": replacement1, "key2": replacement2}, secretData(live))
|
|
assert.Equal(t, map[string]any{"key1": replacement1, "key2": replacement1, "key3": replacement1, "key4": replacement1, "key5": replacement1}, secretData(target))
|
|
}
|
|
|
|
// stringData in secrets should be normalized even if it is invalid
|
|
func TestNormalizeSecret(t *testing.T) {
|
|
tests := []struct {
|
|
testname string
|
|
data map[string]any
|
|
stringData map[string]any
|
|
}{
|
|
{
|
|
testname: "Valid secret",
|
|
data: map[string]any{
|
|
"key1": "key1",
|
|
},
|
|
stringData: map[string]any{
|
|
"key2": "a2V5Mg==",
|
|
},
|
|
},
|
|
{
|
|
testname: "Invalid secret",
|
|
data: map[string]any{
|
|
"key1": "key1",
|
|
"key2": 2,
|
|
},
|
|
stringData: map[string]any{
|
|
"key3": "key3",
|
|
"key4": nil,
|
|
},
|
|
},
|
|
{
|
|
testname: "Invalid secret with stringData only",
|
|
data: nil,
|
|
stringData: map[string]any{
|
|
"key3": "key3",
|
|
"key4": true,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.testname, func(t *testing.T) {
|
|
un := &unstructured.Unstructured{
|
|
Object: map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]any{
|
|
"name": "test-secret",
|
|
},
|
|
"type": "Opaque",
|
|
"data": tt.data,
|
|
"stringData": tt.stringData,
|
|
},
|
|
}
|
|
un = remarshal(un, applyOptions(diffOptionsForTest()))
|
|
|
|
NormalizeSecret(un)
|
|
|
|
_, found, _ := unstructured.NestedMap(un.Object, "stringData")
|
|
assert.False(t, found)
|
|
|
|
data, found, _ := unstructured.NestedMap(un.Object, "data")
|
|
assert.True(t, found)
|
|
|
|
// check all secret keys are found under data in normalized secret
|
|
for _, obj := range []map[string]any{tt.data, tt.stringData} {
|
|
if obj == nil {
|
|
continue
|
|
}
|
|
for k := range obj {
|
|
_, ok := data[k]
|
|
assert.True(t, ok)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHideSecretAnnotations(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hideAnnots map[string]bool
|
|
annots map[string]any
|
|
expectedAnnots map[string]any
|
|
targetNil bool
|
|
}{
|
|
{
|
|
name: "no hidden annotations",
|
|
hideAnnots: nil,
|
|
annots: map[string]any{"token/value": "secret", "key": "secret-key", "app": "test"},
|
|
expectedAnnots: map[string]any{"token/value": "secret", "key": "secret-key", "app": "test"},
|
|
},
|
|
{
|
|
name: "hide annotations",
|
|
hideAnnots: map[string]bool{"token/value": true, "key": true},
|
|
annots: map[string]any{"token/value": "secret", "key": "secret-key", "app": "test"},
|
|
expectedAnnots: map[string]any{"token/value": replacement1, "key": replacement1, "app": "test"},
|
|
},
|
|
{
|
|
name: "hide annotations in last-applied-config",
|
|
hideAnnots: map[string]bool{"token/value": true, "key": true},
|
|
annots: map[string]any{
|
|
"token/value": "secret",
|
|
"app": "test",
|
|
"kubectl.kubernetes.io/last-applied-configuration": `{"apiVersion":"v1","kind":"Secret","metadata":{"annotations":{"app":"test","token/value":"secret","key":"secret-key"},"labels":{"app.kubernetes.io/instance":"test"},"name":"my-secret","namespace":"default"},"type":"Opaque"}`,
|
|
},
|
|
expectedAnnots: map[string]any{
|
|
"token/value": replacement1,
|
|
"app": "test",
|
|
"kubectl.kubernetes.io/last-applied-configuration": `{"apiVersion":"v1","kind":"Secret","metadata":{"annotations":{"app":"test","key":"++++++++","token/value":"++++++++"},"labels":{"app.kubernetes.io/instance":"test"},"name":"my-secret","namespace":"default"},"type":"Opaque"}`,
|
|
},
|
|
targetNil: true,
|
|
},
|
|
{
|
|
name: "special case: hide last-applied-config annotation",
|
|
hideAnnots: map[string]bool{"kubectl.kubernetes.io/last-applied-configuration": true},
|
|
annots: map[string]any{
|
|
"token/value": replacement1,
|
|
"app": "test",
|
|
"kubectl.kubernetes.io/last-applied-configuration": `{"apiVersion":"v1","kind":"Secret","metadata":{"annotations":{"app":"test","token/value":"secret","key":"secret-key"},"labels":{"app.kubernetes.io/instance":"test"},"name":"my-secret","namespace":"default"},"type":"Opaque"}`,
|
|
},
|
|
expectedAnnots: map[string]any{
|
|
"app": "test",
|
|
"kubectl.kubernetes.io/last-applied-configuration": replacement1,
|
|
},
|
|
targetNil: true,
|
|
},
|
|
{
|
|
name: "hide annotations for malformed annotations",
|
|
hideAnnots: map[string]bool{"token/value": true, "key": true},
|
|
annots: map[string]any{"token/value": 0, "key": "secret", "app": true},
|
|
expectedAnnots: map[string]any{"token/value": replacement1, "key": replacement1, "app": true},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
unSecret := &unstructured.Unstructured{
|
|
Object: map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]any{
|
|
"name": "test-secret",
|
|
"annotations": tt.annots,
|
|
},
|
|
"type": "Opaque",
|
|
},
|
|
}
|
|
|
|
liveUn := remarshal(unSecret, applyOptions(diffOptionsForTest()))
|
|
targetUn := remarshal(unSecret, applyOptions(diffOptionsForTest()))
|
|
|
|
if tt.targetNil {
|
|
targetUn = nil
|
|
}
|
|
|
|
target, live, err := HideSecretData(targetUn, liveUn, tt.hideAnnots)
|
|
require.NoError(t, err)
|
|
|
|
// verify configured annotations are hidden
|
|
for _, obj := range []*unstructured.Unstructured{target, live} {
|
|
if obj != nil {
|
|
annots, _, _ := unstructured.NestedMap(obj.Object, "metadata", "annotations")
|
|
for ek, ev := range tt.expectedAnnots {
|
|
v, found := annots[ek]
|
|
assert.True(t, found)
|
|
assert.Equal(t, ev, v)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHideSecretAnnotationsPreserveDifference(t *testing.T) {
|
|
hideAnnots := map[string]bool{"token/value": true}
|
|
|
|
liveUn := &unstructured.Unstructured{
|
|
Object: map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]any{
|
|
"name": "test-secret",
|
|
"annotations": map[string]any{"token/value": "secret", "app": "test"},
|
|
},
|
|
"type": "Opaque",
|
|
},
|
|
}
|
|
targetUn := &unstructured.Unstructured{
|
|
Object: map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]any{
|
|
"name": "test-secret",
|
|
"annotations": map[string]any{"token/value": "new-secret", "app": "test"},
|
|
},
|
|
"type": "Opaque",
|
|
},
|
|
}
|
|
|
|
liveUn = remarshal(liveUn, applyOptions(diffOptionsForTest()))
|
|
targetUn = remarshal(targetUn, applyOptions(diffOptionsForTest()))
|
|
|
|
target, live, err := HideSecretData(targetUn, liveUn, hideAnnots)
|
|
require.NoError(t, err)
|
|
|
|
liveAnnots := live.GetAnnotations()
|
|
v, found := liveAnnots["token/value"]
|
|
assert.True(t, found)
|
|
assert.Equal(t, replacement2, v)
|
|
|
|
targetAnnots := target.GetAnnotations()
|
|
v, found = targetAnnots["token/value"]
|
|
assert.True(t, found)
|
|
assert.Equal(t, replacement1, v)
|
|
}
|
|
|
|
func getTargetSecretJsonBytes() []byte {
|
|
return []byte(`
|
|
{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"type": "kubernetes.io/service-account-token",
|
|
"metadata": {
|
|
"annotations": {
|
|
"kubernetes.io/service-account.name": "default"
|
|
},
|
|
"labels": {
|
|
"app.kubernetes.io/instance": "empty-secret"
|
|
},
|
|
"name": "an-empty-secret",
|
|
"namespace": "default"
|
|
},
|
|
"data": {}
|
|
}`)
|
|
}
|
|
|
|
func getLiveSecretJsonBytes() []byte {
|
|
return []byte(`
|
|
{
|
|
"kind": "Secret",
|
|
"apiVersion": "v1",
|
|
"type": "kubernetes.io/service-account-token",
|
|
"metadata": {
|
|
"annotations": {
|
|
"kubernetes.io/service-account.name": "default",
|
|
"kubernetes.io/service-account.uid": "78688180-d432-4ee8-939d-382b015a6b13"
|
|
},
|
|
"creationTimestamp": "2021-10-27T19:09:22Z",
|
|
"labels": {
|
|
"app.kubernetes.io/instance": "empty-secret"
|
|
},
|
|
"name": "an-empty-secret",
|
|
"namespace": "default",
|
|
"resourceVersion": "2329692",
|
|
"uid": "2e98590d-a699-4281-89d5-aa94dfc1d7d7"
|
|
},
|
|
"data": {
|
|
"namespace": "ZGVmYXVsdA==",
|
|
"token": "ZGVmYXVsdAcb=="
|
|
}
|
|
}`)
|
|
}
|
|
|
|
func bytesToUnstructured(t *testing.T, jsonBytes []byte) *unstructured.Unstructured {
|
|
t.Helper()
|
|
var jsonMap map[string]any
|
|
require.NoError(t, json.Unmarshal(jsonBytes, &jsonMap))
|
|
return &unstructured.Unstructured{
|
|
Object: jsonMap,
|
|
}
|
|
}
|
|
|
|
func TestHideSecretDataHandleEmptySecret(t *testing.T) {
|
|
// given
|
|
targetSecret := bytesToUnstructured(t, getTargetSecretJsonBytes())
|
|
liveSecret := bytesToUnstructured(t, getLiveSecretJsonBytes())
|
|
|
|
// when
|
|
target, live, err := HideSecretData(targetSecret, liveSecret, nil)
|
|
|
|
// then
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, target)
|
|
assert.NotNil(t, live)
|
|
assert.Nil(t, target.Object["data"])
|
|
assert.Equal(t, map[string]any{"namespace": "++++++++", "token": "++++++++"}, secretData(live))
|
|
}
|
|
|
|
func TestHideSecretDataLastAppliedConfig(t *testing.T) {
|
|
lastAppliedSecret := createSecret(map[string]string{"key1": "test1"})
|
|
targetSecret := createSecret(map[string]string{"key1": "test2"})
|
|
liveSecret := createSecret(map[string]string{"key1": "test3"})
|
|
lastAppliedStr, err := json.Marshal(lastAppliedSecret)
|
|
require.NoError(t, err)
|
|
liveSecret.SetAnnotations(map[string]string{corev1.LastAppliedConfigAnnotation: string(lastAppliedStr)})
|
|
|
|
target, live, err := HideSecretData(targetSecret, liveSecret, nil)
|
|
require.NoError(t, err)
|
|
err = json.Unmarshal([]byte(live.GetAnnotations()[corev1.LastAppliedConfigAnnotation]), &lastAppliedSecret)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, map[string]any{"key1": replacement1}, secretData(target))
|
|
assert.Equal(t, map[string]any{"key1": replacement2}, secretData(live))
|
|
assert.Equal(t, map[string]any{"key1": replacement3}, secretData(lastAppliedSecret))
|
|
}
|
|
|
|
func TestRemarshal(t *testing.T) {
|
|
manifest := []byte(`
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
imagePullSecrets: []
|
|
metadata:
|
|
name: my-sa
|
|
`)
|
|
var un unstructured.Unstructured
|
|
require.NoError(t, yaml.Unmarshal(manifest, &un))
|
|
newUn := remarshal(&un, applyOptions(diffOptionsForTest()))
|
|
_, ok := newUn.Object["imagePullSecrets"]
|
|
assert.False(t, ok)
|
|
metadata := newUn.Object["metadata"].(map[string]any)
|
|
_, ok = metadata["creationTimestamp"]
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestRemarshalStatefulSetCreationTimestamp(t *testing.T) {
|
|
manifest := []byte(`
|
|
apiVersion: apps/v1
|
|
kind: StatefulSet
|
|
metadata:
|
|
name: test-sts
|
|
creationTimestamp: "2025-11-06T19:35:31Z"
|
|
spec:
|
|
serviceName: test
|
|
selector:
|
|
matchLabels:
|
|
app: test
|
|
template:
|
|
metadata:
|
|
creationTimestamp: null
|
|
labels:
|
|
app: test
|
|
spec:
|
|
containers:
|
|
- name: test
|
|
image: nginx
|
|
volumeClaimTemplates:
|
|
- metadata:
|
|
name: data
|
|
creationTimestamp: null
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
resources:
|
|
requests:
|
|
storage: 1Gi
|
|
`)
|
|
var un unstructured.Unstructured
|
|
require.NoError(t, yaml.Unmarshal(manifest, &un))
|
|
|
|
// Verify creationTimestamp exists in nested metadata before remarshal
|
|
spec := un.Object["spec"].(map[string]any)
|
|
templateMetadata := spec["template"].(map[string]any)["metadata"].(map[string]any)
|
|
_, ok := templateMetadata["creationTimestamp"]
|
|
assert.True(t, ok, "creationTimestamp should exist in template.metadata before remarshal")
|
|
|
|
volumeClaimTemplates := spec["volumeClaimTemplates"].([]any)
|
|
vctMetadata := volumeClaimTemplates[0].(map[string]any)["metadata"].(map[string]any)
|
|
_, ok = vctMetadata["creationTimestamp"]
|
|
assert.True(t, ok, "creationTimestamp should exist in volumeClaimTemplates[0].metadata before remarshal")
|
|
|
|
// Remarshal
|
|
newUn := remarshal(&un, applyOptions(diffOptionsForTest()))
|
|
|
|
// Verify creationTimestamp is removed from nested metadata after remarshal
|
|
// (top-level metadata.creationTimestamp is preserved as it's part of the resource identity)
|
|
spec = newUn.Object["spec"].(map[string]any)
|
|
templateMetadata = spec["template"].(map[string]any)["metadata"].(map[string]any)
|
|
_, ok = templateMetadata["creationTimestamp"]
|
|
assert.False(t, ok, "creationTimestamp should be removed from template.metadata after remarshal")
|
|
|
|
volumeClaimTemplates = spec["volumeClaimTemplates"].([]any)
|
|
vctMetadata = volumeClaimTemplates[0].(map[string]any)["metadata"].(map[string]any)
|
|
_, ok = vctMetadata["creationTimestamp"]
|
|
assert.False(t, ok, "creationTimestamp should be removed from volumeClaimTemplates[0].metadata after remarshal")
|
|
}
|
|
|
|
func TestRemarshalResources(t *testing.T) {
|
|
getRequests := func(un *unstructured.Unstructured) map[string]any {
|
|
return un.Object["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["resources"].(map[string]any)["requests"].(map[string]any)
|
|
}
|
|
|
|
setRequests := func(un *unstructured.Unstructured, requests map[string]any) {
|
|
un.Object["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["resources"].(map[string]any)["requests"] = requests
|
|
}
|
|
|
|
manifest := []byte(`
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: my-pod
|
|
spec:
|
|
containers:
|
|
- image: nginx:1.7.9
|
|
name: nginx
|
|
resources:
|
|
requests:
|
|
cpu: 0.2
|
|
`)
|
|
un := unstructured.Unstructured{}
|
|
require.NoError(t, yaml.Unmarshal(manifest, &un))
|
|
|
|
testCases := []struct {
|
|
name string
|
|
cpu any
|
|
expectedCPU any
|
|
}{
|
|
{"from float", 0.2, "200m"},
|
|
{"from float64", float64(0.2), "200m"},
|
|
{"from string", "0.2", "200m"},
|
|
{"from invalid", "invalid", "invalid"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setRequests(&un, map[string]any{"cpu": tc.cpu})
|
|
newUn := remarshal(&un, applyOptions(diffOptionsForTest()))
|
|
requestsAfter := getRequests(newUn)
|
|
assert.Equal(t, tc.expectedCPU, requestsAfter["cpu"])
|
|
})
|
|
}
|
|
}
|
|
|
|
func ExampleDiff() {
|
|
expectedResource := unstructured.Unstructured{}
|
|
if err := yaml.Unmarshal([]byte(`
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: my-pod
|
|
spec:
|
|
containers:
|
|
- image: nginx:1.7.9
|
|
name: nginx
|
|
resources:
|
|
requests:
|
|
cpu: 0.2
|
|
`), &expectedResource); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
liveResource := unstructured.Unstructured{}
|
|
if err := yaml.Unmarshal([]byte(`
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: my-pod-123
|
|
creationTimestamp: "2020-03-30T21:34:59Z"
|
|
labels:
|
|
pod-template-hash: 84bf9649fd
|
|
name: argo-cd-cli-84bf9649fd-tm59q
|
|
resourceVersion: "233081332"
|
|
uid: 9a5ae31a-eed2-4f82-81fe-833799c54f99
|
|
spec:
|
|
containers:
|
|
- image: nginx:1.7.9
|
|
name: nginx
|
|
resources:
|
|
requests:
|
|
cpu: 0.1
|
|
`), &liveResource); err != nil {
|
|
panic(err)
|
|
}
|
|
diff, err := Diff(&expectedResource, &liveResource, diffOptionsForTest()...)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if diff.Modified {
|
|
fmt.Println("Resources are different")
|
|
}
|
|
}
|
|
|
|
func diffOptionsForTest() []Option {
|
|
return []Option{
|
|
WithLogr(textlogger.NewLogger(textlogger.NewConfig())),
|
|
IgnoreAggregatedRoles(false),
|
|
}
|
|
}
|
|
|
|
func YamlToSvc(t *testing.T, y []byte) *corev1.Service {
|
|
t.Helper()
|
|
svc := corev1.Service{}
|
|
require.NoErrorf(t, yaml.Unmarshal(y, &svc), "error unmarshaling service bytes")
|
|
return &svc
|
|
}
|
|
|
|
func YamlToDeploy(t *testing.T, y []byte) *appsv1.Deployment {
|
|
t.Helper()
|
|
deploy := appsv1.Deployment{}
|
|
require.NoErrorf(t, yaml.Unmarshal(y, &deploy), "error unmarshaling deployment bytes")
|
|
return &deploy
|
|
}
|
|
|
|
func StrToUnstructured(yamlStr string) *unstructured.Unstructured {
|
|
obj := make(map[string]any)
|
|
err := yaml.Unmarshal([]byte(yamlStr), &obj)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &unstructured.Unstructured{Object: obj}
|
|
}
|
|
|
|
func TestDiffWithIgnoreDifferences(t *testing.T) {
|
|
t.Run("TwoWayDiff will respect ignoreDifferences for comparison but not output normalization", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
|
|
// Create a simple service with sessionAffinity that should be ignored
|
|
liveService := StrToUnstructured(`
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: my-service
|
|
spec:
|
|
selector:
|
|
app: my-app
|
|
ports:
|
|
- port: 80
|
|
sessionAffinity: None
|
|
type: ClusterIP
|
|
`)
|
|
|
|
desiredService := StrToUnstructured(`
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: my-service
|
|
spec:
|
|
selector:
|
|
app: my-app
|
|
ports:
|
|
- port: 80
|
|
sessionAffinity: ClientIP
|
|
type: ClusterIP
|
|
`)
|
|
|
|
// Normalizer that ignores sessionAffinity
|
|
normalizer := &testIgnoreDifferencesNormalizer{
|
|
fieldsToRemove: [][]string{
|
|
{"spec", "sessionAffinity"},
|
|
},
|
|
}
|
|
|
|
opts := []Option{
|
|
WithNormalizer(normalizer),
|
|
WithLogr(textlogger.NewLogger(textlogger.NewConfig())),
|
|
}
|
|
|
|
// when
|
|
result, err := Diff(desiredService, liveService, opts...)
|
|
require.NoError(t, err)
|
|
|
|
// then
|
|
assert.NotNil(t, result)
|
|
|
|
// Since sessionAffinity is ignored in input normalization, there should be no modification
|
|
assert.False(t, result.Modified, "Should not show diff for ignored fields")
|
|
|
|
predictedLiveStr := string(result.PredictedLive)
|
|
normalizedLiveStr := string(result.NormalizedLive)
|
|
|
|
// NOTE: Unlike server-side diff, TwoWayDiff/ThreeWayDiff don't normalize outputs
|
|
// So sessionAffinity WILL still appear in the output bytes, but Modified should be false
|
|
// because input normalization removed the differences during comparison
|
|
assert.Contains(t, predictedLiveStr, "sessionAffinity", "sessionAffinity should still appear in output (no output normalization)")
|
|
assert.Contains(t, normalizedLiveStr, "sessionAffinity", "sessionAffinity should still appear in output (no output normalization)")
|
|
})
|
|
|
|
t.Run("ThreeWayDiff will respect ignoreDifferences for comparison but not output normalization", func(t *testing.T) {
|
|
// given
|
|
t.Parallel()
|
|
|
|
// Create config and live with sessionAffinity differences that should be ignored
|
|
configService := StrToUnstructured(`
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: my-service
|
|
spec:
|
|
selector:
|
|
app: my-app
|
|
ports:
|
|
- port: 80
|
|
sessionAffinity: ClientIP
|
|
type: ClusterIP
|
|
`)
|
|
|
|
liveService := StrToUnstructured(`
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: my-service
|
|
annotations:
|
|
kubectl.kubernetes.io/last-applied-configuration: |
|
|
{"apiVersion":"v1","kind":"Service","metadata":{"name":"my-service"},"spec":{"selector":{"app":"my-app"},"ports":[{"port":80}],"sessionAffinity":"None","type":"ClusterIP"}}
|
|
spec:
|
|
selector:
|
|
app: my-app
|
|
ports:
|
|
- port: 80
|
|
sessionAffinity: None
|
|
type: ClusterIP
|
|
`)
|
|
|
|
// Normalizer that ignores sessionAffinity
|
|
normalizer := &testIgnoreDifferencesNormalizer{
|
|
fieldsToRemove: [][]string{
|
|
{"spec", "sessionAffinity"},
|
|
},
|
|
}
|
|
|
|
opts := []Option{
|
|
WithNormalizer(normalizer),
|
|
WithLogr(textlogger.NewLogger(textlogger.NewConfig())),
|
|
}
|
|
|
|
// when
|
|
result, err := Diff(configService, liveService, opts...)
|
|
require.NoError(t, err)
|
|
|
|
// then
|
|
assert.NotNil(t, result)
|
|
|
|
// Since sessionAffinity is ignored in input normalization, there should be no modification
|
|
assert.False(t, result.Modified, "Should not show diff for ignored fields")
|
|
|
|
predictedLiveStr := string(result.PredictedLive)
|
|
normalizedLiveStr := string(result.NormalizedLive)
|
|
|
|
// NOTE: Unlike server-side diff, TwoWayDiff/ThreeWayDiff don't normalize outputs
|
|
// So sessionAffinity WILL still appear in the output bytes, but Modified should be false
|
|
// because input normalization removed the differences during comparison
|
|
assert.Contains(t, predictedLiveStr, "sessionAffinity", "sessionAffinity should still appear in output (no output normalization)")
|
|
assert.Contains(t, normalizedLiveStr, "sessionAffinity", "sessionAffinity should still appear in output (no output normalization)")
|
|
})
|
|
}
|