diff --git a/pkg/action/action.go b/pkg/action/action.go index 69bcf4da2..5249c8cfa 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -520,3 +520,10 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp func (cfg *Configuration) SetHookOutputFunc(hookOutputFunc func(_, _, _ string) io.Writer) { cfg.HookOutputFunc = hookOutputFunc } + +func determineReleaseSSApplyMethod(serverSideApply bool) release.ApplyMethod { + if serverSideApply { + return release.ApplyMethodServerSideApply + } + return release.ApplyMethodClientSideApply +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 43cf94622..7a510ace6 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -946,3 +946,8 @@ func TestRenderResources_NoPostRenderer(t *testing.T) { assert.NotNil(t, buf) assert.Equal(t, "", notes) } + +func TestDetermineReleaseSSAApplyMethod(t *testing.T) { + assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false)) + assert.Equal(t, release.ApplyMethodServerSideApply, determineReleaseSSApplyMethod(true)) +} diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index 4cb77361a..889545ddc 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -47,6 +47,7 @@ type Metadata struct { Revision int `json:"revision" yaml:"revision"` Status string `json:"status" yaml:"status"` DeployedAt string `json:"deployedAt" yaml:"deployedAt"` + ApplyMethod string `json:"applyMethod,omitempty" yaml:"applyMethod,omitempty"` } // NewGetMetadata creates a new GetMetadata object with the given configuration. @@ -79,6 +80,7 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) { Revision: rel.Version, Status: rel.Info.Status.String(), DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339), + ApplyMethod: rel.ApplyMethod, }, nil } diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go index 275a1bf52..458a6342c 100644 --- a/pkg/action/hooks.go +++ b/pkg/action/hooks.go @@ -33,7 +33,7 @@ import ( ) // execHook executes all of the hooks for the given hook event. -func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, waitStrategy kube.WaitStrategy, timeout time.Duration) error { +func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, waitStrategy kube.WaitStrategy, timeout time.Duration, serverSideApply bool) error { executingHooks := []*release.Hook{} for _, h := range rl.Hooks { @@ -75,7 +75,7 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, // Create hook resources if _, err := cfg.KubeClient.Create( resources, - kube.ClientCreateOptionServerSideApply(false, false)); err != nil { + kube.ClientCreateOptionServerSideApply(serverSideApply, false)); err != nil { h.LastRun.CompletedAt = helmtime.Now() h.LastRun.Phase = release.HookPhaseFailed return fmt.Errorf("warning: Hook %s %s failed: %w", hook, h.Path, err) diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index ad1de2c59..e3a2c0808 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -385,7 +385,8 @@ data: Capabilities: chartutil.DefaultCapabilities, } - err := configuration.execHook(&tc.inputRelease, hookEvent, kube.StatusWatcherStrategy, 600) + serverSideApply := true + err := configuration.execHook(&tc.inputRelease, hookEvent, kube.StatusWatcherStrategy, 600, serverSideApply) if !reflect.DeepEqual(kubeClient.deleteRecord, tc.expectedDeleteRecord) { t.Fatalf("Got unexpected delete record, expected: %#v, but got: %#v", kubeClient.deleteRecord, tc.expectedDeleteRecord) diff --git a/pkg/action/install.go b/pkg/action/install.go index b46b4446b..f7482d466 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -75,7 +75,13 @@ type Install struct { // ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway. // // This should be used with caution. - ForceReplace bool + ForceReplace bool + // ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager") + // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts + ForceConflicts bool + // ServerSideApply when true (default) will enable changes to be applied via Kubernetes server-side apply + // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ + ServerSideApply bool CreateNamespace bool DryRun bool DryRunOption string @@ -145,7 +151,8 @@ type ChartPathOptions struct { // NewInstall creates a new Install object with the given configuration. func NewInstall(cfg *Configuration) *Install { in := &Install{ - cfg: cfg, + cfg: cfg, + ServerSideApply: true, } in.registryClient = cfg.RegistryClient @@ -175,7 +182,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error { // Send them to Kube if _, err := i.cfg.KubeClient.Create( res, - kube.ClientCreateOptionServerSideApply(false, false)); err != nil { + kube.ClientCreateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts)); err != nil { // If the error is CRD already exists, continue. if apierrors.IsAlreadyExists(err) { crdName := res[0].Name @@ -403,7 +410,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma } if _, err := i.cfg.KubeClient.Create( resourceList, - kube.ClientCreateOptionServerSideApply(false, false)); err != nil && !apierrors.IsAlreadyExists(err) { + kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false)); err != nil && !apierrors.IsAlreadyExists(err) { return nil, err } } @@ -415,8 +422,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma } } - // Store the release in history before continuing (new in Helm 3). We always know - // that this is a create operation. + // Store the release in history before continuing. We always know that this is a create operation if err := i.cfg.Releases.Create(rel); err != nil { // We could try to recover gracefully here, but since nothing has been installed // yet, this is probably safer than trying to continue when we know storage is @@ -463,7 +469,7 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource var err error // pre-install hooks if !i.DisableHooks { - if err := i.cfg.execHook(rel, release.HookPreInstall, i.WaitStrategy, i.Timeout); err != nil { + if err := i.cfg.execHook(rel, release.HookPreInstall, i.WaitStrategy, i.Timeout, i.ServerSideApply); err != nil { return rel, fmt.Errorf("failed pre-install: %s", err) } } @@ -474,15 +480,15 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource if len(toBeAdopted) == 0 && len(resources) > 0 { _, err = i.cfg.KubeClient.Create( resources, - kube.ClientCreateOptionServerSideApply(false, false)) + kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false)) } else if len(resources) > 0 { - updateThreeWayMergeForUnstructured := i.TakeOwnership + useUpdateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply // Use three-way merge when taking ownership (and not using server-side apply) _, err = i.cfg.KubeClient.Update( toBeAdopted, resources, - kube.ClientUpdateOptionServerSideApply(false, false), - kube.ClientUpdateOptionThreeWayMergeForUnstructured(updateThreeWayMergeForUnstructured), - kube.ClientUpdateOptionForceReplace(i.ForceReplace)) + kube.ClientUpdateOptionForceReplace(i.ForceReplace), + kube.ClientUpdateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts), + kube.ClientUpdateOptionThreeWayMergeForUnstructured(useUpdateThreeWayMergeForUnstructured)) } if err != nil { return rel, err @@ -503,7 +509,7 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource } if !i.DisableHooks { - if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.Timeout); err != nil { + if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.Timeout, i.ServerSideApply); err != nil { return rel, fmt.Errorf("failed post-install: %s", err) } } @@ -580,7 +586,8 @@ func (i *Install) availableName() error { // createRelease creates a new release object func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{}, labels map[string]string) *release.Release { ts := i.cfg.Now() - return &release.Release{ + + r := &release.Release{ Name: i.ReleaseName, Namespace: i.Namespace, Chart: chrt, @@ -590,9 +597,12 @@ func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{ LastDeployed: ts, Status: release.StatusUnknown, }, - Version: 1, - Labels: labels, + Version: 1, + Labels: labels, + ApplyMethod: string(determineReleaseSSApplyMethod(i.ServerSideApply)), } + + return r } // recordRelease with an update operation in case reuse has been set. diff --git a/pkg/action/release_testing.go b/pkg/action/release_testing.go index b5f6fe712..009f4d793 100644 --- a/pkg/action/release_testing.go +++ b/pkg/action/release_testing.go @@ -96,7 +96,8 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) { rel.Hooks = executingHooks } - if err := r.cfg.execHook(rel, release.HookTest, kube.StatusWatcherStrategy, r.Timeout); err != nil { + serverSideApply := rel.ApplyMethod == string(release.ApplyMethodServerSideApply) + if err := r.cfg.execHook(rel, release.HookTest, kube.StatusWatcherStrategy, r.Timeout, serverSideApply); err != nil { rel.Hooks = append(skippedHooks, rel.Hooks...) r.cfg.Releases.Update(rel) return rel, err diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index dd1f8c390..5f0ed02f1 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -44,9 +44,17 @@ type Rollback struct { // ForceReplace will, if set to `true`, ignore certain warnings and perform the rollback anyway. // // This should be used with caution. - ForceReplace bool - CleanupOnFail bool - MaxHistory int // MaxHistory limits the maximum number of revisions saved per release + ForceReplace bool + // ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager") + // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts + ForceConflicts bool + // ServerSideApply enables changes to be applied via Kubernetes server-side apply + // Can be the string: "true", "false" or "auto" + // When "auto", sever-side usage will be based upon the releases previous usage + // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ + ServerSideApply string + CleanupOnFail bool + MaxHistory int // MaxHistory limits the maximum number of revisions saved per release } // NewRollback creates a new Rollback object with the given configuration. @@ -65,7 +73,7 @@ func (r *Rollback) Run(name string) error { r.cfg.Releases.MaxHistory = r.MaxHistory slog.Debug("preparing rollback", "name", name) - currentRelease, targetRelease, err := r.prepareRollback(name) + currentRelease, targetRelease, serverSideApply, err := r.prepareRollback(name) if err != nil { return err } @@ -78,7 +86,7 @@ func (r *Rollback) Run(name string) error { } slog.Debug("performing rollback", "name", name) - if _, err := r.performRollback(currentRelease, targetRelease); err != nil { + if _, err := r.performRollback(currentRelease, targetRelease, serverSideApply); err != nil { return err } @@ -93,18 +101,18 @@ func (r *Rollback) Run(name string) error { // prepareRollback finds the previous release and prepares a new release object with // the previous release's configuration -func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Release, error) { +func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Release, bool, error) { if err := chartutil.ValidateReleaseName(name); err != nil { - return nil, nil, fmt.Errorf("prepareRollback: Release name is invalid: %s", name) + return nil, nil, false, fmt.Errorf("prepareRollback: Release name is invalid: %s", name) } if r.Version < 0 { - return nil, nil, errInvalidRevision + return nil, nil, false, errInvalidRevision } currentRelease, err := r.cfg.Releases.Last(name) if err != nil { - return nil, nil, err + return nil, nil, false, err } previousVersion := r.Version @@ -114,7 +122,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele historyReleases, err := r.cfg.Releases.History(name) if err != nil { - return nil, nil, err + return nil, nil, false, err } // Check if the history version to be rolled back exists @@ -127,14 +135,19 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele } } if !previousVersionExist { - return nil, nil, fmt.Errorf("release has no %d version", previousVersion) + return nil, nil, false, fmt.Errorf("release has no %d version", previousVersion) } slog.Debug("rolling back", "name", name, "currentVersion", currentRelease.Version, "targetVersion", previousVersion) previousRelease, err := r.cfg.Releases.Get(name, previousVersion) if err != nil { - return nil, nil, err + return nil, nil, false, err + } + + serverSideApply, err := getUpgradeServerSideValue(r.ServerSideApply, previousRelease.ApplyMethod) + if err != nil { + return nil, nil, false, err } // Store a new release object with previous release's configuration @@ -152,16 +165,17 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele // message here, and only override it later if we experience failure. Description: fmt.Sprintf("Rollback to %d", previousVersion), }, - Version: currentRelease.Version + 1, - Labels: previousRelease.Labels, - Manifest: previousRelease.Manifest, - Hooks: previousRelease.Hooks, + Version: currentRelease.Version + 1, + Labels: previousRelease.Labels, + Manifest: previousRelease.Manifest, + Hooks: previousRelease.Hooks, + ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)), } - return currentRelease, targetRelease, nil + return currentRelease, targetRelease, serverSideApply, nil } -func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release) (*release.Release, error) { +func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) { if r.DryRun { slog.Debug("dry run", "name", targetRelease.Name) return targetRelease, nil @@ -177,15 +191,16 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas } // pre-rollback hooks + if !r.DisableHooks { - if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.Timeout); err != nil { + if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.Timeout, serverSideApply); err != nil { return targetRelease, err } } else { slog.Debug("rollback hooks disabled", "name", targetRelease.Name) } - // It is safe to use "force" here because these are resources currently rendered by the chart. + // It is safe to use "forceOwnership" here because these are resources currently rendered by the chart. err = target.Visit(setMetadataVisitor(targetRelease.Name, targetRelease.Namespace, true)) if err != nil { return targetRelease, fmt.Errorf("unable to set metadata visitor from target release: %w", err) @@ -193,8 +208,9 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas results, err := r.cfg.KubeClient.Update( current, target, - kube.ClientUpdateOptionServerSideApply(false, false), - kube.ClientUpdateOptionForceReplace(r.ForceReplace)) + kube.ClientUpdateOptionForceReplace(r.ForceReplace), + kube.ClientUpdateOptionServerSideApply(serverSideApply, r.ForceConflicts), + kube.ClientUpdateOptionThreeWayMergeForUnstructured(false)) if err != nil { msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err) @@ -239,7 +255,7 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas // post-rollback hooks if !r.DisableHooks { - if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.Timeout); err != nil { + if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.Timeout, serverSideApply); err != nil { return targetRelease, err } } diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 163af290e..4444f4331 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -115,7 +115,8 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error) res := &release.UninstallReleaseResponse{Release: rel} if !u.DisableHooks { - if err := u.cfg.execHook(rel, release.HookPreDelete, u.WaitStrategy, u.Timeout); err != nil { + serverSideApply := true + if err := u.cfg.execHook(rel, release.HookPreDelete, u.WaitStrategy, u.Timeout, serverSideApply); err != nil { return res, err } } else { @@ -144,7 +145,8 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error) } if !u.DisableHooks { - if err := u.cfg.execHook(rel, release.HookPostDelete, u.WaitStrategy, u.Timeout); err != nil { + serverSideApply := true + if err := u.cfg.execHook(rel, release.HookPostDelete, u.WaitStrategy, u.Timeout, serverSideApply); err != nil { errs = append(errs, err) } } @@ -244,11 +246,13 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)} } if len(resources) > 0 { - if kubeClient, ok := u.cfg.KubeClient.(kube.InterfaceDeletionPropagation); ok { - _, errs = kubeClient.DeleteWithPropagationPolicy(resources, parseCascadingFlag(u.DeletionPropagation)) - return resources, kept, errs + if len(resources) > 0 { + if kubeClient, ok := u.cfg.KubeClient.(kube.InterfaceDeletionPropagation); ok { + _, errs = kubeClient.DeleteWithPropagationPolicy(resources, parseCascadingFlag(u.DeletionPropagation)) + return resources, kept, errs + } + _, errs = u.cfg.KubeClient.Delete(resources) } - _, errs = u.cfg.KubeClient.Delete(resources) } return resources, kept, errs } diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index 44bd66d96..f7c9e5f44 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" @@ -147,6 +148,6 @@ func TestUninstallRelease_Cascade(t *testing.T) { failer.BuildDummy = true unAction.cfg.KubeClient = failer _, err := unAction.Run(rel.Name) - is.Error(err) + require.Error(t, err) is.Contains(err.Error(), "failed to delete release: come-fail-away") } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index abf4342d3..41ddf859f 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -81,6 +81,14 @@ type Upgrade struct { // // This should be used with caution. ForceReplace bool + // ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager") + // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts + ForceConflicts bool + // ServerSideApply enables changes to be applied via Kubernetes server-side apply + // Can be the string: "true", "false" or "auto" + // When "auto", sever-side usage will be based upon the releases previous usage + // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ + ServerSideApply string // ResetValues will reset the values to the chart's built-ins rather than merging with existing. ResetValues bool // ReuseValues will reuse the user's last supplied values. @@ -127,7 +135,8 @@ type resultMessage struct { // NewUpgrade creates a new Upgrade object with the given configuration. func NewUpgrade(cfg *Configuration) *Upgrade { up := &Upgrade{ - cfg: cfg, + cfg: cfg, + ServerSideApply: "auto", } up.registryClient = cfg.RegistryClient @@ -162,7 +171,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart. } slog.Debug("preparing upgrade", "name", name) - currentRelease, upgradedRelease, err := u.prepareUpgrade(name, chart, vals) + currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(name, chart, vals) if err != nil { return nil, err } @@ -170,7 +179,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart. u.cfg.Releases.MaxHistory = u.MaxHistory slog.Debug("performing update", "name", name) - res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease) + res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply) if err != nil { return res, err } @@ -195,14 +204,14 @@ func (u *Upgrade) isDryRun() bool { } // prepareUpgrade builds an upgraded release for an upgrade operation. -func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, *release.Release, error) { +func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, *release.Release, bool, error) { if chart == nil { - return nil, nil, errMissingChart + return nil, nil, false, errMissingChart } // HideSecret must be used with dry run. Otherwise, return an error. if !u.isDryRun() && u.HideSecret { - return nil, nil, errors.New("hiding Kubernetes secrets requires a dry-run mode") + return nil, nil, false, errors.New("hiding Kubernetes secrets requires a dry-run mode") } // finds the last non-deleted release with the given name @@ -210,14 +219,14 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin if err != nil { // to keep existing behavior of returning the "%q has no deployed releases" error when an existing release does not exist if errors.Is(err, driver.ErrReleaseNotFound) { - return nil, nil, driver.NewErrNoDeployedReleases(name) + return nil, nil, false, driver.NewErrNoDeployedReleases(name) } - return nil, nil, err + return nil, nil, false, err } // Concurrent `helm upgrade`s will either fail here with `errPending` or when creating the release with "already exists". This should act as a pessimistic lock. if lastRelease.Info.Status.IsPending() { - return nil, nil, errPending + return nil, nil, false, errPending } var currentRelease *release.Release @@ -232,7 +241,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin (lastRelease.Info.Status == release.StatusFailed || lastRelease.Info.Status == release.StatusSuperseded) { currentRelease = lastRelease } else { - return nil, nil, err + return nil, nil, false, err } } } @@ -240,11 +249,11 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin // determine if values will be reused vals, err = u.reuseValues(chart, currentRelease, vals) if err != nil { - return nil, nil, err + return nil, nil, false, err } if err := chartutil.ProcessDependencies(chart, vals); err != nil { - return nil, nil, err + return nil, nil, false, err } // Increment revision count. This is passed to templates, and also stored on @@ -260,11 +269,11 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin caps, err := u.cfg.getCapabilities() if err != nil { - return nil, nil, err + return nil, nil, false, err } valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation) if err != nil { - return nil, nil, err + return nil, nil, false, err } // Determine whether or not to interact with remote @@ -275,13 +284,20 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithRemote, u.EnableDNS, u.HideSecret) if err != nil { - return nil, nil, err + return nil, nil, false, err } if driver.ContainsSystemLabels(u.Labels) { - return nil, nil, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) + return nil, nil, false, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) } + serverSideApply, err := getUpgradeServerSideValue(u.ServerSideApply, lastRelease.ApplyMethod) + if err != nil { + return nil, nil, false, err + } + + slog.Debug("determined release apply method", slog.Bool("server_side_apply", serverSideApply), slog.String("previous_release_apply_method", lastRelease.ApplyMethod)) + // Store an upgraded release. upgradedRelease := &release.Release{ Name: name, @@ -294,20 +310,21 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin Status: release.StatusPendingUpgrade, Description: "Preparing upgrade", // This should be overwritten later. }, - Version: revision, - Manifest: manifestDoc.String(), - Hooks: hooks, - Labels: mergeCustomLabels(lastRelease.Labels, u.Labels), + Version: revision, + Manifest: manifestDoc.String(), + Hooks: hooks, + Labels: mergeCustomLabels(lastRelease.Labels, u.Labels), + ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)), } if len(notesTxt) > 0 { upgradedRelease.Info.Notes = notesTxt } err = validateManifest(u.cfg.KubeClient, manifestDoc.Bytes(), !u.DisableOpenAPIValidation) - return currentRelease, upgradedRelease, err + return currentRelease, upgradedRelease, serverSideApply, err } -func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedRelease *release.Release) (*release.Release, error) { +func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedRelease *release.Release, serverSideApply bool) (*release.Release, error) { current, err := u.cfg.KubeClient.Build(bytes.NewBufferString(originalRelease.Manifest), false) if err != nil { // Checking for removed Kubernetes API error so can provide a more informative error message to the user @@ -380,7 +397,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR ctxChan := make(chan resultMessage) doneChan := make(chan interface{}) defer close(doneChan) - go u.releasingUpgrade(rChan, upgradedRelease, current, target, originalRelease) + go u.releasingUpgrade(rChan, upgradedRelease, current, target, originalRelease, serverSideApply) go u.handleContext(ctx, doneChan, ctxChan, upgradedRelease) select { case result := <-rChan: @@ -414,11 +431,11 @@ func (u *Upgrade) handleContext(ctx context.Context, done chan interface{}, c ch return } } -func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release) { +func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release, serverSideApply bool) { // pre-upgrade hooks if !u.DisableHooks { - if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.Timeout); err != nil { + if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.Timeout, serverSideApply); err != nil { u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %s", err)) return } @@ -429,8 +446,8 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele results, err := u.cfg.KubeClient.Update( current, target, - kube.ClientUpdateOptionServerSideApply(false, false), - kube.ClientUpdateOptionForceReplace(u.ForceReplace)) + kube.ClientUpdateOptionForceReplace(u.ForceReplace), + kube.ClientUpdateOptionServerSideApply(serverSideApply, u.ForceConflicts)) if err != nil { u.cfg.recordRelease(originalRelease) u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) @@ -459,7 +476,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele // post-upgrade hooks if !u.DisableHooks { - if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.Timeout); err != nil { + if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.Timeout, serverSideApply); err != nil { u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %s", err)) return } @@ -530,6 +547,8 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e rollin.WaitForJobs = u.WaitForJobs rollin.DisableHooks = u.DisableHooks rollin.ForceReplace = u.ForceReplace + rollin.ForceConflicts = u.ForceConflicts + rollin.ServerSideApply = u.ServerSideApply rollin.Timeout = u.Timeout if rollErr := rollin.Run(rel.Name); rollErr != nil { return rel, fmt.Errorf("an error occurred while rolling back the release. original upgrade error: %w: %w", err, rollErr) @@ -607,3 +626,16 @@ func mergeCustomLabels(current, desired map[string]string) map[string]string { } return labels } + +func getUpgradeServerSideValue(serverSideOption string, releaseApplyMethod string) (bool, error) { + switch serverSideOption { + case "auto": + return releaseApplyMethod == "ssa", nil + case "false": + return false, nil + case "true": + return true, nil + default: + return false, fmt.Errorf("invalid/unknown release server-side apply method: %s", serverSideOption) + } +} diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index e20955560..ccb0b8447 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -583,3 +583,109 @@ func TestUpgradeRelease_DryRun(t *testing.T) { done() req.Error(err) } + +func TestGetUpgradeServerSideValue(t *testing.T) { + tests := []struct { + name string + actionServerSideOption string + releaseApplyMethod string + expectedServerSideApply bool + }{ + { + name: "action ssa auto / release csa", + actionServerSideOption: "auto", + releaseApplyMethod: "csa", + expectedServerSideApply: false, + }, + { + name: "action ssa auto / release ssa", + actionServerSideOption: "auto", + releaseApplyMethod: "ssa", + expectedServerSideApply: true, + }, + { + name: "action ssa auto / release empty", + actionServerSideOption: "auto", + releaseApplyMethod: "", + expectedServerSideApply: false, + }, + { + name: "action ssa true / release csa", + actionServerSideOption: "true", + releaseApplyMethod: "csa", + expectedServerSideApply: true, + }, + { + name: "action ssa true / release ssa", + actionServerSideOption: "true", + releaseApplyMethod: "ssa", + expectedServerSideApply: true, + }, + { + name: "action ssa true / release 'unknown'", + actionServerSideOption: "true", + releaseApplyMethod: "foo", + expectedServerSideApply: true, + }, + { + name: "action ssa true / release empty", + actionServerSideOption: "true", + releaseApplyMethod: "", + expectedServerSideApply: true, + }, + { + name: "action ssa false / release csa", + actionServerSideOption: "false", + releaseApplyMethod: "ssa", + expectedServerSideApply: false, + }, + { + name: "action ssa false / release ssa", + actionServerSideOption: "false", + releaseApplyMethod: "ssa", + expectedServerSideApply: false, + }, + { + name: "action ssa false / release 'unknown'", + actionServerSideOption: "false", + releaseApplyMethod: "foo", + expectedServerSideApply: false, + }, + { + name: "action ssa false / release empty", + actionServerSideOption: "false", + releaseApplyMethod: "ssa", + expectedServerSideApply: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serverSideApply, err := getUpgradeServerSideValue(tt.actionServerSideOption, tt.releaseApplyMethod) + assert.Nil(t, err) + assert.Equal(t, tt.expectedServerSideApply, serverSideApply) + }) + } + + testsError := []struct { + name string + actionServerSideOption string + releaseApplyMethod string + expectedErrorMsg string + }{ + { + name: "action invalid option", + actionServerSideOption: "invalid", + releaseApplyMethod: "ssa", + expectedErrorMsg: "invalid/unknown release server-side apply method: invalid", + }, + } + + for _, tt := range testsError { + t.Run(tt.name, func(t *testing.T) { + _, err := getUpgradeServerSideValue(tt.actionServerSideOption, tt.releaseApplyMethod) + assert.ErrorContains(t, err, tt.expectedErrorMsg) + }) + } + +} diff --git a/pkg/cmd/get_metadata.go b/pkg/cmd/get_metadata.go index aea149f5e..eb90b6e44 100644 --- a/pkg/cmd/get_metadata.go +++ b/pkg/cmd/get_metadata.go @@ -27,6 +27,8 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" + + release "helm.sh/helm/v4/pkg/release/v1" ) type metadataWriter struct { @@ -75,6 +77,20 @@ func newGetMetadataCmd(cfg *action.Configuration, out io.Writer) *cobra.Command } func (w metadataWriter) WriteTable(out io.Writer) error { + + formatApplyMethod := func(applyMethod string) string { + switch applyMethod { + case "": + return "client-side apply (defaulted)" + case string(release.ApplyMethodClientSideApply): + return "client-side apply" + case string(release.ApplyMethodServerSideApply): + return "server-side apply" + default: + return fmt.Sprintf("unknown (%q)", applyMethod) + } + } + _, _ = fmt.Fprintf(out, "NAME: %v\n", w.metadata.Name) _, _ = fmt.Fprintf(out, "CHART: %v\n", w.metadata.Chart) _, _ = fmt.Fprintf(out, "VERSION: %v\n", w.metadata.Version) @@ -86,6 +102,7 @@ func (w metadataWriter) WriteTable(out io.Writer) error { _, _ = fmt.Fprintf(out, "REVISION: %v\n", w.metadata.Revision) _, _ = fmt.Fprintf(out, "STATUS: %v\n", w.metadata.Status) _, _ = fmt.Fprintf(out, "DEPLOYED_AT: %v\n", w.metadata.DeployedAt) + _, _ = fmt.Fprintf(out, "APPLY_METHOD: %v\n", formatApplyMethod(w.metadata.ApplyMethod)) return nil } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index d53b1d981..5be298ff8 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -196,6 +196,8 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal 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") + f.BoolVar(&client.ForceConflicts, "force-conflicts", false, "if set server-side apply will force changes against conflicts") + f.BoolVar(&client.ServerSideApply, "server-side", true, "object updates run in the server instead of the client") f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during install") f.BoolVar(&client.Replace, "replace", false, "reuse the given name, only if that name is a deleted release which remains in the history. This is unsafe in production") f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") @@ -217,6 +219,8 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal addValueOptionsFlags(f, valueOpts) addChartPathOptionsFlags(f, &client.ChartPathOptions) AddWaitFlag(cmd, &client.WaitStrategy) + cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") + cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { requiredArgs := 2 diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 4b7f3016d..ff60aaedf 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -80,12 +80,16 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { 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") + f.BoolVar(&client.ForceConflicts, "force-conflicts", false, "if set server-side apply will force changes against conflicts") + f.StringVar(&client.ServerSideApply, "server-side", "auto", "must be \"true\", \"false\" or \"auto\". Object updates run in the server instead of the client (\"auto\" defaults the value from the previous chart release's method)") f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during rollback") f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") 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") AddWaitFlag(cmd, &client.WaitStrategy) + cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") + cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") return cmd } diff --git a/pkg/cmd/testdata/output/get-metadata.txt b/pkg/cmd/testdata/output/get-metadata.txt index 5744083dd..b3cb73ee2 100644 --- a/pkg/cmd/testdata/output/get-metadata.txt +++ b/pkg/cmd/testdata/output/get-metadata.txt @@ -9,3 +9,4 @@ NAMESPACE: default REVISION: 1 STATUS: deployed DEPLOYED_AT: 1977-09-02T22:04:05Z +APPLY_METHOD: client-side apply (defaulted) diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index c3288286b..f39810c88 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -273,6 +273,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { 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") + f.BoolVar(&client.ForceConflicts, "force-conflicts", false, "if set server-side apply will force changes against conflicts") + f.StringVar(&client.ServerSideApply, "server-side", "auto", "must be \"true\", \"false\" or \"auto\". Object updates run in the server instead of the client (\"auto\" defaults the value from the previous chart release's method)") f.BoolVar(&client.DisableHooks, "no-hooks", false, "disable pre/post upgrade hooks") f.BoolVar(&client.DisableOpenAPIValidation, "disable-openapi-validation", false, "if set, the upgrade process will not validate rendered templates against the Kubernetes OpenAPI Schema") f.BoolVar(&client.SkipCRDs, "skip-crds", false, "if set, no CRDs will be installed when an upgrade is performed with install flag enabled. By default, CRDs are installed if not already present, when an upgrade is performed with install flag enabled") @@ -297,6 +299,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { bindOutputFlag(cmd, &outfmt) bindPostRenderFlag(cmd, &client.PostRenderer) AddWaitFlag(cmd, &client.WaitStrategy) + cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") + cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 2 { diff --git a/pkg/kube/fake/fake.go b/pkg/kube/fake/fake.go index 588bba83d..ae3853fb7 100644 --- a/pkg/kube/fake/fake.go +++ b/pkg/kube/fake/fake.go @@ -108,6 +108,14 @@ func (f *FailingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, [ return f.PrintingKubeClient.Delete(resources) } +// DeleteWithPropagationPolicy returns the configured error if set or prints +func (f *FailingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, policy metav1.DeletionPropagation) (*kube.Result, []error) { + if f.DeleteWithPropagationError != nil { + return nil, []error{f.DeleteWithPropagationError} + } + return f.PrintingKubeClient.DeleteWithPropagationPolicy(resources, policy) +} + // WatchUntilReady returns the configured error if set or prints func (f *FailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, d time.Duration) error { if f.watchUntilReadyError != nil { @@ -146,14 +154,6 @@ func (f *FailingKubeClient) BuildTable(r io.Reader, _ bool) (kube.ResourceList, return f.PrintingKubeClient.BuildTable(r, false) } -// DeleteWithPropagationPolicy returns the configured error if set or prints -func (f *FailingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, policy metav1.DeletionPropagation) (*kube.Result, []error) { - if f.DeleteWithPropagationError != nil { - return nil, []error{f.DeleteWithPropagationError} - } - return f.PrintingKubeClient.DeleteWithPropagationPolicy(resources, policy) -} - func (f *FailingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) { waiter, _ := f.PrintingKubeClient.GetWaiter(ws) printingKubeWaiter, _ := waiter.(*PrintingKubeWaiter) diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index 16c93615a..130e923c6 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -97,6 +97,17 @@ func (p *PrintingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, return &kube.Result{Deleted: resources}, nil } +// DeleteWithPropagationPolicy implements KubeClient delete. +// +// It only prints out the content to be deleted. +func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, _ metav1.DeletionPropagation) (*kube.Result, []error) { + _, err := io.Copy(p.Out, bufferize(resources)) + if err != nil { + return nil, []error{err} + } + return &kube.Result{Deleted: resources}, nil +} + // Update implements KubeClient Update. func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ ...kube.ClientUpdateOption) (*kube.Result, error) { _, err := io.Copy(p.Out, bufferize(modified)) @@ -135,17 +146,6 @@ func (p *PrintingKubeClient) OutputContainerLogsForPodList(_ *v1.PodList, someNa return err } -// DeleteWithPropagationPolicy implements KubeClient delete. -// -// It only prints out the content to be deleted. -func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, _ metav1.DeletionPropagation) (*kube.Result, []error) { - _, err := io.Copy(p.Out, bufferize(resources)) - if err != nil { - return nil, []error{err} - } - return &kube.Result{Deleted: resources}, nil -} - func (p *PrintingKubeClient) GetWaiter(_ kube.WaitStrategy) (kube.Waiter, error) { return &PrintingKubeWaiter{Out: p.Out, LogOutput: p.LogOutput}, nil } diff --git a/pkg/release/v1/release.go b/pkg/release/v1/release.go index 74e834f7b..a7f076e04 100644 --- a/pkg/release/v1/release.go +++ b/pkg/release/v1/release.go @@ -19,6 +19,11 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" ) +type ApplyMethod string + +const ApplyMethodClientSideApply ApplyMethod = "csa" +const ApplyMethodServerSideApply ApplyMethod = "ssa" + // Release describes a deployment of a chart, together with the chart // and the variables used to deploy that chart. type Release struct { @@ -42,6 +47,9 @@ type Release struct { // Labels of the release. // Disabled encoding into Json cause labels are stored in storage driver metadata field. Labels map[string]string `json:"-"` + // ApplyMethod stores whether server-side or client-side apply was used for the release + // Unset (empty string) should be treated as the default of client-side apply + ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa" } // SetStatus is a helper for setting the status on a release.