package cmd
import (
"bytes"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/kube"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/cli-runtime/pkg/resource"
"github.com/databus23/helm-diff/v3/diff"
"github.com/databus23/helm-diff/v3/manifest"
)
var (
validDryRunValues = []string{dryRunServer, dryRunNoOptDefVal, envTrue, envFalse}
validServerSideVals = []string{envTrue, envFalse, serverSideAuto}
)
const (
dryRunNoOptDefVal = "client"
dryRunNone = "none"
dryRunServer = "server"
envTrue = "true"
envFalse = "false"
serverSideAuto = "auto"
)
type diffCmd struct {
release string
chart string
chartVersion string
chartRepo string
detailedExitCode bool
devel bool
disableValidation bool
disableOpenAPIValidation bool
enableDNS bool
SkipSchemaValidation bool
namespace string
valueFiles valueFiles
values []string
stringValues []string
stringLiteralValues []string
jsonValues []string
fileValues []string
reuseValues bool
resetValues bool
resetThenReuseValues bool
allowUnreleased bool
noHooks bool
includeTests bool
includeCRDs bool
postRenderer string
postRendererArgs []string
insecureSkipTLSVerify bool
install bool
normalizeManifests bool
takeOwnership bool
threeWayMerge bool
serverSide string
extraAPIs []string
kubeVersion string
useUpgradeDryRun bool
revision int
diff.Options
dryRunMode string
kubeContext string
}
func (d *diffCmd) isAllowUnreleased() bool {
return d.allowUnreleased || d.install
}
func (d *diffCmd) clusterAccessAllowed() bool {
return d.dryRunMode == dryRunNone || d.dryRunMode == envFalse || d.dryRunMode == dryRunServer
}
func (d *diffCmd) validateRevision(changed bool) error {
if !changed {
return nil
}
if d.revision < 1 {
return fmt.Errorf("flag %q must be a positive revision number, but got %d", "revision", d.revision)
}
if !d.clusterAccessAllowed() {
return fmt.Errorf("flag %q requires cluster access, so it cannot be used with --dry-run=%s", "revision", d.dryRunMode)
}
return nil
}
const globalUsage = `Show a diff explaining what a helm upgrade would change.
This fetches the currently deployed version of a release
and compares it to a chart plus values.
This can be used to visualize what changes a helm upgrade will
perform.
`
func newChartCommand() *cobra.Command {
diff := diffCmd{
namespace: os.Getenv("HELM_NAMESPACE"),
}
unknownFlags := os.Getenv("HELM_DIFF_IGNORE_UNKNOWN_FLAGS") == envTrue
cmd := &cobra.Command{
Use: "upgrade [flags] [RELEASE] [CHART]",
Short: "Show a diff explaining what a helm upgrade would change.",
Long: globalUsage,
Example: strings.Join([]string{
" helm diff upgrade my-release stable/postgresql --values values.yaml",
"",
" # Set HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true to ignore unknown flags",
" # It's useful when you're using `helm-diff` in a `helm upgrade` wrapper.",
" # See https://github.com/databus23/helm-diff/issues/278 for more information.",
" HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true helm diff upgrade my-release stable/postgres --wait",
"",
" # Set HELM_DIFF_USE_UPGRADE_DRY_RUN=true to",
" # use `helm upgrade --dry-run` instead of `helm template` to render manifests from the chart.",
" # See https://github.com/databus23/helm-diff/issues/253 for more information.",
" HELM_DIFF_USE_UPGRADE_DRY_RUN=true helm diff upgrade my-release datadog/datadog",
"",
" # Set HELM_DIFF_THREE_WAY_MERGE=true to",
" # enable the three-way-merge on diff.",
" # This is equivalent to specifying the --three-way-merge flag.",
" # Read the flag usage below for more information on --three-way-merge.",
" HELM_DIFF_THREE_WAY_MERGE=true helm diff upgrade my-release datadog/datadog",
"",
" # Set HELM_DIFF_NORMALIZE_MANIFESTS=true to",
" # normalize the yaml file content when using helm diff.",
" # This is equivalent to specifying the --normalize-manifests flag.",
" # Read the flag usage below for more information on --normalize-manifests.",
" HELM_DIFF_NORMALIZE_MANIFESTS=true helm diff upgrade my-release datadog/datadog",
"",
"# Set HELM_DIFF_OUTPUT_CONTEXT=n to configure the output context to n lines.",
"# This is equivalent to specifying the --context flag.",
"# Read the flag usage below for more information on --context.",
"HELM_DIFF_OUTPUT_CONTEXT=5 helm diff upgrade my-release datadog/datadog",
}, "\n"),
Args: func(cmd *cobra.Command, args []string) error {
return checkArgsLength(len(args), "release name", "chart path")
},
RunE: func(cmd *cobra.Command, args []string) error {
if diff.dryRunMode == "" {
diff.dryRunMode = dryRunNone
} else if !slices.Contains(validDryRunValues, diff.dryRunMode) {
return fmt.Errorf("flag %q must take a bool value or either %q or %q, but got %q", "dry-run", dryRunNoOptDefVal, dryRunServer, diff.dryRunMode)
}
if !slices.Contains(validServerSideVals, diff.serverSide) {
return fmt.Errorf("flag %q must be %q, %q or %q, but got %q", "server-side", envTrue, envFalse, serverSideAuto, diff.serverSide)
}
if err := diff.validateRevision(cmd.Flags().Changed("revision")); err != nil {
return err
}
cmd.SilenceUsage = true
diff.useUpgradeDryRun = os.Getenv("HELM_DIFF_USE_UPGRADE_DRY_RUN") == envTrue
if !diff.threeWayMerge && !cmd.Flags().Changed("three-way-merge") {
enabled := os.Getenv("HELM_DIFF_THREE_WAY_MERGE") == envTrue
diff.threeWayMerge = enabled
if enabled {
fmt.Fprintf(os.Stderr, "Enabled three way merge via the envvar\n")
}
}
if !diff.normalizeManifests && !cmd.Flags().Changed("normalize-manifests") {
enabled := os.Getenv("HELM_DIFF_NORMALIZE_MANIFESTS") == envTrue
diff.normalizeManifests = enabled
if enabled {
fmt.Fprintf(os.Stderr, "Enabled normalize manifests via the envvar\n")
}
}
if diff.OutputContext == -1 && !cmd.Flags().Changed("context") {
contextEnvVar := os.Getenv("HELM_DIFF_OUTPUT_CONTEXT")
if contextEnvVar != "" {
context, err := strconv.Atoi(contextEnvVar)
if err == nil {
diff.OutputContext = context
}
}
}
ProcessDiffOptions(cmd.Flags(), &diff.Options)
diff.release = args[0]
diff.chart = args[1]
return diff.runHelm3()
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: unknownFlags,
},
}
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")
f.StringArrayVarP(&diff.extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions")
f.StringVar(&diff.kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion")
f.VarP(&diff.valueFiles, "values", "f", "specify values in a YAML file (can specify multiple)")
f.StringArrayVar(&diff.values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&diff.stringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&diff.stringLiteralValues, "set-literal", []string{}, "set STRING literal values on the command line")
f.StringArrayVar(&diff.jsonValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)")
f.StringArrayVar(&diff.fileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)")
f.BoolVar(&diff.reuseValues, "reuse-values", false, "reuse the last release's values and merge in any new values. If '--reset-values' is specified, this is ignored")
f.BoolVar(&diff.resetValues, "reset-values", false, "reset the values to the ones built into the chart and merge in any new values")
f.BoolVar(&diff.resetThenReuseValues, "reset-then-reuse-values", false, "reset the values to the ones built into the chart, apply the last release's values and merge in any new values. If '--reset-values' or '--reuse-values' is specified, this is ignored")
f.BoolVar(&diff.allowUnreleased, "allow-unreleased", false, "enables diffing of releases that are not yet deployed via Helm")
f.BoolVar(&diff.install, "install", false, "enables diffing of releases that are not yet deployed via Helm (equivalent to --allow-unreleased, added to match \"helm upgrade --install\" command")
f.BoolVar(&diff.noHooks, "no-hooks", false, "disable diffing of hooks")
f.BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks")
f.BoolVar(&diff.includeCRDs, "include-crds", false, "include CRDs in the diffing")
f.BoolVar(&diff.devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored.")
f.BoolVar(&diff.disableValidation, "disable-validation", false, "disables rendered templates validation against the Kubernetes cluster you are currently pointing to. This is the same validation performed on an install")
f.BoolVar(&diff.disableOpenAPIValidation, "disable-openapi-validation", false, "disables rendered templates validation against the Kubernetes OpenAPI Schema")
f.StringVar(&diff.dryRunMode, "dry-run", "", "--dry-run, --dry-run=client, or --dry-run=true disables cluster access and show diff as if it was install. Implies --install, --reset-values, and --disable-validation."+
" --dry-run=server enables the cluster access with helm-get and the lookup template function.")
f.Lookup("dry-run").NoOptDefVal = dryRunNoOptDefVal
f.BoolVar(&diff.enableDNS, "enable-dns", false, "enable DNS lookups when rendering templates")
f.BoolVar(&diff.SkipSchemaValidation, "skip-schema-validation", false, "skip validation of the rendered manifests against the Kubernetes OpenAPI schema")
f.StringVar(&diff.postRenderer, "post-renderer", "", "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path")
f.StringArrayVar(&diff.postRendererArgs, "post-renderer-args", []string{}, "an argument to the post-renderer (can specify multiple)")
f.BoolVar(&diff.insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download")
f.BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output")
f.BoolVar(&diff.takeOwnership, "take-ownership", false, "if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources")
f.IntVar(&diff.revision, "revision", 0, "revision of the release to use as the diff baseline instead of the newest one")
f.StringVar(&diff.serverSide, "server-side", serverSideAuto, `must be "true", "false" or "auto". Object updates run in the server instead of the client ("auto" defaults the value from the previous chart release's method)`)
AddDiffOptions(f, &diff.Options)
return cmd
}
func (d *diffCmd) runHelm3() error {
if err := compatibleHelm3Version(); err != nil {
return err
}
var releaseManifest []byte
var err error
if d.takeOwnership {
d.threeWayMerge = true
}
if d.clusterAccessAllowed() {
releaseManifest, err = getRelease(d.release, d.revision, d.namespace, d.kubeContext)
}
var newInstall bool
if err != nil && strings.Contains(err.Error(), "release: not found") {
if d.revision > 0 {
return fmt.Errorf("Failed to get revision %d of release %s in namespace %s: %w", d.revision, d.release, d.namespace, err)
}
if d.isAllowUnreleased() {
newInstall = true
err = nil
} else {
fmt.Fprintf(os.Stderr, "********************\n\n\tRelease was not present in Helm. Include the `--allow-unreleased` to perform diff without exiting in error.\n\n********************\n")
return err
}
}
if err != nil {
return fmt.Errorf("Failed to get release %s in namespace %s: %w", d.release, d.namespace, err)
}
installManifest, err := d.template(!newInstall)
if err != nil {
return fmt.Errorf("Failed to render chart: %w", err)
}
var actionConfig *action.Configuration
if d.threeWayMerge || d.takeOwnership {
actionConfig = new(action.Configuration)
localEnv := prepareEnvSettings(d.kubeContext)
if err := actionConfig.Init(localEnv.RESTClientGetter(), localEnv.Namespace(), os.Getenv("HELM_DRIVER")); err != nil {
log.Fatalf("%+v", err)
}
if err := actionConfig.KubeClient.IsReachable(); err != nil {
return err
}
}
if d.threeWayMerge {
releaseManifest, installManifest, err = manifest.Generate(actionConfig, releaseManifest, installManifest)
if err != nil {
return fmt.Errorf("unable to generate manifests: %w", err)
}
}
currentSpecs := make(map[string]*manifest.MappingResult)
if !newInstall && d.clusterAccessAllowed() {
if !d.noHooks && !d.threeWayMerge {
hooks, err := getHooks(d.release, d.revision, d.namespace, d.kubeContext)
if err != nil {
return err
}
releaseManifest = append(releaseManifest, hooks...)
}
if d.includeTests {
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests)
} else {
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
}
}
releaseManifest = nil
var newOwnedReleases map[string]diff.OwnershipDiff
if d.takeOwnership {
resources, err := actionConfig.KubeClient.Build(bytes.NewBuffer(installManifest), false)
if err != nil {
return err
}
newOwnedReleases, err = checkOwnership(d, resources, currentSpecs)
if err != nil {
return err
}
}
var newSpecs map[string]*manifest.MappingResult
if d.includeTests {
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests)
} else {
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
}
installManifest = nil
seenAnyChanges := diff.ManifestsOwnership(currentSpecs, newSpecs, newOwnedReleases, &d.Options, os.Stdout)
if d.detailedExitCode && seenAnyChanges {
return Error{
error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"),
Code: 2,
}
}
return nil
}
func checkOwnership(d *diffCmd, resources kube.ResourceList, currentSpecs map[string]*manifest.MappingResult) (map[string]diff.OwnershipDiff, error) {
newOwnedReleases := make(map[string]diff.OwnershipDiff)
err := resources.Visit(func(info *resource.Info, err error) error {
if err != nil {
return err
}
helper := resource.NewHelper(info.Client, info.Mapping)
currentObj, err := helper.Get(info.Namespace, info.Name)
if err != nil {
if !apierrors.IsNotFound(err) {
return err
}
return nil
}
var result *manifest.MappingResult
var oldRelease string
if d.includeTests {
result, oldRelease, err = manifest.ParseObject(currentObj, d.namespace)
} else {
result, oldRelease, err = manifest.ParseObject(currentObj, d.namespace, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
}
if err != nil {
return err
}
newRelease := d.namespace + "/" + d.release
if oldRelease == newRelease {
return nil
}
newOwnedReleases[result.Name] = diff.OwnershipDiff{
OldRelease: oldRelease,
NewRelease: newRelease,
}
currentSpecs[result.Name] = result
return nil
})
return newOwnedReleases, err
}
func prepareEnvSettings(kubeContext string) *cli.EnvSettings {
localEnv := cli.New()
if len(filepath.SplitList(localEnv.KubeConfig)) > 1 {
localEnv.KubeConfig = ""
}
if kubeContext != "" {
localEnv.KubeContext = kubeContext
}
return localEnv
}