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) } // RedactedURL returns ref with any password in the URL userinfo replaced, // matching helmfile's log redaction (url.URL.Redacted). Non-URL strings are // returned unchanged. Telemetry reuses it so span attributes are sanitized at // least as strictly as log output. func RedactedURL(ref string) string { refURL, err := url.ParseRequestURI(ref) if err != nil { return ref } return refURL.Redacted() } func redactedURL(chart string) string { return RedactedURL(chart) } // 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 := min(attempt, 5) backoff := min(repoRetryBaseBackoff*time.Duration(1<= 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 field := range envType.Fields() { 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 field := range depType.Fields() { 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 field := range envType.Fields() { 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 field := range depType.Fields() { 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 before, _, ok := strings.Cut(flag, "="); ok { flagName = before } // 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{}) 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{}) 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.execWithContext(context.Ctx, 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.execWithContext(context.Ctx, 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.execWithContext(context.Ctx, 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.execWithContext(context.Ctx, 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 after, ok := strings.CutPrefix(flags[i], "--output-dir="); ok { outputDir = after 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{}) 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{}) 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 ` 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.execWithContext(context.Ctx, 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{}) // 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{}) 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{}) 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"}) 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"}) 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.execWithContext(context.Ctx, 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.execWithContext(context.Ctx, 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{}) // 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{}) } 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{}) 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: not found" // Helm 3: "Plugin: 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{}) 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: not found" (Helm 4) / "Plugin: 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) ([]byte, error) { return helm.execWithRunner(helm.runner, args, env, nil) } // execWithContext behaves like exec but attaches the span carried by ctx // (from HelmContext.Ctx) so the subprocess span nests under the per-release // span. Crucially, the subprocess keeps the runner's own context: replacing // it would override specialized cancellation contexts such as the kubedog // safety valve installed via execer.WithContext. A nil ctx is exactly exec. func (helm *execer) execWithContext(ctx context.Context, args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) { runner := helm.runner if ctx != nil { runner = withRunnerCtx(runner, func(runnerCtx context.Context) context.Context { return spanAttachedContext(runnerCtx, ctx) }) } return helm.execWithRunner(runner, args, env, overrideEnableLiveOutput) } func (helm *execer) execWithRunner(runner Runner, args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) { runner = markHelmRunner(runner) 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 := 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 := markHelmRunner(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 ` 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{}) 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 }