fix: prevent panic in helmfile init on plugin install errors (#2401)

toCLIError() panics on unhandled error types (e.g. helmexec.ExitError
from a failed helm plugin install). On Windows, plugin install hooks
often fail due to missing 'sh', causing helmfile init to crash even
when the plugin binary was placed correctly.

- Add helmexec.ExitError case to toCLIError and replace panic in the
  default case with a graceful error return
- After AddPlugin/UpdatePlugin errors, verify whether the plugin is
  actually present before failing; log a warning and continue if so

Fixes #1983

Signed-off-by: Aditya Menon <amenon@canarytechnologies.com>
This commit is contained in:
Aditya Menon
2026-02-18 08:57:11 +08:00
committed by GitHub
parent 0129681222
commit 3dab01c16f
4 changed files with 224 additions and 4 deletions
+23 -3
View File
@@ -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
}
}
}
}
+123
View File
@@ -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")
}