mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 12:54:14 +02:00
* fix: update helm plugins via uninstall+reinstall to honor pinned version (#2726) helm plugin update re-installs a plugin from its cached source WITHOUT the --version flag, so it does not reliably install the pinned version helmfile requests. It reports success (exit 0) while re-downloading the unchanged cached source, leaving 'helm plugin list' showing the old version. This affected 'helmfile init --force' which reported a successful diff/secrets plugin update while the old version remained installed. The reporter's workaround was to uninstall the plugin before running init. UpdatePlugin now uninstalls the existing plugin and reinstalls the exact pinned version via AddPlugin (which passes --version). The unreliable 'plugin update' command is no longer used for the general path; this mirrors the uninstall+reinstall strategy already used for helm-secrets on Helm 4. Fixes #2726 Refs #2548 Signed-off-by: yxxhero <aiopsclub@163.com> * fix: only ignore 'not found' uninstall errors in UpdatePlugin Address review feedback: UpdatePlugin ignored all uninstall errors, which could mask real failures (permissions, broken Helm) behind a less informative 'plugin already exists' error from the subsequent install. Now only the expected 'not found' case (plugin removed concurrently, reported by both Helm 3 'Plugin: <name> not found' and Helm 4 'plugin: <name> not found') is ignored and install proceeds. Any other uninstall error is returned. Adds tests for both branches. Signed-off-by: yxxhero <aiopsclub@163.com> * fix: extract pluginCmd constant to satisfy goconst in tests golangci-lint (goconst, min-occurrences: 8) flagged the "plugin" string literal in exec_test.go after the new UpdatePlugin tests pushed the package- wide count from 7 (under threshold, passing on main) to 14. Introduce a pluginCmd constant and use it for all helm sub-command checks in the UpdatePlugin tests. No behavior change; drops the literal count back below the threshold so CI goconst passes. Signed-off-by: yxxhero <aiopsclub@163.com> * fix: match helm plugin-absent error precisely; guard against plugin update regression Address review feedback on UpdatePlugin: 1. The uninstall error check used strings.Contains(err, "not found"), which is too broad: it also matches unrelated failures such as a missing helm binary ("executable file not found") or an uninstall hook failing with "sh: ...: not found". Those would be silently treated as "plugin absent" and logged/proceeded past, masking the real problem. Match only helm's specific plugin-absent message instead, via a case-insensitive regex that covers both majors: Helm 4: "plugin: <name> not found" Helm 3: "Plugin: <name> not found" All other uninstall failures (permissions, missing binary, hook errors) are now surfaced. 2. The init test mock had no "plugin update" branch, so a production regression to calling helm plugin update would go undetected (false negative). Add an explicit case that records and fails on "plugin update". Also adds a test for the missing-binary case and makes the plugin-absent test table-driven across Helm 3/4 formats. Extracts installCmd to keep goconst (min-occurrences: 8) satisfied after the new tests. Signed-off-by: yxxhero <aiopsclub@163.com> --------- Signed-off-by: yxxhero <aiopsclub@163.com>
1363 lines
44 KiB
Go
1363 lines
44 KiB
Go
package helmexec
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"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
|
|
// RepoRetry is the number of times to retry helm repo and registry login
|
|
// operations on failure, with exponential backoff. 0 disables retries.
|
|
RepoRetry int
|
|
}
|
|
|
|
type execer struct {
|
|
helmBinary string
|
|
options HelmExecOptions
|
|
version *semver.Version
|
|
runner Runner
|
|
logger *zap.SugaredLogger
|
|
kubeconfig string
|
|
kubeContext string
|
|
extra []string
|
|
// decryptedSecretMutex guards decryptedSecrets. It's a pointer so that
|
|
// WithLogger() can shallow-copy the execer and share the same lock+map
|
|
// across clones (the underlying secret cache must remain a single source
|
|
// of truth across all clones).
|
|
decryptedSecretMutex *sync.Mutex
|
|
decryptedSecrets map[string]*decryptedSecret
|
|
writeTempFile func([]byte) (string, error)
|
|
// unittestPluginOnce is a pointer so WithLogger() clones share the
|
|
// detection result with the original execer.
|
|
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 {
|
|
isDir := entry.IsDir()
|
|
if !isDir && entry.Type()&os.ModeSymlink != 0 {
|
|
info, err := os.Stat(filepath.Join(dir, entry.Name()))
|
|
if err == nil {
|
|
isDir = info.IsDir()
|
|
}
|
|
}
|
|
if !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,
|
|
decryptedSecretMutex: &sync.Mutex{},
|
|
decryptedSecrets: make(map[string]*decryptedSecret),
|
|
unittestPluginOnce: &sync.Once{},
|
|
}, nil
|
|
}
|
|
|
|
// LoggerSwapper is the runtime contract that callers use to obtain a logger-
|
|
// scoped helm clone for capturing helm output into a buffer. It's a side
|
|
// interface (not part of helmexec.Interface) so mock implementations don't
|
|
// have to provide it.
|
|
type LoggerSwapper interface {
|
|
WithLogger(logger *zap.SugaredLogger) Interface
|
|
}
|
|
|
|
// WithLogger returns a shallow copy of the execer that writes its log output
|
|
// to the given logger. The clone shares mutable state (decrypted-secret cache
|
|
// and its lock) with the original via the pointer mutex, so concurrent use
|
|
// of clone + original for decryption is safe.
|
|
func (helm *execer) WithLogger(logger *zap.SugaredLogger) Interface {
|
|
clone := *helm
|
|
clone.logger = logger
|
|
return &clone
|
|
}
|
|
|
|
// ContextSwapper is the runtime contract for obtaining a context-scoped helm
|
|
// clone. Canceling the context cancels any helm subprocess started through
|
|
// the clone — the underlying ShellRunner already sends SIGINT to its
|
|
// subprocess on ctx.Done() (runner.go), so this gives callers a clean way to
|
|
// kill an in-flight helm without touching the global app context. Side
|
|
// interface so mocks don't have to implement it.
|
|
type ContextSwapper interface {
|
|
WithContext(ctx context.Context) Interface
|
|
}
|
|
|
|
// WithContext returns a shallow copy of the execer whose runner uses the
|
|
// provided context. Canceling ctx triggers SIGINT to any helm subprocess
|
|
// started through the clone. Falls back gracefully (returns clone with the
|
|
// original runner) when the runner isn't a ShellRunner — only the kill path
|
|
// becomes a no-op in that case.
|
|
func (helm *execer) WithContext(ctx context.Context) Interface {
|
|
clone := *helm
|
|
switch r := helm.runner.(type) {
|
|
case *ShellRunner:
|
|
rClone := *r
|
|
rClone.Ctx = ctx
|
|
clone.runner = &rClone
|
|
case ShellRunner:
|
|
r.Ctx = ctx
|
|
clone.runner = r
|
|
}
|
|
return &clone
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// repoRetryBaseBackoff is the base unit for exponential backoff between repo
|
|
// operation retries (doubles each attempt, capped at 30x). Exposed as a
|
|
// package-level variable so tests can shrink it to avoid real sleeps.
|
|
var repoRetryBaseBackoff = time.Second
|
|
|
|
// retryRepoOp runs op, retrying up to helm.options.RepoRetry times on failure
|
|
// with exponential backoff (1x, 2x, 4x, ..., capped at 30x the base unit). It
|
|
// returns the output from the (last) attempt. A zero/negative RepoRetry
|
|
// disables retrying — op runs exactly once.
|
|
func (helm *execer) retryRepoOp(name string, op func() ([]byte, error)) ([]byte, error) {
|
|
maxRetries := helm.options.RepoRetry
|
|
var out []byte
|
|
var err error
|
|
for attempt := 0; ; attempt++ {
|
|
out, err = op()
|
|
if err == nil || maxRetries <= 0 || attempt >= maxRetries {
|
|
return out, err
|
|
}
|
|
// Cap the shift exponent at 5 (2^5 = 32x already exceeds the 30x cap)
|
|
// so very large --repo-retries values can't overflow time.Duration.
|
|
shift := attempt
|
|
if shift > 5 {
|
|
shift = 5
|
|
}
|
|
backoff := repoRetryBaseBackoff * time.Duration(1<<shift)
|
|
if backoff > 30*repoRetryBaseBackoff {
|
|
backoff = 30 * repoRetryBaseBackoff
|
|
}
|
|
helm.logger.Warnf("repo operation %q failed (%s); retry %d/%d in %v",
|
|
name, conciseError(err), attempt+1, maxRetries, backoff)
|
|
// sleepCtx returns false if interrupted by context cancellation; in that
|
|
// case stop retrying so a canceled context (Ctrl+C) doesn't spin into a
|
|
// tight loop of rapid helm invocations.
|
|
if !helm.sleepCtx(backoff) {
|
|
return out, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// conciseError returns a short reason for logging. For a helm ExitError it
|
|
// reports just the exit status, avoiding the very verbose PATH/ARGS/OUTPUT
|
|
// dump that Error() produces.
|
|
func conciseError(err error) string {
|
|
var ee ExitError
|
|
if errors.As(err, &ee) {
|
|
return fmt.Sprintf("exit status %d", ee.ExitStatus())
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// sleepCtx sleeps for d, returning early if the runner's context is canceled,
|
|
// so an interrupt (Ctrl+C) aborts the retry loop promptly rather than blocking
|
|
// until the full backoff elapses. It returns true if the full duration elapsed,
|
|
// or false if it was interrupted by context cancellation. Falls back to
|
|
// time.Sleep (returning true) for runners without a context (e.g. the test
|
|
// mockRunner).
|
|
func (helm *execer) sleepCtx(d time.Duration) bool {
|
|
ctx := helm.runnerContext()
|
|
if ctx == nil {
|
|
time.Sleep(d)
|
|
return true
|
|
}
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
|
|
// runnerContext returns the ShellRunner's context if the runner is a
|
|
// ShellRunner (pointer or value), else nil.
|
|
func (helm *execer) runnerContext() context.Context {
|
|
switch r := helm.runner.(type) {
|
|
case *ShellRunner:
|
|
return r.Ctx
|
|
case ShellRunner:
|
|
return r.Ctx
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (helm *execer) AddRepo(name, repository, cafile, certfile, keyfile, username, password string, managed string, passCredentials, skipTLSVerify bool) 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
|
|
}()
|
|
|
|
var out []byte
|
|
var err error
|
|
switch managed {
|
|
case "acr":
|
|
helm.logger.Infof("Adding repo %v (acr)", name)
|
|
out, err = helm.retryRepoOp(fmt.Sprintf("add %s (acr)", name), func() ([]byte, error) {
|
|
return helm.azcli(name)
|
|
})
|
|
case "":
|
|
helm.logger.Infof("Adding repo %v %v", name, repository)
|
|
out, err = helm.retryRepoOp(fmt.Sprintf("add %s", name), func() ([]byte, error) {
|
|
// args is local to each attempt, so the username/password append
|
|
// below can't accumulate across retries.
|
|
args := []string{"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")
|
|
}
|
|
if username != "" && password != "" {
|
|
args = append(args, "--username", username, "--password-stdin")
|
|
buffer := bytes.Buffer{}
|
|
fmt.Fprintf(&buffer, "%s\n", password)
|
|
return helm.execStdIn(args, map[string]string{}, &buffer)
|
|
}
|
|
return helm.exec(args, map[string]string{}, nil)
|
|
})
|
|
default:
|
|
helm.logger.Errorf("ERROR: unknown type '%v' for repository %v", managed, name)
|
|
err = fmt.Errorf("unknown managed type %q for repository %v", managed, name)
|
|
}
|
|
|
|
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.retryRepoOp("update", func() ([]byte, error) {
|
|
return 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")
|
|
|
|
helm.logger.Info("Logging in to registry")
|
|
out, err := helm.retryRepoOp(fmt.Sprintf("registry login %s", repository), func() ([]byte, error) {
|
|
buffer := bytes.Buffer{}
|
|
fmt.Fprintf(&buffer, "%s\n", password)
|
|
// Copy args so execStdIn's internal append (for helm.extra) can't alias
|
|
// the shared slice across retries.
|
|
return helm.execStdIn(append([]string{}, 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}
|
|
|
|
var outputToFile bool
|
|
var hasPostRenderer bool
|
|
|
|
for _, f := range flags {
|
|
if f == "--output-dir" || strings.HasPrefix(f, "--output-dir=") {
|
|
outputToFile = true
|
|
}
|
|
if f == "--post-renderer" || strings.HasPrefix(f, "--post-renderer=") {
|
|
hasPostRenderer = true
|
|
}
|
|
}
|
|
|
|
if outputToFile && hasPostRenderer && helm.IsHelm3() {
|
|
// Helm 3 does not apply --post-renderer to files written by --output-dir.
|
|
// It writes pre-post-renderer content to files and sends post-renderer output to stdout.
|
|
// Helm 4 handles this correctly, so the workaround is only needed for Helm 3.
|
|
// Workaround: run without --output-dir, capture stdout (with post-renderer applied),
|
|
// and write the output to the output directory ourselves.
|
|
var outputDir string
|
|
filteredFlags := make([]string, 0, len(flags))
|
|
for i := 0; i < len(flags); i++ {
|
|
if flags[i] == "--output-dir" && i+1 < len(flags) {
|
|
outputDir = flags[i+1]
|
|
i++
|
|
continue
|
|
}
|
|
if strings.HasPrefix(flags[i], "--output-dir=") {
|
|
outputDir = strings.TrimPrefix(flags[i], "--output-dir=")
|
|
continue
|
|
}
|
|
filteredFlags = append(filteredFlags, flags[i])
|
|
}
|
|
|
|
if outputDir == "" {
|
|
return fmt.Errorf("output dir not found for template command")
|
|
}
|
|
|
|
out, err := helm.exec(append(args, filteredFlags...), map[string]string{}, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
templatesDir := filepath.Join(outputDir, "templates")
|
|
legacyOutputPath := filepath.Join(outputDir, name+".yaml")
|
|
outputPath := filepath.Join(templatesDir, name+".yaml")
|
|
|
|
if removeErr := os.Remove(legacyOutputPath); removeErr != nil && !os.IsNotExist(removeErr) {
|
|
return fmt.Errorf("failed to remove legacy output file %s: %w", legacyOutputPath, removeErr)
|
|
}
|
|
|
|
// Remove only the specific file written by the previous run to avoid clobbering
|
|
// unrelated files in a shared output directory.
|
|
if removeErr := os.Remove(outputPath); removeErr != nil && !os.IsNotExist(removeErr) {
|
|
return fmt.Errorf("failed to remove stale output file %s: %w", outputPath, removeErr)
|
|
}
|
|
|
|
if len(out) > 0 {
|
|
if mkdirErr := os.MkdirAll(templatesDir, 0755); mkdirErr != nil {
|
|
return fmt.Errorf("failed to create templates directory %s: %w", templatesDir, mkdirErr)
|
|
}
|
|
|
|
if writeErr := os.WriteFile(outputPath, append(out, '\n'), 0644); writeErr != nil {
|
|
return fmt.Errorf("failed to write output file %s: %w", outputPath, writeErr)
|
|
}
|
|
helm.logger.Debugf("Wrote post-renderer output to %s", outputPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
|
|
|
|
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 emits one `wrote <path>` line per resource document in each file, so a
|
|
// multi-doc template produces N identical lines for the same path. Dedupe them
|
|
// to keep the output readable.
|
|
helm.info(dedupeWroteLines(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) 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" && helmSecretsRequiresSplitInstall(version) && 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
|
|
}
|
|
|
|
// helmSecretsV4SplitMinVersion is the minimum helm-secrets version that uses the
|
|
// split plugin architecture (secrets, secrets-getter, secrets-post-renderer) with Helm 4.
|
|
var helmSecretsV4SplitMinVersion = semver.MustParse("4.7.0")
|
|
|
|
// pluginMissingRe matches helm's "plugin absent" error emitted by `helm plugin
|
|
// uninstall` when the plugin is not installed:
|
|
//
|
|
// Helm 4: "plugin: <name> not found"
|
|
// Helm 3: "Plugin: <name> not found"
|
|
//
|
|
// It is intentionally specific so that unrelated failures that happen to contain
|
|
// "not found" (e.g. a missing helm binary -> "executable file not found", or an
|
|
// uninstall hook failing with "sh: ...: not found") are NOT mistaken for an
|
|
// absent plugin. It is case-insensitive and scoped to a single line.
|
|
var pluginMissingRe = regexp.MustCompile(`(?i)plugin: .* not found`)
|
|
|
|
// helmSecretsRequiresSplitInstall returns true when the given helm-secrets version
|
|
// requires the split plugin architecture introduced in v4.7.0 for Helm 4.
|
|
func helmSecretsRequiresSplitInstall(version string) bool {
|
|
v, err := semver.NewVersion(version)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !v.LessThan(helmSecretsV4SplitMinVersion)
|
|
}
|
|
|
|
func (helm *execer) uninstallPlugin(name string) error {
|
|
helm.logger.Infof("Uninstalling helm plugin %v", name)
|
|
out, err := helm.exec([]string{"plugin", "uninstall", name}, map[string]string{}, nil)
|
|
if err == nil {
|
|
helm.info(out)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (helm *execer) UpdatePlugin(name, repo, version string) error {
|
|
helm.logger.Infof("Updating helm plugin %v", name)
|
|
|
|
// Special handling for helm-secrets 4.7.0+ with Helm 4 which uses split plugin architecture
|
|
if name == "secrets" && helmSecretsRequiresSplitInstall(version) && helm.IsHelm4() {
|
|
// Uninstall existing secrets plugins; ignore errors as some may not exist
|
|
for _, secretsPlugin := range []string{"secrets", "secrets-getter", "secrets-post-renderer"} {
|
|
if err := helm.uninstallPlugin(secretsPlugin); err != nil {
|
|
helm.logger.Debugf("Failed to uninstall helm plugin %v (may not exist): %v", secretsPlugin, err)
|
|
}
|
|
}
|
|
return helm.installHelmSecretsV4(version)
|
|
}
|
|
|
|
// `helm plugin update` re-installs the plugin from its cached source WITHOUT the
|
|
// `--version` flag, so it does not reliably install the specific version we need.
|
|
// On many setups it reports success (exit code 0) while `helm plugin list` still
|
|
// shows the old version, because the cached source is re-downloaded unchanged.
|
|
// See https://github.com/helmfile/helmfile/issues/2726 and
|
|
// https://github.com/helmfile/helmfile/issues/2548.
|
|
//
|
|
// The reliable way to update to a pinned version is to uninstall the existing
|
|
// plugin and reinstall it at the requested version. Only the expected
|
|
// "plugin already absent" case is tolerated: helm reports it as
|
|
// "plugin: <name> not found" (Helm 4) / "Plugin: <name> not found" (Helm 3).
|
|
// We match that specific message rather than a bare "not found", so that other
|
|
// failures (permissions, a missing helm binary whose error contains
|
|
// "executable file not found", a plugin uninstall hook failing with
|
|
// "sh: ...: not found", ...) are surfaced instead of being silently ignored.
|
|
if err := helm.uninstallPlugin(name); err != nil {
|
|
if !pluginMissingRe.MatchString(err.Error()) {
|
|
return fmt.Errorf("failed to uninstall helm plugin %q for reinstall: %w", name, err)
|
|
}
|
|
helm.logger.Debugf("helm plugin %v not present during update, proceeding to install: %v", name, err)
|
|
}
|
|
return helm.AddPlugin(name, repo, version)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// dedupeWroteLines collapses repeated `wrote <path>` lines from helm-template
|
|
// stdout into a single occurrence per path (first-seen order). Helm v4 emits
|
|
// one line per resource document, so a multi-doc YAML produces N identical
|
|
// lines for the same file path; the duplicates are pure noise.
|
|
func dedupeWroteLines(out []byte) []byte {
|
|
if len(out) == 0 {
|
|
return out
|
|
}
|
|
lines := strings.Split(string(out), "\n")
|
|
seen := make(map[string]struct{}, len(lines))
|
|
kept := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "wrote ") {
|
|
if _, ok := seen[line]; ok {
|
|
continue
|
|
}
|
|
seen[line] = struct{}{}
|
|
}
|
|
kept = append(kept, line)
|
|
}
|
|
return []byte(strings.Join(kept, "\n"))
|
|
}
|
|
|
|
func (helm *execer) info(out []byte) {
|
|
// Trim trailing newlines so the encoder's appended newline is the only
|
|
// one. Without this, helm stdout that ends in "\n" (e.g. a chart whose
|
|
// template renders only hooks) becomes a "\n" payload, and the encoder
|
|
// produces "\n\n" — extra blank lines that are very visible in
|
|
// timestamp-per-line CI logs. Skip the call entirely when the trim
|
|
// leaves nothing.
|
|
trimmed := bytes.TrimRight(out, "\n")
|
|
if len(trimmed) > 0 {
|
|
helm.logger.Infof("%s", trimmed)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|