Skip to content

Commit 494eef0

Browse files
luxurine宋正飞
andauthored
feat: --three-way-merge to show diff for actual state vs desired state (#304)
Resolves #176 Co-authored-by: 宋正飞 <[email protected]>
1 parent 860f354 commit 494eef0

File tree

6 files changed

+935
-31
lines changed

6 files changed

+935
-31
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ bin/
33
build/
44
release/
55
.envrc
6+
.idea

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ VERSION := $(shell sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' plugin.yaml)
33

44
HELM_3_PLUGINS := $(shell bash -c 'eval $$(helm env); echo $$HELM_PLUGINS')
55

6-
PKG:= github.com/databus23/helm-diff
6+
PKG:= github.com/databus23/helm-diff/v3
77
LDFLAGS := -X $(PKG)/cmd.Version=$(VERSION)
88

99
# Clear the "unreleased" string in BuildMetadata

cmd/upgrade.go

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
11
package cmd
22

33
import (
4-
"errors"
4+
"bytes"
5+
"encoding/json"
56
"fmt"
7+
"log"
68
"os"
79
"strings"
810

11+
jsoniterator "github.com/json-iterator/go"
12+
"helm.sh/helm/v3/pkg/action"
13+
"helm.sh/helm/v3/pkg/cli"
14+
15+
jsonpatch "github.com/evanphx/json-patch"
16+
"github.com/pkg/errors"
17+
"helm.sh/helm/v3/pkg/kube"
18+
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
19+
apierrors "k8s.io/apimachinery/pkg/api/errors"
20+
"k8s.io/apimachinery/pkg/runtime"
21+
"k8s.io/apimachinery/pkg/types"
22+
"k8s.io/apimachinery/pkg/util/strategicpatch"
23+
"k8s.io/cli-runtime/pkg/resource"
24+
"sigs.k8s.io/yaml"
25+
926
"github.com/spf13/cobra"
1027
"k8s.io/helm/pkg/helm"
1128

@@ -42,6 +59,7 @@ type diffCmd struct {
4259
install bool
4360
stripTrailingCR bool
4461
normalizeManifests bool
62+
threeWayMerge bool
4563
}
4664

4765
func (d *diffCmd) isAllowUnreleased() bool {
@@ -59,6 +77,9 @@ This can be used visualize what changes a helm upgrade will
5977
perform.
6078
`
6179

80+
var envSettings = cli.New()
81+
var yamlSeperator = []byte("\n---\n")
82+
6283
func newChartCommand() *cobra.Command {
6384
diff := diffCmd{
6485
namespace: os.Getenv("HELM_NAMESPACE"),
@@ -98,6 +119,8 @@ func newChartCommand() *cobra.Command {
98119
f := cmd.Flags()
99120
var kubeconfig string
100121
f.StringVar(&kubeconfig, "kubeconfig", "", "This flag is ignored, to allow passing of this top level flag to helm")
122+
f.BoolVar(&diff.threeWayMerge, "three-way-merge", false, "use three-way-merge to compute patch and generate diff output")
123+
// f.StringVar(&diff.kubeContext, "kube-context", "", "name of the kubeconfig context to use")
101124
f.StringVar(&diff.chartVersion, "version", "", "specify the exact chart version to use. If this is not specified, the latest version is used")
102125
f.StringVar(&diff.chartRepo, "repo", "", "specify the chart repository url to locate the requested chart")
103126
f.BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes")
@@ -169,6 +192,25 @@ func (d *diffCmd) runHelm3() error {
169192
return fmt.Errorf("Failed to render chart: %s", err)
170193
}
171194

195+
if d.threeWayMerge {
196+
actionConfig := new(action.Configuration)
197+
if err := actionConfig.Init(envSettings.RESTClientGetter(), envSettings.Namespace(), os.Getenv("HELM_DRIVER"), log.Printf); err != nil {
198+
log.Fatalf("%+v", err)
199+
}
200+
if err := actionConfig.KubeClient.IsReachable(); err != nil {
201+
return err
202+
}
203+
original, err := actionConfig.KubeClient.Build(bytes.NewBuffer(releaseManifest), false)
204+
if err != nil {
205+
return errors.Wrap(err, "unable to build kubernetes objects from original release manifest")
206+
}
207+
target, err := actionConfig.KubeClient.Build(bytes.NewBuffer(installManifest), false)
208+
if err != nil {
209+
return errors.Wrap(err, "unable to build kubernetes objects from new release manifest")
210+
}
211+
releaseManifest, installManifest, err = genManifest(original, target)
212+
}
213+
172214
currentSpecs := make(map[string]*manifest.MappingResult)
173215
if !newInstall && !d.dryRun {
174216
if !d.noHooks {
@@ -202,6 +244,112 @@ func (d *diffCmd) runHelm3() error {
202244
return nil
203245
}
204246

247+
func genManifest(original, target kube.ResourceList) ([]byte, []byte, error) {
248+
var err error
249+
releaseManifest, installManifest := make([]byte, 0), make([]byte, 0)
250+
251+
// to be deleted
252+
targetResources := make(map[string]bool)
253+
for _, r := range target {
254+
targetResources[objectKey(r)] = true
255+
}
256+
for _, r := range original {
257+
if !targetResources[objectKey(r)] {
258+
out, _ := yaml.Marshal(r.Object)
259+
releaseManifest = append(releaseManifest, yamlSeperator...)
260+
releaseManifest = append(releaseManifest, out...)
261+
}
262+
}
263+
264+
existingResources := make(map[string]bool)
265+
for _, r := range original {
266+
existingResources[objectKey(r)] = true
267+
}
268+
269+
var toBeCreated kube.ResourceList
270+
for _, r := range target {
271+
if !existingResources[objectKey(r)] {
272+
toBeCreated = append(toBeCreated, r)
273+
}
274+
}
275+
276+
toBeUpdated, err := existingResourceConflict(toBeCreated)
277+
if err != nil {
278+
return nil, nil, errors.Wrap(err, "rendered manifests contain a resource that already exists. Unable to continue with update")
279+
}
280+
281+
_ = toBeUpdated.Visit(func(r *resource.Info, err error) error {
282+
if err != nil {
283+
return err
284+
}
285+
original.Append(r)
286+
return nil
287+
})
288+
289+
err = target.Visit(func(info *resource.Info, err error) error {
290+
if err != nil {
291+
return err
292+
}
293+
kind := info.Mapping.GroupVersionKind.Kind
294+
295+
// Fetch the current object for the three way merge
296+
helper := resource.NewHelper(info.Client, info.Mapping)
297+
currentObj, err := helper.Get(info.Namespace, info.Name, info.Export)
298+
if err != nil {
299+
if !apierrors.IsNotFound(err) {
300+
return errors.Wrap(err, "could not get information about the resource")
301+
}
302+
// to be created
303+
out, _ := yaml.Marshal(info.Object)
304+
installManifest = append(installManifest, yamlSeperator...)
305+
installManifest = append(installManifest, out...)
306+
return nil
307+
}
308+
// to be updated
309+
out, _ := jsoniterator.ConfigCompatibleWithStandardLibrary.Marshal(currentObj)
310+
pruneObj, err := deleteStatusAndManagedFields(out)
311+
if err != nil {
312+
return errors.Wrapf(err, "prune current obj %q with kind %s", info.Name, kind)
313+
}
314+
pruneOut, err := yaml.Marshal(pruneObj)
315+
if err != nil {
316+
return errors.Wrapf(err, "prune current out %q with kind %s", info.Name, kind)
317+
}
318+
releaseManifest = append(releaseManifest, yamlSeperator...)
319+
releaseManifest = append(releaseManifest, pruneOut...)
320+
321+
originalInfo := original.Get(info)
322+
if originalInfo == nil {
323+
return fmt.Errorf("could not find %q", info.Name)
324+
}
325+
326+
patch, patchType, err := createPatch(originalInfo.Object, currentObj, info)
327+
if err != nil {
328+
return err
329+
}
330+
331+
helper.ServerDryRun = true
332+
targetObj, err := helper.Patch(info.Namespace, info.Name, patchType, patch, nil)
333+
if err != nil {
334+
return errors.Wrapf(err, "cannot patch %q with kind %s", info.Name, kind)
335+
}
336+
out, _ = jsoniterator.ConfigCompatibleWithStandardLibrary.Marshal(targetObj)
337+
pruneObj, err = deleteStatusAndManagedFields(out)
338+
if err != nil {
339+
return errors.Wrapf(err, "prune current obj %q with kind %s", info.Name, kind)
340+
}
341+
pruneOut, err = yaml.Marshal(pruneObj)
342+
if err != nil {
343+
return errors.Wrapf(err, "prune current out %q with kind %s", info.Name, kind)
344+
}
345+
installManifest = append(installManifest, yamlSeperator...)
346+
installManifest = append(installManifest, pruneOut...)
347+
return nil
348+
})
349+
350+
return releaseManifest, installManifest, err
351+
}
352+
205353
func (d *diffCmd) run() error {
206354
if d.chartVersion == "" && d.devel {
207355
d.chartVersion = ">0.0.0-0"
@@ -287,3 +435,92 @@ func (d *diffCmd) run() error {
287435

288436
return nil
289437
}
438+
439+
func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) ([]byte, types.PatchType, error) {
440+
oldData, err := json.Marshal(originalObj)
441+
if err != nil {
442+
return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing current configuration")
443+
}
444+
newData, err := json.Marshal(target.Object)
445+
if err != nil {
446+
return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing target configuration")
447+
}
448+
449+
// Even if currentObj is nil (because it was not found), it will marshal just fine
450+
currentData, err := json.Marshal(currentObj)
451+
if err != nil {
452+
return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing live configuration")
453+
}
454+
// kind := target.Mapping.GroupVersionKind.Kind
455+
// if kind == "Deployment" {
456+
// curr, _ := yaml.Marshal(currentObj)
457+
// fmt.Println(string(curr))
458+
// }
459+
460+
// Get a versioned object
461+
versionedObject := kube.AsVersioned(target)
462+
463+
// Unstructured objects, such as CRDs, may not have an not registered error
464+
// returned from ConvertToVersion. Anything that's unstructured should
465+
// use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported
466+
// on objects like CRDs.
467+
_, isUnstructured := versionedObject.(runtime.Unstructured)
468+
469+
// On newer K8s versions, CRDs aren't unstructured but has this dedicated type
470+
_, isCRD := versionedObject.(*apiextv1.CustomResourceDefinition)
471+
472+
if isUnstructured || isCRD {
473+
// fall back to generic JSON merge patch
474+
patch, err := jsonpatch.CreateMergePatch(oldData, newData)
475+
return patch, types.MergePatchType, err
476+
}
477+
478+
patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject)
479+
if err != nil {
480+
return nil, types.StrategicMergePatchType, errors.Wrap(err, "unable to create patch metadata from object")
481+
}
482+
483+
patch, err := strategicpatch.CreateThreeWayMergePatch(oldData, newData, currentData, patchMeta, true)
484+
return patch, types.StrategicMergePatchType, err
485+
}
486+
487+
func objectKey(r *resource.Info) string {
488+
gvk := r.Object.GetObjectKind().GroupVersionKind()
489+
return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name)
490+
}
491+
492+
func existingResourceConflict(resources kube.ResourceList) (kube.ResourceList, error) {
493+
var requireUpdate kube.ResourceList
494+
495+
err := resources.Visit(func(info *resource.Info, err error) error {
496+
if err != nil {
497+
return err
498+
}
499+
500+
helper := resource.NewHelper(info.Client, info.Mapping)
501+
_, err = helper.Get(info.Namespace, info.Name, info.Export)
502+
if err != nil {
503+
if apierrors.IsNotFound(err) {
504+
return nil
505+
}
506+
return errors.Wrap(err, "could not get information about the resource")
507+
}
508+
509+
requireUpdate.Append(info)
510+
return nil
511+
})
512+
513+
return requireUpdate, err
514+
}
515+
516+
func deleteStatusAndManagedFields(obj []byte) (map[string]interface{}, error) {
517+
var objectMap map[string]interface{}
518+
err := jsoniterator.Unmarshal(obj, &objectMap)
519+
if err != nil {
520+
return nil, errors.Wrap(err, "could not unmarshal byte sequence")
521+
}
522+
delete(objectMap, "status")
523+
delete(objectMap["metadata"].(map[string]interface{}), "managedFields")
524+
525+
return objectMap, nil
526+
}

diff/diff.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ func Releases(oldIndex, newIndex map[string]*manifest.MappingResult, suppressedK
148148
return Manifests(oldIndex, newIndex, suppressedKinds, showSecrets, context, output, stripTrailingCR, to)
149149
}
150150

151-
func diffMappingResults(oldContent *manifest.MappingResult, newContent *manifest.MappingResult, stripTrailingCR bool ) []difflib.DiffRecord {
151+
func diffMappingResults(oldContent *manifest.MappingResult, newContent *manifest.MappingResult, stripTrailingCR bool) []difflib.DiffRecord {
152152
return diffStrings(oldContent.Content, newContent.Content, stripTrailingCR)
153153
}
154154

go.mod

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,29 @@ module github.com/databus23/helm-diff/v3
33
go 1.14
44

55
require (
6-
github.com/Masterminds/goutils v1.1.0 // indirect
76
github.com/Masterminds/semver v1.5.0
87
github.com/Masterminds/sprig v2.22.0+incompatible // indirect
98
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a
10-
github.com/cyphar/filepath-securejoin v0.2.2 // indirect
9+
github.com/evanphx/json-patch v4.2.0+incompatible
1110
github.com/ghodss/yaml v1.0.0
12-
github.com/gobwas/glob v0.2.3 // indirect
1311
github.com/huandu/xstrings v1.3.2 // indirect
14-
github.com/imdario/mergo v0.3.10 // indirect
12+
github.com/json-iterator/go v1.1.8
1513
github.com/mattn/go-colorable v0.1.7 // indirect
1614
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
17-
github.com/mitchellh/copystructure v1.0.0 // indirect
18-
github.com/mitchellh/reflectwalk v1.0.1 // indirect
19-
github.com/pkg/errors v0.9.1 // indirect
20-
github.com/spf13/cobra v1.0.0
15+
github.com/pkg/errors v0.9.1
16+
github.com/spf13/cobra v1.1.3
2117
github.com/spf13/pflag v1.0.5
22-
github.com/stretchr/testify v1.5.1
18+
github.com/stretchr/testify v1.7.0
2319
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
2420
google.golang.org/grpc v1.30.0
25-
gopkg.in/yaml.v2 v2.3.0
26-
k8s.io/api v0.18.6
27-
k8s.io/apimachinery v0.18.6
28-
k8s.io/client-go v0.18.6
21+
gopkg.in/yaml.v2 v2.4.0
22+
helm.sh/helm/v3 v3.3.1
23+
k8s.io/api v0.18.8
24+
k8s.io/apiextensions-apiserver v0.18.8
25+
k8s.io/apimachinery v0.18.8
26+
k8s.io/cli-runtime v0.18.8
27+
k8s.io/client-go v0.18.8
2928
k8s.io/helm v2.16.12+incompatible
29+
rsc.io/letsencrypt v0.0.3 // indirect
30+
sigs.k8s.io/yaml v1.2.0
3031
)

0 commit comments

Comments
 (0)