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>
This commit is contained in:
yxxhero
2026-07-23 18:10:04 +08:00
committed by GitHub
parent f64f3ec197
commit 7bebfab71a
9 changed files with 425 additions and 41 deletions
+21
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"strconv"
"go.uber.org/zap"
"golang.org/x/term"
@@ -42,6 +43,11 @@ type GlobalOptions struct {
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.
@@ -278,6 +284,21 @@ 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
+28
View File
@@ -420,3 +420,31 @@ func TestColorFlagOverridesNoColorEnv(t *testing.T) {
})
}
}
// TestRepoRetry tests the repo-retries flag and HELMFILE_REPO_RETRIES env var fallback
func TestRepoRetry(t *testing.T) {
// RepoRetry < 0 means the flag was not specified (CLI default sentinel).
unset := GlobalOptions{RepoRetry: -1}
tests := []struct {
name string
opts GlobalOptions
env string
expected int
}{
{name: "default (unset)", opts: unset, env: "", expected: 0},
{name: "env set", opts: unset, env: "3", expected: 3},
{name: "flag set", opts: GlobalOptions{RepoRetry: 5}, env: "", expected: 5},
{name: "flag overrides env", opts: GlobalOptions{RepoRetry: 5}, env: "3", expected: 5},
{name: "flag zero disables env", opts: GlobalOptions{RepoRetry: 0}, env: "3", expected: 0},
{name: "invalid env ignored", opts: unset, env: "abc", expected: 0},
{name: "negative env ignored", opts: unset, env: "-1", expected: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv(envvar.RepoRetry, test.env)
received := NewGlobalImpl(&test.opts).RepoRetry()
require.Equalf(t, test.expected, received, "RepoRetry expected %d, received %d", test.expected, received)
})
}
}