diff --git a/cmd/apply.go b/cmd/apply.go index 7e4b8aa5..23144674 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -16,6 +16,7 @@ func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command { Short: "Apply all resources from state file only when there are changes", RunE: func(cmd *cobra.Command, args []string) error { applyImpl := config.NewApplyImpl(globalCfg, applyOptions) + applyImpl.Cmd = cmd err := config.NewCLIConfigImpl(applyImpl.GlobalImpl) if err != nil { @@ -77,7 +78,7 @@ func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.BoolVar(&applyOptions.TrackLogsUntilReady, "track-logs-until-ready", false, "Stop pod log streaming when each pod becomes ready (requires --track-logs)") f.BoolVar(&applyOptions.TrackFailedLogs, "track-failed-logs", false, "Enable log streaming with kubedog tracking, but only emit logs for pods that enter a failed state. Overridden by --track-logs when both are set") f.DurationVar(&applyOptions.TrackLogsInterval, "track-logs-interval", applyOptions.TrackLogsInterval, - "Interval between kubedog log output updates (minimum 1s)") + "Interval between kubedog log output updates (minimum 1s); overrides trackLogsInterval") f.IntVar(&applyOptions.HelmStuckGrace, "helm-stuck-grace", 0, "When using --track-mode kubedog: if the cluster confirms all tracked resources have converged but the helm subprocess is still running, wait this many seconds before sending SIGINT to helm. Recovers from helm v4 hook waiter wedges. May leave the release secret in pending-install state requiring manual cleanup. 0 disables.") f.BoolVar(&applyOptions.TrackFailOnError, "track-fail-on-error", false, "Fail with non-zero exit code when kubedog tracking fails") f.StringVar(&applyOptions.Description, "description", "", `Set description for all releases. If set, overridesdescriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`) diff --git a/cmd/sync.go b/cmd/sync.go index f25a8927..33808c2e 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -16,6 +16,8 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command { Short: "Sync releases defined in state file", RunE: func(cmd *cobra.Command, args []string) error { syncImpl := config.NewSyncImpl(globalCfg, syncOptions) + syncImpl.Cmd = cmd + err := config.NewCLIConfigImpl(syncImpl.GlobalImpl) if err != nil { return err @@ -61,7 +63,7 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.BoolVar(&syncOptions.TrackLogsUntilReady, "track-logs-until-ready", false, "Stop pod log streaming when each pod becomes ready (requires --track-logs)") f.BoolVar(&syncOptions.TrackFailedLogs, "track-failed-logs", false, "Enable log streaming with kubedog tracking, but only emit logs for pods that enter a failed state. Overridden by --track-logs when both are set") f.DurationVar(&syncOptions.TrackLogsInterval, "track-logs-interval", syncOptions.TrackLogsInterval, - "Interval between kubedog log output updates (minimum 1s)") + "Interval between kubedog log output updates (minimum 1s); overrides trackLogsInterval") f.IntVar(&syncOptions.HelmStuckGrace, "helm-stuck-grace", 0, "When using --track-mode kubedog: if the cluster confirms all tracked resources have converged but the helm subprocess is still running, wait this many seconds before sending SIGINT to helm. Recovers from helm v4 hook waiter wedges. May leave the release secret in pending-install state requiring manual cleanup. 0 disables.") f.BoolVar(&syncOptions.TrackFailOnError, "track-fail-on-error", false, "Fail with non-zero exit code when kubedog tracking fails") f.StringVar(&syncOptions.Description, "description", "", `Set description for all releases. If set, overrides descriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index d3562662..01650103 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -34,12 +34,13 @@ helmfile apply --track-mode kubedog --track-timeout 300 --track-logs --track-log - **`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`**: Print logs from tracked resources during deployment +- **`trackLogsInterval`**: Interval between log output updates, as a duration such as `1s` or `30s` (default: `10s`; minimum: `1s`) - **`trackLogsUntilReady`**: With `trackLogs: true`, stop routine pod logs once each pod first becomes ready (default: false) - **`trackFailedLogs`**: Print collected logs only for pods that fail during deployment To see logs only for failed pods, set `trackFailedLogs: true` on a release or use `--track-failed-logs` with `helmfile apply` or `helmfile sync`. If both log options are enabled, logs from all tracked pods are printed. -With kubedog tracking and either log option enabled, `helmfile apply` and `helmfile sync` print collected logs every 10 seconds by default. Use `--track-logs-interval` to change this interval (for example, `1s` above). The minimum is `1s`. Deployment progress is checked for changes every 10 seconds. +With kubedog tracking and either `trackLogs` or `trackFailedLogs` enabled, `helmfile apply` and `helmfile sync` print collected logs every 10 seconds by default. Set `trackLogsInterval` on a release or use `--track-logs-interval` to change this interval (for example, `1s` above). An explicitly supplied command-line flag overrides the release setting; otherwise, the release setting takes precedence over the `10s` default. The minimum is `1s`. Deployment progress is checked for changes every 10 seconds. ### Startup-Only Pod Logs diff --git a/docs/configuration.md b/docs/configuration.md index 7f81066f..7c273107 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -514,7 +514,8 @@ See [Advanced Features](advanced-features.md#resource-tracking-with-kubedog) for |-------|------|---------|-------------| | `trackMode` | string | `""` | Track mode: `helm`, `helm-legacy`, or `kubedog` | | `trackTimeout` | int | 300 | Tracking timeout in seconds | -| `trackLogs` | bool | false | Print logs from tracked resources during deployment (every 10 seconds by default; see [`--track-logs-interval`](advanced-features.md#resource-tracking-with-kubedog)) | +| `trackLogs` | bool | false | Print logs from tracked resources during deployment | +| `trackLogsInterval` | duration | `10s` | Interval between log output updates for `trackLogs` or `trackFailedLogs` (e.g. `1s`; minimum `1s`); overrides the default, but an explicitly supplied `--track-logs-interval` takes precedence | | `trackLogsUntilReady` | bool | false | With `trackLogs: true`, stop routine pod logs once each pod first becomes ready; readiness and failure tracking continue. Job logs are unaffected | | `trackFailedLogs` | bool | false | Print collected logs only for pods that fail during deployment; overridden by `trackLogs` | | `trackKinds` | list | | Whitelist of resource kinds to track | diff --git a/pkg/config/apply.go b/pkg/config/apply.go index 7477354c..867fecd8 100644 --- a/pkg/config/apply.go +++ b/pkg/config/apply.go @@ -4,6 +4,8 @@ import ( "fmt" "slices" "time" + + "github.com/spf13/cobra" ) // ApplyOptoons is the options for the apply command @@ -121,6 +123,7 @@ func NewApplyOptions() *ApplyOptions { type ApplyImpl struct { *GlobalImpl *ApplyOptions + Cmd *cobra.Command } // NewApplyImpl creates a new ApplyImpl @@ -356,8 +359,11 @@ func (a *ApplyImpl) TrackFailedLogs() bool { return a.ApplyOptions.TrackFailedLogs } -// TrackLogsInterval returns the kubedog log output interval. +// TrackLogsInterval returns the CLI interval, or zero when the flag was not supplied. func (a *ApplyImpl) TrackLogsInterval() time.Duration { + if a.Cmd != nil && !a.Cmd.Flags().Changed("track-logs-interval") { + return 0 + } return a.ApplyOptions.TrackLogsInterval } diff --git a/pkg/config/apply_test.go b/pkg/config/apply_test.go new file mode 100644 index 00000000..3cd1dfdb --- /dev/null +++ b/pkg/config/apply_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestApplyImpl_TrackLogsInterval(t *testing.T) { + tests := []struct { + name string + setFlag string // explicit --track-logs-interval value; empty means not supplied + noCmd bool // construct ApplyImpl without a Cmd, like non-cobra callers + want time.Duration + }{ + { + name: "zero when flag not supplied", + want: 0, + }, + { + name: "explicit value returned when flag supplied", + setFlag: "3s", + want: 3 * time.Second, + }, + { + name: "explicit default value still counts as supplied", + setFlag: "10s", + want: 10 * time.Second, + }, + { + name: "nil cmd falls back to option value", + noCmd: true, + want: defaultTrackLogsInterval, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + options := NewApplyOptions() + cmd := &cobra.Command{} + cmd.Flags().DurationVar(&options.TrackLogsInterval, "track-logs-interval", options.TrackLogsInterval, "") + if tt.setFlag != "" { + require.NoError(t, cmd.Flags().Set("track-logs-interval", tt.setFlag)) + } + + impl := NewApplyImpl(nil, options) + if !tt.noCmd { + impl.Cmd = cmd + } + require.Equal(t, tt.want, impl.TrackLogsInterval()) + }) + } +} diff --git a/pkg/config/sync.go b/pkg/config/sync.go index e32c2c90..c32564e1 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -4,6 +4,8 @@ import ( "fmt" "slices" "time" + + "github.com/spf13/cobra" ) // defaultTrackLogsInterval is the default --track-logs-interval shared by @@ -110,6 +112,7 @@ func NewSyncOptions() *SyncOptions { type SyncImpl struct { *GlobalImpl *SyncOptions + Cmd *cobra.Command } // NewSyncImpl creates a new SyncImpl @@ -269,8 +272,11 @@ func (t *SyncImpl) TrackFailedLogs() bool { return t.SyncOptions.TrackFailedLogs } -// TrackLogsInterval returns the kubedog log output interval. +// TrackLogsInterval returns the CLI interval, or zero when the flag was not supplied. func (t *SyncImpl) TrackLogsInterval() time.Duration { + if t.Cmd != nil && !t.Cmd.Flags().Changed("track-logs-interval") { + return 0 + } return t.SyncOptions.TrackLogsInterval } diff --git a/pkg/config/sync_test.go b/pkg/config/sync_test.go new file mode 100644 index 00000000..3bb715d0 --- /dev/null +++ b/pkg/config/sync_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestSyncImpl_TrackLogsInterval(t *testing.T) { + tests := []struct { + name string + setFlag string // explicit --track-logs-interval value; empty means not supplied + noCmd bool // construct SyncImpl without a Cmd, like non-cobra callers + want time.Duration + }{ + { + name: "zero when flag not supplied", + want: 0, + }, + { + name: "explicit value returned when flag supplied", + setFlag: "3s", + want: 3 * time.Second, + }, + { + name: "explicit default value still counts as supplied", + setFlag: "10s", + want: 10 * time.Second, + }, + { + name: "nil cmd falls back to option value", + noCmd: true, + want: defaultTrackLogsInterval, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + options := NewSyncOptions() + cmd := &cobra.Command{} + cmd.Flags().DurationVar(&options.TrackLogsInterval, "track-logs-interval", options.TrackLogsInterval, "") + if tt.setFlag != "" { + require.NoError(t, cmd.Flags().Set("track-logs-interval", tt.setFlag)) + } + + impl := NewSyncImpl(nil, options) + if !tt.noCmd { + impl.Cmd = cmd + } + require.Equal(t, tt.want, impl.TrackLogsInterval()) + }) + } +} diff --git a/pkg/state/helmx.go b/pkg/state/helmx.go index 72ee26bc..8a7476e0 100644 --- a/pkg/state/helmx.go +++ b/pkg/state/helmx.go @@ -338,6 +338,8 @@ func (st *HelmState) buildReleaseTracker(release *ReleaseSpec, opts *SyncOpts, u WithColor(useColor) if opts != nil && opts.TrackLogsInterval > 0 { trackOpts.WithLogsInterval(opts.TrackLogsInterval) + } else if release.TrackLogsInterval != nil { + trackOpts.WithLogsInterval(*release.TrackLogsInterval) } tracker, err := kubedog.NewTracker(&kubedog.TrackerConfig{ diff --git a/pkg/state/helmx_test.go b/pkg/state/helmx_test.go index 02327f00..b0a85107 100644 --- a/pkg/state/helmx_test.go +++ b/pkg/state/helmx_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" @@ -859,3 +860,58 @@ current-context: test }) } } + +func TestBuildReleaseTracker_LogsInterval(t *testing.T) { + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") + require.NoError(t, os.WriteFile(kubeconfig, []byte(`apiVersion: v1 +kind: Config +clusters: +- name: test + cluster: + server: http://127.0.0.1:1 +contexts: +- name: test + context: + cluster: test +current-context: test +`), 0600)) + + tests := []struct { + name string + release string + opts *SyncOpts + want time.Duration + }{ + { + name: "10s default when unset", + want: 10 * time.Second, + }, + { + name: "explicit cli interval overrides release setting", + release: "trackLogsInterval: 2s\n", + opts: &SyncOpts{TrackLogsInterval: 30 * time.Second}, + want: 30 * time.Second, + }, + { + name: "release interval used when cli flag absent", + release: "trackLogsInterval: 2s\n", + want: 2 * time.Second, + }, + { + name: "cli interval used when release unset", + opts: &SyncOpts{TrackLogsInterval: 3 * time.Second}, + want: 3 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var release ReleaseSpec + require.NoError(t, yaml.Unmarshal([]byte(tt.release), &release)) + st := &HelmState{kubeconfig: kubeconfig} + _, opts, err := st.buildReleaseTracker(&release, tt.opts, false) + require.NoError(t, err) + require.Equal(t, tt.want, opts.LogsInterval) + }) + } +} diff --git a/pkg/state/state.go b/pkg/state/state.go index 247860f9..d100a3b5 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -574,6 +574,8 @@ type ReleaseSpec struct { TrackTimeout *int `yaml:"trackTimeout,omitempty"` // TrackLogs enables log streaming with kubedog TrackLogs *bool `yaml:"trackLogs,omitempty"` + // TrackLogsInterval specifies how often captured logs are printed. The minimum is 1s. + TrackLogsInterval *time.Duration `yaml:"trackLogsInterval,omitempty"` // TrackLogsUntilReady stops pod log streaming when each pod becomes ready. Requires TrackLogs. TrackLogsUntilReady *bool `yaml:"trackLogsUntilReady,omitempty"` // TrackFailedLogs streams logs only for pods that enter a failed state diff --git a/pkg/state/state_exec_tmpl.go b/pkg/state/state_exec_tmpl.go index 5dc78b87..47caa464 100644 --- a/pkg/state/state_exec_tmpl.go +++ b/pkg/state/state_exec_tmpl.go @@ -7,6 +7,7 @@ import ( "reflect" "slices" "strings" + "time" "dario.cat/mergo" @@ -147,6 +148,9 @@ func (st *HelmState) ExecuteTemplates() (*HelmState, error) { if st.Releases[i].Chart == "" { return nil, fmt.Errorf("encountered empty chart while reading release %q", st.Releases[i].Name) } + if interval := st.Releases[i].TrackLogsInterval; interval != nil && *interval < time.Second { + return nil, fmt.Errorf("release %q: trackLogsInterval must be at least 1s, got: %s", st.Releases[i].Name, *interval) + } } return &r, nil diff --git a/pkg/state/state_exec_tmpl_test.go b/pkg/state/state_exec_tmpl_test.go index df2c0e8c..c8a94754 100644 --- a/pkg/state/state_exec_tmpl_test.go +++ b/pkg/state/state_exec_tmpl_test.go @@ -5,8 +5,10 @@ import ( "reflect" "strings" "testing" + "time" "github.com/go-test/deep" + "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/helmfile/helmfile/pkg/environment" @@ -277,6 +279,65 @@ func TestHelmState_executeTemplates(t *testing.T) { } } +func TestHelmState_executeTemplatesTrackLogsIntervalValidation(t *testing.T) { + tests := []struct { + name string + interval time.Duration + wantErr string + }{ + { + name: "minimum 1s is valid", + interval: time.Second, + }, + { + name: "above minimum is valid", + interval: 5 * time.Second, + }, + { + name: "below minimum is rejected", + interval: 500 * time.Millisecond, + wantErr: `release "foo": trackLogsInterval must be at least 1s, got: 500ms`, + }, + { + name: "zero is rejected", + interval: 0, + wantErr: `release "foo": trackLogsInterval must be at least 1s, got: 0s`, + }, + { + name: "negative is rejected", + interval: -time.Second, + wantErr: `release "foo": trackLogsInterval must be at least 1s, got: -1s`, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + interval := tt.interval + st := &HelmState{ + fs: &filesystem.FileSystem{Glob: func(s string) ([]string, error) { return nil, nil }}, + basePath: ".", + Env: environment.Environment{Name: "test_env"}, + Releases: []ReleaseSpec{ + { + Name: "foo", + Chart: "incubator/raw", + TrackLogsInterval: &interval, + }, + }, + RenderedValues: map[string]any{}, + } + + _, err := st.ExecuteTemplates() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestHelmState_recursiveRefsTemplates(t *testing.T) { tests := []struct { name string diff --git a/pkg/state/temp_test.go b/pkg/state/temp_test.go index e4f35a27..7c2438d9 100644 --- a/pkg/state/temp_test.go +++ b/pkg/state/temp_test.go @@ -42,39 +42,39 @@ func TestGenerateID(t *testing.T) { run(testcase{ subject: "baseline", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, - want: "foo-values-699f8dfb8f", + want: "foo-values-66b6b57c5", }) run(testcase{ subject: "different bytes content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: []byte(`{"k":"v"}`), - want: "foo-values-8484b97b54", + want: "foo-values-764568cb", }) run(testcase{ subject: "different map content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: map[string]any{"k": "v"}, - want: "foo-values-6d74854796", + want: "foo-values-78dcc498c4", }) run(testcase{ subject: "different chart", release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"}, - want: "foo-values-6c75d94b64", + want: "foo-values-788fc9bdf8", }) run(testcase{ subject: "different name", release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"}, - want: "bar-values-fdc66db9d", + want: "bar-values-6b4546d548", }) run(testcase{ subject: "specific ns", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"}, - want: "myns-foo-values-74d9576cdb", + want: "myns-foo-values-75b997c6dd", }) for id, n := range ids {