Files
helmfile/pkg/helmexec/exec.go
T
c36dbfd417 fix: resolve OCI version constraints before deriving the shared chart cache path (#2768)
* fix: resolve OCI version constraints before deriving the shared chart cache path

When an OCI release uses a semver constraint (e.g. `~1`, `^2.0.0`, `*`),
`getOCIChartPath` currently derives the on-disk cache directory from the
raw constraint string via `safeVersionPath`, which substitutes constraint
characters (`~`, `^`, `>`, `<`, `!`, `|`, `=`, ` `, `,`, `*`) with `_`.
So `version: ~1` becomes `.../mychart/_1/` on disk. `acquireChartLock`
then refuses to refresh anything under the shared cache dir to avoid
race conditions between concurrent processes, so once the constraint is
first resolved and written to `_1/`, every subsequent render on that
machine (or that container replica) returns the pinned tarball
regardless of newer matching tags being published.

In multi-pod deployments like ArgoCD's argocd-repo-server this shows up
as intermittent stale renders: different pods populate their caches at
different moments and serve different snapshots of the same `~1`
release forever.

Fix: for OCI releases whose `version` looks like a constraint, run
`helm show chart <ref> --version <constraint> [flags]` and use the
returned metadata.Version as the effective version for all downstream
cache-key and path derivation. Helm already resolves the constraint
against the registry and returns the concrete matching Chart.yaml.
Callers get a content-addressable cache path (`.../mychart/1.0.1/`)
that naturally invalidates when the constraint resolves to a new
version. Exact-version releases and non-OCI releases skip the extra
call.

Adds an opt-out `resolveOCIVersions` field on `helmDefaults` and
`ReleaseSpec` (both default true). If the resolution call fails
transiently, the resolver logs a warning and falls back to the
pre-fix behavior so a network hiccup doesn't break rendering.

Adds `ShowChartWithFlags` to helmexec.Interface so the existing
`ShowChart` API stays backwards compatible.

Resolves #2766

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* refactor: move ShowChartWithFlags to a ChartInspector capability interface

Address Copilot review feedback on PR #2768: adding a method to the
exported helmexec.Interface is a source-breaking change for every
third-party implementation and mock of that interface, even though
ShowChart itself stayed backward-compatible.

Move ShowChartWithFlags off Interface and onto a new capability
interface, helmexec.ChartInspector, following the same pattern used
by the existing DependencyUpdater capability interface. The concrete
execer and the exectest.Helm test stub still satisfy it (they
already have the method); the OCI resolver in state.HelmState now
type-asserts and falls back to the pre-fix caching behavior when the
capability is absent, so downstream callers with their own
helmexec.Interface implementations keep compiling untouched.

Adds TestResolveOCIConstraintVersion_ChartInspectorFallback that
exercises the type-assertion path with a helm value that satisfies
Interface but deliberately does not satisfy ChartInspector.

Reverts ShowChartWithFlags additions from testutil.noCallHelmExec
and app_test.mockHelmExec since Interface no longer requires them.

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* fix: detect wildcard-segment semver constraints (1.x, 1.X) as constraints

Address Copilot review feedback on PR #2768: the previous
isVersionConstraint implementation scanned the input for operator
characters (~, ^, >, <, !, |, =, space, comma, *). Masterminds/semver
also accepts wildcard-segment constraints like "1.x", "1.X", "1.x.x",
and "1.2.X" that contain no operator characters. Those would slip past
the classifier, bypass OCI constraint resolution, and remain cached
forever under the raw ".../mychart/1.x/" path — the same stale-cache
bug the PR is meant to fix.

Replace the character scan with a semver-parser-based check: a value
is a constraint iff Masterminds/semver rejects it as a NewVersion but
accepts it as a NewConstraint. This correctly:

  - Recognizes wildcard forms (1.x, 1.X, 1.x.x, 1.2.x, v1.x).
  - Preserves exact versions where "x" appears in prerelease metadata
    ("1.0.0-alpha.x") or build metadata ("1.0.0+x", "1.0.0+build.x.1")
    without misclassifying them, which a naive "add x to the scanned
    charset" fix would have gotten wrong.
  - Continues to classify values that are neither a version nor a
    constraint (empty string, "latest", junk) as non-constraints; helm
    handles those elsewhere.

Removes the now-unused versionConstraintChars string constant.

Expands TestIsVersionConstraint with 8 wildcard cases and 3 prerelease
/build metadata cases containing "x", plus 2 non-parseable inputs.
Adds a "wildcard segment constraint resolves to concrete version"
subtest to TestResolveOCIConstraintVersion so the end-to-end pipeline
is exercised for a version string that has no operator characters.

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* test: add getOCIChart integration test proving cache-path/pull-flag wiring

Address Copilot review feedback on PR #2768. The existing unit test
exercised resolveOCIConstraintVersion in isolation but did not prove
that its output was propagated into the downstream cache key, cache
path, and `helm chart pull --version` flag. Add a targeted
integration test that:

  1. Calls getOCIChart with a constraint release (`~1`) and a helm
     mock whose ShowChartWithFlags returns Chart.yaml version 1.0.1.
  2. Asserts helm chart pull receives `--version 1.0.1`, not `~1`.
  3. Asserts the destination path passed to helm chart pull contains
     the resolved-version segment (`/1.0.1/`) and does NOT contain
     the raw-constraint segment (`/_1/`).
  4. Reads back the on-disk Chart.yaml under the cache path to
     confirm resolved version, path, and flag agree end to end.

Add a second test that runs the same release twice with different
resolver outputs (1.0.1, then 1.0.2 — simulating a newly published
matching tag) and asserts the two resolutions land in distinct cache
directories. This is the promise of the fix: once the raw constraint
is out of the path, a new matching tag stops silently reusing the
previously-resolved cache entry.

The integration test flushed out a real correctness gap in the
initial fix: getOCIChart resolved release.Version and chartVersion
but did NOT recompute the qualified OCI ref that
getOCIQualifiedChartName built pre-resolution. Helm was therefore
receiving `oci://<repo>/<chart>:<constraint>` alongside a
`--version <resolved>` flag — at best redundant, at worst rejected
by future Helm versions. Fixed by re-invoking
getOCIQualifiedChartName on the mutated release copy so the
embedded tag also carries the resolved value.

Isolates the shared helmfile cache via `t.Setenv(HELMFILE_CACHE_HOME,
t.TempDir())` so the OutputDirTemplate == "" code path (which writes
into remote.CacheDir) does not touch the user's real
`~/.cache/helmfile` during test runs.

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* refactor: flatten OCI constraint-resolution wiring in getOCIChart

Address review feedback on PR #2768:

- Extract the inline resolve/requalify block from getOCIChart into
  applyOCIConstraintResolution, keeping getOCIChart flat (guard-clause
  style) and making the resolution wiring independently testable. The
  helper returns the (possibly updated) release, qualified chart name,
  and chart version; every failure mode returns its inputs unchanged.

- On a re-qualify failure after a successful resolution, fall back to
  the pre-fix behavior entirely (raw constraint in cache key, ref, AND
  --version flag) instead of the previous half-resolved mix (resolved
  version in the cache key, raw constraint in the path and flag), which
  could desynchronize the in-process cache key from the on-disk path.

- Build the 'helm show chart' ref by reusing parseOCIChartRef instead of
  re-implementing its last-slash/last-colon tag-splitting inline. Same
  behavior for all realistic refs (registry ports preserved), and it
  also handles the digest suffix should one ever reach this point.

- Drop --devel from the resolver flags: helm documents --devel as
  ignored whenever --version is set, and --version is always passed on
  this path.

No behavior change intended beyond the requalify-failure fallback
(which cannot realistically trigger) and the removal of the inert
--devel flag.

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: classify partial semver versions (1, 1.2) as OCI constraints

Address review feedback on PR #2768: Masterminds' lenient parser accepts
partial versions like "1" or "1.2" as versions, so the previous
classifier (NewVersion fails && NewConstraint succeeds) treated them as
exact pins. But helm's OCI resolution — registry.GetTagMatchingVersionOrConstraint
— honors a version string as an exact pin ONLY when a registry tag
literally equals it; otherwise it parses the string as a constraint, and
"1"/"1.2" float across 1.x.y/1.2.y tags. Caching those under their raw
spelling reproduces the stale-cache bug of issue #2766, just with a
narrower trigger.

Replace the NewVersion probe with isFullSemver, which additionally
requires the whole major.minor.patch triple to be spelled out
(optional v prefix, prerelease, and build metadata all still count as
exact when the core is fully qualified). When a registry does carry a
literal tag equal to the version string, the resolver's
metadata.Version == chartVersion path reports no change, so literal-tag
pins keep today's behavior.

TestIsVersionConstraint: "1"/"1.0" flip to constraints, joined by new
v1.2/0/v1 cases and a 1.2.3 exact case. TestResolveOCIConstraintVersion
gains a "partial version resolves" subtest.

Docs updated to describe the parser-based classification instead of
"constraint characters".

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: skip OCI constraint resolution under skipRefresh

Address review feedback on PR #2768: the resolver ran even under
--skip-refresh, so offline and cache-only workflows gained a
'helm show chart' registry attempt per constraint-versioned OCI release.
It degraded gracefully (warn + fallback), but added registry-timeout
latency and warning noise per release.

skipOCIConstraintResolution now suppresses resolution when any of the
skipRefresh levels is set — CLI --skip-refresh (forced), per-release
skipRefresh, or helmDefaults.skipRefresh — with the same precedence the
other skipRefresh consumers in prepareChartForRelease use. Skipped runs
fall back to the constraint-keyed cache path, i.e. they reuse whatever a
previous non-skipped run resolved, which is what 'skip checking for
updates to cached charts' means for constraint versions.

The existing issue #2766 integration tests flip their opts to
SkipRefresh: false since they assert resolution happens. New coverage:
TestSkipOCIConstraintResolution (tri-state precedence table) and
TestGetOCIChart_SkipRefreshSkipsConstraintResolution (no inspector call,
raw constraint in --version and cache path).

Signed-off-by: yxxhero <aiopsclub@163.com>

* perf: memoize OCI constraint resolution per chart+constraint

Address review feedback on PR #2768: resolution ran before the
in-process chart-cache fast path and was not memoized, so every
constraint-versioned OCI release paid its own 'helm show chart' registry
round-trip on every render — including N releases sharing the same
chart+constraint, whose parallel workers could even resolve to different
versions if the registry changed between their lookups.

Memoize successful resolutions in resolvedOCIConstraints keyed by
(chart ref, constraint), mirroring the downloadedCharts pattern:

- Releases sharing a chart+constraint cost one round-trip per process
  and consistently use one resolved version per run.
- Only successful resolutions are memoized; failures may be transient.
- Flags are not part of the key: they govern TLS/verification/registry
  credentials, not which tag a constraint matches (--devel is already
  omitted as it is ignored whenever --version is set).
- Concurrent misses may both hit the registry; last write wins,
  harmlessly.

resetResolvedOCIConstraintsForTest is added alongside the existing
resetChartCacheForTest and wired into the issue #2766 tests — notably
ResolvesToDifferentVersionsPicksSeparateCachePaths, which reuses the
same chart+constraint across its two runs and would otherwise be served
the first resolution from the memo (which is exactly the intended
per-process semantics).

New coverage: TestResolveOCIConstraintVersion_Memoized (memo hit skips
the registry, different constraint is a different key) and
TestGetOCIChart_SharedConstraintResolvedOncePerProcess (two releases,
one inspector call, one pull, same path).

Signed-off-by: yxxhero <aiopsclub@163.com>

* test: cover URL-embedded OCI constraint resolution

Address review feedback on PR #2768: the existing integration tests only
exercised the repo-aliased spelling (chart: myrepo/mychart, version:
'~1') and the version-field spelling. The chart-URL spelling
(chart: oci://<registry>/<chart>:~1) takes a different branch in
getOCIQualifiedChartName — the URL version is deliberately NOT embedded
into the qualified ref and flows through --version only — so its
re-qualification after constraint resolution (release.Version mutated to
the resolved value, versionInURL still the constraint) was untested.

TestGetOCIChart_URLEmbeddedConstraintResolves asserts the resolver
receives the URL-embedded constraint, helm chart pull receives the
resolved version via --version with a tag-less ref, and the cache path
carries the resolved version segment instead of the raw constraint.

Also gofmt-aligns the test tables added in earlier commits and drops a
redundant 1.2.3 test case that tripped goconst.

Signed-off-by: yxxhero <aiopsclub@163.com>

* docs: note empty-version OCI releases are unaffected by resolveOCIVersions

Releases with no version: at all keep their pre-existing semantics: helm
picks the latest tag at pull time and helmfile caches it under a
version-less shared-cache path. Document the limitation alongside the
other resolveOCIVersions scope notes.

Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: Samuel Archambault <samuel.archambault@getmaintainx.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-07 16:50:25 +08:00

1372 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) {
return helm.ShowChartWithFlags(chartPath)
}
// ShowChartWithFlags runs `helm show chart` and unmarshals the resulting
// Chart.yaml. Callers may pass additional helm flags (for example --version,
// --plain-http, --registry-config, --ca-file, --insecure-skip-tls-verify).
// When --version references a semver constraint, helm resolves it against the
// registry and returns the concrete matching Chart.yaml, so callers can read
// metadata.Version to obtain the resolved version.
func (helm *execer) ShowChartWithFlags(chartPath string, flags ...string) (chart.Metadata, error) {
helmArgs := append([]string{"show", "chart", chartPath}, flags...)
out, err := helm.exec(helmArgs, map[string]string{}, nil)
if err != nil {
return chart.Metadata{}, err
}
var metadata chart.Metadata
if err := yaml.Unmarshal(out, &metadata); err != nil {
return chart.Metadata{}, err
}
return metadata, nil
}