Skip to content

upgrade command add three-way-merge option #304

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ bin/
build/
release/
.envrc
.idea
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ VERSION := $(shell sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' plugin.yaml)

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

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

# Clear the "unreleased" string in BuildMetadata
Expand Down
239 changes: 238 additions & 1 deletion cmd/upgrade.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
package cmd

import (
"errors"
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"strings"

jsoniterator "github.com/json-iterator/go"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"

jsonpatch "github.com/evanphx/json-patch"
"github.com/pkg/errors"
"helm.sh/helm/v3/pkg/kube"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/strategicpatch"
"k8s.io/cli-runtime/pkg/resource"
"sigs.k8s.io/yaml"

"github.com/spf13/cobra"
"k8s.io/helm/pkg/helm"

Expand Down Expand Up @@ -42,6 +59,7 @@ type diffCmd struct {
install bool
stripTrailingCR bool
normalizeManifests bool
threeWayMerge bool
}

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

var envSettings = cli.New()
var yamlSeperator = []byte("\n---\n")

func newChartCommand() *cobra.Command {
diff := diffCmd{
namespace: os.Getenv("HELM_NAMESPACE"),
Expand Down Expand Up @@ -98,6 +119,8 @@ func newChartCommand() *cobra.Command {
f := cmd.Flags()
var kubeconfig string
f.StringVar(&kubeconfig, "kubeconfig", "", "This flag is ignored, to allow passing of this top level flag to helm")
f.BoolVar(&diff.threeWayMerge, "three-way-merge", false, "use three-way-merge to compute patch and generate diff output")
// f.StringVar(&diff.kubeContext, "kube-context", "", "name of the kubeconfig context to use")
f.StringVar(&diff.chartVersion, "version", "", "specify the exact chart version to use. If this is not specified, the latest version is used")
f.StringVar(&diff.chartRepo, "repo", "", "specify the chart repository url to locate the requested chart")
f.BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes")
Expand Down Expand Up @@ -169,6 +192,25 @@ func (d *diffCmd) runHelm3() error {
return fmt.Errorf("Failed to render chart: %s", err)
}

if d.threeWayMerge {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably L171-L193 doesn't need to be run when we enter the 3-way merge mode?

Copy link
Collaborator

@mumoshu mumoshu Jan 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind. It turned out correct as we still render the chart at L190 to be used when building K8s objects at L207.

actionConfig := new(action.Configuration)
if err := actionConfig.Init(envSettings.RESTClientGetter(), envSettings.Namespace(), os.Getenv("HELM_DRIVER"), log.Printf); err != nil {
log.Fatalf("%+v", err)
}
if err := actionConfig.KubeClient.IsReachable(); err != nil {
return err
}
original, err := actionConfig.KubeClient.Build(bytes.NewBuffer(releaseManifest), false)
if err != nil {
return errors.Wrap(err, "unable to build kubernetes objects from original release manifest")
}
target, err := actionConfig.KubeClient.Build(bytes.NewBuffer(installManifest), false)
if err != nil {
return errors.Wrap(err, "unable to build kubernetes objects from new release manifest")
}
releaseManifest, installManifest, err = genManifest(original, target)
}

currentSpecs := make(map[string]*manifest.MappingResult)
if !newInstall && !d.dryRun {
if !d.noHooks {
Expand Down Expand Up @@ -202,6 +244,112 @@ func (d *diffCmd) runHelm3() error {
return nil
}

func genManifest(original, target kube.ResourceList) ([]byte, []byte, error) {
var err error
releaseManifest, installManifest := make([]byte, 0), make([]byte, 0)

// to be deleted
targetResources := make(map[string]bool)
for _, r := range target {
targetResources[objectKey(r)] = true
}
for _, r := range original {
if !targetResources[objectKey(r)] {
out, _ := yaml.Marshal(r.Object)
releaseManifest = append(releaseManifest, yamlSeperator...)
releaseManifest = append(releaseManifest, out...)
}
}

existingResources := make(map[string]bool)
for _, r := range original {
existingResources[objectKey(r)] = true
}

var toBeCreated kube.ResourceList
for _, r := range target {
if !existingResources[objectKey(r)] {
toBeCreated = append(toBeCreated, r)
}
}

toBeUpdated, err := existingResourceConflict(toBeCreated)
if err != nil {
return nil, nil, errors.Wrap(err, "rendered manifests contain a resource that already exists. Unable to continue with update")
}

_ = toBeUpdated.Visit(func(r *resource.Info, err error) error {
if err != nil {
return err
}
original.Append(r)
return nil
})

err = target.Visit(func(info *resource.Info, err error) error {
if err != nil {
return err
}
kind := info.Mapping.GroupVersionKind.Kind

// Fetch the current object for the three way merge
helper := resource.NewHelper(info.Client, info.Mapping)
currentObj, err := helper.Get(info.Namespace, info.Name, info.Export)
if err != nil {
if !apierrors.IsNotFound(err) {
return errors.Wrap(err, "could not get information about the resource")
}
// to be created
out, _ := yaml.Marshal(info.Object)
installManifest = append(installManifest, yamlSeperator...)
installManifest = append(installManifest, out...)
return nil
}
// to be updated
out, _ := jsoniterator.ConfigCompatibleWithStandardLibrary.Marshal(currentObj)
pruneObj, err := deleteStatusAndManagedFields(out)
if err != nil {
return errors.Wrapf(err, "prune current obj %q with kind %s", info.Name, kind)
}
pruneOut, err := yaml.Marshal(pruneObj)
if err != nil {
return errors.Wrapf(err, "prune current out %q with kind %s", info.Name, kind)
}
releaseManifest = append(releaseManifest, yamlSeperator...)
releaseManifest = append(releaseManifest, pruneOut...)

originalInfo := original.Get(info)
if originalInfo == nil {
return fmt.Errorf("could not find %q", info.Name)
}

patch, patchType, err := createPatch(originalInfo.Object, currentObj, info)
if err != nil {
return err
}

helper.ServerDryRun = true
targetObj, err := helper.Patch(info.Namespace, info.Name, patchType, patch, nil)
if err != nil {
return errors.Wrapf(err, "cannot patch %q with kind %s", info.Name, kind)
}
out, _ = jsoniterator.ConfigCompatibleWithStandardLibrary.Marshal(targetObj)
pruneObj, err = deleteStatusAndManagedFields(out)
if err != nil {
return errors.Wrapf(err, "prune current obj %q with kind %s", info.Name, kind)
}
pruneOut, err = yaml.Marshal(pruneObj)
if err != nil {
return errors.Wrapf(err, "prune current out %q with kind %s", info.Name, kind)
}
installManifest = append(installManifest, yamlSeperator...)
installManifest = append(installManifest, pruneOut...)
return nil
})

return releaseManifest, installManifest, err
}

func (d *diffCmd) run() error {
if d.chartVersion == "" && d.devel {
d.chartVersion = ">0.0.0-0"
Expand Down Expand Up @@ -287,3 +435,92 @@ func (d *diffCmd) run() error {

return nil
}

func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) ([]byte, types.PatchType, error) {
oldData, err := json.Marshal(originalObj)
if err != nil {
return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing current configuration")
}
newData, err := json.Marshal(target.Object)
if err != nil {
return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing target configuration")
}

// Even if currentObj is nil (because it was not found), it will marshal just fine
currentData, err := json.Marshal(currentObj)
if err != nil {
return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing live configuration")
}
// kind := target.Mapping.GroupVersionKind.Kind
// if kind == "Deployment" {
// curr, _ := yaml.Marshal(currentObj)
// fmt.Println(string(curr))
// }

// Get a versioned object
versionedObject := kube.AsVersioned(target)

// Unstructured objects, such as CRDs, may not have an not registered error
// returned from ConvertToVersion. Anything that's unstructured should
// use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported
// on objects like CRDs.
_, isUnstructured := versionedObject.(runtime.Unstructured)

// On newer K8s versions, CRDs aren't unstructured but has this dedicated type
_, isCRD := versionedObject.(*apiextv1.CustomResourceDefinition)

if isUnstructured || isCRD {
// fall back to generic JSON merge patch
patch, err := jsonpatch.CreateMergePatch(oldData, newData)
return patch, types.MergePatchType, err
}

patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject)
if err != nil {
return nil, types.StrategicMergePatchType, errors.Wrap(err, "unable to create patch metadata from object")
}

patch, err := strategicpatch.CreateThreeWayMergePatch(oldData, newData, currentData, patchMeta, true)
return patch, types.StrategicMergePatchType, err
}

func objectKey(r *resource.Info) string {
gvk := r.Object.GetObjectKind().GroupVersionKind()
return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name)
}

func existingResourceConflict(resources kube.ResourceList) (kube.ResourceList, error) {
var requireUpdate kube.ResourceList

err := resources.Visit(func(info *resource.Info, err error) error {
if err != nil {
return err
}

helper := resource.NewHelper(info.Client, info.Mapping)
_, err = helper.Get(info.Namespace, info.Name, info.Export)
if err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return errors.Wrap(err, "could not get information about the resource")
}

requireUpdate.Append(info)
return nil
})

return requireUpdate, err
}

func deleteStatusAndManagedFields(obj []byte) (map[string]interface{}, error) {
var objectMap map[string]interface{}
err := jsoniterator.Unmarshal(obj, &objectMap)
if err != nil {
return nil, errors.Wrap(err, "could not unmarshal byte sequence")
}
delete(objectMap, "status")
delete(objectMap["metadata"].(map[string]interface{}), "managedFields")

return objectMap, nil
}
2 changes: 1 addition & 1 deletion diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func Releases(oldIndex, newIndex map[string]*manifest.MappingResult, suppressedK
return Manifests(oldIndex, newIndex, suppressedKinds, showSecrets, context, output, stripTrailingCR, to)
}

func diffMappingResults(oldContent *manifest.MappingResult, newContent *manifest.MappingResult, stripTrailingCR bool ) []difflib.DiffRecord {
func diffMappingResults(oldContent *manifest.MappingResult, newContent *manifest.MappingResult, stripTrailingCR bool) []difflib.DiffRecord {
return diffStrings(oldContent.Content, newContent.Content, stripTrailingCR)
}

Expand Down
27 changes: 14 additions & 13 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,29 @@ module github.com/databus23/helm-diff/v3
go 1.14

require (
github.com/Masterminds/goutils v1.1.0 // indirect
github.com/Masterminds/semver v1.5.0
github.com/Masterminds/sprig v2.22.0+incompatible // indirect
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a
github.com/cyphar/filepath-securejoin v0.2.2 // indirect
github.com/evanphx/json-patch v4.2.0+incompatible
github.com/ghodss/yaml v1.0.0
github.com/gobwas/glob v0.2.3 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.10 // indirect
github.com/json-iterator/go v1.1.8
github.com/mattn/go-colorable v0.1.7 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/spf13/cobra v1.0.0
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.1.3
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.5.1
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
google.golang.org/grpc v1.30.0
gopkg.in/yaml.v2 v2.3.0
k8s.io/api v0.18.6
k8s.io/apimachinery v0.18.6
k8s.io/client-go v0.18.6
gopkg.in/yaml.v2 v2.4.0
helm.sh/helm/v3 v3.3.1
k8s.io/api v0.18.8
k8s.io/apiextensions-apiserver v0.18.8
k8s.io/apimachinery v0.18.8
k8s.io/cli-runtime v0.18.8
k8s.io/client-go v0.18.8
k8s.io/helm v2.16.12+incompatible
rsc.io/letsencrypt v0.0.3 // indirect
sigs.k8s.io/yaml v1.2.0
)
Loading