feat: add support for helm 4 --server-side upgrade flag (#2641)

* feat: add support for helm 4 --server-side upgrade flag

Add support for the helm 4 upgrade flag --server-side which accepts
"true", "false", or "auto" (default "auto"). This allows users to
explicitly control server-side apply behavior, which is needed for
releases originally installed with Helm 3 and being managed with Helm 4.

The flag can be configured via:
- CLI: --server-side flag on sync, apply, and diff commands
- helmDefaults.serverSide in helmfile.yaml
- releases[].serverSide per-release override

Precedence: release-level > CLI flag > helmDefaults.
Errors are returned when serverSide is set but running Helm 3, or when
an invalid value is provided.

Closes #2640

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

* test: update TestGenerateID expected hashes for new ServerSide field

Adding ServerSide *string to ReleaseSpec changes spew's %#v output and
shifts the FNV hash used by generateValuesID. Update the hard-coded want
values to the new deterministic hashes.

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

---------

Signed-off-by: yxxhero <aiopsclub@163.com>
This commit is contained in:
yxxhero 2026-06-16 08:24:47 +08:00 committed by GitHub
parent 047fdcd17d
commit a94fdf4194
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 265 additions and 6 deletions

View File

@ -57,6 +57,7 @@ func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.BoolVar(&applyOptions.NoHooks, "no-hooks", false, "do not diff changes made by hooks.")
f.BoolVar(&applyOptions.HideNotes, "hide-notes", false, "add --hide-notes flag to helm")
f.BoolVar(&applyOptions.TakeOwnership, "take-ownership", false, "add --take-ownership flag to helm")
f.StringVar(&applyOptions.ServerSide, "server-side", "", `add --server-side flag to helm upgrade (Helm 4 only). Must be "true", "false", or "auto"`)
f.BoolVar(&applyOptions.SyncReleaseLabels, "sync-release-labels", false, "sync release labels to the target release")
f.BoolVar(&applyOptions.SuppressDiff, "suppress-diff", false, "suppress diff in the output. Usable in new installs")
f.BoolVar(&applyOptions.Wait, "wait", false, `Override helmDefaults.wait setting "helm upgrade --install --wait"`)

View File

@ -54,6 +54,7 @@ func NewDiffCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.BoolVar(&diffOptions.ReuseValues, "reuse-values", false, `Override helmDefaults.reuseValues "helm diff upgrade --install --reuse-values"`)
f.BoolVar(&diffOptions.ResetValues, "reset-values", false, `Override helmDefaults.reuseValues "helm diff upgrade --install --reset-values"`)
f.BoolVar(&diffOptions.TakeOwnership, "take-ownership", false, "add --take-ownership flag to helm")
f.StringVar(&diffOptions.ServerSide, "server-side", "", `add --server-side flag to helm diff upgrade (Helm 4 only). Must be "true", "false", or "auto"`)
f.StringVar(&diffOptions.PostRenderer, "post-renderer", "", `pass --post-renderer to "helm template" or "helm upgrade --install"`)
f.StringArrayVar(&diffOptions.PostRendererArgs, "post-renderer-args", nil, `pass --post-renderer-args to "helm template" or "helm upgrade --install"`)
f.StringArrayVar(&diffOptions.SuppressOutputLineRegex, "suppress-output-line-regex", nil, "a list of regex patterns to suppress output lines from the diff output")

View File

@ -44,6 +44,7 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.BoolVar(&syncOptions.EnforceNeedsAreInstalled, "enforce-needs-are-installed", false, "enforce that all 'needs' dependencies are installable before applying changes")
f.BoolVar(&syncOptions.HideNotes, "hide-notes", false, "add --hide-notes flag to helm")
f.BoolVar(&syncOptions.TakeOwnership, "take-ownership", false, `add --take-ownership flag to helm`)
f.StringVar(&syncOptions.ServerSide, "server-side", "", `add --server-side flag to helm upgrade (Helm 4 only). Must be "true", "false", or "auto"`)
f.BoolVar(&syncOptions.SyncReleaseLabels, "sync-release-labels", false, "sync release labels to the target release")
f.BoolVar(&syncOptions.Wait, "wait", false, `Override helmDefaults.wait setting "helm upgrade --install --wait"`)
f.BoolVar(&syncOptions.WaitForJobs, "wait-for-jobs", false, `Override helmDefaults.waitForJobs setting "helm upgrade --install --wait-for-jobs"`)

View File

@ -433,6 +433,7 @@ The following `helmDefaults` fields are also available but not shown in the exam
| `skipRefresh` | bool | false | Skip running `helm dependency up` |
| `forceConflicts` | bool | false | Force server-side apply changes against conflicts (Helm 4 only) |
| `takeOwnership` | bool | false | Take ownership of existing resources |
| `serverSide` | string | | Controls the helm 4 `--server-side` flag. Must be `"true"`, `"false"`, or `"auto"` (Helm 4 only) |
| `trackMode` | string | `""` | Default tracking mode for resources. See [Advanced Features](advanced-features.md#resource-tracking-with-kubedog) |
| `disableAutoDetectedKubeVersionForDiff` | bool | false | Disable auto-detected kubeVersion being passed to helm diff |
@ -456,6 +457,7 @@ The following per-release fields are also available:
| `skipRefresh` | bool | false | Per-release skip for `helm dependency up` |
| `disableAutoDetectedKubeVersionForDiff` | bool | false | Disable auto-detected kubeVersion for helm diff on this release |
| `takeOwnership` | bool | false | Take ownership of existing resources for this release |
| `serverSide` | string | | Controls the helm 4 `--server-side` flag for this release. Must be `"true"`, `"false"`, or `"auto"` (Helm 4 only) |
| `forceConflicts` | bool | false | Force server-side apply against conflicts (Helm 4 only) |
| `description` | string | | Description of the release |
| `enableDNS` | bool | false | Enable DNS lookups when rendering templates |

View File

@ -1816,6 +1816,7 @@ func (a *App) apply(r *Run, c ApplyConfigProvider) (bool, bool, []error) {
SkipSchemaValidation: c.SkipSchemaValidation(),
SuppressOutputLineRegex: c.SuppressOutputLineRegex(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
DetectedKubeVersion: detectedKubeVersion,
}
@ -1937,6 +1938,7 @@ Do you really want to apply?
SyncArgs: c.SyncArgs(),
HideNotes: c.HideNotes(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
SyncReleaseLabels: c.SyncReleaseLabels(),
TrackMode: c.TrackMode(),
TrackTimeout: c.TrackTimeout(),
@ -2096,6 +2098,7 @@ func (a *App) diff(r *Run, c DiffConfigProvider) (*string, bool, bool, []error)
SkipSchemaValidation: c.SkipSchemaValidation(),
SuppressOutputLineRegex: c.SuppressOutputLineRegex(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
DetectedKubeVersion: detectedKubeVersion,
}
@ -2335,6 +2338,7 @@ func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, bool, []error) {
SkipSchemaValidation: diffC.SkipSchemaValidation(),
SuppressOutputLineRegex: diffC.SuppressOutputLineRegex(),
TakeOwnership: diffC.TakeOwnership(),
ServerSide: diffC.ServerSide(),
DetectedKubeVersion: detectedKubeVersion,
}
infoMsgPtr, _, _, diffErrs := r.diff(false, diffC.DetailedExitcode(), diffC, diffOpts)
@ -2422,6 +2426,7 @@ Do you really want to sync?
SyncArgs: c.SyncArgs(),
HideNotes: c.HideNotes(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
SkipSchemaValidation: c.SkipSchemaValidation(),
SyncReleaseLabels: c.SyncReleaseLabels(),
TrackMode: c.TrackMode(),

View File

@ -2539,6 +2539,7 @@ type applyConfig struct {
showOnly []string
hideNotes bool
takeOwnership bool
serverSide string
syncReleaseLabels bool
enforceNeedsAreInstalled bool
trackMode string
@ -2755,6 +2756,10 @@ func (a applyConfig) TakeOwnership() bool {
return a.takeOwnership
}
func (a applyConfig) ServerSide() string {
return a.serverSide
}
func (a applyConfig) SyncReleaseLabels() bool {
return a.syncReleaseLabels
}

View File

@ -49,6 +49,7 @@ type ApplyConfigProvider interface {
Cascade() string
HideNotes() bool
TakeOwnership() bool
ServerSide() string
SuppressOutputLineRegex() []string
Values() []string
@ -108,6 +109,7 @@ type SyncConfigProvider interface {
PostRendererArgs() []string
HideNotes() bool
TakeOwnership() bool
ServerSide() string
Cascade() string
Values() []string
@ -176,6 +178,7 @@ type DiffConfigProvider interface {
Context() int
DiffOutput() string
TakeOwnership() bool
ServerSide() string
concurrencyConfig
valuesControlMode

View File

@ -47,6 +47,7 @@ type diffConfig struct {
reuseValues bool
logger *zap.SugaredLogger
takeOwnership bool
serverSide string
enforceNeedsAreInstalled bool
}
@ -188,6 +189,9 @@ func (a diffConfig) SuppressOutputLineRegex() []string {
func (a diffConfig) TakeOwnership() bool {
return a.takeOwnership
}
func (a diffConfig) ServerSide() string {
return a.serverSide
}
func (a diffConfig) EnforceNeedsAreInstalled() bool {
return a.enforceNeedsAreInstalled

View File

@ -81,6 +81,9 @@ type ApplyOptions struct {
// TakeOwnership is true if the ownership should be taken
TakeOwnership bool
// ServerSide controls the helm 4 --server-side flag. Must be "true", "false", or "auto".
ServerSide string
SyncReleaseLabels bool
// TrackMode specifies whether to use 'helm' or 'kubedog' for tracking resources
TrackMode string
@ -298,6 +301,11 @@ func (a *ApplyImpl) TakeOwnership() bool {
return a.ApplyOptions.TakeOwnership
}
// ServerSide returns the ServerSide.
func (a *ApplyImpl) ServerSide() string {
return a.ApplyOptions.ServerSide
}
// SyncReleaseLabels returns the SyncReleaseLabels.
func (a *ApplyImpl) SyncReleaseLabels() bool {
return a.ApplyOptions.SyncReleaseLabels

View File

@ -53,6 +53,8 @@ type DiffOptions struct {
SkipSchemaValidation bool
// TakeOwnership is true if the ownership should be taken
TakeOwnership bool
// ServerSide controls the helm 4 --server-side flag. Must be "true", "false", or "auto".
ServerSide string
}
// NewDiffOptions creates a new Apply
@ -219,3 +221,8 @@ func (t *DiffImpl) SkipSchemaValidation() bool {
func (t *DiffImpl) TakeOwnership() bool {
return t.DiffOptions.TakeOwnership
}
// ServerSide returns the ServerSide.
func (t *DiffImpl) ServerSide() string {
return t.DiffOptions.ServerSide
}

View File

@ -51,6 +51,8 @@ type SyncOptions struct {
HideNotes bool
// TakeOwnership is the take ownership flag
TakeOwnership bool
// ServerSide controls the helm 4 --server-side flag. Must be "true", "false", or "auto".
ServerSide string
// SyncReleaseLabels is the sync release labels flag
SyncReleaseLabels bool
// TrackMode specifies whether to use 'helm' or 'kubedog' for tracking resources
@ -214,6 +216,11 @@ func (t *SyncImpl) TakeOwnership() bool {
return t.SyncOptions.TakeOwnership
}
// ServerSide returns the server-side value.
func (t *SyncImpl) ServerSide() string {
return t.SyncOptions.ServerSide
}
func (t *SyncImpl) SyncReleaseLabels() bool {
return t.SyncOptions.SyncReleaseLabels
}

View File

@ -354,6 +354,44 @@ func (st *HelmState) appendTakeOwnershipFlagsForUpgrade(flags []string, helm hel
return flags
}
// validServerSideValues are the allowed values for the helm 4 --server-side flag.
var validServerSideValues = map[string]struct{}{"true": {}, "false": {}, "auto": {}}
// appendServerSideFlagsForUpgrade appends the helm 4 --server-side flag when appropriate.
// Precedence: release-level > CLI flag > helmDefaults.
func (st *HelmState) appendServerSideFlagsForUpgrade(flags []string, helm helmexec.Interface, release *ReleaseSpec, serverSide string) ([]string, error) {
if !helm.IsHelm4() {
// --server-side with a string value is helm 4 only. Guard against misconfiguration
// when the user is running helm 3.
if release.ServerSide != nil && *release.ServerSide != "" {
return nil, fmt.Errorf("serverSide requires Helm 4 or greater (set via releases[].serverSide)")
}
if st.HelmDefaults.ServerSide != nil && *st.HelmDefaults.ServerSide != "" {
return nil, fmt.Errorf("serverSide requires Helm 4 or greater (set via helmDefaults.serverSide)")
}
return flags, nil
}
var value string
switch {
case release.ServerSide != nil && *release.ServerSide != "":
value = *release.ServerSide
case serverSide != "":
value = serverSide
case st.HelmDefaults.ServerSide != nil && *st.HelmDefaults.ServerSide != "":
value = *st.HelmDefaults.ServerSide
default:
return flags, nil
}
if _, ok := validServerSideValues[value]; !ok {
return nil, fmt.Errorf("invalid serverSide value %q: must be \"true\", \"false\", or \"auto\"", value)
}
flags = append(flags, "--server-side", value)
return flags, nil
}
// append show-only flags to helm flags
func (st *HelmState) appendShowOnlyFlags(flags []string, showOnly []string) []string {
showOnlyFlags := []string{}

View File

@ -572,3 +572,157 @@ func TestFormatLabels(t *testing.T) {
})
}
}
func strPtr(s string) *string {
return &s
}
func TestAppendServerSideFlagsForUpgrade(t *testing.T) {
type args struct {
flags []string
helm helmexec.Interface
helmSpec HelmSpec
opt *SyncOpts
release *ReleaseSpec
expected []string
wantErr bool
}
tests := []struct {
name string
args args
}{
{
name: "no server-side when not configured",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
release: &ReleaseSpec{},
opt: &SyncOpts{},
expected: []string{},
},
},
{
name: "server-side from cmd flag",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
release: &ReleaseSpec{},
opt: &SyncOpts{ServerSide: "true"},
expected: []string{"--server-side", "true"},
},
},
{
name: "server-side from release",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
release: &ReleaseSpec{ServerSide: strPtr("false")},
opt: &SyncOpts{},
expected: []string{"--server-side", "false"},
},
},
{
name: "server-side from helmDefaults",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
helmSpec: HelmSpec{ServerSide: strPtr("auto")},
release: &ReleaseSpec{},
opt: &SyncOpts{},
expected: []string{"--server-side", "auto"},
},
},
{
name: "release-level overrides cmd flag",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
release: &ReleaseSpec{ServerSide: strPtr("true")},
opt: &SyncOpts{ServerSide: "false"},
expected: []string{"--server-side", "true"},
},
},
{
name: "cmd flag overrides helmDefaults",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
helmSpec: HelmSpec{ServerSide: strPtr("false")},
release: &ReleaseSpec{},
opt: &SyncOpts{ServerSide: "true"},
expected: []string{"--server-side", "true"},
},
},
{
name: "helm 3 with no config is ignored",
args: args{
flags: []string{},
helm: testutil.NewVersionHelmExec("3.17.0"),
release: &ReleaseSpec{},
opt: &SyncOpts{},
expected: []string{},
},
},
{
name: "helm 3 with release-level config errors",
args: args{
flags: []string{},
helm: testutil.NewVersionHelmExec("3.17.0"),
release: &ReleaseSpec{ServerSide: strPtr("true")},
opt: &SyncOpts{},
expected: []string{},
wantErr: true,
},
},
{
name: "helm 3 with helmDefaults config errors",
args: args{
flags: []string{},
helm: testutil.NewVersionHelmExec("3.17.0"),
helmSpec: HelmSpec{ServerSide: strPtr("true")},
release: &ReleaseSpec{},
opt: &SyncOpts{},
expected: []string{},
wantErr: true,
},
},
{
name: "invalid value errors",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
release: &ReleaseSpec{},
opt: &SyncOpts{ServerSide: "yes"},
expected: []string{},
wantErr: true,
},
},
{
name: "empty release-level value falls through to cmd flag",
args: args{
flags: []string{},
helm: testutil.NewHelmExec(true),
release: &ReleaseSpec{ServerSide: strPtr("")},
opt: &SyncOpts{ServerSide: "auto"},
expected: []string{"--server-side", "auto"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := &HelmState{}
st.HelmDefaults = tt.args.helmSpec
optServerSide := ""
if tt.args.opt != nil {
optServerSide = tt.args.opt.ServerSide
}
got, err := st.appendServerSideFlagsForUpgrade(tt.args.flags, tt.args.helm, tt.args.release, optServerSide)
if tt.args.wantErr {
require.Error(t, err, "appendServerSideFlagsForUpgrade() should return error")
} else {
require.NoError(t, err, "appendServerSideFlagsForUpgrade() should not return error")
require.Equalf(t, tt.args.expected, got, "appendServerSideFlagsForUpgrade() = %v, want %v", got, tt.args.expected)
}
})
}
}

View File

@ -250,6 +250,8 @@ type HelmSpec struct {
SyncReleaseLabels *bool `yaml:"syncReleaseLabels,omitempty"`
// TakeOwnership is true if the helmfile should take ownership of the release
TakeOwnership *bool `yaml:"takeOwnership,omitempty"`
// ServerSide controls the helm 4 --server-side flag for upgrade. Must be "true", "false", or "auto".
ServerSide *string `yaml:"serverSide,omitempty"`
// TrackMode specifies whether to use 'helm' or 'kubedog' for tracking resources
TrackMode string `yaml:"trackMode,omitempty"`
}
@ -477,6 +479,8 @@ type ReleaseSpec struct {
SyncReleaseLabels *bool `yaml:"syncReleaseLabels,omitempty"`
// TakeOwnership is true if release should take ownership of resources
TakeOwnership *bool `yaml:"takeOwnership,omitempty"`
// ServerSide controls the helm 4 --server-side flag for this release. Must be "true", "false", or "auto".
ServerSide *string `yaml:"serverSide,omitempty"`
// TrackMode specifies whether to use 'helm' or 'kubedog' for tracking resources
TrackMode string `yaml:"trackMode,omitempty"`
// TrackTimeout specifies timeout for kubedog tracking (in seconds)
@ -965,6 +969,7 @@ type SyncOpts struct {
SyncArgs string
HideNotes bool
TakeOwnership bool
ServerSide string
TrackMode string
TrackTimeout int
TrackLogs bool
@ -2908,6 +2913,7 @@ type DiffOpts struct {
SuppressOutputLineRegex []string
SkipSchemaValidation bool
TakeOwnership bool
ServerSide string
// DetectedKubeVersion is the Kubernetes version detected from the cluster.
// This is used when kubeVersion is not specified in helmfile.yaml
DetectedKubeVersion string
@ -3766,10 +3772,12 @@ func (st *HelmState) flagsForUpgrade(helm helmexec.Interface, release *ReleaseSp
postRenderer := ""
syncReleaseLabels := false
takeOwnership := false
serverSide := ""
if opt != nil {
postRenderer = opt.PostRenderer
syncReleaseLabels = opt.SyncReleaseLabels
takeOwnership = opt.TakeOwnership
serverSide = opt.ServerSide
}
flags = st.appendConnectionFlags(flags, release)
@ -3808,6 +3816,13 @@ func (st *HelmState) flagsForUpgrade(helm helmexec.Interface, release *ReleaseSp
// append take-ownership flag
flags = st.appendTakeOwnershipFlagsForUpgrade(flags, helm, release, takeOwnership)
// append server-side flag
var ssErr error
flags, ssErr = st.appendServerSideFlagsForUpgrade(flags, helm, release, serverSide)
if ssErr != nil {
return nil, nil, ssErr
}
flags = st.appendExtraSyncFlags(flags, opt)
common, clean, err := st.namespaceAndValuesFlags(helm, release, workerIndex)
@ -3992,8 +4007,10 @@ func (st *HelmState) flagsForDiff(helm helmexec.Interface, release *ReleaseSpec,
}
takeOwnership := false
serverSide := ""
if opt != nil {
takeOwnership = opt.TakeOwnership
serverSide = opt.ServerSide
}
flags, err = st.appendTakeOwnershipFlagsForDiff(flags, release, takeOwnership, pluginsDir)
@ -4001,6 +4018,12 @@ func (st *HelmState) flagsForDiff(helm helmexec.Interface, release *ReleaseSpec,
return nil, nil, err
}
// append server-side flag
flags, err = st.appendServerSideFlagsForUpgrade(flags, helm, release, serverSide)
if err != nil {
return nil, nil, err
}
common, files, err := st.namespaceAndValuesFlags(helm, release, workerIndex)
if err != nil {
return nil, files, err

View File

@ -38,39 +38,39 @@ func TestGenerateID(t *testing.T) {
run(testcase{
subject: "baseline",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
want: "foo-values-7f6f8d74dd",
want: "foo-values-56c9b878cf",
})
run(testcase{
subject: "different bytes content",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
data: []byte(`{"k":"v"}`),
want: "foo-values-5fc74c864c",
want: "foo-values-55d46b7dd4",
})
run(testcase{
subject: "different map content",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
data: map[string]any{"k": "v"},
want: "foo-values-77df88dd65",
want: "foo-values-8c574f646",
})
run(testcase{
subject: "different chart",
release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"},
want: "foo-values-77c96457f7",
want: "foo-values-664cdfb57d",
})
run(testcase{
subject: "different name",
release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"},
want: "bar-values-6695f7ff4c",
want: "bar-values-fbb5fd45b",
})
run(testcase{
subject: "specific ns",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"},
want: "myns-foo-values-9b9484d4c",
want: "myns-foo-values-7db84f99b",
})
for id, n := range ids {