Files
helmfile/pkg/config/global.go
T
yxxhero 7bebfab71a feat: add --repo-retries for helm repo and registry login commands (#2683)
* feat: add --repo-retries for retrying helm repo and registry login commands

Add a configurable retry mechanism for chart repository operations to
handle unstable networks (corporate proxies, slow internal registries).

Closes #1894

- New --repo-retries N flag and HELMFILE_REPO_RETRIES env var (default 0
  = opt-in, backward compatible)
- Retry applies to helm repo add, helm repo update (incl. ACR), and
  helm registry login with exponential backoff (1s, 2s, 4s, ..., capped 30s)
- Single retryRepoOp helper; per-attempt args/buffer are local to avoid
  state leaking across retries
- Tests cover succeed-after-retry, exhausted-retries, disabled-by-default,
  and regression guards for password-buffer and args-accumulation

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

* fix: address PR review (overflow guard, cancellable sleep, flag-override, docs)

Address Copilot review feedback on #2683:

- Cap backoff shift exponent at 5 to prevent time.Duration overflow on
  large --repo-retries values
- Make retry sleep context-aware (sleepCtx) so Ctrl+C aborts the retry
  loop promptly via the ShellRunner context
- Log a concise exit status instead of the verbose ExitError dump, and
  clarify the retry-counter wording ('retry N/M')
- Use -1 sentinel as the CLI default so --repo-retries=0 can explicitly
  disable retries even when HELMFILE_REPO_RETRIES is set
- Align help text and docs: retry applies 'on failure' (not just
  transient errors), document the 0-disables behavior
- Add tests for overflow guard, cancellable sleep, and flag-zero-disables

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

* fix: abort retries on canceled context, hide sentinel default, align comment

Address follow-up Copilot review on #2683:

- Fix tight-loop bug: sleepCtx now returns whether it completed vs was
  interrupted by context cancellation, and retryRepoOp aborts the retry
  loop on interruption so Ctrl+C no longer spins into rapid helm calls
- Hide the -1 sentinel from --help by overriding the displayed default
  to 0 (pflag DefValue), matching the documented default while keeping
  the flag-override semantics
- Correct HelmExecOptions.RepoRetry comment: 'on failure' not 'transient
  network errors', matching the actual retry behavior
- Add Test_Retry_AbortsOnCanceledContext covering the no-tight-loop path

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

* fix: copy args per retry in RegistryLogin, make cancel test deterministic

Address follow-up Copilot review on #2683:

- RegistryLogin: pass a per-attempt copy of args to execStdIn so its
  internal append (for helm.extra) can't alias the shared slice across
  retries
- Test_Retry_AbortsOnCanceledContext: cancel the context deterministically
  inside the op closure after the first attempt, replacing the flaky
  time.Sleep(20ms) goroutine

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

* fix: return error on unknown managed repo type instead of silent skip

Address Copilot review on #2683: AddRepo logged an error for an unknown
managed type but returned nil, silently succeeding while skipping the
repo add. Now returns an error so misconfigurations fail loudly.

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

---------

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-23 18:10:04 +08:00

390 lines
11 KiB
Go

package config
import (
"errors"
"fmt"
"io"
"os"
"strconv"
"go.uber.org/zap"
"golang.org/x/term"
"github.com/helmfile/helmfile/pkg/envvar"
"github.com/helmfile/helmfile/pkg/maputil"
"github.com/helmfile/helmfile/pkg/state"
)
// GlobalOptions is the global configuration for the Helmfile CLI.
type GlobalOptions struct {
// HelmBinary is the path to the Helm binary.
HelmBinary string
// KustomizeBinary is the path to the Kustomize binary.
KustomizeBinary string
// File is the path to the Helmfile.
File string
// Environment is the name of the environment to use.
Environment string
// StateValuesSet is a list of state values to set on the command line.
StateValuesSet []string
// StateValuesSetString is a list of state values to set on the command line.
StateValuesSetString []string
// StateValuesFiles is a list of state values files to use.
StateValuesFile []string
// SkipDeps is true if the running "helm repo update" and "helm dependency build" should be skipped
SkipDeps bool
// SkipRefresh is true if the running "helm repo update" should be skipped
SkipRefresh bool
// StripArgsValuesOnExitError is true if the ARGS output on exit error should be suppressed
StripArgsValuesOnExitError bool
// DisableForceUpdate is true if force updating repos is not desirable when executing "helm repo add" (Helm 3)
DisableForceUpdate bool
// EnforcePluginVerification is true if plugin installation should fail when verification is not supported
EnforcePluginVerification bool
// HelmOCIPlainHTTP is true if Helm should use plain HTTP for OCI registries
HelmOCIPlainHTTP bool
// RepoRetry is the number of times to retry "helm repo add/update" and
// "helm registry login" on failure with exponential backoff.
// A negative value (the CLI default sentinel) means "unset" and falls back
// to the HELMFILE_REPO_RETRIES env var; 0 explicitly disables retries.
RepoRetry int
// Quiet is true if the output should be quiet.
Quiet bool
// Kubeconfig is the path to the kubeconfig file to use.
Kubeconfig string
// KubeContext is the name of the kubectl context to use.
KubeContext string
// Debug is true if the output should be verbose.
Debug bool
// Color is true if the output should be colorized.
Color bool
// NoColor is true if the output should not be colorized.
NoColor bool
// LogLevel is the log level to use.
LogLevel string
// Namespace is the namespace to use.
Namespace string
// Chart is the chart to use.
Chart string
// Selector is a list of selectors to use.
Selector []string
// AllowNoMatchingRelease is not exit with an error code if the provided selector has no matching releases.
AllowNoMatchingRelease bool
// logger is the logger to use.
logger *zap.SugaredLogger
// EnableLiveOutput enables live output from the Helm binary stdout/stderr into Helmfile own stdout/stderr
EnableLiveOutput bool
// Interactive is true if the user should be prompted for input.
Interactive bool
// Args is the list of arguments to pass to the Helm binary.
Args string
// LogOutput is the writer to use for writing logs.
LogOutput io.Writer
// SequentialHelmfiles is true if helmfile.d files should be processed sequentially instead of in parallel.
SequentialHelmfiles bool
}
// Logger returns the logger to use.
func (g *GlobalOptions) Logger() *zap.SugaredLogger {
return g.logger
}
// GetLogLevel returns the log level to use.
func (g *GlobalOptions) SetLogger(logger *zap.SugaredLogger) {
g.logger = logger
}
// GlobalImpl is the global configuration for the Helmfile CLI.
type GlobalImpl struct {
*GlobalOptions
set map[string]any
}
// NewGlobalImpl creates a new GlobalImpl.
func NewGlobalImpl(opts *GlobalOptions) *GlobalImpl {
return &GlobalImpl{
GlobalOptions: opts,
set: make(map[string]any),
}
}
// Setset sets the set
func (g *GlobalImpl) SetSet(set map[string]any) {
g.set = maputil.MergeMaps(g.set, set)
}
// HelmBinary returns the path to the Helm binary.
func (g *GlobalImpl) HelmBinary() string {
var helmBinary string
switch {
case g.GlobalOptions.HelmBinary != "":
helmBinary = g.GlobalOptions.HelmBinary
case os.Getenv("HELMFILE_HELM_BINARY") != "":
helmBinary = os.Getenv("HELMFILE_HELM_BINARY")
default:
helmBinary = state.DefaultHelmBinary
}
return helmBinary
}
// KustomizeBinary returns the path to the Kustomize binary.
func (g *GlobalImpl) KustomizeBinary() string {
var kustomizeBinary string
switch {
case g.GlobalOptions.KustomizeBinary != "":
kustomizeBinary = g.GlobalOptions.KustomizeBinary
case os.Getenv("HELMFILE_KUSTOMIZE_BINARY") != "":
kustomizeBinary = os.Getenv("HELMFILE_KUSTOMIZE_BINARY")
default:
kustomizeBinary = state.DefaultKustomizeBinary
}
return kustomizeBinary
}
// LogLevel returns the log level to use.
func (g *GlobalImpl) LogLevel() string {
var logLevel string
switch {
case g.GlobalOptions.LogLevel != "":
logLevel = g.GlobalOptions.LogLevel
case os.Getenv("HELMFILE_LOG_LEVEL") != "":
logLevel = os.Getenv("HELMFILE_LOG_LEVEL")
default:
logLevel = "info"
}
return logLevel
}
// Debug returns whether debug output is enabled.
func (g *GlobalImpl) Debug() bool {
if g.GlobalOptions.Debug {
return true
}
return os.Getenv(envvar.Debug) == "true"
}
// Quiet returns whether quiet output is enabled.
func (g *GlobalImpl) Quiet() bool {
if g.GlobalOptions.Quiet {
return true
}
return os.Getenv(envvar.Quiet) == "true"
}
// Kubeconfig returns the path to the kubeconfig file to use.
func (g *GlobalImpl) Kubeconfig() string {
return g.GlobalOptions.Kubeconfig
}
// KubeContext returns the name of the kubectl context to use.
func (g *GlobalImpl) KubeContext() string {
var kubeContext string
switch {
case g.GlobalOptions.KubeContext != "":
kubeContext = g.GlobalOptions.KubeContext
case os.Getenv("HELMFILE_KUBE_CONTEXT") != "":
kubeContext = os.Getenv("HELMFILE_KUBE_CONTEXT")
default:
kubeContext = ""
}
return kubeContext
}
// Namespace returns the namespace to use.
func (g *GlobalImpl) Namespace() string {
var namespace string
switch {
case g.GlobalOptions.Namespace != "":
namespace = g.GlobalOptions.Namespace
case os.Getenv("HELMFILE_NAMESPACE") != "":
namespace = os.Getenv("HELMFILE_NAMESPACE")
default:
namespace = ""
}
return namespace
}
// Chart returns the chart to use.
func (g *GlobalImpl) Chart() string {
return g.GlobalOptions.Chart
}
// FileOrDir returns the path to the Helmfile.
func (g *GlobalImpl) FileOrDir() string {
file := g.File
if file == "" {
file = os.Getenv(envvar.FilePath)
}
return file
}
// Selectors returns the selectors to use.
func (g *GlobalImpl) Selectors() []string {
return g.Selector
}
// StateValuesSet returns the set
func (g *GlobalImpl) StateValuesSet() map[string]any {
return g.set
}
// StateValuesSet returns the set
func (g *GlobalImpl) RawStateValuesSet() []string {
return g.GlobalOptions.StateValuesSet
}
// RawStateValuesSetString returns the set
func (g *GlobalImpl) RawStateValuesSetString() []string {
return g.StateValuesSetString
}
// StateValuesFiles returns the state values files
func (g *GlobalImpl) StateValuesFiles() []string {
return g.StateValuesFile
}
// EnableLiveOutput return when to pipe the stdout and stderr from Helm live to the helmfile stdout
func (g *GlobalImpl) EnableLiveOutput() bool {
return g.GlobalOptions.EnableLiveOutput
}
// SkipDeps return if running "helm repo update" and "helm dependency build" should be skipped
func (g *GlobalImpl) SkipDeps() bool {
return g.GlobalOptions.SkipDeps
}
// SkipRefresh return if running "helm repo update"
func (g *GlobalImpl) SkipRefresh() bool {
return g.GlobalOptions.SkipRefresh
}
// StripArgsValuesOnExitError return if the ARGS output on exit error should be suppressed
func (g *GlobalImpl) StripArgsValuesOnExitError() bool {
return g.GlobalOptions.StripArgsValuesOnExitError
}
// DisableForceUpdate return when to disable forcing updates to repos upon adding (Helm 3)
func (g *GlobalImpl) DisableForceUpdate() bool {
return g.GlobalOptions.DisableForceUpdate
}
// EnforcePluginVerification return when to enforce plugin verification
func (g *GlobalImpl) EnforcePluginVerification() bool {
return g.GlobalOptions.EnforcePluginVerification
}
// HelmOCIPlainHTTP returns whether to use plain HTTP for OCI registries
func (g *GlobalImpl) HelmOCIPlainHTTP() bool {
return g.GlobalOptions.HelmOCIPlainHTTP
}
// RepoRetry returns the number of times to retry helm repo and registry login
// operations on failure, with exponential backoff. A negative value means the
// flag was not specified, so the HELMFILE_REPO_RETRIES env var is consulted;
// this lets --repo-retries=0 explicitly disable retries even when the env var
// is set.
func (g *GlobalImpl) RepoRetry() int {
if g.GlobalOptions.RepoRetry >= 0 {
return g.GlobalOptions.RepoRetry
}
if v, err := strconv.Atoi(os.Getenv(envvar.RepoRetry)); err == nil && v > 0 {
return v
}
return 0
}
// SequentialHelmfiles returns whether to process helmfile.d files sequentially
func (g *GlobalImpl) SequentialHelmfiles() bool {
return g.GlobalOptions.SequentialHelmfiles
}
// Logger returns the logger
func (g *GlobalImpl) Logger() *zap.SugaredLogger {
return g.logger
}
func (g *GlobalImpl) Color() bool {
if c := g.GlobalOptions.Color; c {
return c
}
if g.NoColor() {
return false
}
// We replicate the helm-diff behavior in helmfile
// because when helmfile calls helm-diff, helm-diff has no access to term and therefore
// we can't rely on helm-diff's ability to auto-detect term for color output.
// See https://github.com/roboll/helmfile/issues/2043
terminal := term.IsTerminal(int(os.Stdout.Fd()))
// https://github.com/databus23/helm-diff/issues/281
dumb := os.Getenv("TERM") == "dumb"
return terminal && !dumb
}
// NoColor returns the no color flag
func (g *GlobalImpl) NoColor() bool {
if g.GlobalOptions.NoColor {
return true
}
// Explicit --color short-circuits env-derived no-color: a flag must win over an env var.
if g.GlobalOptions.Color {
return false
}
if os.Getenv(envvar.NoColor) == "true" {
return true
}
// Honor the de-facto https://no-color.org/ standard: any non-empty value disables color.
return os.Getenv("NO_COLOR") != ""
}
// Env returns the environment to use.
func (g *GlobalImpl) Env() string {
var env string
switch {
case g.Environment != "":
env = g.Environment
case os.Getenv("HELMFILE_ENVIRONMENT") != "":
env = os.Getenv("HELMFILE_ENVIRONMENT")
default:
env = state.DefaultEnv
}
return env
}
// ValidateConfig validates the global options.
func (g *GlobalImpl) ValidateConfig() error {
if g.NoColor() && g.Color() {
return errors.New("--color and --no-color cannot be specified at the same time")
}
return nil
}
// Interactive returns the Interactive
func (g *GlobalImpl) Interactive() bool {
if g.GlobalOptions.Interactive {
return true
}
return os.Getenv(envvar.Interactive) == "true"
}
// Args returns the args to use for helm
func (g *GlobalImpl) Args() string {
args := g.GlobalOptions.Args
enableHelmDebug := g.Debug()
if enableHelmDebug {
args = fmt.Sprintf("%s %s", args, "--debug")
}
return args
}