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
+6 -1
View File
@@ -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
+72
View File
@@ -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)
})
}
}