From f21b143befbca1a6d930ba4ac95c2e7d2c56d3c2 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Sun, 4 May 2025 12:41:40 -0700 Subject: [PATCH] refactor: Replace action 'DryRun' string with DryRunStrategy type + deprecations Signed-off-by: George Jenkins --- pkg/action/action.go | 25 ++++++ pkg/action/action_test.go | 12 +++ pkg/action/install.go | 42 ++++------ pkg/action/install_test.go | 60 +++++++------- pkg/action/rollback.go | 12 +-- pkg/action/upgrade.go | 30 ++----- pkg/action/upgrade_test.go | 4 +- pkg/cmd/helpers.go | 83 ++++++++++++++++++++ pkg/cmd/helpers_test.go | 156 +++++++++++++++++++++++++++++++++++++ pkg/cmd/install.go | 41 +++------- pkg/cmd/rollback.go | 10 ++- pkg/cmd/template.go | 26 ++++--- pkg/cmd/upgrade.go | 22 ++---- 13 files changed, 374 insertions(+), 149 deletions(-) create mode 100644 pkg/cmd/helpers.go diff --git a/pkg/action/action.go b/pkg/action/action.go index 1aa9f9d19..a3db047b4 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -70,6 +70,21 @@ var ( errPending = errors.New("another operation (install/upgrade/rollback) is in progress") ) +type DryRunStrategy string + +const ( + // DryRunNone indicates the client will make all mutating calls + DryRunNone DryRunStrategy = "none" + + // DryRunClient, or client-side dry-run, indicates the client will avoid + // making calls to the server + DryRunClient DryRunStrategy = "client" + + // DryRunServer, or server-side dry-run, indicates the client will send + // calls to the APIServer with the dry-run parameter to prevent persisting changes + DryRunServer DryRunStrategy = "server" +) + // Configuration injects the dependencies that all actions share. type Configuration struct { // RESTClientGetter is an interface that loads Kubernetes clients. @@ -528,3 +543,13 @@ func determineReleaseSSApplyMethod(serverSideApply bool) release.ApplyMethod { } return release.ApplyMethodClientSideApply } + +// isDryRun returns true if the strategy is set to run as a DryRun +func isDryRun(strategy DryRunStrategy) bool { + return strategy == DryRunClient || strategy == DryRunServer +} + +// interactWithServer determine whether or not to interact with a remote Kubernetes server +func interactWithServer(strategy DryRunStrategy) bool { + return strategy == DryRunNone || strategy == DryRunServer +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 78ca01089..d993da100 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -951,3 +951,15 @@ func TestDetermineReleaseSSAApplyMethod(t *testing.T) { assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false)) assert.Equal(t, release.ApplyMethodServerSideApply, determineReleaseSSApplyMethod(true)) } + +func TestIsDryRun(t *testing.T) { + assert.False(t, isDryRun(DryRunNone)) + assert.True(t, isDryRun(DryRunClient)) + assert.True(t, isDryRun(DryRunServer)) +} + +func TestInteractWithServer(t *testing.T) { + assert.True(t, interactWithServer(DryRunNone)) + assert.False(t, interactWithServer(DryRunClient)) + assert.True(t, interactWithServer(DryRunServer)) +} diff --git a/pkg/action/install.go b/pkg/action/install.go index c6d4f723c..1124845af 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -75,7 +75,6 @@ type Install struct { ChartPathOptions - ClientOnly bool // ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway. // // This should be used with caution. @@ -87,8 +86,8 @@ type Install struct { // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ ServerSideApply bool CreateNamespace bool - DryRun bool - DryRunOption string + // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster + DryRunStrategy DryRunStrategy // HideSecret can be set to true when DryRun is enabled in order to hide // Kubernetes Secrets in the output. It cannot be used outside of DryRun. HideSecret bool @@ -159,6 +158,7 @@ func NewInstall(cfg *Configuration) *Install { in := &Install{ cfg: cfg, ServerSideApply: true, + DryRunStrategy: DryRunNone, } in.registryClient = cfg.RegistryClient @@ -264,8 +264,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return nil, errors.New("invalid chart apiVersion") } - // Check reachability of cluster unless in client-only mode (e.g. `helm template` without `--validate`) - if !i.ClientOnly { + if interactWithServer(i.DryRunStrategy) { if err := i.cfg.KubeClient.IsReachable(); err != nil { slog.Error(fmt.Sprintf("cluster reachability check failed: %v", err)) return nil, fmt.Errorf("cluster reachability check failed: %w", err) @@ -273,7 +272,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st } // HideSecret must be used with dry run. Otherwise, return an error. - if !i.isDryRun() && i.HideSecret { + if !isDryRun(i.DryRunStrategy) && i.HideSecret { slog.Error("hiding Kubernetes secrets requires a dry-run mode") return nil, errors.New("hiding Kubernetes secrets requires a dry-run mode") } @@ -288,23 +287,18 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return nil, fmt.Errorf("chart dependencies processing failed: %w", err) } - var interactWithRemote bool - if !i.isDryRun() || i.DryRunOption == "server" || i.DryRunOption == "none" || i.DryRunOption == "false" { - interactWithRemote = true - } - // Pre-install anything in the crd/ directory. We do this before Helm // contacts the upstream server and builds the capabilities object. - if crds := chrt.CRDObjects(); !i.ClientOnly && !i.SkipCRDs && len(crds) > 0 { + if crds := chrt.CRDObjects(); interactWithServer(i.DryRunStrategy) && !i.SkipCRDs && len(crds) > 0 { // On dry run, bail here - if i.isDryRun() { + if isDryRun(i.DryRunStrategy) { slog.Warn("This chart or one of its subcharts contains CRDs. Rendering may fail or contain inaccuracies.") } else if err := i.installCRDs(crds); err != nil { return nil, err } } - if i.ClientOnly { + if !interactWithServer(i.DryRunStrategy) { // Add mock objects in here so it doesn't use Kube API server // NOTE(bacongobbler): used for `helm template` i.cfg.Capabilities = common.DefaultCapabilities.Copy() @@ -317,7 +311,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st mem := driver.NewMemory() mem.SetNamespace(i.Namespace) i.cfg.Releases = storage.Init(mem) - } else if !i.ClientOnly && len(i.APIVersions) > 0 { + } else if interactWithServer(i.DryRunStrategy) && len(i.APIVersions) > 0 { slog.Debug("API Version list given outside of client only mode, this list will be ignored") } @@ -333,7 +327,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st } // special case for helm template --is-upgrade - isUpgrade := i.IsUpgrade && i.isDryRun() + isUpgrade := i.IsUpgrade && isDryRun(i.DryRunStrategy) options := common.ReleaseOptions{ Name: i.ReleaseName, Namespace: i.Namespace, @@ -353,7 +347,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st rel := i.createRelease(chrt, vals, i.Labels) var manifestDoc *bytes.Buffer - rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithRemote, i.EnableDNS, i.HideSecret) + rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret) // Even for errors, attach this if available if manifestDoc != nil { rel.Manifest = manifestDoc.String() @@ -386,7 +380,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st // we'll end up in a state where we will delete those resources upon // deleting the release because the manifest will be pointing at that // resource - if !i.ClientOnly && !isUpgrade && len(resources) > 0 { + if interactWithServer(i.DryRunStrategy) && !isUpgrade && len(resources) > 0 { if i.TakeOwnership { toBeAdopted, err = requireAdoption(resources) } else { @@ -398,7 +392,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st } // Bail out here if it is a dry run - if i.isDryRun() { + if isDryRun(i.DryRunStrategy) { rel.Info.Description = "Dry run complete" return rel, nil } @@ -480,14 +474,6 @@ func (i *Install) getGoroutineCount() int32 { return i.goroutineCount.Load() } -// isDryRun returns true if Upgrade is set to run as a DryRun -func (i *Install) isDryRun() bool { - if i.DryRun || i.DryRunOption == "client" || i.DryRunOption == "server" || i.DryRunOption == "true" { - return true - } - return false -} - func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.ResourceList, resources kube.ResourceList) (*release.Release, error) { var err error // pre-install hooks @@ -590,7 +576,7 @@ func (i *Install) availableName() error { return fmt.Errorf("release name %q: %w", start, err) } // On dry run, bail here - if i.isDryRun() { + if isDryRun(i.DryRunStrategy) { return nil } diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index aae36152d..8d1fc149d 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -249,16 +249,6 @@ func TestInstallReleaseWithValues(t *testing.T) { is.Equal(expectedUserValues, rel.Config) } -func TestInstallReleaseClientOnly(t *testing.T) { - is := assert.New(t) - instAction := installAction(t) - instAction.ClientOnly = true - instAction.Run(buildChart(), nil) // disregard output - - is.Equal(instAction.cfg.Capabilities, common.DefaultCapabilities) - is.Equal(instAction.cfg.KubeClient, &kubefake.PrintingKubeClient{Out: io.Discard}) -} - func TestInstallRelease_NoName(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "" @@ -355,27 +345,30 @@ func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) { is.Equal(rel.Info.Description, "Install complete") } -func TestInstallRelease_DryRun(t *testing.T) { - is := assert.New(t) - instAction := installAction(t) - instAction.DryRun = true - vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(withSampleTemplates()), vals) - if err != nil { - t.Fatalf("Failed install: %s", err) - } +func TestInstallRelease_DryRunClient(t *testing.T) { + for _, dryRunStrategy := range []DryRunStrategy{DryRunClient, DryRunServer} { + is := assert.New(t) + instAction := installAction(t) + instAction.DryRunStrategy = dryRunStrategy - is.Contains(res.Manifest, "---\n# Source: hello/templates/hello\nhello: world") - is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world") - is.Contains(res.Manifest, "hello: Earth") - is.NotContains(res.Manifest, "hello: {{ template \"_planet\" . }}") - is.NotContains(res.Manifest, "empty") + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(withSampleTemplates()), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } - _, err = instAction.cfg.Releases.Get(res.Name, res.Version) - is.Error(err) - is.Len(res.Hooks, 1) - is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "expect hook to not be marked as run") - is.Equal(res.Info.Description, "Dry run complete") + is.Contains(res.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world") + is.Contains(res.Manifest, "hello: Earth") + is.NotContains(res.Manifest, "hello: {{ template \"_planet\" . }}") + is.NotContains(res.Manifest, "empty") + + _, err = instAction.cfg.Releases.Get(res.Name, res.Version) + is.Error(err) + is.Len(res.Hooks, 1) + is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "expect hook to not be marked as run") + is.Equal(res.Info.Description, "Dry run complete") + } } func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { @@ -383,7 +376,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { instAction := installAction(t) // First perform a normal dry-run with the secret and confirm its presence. - instAction.DryRun = true + instAction.DryRunStrategy = DryRunClient vals := map[string]interface{}{} res, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err != nil { @@ -410,7 +403,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { is.Equal(res2.Info.Description, "Dry run complete") // Ensure there is an error when HideSecret True but not in a dry-run mode - instAction.DryRun = false + instAction.DryRunStrategy = DryRunNone vals = map[string]interface{}{} _, err = instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err == nil { @@ -422,7 +415,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { func TestInstallRelease_DryRun_Lookup(t *testing.T) { is := assert.New(t) instAction := installAction(t) - instAction.DryRun = true + instAction.DryRunStrategy = DryRunNone vals := map[string]interface{}{} mockChart := buildChart(withSampleTemplates()) @@ -442,7 +435,7 @@ func TestInstallRelease_DryRun_Lookup(t *testing.T) { func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) { is := assert.New(t) instAction := installAction(t) - instAction.DryRun = true + instAction.DryRunStrategy = DryRunNone vals := map[string]interface{}{} _, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals) expectedErr := `hello/templates/incorrect:1:10 @@ -1001,7 +994,6 @@ func TestInstallRun_UnreachableKubeClient(t *testing.T) { config.KubeClient = &failingKubeClient instAction := NewInstall(config) - instAction.ClientOnly = false ctx, done := context.WithCancel(t.Context()) chrt := buildChart() res, err := instAction.RunWithContext(ctx, chrt, nil) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index f56052988..ade3ab233 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -39,7 +39,8 @@ type Rollback struct { WaitStrategy kube.WaitStrategy WaitForJobs bool DisableHooks bool - DryRun bool + // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster + DryRunStrategy DryRunStrategy // ForceReplace will, if set to `true`, ignore certain warnings and perform the rollback anyway. // // This should be used with caution. @@ -59,7 +60,8 @@ type Rollback struct { // NewRollback creates a new Rollback object with the given configuration. func NewRollback(cfg *Configuration) *Rollback { return &Rollback{ - cfg: cfg, + cfg: cfg, + DryRunStrategy: DryRunNone, } } @@ -77,7 +79,7 @@ func (r *Rollback) Run(name string) error { return err } - if !r.DryRun { + if !isDryRun(r.DryRunStrategy) { slog.Debug("creating rolled back release", "name", name) if err := r.cfg.Releases.Create(targetRelease); err != nil { return err @@ -89,7 +91,7 @@ func (r *Rollback) Run(name string) error { return err } - if !r.DryRun { + if !isDryRun(r.DryRunStrategy) { slog.Debug("updating status for rolled back release", "name", name) if err := r.cfg.Releases.Update(targetRelease); err != nil { return err @@ -175,7 +177,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele } func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) { - if r.DryRun { + if isDryRun(r.DryRunStrategy) { slog.Debug("dry run", "name", targetRelease.Name) return targetRelease, nil } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 3c84570b2..8dfc8b206 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -73,10 +73,8 @@ type Upgrade struct { WaitForJobs bool // DisableHooks disables hook processing if set to true. DisableHooks bool - // DryRun controls whether the operation is prepared, but not executed. - DryRun bool - // DryRunOption controls whether the operation is prepared, but not executed with options on whether or not to interact with the remote cluster. - DryRunOption string + // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster + DryRunStrategy DryRunStrategy // HideSecret can be set to true when DryRun is enabled in order to hide // Kubernetes Secrets in the output. It cannot be used outside of DryRun. HideSecret bool @@ -140,6 +138,7 @@ func NewUpgrade(cfg *Configuration) *Upgrade { up := &Upgrade{ cfg: cfg, ServerSideApply: "auto", + DryRunStrategy: DryRunNone, } up.registryClient = cfg.RegistryClient @@ -198,7 +197,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char } // Do not update for dry runs - if !u.isDryRun() { + if !isDryRun(u.DryRunStrategy) { slog.Debug("updating status for upgraded release", "name", name) if err := u.cfg.Releases.Update(upgradedRelease); err != nil { return res, err @@ -208,14 +207,6 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char return res, nil } -// isDryRun returns true if Upgrade is set to run as a DryRun -func (u *Upgrade) isDryRun() bool { - if u.DryRun || u.DryRunOption == "client" || u.DryRunOption == "server" || u.DryRunOption == "true" { - return true - } - return false -} - // prepareUpgrade builds an upgraded release for an upgrade operation. func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[string]interface{}) (*release.Release, *release.Release, bool, error) { if chart == nil { @@ -223,7 +214,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str } // HideSecret must be used with dry run. Otherwise, return an error. - if !u.isDryRun() && u.HideSecret { + if !isDryRun(u.DryRunStrategy) && u.HideSecret { return nil, nil, false, errors.New("hiding Kubernetes secrets requires a dry-run mode") } @@ -289,13 +280,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str return nil, nil, false, err } - // Determine whether or not to interact with remote - var interactWithRemote bool - if !u.isDryRun() || u.DryRunOption == "server" || u.DryRunOption == "none" || u.DryRunOption == "false" { - interactWithRemote = true - } - - hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithRemote, u.EnableDNS, u.HideSecret) + hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret) if err != nil { return nil, nil, false, err } @@ -391,8 +376,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR return nil }) - // Run if it is a dry run - if u.isDryRun() { + if isDryRun(u.DryRunStrategy) { slog.Debug("dry run for release", "name", upgradedRelease.Name) if len(u.Description) > 0 { upgradedRelease.Info.Description = u.Description diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index 0a436534f..34f1a2fb3 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -545,7 +545,7 @@ func TestUpgradeRelease_DryRun(t *testing.T) { rel.Info.Status = release.StatusDeployed req.NoError(upAction.cfg.Releases.Create(rel)) - upAction.DryRun = true + upAction.DryRunStrategy = DryRunClient vals := map[string]interface{}{} ctx, done := context.WithCancel(t.Context()) @@ -577,7 +577,7 @@ func TestUpgradeRelease_DryRun(t *testing.T) { is.Equal(1, lastRelease.Version) // Ensure in a dry run mode when using HideSecret - upAction.DryRun = false + upAction.DryRunStrategy = DryRunNone vals = map[string]interface{}{} ctx, done = context.WithCancel(t.Context()) diff --git a/pkg/cmd/helpers.go b/pkg/cmd/helpers.go new file mode 100644 index 000000000..e555dd18b --- /dev/null +++ b/pkg/cmd/helpers.go @@ -0,0 +1,83 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/pkg/action" +) + +func addDryRunFlag(cmd *cobra.Command) { + // --dry-run options with expected outcome: + // - Not set means no dry run and server is contacted. + // - Set with no value, a value of client, or a value of true and the server is not contacted + // - Set with a value of false, none, or false and the server is contacted + // The true/false part is meant to reflect some legacy behavior while none is equal to "". + f := cmd.Flags() + f.String( + "dry-run", + "none", + `simulates the operation without persisting changes. Must be one of: "none" (default), "client", or "server". '--dry-run=none' executes the operation normally and persists changes (no simulation). '--dry-run=client' simulates the operation client-side only and avoids cluster connections. '--dry-run=server' simulates the operation on the server, requiring cluster connectivity.`) + f.Lookup("dry-run").NoOptDefVal = "unset" +} + +// Determine the `action.DryRunStrategy` given -dry-run=` flag (or absence of) +// Legacy usage of the flag: boolean values, and `--dry-run` (without value) are supported, and log warnings emitted +func cmdGetDryRunFlagStrategy(cmd *cobra.Command, isTemplate bool) (action.DryRunStrategy, error) { + + f := cmd.Flag("dry-run") + v := f.Value.String() + + switch v { + case f.NoOptDefVal: + slog.Warn(`--dry-run is deprecated and should be replaced with '--dry-run=client'`) + return action.DryRunClient, nil + case string(action.DryRunClient): + return action.DryRunClient, nil + case string(action.DryRunServer): + return action.DryRunServer, nil + case string(action.DryRunNone): + if isTemplate { + // Special case hack for `helm template`, which is always a dry run + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "server" or "client"`, v) + } + return action.DryRunNone, nil + } + + b, err := strconv.ParseBool(v) + if err != nil { + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "none", "server", or "client"`, v) + } + + if isTemplate && !b { + // Special case for `helm template`, which is always a dry run + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "server" or "client"`, v) + } + + result := action.DryRunNone + if b { + result = action.DryRunClient + } + slog.Warn(fmt.Sprintf(`boolean '--dry-run=%v' flag is deprecated and must be replaced with '--dry-run=%s'`, v, result)) + + return result, nil +} diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go index 96bf6434b..08065499e 100644 --- a/pkg/cmd/helpers_test.go +++ b/pkg/cmd/helpers_test.go @@ -18,7 +18,9 @@ package cmd import ( "bytes" + "encoding/json" "io" + "log/slog" "os" "strings" "testing" @@ -26,6 +28,8 @@ import ( shellwords "github.com/mattn/go-shellwords" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "helm.sh/helm/v4/internal/test" "helm.sh/helm/v4/pkg/action" @@ -151,3 +155,155 @@ func resetEnv() func() { settings = cli.New() } } + +func TestCmdGetDryRunFlagStrategy(t *testing.T) { + + type testCaseExpectedLog struct { + Level string + Msg string + } + testCases := map[string]struct { + DryRunFlagArg string + IsTemplate bool + ExpectedStrategy action.DryRunStrategy + ExpectedError bool + ExpectedLog *testCaseExpectedLog + }{ + "unset_value": { + DryRunFlagArg: "--dry-run", + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `--dry-run is deprecated and should be replaced with '--dry-run=client'`, + }, + }, + "unset_special": { + DryRunFlagArg: "--dry-run=unset", // Special value that matches cmd.Flags("dry-run").NoOptDefVal + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `--dry-run is deprecated and should be replaced with '--dry-run=client'`, + }, + }, + "none": { + DryRunFlagArg: "--dry-run=none", + ExpectedStrategy: action.DryRunNone, + }, + "client": { + DryRunFlagArg: "--dry-run=client", + ExpectedStrategy: action.DryRunClient, + }, + "server": { + DryRunFlagArg: "--dry-run=server", + ExpectedStrategy: action.DryRunServer, + }, + "bool_false": { + DryRunFlagArg: "--dry-run=false", + ExpectedStrategy: action.DryRunNone, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=false' flag is deprecated and must be replaced with '--dry-run=none'`, + }, + }, + "bool_true": { + DryRunFlagArg: "--dry-run=true", + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=true' flag is deprecated and must be replaced with '--dry-run=client'`, + }, + }, + "bool_0": { + DryRunFlagArg: "--dry-run=0", + ExpectedStrategy: action.DryRunNone, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=0' flag is deprecated and must be replaced with '--dry-run=none'`, + }, + }, + "bool_1": { + DryRunFlagArg: "--dry-run=1", + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=1' flag is deprecated and must be replaced with '--dry-run=client'`, + }, + }, + "invalid": { + DryRunFlagArg: "--dry-run=invalid", + ExpectedError: true, + }, + "template_unset_value": { + DryRunFlagArg: "--dry-run", + IsTemplate: true, + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `--dry-run is deprecated and should be replaced with '--dry-run=client'`, + }, + }, + "template_bool_false": { + DryRunFlagArg: "--dry-run=false", + IsTemplate: true, + ExpectedError: true, + }, + "template_bool_template_true": { + DryRunFlagArg: "--dry-run=true", + IsTemplate: true, + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=true' flag is deprecated and must be replaced with '--dry-run=client'`, + }, + }, + "template_none": { + DryRunFlagArg: "--dry-run=none", + IsTemplate: true, + ExpectedError: true, + }, + "template_client": { + DryRunFlagArg: "--dry-run=client", + IsTemplate: true, + ExpectedStrategy: action.DryRunClient, + }, + "template_server": { + DryRunFlagArg: "--dry-run=server", + IsTemplate: true, + ExpectedStrategy: action.DryRunServer, + }, + } + + for name, tc := range testCases { + + logBuf := new(bytes.Buffer) + logger := slog.New(slog.NewJSONHandler(logBuf, nil)) + slog.SetDefault(logger) + + cmd := &cobra.Command{ + Use: "helm", + } + addDryRunFlag(cmd) + cmd.Flags().Parse([]string{"helm", tc.DryRunFlagArg}) + + t.Run(name, func(t *testing.T) { + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, tc.IsTemplate) + if tc.ExpectedError { + assert.Error(t, err) + } else { + assert.Nil(t, err) + assert.Equal(t, tc.ExpectedStrategy, dryRunStrategy) + } + + if tc.ExpectedLog != nil { + logResult := map[string]string{} + err = json.Unmarshal(logBuf.Bytes(), &logResult) + require.Nil(t, err) + + assert.Equal(t, tc.ExpectedLog.Level, logResult["level"]) + assert.Equal(t, tc.ExpectedLog.Msg, logResult["msg"]) + } else { + assert.Equal(t, 0, logBuf.Len()) + } + }) + } +} diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 4f30bd7df..28bfa0b51 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -18,14 +18,12 @@ package cmd import ( "context" - "errors" "fmt" "io" "log" "log/slog" "os" "os/signal" - "slices" "syscall" "time" @@ -144,7 +142,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return compInstall(args, toComplete, client) }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSverify, client.PlainHTTP, client.Username, client.Password) if err != nil { @@ -152,12 +150,12 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetRegistryClient(registryClient) - // This is for the case where "" is specifically passed in as a - // value. When there is no value passed in NoOptDefVal will be used - // and it is set to client. See addInstallFlags. - if client.DryRunOption == "" { - client.DryRunOption = "none" + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) + if err != nil { + return err } + client.DryRunStrategy = dryRunStrategy + rel, err := runInstall(args, client, valueOpts, out) if err != nil { return fmt.Errorf("INSTALLATION FAILED: %w", err) @@ -173,11 +171,12 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } - addInstallFlags(cmd, cmd.Flags(), client, valueOpts) + f := cmd.Flags() + addInstallFlags(cmd, f, client, valueOpts) // hide-secret is not available in all places the install flags are used so // it is added separately - f := cmd.Flags() f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") + addDryRunFlag(cmd) bindOutputFlag(cmd, &outfmt) bindPostRenderFlag(cmd, &client.PostRenderer, settings) @@ -186,13 +185,6 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Install, valueOpts *values.Options) { f.BoolVar(&client.CreateNamespace, "create-namespace", false, "create the release namespace if not present") - // --dry-run options with expected outcome: - // - Not set means no dry run and server is contacted. - // - Set with no value, a value of client, or a value of true and the server is not contacted - // - Set with a value of false, none, or false and the server is contacted - // The true/false part is meant to reflect some legacy behavior while none is equal to "". - f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.") - f.Lookup("dry-run").NoOptDefVal = "client" f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") @@ -316,11 +308,6 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options client.Namespace = settings.Namespace() - // Validate DryRunOption member is one of the allowed values - if err := validateDryRunOptionFlag(client.DryRunOption); err != nil { - return nil, err - } - // Create context and prepare the handle of SIGTERM ctx := context.Background() ctx, cancel := context.WithCancel(ctx) @@ -363,13 +350,3 @@ func compInstall(args []string, toComplete string, client *action.Install) ([]st } return nil, cobra.ShellCompDirectiveNoFileComp } - -func validateDryRunOptionFlag(dryRunOptionFlagValue string) error { - // Validate dry-run flag value with a set of allowed value - allowedDryRunValues := []string{"false", "true", "none", "client", "server"} - isAllowed := slices.Contains(allowedDryRunValues, dryRunOptionFlagValue) - if !isAllowed { - return errors.New("invalid dry-run flag. Flag must one of the following: false, true, none, client, server") - } - return nil -} diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index ff60aaedf..00a2725bc 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -57,7 +57,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return noMoreArgsComp() }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 1 { ver, err := strconv.Atoi(args[1]) if err != nil { @@ -66,6 +66,12 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.Version = ver } + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) + if err != nil { + return err + } + client.DryRunStrategy = dryRunStrategy + if err := client.Run(args[0]); err != nil { return err } @@ -76,7 +82,6 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } f := cmd.Flags() - f.BoolVar(&client.DryRun, "dry-run", false, "simulate a rollback") f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") @@ -87,6 +92,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout") f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this rollback when rollback fails") f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit") + addDryRunFlag(cmd) AddWaitFlag(cmd, &client.WaitStrategy) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index 81c112d51..af0690c57 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -67,7 +67,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return compInstall(args, toComplete, client) }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { if kubeVersion != "" { parsedKubeVersion, err := common.ParseKubeVersion(kubeVersion) if err != nil { @@ -83,16 +83,17 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetRegistryClient(registryClient) - // This is for the case where "" is specifically passed in as a - // value. When there is no value passed in NoOptDefVal will be used - // and it is set to client. See addInstallFlags. - if client.DryRunOption == "" { - client.DryRunOption = "true" + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, true) + if err != nil { + return err + } + if validate { + // Mimic deprecated --validate flag behavior by enabling server dry run + dryRunStrategy = action.DryRunServer } - client.DryRun = true + client.DryRunStrategy = dryRunStrategy client.ReleaseName = "release-name" client.Replace = true // Skip the name check - client.ClientOnly = !validate client.APIVersions = common.VersionSet(extraAPIs) client.IncludeCRDs = includeCrds rel, err := runInstall(args, client, valueOpts, out) @@ -196,14 +197,21 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { addInstallFlags(cmd, f, client, valueOpts) f.StringArrayVarP(&showFiles, "show-only", "s", []string{}, "only show manifests rendered from the given templates") f.StringVar(&client.OutputDir, "output-dir", "", "writes the executed templates to files in output-dir instead of stdout") - f.BoolVar(&validate, "validate", false, "validate your manifests against the Kubernetes cluster you are currently pointing at. This is the same validation performed on an install") + f.BoolVar(&validate, "validate", false, "deprecated") + f.MarkDeprecated("validate", "use '--dry-run=server' instead") f.BoolVar(&includeCrds, "include-crds", false, "include CRDs in the templated output") f.BoolVar(&skipTests, "skip-tests", false, "skip tests from templated output") f.BoolVar(&client.IsUpgrade, "is-upgrade", false, "set .Release.IsUpgrade instead of .Release.IsInstall") f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)") f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.") + f.String( + "dry-run", + "client", + `simulates the operation either client-side or server-side. Must be either: "client", or "server". '--dry-run=client simulates the operation client-side only and avoids cluster connections. '--dry-run=server' simulates/validates the operation on the server, requiring cluster connectivity.`) + f.Lookup("dry-run").NoOptDefVal = "unset" bindPostRenderFlag(cmd, &client.PostRenderer, settings) + cmd.MarkFlagsMutuallyExclusive("validate", "dry-run") return cmd } diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index fcc4f9294..a9f834ebb 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -100,7 +100,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } return noMoreArgsComp() }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { client.Namespace = settings.Namespace() registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, @@ -110,12 +110,12 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetRegistryClient(registryClient) - // This is for the case where "" is specifically passed in as a - // value. When there is no value passed in NoOptDefVal will be used - // and it is set to client. See addInstallFlags. - if client.DryRunOption == "" { - client.DryRunOption = "none" + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) + if err != nil { + return err } + client.DryRunStrategy = dryRunStrategy + // Fixes #7002 - Support reading values from STDIN for `upgrade` command // Must load values AFTER determining if we have to call install so that values loaded from stdin are not read twice if client.Install { @@ -132,8 +132,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { instClient.CreateNamespace = createNamespace instClient.ChartPathOptions = client.ChartPathOptions instClient.ForceReplace = client.ForceReplace - instClient.DryRun = client.DryRun - instClient.DryRunOption = client.DryRunOption + instClient.DryRunStrategy = client.DryRunStrategy instClient.DisableHooks = client.DisableHooks instClient.SkipCRDs = client.SkipCRDs instClient.Timeout = client.Timeout @@ -183,10 +182,6 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if err != nil { return err } - // Validate dry-run flag value is one of the allowed values - if err := validateDryRunOptionFlag(client.DryRunOption); err != nil { - return err - } p := getter.All(settings) vals, err := valueOpts.MergeValues(p) @@ -274,9 +269,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&createNamespace, "create-namespace", false, "if --install is set, create the release namespace if not present") f.BoolVarP(&client.Install, "install", "i", false, "if a release by this name doesn't already exist, run an install") f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") - f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.") f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") - f.Lookup("dry-run").NoOptDefVal = "client" f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") @@ -303,6 +296,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart") f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") f.BoolVar(&client.TakeOwnership, "take-ownership", false, "if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources") + addDryRunFlag(cmd) addChartPathOptionsFlags(f, &client.ChartPathOptions) addValueOptionsFlags(f, valueOpts) bindOutputFlag(cmd, &outfmt)