Files
helmfile/pkg/helmexec/exec_test.go
T
yxxhero c008d0e129 fix: update helm plugins via uninstall+reinstall to honor pinned version (#2727)
* fix: update helm plugins via uninstall+reinstall to honor pinned version (#2726)

helm plugin update re-installs a plugin from its cached source WITHOUT the
--version flag, so it does not reliably install the pinned version helmfile
requests. It reports success (exit 0) while re-downloading the unchanged
cached source, leaving 'helm plugin list' showing the old version.

This affected 'helmfile init --force' which reported a successful diff/secrets
plugin update while the old version remained installed. The reporter's
workaround was to uninstall the plugin before running init.

UpdatePlugin now uninstalls the existing plugin and reinstalls the exact
pinned version via AddPlugin (which passes --version). The unreliable
'plugin update' command is no longer used for the general path; this mirrors
the uninstall+reinstall strategy already used for helm-secrets on Helm 4.

Fixes #2726
Refs #2548

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

* fix: only ignore 'not found' uninstall errors in UpdatePlugin

Address review feedback: UpdatePlugin ignored all uninstall errors, which
could mask real failures (permissions, broken Helm) behind a less informative
'plugin already exists' error from the subsequent install.

Now only the expected 'not found' case (plugin removed concurrently, reported
by both Helm 3 'Plugin: <name> not found' and Helm 4 'plugin: <name> not
found') is ignored and install proceeds. Any other uninstall error is returned.

Adds tests for both branches.

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

* fix: extract pluginCmd constant to satisfy goconst in tests

golangci-lint (goconst, min-occurrences: 8) flagged the "plugin" string
literal in exec_test.go after the new UpdatePlugin tests pushed the package-
wide count from 7 (under threshold, passing on main) to 14.

Introduce a pluginCmd constant and use it for all helm sub-command checks in
the UpdatePlugin tests. No behavior change; drops the literal count back below
the threshold so CI goconst passes.

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

* fix: match helm plugin-absent error precisely; guard against plugin update regression

Address review feedback on UpdatePlugin:

1. The uninstall error check used strings.Contains(err, "not found"), which
   is too broad: it also matches unrelated failures such as a missing helm
   binary ("executable file not found") or an uninstall hook failing with
   "sh: ...: not found". Those would be silently treated as "plugin absent"
   and logged/proceeded past, masking the real problem.

   Match only helm's specific plugin-absent message instead, via a
   case-insensitive regex that covers both majors:
     Helm 4: "plugin: <name> not found"
     Helm 3: "Plugin: <name> not found"
   All other uninstall failures (permissions, missing binary, hook errors)
   are now surfaced.

2. The init test mock had no "plugin update" branch, so a production
   regression to calling helm plugin update would go undetected (false
   negative). Add an explicit case that records and fails on "plugin update".

Also adds a test for the missing-binary case and makes the plugin-absent test
table-driven across Helm 3/4 formats. Extracts installCmd to keep goconst
(min-occurrences: 8) satisfied after the new tests.

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

---------

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-08-03 22:16:18 +08:00

2206 lines
72 KiB
Go

package helmexec
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/tj/assert"
"go.uber.org/zap"
)
// pluginCmd is the "plugin" helm subcommand used repeatedly across these tests.
// Extracted as constants so the repeated string literals do not trip goconst
// (min-occurrences: 8) once additional plugin tests are added.
const (
pluginCmd = "plugin"
installCmd = "install"
)
// Mocking the command-line runner
type mockRunner struct {
output []byte
versionOutput []byte // if set, returned for "helm version --short" probe; overrides default Helm 4 fallback
err error
// failCount is the number of times Execute/ExecuteStdIn fail (with
// errTransientNet) before succeeding. Used to exercise retry logic.
failCount int
calls int
// stdins records the stdin content passed to each ExecuteStdIn call,
// so tests can assert that retries still pass the full payload (e.g. the
// password is not consumed by a prior attempt).
stdins []string
// execArgs records the args passed to each Execute/ExecuteStdIn call, so
// tests can assert that retries don't accumulate duplicated flags.
execArgs [][]string
}
var errTransientNet = fmt.Errorf("transient network error")
func (mock *mockRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
mock.calls++
mock.execArgs = append(mock.execArgs, append([]string{}, args...))
if stdin != nil {
b, _ := io.ReadAll(stdin)
mock.stdins = append(mock.stdins, string(b))
}
if mock.failCount > 0 && mock.calls <= mock.failCount {
return nil, errTransientNet
}
return mock.output, mock.err
}
func (mock *mockRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
if strings.Join(args, " ") == "version --short" {
if mock.versionOutput != nil {
return mock.versionOutput, nil
}
if len(mock.output) == 0 {
return []byte("v4.0.1+g12500dd"), nil
}
}
mock.calls++
mock.execArgs = append(mock.execArgs, append([]string{}, args...))
if mock.failCount > 0 && mock.calls <= mock.failCount {
return nil, errTransientNet
}
return mock.output, mock.err
}
func MockExecer(logger *zap.SugaredLogger, kubeconfig, kubeContext string) (*execer, error) {
execer, err := New("helm", HelmExecOptions{}, logger, kubeconfig, kubeContext, &mockRunner{})
if err != nil {
return nil, err
}
return execer, nil
}
// Test methods
func TestNewHelmExec(t *testing.T) {
buffer := bytes.NewBufferString("something")
helm, err := MockExecer(NewLogger(buffer, "debug"), "config", "dev")
if err != nil {
t.Error(err)
}
if helm.kubeContext != "dev" {
t.Error("helmexec.New() - kubeContext")
}
if buffer.String() != "something" {
t.Error("helmexec.New() - changed buffer")
}
if len(helm.extra) != 0 {
t.Error("helmexec.New() - extra args not empty")
}
}
func Test_SetExtraArgs(t *testing.T) {
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
if err != nil {
t.Error(err)
}
helm.SetExtraArgs()
if len(helm.extra) != 0 {
t.Error("helmexec.SetExtraArgs() - passing no arguments should not change extra field")
}
helm.SetExtraArgs("foo")
if !reflect.DeepEqual(helm.extra, []string{"foo"}) {
t.Error("helmexec.SetExtraArgs() - one extra argument missing")
}
helm.SetExtraArgs("alpha", "beta")
if !reflect.DeepEqual(helm.extra, []string{"alpha", "beta"}) {
t.Error("helmexec.SetExtraArgs() - two extra arguments missing (overwriting the previous value)")
}
}
func Test_SetHelmBinary(t *testing.T) {
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
if err != nil {
t.Error(err)
}
if helm.helmBinary != "helm" {
t.Error("helmexec.command - default command is not helm")
}
helm.SetHelmBinary("foo")
if helm.helmBinary != "foo" {
t.Errorf("helmexec.SetHelmBinary() - actual = %s expect = foo", helm.helmBinary)
}
}
func Test_SetEnableLiveOutput(t *testing.T) {
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
if err != nil {
t.Error(err)
}
if helm.options.EnableLiveOutput {
t.Error("helmexec.options.EnableLiveOutput should not be enabled by default")
}
helm.SetEnableLiveOutput(true)
if !helm.options.EnableLiveOutput {
t.Errorf("helmexec.SetEnableLiveOutput() - actual = %t expect = true", helm.options.EnableLiveOutput)
}
}
func Test_AddRepo_Helm_Version(t *testing.T) {
tests := []struct {
name string
version string
disableForceUpdate bool
expected string
}{
{
name: "Helm 3.2.0 (before force-update)",
version: "3.2.0",
expected: `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --cert-file cert.pem --key-file key.pem
`,
},
{
name: "Helm 3.3.2 (force-update added)",
version: "3.3.2",
expected: `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
`,
},
{
name: "Helm 3.19.2 (with force-update)",
version: "3.19.2",
expected: `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
`,
},
{
name: "Helm 3.19.2 (force-update disabled)",
version: "3.19.2",
disableForceUpdate: true,
expected: `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --cert-file cert.pem --key-file key.pem
`,
},
{
name: "Helm 4.0.1 (force-update needed)",
version: "4.0.1",
expected: `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
`,
},
{
name: "Helm 4.0.1 (force-update disabled)",
version: "4.0.1",
disableForceUpdate: true,
expected: `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --cert-file cert.pem --key-file key.pem
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse(tt.version),
logger: logger,
kubeconfig: "config",
kubeContext: "dev",
runner: &mockRunner{},
options: HelmExecOptions{DisableForceUpdate: tt.disableForceUpdate},
}
err := helm.AddRepo("myRepo", "https://repo.example.com/", "", "cert.pem", "key.pem", "", "", "", false, false)
assert.NoError(t, err)
assert.Equal(t, tt.expected, buffer.String())
})
}
}
func Test_AddRepo(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// Test case with certfile and keyfile
buffer.Reset()
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "cert.pem", "key.pem", "", "", "", false, false)
expected := `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test case with cafile
buffer.Reset()
err = helm.AddRepo("myRepo", "https://repo.example.com/", "ca.crt", "", "", "", "", "", false, false)
expected = `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --ca-file ca.crt
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test case with no certfile or cafile
buffer.Reset()
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "", "", "", false, false)
expected = `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test case with managed "acr"
buffer.Reset()
err = helm.AddRepo("acrRepo", "", "", "", "", "", "", "acr", false, false)
expected = `Adding repo acrRepo (acr)
exec: az acr helm repo add --name acrRepo
exec: az acr helm repo add --name acrRepo:
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test case with unknown managed type
buffer.Reset()
err = helm.AddRepo("otherRepo", "", "", "", "", "", "", "unknown", false, false)
expected = `ERROR: unknown type 'unknown' for repository otherRepo
`
if err == nil {
t.Errorf("expected error for unknown managed type, got nil")
}
if buffer.String() != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test case with username and password (using password-stdin)
buffer.Reset()
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "example_user", "example_password", "", false, false)
expected = `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --username example_user --password-stdin
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test case with username, password, and pass-credentials
buffer.Reset()
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "example_user", "example_password", "", true, false)
expected = `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --pass-credentials --username example_user --password-stdin
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actual := buffer.String()
if actual != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
}
// Test case with skipTLSVerify
buffer.Reset()
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "", "", "", false, true)
expected = `Adding repo myRepo https://repo.example.com/
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --insecure-skip-tls-verify
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
normalize := func(s string) string {
return strings.Join(strings.Fields(s), " ")
}
actual = normalize(buffer.String())
expected = normalize(expected)
if actual != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
}
// Test case with empty name
buffer.Reset()
err = helm.AddRepo("", "https://repo.example.com/", "", "", "", "", "", "", false, false)
expected = `empty field name`
if err != nil && err.Error() != "empty field name" {
t.Errorf("unexpected error: %v", err)
}
actual = strings.TrimSpace(buffer.String())
if actual != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
}
}
func Test_UpdateRepo(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.UpdateRepo()
expected := `Updating repo
exec: helm --kubeconfig config --kube-context dev repo update
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.UpdateRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_RegistryLogin(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse("v3.12.0"),
logger: logger,
kubeconfig: "config",
kubeContext: "dev",
runner: &mockRunner{},
}
err := helm.RegistryLogin("repo.example.com", "example_user", "example_password", "example_ca", "example_cert", "example_key", true)
expected := `Logging in to registry
exec: helm --kubeconfig config --kube-context dev registry login repo.example.com --cert-file example_cert --key-file example_key --ca-file example_ca --insecure --username example_user --password-stdin
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.RegistryLogin()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test helm version prior to v3.12.0, without support for TLS
buffer.Reset()
helm.version = semver.MustParse("v3.11.0")
err = helm.RegistryLogin("repo.example.com", "example_user", "example_password", "example_ca", "example_cert", "example_key", true)
expected = `Logging in to registry
exec: helm --kubeconfig config --kube-context dev registry login repo.example.com --insecure --username example_user --password-stdin
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.RegistryLogin()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
// newRetryExecer builds an execer with the given RepoRetry option and a
// mockRunner that fails failCount times before succeeding.
func newRetryExecer(t *testing.T, repoRetry, failCount int) (*execer, *mockRunner) {
t.Helper()
logger := NewLogger(os.Stdout, "debug")
runner := &mockRunner{failCount: failCount}
helm, err := New("helm", HelmExecOptions{RepoRetry: repoRetry}, logger, "config", "dev", runner)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return helm, runner
}
// withTestBackoff shrinks repoRetryBaseBackoff for the test's duration so retry
// tests don't sleep for real. The restore is via t.Cleanup so it always runs.
func withTestBackoff(t *testing.T) {
t.Helper()
original := repoRetryBaseBackoff
repoRetryBaseBackoff = time.Millisecond
t.Cleanup(func() { repoRetryBaseBackoff = original })
}
// retryTestCase is the shared shape for the AddRepo/UpdateRepo/RegistryLogin
// retry table tests.
type retryTestCase struct {
name string
repoRetry int
failCount int
wantErr bool
wantCalls int // expected number of exec (non-version) calls
managed string
}
func runRetryCases(t *testing.T, run func(*testing.T, *execer, *mockRunner, retryTestCase), cases []retryTestCase) {
t.Helper()
withTestBackoff(t)
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
helm, runner := newRetryExecer(t, tt.repoRetry, tt.failCount)
run(t, helm, runner, tt)
if runner.calls != tt.wantCalls {
t.Errorf("runner.calls = %d, want %d", runner.calls, tt.wantCalls)
}
})
}
}
func Test_AddRepo_Retry(t *testing.T) {
runRetryCases(t, func(t *testing.T, helm *execer, runner *mockRunner, tt retryTestCase) {
err := helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "user", "pass", tt.managed, false, false)
if (err != nil) != tt.wantErr {
t.Errorf("AddRepo() error = %v, wantErr %v", err, tt.wantErr)
}
// For the normal (non-acr) path, each attempt's args must contain
// --username exactly once: guards against the username/password flags
// accumulating across retries.
if tt.managed == "" {
for i, a := range runner.execArgs {
if c := strings.Count(strings.Join(a, " "), "--username"); c != 1 {
t.Errorf("AddRepo() attempt %d: --username count = %d, want 1 (args=%v)", i+1, c, a)
}
}
}
}, []retryTestCase{
{name: "succeeds after one retry", repoRetry: 2, failCount: 1, wantErr: false, wantCalls: 2},
{name: "retries exhausted", repoRetry: 1, failCount: 2, wantErr: true, wantCalls: 2},
{name: "retry disabled runs once", repoRetry: 0, failCount: 1, wantErr: true, wantCalls: 1},
{name: "acr managed retries", repoRetry: 3, failCount: 2, wantErr: false, wantCalls: 3, managed: "acr"},
})
}
func Test_UpdateRepo_Retry(t *testing.T) {
runRetryCases(t, func(t *testing.T, helm *execer, _ *mockRunner, tt retryTestCase) {
err := helm.UpdateRepo()
if (err != nil) != tt.wantErr {
t.Errorf("UpdateRepo() error = %v, wantErr %v", err, tt.wantErr)
}
}, []retryTestCase{
{name: "succeeds after one retry", repoRetry: 2, failCount: 1, wantErr: false, wantCalls: 2},
{name: "retries exhausted", repoRetry: 1, failCount: 2, wantErr: true, wantCalls: 2},
{name: "retry disabled runs once", repoRetry: 0, failCount: 1, wantErr: true, wantCalls: 1},
})
}
func Test_RegistryLogin_Retry(t *testing.T) {
runRetryCases(t, func(t *testing.T, helm *execer, runner *mockRunner, tt retryTestCase) {
err := helm.RegistryLogin("repo.example.com", "user", "pass", "", "", "", false)
if (err != nil) != tt.wantErr {
t.Errorf("RegistryLogin() error = %v, wantErr %v", err, tt.wantErr)
}
// Every attempt must carry the full password (regression guard:
// the password buffer must not be consumed by a prior attempt).
for i, s := range runner.stdins {
if s != "pass\n" {
t.Errorf("RegistryLogin() attempt %d stdin = %q, want %q", i+1, s, "pass\n")
}
}
}, []retryTestCase{
{name: "succeeds after one retry", repoRetry: 2, failCount: 1, wantErr: false, wantCalls: 2},
{name: "retries exhausted", repoRetry: 1, failCount: 2, wantErr: true, wantCalls: 2},
{name: "retry disabled runs once", repoRetry: 0, failCount: 1, wantErr: true, wantCalls: 1},
})
}
// Test_Retry_LargeCount_NoOverflow verifies that a large --repo-retries value
// doesn't overflow the backoff duration (the shift exponent must be capped).
func Test_Retry_LargeCount_NoOverflow(t *testing.T) {
withTestBackoff(t)
// failCount > repoRetry so every attempt fails; attempt indices exceed the
// shift cap of 5, exercising the overflow guard without panicking.
const retries = 10
helm, runner := newRetryExecer(t, retries, retries+1)
err := helm.UpdateRepo()
if err == nil {
t.Fatal("UpdateRepo() expected error, got nil")
}
if runner.calls != retries+1 { // 1 initial + retries
t.Errorf("runner.calls = %d, want %d", runner.calls, retries+1)
}
}
// Test_SleepCtx_Canceled verifies sleepCtx returns promptly (and false) when
// the runner's context is already canceled, rather than blocking for the full
// duration.
func Test_SleepCtx_Canceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
helm := &execer{
runner: &ShellRunner{Ctx: ctx},
logger: NewLogger(io.Discard, "debug"),
}
start := time.Now()
completed := helm.sleepCtx(30 * time.Second)
elapsed := time.Since(start)
if completed {
t.Error("sleepCtx() = true with a canceled context; want false")
}
if elapsed > time.Second {
t.Errorf("sleepCtx blocked for %v with a canceled context; expected prompt return", elapsed)
}
}
// Test_SleepCtx_NoContext verifies sleepCtx falls back to time.Sleep (returning
// true) when the runner has no context (e.g. mockRunner).
func Test_SleepCtx_NoContext(t *testing.T) {
helm := &execer{
runner: &mockRunner{},
logger: NewLogger(io.Discard, "debug"),
}
start := time.Now()
completed := helm.sleepCtx(5 * time.Millisecond)
elapsed := time.Since(start)
if !completed {
t.Error("sleepCtx() = false without a context; want true")
}
if elapsed < 4*time.Millisecond {
t.Errorf("sleepCtx returned in %v without a context; expected to sleep ~5ms", elapsed)
}
}
// Test_Retry_AbortsOnCanceledContext verifies that retryRepoOp stops retrying
// once the runner's context is canceled, rather than spinning into a tight
// loop of rapid helm invocations.
func Test_Retry_AbortsOnCanceledContext(t *testing.T) {
withTestBackoff(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner := &mockRunner{failCount: 100} // always fails
helm := &execer{
options: HelmExecOptions{RepoRetry: 100},
version: semver.MustParse("v4.0.1"),
runner: &ShellRunner{Ctx: ctx},
logger: NewLogger(io.Discard, "debug"),
}
// Cancel deterministically inside the op after the first attempt runs, so
// the retry loop must bail out at sleepCtx — no timing-based flakiness.
calls := 0
_, err := helm.retryRepoOp("test", func() ([]byte, error) {
calls++
out, e := runner.Execute("helm", []string{"repo", "update"}, nil, false)
if calls == 1 {
cancel()
}
return out, e
})
if err == nil {
t.Fatal("expected error, got nil")
}
// The context is canceled during the first attempt; sleepCtx must observe
// it and abort, so only the first attempt should run.
if calls > 2 {
t.Errorf("calls = %d after cancellation; expected <= 2 (no tight loop)", calls)
}
}
func Test_SyncRelease(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.SyncRelease(HelmContext{}, "release", "chart", "default", "--timeout 10", "--wait", "--wait-for-jobs")
expected := `Upgrading release=release, chart=chart, namespace=default
exec: helm --kubeconfig config --kube-context dev upgrade --install release chart --timeout 10 --wait --wait-for-jobs --history-max 0
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.SyncRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
err = helm.SyncRelease(HelmContext{}, "release", "chart", "default")
expected = `Upgrading release=release, chart=chart, namespace=default
exec: helm --kubeconfig config --kube-context dev upgrade --install release chart --history-max 0
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.SyncRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
err = helm.SyncRelease(HelmContext{}, "release", "https://example_user:example_password@repo.example.com/chart.tgz", "default")
expected = `Upgrading release=release, chart=https://example_user:xxxxx@repo.example.com/chart.tgz, namespace=default
exec: helm --kubeconfig config --kube-context dev upgrade --install release https://example_user:example_password@repo.example.com/chart.tgz --history-max 0
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.SyncRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_UpdateDeps(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.UpdateDeps("./chart/foo")
expected := `Updating dependency ./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.UpdateDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
helm.SetExtraArgs("--verify")
err = helm.UpdateDeps("./chart/foo")
// --verify is a dependency-specific flag and should be preserved
expected = `Updating dependency ./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo --verify
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.UpdateDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_BuildDeps(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm3Runner := mockRunner{output: []byte("v3.2.4+ge29ce2a")}
helm, err := New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.BuildDeps("foo", "./chart/foo", []string{"--skip-refresh"}...)
expected := `Building dependency release=foo, chart=./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --skip-refresh
v3.2.4+ge29ce2a
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
err = helm.BuildDeps("foo", "./chart/foo")
expected = `Building dependency release=foo, chart=./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo
v3.2.4+ge29ce2a
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
helm.SetExtraArgs("--verify")
err = helm.BuildDeps("foo", "./chart/foo", []string{"--skip-refresh"}...)
// --verify is a dependency-specific flag and should be preserved
expected = `Building dependency release=foo, chart=./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --skip-refresh --verify
v3.2.4+ge29ce2a
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94")}
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm2Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.BuildDeps("foo", "./chart/foo")
expected = `Building dependency release=foo, chart=./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo
Client: v2.16.1+ge13bc94
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test that --dry-run flag is filtered out (not supported by helm dependency build)
buffer.Reset()
helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")}
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
helm.SetExtraArgs("--dry-run=server")
err = helm.BuildDeps("foo", "./chart/foo")
expected = `Building dependency release=foo, chart=./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo
v3.2.4+ge29ce2a
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.BuildDeps() with --dry-run should filter it out\nactual = %v\nexpect = %v", buffer.String(), expected)
}
// Test that global flags (--debug) and dependency flags (--verify) are preserved,
// while template-specific flags (--dry-run) are filtered out
buffer.Reset()
helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")}
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
helm.SetExtraArgs("--debug", "--dry-run=server", "--verify", "--wait")
err = helm.BuildDeps("foo", "./chart/foo")
expected = `Building dependency release=foo, chart=./chart/foo
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --debug --verify
v3.2.4+ge29ce2a
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.BuildDeps() should preserve global and dependency flags\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_DecryptSecret(t *testing.T) {
// Set secrets plugin version to 4.7.4
if err := os.Setenv("HELM_PLUGINS", "../../test/plugins/secrets/4.7.4"); err != nil {
t.Errorf("failed to set environment HELM_PLUGINS error: %s", err)
}
defer func() {
if err := os.Unsetenv("HELM_PLUGINS"); err != nil {
t.Errorf("failed to unset environment HELM_PLUGINS error: %s", err)
}
}()
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
tmpFilePath := "path/to/temp/file"
helm.writeTempFile = func(content []byte) (string, error) {
return tmpFilePath, nil
}
_, err = helm.DecryptSecret(HelmContext{}, "secretName")
if err != nil {
t.Errorf("Error: %v", err)
}
cwd, err := filepath.Abs(".")
if err != nil {
t.Errorf("Error: %v", err)
}
// Run again for caching
_, err = helm.DecryptSecret(HelmContext{}, "secretName")
expected := fmt.Sprintf(`Preparing to decrypt secret %v/secretName
Decrypting secret %s/secretName
exec: helm --kubeconfig config --kube-context dev secrets decrypt %s/secretName
Decrypted %s/secretName into %s
Preparing to decrypt secret %s/secretName
Found secret in cache %s/secretName
Decrypted %s/secretName into %s
`, cwd, cwd, cwd, cwd, tmpFilePath, cwd, cwd, cwd, tmpFilePath)
if err != nil {
if _, ok := err.(*os.PathError); ok {
} else {
t.Errorf("Error: %v", err)
}
}
if d := cmp.Diff(expected, buffer.String()); d != "" {
t.Errorf("helmexec.DecryptSecret(): want (-), got (+):\n%s", d)
}
}
func Test_DecryptSecretWithGotmpl(t *testing.T) {
// Set secrets plugin version to 4.7.4
if err := os.Setenv("HELM_PLUGINS", "../../test/plugins/secrets/4.7.4"); err != nil {
t.Errorf("failed to set environment HELM_PLUGINS error: %s", err)
}
defer func() {
if err := os.Unsetenv("HELM_PLUGINS"); err != nil {
t.Errorf("failed to unset environment HELM_PLUGINS error: %s", err)
}
}()
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
tmpFilePath := "path/to/temp/file"
helm.writeTempFile = func(content []byte) (string, error) {
return tmpFilePath, nil
}
secretName := "secretName.yaml.gotmpl"
_, err = helm.DecryptSecret(HelmContext{}, secretName)
if err != nil {
t.Errorf("Error: %v", err)
}
cwd, err := filepath.Abs(".")
if err != nil {
t.Errorf("Error: %v", err)
}
expected := fmt.Sprintf(`Preparing to decrypt secret %v/secretName.yaml.gotmpl
Decrypting secret %s/secretName.yaml.gotmpl
exec: helm --kubeconfig config --kube-context dev secrets decrypt %s/secretName.yaml.gotmpl
Decrypted %s/secretName.yaml.gotmpl into %s
`, cwd, cwd, cwd, cwd, tmpFilePath)
if err != nil {
t.Errorf("Error: %v", err)
}
if d := cmp.Diff(expected, buffer.String()); d != "" {
t.Errorf("helmexec.DecryptSecret(): want (-), got (+):\n%s", d)
}
}
func Test_DiffRelease(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false, "--timeout 10", "--wait", "--wait-for-jobs")
expected := `Comparing release=release, chart=chart, namespace=default
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart --timeout 10 --wait --wait-for-jobs
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.DiffRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false)
expected = `Comparing release=release, chart=chart, namespace=default
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.DiffRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
err = helm.DiffRelease(HelmContext{}, "release", "https://example_user:example_password@repo.example.com/chart.tgz", "default", false)
expected = `Comparing release=release, chart=https://example_user:xxxxx@repo.example.com/chart.tgz, namespace=default
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release https://example_user:example_password@repo.example.com/chart.tgz
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.DiffRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_DiffRelease_ColorFlagHelm4(t *testing.T) {
// Test that --color and --no-color flags are removed and HELM_DIFF_COLOR env var is set for Helm 4
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
// MockExecer creates a Helm 4 execer by default (returns v4.0.1)
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// Verify it's Helm 4
if !helm.IsHelm4() {
t.Errorf("expected Helm 4, got version: %v", helm.GetVersion())
}
// Test with --color flag
buffer.Reset()
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false, "--color", "--context", "3")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// The --color flag should be removed and --context should remain
expected := `Comparing release=release, chart=chart, namespace=default
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart --context 3
`
actual := buffer.String()
if actual != expected {
t.Errorf("helmexec.DiffRelease() with --color\nactual = %v\nexpect = %v", actual, expected)
}
// Verify --color flag was removed
if strings.Contains(actual, "--color") {
t.Errorf("--color flag should have been removed, but got: %v", actual)
}
// Test with --no-color flag
buffer.Reset()
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false, "--no-color", "--context", "3")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// The --no-color flag should be removed and --context should remain
expected = `Comparing release=release, chart=chart, namespace=default
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart --context 3
`
actual = buffer.String()
if actual != expected {
t.Errorf("helmexec.DiffRelease() with --no-color\nactual = %v\nexpect = %v", actual, expected)
}
// Verify --no-color flag was removed
if strings.Contains(actual, "--no-color") {
t.Errorf("--no-color flag should have been removed, but got: %v", actual)
}
}
func Test_FilterColorFlagsForHelm4(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tests := []struct {
name string
inputFlags []string
expectedFlags []string
expectedEnvKey string
expectedEnvVal string
}{
{
name: "color flag",
inputFlags: []string{"--color", "--context", "3"},
expectedFlags: []string{"--context", "3"},
expectedEnvKey: "HELM_DIFF_COLOR",
expectedEnvVal: "true",
},
{
name: "no-color flag",
inputFlags: []string{"--no-color", "--context", "3"},
expectedFlags: []string{"--context", "3"},
expectedEnvKey: "HELM_DIFF_COLOR",
expectedEnvVal: "false",
},
{
name: "no color flags",
inputFlags: []string{"--context", "3", "--detailed-exitcode"},
expectedFlags: []string{"--context", "3", "--detailed-exitcode"},
expectedEnvKey: "",
expectedEnvVal: "",
},
{
name: "color flag with other flags",
inputFlags: []string{"--detailed-exitcode", "--color", "--suppress", "secret"},
expectedFlags: []string{"--detailed-exitcode", "--suppress", "secret"},
expectedEnvKey: "HELM_DIFF_COLOR",
expectedEnvVal: "true",
},
{
name: "both color and no-color flags (first wins with defensive check)",
inputFlags: []string{"--color", "--no-color", "--context", "3"},
expectedFlags: []string{"--context", "3"},
expectedEnvKey: "HELM_DIFF_COLOR",
expectedEnvVal: "true", // Changed: first flag wins due to defensive check
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := make(map[string]string)
actualFlags := helm.filterColorFlagsForHelm4(tt.inputFlags, env)
if !reflect.DeepEqual(actualFlags, tt.expectedFlags) {
t.Errorf("filterColorFlagsForHelm4() flags\nactual = %v\nexpect = %v", actualFlags, tt.expectedFlags)
}
if tt.expectedEnvKey != "" {
if env[tt.expectedEnvKey] != tt.expectedEnvVal {
t.Errorf("filterColorFlagsForHelm4() env[%v]\nactual = %v\nexpect = %v",
tt.expectedEnvKey, env[tt.expectedEnvKey], tt.expectedEnvVal)
}
} else if len(env) > 0 {
t.Errorf("filterColorFlagsForHelm4() expected no env vars, but got: %v", env)
}
})
}
}
func Test_DeleteRelease(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.DeleteRelease(HelmContext{}, "release")
expected := `Deleting release
exec: helm --kubeconfig config --kube-context dev delete release
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.DeleteRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_DeleteRelease_Flags(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.DeleteRelease(HelmContext{}, "release", "--purge")
expected := `Deleting release
exec: helm --kubeconfig config --kube-context dev delete release --purge
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.DeleteRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_TestRelease(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.TestRelease(HelmContext{}, "release")
expected := `Testing release
exec: helm --kubeconfig config --kube-context dev test release
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.TestRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_TestRelease_Flags(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.TestRelease(HelmContext{}, "release", "--cleanup", "--timeout", "60")
expected := `Testing release
exec: helm --kubeconfig config --kube-context dev test release --cleanup --timeout 60
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.TestRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_ReleaseStatus(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.ReleaseStatus(HelmContext{}, "myRelease")
expected := `Getting status myRelease
exec: helm --kubeconfig config --kube-context dev status myRelease
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.ReleaseStatus()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_exec(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "", "")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
env := map[string]string{}
_, err = helm.exec([]string{"version"}, env, nil)
expected := `exec: helm version
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
helm, err = MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
ret, _ := helm.exec([]string{"diff"}, env, nil)
if len(ret) != 0 {
t.Error("helmexec.exec() - expected empty return value")
}
buffer.Reset()
helm, err = MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
_, err = helm.exec([]string{"diff", "release", "chart", "--timeout 10", "--wait", "--wait-for-jobs"}, env, nil)
expected = `exec: helm --kubeconfig config --kube-context dev diff release chart --timeout 10 --wait --wait-for-jobs
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
_, err = helm.exec([]string{"version"}, env, nil)
expected = `exec: helm --kubeconfig config --kube-context dev version
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
helm.SetExtraArgs("foo")
_, err = helm.exec([]string{"version"}, env, nil)
expected = `exec: helm --kubeconfig config --kube-context dev version foo
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
helm, err = MockExecer(logger, "", "")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
helm.SetHelmBinary("overwritten")
_, err = helm.exec([]string{"version"}, env, nil)
expected = `exec: overwritten version
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_Lint(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.Lint("release", "path/to/chart", "--values", "file.yml")
expected := `Linting release=release, chart=path/to/chart
exec: helm --kubeconfig config --kube-context dev lint path/to/chart --values file.yml
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.Lint()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_Fetch(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.Fetch("chart", "--version", "1.2.3", "--untar", "--untardir", "/tmp/dir")
expected := `Fetching chart
exec: helm --kubeconfig config --kube-context dev fetch chart --version 1.2.3 --untar --untardir /tmp/dir
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.Fetch()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
err = helm.Fetch("https://example_user:example_password@repo.example.com/chart.tgz", "--version", "1.2.3", "--untar", "--untardir", "/tmp/dir")
expected = `Fetching https://example_user:xxxxx@repo.example.com/chart.tgz
exec: helm --kubeconfig config --kube-context dev fetch https://example_user:example_password@repo.example.com/chart.tgz --version 1.2.3 --untar --untardir /tmp/dir
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.Fetch()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_ChartPull(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
tests := []struct {
name string
helmBin string
helmVersion string
chartName string
chartPath string
chartFlags []string
listResult string
}{
{
name: "less then v3.7.0",
helmBin: "helm",
helmVersion: "v3.6.0",
chartName: "chart",
chartPath: "path1",
chartFlags: []string{"--untar", "--untardir", "/tmp/dir"},
listResult: `Pulling chart
exec: helm --kubeconfig config --kube-context dev chart pull chart
`,
},
{
name: "more then v3.7.0",
helmBin: "helm",
helmVersion: "v3.10.0",
chartName: "repo/helm-charts:0.14.0",
chartPath: "path1",
chartFlags: []string{"--untardir", "/tmp/dir", "--version", "0.14.0"},
listResult: `Pulling repo/helm-charts:0.14.0
exec: helm --kubeconfig config --kube-context dev pull oci://repo/helm-charts --destination path1 --untar --untardir /tmp/dir --version 0.14.0
`,
},
{
name: "more then v3.7.0 with rc",
helmBin: "helm",
helmVersion: "v3.14.0-rc.1+g69dcc92",
chartName: "repo/helm-charts",
chartPath: "path1",
chartFlags: []string{"--untardir", "/tmp/dir", "--devel"},
listResult: `Pulling repo/helm-charts
exec: helm --kubeconfig config --kube-context dev pull oci://repo/helm-charts --destination path1 --untar --untardir /tmp/dir --devel
`,
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
buffer.Reset()
helm, err := New(tt.helmBin, HelmExecOptions{}, logger, "config", "dev", &mockRunner{output: []byte(tt.helmVersion)})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.ChartPull(tt.chartName, tt.chartPath, tt.chartFlags...)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
isMatch, _ := regexp.MatchString(tt.listResult, buffer.String())
if !isMatch {
t.Errorf("helmexec.ChartPull()\nactual = %v\nexpect = %v", buffer.String(), tt.listResult)
}
})
}
}
func Test_ChartExport(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
tests := []struct {
name string
helmBin string
helmVersion string
chartName string
chartPath string
listResult string
expectedError string
}{
{
name: "",
helmBin: "helm",
helmVersion: "v3.6.0",
chartName: "chart",
chartPath: "path1",
listResult: `Exporting chart
exec: helm --kubeconfig config --kube-context dev chart export chart --destination path1
`,
expectedError: "",
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
buffer.Reset()
helm := &execer{
helmBinary: tt.helmBin,
version: semver.MustParse(tt.helmVersion),
logger: logger,
kubeconfig: "config",
kubeContext: "dev",
runner: &mockRunner{},
}
err := helm.ChartExport(tt.chartName, tt.chartPath)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != tt.listResult {
t.Errorf("helmexec.ChartExport()\nactual = %v\nexpect = %v", buffer.String(), tt.listResult)
}
})
}
}
var logLevelTests = map[string]string{
"debug": `Adding repo myRepo https://repo.example.com/
exec: helm repo add myRepo https://repo.example.com/ --force-update --username example_user --password-stdin
`,
"info": `Adding repo myRepo https://repo.example.com/
`,
"warn": ``,
}
func Test_LogLevels(t *testing.T) {
var buffer bytes.Buffer
for logLevel, expected := range logLevelTests {
buffer.Reset()
logger := NewLogger(&buffer, logLevel)
helm, err := MockExecer(logger, "", "")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "example_user", "example_password", "", false, false)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actual := buffer.String()
if strings.Contains(actual, "--password-stdin") {
expected = strings.Replace(expected, "--password example_password", "--password-stdin", 1)
}
if actual != expected {
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
}
}
}
func Test_mergeEnv(t *testing.T) {
actual := env2map(mergeEnv([]string{"A=1", "B=c=d", "E=2"}, map[string]string{"B": "3", "F": "4"}))
expected := map[string]string{"A": "1", "B": "3", "E": "2", "F": "4"}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("mergeEnv()\nactual = %v\nexpect = %v", actual, expected)
}
}
func Test_Template(t *testing.T) {
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm, err := MockExecer(logger, "config", "dev")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
err = helm.TemplateRelease("release", "path/to/chart", "--values", "file.yml")
expected := `Templating release=release, chart=path/to/chart
exec: helm --kubeconfig config --kube-context dev template release path/to/chart --values file.yml
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.Template()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
buffer.Reset()
err = helm.TemplateRelease("release", "https://example_user:example_password@repo.example.com/chart.tgz", "--values", "file.yml")
expected = `Templating release=release, chart=https://example_user:xxxxx@repo.example.com/chart.tgz
exec: helm --kubeconfig config --kube-context dev template release https://example_user:example_password@repo.example.com/chart.tgz --values file.yml
`
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if buffer.String() != expected {
t.Errorf("helmexec.Template()\nactual = %v\nexpect = %v", buffer.String(), expected)
}
}
func Test_Template_PostRendererWithOutputDir(t *testing.T) {
tests := []struct {
name string
postRendererFlag string
}{
{"separate flags", "--post-renderer"},
{"combined flag", "--post-renderer=/bin/echo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
// Use Helm 3 version for the version probe so the Helm 3 workaround is applied.
// The workaround is not needed for Helm 4, which natively applies --post-renderer to --output-dir output.
runner := &mockRunner{versionOutput: []byte("v3.20.0")}
helm, err := New("helm", HelmExecOptions{}, logger, "config", "dev", runner)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
runner.output = []byte("apiVersion: v1\nkind: Namespace\n")
var flags []string
if tt.postRendererFlag == "--post-renderer" {
flags = []string{"--post-renderer", "/bin/echo", "--output-dir", tmpDir, "--values", "file.yml"}
} else {
flags = []string{tt.postRendererFlag, "--output-dir", tmpDir, "--values", "file.yml"}
}
err = helm.TemplateRelease("myrelease", "path/to/chart", flags...)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
outputPath := filepath.Join(tmpDir, "templates", "myrelease.yaml")
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("expected output file %s to exist: %v", outputPath, err)
}
expected := "apiVersion: v1\nkind: Namespace\n\n"
if string(data) != expected {
t.Errorf("output file content:\nactual=%q\nexpect=%q", string(data), expected)
}
outputLog := buffer.String()
if strings.Contains(outputLog, "--output-dir") {
t.Errorf("helm should NOT have been called with --output-dir, got: %s", outputLog)
}
if !strings.Contains(outputLog, "--post-renderer") {
t.Errorf("helm should have been called with --post-renderer, got: %s", outputLog)
}
})
}
}
func Test_IsHelm3(t *testing.T) {
helm3Runner := mockRunner{output: []byte("v3.0.0+ge29ce2a\n")}
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !helm.IsHelm3() {
t.Error("helmexec.IsHelm3() - Failed to detect Helm 3")
}
helm4Runner := mockRunner{output: []byte("v4.0.1+g12500dd\n")}
helm, err = New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm4Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if helm.IsHelm3() {
t.Error("helmexec.IsHelm3() - Detected Helm 3 with Helm 4 version")
}
}
func Test_IsHelm4(t *testing.T) {
helm3Runner := mockRunner{output: []byte("v3.0.0+ge29ce2a\n")}
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if helm.IsHelm4() {
t.Error("helmexec.IsHelm4() - Detected Helm 4 with Helm 3 version")
}
helm4Runner := mockRunner{output: []byte("v4.0.1+g12500dd\n")}
helm, err = New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm4Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !helm.IsHelm4() {
t.Error("helmexec.IsHelm4() - Failed to detect Helm 4")
}
}
func Test_GetPluginVersion(t *testing.T) {
v3ExpectedVersion := "3.15.0"
v4ExpectedVersion := "4.7.4"
v3PluginDirPath := "../../test/plugins/secrets/3.15.0"
v4PluginDirPath := "../../test/plugins/secrets/4.7.4"
v3SecretPluginVersion, err := GetPluginVersion("secrets", v3PluginDirPath)
require.NoError(t, err)
if v3SecretPluginVersion.String() != v3ExpectedVersion {
t.Errorf("secrets v3 plugin version is %v, expected %v", v3SecretPluginVersion.String(), v3ExpectedVersion)
}
v4SecretPluginVersion, err := GetPluginVersion("secrets", v4PluginDirPath)
require.NoError(t, err)
if v4SecretPluginVersion.String() != v4ExpectedVersion {
t.Errorf("secrets v4 plugin version is %v, expected %v", v4SecretPluginVersion.String(), v4ExpectedVersion)
}
}
func Test_GetPluginVersion_XDGPaths(t *testing.T) {
v3ExpectedVersion := "3.15.0"
v4ExpectedVersion := "4.7.4"
v3PluginDirPath := "../../test/plugins/secrets/3.15.0"
v4PluginDirPath := "../../test/plugins/secrets/4.7.4"
sep := string(os.PathListSeparator)
xdgPaths := "nonexistent/path" + sep + v3PluginDirPath + sep + "another/nonexistent"
pluginVersion, err := GetPluginVersion("secrets", xdgPaths)
require.NoError(t, err)
assert.Equal(t, v3ExpectedVersion, pluginVersion.String())
xdgPathsV4 := v4PluginDirPath + sep + "nonexistent/path"
pluginVersion, err = GetPluginVersion("secrets", xdgPathsV4)
require.NoError(t, err)
assert.Equal(t, v4ExpectedVersion, pluginVersion.String())
_, err = GetPluginVersion("nonexistent-plugin", xdgPaths)
require.Error(t, err)
assert.Contains(t, err.Error(), "plugin nonexistent-plugin not installed")
}
func Test_GetPluginVersion_Symlink(t *testing.T) {
// Simulate a nix/devbox environment where the plugin directory is a symlink
tmpDir := t.TempDir()
// Create a real plugin directory with plugin.yaml
realPluginDir := filepath.Join(tmpDir, "real", "helm-diff")
require.NoError(t, os.MkdirAll(realPluginDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(realPluginDir, "plugin.yaml"), []byte(`name: "diff"
version: "3.12.0"
`), 0o644))
// Create a plugins directory where helm-diff is a symlink (like nix/devbox)
symlinkPluginsDir := filepath.Join(tmpDir, "plugins")
require.NoError(t, os.MkdirAll(symlinkPluginsDir, 0o755))
require.NoError(t, os.Symlink(realPluginDir, filepath.Join(symlinkPluginsDir, "helm-diff")))
version, err := GetPluginVersion("diff", symlinkPluginsDir)
require.NoError(t, err)
assert.Equal(t, "3.12.0", version.String())
}
func Test_GetVersion(t *testing.T) {
helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94\n")}
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm2Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
ver := helm.GetVersion()
if ver.Major != 2 || ver.Minor != 16 || ver.Patch != 1 {
t.Errorf("helmexec.GetVersion - did not detect correct Helm2 version; it was: %+v", ver)
}
helm3Runner := mockRunner{output: []byte("v3.2.4+ge29ce2a\n")}
helm, err = New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
ver = helm.GetVersion()
if ver.Major != 3 || ver.Minor != 2 || ver.Patch != 4 {
t.Errorf("helmexec.GetVersion - did not detect correct Helm3 version; it was: %+v", ver)
}
}
func Test_IsVersionAtLeast(t *testing.T) {
helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94\n")}
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm2Runner)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !helm.IsVersionAtLeast("2.1.0") {
t.Error("helmexec.IsVersionAtLeast - 2.16.1 not atleast 2.1")
}
if helm.IsVersionAtLeast("2.19.0") {
t.Error("helmexec.IsVersionAtLeast - 2.16.1 is atleast 2.19")
}
if helm.IsVersionAtLeast("3.2.0") {
t.Error("helmexec.IsVersionAtLeast - 2.16.1 is atleast 3.2")
}
}
func Test_resolveOciChart(t *testing.T) {
tests := []struct {
name string
chartPath string
ociChartURL string
ociChartTag string
}{
{
name: "normal",
chartPath: "chart/nginx:v1",
ociChartURL: "oci://chart/nginx",
ociChartTag: "v1",
},
{
name: "contains the port",
chartPath: "chart:5000/nginx:v1",
ociChartURL: "oci://chart:5000/nginx",
ociChartTag: "v1",
},
{
name: "no tag",
chartPath: "chart:5000/nginx",
ociChartURL: "oci://chart:5000/nginx",
ociChartTag: "",
},
{
name: "digest only",
chartPath: "ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
ociChartURL: "oci://ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
ociChartTag: "",
},
{
name: "version and digest",
chartPath: "ghcr.io/nginxinc/charts/nginx-ingress:2.0.0@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
ociChartURL: "oci://ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
ociChartTag: "2.0.0",
},
{
name: "port with digest",
chartPath: "registry:5000/chart@sha256:abc123",
ociChartURL: "oci://registry:5000/chart@sha256:abc123",
ociChartTag: "",
},
{
name: "port with version and digest",
chartPath: "registry:5000/chart:1.0.0@sha256:abc123",
ociChartURL: "oci://registry:5000/chart@sha256:abc123",
ociChartTag: "1.0.0",
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
url, tag := resolveOciChart(tt.chartPath)
if tt.ociChartURL != url || tt.ociChartTag != tag {
actual := fmt.Sprintf("ociChartURL->%s ociChartTag->%s", url, tag)
expected := fmt.Sprintf("ociChartURL->%s ociChartTag->%s", tt.ociChartURL, tt.ociChartTag)
t.Errorf("resolveOciChart()\nactual = %v\nexpect = %v", actual, expected)
}
})
}
}
func Test_ShowChart(t *testing.T) {
showChartRunner := mockRunner{output: []byte("name: my-chart\nversion: 3.2.0\n")}
helm := &execer{
helmBinary: "helm",
version: semver.MustParse("3.3.2"),
logger: NewLogger(os.Stdout, "info"),
kubeContext: "dev",
runner: &showChartRunner,
}
metadata, err := helm.ShowChart("my-chart")
if err != nil {
t.Errorf("helmexec.ShowChart() - unexpected error: %v", err)
}
if metadata.Name != "my-chart" {
t.Errorf("helmexec.ShowChart() - expected chart name was %s, received: %s", "my-chart", metadata.Name)
}
if metadata.Version != "3.2.0" {
t.Errorf("helmexec.ShowChart() - expected chart version was %s, received: %s", "3.2.0", metadata.Version)
}
}
func Test_SetDisableForceUpdate(t *testing.T) {
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
if err != nil {
t.Error(err)
}
if helm.options.DisableForceUpdate {
t.Error("helmexec.options.DisableForceUpdate should not be enabled by default")
}
helm.SetDisableForceUpdate(true)
if !helm.options.DisableForceUpdate {
t.Errorf("helmexec.SetDisableForceUpdate() - actual = %t expect = true", helm.options.DisableForceUpdate)
}
}
func TestParseHelmVersion(t *testing.T) {
tests := []struct {
name string
version string
want *semver.Version
wantErr bool
}{
{
name: "helm 2",
version: "Client: v2.16.1+ge13bc94\n",
want: semver.MustParse("v2.16.1+ge13bc94"),
wantErr: false,
},
{
name: "helm 3",
version: "Client: v3.2.4+ge29ce2a\n",
want: semver.MustParse("v3.2.4+ge29ce2a"),
wantErr: false,
},
{
name: "helm 3 with os arch and build info",
version: "Client v3.7.1+7.el8+g8f33223\n",
want: semver.MustParse("v3.7.1+7.el8"),
wantErr: false,
},
{
name: "empty version",
version: "",
want: nil,
wantErr: true,
},
{
name: "invalid version",
version: "oooooo",
want: nil,
wantErr: true,
},
{
name: "version without v prefix",
version: "3.2.4",
want: semver.MustParse("v3.2.4"),
wantErr: false,
},
{
name: "version without v prefix with build info",
version: "3.2.4+ge29ce2a",
want: semver.MustParse("v3.2.4+ge29ce2a"),
wantErr: false,
},
{
name: "version without v prefix with spaces",
version: " 3.2.4 ",
want: semver.MustParse("v3.2.4"),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseHelmVersion(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseHelmVersion() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseHelmVersion() = %v, want %v", got, tt.want)
}
})
}
}
func Test_helmSecretsRequiresSplitInstall(t *testing.T) {
tests := []struct {
name string
version string
want bool
}{
{name: "below threshold", version: "v4.6.9", want: false},
{name: "exactly at threshold", version: "v4.7.0", want: true},
{name: "above threshold single digit minor", version: "v4.8.0", want: true},
{name: "above threshold double digit minor (v4.10.0+)", version: "v4.10.0", want: true},
{name: "pre-release below threshold", version: "v4.7.0-beta.1", want: false},
{name: "invalid version string", version: "not-a-version", want: false},
{name: "empty string", version: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := helmSecretsRequiresSplitInstall(tt.version)
assert.Equal(t, tt.want, got)
})
}
}
type funcRunner struct {
execute func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error)
}
func (r *funcRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
return r.execute(cmd, args, env, false)
}
func (r *funcRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
return r.execute(cmd, args, env, enableLiveOutput)
}
func Test_UpdatePlugin_Helm4SecretsUsesUninstallReinstall(t *testing.T) {
var calledArgs [][]string
runner := &funcRunner{
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
calledArgs = append(calledArgs, append([]string(nil), args...))
return []byte{}, nil
},
}
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse("4.0.0"),
logger: logger,
kubeconfig: "config",
kubeContext: "dev",
runner: runner,
}
err := helm.UpdatePlugin("secrets", "https://github.com/jkroepke/helm-secrets", "v4.7.0")
require.NoError(t, err)
// Verify that "plugin update" was NOT called (the Helm 4 secrets path should skip it).
for _, args := range calledArgs {
for i, a := range args {
if a == pluginCmd && i+1 < len(args) && args[i+1] == "update" {
t.Errorf("expected 'plugin update' to not be called for Helm 4 secrets, but it was: %v", args)
}
}
}
// Verify that uninstall was called for all three split plugins.
checkUninstall := func(name string) {
for _, args := range calledArgs {
for i, a := range args {
if a == pluginCmd && i+2 < len(args) && args[i+1] == "uninstall" && args[i+2] == name {
return
}
}
}
t.Errorf("expected 'plugin uninstall %s' to be called", name)
}
checkUninstall("secrets")
checkUninstall("secrets-getter")
checkUninstall("secrets-post-renderer")
// Verify that install was called for all three split plugin tarballs.
checkInstall := func(urlSubstring string) {
for _, args := range calledArgs {
for i, a := range args {
if a == pluginCmd && i+2 < len(args) && args[i+1] == installCmd && strings.Contains(args[i+2], urlSubstring) {
return
}
}
}
t.Errorf("expected 'plugin install' for %q to be called", urlSubstring)
}
checkInstall("secrets-4.7.0.tgz")
checkInstall("secrets-getter-4.7.0.tgz")
checkInstall("secrets-post-renderer-4.7.0.tgz")
}
// Test_UpdatePlugin_GeneralPathUsesUninstallReinstall verifies that for a regular
// plugin, UpdatePlugin does NOT rely on the unreliable `helm plugin update`
// command. Instead it must uninstall the existing plugin and reinstall the exact
// pinned version. See issues #2726 and #2548: `helm plugin update` reports
// success but leaves `helm plugin list` showing the old version because it
// re-installs from the cached source without the --version flag.
func Test_UpdatePlugin_GeneralPathUsesUninstallReinstall(t *testing.T) {
var calledArgs [][]string
runner := &funcRunner{
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
calledArgs = append(calledArgs, append([]string(nil), args...))
return []byte{}, nil
},
}
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse("3.16.4"),
logger: logger,
runner: runner,
}
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
require.NoError(t, err)
// `plugin update` must never be used: it does not honor --version and silently
// leaves the old version installed.
for _, args := range calledArgs {
for i, a := range args {
if a == pluginCmd && i+1 < len(args) && args[i+1] == "update" {
t.Errorf("expected 'plugin update' to not be called, but it was: %v", args)
}
}
}
// `plugin uninstall diff` must be called to clear the stale version.
uninstalled := false
for _, args := range calledArgs {
for i, a := range args {
if a == pluginCmd && i+2 < len(args) && args[i+1] == "uninstall" && args[i+2] == "diff" {
uninstalled = true
}
}
}
require.True(t, uninstalled, "expected 'plugin uninstall diff' to be called")
// `plugin install <repo> --version <pinned>` must be called with the exact
// requested version so the installed plugin is updated to it.
installed := false
for _, args := range calledArgs {
for i, a := range args {
if a == pluginCmd && i+1 < len(args) && args[i+1] == installCmd {
hasRepo := false
hasVersion := false
for _, arg := range args[i+2:] {
if arg == "https://github.com/databus23/helm-diff" {
hasRepo = true
}
if arg == "v3.15.10" {
hasVersion = true
}
}
if hasRepo && hasVersion {
installed = true
}
}
}
}
require.True(t, installed, "expected 'plugin install' with the pinned version to be called")
}
// Test_UpdatePlugin_NotFoundUninstallProceedsToInstall ensures that when the
// plugin is already absent (helm reports its "plugin not found" message),
// UpdatePlugin still proceeds to install the pinned version rather than treating
// it as an error. Both helm major formats are covered.
func Test_UpdatePlugin_NotFoundUninstallProceedsToInstall(t *testing.T) {
// Helm 4 reports "plugin: <name> not found"; Helm 3 reports "Plugin: <name> not found".
for _, tc := range []struct {
name string
version string
msg string
}{
{name: "helm4", version: "4.2.3", msg: "plugin: diff not found"},
{name: "helm3", version: "3.16.4", msg: "Plugin: diff not found"},
} {
t.Run(tc.name, func(t *testing.T) {
var calledArgs [][]string
runner := &funcRunner{
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
calledArgs = append(calledArgs, append([]string(nil), args...))
if len(args) >= 2 && args[0] == pluginCmd && args[1] == "uninstall" {
return nil, ExitError{Message: tc.msg, Code: 1}
}
return []byte{}, nil
},
}
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse(tc.version),
logger: logger,
runner: runner,
}
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
require.NoError(t, err, "a plugin-absent uninstall error should be ignored and install should proceed")
// install with the pinned version must still be attempted
installed := false
for _, args := range calledArgs {
for i, a := range args {
if a == pluginCmd && i+1 < len(args) && args[i+1] == installCmd {
for _, arg := range args[i+2:] {
if arg == "v3.15.10" {
installed = true
}
}
}
}
}
require.True(t, installed, "expected install to proceed after a plugin-absent uninstall")
})
}
}
// Test_UpdatePlugin_RealUninstallFailureReturnsError ensures that a genuine
// uninstall failure (permissions, broken Helm, etc.) is surfaced instead of
// being ignored, which would otherwise mask the root cause behind a confusing
// "plugin already exists" error from the subsequent install.
func Test_UpdatePlugin_RealUninstallFailureReturnsError(t *testing.T) {
installCalled := false
runner := &funcRunner{
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
if len(args) >= 2 && args[0] == pluginCmd && args[1] == "uninstall" {
return nil, ExitError{Message: "permission denied", Code: 1}
}
if len(args) >= 2 && args[0] == pluginCmd && args[1] == installCmd {
installCalled = true
}
return []byte{}, nil
},
}
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse("3.16.4"),
logger: logger,
runner: runner,
}
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
require.Error(t, err, "a real uninstall failure should be returned")
assert.Contains(t, err.Error(), "uninstall")
assert.False(t, installCalled, "install must not be attempted after a real uninstall failure")
}
// Test_UpdatePlugin_ExecutableNotFoundUninstallErrorIsNotSwallowed guards against
// an overly broad "not found" check: when helm itself is missing the runner
// surfaces an error containing "executable file not found", which must NOT be
// mistaken for an absent plugin (otherwise UpdatePlugin would silently log and
// proceed to install, masking the real problem). Only the helm-specific
// "plugin: <name> not found" message is tolerated.
func Test_UpdatePlugin_ExecutableNotFoundUninstallErrorIsNotSwallowed(t *testing.T) {
installCalled := false
runner := &funcRunner{
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
if len(args) >= 2 && args[0] == pluginCmd && args[1] == "uninstall" {
// Mimics helmfile's ShellRunner when the helm binary is absent.
return nil, fmt.Errorf("unexpected error: exec: %q: executable file not found in $PATH", cmd)
}
if len(args) >= 2 && args[0] == pluginCmd && args[1] == installCmd {
installCalled = true
}
return []byte{}, nil
},
}
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse("3.16.4"),
logger: logger,
runner: runner,
}
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
require.Error(t, err, "a missing-binary uninstall error must be returned, not swallowed")
assert.Contains(t, err.Error(), "executable file not found")
assert.False(t, installCalled, "install must not be attempted when the helm binary is missing")
}
func Test_dedupeWroteLines(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "empty",
in: "",
want: "",
},
{
name: "no wrote lines untouched",
in: "Found 25 resources in manifest for release system\n",
want: "Found 25 resources in manifest for release system\n",
},
{
name: "collapses duplicate wrote lines preserving first-seen order",
in: "wrote /tmp/a/service_account.yaml\n" +
"wrote /tmp/a/service_account.yaml\n" +
"wrote /tmp/a/secrets.yaml\n" +
"wrote /tmp/a/service_account.yaml\n" +
"wrote /tmp/a/secrets.yaml\n" +
"Found 25 resources in manifest for release system",
want: "wrote /tmp/a/service_account.yaml\n" +
"wrote /tmp/a/secrets.yaml\n" +
"Found 25 resources in manifest for release system",
},
{
name: "non-wrote duplicates kept",
in: "hello\nhello\n",
want: "hello\nhello\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(dedupeWroteLines([]byte(c.in)))
if got != c.want {
t.Errorf("dedupeWroteLines() = %q, want %q", got, c.want)
}
})
}
}