package cmd
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"slices"
"strconv"
"strings"
"github.com/Masterminds/semver/v3"
)
type valueFiles []string
func (v *valueFiles) String() string {
return fmt.Sprint(*v)
}
func (v *valueFiles) Valid() error {
errStr := ""
for _, valuesFile := range *v {
if strings.TrimSpace(valuesFile) != "-" {
if _, err := os.Stat(valuesFile); os.IsNotExist(err) {
errStr += err.Error()
}
}
}
if errStr == "" {
return nil
}
return errors.New(errStr)
}
func (v *valueFiles) Type() string {
return "valueFiles"
}
func (v *valueFiles) Set(value string) error {
for _, filePath := range strings.Split(value, ",") {
*v = append(*v, filePath)
}
return nil
}
func checkArgsLength(argsReceived int, requiredArgs ...string) error {
expectedNum := len(requiredArgs)
if argsReceived != expectedNum {
arg := "arguments"
if expectedNum == 1 {
arg = "argument"
}
return fmt.Errorf("This command needs %v %s: %s", expectedNum, arg, strings.Join(requiredArgs, ", "))
}
return nil
}
var (
helmVersionRE = regexp.MustCompile(`Version:\s*"([^"]+)"`)
helmV4Version = semver.MustParse("v4.0.0")
minHelmVersion = semver.MustParse("v3.1.0-rc.1")
minHelmVersionWithDryRunLookupSupport = semver.MustParse("v3.13.0")
minHelmVersionWithResetThenReuseValues = semver.MustParse("v3.14.0")
)
func getHelmVersion() (*semver.Version, error) {
cmd := exec.Command(os.Getenv("HELM_BIN"), "version")
debugPrint("Executing %s", strings.Join(cmd.Args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("Failed to run `%s version`: %w", os.Getenv("HELM_BIN"), err)
}
versionOutput := string(output)
matches := helmVersionRE.FindStringSubmatch(versionOutput)
if matches == nil {
return nil, fmt.Errorf("Failed to find version in output %#v", versionOutput)
}
helmVersion, err := semver.NewVersion(matches[1])
if err != nil {
return nil, fmt.Errorf("Failed to parse version %#v: %w", matches[1], err)
}
return helmVersion, nil
}
func isHelmVersionAtLeast(versionToCompareTo *semver.Version) (bool, error) {
helmVersion, err := getHelmVersion()
if err != nil {
return false, err
}
if helmVersion.LessThan(versionToCompareTo) {
return false, nil
}
return true, nil
}
func isHelmVersionGreaterThanEqual(versionToCompareTo *semver.Version) (bool, error) {
helmVersion, err := getHelmVersion()
if err != nil {
return false, err
}
return helmVersion.GreaterThanEqual(versionToCompareTo), nil
}
func compatibleHelm3Version() error {
isCompatible, err := isHelmVersionAtLeast(minHelmVersion)
if err != nil {
return err
}
if !isCompatible {
return fmt.Errorf("helm diff upgrade requires at least helm version %s", minHelmVersion.String())
}
return nil
}
const helmGetSubcmd = "get"
func helmGetArgs(what, release string, revision int, namespace, kubeContext string) []string {
args := []string{helmGetSubcmd, what, release}
if revision > 0 {
args = append(args, "--revision", strconv.Itoa(revision))
}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if kubeContext != "" {
args = append(args, "--kube-context", kubeContext)
}
return args
}
func getRelease(release string, revision int, namespace, kubeContext string) ([]byte, error) {
cmd := exec.Command(os.Getenv("HELM_BIN"), helmGetArgs("manifest", release, revision, namespace, kubeContext)...)
return outputWithRichError(cmd)
}
func getHooks(release string, revision int, namespace, kubeContext string) ([]byte, error) {
cmd := exec.Command(os.Getenv("HELM_BIN"), helmGetArgs("hooks", release, revision, namespace, kubeContext)...)
return outputWithRichError(cmd)
}
func getChart(release, namespace, kubeContext string) (string, error) {
args := []string{helmGetSubcmd, "all", release, "--template", "{{.Release.Chart.Name}}"}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if kubeContext != "" {
args = append(args, "--kube-context", kubeContext)
}
cmd := exec.Command(os.Getenv("HELM_BIN"), args...)
out, err := outputWithRichError(cmd)
if err != nil {
return "", err
}
return string(out), nil
}
func (d *diffCmd) template(isUpgrade bool) ([]byte, error) {
flags := []string{}
if d.devel {
flags = append(flags, "--devel")
}
if d.noHooks && !d.useUpgradeDryRun {
flags = append(flags, "--no-hooks")
}
if d.includeCRDs {
flags = append(flags, "--include-crds")
}
if d.chartVersion != "" {
flags = append(flags, "--version", d.chartVersion)
}
if d.chartRepo != "" {
flags = append(flags, "--repo", d.chartRepo)
}
if d.namespace != "" {
flags = append(flags, "--namespace", d.namespace)
}
if d.kubeContext != "" {
flags = append(flags, "--kube-context", d.kubeContext)
}
if d.postRenderer != "" {
flags = append(flags, "--post-renderer", d.postRenderer)
}
for _, arg := range d.postRendererArgs {
flags = append(flags, "--post-renderer-args", arg)
}
if d.insecureSkipTLSVerify {
flags = append(flags, "--insecure-skip-tls-verify")
}
shouldDefaultReusingValues := isUpgrade && len(d.values) == 0 && len(d.stringValues) == 0 && len(d.stringLiteralValues) == 0 && len(d.jsonValues) == 0 && len(d.valueFiles) == 0 && len(d.fileValues) == 0
if (d.reuseValues || d.resetThenReuseValues || shouldDefaultReusingValues) && !d.resetValues && d.clusterAccessAllowed() {
tmpfile, err := os.CreateTemp("", "existing-values")
if err != nil {
return nil, err
}
defer func() {
_ = os.Remove(tmpfile.Name())
}()
if d.resetThenReuseValues && !d.reuseValues {
var supported bool
supported, err = isHelmVersionAtLeast(minHelmVersionWithResetThenReuseValues)
if err != nil {
return nil, err
}
if !supported {
return nil, fmt.Errorf("Using --reset-then-reuse-values requires at least helm version %s", minHelmVersionWithResetThenReuseValues.String())
}
err = d.writeExistingValues(tmpfile, false)
} else {
err = d.writeExistingValues(tmpfile, true)
}
if err != nil {
return nil, err
}
flags = append(flags, "--values", tmpfile.Name())
}
for _, value := range d.values {
flags = append(flags, "--set", value)
}
for _, stringValue := range d.stringValues {
flags = append(flags, "--set-string", stringValue)
}
for _, stringLiteralValue := range d.stringLiteralValues {
flags = append(flags, "--set-literal", stringLiteralValue)
}
for _, jsonValue := range d.jsonValues {
flags = append(flags, "--set-json", jsonValue)
}
for _, valueFile := range d.valueFiles {
if strings.TrimSpace(valueFile) == "-" {
bytes, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, err
}
tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values")
if err != nil {
return nil, err
}
defer func() {
_ = os.Remove(tmpfile.Name())
}()
if _, err := tmpfile.Write(bytes); err != nil {
_ = tmpfile.Close()
return nil, err
}
if err := tmpfile.Close(); err != nil {
return nil, err
}
flags = append(flags, "--values", tmpfile.Name())
} else {
flags = append(flags, "--values", valueFile)
}
}
for _, fileValue := range d.fileValues {
flags = append(flags, "--set-file", fileValue)
}
if d.disableOpenAPIValidation {
flags = append(flags, "--disable-openapi-validation")
}
if d.enableDNS {
flags = append(flags, "--enable-dns")
}
if d.SkipSchemaValidation {
flags = append(flags, "--skip-schema-validation")
}
if d.takeOwnership {
flags = append(flags, "--take-ownership")
}
isHelmV4, _ := isHelmVersionGreaterThanEqual(helmV4Version)
flags = append(flags, serverSideFlags(isHelmV4, d.useUpgradeDryRun, d.serverSide)...)
var (
subcmd string
filter func([]byte) []byte
)
if d.useUpgradeDryRun {
if d.isAllowUnreleased() {
flags = append(flags, "--install")
}
if useDryRunService, err := isHelmVersionAtLeast(minHelmVersionWithDryRunLookupSupport); err == nil && useDryRunService {
flags = append(flags, "--dry-run=server")
} else {
flags = append(flags, "--dry-run")
}
subcmd = "upgrade"
filter = func(s []byte) []byte {
return extractManifestFromHelmUpgradeDryRunOutput(s, d.noHooks)
}
} else {
if !d.disableValidation && d.clusterAccessAllowed() {
isHelmV4, err := isHelmVersionGreaterThanEqual(helmV4Version)
if err == nil && isHelmV4 {
if !slices.Contains([]string{dryRunNoOptDefVal, envTrue, envFalse}, d.dryRunMode) {
flags = append(flags, "--dry-run=server")
}
} else {
flags = append(flags, "--validate")
}
}
if isUpgrade {
flags = append(flags, "--is-upgrade")
}
for _, a := range d.extraAPIs {
flags = append(flags, "--api-versions", a)
}
if d.kubeVersion != "" {
flags = append(flags, "--kube-version", d.kubeVersion)
}
if useDryRunService, err := isHelmVersionAtLeast(minHelmVersionWithDryRunLookupSupport); err == nil && useDryRunService {
isHelmV4, _ := isHelmVersionGreaterThanEqual(helmV4Version)
if d.dryRunMode == envFalse {
} else if !(isHelmV4 && !slices.Contains([]string{dryRunNoOptDefVal, envTrue}, d.dryRunMode)) {
if d.dryRunMode == dryRunServer {
flags = append(flags, "--dry-run=server")
} else {
flags = append(flags, "--dry-run=client")
}
}
}
subcmd = "template"
filter = func(s []byte) []byte {
return stripOCIPullProgress(s)
}
}
args := []string{subcmd, d.release, d.chart}
args = append(args, flags...)
cmd := exec.Command(os.Getenv("HELM_BIN"), args...)
out, err := outputWithRichError(cmd)
return filter(out), err
}
func (d *diffCmd) writeExistingValues(f *os.File, all bool) error {
args := []string{helmGetSubcmd, "values", d.release, "--output", "yaml"}
if all {
args = append(args, "--all")
}
if d.namespace != "" {
args = append(args, "--namespace", d.namespace)
}
if d.kubeContext != "" {
args = append(args, "--kube-context", d.kubeContext)
}
cmd := exec.Command(os.Getenv("HELM_BIN"), args...)
debugPrint("Executing %s", strings.Join(cmd.Args, " "))
defer func() {
_ = f.Close()
}()
cmd.Stdout = f
return cmd.Run()
}
func extractManifestFromHelmUpgradeDryRunOutput(s []byte, noHooks bool) []byte {
if len(s) == 0 {
return s
}
var (
hooks []byte
manifest []byte
)
i := bytes.Index(s, []byte("HOOKS:"))
if i > -1 {
hooks = s[i:]
}
j := bytes.Index(hooks, []byte("MANIFEST:"))
if j > -1 {
manifest = hooks[j:]
hooks = hooks[:j]
}
k := bytes.Index(manifest, []byte("\nNOTES:"))
if k > -1 {
manifest = manifest[:k+1]
}
if noHooks {
hooks = nil
} else {
a := bytes.Index(hooks, []byte("---"))
if a > -1 {
hooks = hooks[a:]
} else {
hooks = nil
}
}
a := bytes.Index(manifest, []byte("---"))
if a > -1 {
manifest = manifest[a:]
}
r := []byte{}
r = append(r, manifest...)
r = append(r, hooks...)
return r
}
var ociPullProgressRE = regexp.MustCompile(`(?m)^(?:Pulled|Digest|Pulling):[^\n]*\n?`)
func stripOCIPullProgress(s []byte) []byte {
return ociPullProgressRE.ReplaceAll(s, []byte(""))
}
func serverSideFlags(isHelmV4 bool, useUpgradeDryRun bool, serverSide string) []string {
if !isHelmV4 {
return nil
}
switch {
case useUpgradeDryRun:
return []string{"--server-side=" + serverSide}
case serverSide == envTrue || serverSide == envFalse:
return []string{"--server-side=" + serverSide}
default:
return nil
}
}