Files
helmfile/pkg/app/init.go
T
Copilotandyxxhero c57134cda7 Fix helmfile init failing to update outdated helm plugins with Helm v4 (#2554)
* Initial plan

* Fix helmfile init not updating outdated helm plugins with Helm v4

- UpdatePlugin now handles secrets plugin with Helm 4 by using the split
  plugin architecture (uninstall old + install via installHelmSecretsV4)
- UpdatePlugin falls back to uninstall + reinstall when helm plugin update
  fails (e.g., with Helm 4 or tarball-installed plugins)
- Fix string-based semver comparison for helm-secrets version check in
  both AddPlugin and UpdatePlugin using proper semver comparison
- Add helmSecretsRequiresSplitInstall helper for reuse and correctness
- Add tests for update failure fallback scenarios

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/533f1b1c-dda6-4934-af27-051e4eaa9927

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* Address reviewer feedback: preserve update error context and add version assertions in tests

- exec.go: include original update error in fallback log message; wrap both
  errors (update + reinstall) when reinstall also fails so callers get full context
- init_test.go: add semver import and GetPluginVersion assertions after
  CheckHelmPlugins to verify plugins are at required versions on disk

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/c784db7d-7d4c-40a0-97f0-a31eb8901cd6

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* Address second round of reviewer feedback

- exec.go: rename UpdatePlugin parameter path→repo for clarity
- exec.go: fix uninstallPlugin to only emit INFO log when err == nil
- exec_test.go: add Test_helmSecretsRequiresSplitInstall table-driven tests
  covering v4.6.9, v4.7.0, v4.8.0, v4.10.0, pre-release, invalid and empty
- exec_test.go: add Test_UpdatePlugin_Helm4SecretsUsesUninstallReinstall
  verifying that Helm 4 + secrets uses uninstall+reinstall (not plugin update)

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/cbd3f8c9-ec7d-4500-b168-cb1c2f7c87bc

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* Add len(args) >= 3 guards in test mock for plugin update/uninstall cases

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/ea0f5afc-d52d-473b-b759-853a8f841a26

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* Return early with combined error when uninstall fails in UpdatePlugin fallback

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/bb9a675c-309d-4b06-83d4-a6fe078dce64

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
2026-04-25 11:04:23 +08:00

277 lines
7.4 KiB
Go

package app
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/Masterminds/semver/v3"
"go.uber.org/zap"
cliv3 "helm.sh/helm/v3/pkg/cli"
cliv4 "helm.sh/helm/v4/pkg/cli"
"github.com/helmfile/helmfile/pkg/helmexec"
)
const (
HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x)
HelmDiffRecommendedVersion = "v3.15.3"
HelmRecommendedVersion = "v4.1.0" // Recommended to use latest Helm 4
HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture)
HelmGitRecommendedVersion = "v1.3.0"
HelmS3RecommendedVersion = "v0.16.3"
HelmUnittestRecommendedVersion = "v1.0.3"
HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" // Default to Helm 3 script for compatibility
)
var (
manuallyInstallCode = 1
windowPackageManagers = map[string]string{
"scoop": fmt.Sprintf("scoop install helm@%s", strings.TrimLeft(HelmRecommendedVersion, "v")),
"choco": fmt.Sprintf("choco install kubernetes-helm --version %s", strings.TrimLeft(HelmRecommendedVersion, "v")),
}
helmPlugins = []helmRecommendedPlugin{
{
name: "diff",
version: HelmDiffRecommendedVersion,
repo: "https://github.com/databus23/helm-diff",
},
{
name: "secrets",
version: HelmSecretsRecommendedVersion,
repo: "https://github.com/jkroepke/helm-secrets",
},
{
name: "s3",
version: HelmS3RecommendedVersion,
repo: "https://github.com/hypnoglow/helm-s3.git",
},
{
name: "helm-git",
version: HelmGitRecommendedVersion,
repo: "https://github.com/aslafy-z/helm-git.git",
},
{
name: "unittest",
version: HelmUnittestRecommendedVersion,
repo: "https://github.com/helm-unittest/helm-unittest",
},
}
)
type helmRecommendedPlugin struct {
name string
version string
repo string
}
type HelmfileInit struct {
helmBinary string
configProvider InitConfigProvider
logger *zap.SugaredLogger
runner helmexec.Runner
}
func downloadfile(filepath string, url string) error {
file, err := os.Create(filepath)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
resp, err := http.Get(url)
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("download %s error, code: %d", url, resp.StatusCode)
}
defer func() { _ = resp.Body.Close() }()
_, err = io.Copy(file, resp.Body)
if err != nil {
return err
}
return nil
}
func NewHelmfileInit(helmBinary string, c InitConfigProvider, logger *zap.SugaredLogger, runner helmexec.Runner) *HelmfileInit {
return &HelmfileInit{helmBinary: helmBinary, configProvider: c, logger: logger, runner: runner}
}
func (h *HelmfileInit) UpdateHelm() error {
return h.InstallHelm()
}
func (h *HelmfileInit) installHelmOnWindows() error {
for name, command := range windowPackageManagers {
_, err := exec.LookPath(name)
if err != nil {
continue
}
err = h.WhetherContinue(fmt.Sprintf("use: '%s'", command))
if err != nil {
return err
}
_, err = h.runner.Execute("cmd", []string{
"/c",
command,
}, nil, true)
return err
}
return &Error{msg: "windows platform, please install helm manually, installation steps: https://helm.sh/docs/intro/install/", code: &manuallyInstallCode}
}
func (h *HelmfileInit) InstallHelm() error {
if runtime.GOOS == "windows" {
return h.installHelmOnWindows()
}
err := h.WhetherContinue(fmt.Sprintf("use: '%s'", HelmInstallCommand))
if err != nil {
return err
}
getHelmScript, err := os.CreateTemp("", "get-helm.sh")
defer func() {
_ = getHelmScript.Close()
_ = os.Remove(getHelmScript.Name())
}()
if err != nil {
return err
}
err = downloadfile(getHelmScript.Name(), HelmInstallCommand)
if err != nil {
return err
}
_, err = h.runner.Execute("bash", []string{
getHelmScript.Name(),
"--version",
HelmRecommendedVersion,
}, nil, true)
if err != nil {
return err
}
h.helmBinary = DefaultHelmBinary
return nil
}
func (h *HelmfileInit) WhetherContinue(ask string) error {
if h.configProvider.Force() {
return nil
}
askYes := AskForConfirmation(ask)
if !askYes {
return &Error{msg: "cancel automatic installation, please install manually", code: &manuallyInstallCode}
}
return nil
}
func (h *HelmfileInit) CheckHelmPlugins() error {
helm, err := helmexec.New(h.helmBinary, helmexec.HelmExecOptions{}, h.logger, "", "", h.runner)
if err != nil {
return err
}
// Use version-specific cli based on detected Helm version
var pluginsDir string
if helm.IsHelm3() {
pluginsDir = cliv3.New().PluginsDirectory
} else {
pluginsDir = cliv4.New().PluginsDirectory
}
for _, p := range helmPlugins {
pluginVersion, err := helmexec.GetPluginVersion(p.name, pluginsDir)
if err != nil {
if !strings.Contains(err.Error(), "not installed") {
return err
}
err = h.WhetherContinue(fmt.Sprintf("The helm plugin %q is not installed, do you want to install it?", p.name))
if err != nil {
return err
}
err = helm.AddPlugin(p.name, p.repo, p.version)
if err != nil {
// 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)
}
}
}
requiredVersion, _ := semver.NewVersion(p.version)
if pluginVersion.LessThan(requiredVersion) {
err = h.WhetherContinue(fmt.Sprintf("The helm plugin %q version is too low, do you want to update it?", p.name))
if err != nil {
return err
}
err = helm.UpdatePlugin(p.name, p.repo, p.version)
if err != nil {
// 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
}
}
}
}
return nil
}
func (h *HelmfileInit) CheckHelm() error {
helmExits := true
_, err := exec.LookPath(h.helmBinary)
if err != nil {
helmExits = false
}
if !helmExits {
h.logger.Info("helm not found, needs to be installed")
err = h.InstallHelm()
if err != nil {
return err
}
}
helmversion, err := helmexec.GetHelmVersion(h.helmBinary, h.runner)
if err != nil {
return err
}
requiredHelmVersion, _ := semver.NewVersion(HelmRequiredVersion)
if helmversion.LessThan(requiredHelmVersion) {
h.logger.Infof("helm version is too low, the current version is %s, the required version is %s", helmversion, requiredHelmVersion)
err = h.UpdateHelm()
if err != nil {
return err
}
}
return nil
}
func (h *HelmfileInit) Initialize() error {
err := h.CheckHelm()
if err != nil {
return err
}
err = h.CheckHelmPlugins()
if err != nil {
return err
}
h.logger.Info("helmfile initialization completed!")
return nil
}