diff --git a/cmd/root.go b/cmd/root.go index 33253819..2ed0c5ad 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + stderrors "errors" "fmt" "os" @@ -23,6 +24,10 @@ var globalUsage = "Declaratively deploy your Kubernetes manifests, Kustomize con func toCLIError(g *config.GlobalImpl, err error) error { if err != nil { + var exitErr helmexec.ExitError + if stderrors.As(err, &exitErr) { + return errors.NewExitError(exitErr.Error(), exitErr.ExitStatus()) + } switch e := err.(type) { case *app.NoMatchingHelmfileError: noMatchingExitCode := 3 @@ -35,7 +40,7 @@ func toCLIError(g *config.GlobalImpl, err error) error { case *app.Error: return errors.NewExitError(e.Error(), e.Code()) default: - panic(fmt.Errorf("BUG: please file an github issue for this unhandled error: %T: %v", e, e)) + return errors.NewExitError(fmt.Sprintf("unexpected error: %T: %v", e, e), 1) } } return err diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..7b34d1ee --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/helmfile/helmfile/pkg/config" + "github.com/helmfile/helmfile/pkg/errors" + "github.com/helmfile/helmfile/pkg/helmexec" +) + +func TestToCLIError(t *testing.T) { + g := config.NewGlobalImpl(&config.GlobalOptions{}) + + tests := []struct { + name string + err error + wantNil bool + wantExitCode int + wantMsgContains string + }{ + { + name: "nil error returns nil", + err: nil, + wantNil: true, + }, + { + name: "helmexec.ExitError returns correct exit code", + err: helmexec.ExitError{ + Message: "helm command failed", + Code: 7, + }, + wantExitCode: 7, + wantMsgContains: "helm command failed", + }, + { + name: "wrapped helmexec.ExitError preserves exit code", + err: fmt.Errorf("helm version failed: %w", helmexec.ExitError{Message: "exit status 7", Code: 7}), + wantExitCode: 7, + wantMsgContains: "exit status 7", + }, + { + name: "unknown error type returns exit code 1 without panic", + err: fmt.Errorf("some unexpected error"), + wantExitCode: 1, + wantMsgContains: "unexpected error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Should never panic + var result error + assert.NotPanics(t, func() { + result = toCLIError(g, tt.err) + }) + + if tt.wantNil { + assert.NoError(t, result) + return + } + + assert.Error(t, result) + exitErr, ok := result.(*errors.ExitError) + assert.True(t, ok, "expected *errors.ExitError, got %T", result) + assert.Equal(t, tt.wantExitCode, exitErr.ExitCode()) + assert.Contains(t, exitErr.Error(), tt.wantMsgContains) + }) + } +} diff --git a/pkg/app/init.go b/pkg/app/init.go index ffb1680f..c43865bd 100644 --- a/pkg/app/init.go +++ b/pkg/app/init.go @@ -196,9 +196,20 @@ func (h *HelmfileInit) CheckHelmPlugins() error { err = helm.AddPlugin(p.name, p.repo, p.version) if err != nil { - return err + // Check if plugin was installed despite the error (common on Windows where + // plugin install scripts fail due to missing 'sh' but the binary is placed correctly) + installedVersion, verifyErr := helmexec.GetPluginVersion(p.name, pluginsDir) + if verifyErr != nil { + return err // Plugin truly not installed + } + h.logger.Warnf("helm plugin %q install reported an error, but plugin is present at version %s: %v", p.name, installedVersion, err) + pluginVersion = installedVersion + } else { + pluginVersion, err = helmexec.GetPluginVersion(p.name, pluginsDir) + if err != nil { + return fmt.Errorf("plugin %q was installed but version could not be verified: %w", p.name, err) + } } - pluginVersion, _ = helmexec.GetPluginVersion(p.name, pluginsDir) } requiredVersion, _ := semver.NewVersion(p.version) if pluginVersion.LessThan(requiredVersion) { @@ -208,7 +219,16 @@ func (h *HelmfileInit) CheckHelmPlugins() error { } err = helm.UpdatePlugin(p.name) if err != nil { - return err + // Check if plugin was updated despite the error + updatedVersion, verifyErr := helmexec.GetPluginVersion(p.name, pluginsDir) + if verifyErr != nil { + return err + } + if !updatedVersion.LessThan(requiredVersion) { + h.logger.Warnf("helm plugin %q update reported an error, but plugin is at version %s: %v", p.name, updatedVersion, err) + } else { + return err + } } } } diff --git a/pkg/app/init_test.go b/pkg/app/init_test.go index 59d9124d..76849506 100644 --- a/pkg/app/init_test.go +++ b/pkg/app/init_test.go @@ -2,14 +2,21 @@ package app import ( "fmt" + "io" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/helmfile/helmfile/pkg/helmexec" ) func TestDownloadfile(t *testing.T) { @@ -84,3 +91,119 @@ func TestDownloadfile(t *testing.T) { }) } } + +// initMockRunner implements helmexec.Runner for testing with configurable behavior. +type initMockRunner struct { + // executeFunc is called for each Execute call. If nil, returns empty output and no error. + executeFunc func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) +} + +func (m *initMockRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) { + if m.executeFunc != nil { + return m.executeFunc(cmd, args, env, enableLiveOutput) + } + return []byte{}, nil +} + +func (m *initMockRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) { + return []byte{}, nil +} + +// mockInitConfigProvider implements InitConfigProvider for testing. +type mockInitConfigProvider struct { + force bool +} + +func (m *mockInitConfigProvider) Force() bool { + return m.force +} + +func newTestLogger() *zap.SugaredLogger { + cfg := zapcore.EncoderConfig{MessageKey: "message"} + core := zapcore.NewCore( + zapcore.NewConsoleEncoder(cfg), + zapcore.AddSync(io.Discard), + zapcore.DebugLevel, + ) + return zap.New(core).Sugar() +} + +// createPluginYAML creates a plugin.yaml in a temp plugins directory. +func createPluginYAML(t *testing.T, pluginsDir, pluginDirName, name, version string) { + t.Helper() + dir := filepath.Join(pluginsDir, pluginDirName) + require.NoError(t, os.MkdirAll(dir, 0o755)) + content := fmt.Sprintf("name: %s\nversion: %s\n", name, version) + require.NoError(t, os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(content), 0o644)) +} + +// newHelmPluginMockRunner creates a mock runner that returns a valid helm version +// and fails all "helm plugin" subcommands with the given error. +func newHelmPluginMockRunner(pluginErr error) *initMockRunner { + return &initMockRunner{ + executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) { + for _, a := range args { + if a == "--short" { + return []byte("v3.18.6"), nil + } + } + // Fail any "helm plugin ..." subcommand (install, update, etc.) + if len(args) > 0 && args[0] == "plugin" { + return nil, pluginErr + } + return []byte{}, nil + }, + } +} + +func TestCheckHelmPlugins_InstallErrorButPluginPresent(t *testing.T) { + pluginsDir := t.TempDir() + t.Setenv("HELM_PLUGINS", pluginsDir) + + // Do NOT pre-populate plugins — the directory starts empty so + // GetPluginVersion returns "not installed" and the install path is triggered. + // The mock runner simulates the Windows scenario where "helm plugin install" + // places the binary but the post-install script fails: it creates the + // plugin.yaml on disk and then returns an error. + runner := &initMockRunner{ + executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) { + for _, a := range args { + if a == "--short" { + return []byte("v3.18.6"), nil + } + } + if len(args) > 0 && args[0] == "plugin" && len(args) >= 3 && args[1] == "install" { + // Find which plugin is being installed by matching the repo URL. + repo := args[2] + for _, p := range helmPlugins { + if p.repo == repo { + createPluginYAML(t, pluginsDir, p.name, p.name, strings.TrimPrefix(p.version, "v")) + break + } + } + return nil, helmexec.ExitError{Message: "sh: not found", Code: 1} + } + return []byte{}, nil + }, + } + + h := NewHelmfileInit("helm", &mockInitConfigProvider{force: true}, newTestLogger(), runner) + err := h.CheckHelmPlugins() + // Should succeed because plugins are present despite install errors + assert.NoError(t, err) +} + +func TestCheckHelmPlugins_InstallErrorPluginTrulyMissing(t *testing.T) { + pluginsDir := t.TempDir() + t.Setenv("HELM_PLUGINS", pluginsDir) + + // Don't create any plugin files — the plugins directory is empty. + + runner := newHelmPluginMockRunner(helmexec.ExitError{Message: "sh: not found", Code: 1}) + + h := NewHelmfileInit("helm", &mockInitConfigProvider{force: true}, newTestLogger(), runner) + err := h.CheckHelmPlugins() + // Should fail because plugin is truly not installed + assert.Error(t, err) + assert.Contains(t, err.Error(), "sh: not found") +}