Skip to content

Commit

Permalink
Implement k8s manifest diff (#5298)
Browse files Browse the repository at this point in the history
Signed-off-by: Shinnosuke Sawada-Dazai <[email protected]>
  • Loading branch information
Warashi authored Oct 30, 2024
1 parent c842a89 commit 8b49a84
Show file tree
Hide file tree
Showing 5 changed files with 721 additions and 5 deletions.
84 changes: 84 additions & 0 deletions pkg/app/pipedv1/plugin/kubernetes/provider/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2024 The PipeCD 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 provider

import (
"go.uber.org/zap"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"

"github.com/pipe-cd/pipecd/pkg/plugin/diff"
)


func Diff(old, new Manifest, logger *zap.Logger, opts ...diff.Option) (*diff.Result, error) {
if old.Key.IsSecret() && new.Key.IsSecret() {
var err error
old.Body, err = normalizeNewSecret(old.Body, new.Body)
if err != nil {
return nil, err
}
}

key := old.Key.String()

normalizedOld, err := remarshal(old.Body)
if err != nil {
logger.Info("compare manifests directly since it was unable to remarshal old Kubernetes manifest to normalize special fields", zap.Error(err))
return diff.DiffUnstructureds(*old.Body, *new.Body, key, opts...)
}

normalizedNew, err := remarshal(new.Body)
if err != nil {
logger.Info("compare manifests directly since it was unable to remarshal new Kubernetes manifest to normalize special fields", zap.Error(err))
return diff.DiffUnstructureds(*old.Body, *new.Body, key, opts...)
}

return diff.DiffUnstructureds(*normalizedOld, *normalizedNew, key, opts...)
}

func normalizeNewSecret(old, new *unstructured.Unstructured) (*unstructured.Unstructured, error) {
var o, n v1.Secret
runtime.DefaultUnstructuredConverter.FromUnstructured(old.Object, &o)
runtime.DefaultUnstructuredConverter.FromUnstructured(new.Object, &n)

// Move as much as possible fields from `o.Data` to `o.StringData` to make `o` close to `n` to minimize the diff.
for k, v := range o.Data {
// Skip if the field also exists in StringData.
if _, ok := o.StringData[k]; ok {
continue
}

if _, ok := n.StringData[k]; !ok {
continue
}

if o.StringData == nil {
o.StringData = make(map[string]string)
}

// If the field is existing in `n.StringData`, we should move that field from `o.Data` to `o.StringData`
o.StringData[k] = string(v)
delete(o.Data, k)
}

newO, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&o)
if err != nil {
return nil, err
}

return &unstructured.Unstructured{Object: newO}, nil
}
239 changes: 239 additions & 0 deletions pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// Copyright 2024 The PipeCD 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 provider

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/pipe-cd/pipecd/pkg/plugin/diff"
)

func TestDiff(t *testing.T) {
t.Parallel()

testcases := []struct {
name string
manifests string
expected string
diffNum int
falsePositive bool
}{
{
name: "Secret no diff 1",
manifests: `apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
---
apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
`,
expected: "",
diffNum: 0,
},
{
name: "Secret no diff 2",
manifests: `apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
password: hoge
stringData:
foo: bar
---
apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
password: hoge
stringData:
foo: bar
`,
expected: "",
diffNum: 0,
},
{
name: "Secret no diff with merge",
manifests: `apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
password: hoge
foo: YmFy
---
apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
password: hoge
stringData:
foo: bar
`,
expected: "",
diffNum: 0,
},
{
name: "Secret no diff override false-positive",
manifests: `apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
password: hoge
foo: YmFy
---
apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
password: hoge
foo: Zm9v
stringData:
foo: bar
`,
expected: "",
diffNum: 0,
falsePositive: true,
},
{
name: "Secret has diff",
manifests: `apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
foo: YmFy
---
apiVersion: apps/v1
kind: Secret
metadata:
name: secret-management
data:
password: hoge
stringData:
foo: bar
`,
expected: ` #data
+ data:
+ password: hoge
`,
diffNum: 1,
},
{
name: "Pod no diff 1",
manifests: `apiVersion: v1
kind: Pod
metadata:
name: static-web
labels:
role: myrole
spec:
containers:
- name: web
image: nginx
resources:
limits:
memory: "2Gi"
---
apiVersion: v1
kind: Pod
metadata:
name: static-web
labels:
role: myrole
spec:
containers:
- name: web
image: nginx
ports:
resources:
limits:
memory: "2Gi"
`,
expected: "",
diffNum: 0,
falsePositive: false,
},
{
name: "Pod no diff 2",
manifests: `apiVersion: v1
kind: Pod
metadata:
name: static-web
labels:
role: myrole
spec:
containers:
- name: web
image: nginx
resources:
limits:
memory: "1536Mi"
---
apiVersion: v1
kind: Pod
metadata:
name: static-web
labels:
role: myrole
spec:
containers:
- name: web
image: nginx
ports:
resources:
limits:
memory: "1.5Gi"
`,
expected: "",
diffNum: 0,
falsePositive: false,
},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
manifests, err := ParseManifests(tc.manifests)
require.NoError(t, err)
require.Equal(t, 2, len(manifests))
old, new := manifests[0], manifests[1]

result, err := Diff(old, new, zap.NewNop(), diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString())
require.NoError(t, err)

renderer := diff.NewRenderer(diff.WithLeftPadding(1))
ds := renderer.Render(result.Nodes())
if tc.falsePositive {
assert.NotEqual(t, tc.diffNum, result.NumNodes())
assert.NotEqual(t, tc.expected, ds)
} else {
assert.Equal(t, tc.diffNum, result.NumNodes())
assert.Equal(t, tc.expected, ds)
}
})
}
}
Loading

0 comments on commit 8b49a84

Please sign in to comment.