feat: add helm-legacy track mode for Helm v4 compatibility (#2466)

Add support for trackMode: helm-legacy to use Helm v4's --wait=legacy flag,
which maintains compatibility with Helm v3's wait behavior during migration.

Helm v4 changed the default --wait behavior from polling to a watcher-based
approach. This can cause issues with charts that have broken livenessProbe
configurations without startupProbe. The --wait=legacy flag preserves the
Helm v3 polling behavior for smoother migration.

Changes:
- Add TrackModeHelmLegacy constant in pkg/kubedog/options.go
- Use kubedog.TrackMode constants instead of raw strings in helmx.go
- Enhance appendWaitFlags to use --wait=legacy for Helm v4 when trackMode
  is helm-legacy
- Add nil check for logger before logging warning
- Add version check with warning when helm-legacy is used with Helm v3
- Update validation in pkg/config to accept helm-legacy track mode
- Update command-line flags in cmd/apply.go and cmd/sync.go
- Add comprehensive documentation in docs/advanced-features.md
- Add thorough test coverage including warning message verification

Behavior:
- Helm v4 + helm-legacy: Uses --wait=legacy
- Helm v3 + helm-legacy: Falls back to --wait with warning
- Helm v4 + helm: Uses --wait (watcher mode)
- Any + kubedog: Skips --wait flag

Fixes #2464

Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
yxxhero 2026-03-08 11:51:14 +08:00 committed by GitHub
parent 077a5a8dab
commit c6e7249eb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 163 additions and 21 deletions

View File

@ -68,7 +68,7 @@ func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.BoolVar(&applyOptions.SkipSchemaValidation, "skip-schema-validation", false, `pass --skip-schema-validation to "helm template" or "helm upgrade --install"`)
f.StringVar(&applyOptions.Cascade, "cascade", "", "pass cascade to helm exec, default: background")
f.StringArrayVar(&applyOptions.SuppressOutputLineRegex, "suppress-output-line-regex", nil, "a list of regex patterns to suppress output lines from diff output")
f.StringVar(&applyOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default) or 'kubedog'")
f.StringVar(&applyOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default), 'helm-legacy' (Helm v4 only), or 'kubedog'")
f.IntVar(&applyOptions.TrackTimeout, "track-timeout", 0, `Timeout in seconds for kubedog tracking (0 to use default 300s timeout)`)
f.BoolVar(&applyOptions.TrackLogs, "track-logs", false, "Enable log streaming with kubedog tracking")

View File

@ -54,7 +54,7 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.StringArrayVar(&syncOptions.PostRendererArgs, "post-renderer-args", nil, `pass --post-renderer-args to "helm template" or "helm upgrade --install"`)
f.BoolVar(&syncOptions.SkipSchemaValidation, "skip-schema-validation", false, `pass --skip-schema-validation to "helm template" or "helm upgrade --install"`)
f.StringVar(&syncOptions.Cascade, "cascade", "", "pass cascade to helm exec, default: background")
f.StringVar(&syncOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default) or 'kubedog'")
f.StringVar(&syncOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default), 'helm-legacy' (Helm v4 only), or 'kubedog'")
f.IntVar(&syncOptions.TrackTimeout, "track-timeout", 0, `Timeout in seconds for kubedog tracking (0 to use default 300s timeout)`)
f.BoolVar(&syncOptions.TrackLogs, "track-logs", false, "Enable log streaming with kubedog tracking")

View File

@ -31,10 +31,18 @@ helmfile apply --track-mode kubedog --track-timeout 300 --track-logs
#### Configuration Options
- **`trackMode`**: Set to `kubedog` to enable kubedog tracking (default: `helm`)
- **`trackMode`**: Set to `kubedog` to enable kubedog tracking, or `helm-legacy` to use Helm v4's legacy wait mode (default: `helm`)
- **`trackTimeout`**: Timeout in seconds for tracking resources (default: 300)
- **`trackLogs`**: Enable real-time log streaming from tracked resources
#### Track Modes
Helmfile supports three track modes:
- **`helm`** (default): Uses Helm's built-in `--wait` flag for resource tracking
- **`helm-legacy`**: Uses Helm v4's `--wait=legacy` flag. This is useful when migrating from Helm v3 to Helm v4 and you have charts that may have compatibility issues with the new watcher-based wait mechanism (e.g., charts with `livenessProbe` but no `startupProbe`). Note: This mode only works with Helm v4; with Helm v3 it falls back to regular `--wait`.
- **`kubedog`**: Uses kubedog for advanced resource tracking with detailed feedback
#### Resource Filtering
Control which resources to track using whitelist/blacklist:
@ -86,9 +94,31 @@ Resource filtering follows this priority (highest to lowest):
- **Fine-grained control**: Track only the resources you care about
- **Better debugging**: Immediate visibility into deployment issues
#### Helm v4 Legacy Wait Mode
When using Helm v4 with charts that have broken `livenessProbe` configurations without `startupProbe`, the default `--wait=watcher` mode may fail. Helm v4 introduces `--wait=legacy` which uses the simpler polling mechanism compatible with Helm v3's behavior.
To use this mode, set `trackMode: helm-legacy`:
```yaml
releases:
- name: myapp
chart: ./charts/myapp
trackMode: helm-legacy
```
Or via command-line:
```bash
helmfile apply --track-mode helm-legacy
```
#### Compatibility
- Kubedog tracking is compatible with Helm 3.x
- **`helm`**: Default mode, uses Helm's built-in `--wait` flag
- **`helm-legacy`**: Uses Helm v4's `--wait=legacy` flag (only available in Helm v4)
- **`kubedog`**: Uses kubedog library for advanced resource tracking
- Kubedog tracking is compatible with Helm 3.x and 4.x
- Kubedog is a compiled dependency and is only used when `trackMode: kubedog` is set
- Works with charts that deploy supported workload kinds (currently `Deployment`, `StatefulSet`, `DaemonSet`, and `Job`); other resource kinds are created by Helm/Helmfile as usual but are ignored by the kubedog tracker

View File

@ -1,6 +1,9 @@
package config
import "fmt"
import (
"fmt"
"slices"
)
// ApplyOptoons is the options for the apply command
type ApplyOptions struct {
@ -305,8 +308,9 @@ func (a *ApplyImpl) TrackLogs() bool {
}
func (a *ApplyImpl) ValidateConfig() error {
if a.ApplyOptions.TrackMode != "" && a.ApplyOptions.TrackMode != "helm" && a.ApplyOptions.TrackMode != "kubedog" {
return fmt.Errorf("--track-mode must be 'helm' or 'kubedog', got: %s", a.ApplyOptions.TrackMode)
validTrackModes := []string{"helm", "helm-legacy", "kubedog"}
if a.ApplyOptions.TrackMode != "" && !slices.Contains(validTrackModes, a.ApplyOptions.TrackMode) {
return fmt.Errorf("--track-mode must be 'helm', 'helm-legacy', or 'kubedog', got: %s", a.ApplyOptions.TrackMode)
}
return a.GlobalImpl.ValidateConfig()
}

View File

@ -1,6 +1,9 @@
package config
import "fmt"
import (
"fmt"
"slices"
)
// SyncOptions is the options for the build command
type SyncOptions struct {
@ -212,8 +215,9 @@ func (t *SyncImpl) TrackLogs() bool {
}
func (t *SyncImpl) ValidateConfig() error {
if t.SyncOptions.TrackMode != "" && t.SyncOptions.TrackMode != "helm" && t.SyncOptions.TrackMode != "kubedog" {
return fmt.Errorf("--track-mode must be 'helm' or 'kubedog', got: %s", t.SyncOptions.TrackMode)
validTrackModes := []string{"helm", "helm-legacy", "kubedog"}
if t.SyncOptions.TrackMode != "" && !slices.Contains(validTrackModes, t.SyncOptions.TrackMode) {
return fmt.Errorf("--track-mode must be 'helm', 'helm-legacy', or 'kubedog', got: %s", t.SyncOptions.TrackMode)
}
return t.GlobalImpl.ValidateConfig()
}

View File

@ -9,8 +9,9 @@ import (
type TrackMode string
const (
TrackModeHelm TrackMode = "helm"
TrackModeKubedog TrackMode = "kubedog"
TrackModeHelm TrackMode = "helm"
TrackModeHelmLegacy TrackMode = "helm-legacy"
TrackModeKubedog TrackMode = "kubedog"
)
type TrackOptions struct {

View File

@ -186,6 +186,10 @@ func (st *HelmState) appendWaitForJobsFlags(flags []string, release *ReleaseSpec
}
func (st *HelmState) shouldUseKubedog(release *ReleaseSpec, ops *SyncOpts) bool {
return st.getTrackMode(release, ops) == string(kubedog.TrackModeKubedog)
}
func (st *HelmState) getTrackMode(release *ReleaseSpec, ops *SyncOpts) string {
trackMode := release.TrackMode
if trackMode == "" && ops != nil && ops.TrackMode != "" {
trackMode = ops.TrackMode
@ -193,24 +197,43 @@ func (st *HelmState) shouldUseKubedog(release *ReleaseSpec, ops *SyncOpts) bool
if trackMode == "" {
trackMode = st.HelmDefaults.TrackMode
}
return trackMode == "kubedog"
if trackMode == "" {
trackMode = string(kubedog.TrackModeHelm)
}
return trackMode
}
func (st *HelmState) appendWaitFlags(flags []string, release *ReleaseSpec, ops *SyncOpts) []string {
func (st *HelmState) appendWaitFlags(flags []string, helm helmexec.Interface, release *ReleaseSpec, ops *SyncOpts) []string {
if st.shouldUseKubedog(release, ops) {
return flags
}
shouldWait := false
switch {
case release.Wait != nil && *release.Wait:
flags = append(flags, "--wait")
shouldWait = true
case ops != nil && ops.Wait:
flags = append(flags, "--wait")
shouldWait = true
case release.Wait == nil && st.HelmDefaults.Wait:
flags = append(flags, "--wait")
shouldWait = true
}
// Note: --wait-retries flag has been removed from Helm and is no longer supported
// WaitRetries configuration is preserved for backward compatibility but ignored
if shouldWait {
trackMode := st.getTrackMode(release, ops)
if trackMode == string(kubedog.TrackModeHelmLegacy) {
if helm != nil && helm.IsHelm4() {
flags = append(flags, "--wait=legacy")
} else {
if st.logger != nil {
st.logger.Warnf("trackMode 'helm-legacy' requires Helm v4, falling back to regular --wait for release %s", release.Name)
}
flags = append(flags, "--wait")
}
} else {
flags = append(flags, "--wait")
}
}
return flags
}

View File

@ -1,6 +1,7 @@
package state
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
@ -77,6 +78,7 @@ func TestAppendWaitFlags(t *testing.T) {
release *ReleaseSpec
syncOpts *SyncOpts
helmSpec HelmSpec
helm helmexec.Interface
expected []string
}{
// --wait
@ -85,6 +87,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{Wait: &[]bool{true}[0]},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -92,6 +95,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{},
syncOpts: &SyncOpts{Wait: true},
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -99,6 +103,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{},
syncOpts: nil,
helmSpec: HelmSpec{Wait: true},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -106,6 +111,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{Wait: &[]bool{false}[0]},
syncOpts: nil,
helmSpec: HelmSpec{Wait: true},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{},
},
{
@ -113,6 +119,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{},
syncOpts: &SyncOpts{},
helmSpec: HelmSpec{Wait: true},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -120,6 +127,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{},
syncOpts: nil,
helmSpec: HelmSpec{Wait: false},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{},
},
// --wait-retries flag has been removed from Helm
@ -128,6 +136,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{Wait: &[]bool{true}[0], WaitRetries: &[]int{1}[0]},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -135,6 +144,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{Wait: &[]bool{true}[0], WaitRetries: &[]int{1}[0]},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -142,6 +152,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{WaitRetries: &[]int{1}[0]},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{},
},
{
@ -149,6 +160,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{},
syncOpts: &SyncOpts{Wait: true, WaitRetries: 2},
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -156,6 +168,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{},
syncOpts: nil,
helmSpec: HelmSpec{Wait: true, WaitRetries: 3},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -163,6 +176,7 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{Wait: &[]bool{true}[0]},
syncOpts: nil,
helmSpec: HelmSpec{WaitRetries: 4},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
@ -170,16 +184,82 @@ func TestAppendWaitFlags(t *testing.T) {
release: &ReleaseSpec{WaitRetries: &[]int{5}[0]},
syncOpts: nil,
helmSpec: HelmSpec{Wait: true},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
// helm-legacy track mode with Helm v4
{
name: "helm-legacy track mode with Helm v4",
release: &ReleaseSpec{Wait: &[]bool{true}[0], TrackMode: "helm-legacy"},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("4.0.0"),
expected: []string{"--wait=legacy"},
},
{
name: "helm-legacy track mode with Helm v4 from syncOpts",
release: &ReleaseSpec{Wait: &[]bool{true}[0]},
syncOpts: &SyncOpts{TrackMode: "helm-legacy"},
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("4.0.0"),
expected: []string{"--wait=legacy"},
},
{
name: "helm-legacy track mode with Helm v4 from helmDefaults",
release: &ReleaseSpec{Wait: &[]bool{true}[0]},
syncOpts: nil,
helmSpec: HelmSpec{TrackMode: "helm-legacy"},
helm: testutil.NewVersionHelmExec("4.0.0"),
expected: []string{"--wait=legacy"},
},
{
name: "helm-legacy track mode with Helm v3 shows warning and uses --wait",
release: &ReleaseSpec{Wait: &[]bool{true}[0], Name: "test-release", TrackMode: "helm-legacy"},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("3.15.0"),
expected: []string{"--wait"},
},
{
name: "helm-legacy track mode without wait flag",
release: &ReleaseSpec{Wait: &[]bool{false}[0], Name: "test-release", TrackMode: "helm-legacy"},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("4.0.0"),
expected: []string{},
},
{
name: "helm track mode with Helm v4 uses --wait",
release: &ReleaseSpec{Wait: &[]bool{true}[0], TrackMode: "helm"},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("4.0.0"),
expected: []string{"--wait"},
},
{
name: "kubedog track mode skips --wait",
release: &ReleaseSpec{Wait: &[]bool{true}[0], TrackMode: "kubedog"},
syncOpts: nil,
helmSpec: HelmSpec{},
helm: testutil.NewVersionHelmExec("4.0.0"),
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buffer bytes.Buffer
st := &HelmState{}
st.HelmDefaults = tt.helmSpec
got := st.appendWaitFlags([]string{}, tt.release, tt.syncOpts)
st.logger = helmexec.NewLogger(&buffer, "debug")
got := st.appendWaitFlags([]string{}, tt.helm, tt.release, tt.syncOpts)
require.Equalf(t, tt.expected, got, "appendWaitFlags() = %v, want %v", got, tt.expected)
// Check for warning message when helm-legacy is used with Helm v3
if tt.name == "helm-legacy track mode with Helm v3 shows warning and uses --wait" {
require.Contains(t, buffer.String(), "trackMode 'helm-legacy' requires Helm v4")
require.Contains(t, buffer.String(), tt.release.Name)
}
})
}
}

View File

@ -3439,7 +3439,7 @@ func (st *HelmState) flagsForUpgrade(helm helmexec.Interface, release *ReleaseSp
flags = st.appendChartVersionFlags(flags, release)
flags = st.appendEnableDNSFlags(flags, release)
flags = st.appendWaitFlags(flags, release, opt)
flags = st.appendWaitFlags(flags, helm, release, opt)
flags = st.appendWaitForJobsFlags(flags, release, opt)
// non-OCI chart should be verified here