diff --git a/pkg/action/install.go b/pkg/action/install.go index 6fc919366..1a0426d8a 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -100,12 +100,14 @@ type Install struct { Devel bool DependencyUpdate bool Timeout time.Duration - Namespace string - ReleaseName string - GenerateName bool - NameTemplate string - Description string - OutputDir string + // waitProgress is called immediately before waiting for resources. + waitProgress func(time.Duration) + Namespace string + ReleaseName string + GenerateName bool + NameTemplate string + Description string + OutputDir string // RollbackOnFailure enables rolling back (uninstalling) the release on failure if set RollbackOnFailure bool SkipCRDs bool @@ -182,6 +184,11 @@ func (i *Install) GetRegistryClient() *registry.Client { return i.registryClient } +// SetWaitProgress configures a callback invoked immediately before waiting for resources. +func (i *Install) SetWaitProgress(waitProgress func(time.Duration)) { + i.waitProgress = waitProgress +} + func (i *Install) installCRDs(crds []chart.CRD) error { // We do these one file at a time in the order they were read. totalItems := []*resource.Info{} @@ -543,6 +550,10 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted, resources ku return rel, fmt.Errorf("failed to get waiter: %w", err) } + if i.waitProgress != nil { + i.waitProgress(i.Timeout) + } + if i.WaitForJobs { err = waiter.WaitWithJobs(resources, i.Timeout) } else { diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 2d83abe27..209678864 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -618,6 +618,11 @@ func TestInstallRelease_Wait(t *testing.T) { failer.WaitError = errors.New("I timed out") instAction.cfg.KubeClient = failer instAction.WaitStrategy = kube.StatusWatcherStrategy + progressCalls := 0 + instAction.SetWaitProgress(func(timeout time.Duration) { + assert.Equal(t, instAction.Timeout, timeout) + progressCalls++ + }) vals := map[string]any{} goroutines := instAction.getGoroutineCount() @@ -628,6 +633,7 @@ func TestInstallRelease_Wait(t *testing.T) { req.NoError(err) is.Contains(res.Info.Description, "I timed out") is.Equal(rcommon.StatusFailed, res.Info.Status) + is.Equal(1, progressCalls) is.Equal(goroutines, instAction.getGoroutineCount()) } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 85904124b..07b1c6511 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -76,6 +76,8 @@ type Upgrade struct { WaitOptions []kube.WaitOption // WaitForJobs determines whether the wait operation for the Jobs should be performed after the upgrade is requested. WaitForJobs bool + // waitProgress is called immediately before waiting for resources. + waitProgress func(time.Duration) // DisableHooks disables hook processing if set to true. DisableHooks bool // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster @@ -160,6 +162,11 @@ func (u *Upgrade) SetRegistryClient(client *registry.Client) { u.registryClient = client } +// SetWaitProgress configures a callback invoked immediately before waiting for resources. +func (u *Upgrade) SetWaitProgress(waitProgress func(time.Duration)) { + u.waitProgress = waitProgress +} + // Run executes the upgrade on the given release. func (u *Upgrade) Run(name string, chart chart.Charter, vals map[string]any) (ri.Releaser, error) { ctx := context.Background() @@ -489,6 +496,10 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) return } + if u.waitProgress != nil { + u.waitProgress(u.Timeout) + } + if u.WaitForJobs { if err := waiter.WaitWithJobs(target, u.Timeout); err != nil { u.cfg.recordRelease(originalRelease) diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index 53419b6a8..70d088bb9 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -94,6 +94,11 @@ func TestUpgradeRelease_Wait(t *testing.T) { failer.WaitError = errors.New("I timed out") upAction.cfg.KubeClient = failer upAction.WaitStrategy = kube.StatusWatcherStrategy + progressCalls := 0 + upAction.SetWaitProgress(func(timeout time.Duration) { + assert.Equal(t, upAction.Timeout, timeout) + progressCalls++ + }) vals := map[string]any{} resi, err := upAction.Run(rel.Name, buildChart(), vals) @@ -102,6 +107,7 @@ func TestUpgradeRelease_Wait(t *testing.T) { req.NoError(err) is.Contains(res.Info.Description, "I timed out") is.Equal(common.StatusFailed, res.Info.Status) + is.Equal(1, progressCalls) } func TestUpgradeRelease_WaitForJobs(t *testing.T) { diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 182b4a7ff..ed01a5c67 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -38,6 +38,7 @@ import ( "helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/downloader" "helm.sh/helm/v4/pkg/getter" + "helm.sh/helm/v4/pkg/kube" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -157,6 +158,8 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.DryRunStrategy = dryRunStrategy + configureWaitProgress(client, out, outfmt) + rel, err := runInstall(args, client, valueOpts, out) if err != nil { return fmt.Errorf("INSTALLATION FAILED: %w", err) @@ -370,6 +373,29 @@ func checkIfInstallable(ch chart.Accessor) error { return fmt.Errorf("%s charts are not installable", meta["Type"]) } +func configureWaitProgress(client *action.Install, out io.Writer, outfmt output.Format) { + client.SetWaitProgress(nil) + if outfmt != output.Table || client.DryRunStrategy != action.DryRunNone { + return + } + if client.WaitStrategy == kube.HookOnlyStrategy && !client.RollbackOnFailure { + return + } + client.SetWaitProgress(func(timeout time.Duration) { + printWaitMessage(out, outfmt, kube.StatusWatcherStrategy, false, action.DryRunNone, timeout) + }) +} + +func printWaitMessage(out io.Writer, outfmt output.Format, strategy kube.WaitStrategy, rollbackOnFailure bool, dryRun action.DryRunStrategy, timeout time.Duration) { + if strategy == kube.HookOnlyStrategy && rollbackOnFailure { + strategy = kube.StatusWatcherStrategy + } + if outfmt != output.Table || strategy == kube.HookOnlyStrategy || dryRun != action.DryRunNone { + return + } + fmt.Fprintf(out, "Waiting for resources to become ready (timeout: %s)\n", timeout) +} + // Provide dynamic auto-completion for the install and template commands func compInstall(args []string, toComplete string, client *action.Install) ([]string, cobra.ShellCompDirective) { requiredArgs := 1 diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index d1e081e5c..59092efa9 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -17,14 +17,19 @@ limitations under the License. package cmd import ( + "bytes" "fmt" "net/http" "net/http/httptest" "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/cli/output" + "helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/repo/v1/repotest" ) @@ -323,3 +328,56 @@ func TestInstallFileCompletion(t *testing.T) { checkFileCompletion(t, "install myname", true) checkFileCompletion(t, "install myname mychart", false) } + +func TestPrintWaitMessage(t *testing.T) { + const message = "Waiting for resources to become ready (timeout: 42s)\n" + tests := []struct { + name string + format output.Format + strategy kube.WaitStrategy + rollbackOnFailure bool + dryRun action.DryRunStrategy + want string + }{ + {name: "explicit watcher wait", format: output.Table, strategy: kube.StatusWatcherStrategy, dryRun: action.DryRunNone, want: message}, + {name: "implicit watcher wait", format: output.Table, strategy: kube.HookOnlyStrategy, rollbackOnFailure: true, dryRun: action.DryRunNone, want: message}, + {name: "hook-only wait", format: output.Table, strategy: kube.HookOnlyStrategy, dryRun: action.DryRunNone}, + {name: "structured output", format: output.JSON, strategy: kube.StatusWatcherStrategy, dryRun: action.DryRunNone}, + {name: "client dry run", format: output.Table, strategy: kube.StatusWatcherStrategy, dryRun: action.DryRunClient}, + {name: "server dry run", format: output.Table, strategy: kube.StatusWatcherStrategy, dryRun: action.DryRunServer}, + {name: "implicit wait client dry run", format: output.Table, strategy: kube.HookOnlyStrategy, rollbackOnFailure: true, dryRun: action.DryRunClient}, + {name: "implicit wait server dry run", format: output.Table, strategy: kube.HookOnlyStrategy, rollbackOnFailure: true, dryRun: action.DryRunServer}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var out bytes.Buffer + printWaitMessage(&out, tt.format, tt.strategy, tt.rollbackOnFailure, tt.dryRun, 42*time.Second) + require.Equal(t, tt.want, out.String()) + }) + } +} + +func TestInstallImplicitWaitProgress(t *testing.T) { + for _, flag := range []string{"--rollback-on-failure", "--atomic"} { + t.Run(flag, func(t *testing.T) { + defer resetEnv()() + + _, out, err := executeActionCommand("install implicit-wait testdata/testcharts/empty " + flag) + require.NoError(t, err) + require.Contains(t, out, "Waiting for resources to become ready (timeout: 5m0s)\n") + }) + } +} + +func TestInstallDryRunSuppressesWaitProgress(t *testing.T) { + for _, dryRun := range []string{"client", "server"} { + t.Run(dryRun, func(t *testing.T) { + defer resetEnv()() + + _, out, err := executeActionCommand("install dry-run-wait testdata/testcharts/empty --rollback-on-failure --dry-run=" + dryRun) + require.NoError(t, err) + require.NotContains(t, out, "Waiting for resources to become ready") + }) + } +} diff --git a/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt b/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt index c5676c610..67e223847 100644 --- a/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt +++ b/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt @@ -1,3 +1,4 @@ +Waiting for resources to become ready (timeout: 5m0s) NAME: apollo LAST DEPLOYED: Fri Sep 2 22:04:05 1977 NAMESPACE: default diff --git a/pkg/cmd/testdata/output/install-with-wait.txt b/pkg/cmd/testdata/output/install-with-wait.txt index c5676c610..67e223847 100644 --- a/pkg/cmd/testdata/output/install-with-wait.txt +++ b/pkg/cmd/testdata/output/install-with-wait.txt @@ -1,3 +1,4 @@ +Waiting for resources to become ready (timeout: 5m0s) NAME: apollo LAST DEPLOYED: Fri Sep 2 22:04:05 1977 NAMESPACE: default diff --git a/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt b/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt index 21784413c..44524d8df 100644 --- a/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt +++ b/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt @@ -1,3 +1,4 @@ +Waiting for resources to become ready (timeout: 5m0s) Release "crazy-bunny" has been upgraded. Happy Helming! NAME: crazy-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 diff --git a/pkg/cmd/testdata/output/upgrade-with-wait.txt b/pkg/cmd/testdata/output/upgrade-with-wait.txt index 21784413c..44524d8df 100644 --- a/pkg/cmd/testdata/output/upgrade-with-wait.txt +++ b/pkg/cmd/testdata/output/upgrade-with-wait.txt @@ -1,3 +1,4 @@ +Waiting for resources to become ready (timeout: 5m0s) Release "crazy-bunny" has been upgraded. Happy Helming! NAME: crazy-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 688842141..28b514311 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -38,6 +38,7 @@ import ( "helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/downloader" "helm.sh/helm/v4/pkg/getter" + "helm.sh/helm/v4/pkg/kube" ri "helm.sh/helm/v4/pkg/release" "helm.sh/helm/v4/pkg/release/common" "helm.sh/helm/v4/pkg/storage/driver" @@ -82,6 +83,19 @@ which can contain sensitive values. To hide Kubernetes Secrets use the --hide-secret flag. Please carefully consider how and when these flags are used. ` +func configureUpgradeWaitProgress(client *action.Upgrade, out io.Writer, outfmt output.Format) { + client.SetWaitProgress(nil) + if outfmt != output.Table || client.DryRunStrategy != action.DryRunNone { + return + } + if client.WaitStrategy == kube.HookOnlyStrategy && !client.RollbackOnFailure { + return + } + client.SetWaitProgress(func(timeout time.Duration) { + printWaitMessage(out, outfmt, kube.StatusWatcherStrategy, false, action.DryRunNone, timeout) + }) +} + func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewUpgrade(cfg) client.WaitOptions = append(client.WaitOptions, defaultCLIWaitOptions()...) @@ -163,6 +177,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { instClient.Replace = true } + configureWaitProgress(instClient, out, outfmt) + rel, err := runInstall(args, instClient, valueOpts, out) if err != nil { return err @@ -257,6 +273,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { cancel() }() + configureUpgradeWaitProgress(client, out, outfmt) + rel, err := client.RunWithContext(ctx, args[0], ch, vals) if err != nil { return fmt.Errorf("UPGRADE FAILED: %w", err) diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index 5aba7b11b..3a16d8323 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -587,3 +587,34 @@ func TestUpgradeInstallServerSideApply(t *testing.T) { }) } } + +func TestUpgradeImplicitWaitProgress(t *testing.T) { + tests := []struct { + name string + install bool + }{ + {name: "upgrade"}, + {name: "upgrade install", install: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer resetEnv()() + + releaseName := "implicit-wait" + relMock, ch, chartPath := prepareMockRelease(t, releaseName) + store := storageFixture() + installFlag := "" + if tt.install { + installFlag = "--install" + } else { + require.NoError(t, store.Create(relMock(releaseName, 1, ch))) + } + + cmd := fmt.Sprintf("upgrade %s %s --rollback-on-failure '%s'", releaseName, installFlag, chartPath) + _, out, err := executeActionCommandC(store, cmd) + require.NoError(t, err) + assert.Contains(t, out, "Waiting for resources to become ready (timeout: 5m0s)\n") + }) + } +}