mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-01 08:47:15 +02:00
When using atomic: true with trackMode: kubedog, the --atomic flag was passed to Helm which caused Helm to wait for resources and rollback internally before helmfile could start kubedog tracking. This prevented users from seeing deployment logs and errors. Changes: - Add RollbackRelease method to helmexec interface and implementation - Add appendAtomicFlags function that skips --atomic when kubedog is enabled - Add handleKubedogFailure function to manually rollback on tracking failure - Update flagsForUpgrade to use new atomic flag handling - Add tests for appendAtomicFlags behavior Now when atomic: true and trackMode: kubedog are both enabled: 1. Helm upgrade runs without --atomic (no internal wait) 2. kubedog tracks resources immediately (users can see logs) 3. If kubedog tracking fails, helmfile manually executes rollback 4. Atomic semantics are preserved Fixes: #2448 Signed-off-by: yxxhero <aiopsclub@163.com>
1051 lines
31 KiB
Go
1051 lines
31 KiB
Go
package helmexec
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
|
|
"github.com/Masterminds/semver/v3"
|
|
"github.com/helmfile/chartify"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
actionv3 "helm.sh/helm/v3/pkg/action"
|
|
cliv3 "helm.sh/helm/v3/pkg/cli"
|
|
actionv4 "helm.sh/helm/v4/pkg/action"
|
|
chart "helm.sh/helm/v4/pkg/chart/v2"
|
|
cliv4 "helm.sh/helm/v4/pkg/cli"
|
|
|
|
"github.com/helmfile/helmfile/pkg/yaml"
|
|
)
|
|
|
|
type decryptedSecret struct {
|
|
mutex sync.RWMutex
|
|
bytes []byte
|
|
err error
|
|
}
|
|
|
|
type HelmExecOptions struct {
|
|
EnableLiveOutput bool
|
|
DisableForceUpdate bool // If true, do not force helm repos to update when executing "helm repo add" (Helm 3)
|
|
EnforcePluginVerification bool // If true, fail plugin installation if verification is not supported
|
|
HelmOCIPlainHTTP bool // If true, use plain HTTP for OCI registries
|
|
}
|
|
|
|
type execer struct {
|
|
helmBinary string
|
|
options HelmExecOptions
|
|
version *semver.Version
|
|
runner Runner
|
|
logger *zap.SugaredLogger
|
|
kubeconfig string
|
|
kubeContext string
|
|
extra []string
|
|
decryptedSecretMutex sync.Mutex
|
|
decryptedSecrets map[string]*decryptedSecret
|
|
writeTempFile func([]byte) (string, error)
|
|
unittestPluginOnce sync.Once
|
|
unittestPluginErr error
|
|
}
|
|
|
|
func NewLogger(writer io.Writer, logLevel string) *zap.SugaredLogger {
|
|
var cfg zapcore.EncoderConfig
|
|
cfg.MessageKey = "message"
|
|
out := zapcore.AddSync(writer)
|
|
var level zapcore.Level
|
|
err := level.Set(logLevel)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
core := zapcore.NewCore(
|
|
zapcore.NewConsoleEncoder(cfg),
|
|
out,
|
|
level,
|
|
)
|
|
return zap.New(core).Sugar()
|
|
}
|
|
|
|
func parseHelmVersion(versionStr string) (*semver.Version, error) {
|
|
if len(versionStr) == 0 {
|
|
return nil, fmt.Errorf("empty helm version")
|
|
}
|
|
|
|
// Check if version string starts with "v", if not add it
|
|
processedVersion := strings.TrimSpace(versionStr)
|
|
if !strings.HasPrefix(processedVersion, "v") {
|
|
processedVersion = "v" + processedVersion
|
|
}
|
|
|
|
v, err := chartify.FindSemVerInfo(processedVersion)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error find helm srmver version '%s': %w", versionStr, err)
|
|
}
|
|
|
|
ver, err := semver.NewVersion(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error parsing helm version '%s'", versionStr)
|
|
}
|
|
|
|
return ver, nil
|
|
}
|
|
|
|
func GetHelmVersion(helmBinary string, runner Runner) (*semver.Version, error) {
|
|
// Autodetect from `helm version` - just short works for both Helm 3 and Helm 4
|
|
outBytes, err := runner.Execute(helmBinary, []string{"version", "--short"}, nil, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error determining helm version: %w", err)
|
|
}
|
|
|
|
return parseHelmVersion(string(outBytes))
|
|
}
|
|
|
|
// PluginMetadata represents the metadata of a Helm plugin
|
|
type PluginMetadata struct {
|
|
Name string `yaml:"name"`
|
|
Version string `yaml:"version"`
|
|
}
|
|
|
|
func GetPluginVersion(name, pluginsDir string) (*semver.Version, error) {
|
|
pluginDirs := filepath.SplitList(pluginsDir)
|
|
|
|
var firstReadErr error
|
|
for _, dir := range pluginDirs {
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
if firstReadErr == nil {
|
|
firstReadErr = err
|
|
}
|
|
continue
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
pluginFile := filepath.Join(dir, entry.Name(), "plugin.yaml")
|
|
data, err := os.ReadFile(pluginFile)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var metadata PluginMetadata
|
|
if err := yaml.Unmarshal(data, &metadata); err != nil {
|
|
continue
|
|
}
|
|
|
|
if metadata.Name == name {
|
|
return semver.NewVersion(metadata.Version)
|
|
}
|
|
}
|
|
}
|
|
|
|
if firstReadErr != nil {
|
|
return nil, firstReadErr
|
|
}
|
|
return nil, fmt.Errorf("plugin %s not installed", name)
|
|
}
|
|
|
|
func redactedURL(chart string) string {
|
|
chartURL, err := url.ParseRequestURI(chart)
|
|
if err != nil {
|
|
return chart
|
|
}
|
|
return chartURL.Redacted()
|
|
}
|
|
|
|
// New for running helm commands
|
|
func New(helmBinary string, options HelmExecOptions, logger *zap.SugaredLogger, kubeconfig string, kubeContext string, runner Runner) (*execer, error) {
|
|
version, err := GetHelmVersion(helmBinary, runner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if version.Prerelease() != "" {
|
|
logger.Warnf("Helm version %s is a pre-release version. This may cause problems when deploying Helm charts.\n", version)
|
|
*version, _ = version.SetPrerelease("")
|
|
}
|
|
|
|
return &execer{
|
|
helmBinary: helmBinary,
|
|
options: options,
|
|
version: version,
|
|
logger: logger,
|
|
kubeconfig: kubeconfig,
|
|
kubeContext: kubeContext,
|
|
runner: runner,
|
|
decryptedSecrets: make(map[string]*decryptedSecret),
|
|
}, nil
|
|
}
|
|
|
|
func (helm *execer) SetExtraArgs(args ...string) {
|
|
helm.extra = args
|
|
}
|
|
|
|
func (helm *execer) SetHelmBinary(bin string) {
|
|
helm.helmBinary = bin
|
|
}
|
|
|
|
func (helm *execer) SetEnableLiveOutput(enableLiveOutput bool) {
|
|
helm.options.EnableLiveOutput = enableLiveOutput
|
|
}
|
|
|
|
func (helm *execer) SetDisableForceUpdate(forceUpdate bool) {
|
|
helm.options.DisableForceUpdate = forceUpdate
|
|
}
|
|
|
|
func (helm *execer) AddRepo(name, repository, cafile, certfile, keyfile, username, password string, managed string, passCredentials, skipTLSVerify bool) error {
|
|
var args []string
|
|
var out []byte
|
|
var err error
|
|
|
|
if name == "" && repository != "" {
|
|
helm.logger.Infof("empty field name\n")
|
|
return fmt.Errorf("empty field name")
|
|
}
|
|
|
|
savedExtra := helm.extra
|
|
helm.extra = []string{}
|
|
defer func() {
|
|
helm.extra = savedExtra
|
|
}()
|
|
|
|
switch managed {
|
|
case "acr":
|
|
helm.logger.Infof("Adding repo %v (acr)", name)
|
|
out, err = helm.azcli(name)
|
|
case "":
|
|
args = append(args, "repo", "add", name, repository)
|
|
|
|
// --force-update is needed for both Helm 3.3.2+ and Helm 4
|
|
// to ensure repository indexes are updated when a repository already exists
|
|
// See https://github.com/helm/helm/pull/8777
|
|
if !helm.options.DisableForceUpdate && (helm.IsHelm4() || helm.IsVersionAtLeast("3.3.2")) {
|
|
args = append(args, "--force-update")
|
|
}
|
|
|
|
if certfile != "" && keyfile != "" {
|
|
args = append(args, "--cert-file", certfile, "--key-file", keyfile)
|
|
}
|
|
if cafile != "" {
|
|
args = append(args, "--ca-file", cafile)
|
|
}
|
|
|
|
if passCredentials {
|
|
args = append(args, "--pass-credentials")
|
|
}
|
|
if skipTLSVerify {
|
|
args = append(args, "--insecure-skip-tls-verify")
|
|
}
|
|
helm.logger.Infof("Adding repo %v %v", name, repository)
|
|
if username != "" && password != "" {
|
|
args = append(args, "--username", username, "--password-stdin")
|
|
buffer := bytes.Buffer{}
|
|
buffer.Write([]byte(fmt.Sprintf("%s\n", password)))
|
|
out, err = helm.execStdIn(args, map[string]string{}, &buffer)
|
|
} else {
|
|
out, err = helm.exec(args, map[string]string{}, nil)
|
|
}
|
|
default:
|
|
helm.logger.Errorf("ERROR: unknown type '%v' for repository %v", managed, name)
|
|
out = nil
|
|
err = nil
|
|
}
|
|
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) UpdateRepo() error {
|
|
helm.logger.Info("Updating repo")
|
|
savedExtra := helm.extra
|
|
helm.extra = []string{}
|
|
defer func() {
|
|
helm.extra = savedExtra
|
|
}()
|
|
out, err := helm.exec([]string{"repo", "update"}, map[string]string{}, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) RegistryLogin(repository, username, password, caFile, certFile, keyFile string, skipTLSVerify bool) error {
|
|
if username == "" || password == "" {
|
|
return nil
|
|
}
|
|
|
|
args := []string{
|
|
"registry",
|
|
"login",
|
|
repository,
|
|
}
|
|
helmVersionConstraint, _ := semver.NewConstraint(">= 3.12.0")
|
|
if helmVersionConstraint.Check(helm.version) {
|
|
// in the 3.12.0 version, the registry login support --key-file --cert-file and --ca-file
|
|
// https://github.com/helm/helm/releases/tag/v3.12.0
|
|
if certFile != "" && keyFile != "" {
|
|
args = append(args, "--cert-file", certFile, "--key-file", keyFile)
|
|
}
|
|
if caFile != "" {
|
|
args = append(args, "--ca-file", caFile)
|
|
}
|
|
}
|
|
|
|
if skipTLSVerify {
|
|
args = append(args, "--insecure")
|
|
}
|
|
|
|
args = append(args, "--username", username, "--password-stdin")
|
|
buffer := bytes.Buffer{}
|
|
buffer.Write([]byte(fmt.Sprintf("%s\n", password)))
|
|
|
|
helm.logger.Info("Logging in to registry")
|
|
out, err := helm.execStdIn(args, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, &buffer)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
// toKebabCase converts a PascalCase or camelCase string to kebab-case.
|
|
// e.g., "SkipRefresh" -> "skip-refresh", "KubeContext" -> "kube-context"
|
|
func toKebabCase(s string) string {
|
|
var result strings.Builder
|
|
for i, r := range s {
|
|
if i > 0 && unicode.IsUpper(r) {
|
|
result.WriteRune('-')
|
|
}
|
|
result.WriteRune(unicode.ToLower(r))
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
// getSupportedDependencyFlags returns a map of supported flags for helm dependency commands.
|
|
// It uses reflection on helm's action.Dependency and cli.EnvSettings structs to
|
|
// dynamically determine which flags are supported, avoiding hardcoded lists.
|
|
// Uses version-specific packages based on whether Helm 3 or Helm 4 is detected.
|
|
func getSupportedDependencyFlags() map[string]bool {
|
|
supported := make(map[string]bool)
|
|
|
|
// Determine which Helm version's API to use based on environment or default to Helm 4
|
|
useHelm3 := os.Getenv("HELMFILE_HELM4") != "1"
|
|
|
|
if useHelm3 {
|
|
// Get global flags from Helm 3 cli.EnvSettings
|
|
envSettings := cliv3.New()
|
|
envType := reflect.TypeOf(*envSettings)
|
|
for i := 0; i < envType.NumField(); i++ {
|
|
field := envType.Field(i)
|
|
if field.IsExported() {
|
|
flagName := "--" + toKebabCase(field.Name)
|
|
supported[flagName] = true
|
|
}
|
|
}
|
|
|
|
// Add namespace short form
|
|
supported["-n"] = true
|
|
|
|
// Get dependency-specific flags from Helm 3 action.Dependency
|
|
dep := actionv3.NewDependency()
|
|
depType := reflect.TypeOf(*dep)
|
|
for i := 0; i < depType.NumField(); i++ {
|
|
field := depType.Field(i)
|
|
if field.IsExported() {
|
|
flagName := "--" + toKebabCase(field.Name)
|
|
supported[flagName] = true
|
|
}
|
|
}
|
|
} else {
|
|
// Get global flags from Helm 4 cli.EnvSettings
|
|
envSettings := cliv4.New()
|
|
envType := reflect.TypeOf(*envSettings)
|
|
for i := 0; i < envType.NumField(); i++ {
|
|
field := envType.Field(i)
|
|
if field.IsExported() {
|
|
flagName := "--" + toKebabCase(field.Name)
|
|
supported[flagName] = true
|
|
}
|
|
}
|
|
|
|
// Add namespace short form
|
|
supported["-n"] = true
|
|
|
|
// Get dependency-specific flags from Helm 4 action.Dependency
|
|
dep := actionv4.NewDependency()
|
|
depType := reflect.TypeOf(*dep)
|
|
for i := 0; i < depType.NumField(); i++ {
|
|
field := depType.Field(i)
|
|
if field.IsExported() {
|
|
flagName := "--" + toKebabCase(field.Name)
|
|
supported[flagName] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return supported
|
|
}
|
|
|
|
// Cache of supported flags, initialized once
|
|
var (
|
|
supportedDependencyFlagsOnce sync.Once
|
|
supportedDependencyFlags map[string]bool
|
|
)
|
|
|
|
// filterDependencyUnsupportedFlags filters flags to only those supported by helm dependency commands.
|
|
// Uses reflection on helm's action.Dependency and cli.EnvSettings structs to dynamically
|
|
// determine supported flags, avoiding hardcoded lists.
|
|
func filterDependencyUnsupportedFlags(flags []string) []string {
|
|
if len(flags) == 0 {
|
|
return flags
|
|
}
|
|
|
|
// Initialize supported flags map once
|
|
supportedDependencyFlagsOnce.Do(func() {
|
|
supportedDependencyFlags = getSupportedDependencyFlags()
|
|
})
|
|
|
|
filtered := make([]string, 0, len(flags))
|
|
for _, flag := range flags {
|
|
// Extract flag name without value (e.g., "--dry-run=server" -> "--dry-run")
|
|
flagName := flag
|
|
if idx := strings.Index(flag, "="); idx != -1 {
|
|
flagName = flag[:idx]
|
|
}
|
|
|
|
// Check if this flag or any prefix of it is supported
|
|
supported := false
|
|
for supportedFlag := range supportedDependencyFlags {
|
|
if strings.HasPrefix(flagName, supportedFlag) {
|
|
supported = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if supported {
|
|
filtered = append(filtered, flag)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (helm *execer) BuildDeps(name, chart string, flags ...string) error {
|
|
helm.logger.Infof("Building dependency release=%v, chart=%v", name, chart)
|
|
|
|
// Filter out template/install/upgrade-specific flags while preserving global flags
|
|
savedExtra := helm.extra
|
|
helm.extra = filterDependencyUnsupportedFlags(helm.extra)
|
|
defer func() {
|
|
helm.extra = savedExtra
|
|
}()
|
|
|
|
args := []string{
|
|
"dependency",
|
|
"build",
|
|
chart,
|
|
}
|
|
|
|
args = append(args, flags...)
|
|
|
|
// Helm 4 requires --plain-http for HTTP-only OCI registries (not HTTPS with self-signed certs)
|
|
if helm.options.HelmOCIPlainHTTP && helm.IsHelm4() {
|
|
args = append(args, "--plain-http")
|
|
}
|
|
|
|
out, err := helm.exec(args, map[string]string{}, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) UpdateDeps(chart string) error {
|
|
helm.logger.Infof("Updating dependency %v", chart)
|
|
|
|
// Filter out template/install/upgrade-specific flags while preserving global flags
|
|
savedExtra := helm.extra
|
|
helm.extra = filterDependencyUnsupportedFlags(helm.extra)
|
|
defer func() {
|
|
helm.extra = savedExtra
|
|
}()
|
|
|
|
args := []string{"dependency", "update", chart}
|
|
|
|
// Helm 4 requires --plain-http for HTTP-only OCI registries (not HTTPS with self-signed certs)
|
|
if helm.options.HelmOCIPlainHTTP && helm.IsHelm4() {
|
|
args = append(args, "--plain-http")
|
|
}
|
|
|
|
out, err := helm.exec(args, map[string]string{}, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) SyncRelease(context HelmContext, name, chart, namespace string, flags ...string) error {
|
|
helm.logger.Infof("Upgrading release=%v, chart=%v, namespace=%v", name, redactedURL(chart), namespace)
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
|
|
flags = append(flags, "--history-max", strconv.Itoa(context.HistoryMax))
|
|
|
|
out, err := helm.exec(append(append(preArgs, "upgrade", "--install", name, chart), flags...), env, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) ReleaseStatus(context HelmContext, name string, flags ...string) error {
|
|
helm.logger.Infof("Getting status %v", name)
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
out, err := helm.exec(append(append(preArgs, "status", name), flags...), env, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) List(context HelmContext, filter string, flags ...string) (string, error) {
|
|
helm.logger.Infof("Listing releases matching %v", filter)
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
args := []string{"list", "--filter", filter}
|
|
|
|
enableLiveOutput := false
|
|
out, err := helm.exec(append(append(preArgs, args...), flags...), env, &enableLiveOutput)
|
|
// In v2 we have been expecting `helm list FILTER` prints nothing.
|
|
// In v3 helm still prints the header like `NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION`,
|
|
// which confuses helmfile's existing logic that treats any non-empty output from `helm list` is considered as the indication
|
|
// of the release to exist.
|
|
//
|
|
// This fixes it by removing the header from the v3 output, so that the output is formatted the same as that of v2.
|
|
lines := strings.Split(string(out), "\n")
|
|
lines = lines[1:]
|
|
out = []byte(strings.Join(lines, "\n"))
|
|
helm.info(out)
|
|
return string(out), err
|
|
}
|
|
|
|
func (helm *execer) DecryptSecret(context HelmContext, name string, flags ...string) (string, error) {
|
|
absPath, err := filepath.Abs(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
helm.logger.Debugf("Preparing to decrypt secret %v", absPath)
|
|
helm.decryptedSecretMutex.Lock()
|
|
|
|
secret, ok := helm.decryptedSecrets[absPath]
|
|
|
|
// Cache miss
|
|
if !ok {
|
|
secret = &decryptedSecret{}
|
|
helm.decryptedSecrets[absPath] = secret
|
|
|
|
secret.mutex.Lock()
|
|
defer secret.mutex.Unlock()
|
|
helm.decryptedSecretMutex.Unlock()
|
|
|
|
helm.logger.Infof("Decrypting secret %v", absPath)
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
// Use version-specific cli based on detected Helm version
|
|
var pluginsDir string
|
|
if helm.IsHelm3() {
|
|
pluginsDir = cliv3.New().PluginsDirectory
|
|
} else {
|
|
pluginsDir = cliv4.New().PluginsDirectory
|
|
}
|
|
pluginVersion, err := GetPluginVersion("secrets", pluginsDir)
|
|
if err != nil {
|
|
secret.err = err
|
|
return "", err
|
|
}
|
|
secretArg := "view"
|
|
// helm secret view command. The helm secret decrypt command is a drop-in replacement in 4.0.0 version
|
|
if pluginVersion.Major() > 3 {
|
|
secretArg = "decrypt"
|
|
}
|
|
enableLiveOutput := false
|
|
secretBytes, err := helm.exec(append(append(preArgs, "secrets", secretArg, absPath), flags...), env, &enableLiveOutput)
|
|
if err != nil {
|
|
secret.err = err
|
|
return "", err
|
|
}
|
|
|
|
// When the source encrypted file is not a yaml file AND helm secrets < 4
|
|
// secrets plugin returns a yaml file with all the content in a yaml `data` key
|
|
// which isn't parsable from an hcl perspective
|
|
if strings.HasSuffix(name, ".hcl") && pluginVersion.Major() < 4 {
|
|
type helmSecretDataV3 struct {
|
|
Data string `yaml:"data"`
|
|
}
|
|
var data helmSecretDataV3
|
|
err := yaml.Unmarshal(secretBytes, &data)
|
|
if err != nil {
|
|
return "", fmt.Errorf("Could not unmarshall helm secret plugin V3 decrypted file to a yaml string\n"+
|
|
"You may consider upgrading your helm secrets plugin to >4.0.\n %s", err.Error())
|
|
}
|
|
secretBytes = []byte(data.Data)
|
|
}
|
|
|
|
secret.bytes = secretBytes
|
|
} else {
|
|
// Cache hit
|
|
helm.logger.Debugf("Found secret in cache %v", absPath)
|
|
|
|
secret.mutex.RLock()
|
|
helm.decryptedSecretMutex.Unlock()
|
|
defer secret.mutex.RUnlock()
|
|
|
|
if secret.err != nil {
|
|
return "", secret.err
|
|
}
|
|
}
|
|
|
|
tempFile := helm.writeTempFile
|
|
|
|
if tempFile == nil {
|
|
tempFile = func(content []byte) (string, error) {
|
|
dir := filepath.Dir(name)
|
|
extension := filepath.Ext(name)
|
|
tmpFile, err := os.CreateTemp(dir, "secret*"+extension)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = tmpFile.Close()
|
|
}()
|
|
|
|
_, err = tmpFile.Write(content)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return tmpFile.Name(), nil
|
|
}
|
|
}
|
|
|
|
tmpFileName, err := tempFile(secret.bytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
helm.logger.Debugf("Decrypted %s into %s", absPath, tmpFileName)
|
|
|
|
return tmpFileName, err
|
|
}
|
|
|
|
func (helm *execer) TemplateRelease(name string, chart string, flags ...string) error {
|
|
helm.logger.Infof("Templating release=%v, chart=%v", name, redactedURL(chart))
|
|
args := []string{"template", name, chart}
|
|
|
|
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
|
|
|
|
var outputToFile bool
|
|
|
|
for _, f := range flags {
|
|
if strings.HasPrefix("--output-dir", f) {
|
|
outputToFile = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if outputToFile {
|
|
// With --output-dir is passed to helm-template,
|
|
// we can safely direct all the logs from it to our logger.
|
|
//
|
|
// It's safe because anything written to stdout by helm-template with output-dir is logs,
|
|
// like excessive `wrote path/to/output/dir/chart/template/file.yaml` messages,
|
|
// but manifets.
|
|
//
|
|
// See https://github.com/roboll/helmfile/pull/1691#issuecomment-805636021 for more information.
|
|
helm.info(out)
|
|
} else {
|
|
// Always write to stdout for use with e.g. `helmfile template | kubectl apply -f -`
|
|
helm.write(nil, out)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) DiffRelease(context HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error {
|
|
diffMsg := fmt.Sprintf("Comparing release=%v, chart=%v, namespace=%v\n", name, redactedURL(chart), namespace)
|
|
if context.Writer != nil && !suppressDiff {
|
|
_, _ = fmt.Fprint(context.Writer, diffMsg)
|
|
} else {
|
|
helm.logger.Info(diffMsg)
|
|
}
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
var overrideEnableLiveOutput *bool = nil
|
|
if suppressDiff {
|
|
enableLiveOutput := false
|
|
overrideEnableLiveOutput = &enableLiveOutput
|
|
}
|
|
|
|
// Issue #2280: In Helm 4, the --color flag is parsed by Helm before reaching the plugin,
|
|
// causing it to consume the next argument. Remove color flags and use HELM_DIFF_COLOR env var.
|
|
if helm.IsHelm4() {
|
|
flags = helm.filterColorFlagsForHelm4(flags, env)
|
|
}
|
|
|
|
out, err := helm.exec(append(append(preArgs, "diff", "upgrade", "--allow-unreleased", name, chart), flags...), env, overrideEnableLiveOutput)
|
|
// Do our best to write STDOUT only when diff existed
|
|
// Unfortunately, this works only when you run helmfile with `--detailed-exitcode`
|
|
detailedExitcodeEnabled := false
|
|
for _, f := range flags {
|
|
if strings.Contains(f, "detailed-exitcode") {
|
|
detailedExitcodeEnabled = true
|
|
break
|
|
}
|
|
}
|
|
if detailedExitcodeEnabled {
|
|
e, ok := err.(ExitError)
|
|
if ok && e.ExitStatus() == 2 {
|
|
if !(suppressDiff) {
|
|
helm.write(context.Writer, out)
|
|
}
|
|
return err
|
|
}
|
|
} else if !(suppressDiff) {
|
|
helm.write(context.Writer, out)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// filterColorFlagsForHelm4 removes --color and --no-color flags from the flags slice
|
|
// and sets the HELM_DIFF_COLOR environment variable instead.
|
|
// In Helm 4, the --color flag is parsed by Helm itself before reaching the helm-diff plugin,
|
|
// causing Helm to consume the next argument as the color value (issue #2280).
|
|
// The helm-diff plugin supports HELM_DIFF_COLOR=[true|false] env var as an alternative.
|
|
func (helm *execer) filterColorFlagsForHelm4(flags []string, env map[string]string) []string {
|
|
filtered := make([]string, 0, len(flags))
|
|
|
|
for _, flag := range flags {
|
|
switch flag {
|
|
case "--color":
|
|
// Use environment variable instead of flag for Helm 4
|
|
// Only set if not already present (defensive check)
|
|
if _, exists := env["HELM_DIFF_COLOR"]; !exists {
|
|
env["HELM_DIFF_COLOR"] = "true"
|
|
}
|
|
case "--no-color":
|
|
// Use environment variable instead of flag for Helm 4
|
|
// Only set if not already present (defensive check)
|
|
if _, exists := env["HELM_DIFF_COLOR"]; !exists {
|
|
env["HELM_DIFF_COLOR"] = "false"
|
|
}
|
|
default:
|
|
// Keep all other flags unchanged
|
|
filtered = append(filtered, flag)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func (helm *execer) Lint(name, chart string, flags ...string) error {
|
|
helm.logger.Infof("Linting release=%v, chart=%v", name, chart)
|
|
out, err := helm.exec(append([]string{"lint", chart}, flags...), map[string]string{}, nil)
|
|
// Always write to stdout to write the linting result to eg. a file
|
|
helm.write(nil, out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) Unittest(name, chart string, flags ...string) error {
|
|
// Check if the helm-unittest plugin is installed (cached across invocations)
|
|
helm.unittestPluginOnce.Do(func() {
|
|
var pluginsDir string
|
|
if helm.IsHelm3() {
|
|
pluginsDir = cliv3.New().PluginsDirectory
|
|
} else {
|
|
pluginsDir = cliv4.New().PluginsDirectory
|
|
}
|
|
_, err := GetPluginVersion("unittest", pluginsDir)
|
|
if err != nil {
|
|
helm.unittestPluginErr = fmt.Errorf("helm-unittest plugin is required for `helmfile unittest`. Install it with: helm plugin install https://github.com/helm-unittest/helm-unittest: %w", err)
|
|
}
|
|
})
|
|
if helm.unittestPluginErr != nil {
|
|
return helm.unittestPluginErr
|
|
}
|
|
|
|
helm.logger.Infof("Unit testing release=%v, chart=%v", name, chart)
|
|
out, err := helm.exec(append([]string{"unittest", chart}, flags...), map[string]string{}, nil)
|
|
helm.write(nil, out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) Fetch(chart string, flags ...string) error {
|
|
helm.logger.Infof("Fetching %v", redactedURL(chart))
|
|
out, err := helm.exec(append([]string{"fetch", chart}, flags...), map[string]string{}, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) ChartPull(chart string, path string, flags ...string) error {
|
|
var helmArgs []string
|
|
helm.logger.Infof("Pulling %v", chart)
|
|
helmVersionConstraint, _ := semver.NewConstraint(">= 3.7.0")
|
|
if helmVersionConstraint.Check(helm.version) {
|
|
// in the 3.7.0 version, the chart pull has been replaced with helm pull
|
|
// https://github.com/helm/helm/releases/tag/v3.7.0
|
|
ociChartURL, _ := resolveOciChart(chart)
|
|
helmArgs = []string{"pull", ociChartURL, "--destination", path, "--untar"}
|
|
helmArgs = append(helmArgs, flags...)
|
|
// Add --plain-http for OCI registries if requested (Helm 4 requirement for insecure registries)
|
|
if helm.options.HelmOCIPlainHTTP && strings.HasPrefix(ociChartURL, "oci://") {
|
|
helmArgs = append(helmArgs, "--plain-http")
|
|
}
|
|
} else {
|
|
helmArgs = []string{"chart", "pull", chart}
|
|
}
|
|
out, err := helm.exec(helmArgs, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) ChartExport(chart string, path string) error {
|
|
helmVersionConstraint, _ := semver.NewConstraint(">= 3.7.0")
|
|
if helmVersionConstraint.Check(helm.version) {
|
|
// in the 3.7.0 version, the chart export has been removed
|
|
// https://github.com/helm/helm/releases/tag/v3.7.0
|
|
return nil
|
|
}
|
|
var helmArgs []string
|
|
helm.logger.Infof("Exporting %v", chart)
|
|
helmArgs = []string{"chart", "export", chart, "--destination", path}
|
|
// no extra flags for before v3.7.0, details in helm chart export --help
|
|
out, err := helm.exec(helmArgs, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) DeleteRelease(context HelmContext, name string, flags ...string) error {
|
|
helm.logger.Infof("Deleting %v", name)
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
out, err := helm.exec(append(append(preArgs, "delete", name), flags...), env, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) RollbackRelease(context HelmContext, name string, flags ...string) error {
|
|
helm.logger.Infof("Rolling back %v", name)
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
out, err := helm.exec(append(append(preArgs, "rollback", name), flags...), env, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) TestRelease(context HelmContext, name string, flags ...string) error {
|
|
helm.logger.Infof("Testing %v", name)
|
|
preArgs := make([]string, 0)
|
|
env := make(map[string]string)
|
|
args := []string{"test", name}
|
|
out, err := helm.exec(append(append(preArgs, args...), flags...), env, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) AddPlugin(name, path, version string) error {
|
|
helm.logger.Infof("Install helm plugin %v", name)
|
|
|
|
// Special handling for helm-secrets 4.7.0+ with Helm 4 which uses split plugin architecture
|
|
if name == "secrets" && version >= "v4.7.0" && helm.IsHelm4() {
|
|
return helm.installHelmSecretsV4(version)
|
|
}
|
|
|
|
// Try with verification first
|
|
out, err := helm.exec([]string{"plugin", "install", path, "--version", version}, map[string]string{}, nil)
|
|
|
|
// If verification fails, retry without verification (unless enforced)
|
|
if err != nil && strings.Contains(err.Error(), "does not support verification") {
|
|
if helm.options.EnforcePluginVerification {
|
|
helm.logger.Errorf("Plugin %v does not support verification and plugin verification enforcement is enabled", name)
|
|
return fmt.Errorf("plugin %s does not support verification (remove --enforce-plugin-verification flag to allow unverified plugins)", name)
|
|
}
|
|
helm.logger.Debugf("Plugin %v does not support verification, retrying with --verify=false", name)
|
|
out, err = helm.exec([]string{"plugin", "install", path, "--version", version, "--verify=false"}, map[string]string{}, nil)
|
|
}
|
|
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) installHelmSecretsV4(version string) error {
|
|
helm.logger.Infof("Installing helm-secrets %s (split plugin architecture for Helm 4)", version)
|
|
|
|
baseURL := fmt.Sprintf("https://github.com/jkroepke/helm-secrets/releases/download/%s", version)
|
|
// Strip "v" prefix for filename (e.g., "v4.7.4" -> "4.7.4")
|
|
versionNum := strings.TrimPrefix(version, "v")
|
|
plugins := []string{
|
|
fmt.Sprintf("secrets-%s.tgz", versionNum),
|
|
fmt.Sprintf("secrets-getter-%s.tgz", versionNum),
|
|
fmt.Sprintf("secrets-post-renderer-%s.tgz", versionNum),
|
|
}
|
|
|
|
verifyFlag := ""
|
|
if !helm.options.EnforcePluginVerification {
|
|
verifyFlag = "--verify=false"
|
|
}
|
|
|
|
for _, plugin := range plugins {
|
|
url := fmt.Sprintf("%s/%s", baseURL, plugin)
|
|
args := []string{"plugin", "install", url}
|
|
if verifyFlag != "" {
|
|
args = append(args, verifyFlag)
|
|
}
|
|
|
|
out, err := helm.exec(args, map[string]string{}, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to install %s: %w", plugin, err)
|
|
}
|
|
helm.info(out)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (helm *execer) UpdatePlugin(name string) error {
|
|
helm.logger.Infof("Update helm plugin %v", name)
|
|
out, err := helm.exec([]string{"plugin", "update", name}, map[string]string{}, nil)
|
|
helm.info(out)
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) exec(args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) {
|
|
cmdargs := args
|
|
if len(helm.extra) > 0 {
|
|
cmdargs = append(cmdargs, helm.extra...)
|
|
}
|
|
if helm.kubeContext != "" {
|
|
cmdargs = append([]string{"--kube-context", helm.kubeContext}, cmdargs...)
|
|
}
|
|
if helm.kubeconfig != "" {
|
|
cmdargs = append([]string{"--kubeconfig", helm.kubeconfig}, cmdargs...)
|
|
}
|
|
cmd := fmt.Sprintf("exec: %s %s", helm.helmBinary, strings.Join(cmdargs, " "))
|
|
helm.logger.Debug(cmd)
|
|
enableLiveOutput := helm.options.EnableLiveOutput
|
|
if overrideEnableLiveOutput != nil {
|
|
enableLiveOutput = *overrideEnableLiveOutput
|
|
}
|
|
outBytes, err := helm.runner.Execute(helm.helmBinary, cmdargs, env, enableLiveOutput)
|
|
return outBytes, err
|
|
}
|
|
|
|
func (helm *execer) execStdIn(args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
|
|
cmdargs := args
|
|
if len(helm.extra) > 0 {
|
|
cmdargs = append(cmdargs, helm.extra...)
|
|
}
|
|
if helm.kubeContext != "" {
|
|
cmdargs = append([]string{"--kube-context", helm.kubeContext}, cmdargs...)
|
|
}
|
|
if helm.kubeconfig != "" {
|
|
cmdargs = append([]string{"--kubeconfig", helm.kubeconfig}, cmdargs...)
|
|
}
|
|
cmd := fmt.Sprintf("exec: %s %s", helm.helmBinary, strings.Join(cmdargs, " "))
|
|
helm.logger.Debug(cmd)
|
|
outBytes, err := helm.runner.ExecuteStdIn(helm.helmBinary, cmdargs, env, stdin)
|
|
return outBytes, err
|
|
}
|
|
|
|
func (helm *execer) azcli(name string) ([]byte, error) {
|
|
cmdargs := append(strings.Split("acr helm repo add --name", " "), name)
|
|
cmd := fmt.Sprintf("exec: az %s", strings.Join(cmdargs, " "))
|
|
helm.logger.Debug(cmd)
|
|
outBytes, err := helm.runner.Execute("az", cmdargs, map[string]string{}, false)
|
|
if len(outBytes) > 0 {
|
|
helm.logger.Debugf("%s: %s", cmd, outBytes)
|
|
} else {
|
|
helm.logger.Debugf("%s:", cmd)
|
|
}
|
|
return outBytes, err
|
|
}
|
|
|
|
func (helm *execer) info(out []byte) {
|
|
if len(out) > 0 {
|
|
helm.logger.Infof("%s", out)
|
|
}
|
|
}
|
|
|
|
func (helm *execer) write(w io.Writer, out []byte) {
|
|
if len(out) > 0 {
|
|
if w == nil {
|
|
w = os.Stdout
|
|
}
|
|
_, _ = fmt.Fprintf(w, "%s\n", out)
|
|
}
|
|
}
|
|
|
|
func (helm *execer) IsHelm3() bool {
|
|
return helm.version.Major() == 3
|
|
}
|
|
|
|
func (helm *execer) IsHelm4() bool {
|
|
return helm.version.Major() == 4
|
|
}
|
|
|
|
func (helm *execer) GetVersion() Version {
|
|
return Version{
|
|
Major: int(helm.version.Major()),
|
|
Minor: int(helm.version.Minor()),
|
|
Patch: int(helm.version.Patch()),
|
|
}
|
|
}
|
|
|
|
func (helm *execer) IsVersionAtLeast(versionStr string) bool {
|
|
ver := semver.MustParse(versionStr)
|
|
return helm.version.Equal(ver) || helm.version.GreaterThan(ver)
|
|
}
|
|
|
|
func resolveOciChart(ociChart string) (ociChartURL, ociChartTag string) {
|
|
// Split off digest (e.g., @sha256:abc) first so the colon in sha256:
|
|
// does not confuse the version tag search below.
|
|
var digest string
|
|
if atIdx := strings.Index(ociChart, "@"); atIdx >= 0 {
|
|
digest = ociChart[atIdx:] // includes the "@"
|
|
ociChart = ociChart[:atIdx]
|
|
}
|
|
|
|
var urlTagIndex int
|
|
// Get the last : index in the pre-digest part
|
|
// e.g.,
|
|
// 1. registry:443/helm-charts
|
|
// 2. registry/helm-charts:latest
|
|
// 3. registry:443/helm-charts:latest
|
|
if strings.LastIndex(ociChart, ":") <= strings.LastIndex(ociChart, "/") {
|
|
urlTagIndex = len(ociChart)
|
|
ociChartTag = ""
|
|
} else {
|
|
urlTagIndex = strings.LastIndex(ociChart, ":")
|
|
ociChartTag = ociChart[urlTagIndex+1:]
|
|
}
|
|
ociChartURL = fmt.Sprintf("oci://%s%s", ociChart[:urlTagIndex], digest)
|
|
return ociChartURL, ociChartTag
|
|
}
|
|
|
|
func (helm *execer) ShowChart(chartPath string) (chart.Metadata, error) {
|
|
var helmArgs = []string{"show", "chart", chartPath}
|
|
out, error := helm.exec(helmArgs, map[string]string{}, nil)
|
|
if error != nil {
|
|
return chart.Metadata{}, error
|
|
}
|
|
var metadata chart.Metadata
|
|
error = yaml.Unmarshal(out, &metadata)
|
|
if error != nil {
|
|
return chart.Metadata{}, error
|
|
}
|
|
return metadata, nil
|
|
}
|