add trackLogsInterval option (#2825)

* add trackLogsInterval option

Signed-off-by: pznamensky <kompastver@gmail.com>

* test: add coverage for trackLogsInterval option

The trackLogsInterval feature shipped without tests. Add coverage for:

- config getter: zero when --track-logs-interval is not supplied, explicit
  value (including an explicit default) when supplied, and the nil-Cmd
  fallback for non-cobra callers (pkg/config/apply_test.go, sync_test.go)
- buildReleaseTracker resolution: explicit CLI interval overrides the
  release setting, release setting used when the flag is absent, 10s
  default preserved when both are unset (pkg/state/helmx_test.go)
- release-level minimum validation in ExecuteTemplates: below/zero/
  negative rejected, 1s and above accepted (pkg/state/state_exec_tmpl_test.go)

Also add the missing blank line after syncImpl.Cmd in cmd/sync.go for
consistency with cmd/apply.go.

Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: pznamensky <kompastver@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
This commit is contained in:
pznamensky
2026-10-10 14:13:10 +08:00
committed by GitHub
co-authored by yxxhero
parent 3216c02743
commit 12fb6887c0
14 changed files with 266 additions and 12 deletions
+2 -1
View File
@@ -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"`)
+3 -1
View File
@@ -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"`)
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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 |
+7 -1
View File
@@ -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
}
+56
View File
@@ -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())
})
}
}
+7 -1
View File
@@ -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
}
+56
View File
@@ -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())
})
}
}
+2
View File
@@ -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{
+56
View File
@@ -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)
})
}
}
+2
View File
@@ -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
+4
View File
@@ -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
+61
View File
@@ -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
+6 -6
View File
@@ -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 {