diff --git a/cmd/apply.go b/cmd/apply.go index 93213b2a..aa399737 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -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") diff --git a/cmd/sync.go b/cmd/sync.go index 0e7b795b..376b1938 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -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") diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 1c05c9d7..555c6bfc 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -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 diff --git a/pkg/config/apply.go b/pkg/config/apply.go index dbf36ce3..f97d712c 100644 --- a/pkg/config/apply.go +++ b/pkg/config/apply.go @@ -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() } diff --git a/pkg/config/sync.go b/pkg/config/sync.go index 87a005b6..79a90eb6 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -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() } diff --git a/pkg/kubedog/options.go b/pkg/kubedog/options.go index a5b90752..8e3293cc 100644 --- a/pkg/kubedog/options.go +++ b/pkg/kubedog/options.go @@ -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 { diff --git a/pkg/state/helmx.go b/pkg/state/helmx.go index 62692e00..779bffd2 100644 --- a/pkg/state/helmx.go +++ b/pkg/state/helmx.go @@ -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 } diff --git a/pkg/state/helmx_test.go b/pkg/state/helmx_test.go index 78c0c3d1..80b8d5fe 100644 --- a/pkg/state/helmx_test.go +++ b/pkg/state/helmx_test.go @@ -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) + } }) } } diff --git a/pkg/state/state.go b/pkg/state/state.go index 219b564f..e8d16a32 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -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