mirror of https://github.com/helm/helm
The sequenced deployment engine driving per-batch create-and-wait across the resource-group and subchart DAGs, wired into install, upgrade, rollback (respecting stored SequencingInfo), and reverse-order uninstall. Default (non-ordered) paths are unchanged. Refs: HIP-0025 Signed-off-by: Rohit Gudi <50377477+caretak3r@users.noreply.github.com>
parent
8800cd7a3c
commit
4bc613cb67
@ -0,0 +1,127 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"helm.sh/helm/v4/pkg/chart/common"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
rcommon "helm.sh/helm/v4/pkg/release/common"
|
||||
release "helm.sh/helm/v4/pkg/release/v1"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func TestBackwardCompat_OrderedWaitWithoutAnnotations(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
buf := captureWarningOutput(t)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/first.yaml", "first", nil),
|
||||
makeConfigMapTemplate("templates/second.yaml", "second", nil),
|
||||
}, withName("backward-compat-no-annotations"))
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
||||
|
||||
require.Len(t, client.createCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/first", "ConfigMap/second"}, client.createCalls[0])
|
||||
require.Len(t, client.waitCalls, 1)
|
||||
assert.ElementsMatch(t, client.createCalls[0], client.waitCalls[0])
|
||||
assert.Empty(t, buf.String())
|
||||
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
||||
}
|
||||
|
||||
func TestBackwardCompat_RollbackWithoutSequencingInfo(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("backward-compat-rollback"))
|
||||
seedRollbackRelease(t, rollback, "backward-compat-rollback", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("backward-compat-rollback"))
|
||||
seedRollbackRelease(t, rollback, "backward-compat-rollback", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
rollback.Version = 1
|
||||
|
||||
var runErr error
|
||||
assert.NotPanics(t, func() {
|
||||
runErr = rollback.Run("backward-compat-rollback")
|
||||
})
|
||||
require.NoError(t, runErr)
|
||||
|
||||
require.Len(t, client.updateCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
|
||||
require.Len(t, client.waitCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0])
|
||||
assert.Nil(t, latestRollbackRelease(t, rollback, "backward-compat-rollback").SequencingInfo)
|
||||
}
|
||||
|
||||
func TestBackwardCompat_MixedAnnotationsUnsequencedLast(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
buf := captureWarningOutput(t)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/plain-one.yaml", "plain-one", nil),
|
||||
makeConfigMapTemplate("templates/plain-two.yaml", "plain-two", nil),
|
||||
}, withName("backward-compat-mixed"))
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
||||
|
||||
require.Len(t, client.createCalls, 3)
|
||||
assert.Equal(t, []string{"ConfigMap/database"}, client.createCalls[0])
|
||||
assert.Equal(t, []string{"ConfigMap/app"}, client.createCalls[1])
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/plain-one", "ConfigMap/plain-two"}, client.createCalls[2])
|
||||
// Every batch, including terminal groups, is waited on.
|
||||
require.Len(t, client.waitCalls, 3)
|
||||
assert.Equal(t, []string{"ConfigMap/database"}, client.waitCalls[0])
|
||||
assert.Equal(t, []string{"ConfigMap/app"}, client.waitCalls[1])
|
||||
assert.ElementsMatch(t, client.createCalls[2], client.waitCalls[2])
|
||||
assert.Empty(t, buf.String())
|
||||
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
||||
}
|
||||
@ -0,0 +1,498 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"helm.sh/helm/v4/pkg/chart/common"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
rcommon "helm.sh/helm/v4/pkg/release/common"
|
||||
release "helm.sh/helm/v4/pkg/release/v1"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func newRollbackAction(t *testing.T, kubeClient kube.Interface) *Rollback {
|
||||
t.Helper()
|
||||
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.KubeClient = kubeClient
|
||||
|
||||
rollback := NewRollback(cfg)
|
||||
rollback.Timeout = 5 * time.Minute
|
||||
rollback.WaitStrategy = kube.OrderedWaitStrategy
|
||||
rollback.ServerSideApply = "auto"
|
||||
|
||||
return rollback
|
||||
}
|
||||
|
||||
func seedRollbackRelease(t *testing.T, rollback *Rollback, name string, version int, status rcommon.Status, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
rel := &release.Release{
|
||||
Name: name,
|
||||
Namespace: "spaced",
|
||||
Chart: ch,
|
||||
Config: map[string]any{},
|
||||
Manifest: manifest,
|
||||
Info: &release.Info{
|
||||
FirstDeployed: now,
|
||||
LastDeployed: now,
|
||||
Status: status,
|
||||
Description: "Seeded release",
|
||||
},
|
||||
Version: version,
|
||||
ApplyMethod: "csa",
|
||||
SequencingInfo: sequencingInfo,
|
||||
}
|
||||
|
||||
require.NoError(t, rollback.cfg.Releases.Create(rel))
|
||||
}
|
||||
|
||||
func storedRollbackRelease(t *testing.T, rollback *Rollback, name string, version int) *release.Release {
|
||||
t.Helper()
|
||||
|
||||
reli, err := rollback.cfg.Releases.Get(name, version)
|
||||
require.NoError(t, err)
|
||||
|
||||
rel, err := releaserToV1Release(reli)
|
||||
require.NoError(t, err)
|
||||
|
||||
return rel
|
||||
}
|
||||
|
||||
func latestRollbackRelease(t *testing.T, rollback *Rollback, name string) *release.Release {
|
||||
t.Helper()
|
||||
|
||||
reli, err := rollback.cfg.Releases.Last(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
rel, err := releaserToV1Release(reli)
|
||||
require.NoError(t, err)
|
||||
|
||||
return rel
|
||||
}
|
||||
|
||||
func TestRollback_Sequenced_Basic(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("rollback-basic"))
|
||||
|
||||
sequencingInfo := &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}
|
||||
seedRollbackRelease(t, rollback, "rollback-basic", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), sequencingInfo)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-basic"))
|
||||
seedRollbackRelease(t, rollback, "rollback-basic", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-basic"))
|
||||
|
||||
require.Len(t, client.updateCalls, 2)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
|
||||
assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].current)
|
||||
assert.Equal(t, []string{"ConfigMap/app"}, client.updateCalls[1].current)
|
||||
|
||||
rel := latestRollbackRelease(t, rollback, "rollback-basic")
|
||||
assert.Equal(t, 3, rel.Version)
|
||||
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
||||
assert.True(t, rel.IsSequenced())
|
||||
assert.True(t, rel.Sequenced)
|
||||
assert.Nil(t, rel.SequencingInfo)
|
||||
}
|
||||
|
||||
// TestRollback_Sequenced_RemovedGroupsDeletedInReverseOrder locks bead
|
||||
// 7yi-rollback: resources present in the from-revision but absent from the
|
||||
// rollback target must be deleted batch-by-batch in exact reverse deployment
|
||||
// order of the FROM revision's plan, with a delete-wait gating each batch,
|
||||
// not in one unordered bulk call.
|
||||
func TestRollback_Sequenced_RemovedGroupsDeletedInReverseOrder(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-removed"))
|
||||
seedRollbackRelease(t, rollback, "rollback-removed", 1, rcommon.StatusSuperseded, targetChart, configMapManifest("app", nil), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-removed"))
|
||||
seedRollbackRelease(t, rollback, "rollback-removed", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
configMapManifest("app", nil),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-removed"))
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/queue"}, {"ConfigMap/database"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
// TestRollback_Sequenced_DefaultWaitStrategyGatesBatches locks bead 4sj for
|
||||
// rollback: a rollback to a sequenced revision under the DEFAULT hookOnly
|
||||
// strategy must gate each re-deploy batch on real readiness waits.
|
||||
func TestRollback_Sequenced_DefaultWaitStrategyGatesBatches(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.KubeClient = client
|
||||
|
||||
rollback := NewRollback(cfg)
|
||||
rollback.Timeout = 5 * time.Minute
|
||||
rollback.WaitStrategy = kube.HookOnlyStrategy
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("rollback-default-wait"))
|
||||
|
||||
now := time.Now()
|
||||
require.NoError(t, rollback.cfg.Releases.Create(&release.Release{
|
||||
Name: "rollback-default-wait",
|
||||
Namespace: "spaced",
|
||||
Chart: targetChart,
|
||||
Config: map[string]any{},
|
||||
Manifest: joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
),
|
||||
Info: &release.Info{
|
||||
FirstDeployed: now,
|
||||
LastDeployed: now,
|
||||
Status: rcommon.StatusSuperseded,
|
||||
Description: "Seeded release",
|
||||
},
|
||||
Version: 1,
|
||||
ApplyMethod: "csa",
|
||||
Sequenced: true,
|
||||
}))
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-default-wait"))
|
||||
seedRollbackRelease(t, rollback, "rollback-default-wait", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-default-wait"))
|
||||
|
||||
require.Len(t, client.updateCalls, 2)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
|
||||
assert.Contains(t, client.waiterStrategies, kube.StatusWatcherStrategy)
|
||||
}
|
||||
|
||||
func TestRollback_ToNonSequenced(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("rollback-to-standard"))
|
||||
seedRollbackRelease(t, rollback, "rollback-to-standard", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-to-standard"))
|
||||
seedRollbackRelease(t, rollback, "rollback-to-standard", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-to-standard"))
|
||||
|
||||
require.Len(t, client.updateCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
|
||||
require.Len(t, client.waitCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0])
|
||||
assert.False(t, latestRollbackRelease(t, rollback, "rollback-to-standard").IsSequenced())
|
||||
}
|
||||
|
||||
func TestRollback_FromSequencedToNonSequenced(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-transition"))
|
||||
seedRollbackRelease(t, rollback, "rollback-transition", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("rollback-transition"))
|
||||
seedRollbackRelease(t, rollback, "rollback-transition", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-transition"))
|
||||
|
||||
require.Len(t, client.updateCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
|
||||
assert.False(t, latestRollbackRelease(t, rollback, "rollback-transition").IsSequenced())
|
||||
}
|
||||
|
||||
func TestRollback_SequencedFlagPreservedFromLegacyRelease(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
}, withName("rollback-sequencing-info"))
|
||||
sequencingInfo := &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}
|
||||
seedRollbackRelease(t, rollback, "rollback-sequencing-info", 1, rcommon.StatusSuperseded, targetChart, configMapManifest("database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}), sequencingInfo)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
}, withName("rollback-sequencing-info"))
|
||||
seedRollbackRelease(t, rollback, "rollback-sequencing-info", 2, rcommon.StatusDeployed, currentChart, configMapManifest("database", nil), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-sequencing-info"))
|
||||
|
||||
rel := latestRollbackRelease(t, rollback, "rollback-sequencing-info")
|
||||
assert.True(t, rel.IsSequenced())
|
||||
assert.True(t, rel.Sequenced)
|
||||
assert.Nil(t, rel.SequencingInfo)
|
||||
}
|
||||
|
||||
func TestRollback_Sequenced_Failure(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 2
|
||||
client.waitError = errors.New("timed out waiting for batch")
|
||||
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("rollback-failure"))
|
||||
seedRollbackRelease(t, rollback, "rollback-failure", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-failure"))
|
||||
seedRollbackRelease(t, rollback, "rollback-failure", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
err := rollback.Run("rollback-failure")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out waiting for batch")
|
||||
|
||||
require.Len(t, client.updateCalls, 2)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}}, updateTargets(client.updateCalls))
|
||||
assert.Empty(t, client.deleteCalls)
|
||||
|
||||
assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-failure").Info.Status)
|
||||
assert.Equal(t, rcommon.StatusSuperseded, storedRollbackRelease(t, rollback, "rollback-failure", 2).Info.Status)
|
||||
}
|
||||
|
||||
func TestRollback_Sequenced_CleanupOnFail(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 2
|
||||
client.waitError = errors.New("timed out waiting for batch")
|
||||
|
||||
rollback := newRollbackAction(t, client)
|
||||
rollback.CleanupOnFail = true
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("rollback-cleanup"))
|
||||
seedRollbackRelease(t, rollback, "rollback-cleanup", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-cleanup"))
|
||||
seedRollbackRelease(t, rollback, "rollback-cleanup", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
err := rollback.Run("rollback-cleanup")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out waiting for batch")
|
||||
assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls)
|
||||
assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-cleanup").Info.Status)
|
||||
}
|
||||
|
||||
func TestRollback_Sequenced_DryRun(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
rollback.DryRunStrategy = DryRunClient
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("rollback-dry-run"))
|
||||
seedRollbackRelease(t, rollback, "rollback-dry-run", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-dry-run"))
|
||||
seedRollbackRelease(t, rollback, "rollback-dry-run", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-dry-run"))
|
||||
|
||||
assert.Empty(t, client.updateCalls)
|
||||
assert.Empty(t, client.waitCalls)
|
||||
assert.Empty(t, client.deleteCalls)
|
||||
assert.Equal(t, 2, latestRollbackRelease(t, rollback, "rollback-dry-run").Version)
|
||||
}
|
||||
@ -0,0 +1,375 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
release "helm.sh/helm/v4/pkg/release/v1"
|
||||
"helm.sh/helm/v4/pkg/release/v1/sequence"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
)
|
||||
|
||||
// computeDeadline returns a deadline based on the given timeout duration.
|
||||
// If timeout is zero or negative, it returns the zero time (no deadline).
|
||||
func computeDeadline(timeout time.Duration) time.Time {
|
||||
if timeout <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Now().Add(timeout)
|
||||
}
|
||||
|
||||
// GroupManifestsByDirectSubchart groups manifests by the direct subchart they belong to.
|
||||
// chartPath is the full path-prefix for the current chart level — at the top level
|
||||
// it is the chart name (e.g. "parent"); at deeper recursion levels it is the joined
|
||||
// path through "/charts/" segments (e.g. "parent/charts/sub").
|
||||
// The current chart level's own manifests are returned under the empty string key "".
|
||||
// Direct subcharts are keyed by their immediate directory name under
|
||||
// "<chartPath>/charts/<subchart>/". Nested grandchildren are grouped under their
|
||||
// direct subchart parent ("sub"), since nested sequencing is handled recursively.
|
||||
func GroupManifestsByDirectSubchart(manifests []releaseutil.Manifest, chartPath string) map[string][]releaseutil.Manifest {
|
||||
result := make(map[string][]releaseutil.Manifest)
|
||||
if chartPath == "" {
|
||||
result[""] = append(result[""], manifests...)
|
||||
return result
|
||||
}
|
||||
|
||||
chartsPrefix := chartPath + "/charts/"
|
||||
for _, m := range manifests {
|
||||
if !strings.HasPrefix(m.Name, chartsPrefix) {
|
||||
result[""] = append(result[""], m)
|
||||
continue
|
||||
}
|
||||
rest := m.Name[len(chartsPrefix):]
|
||||
subchartName, _, ok := strings.Cut(rest, "/")
|
||||
if !ok {
|
||||
result[""] = append(result[""], m)
|
||||
continue
|
||||
}
|
||||
result[subchartName] = append(result[subchartName], m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildManifestYAML concatenates the Content fields of the given manifests into a single
|
||||
// YAML stream suitable for passing to KubeClient.Build().
|
||||
func buildManifestYAML(manifests []releaseutil.Manifest) string {
|
||||
if len(manifests) == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
for i, m := range manifests {
|
||||
if i > 0 {
|
||||
buf.WriteString("---\n")
|
||||
}
|
||||
buf.WriteString(m.Content)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// sequencedDeployment executes a sequence.Plan: ordered installation, upgrade,
|
||||
// or rollback of chart resources, one batch at a time, gating each batch on
|
||||
// readiness before the next starts.
|
||||
type sequencedDeployment struct {
|
||||
cfg *Configuration
|
||||
releaseName string
|
||||
releaseNamespace string
|
||||
disableOpenAPI bool
|
||||
serverSideApply bool
|
||||
forceConflicts bool
|
||||
forceReplace bool
|
||||
waitStrategy kube.WaitStrategy
|
||||
waitOptions []kube.WaitOption
|
||||
waitForJobs bool
|
||||
timeout time.Duration
|
||||
readinessTimeout time.Duration
|
||||
deadline time.Time // overall operation deadline
|
||||
|
||||
// upgradeMode forces every batch through KubeClient.Update. When false
|
||||
// (install), a batch is still applied via Update when it intersects
|
||||
// currentResources (resources adopted with --take-ownership).
|
||||
upgradeMode bool
|
||||
// currentResources are the objects a batch may be an update of: the full
|
||||
// old-release resource list on upgrade/rollback, the toBeAdopted set on
|
||||
// install. Matched per batch by objectKey.
|
||||
currentResources kube.ResourceList
|
||||
upgradeCSAFieldManager bool // upgrade client-side apply field manager
|
||||
// threeWayMergeForUnstructured mirrors performInstall's adoption Update
|
||||
// (TakeOwnership && !ServerSideApply); always false on upgrade/rollback.
|
||||
threeWayMergeForUnstructured bool
|
||||
createdResources kube.ResourceList
|
||||
}
|
||||
|
||||
// logPlanWarnings surfaces non-fatal sequencing-plan warnings to the user.
|
||||
func logPlanWarnings(logger *slog.Logger, plan *sequence.Plan) {
|
||||
for _, w := range plan.Warnings {
|
||||
logger.Warn("sequencing: "+w.Message, "chart", w.ChartPath)
|
||||
}
|
||||
}
|
||||
|
||||
// getWaiterFor returns a waiter for the given strategy, preferring the
|
||||
// options-aware interface when the client supports it.
|
||||
func getWaiterFor(client kube.Interface, ws kube.WaitStrategy, opts ...kube.WaitOption) (kube.Waiter, error) {
|
||||
if c, ok := client.(kube.InterfaceWaitOptions); ok {
|
||||
return c.GetWaiterWithOptions(ws, opts...)
|
||||
}
|
||||
return client.GetWaiter(ws)
|
||||
}
|
||||
|
||||
// apply deploys the plan batch by batch, in order.
|
||||
func (s *sequencedDeployment) apply(ctx context.Context, plan *sequence.Plan) error {
|
||||
for _, batch := range plan.Batches {
|
||||
if err := s.applyBatch(ctx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBatch builds one batch's manifests, sets release metadata, strips
|
||||
// Helm-internal sequencing annotations, creates or updates the resources, and
|
||||
// waits for readiness. Context cancellation is honored at each stage boundary:
|
||||
// before Build, before Create/Update, and before Wait.
|
||||
func (s *sequencedDeployment) applyBatch(ctx context.Context, batch sequence.Batch) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
manifests := batch.Manifests()
|
||||
if len(manifests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
yaml := buildManifestYAML(manifests)
|
||||
target, err := s.cfg.KubeClient.Build(bytes.NewBufferString(yaml), !s.disableOpenAPI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building resource batch: %w", err)
|
||||
}
|
||||
if len(target) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := target.Visit(setMetadataVisitor(s.releaseName, s.releaseNamespace, true)); err != nil {
|
||||
return fmt.Errorf("setting metadata for resource batch: %w", err)
|
||||
}
|
||||
if err := stripSequencingAnnotations(target); err != nil {
|
||||
return fmt.Errorf("stripping sequencing annotations: %w", err)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetKeys := make(map[string]bool, len(target))
|
||||
for _, r := range target {
|
||||
targetKeys[objectKey(r)] = true
|
||||
}
|
||||
var matchingCurrent kube.ResourceList
|
||||
for _, r := range s.currentResources {
|
||||
if targetKeys[objectKey(r)] {
|
||||
matchingCurrent = append(matchingCurrent, r)
|
||||
}
|
||||
}
|
||||
|
||||
var result *kube.Result
|
||||
if s.upgradeMode || len(matchingCurrent) > 0 {
|
||||
result, err = s.cfg.KubeClient.Update(
|
||||
matchingCurrent,
|
||||
target,
|
||||
kube.ClientUpdateOptionForceReplace(s.forceReplace),
|
||||
kube.ClientUpdateOptionServerSideApply(s.serverSideApply, s.forceConflicts),
|
||||
kube.ClientUpdateOptionThreeWayMergeForUnstructured(s.threeWayMergeForUnstructured),
|
||||
kube.ClientUpdateOptionUpgradeClientSideFieldManager(s.upgradeCSAFieldManager),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating resource batch: %w", err)
|
||||
}
|
||||
} else {
|
||||
result, err = s.cfg.KubeClient.Create(
|
||||
target,
|
||||
kube.ClientCreateOptionServerSideApply(s.serverSideApply, false))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating resource batch: %w", err)
|
||||
}
|
||||
}
|
||||
s.createdResources = append(s.createdResources, result.Created...)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !batch.Wait {
|
||||
return nil
|
||||
}
|
||||
return s.waitForResources(target, batch.HasCustomReadiness)
|
||||
}
|
||||
|
||||
// stripSequencingAnnotations removes Helm-internal sequencing annotations from
|
||||
// resources before they are applied to Kubernetes. This prevents K8s API
|
||||
// validation errors for annotation keys that are not valid K8s label keys.
|
||||
func stripSequencingAnnotations(resources kube.ResourceList) error {
|
||||
return resources.Visit(func(info *resource.Info, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acc, accErr := meta.Accessor(info.Object)
|
||||
if accErr != nil {
|
||||
return nil // skip non-accessible objects
|
||||
}
|
||||
annotations := acc.GetAnnotations()
|
||||
if len(annotations) == 0 {
|
||||
return nil
|
||||
}
|
||||
changed := false
|
||||
for _, key := range releaseutil.HelmInternalSequencingAnnotations() {
|
||||
if _, exists := annotations[key]; exists {
|
||||
delete(annotations, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
acc.SetAnnotations(annotations)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// deleteRemoved deletes resources of the previous release that are absent from
|
||||
// the new one, walking an already-reversed plan of the OLD release so removal
|
||||
// happens in exact reverse deployment order, gating each batch on WaitForDelete
|
||||
// (HIP-0025 §Sequencing order, bead 7yi). removedKeys holds the objectKey()s
|
||||
// of resources to delete; batch resources outside the set are skipped.
|
||||
func (s *sequencedDeployment) deleteRemoved(reversedPlan *sequence.Plan, removedKeys map[string]bool, waiter kube.Waiter) error {
|
||||
for _, batch := range reversedPlan.Batches {
|
||||
manifests := batch.Manifests()
|
||||
if len(manifests) == 0 {
|
||||
continue
|
||||
}
|
||||
resources, err := s.cfg.KubeClient.Build(bytes.NewBufferString(buildManifestYAML(manifests)), false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building removed-resource batch: %w", err)
|
||||
}
|
||||
var toDelete kube.ResourceList
|
||||
for _, r := range resources {
|
||||
if removedKeys[objectKey(r)] {
|
||||
toDelete = append(toDelete, r)
|
||||
}
|
||||
}
|
||||
if len(toDelete) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, errs := s.cfg.KubeClient.Delete(toDelete, metav1.DeletePropagationBackground); errs != nil {
|
||||
return fmt.Errorf("deleting removed resources: %w", joinErrors(errs, ", "))
|
||||
}
|
||||
if !batch.Wait {
|
||||
continue
|
||||
}
|
||||
waitTimeout, err := s.batchWaitTimeout()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := waiter.WaitForDelete(toDelete, waitTimeout); err != nil {
|
||||
return fmt.Errorf("waiting for removed resources to be deleted: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRemovedFromOldRelease builds the OLD release's deployment plan from its
|
||||
// stored manifest and deletes the removed resources batch-by-batch in exact
|
||||
// reverse deployment order (HIP-0025 §Sequencing order, bead 7yi). When no plan
|
||||
// can be built from the stored release (pre-sequencing record, unparsable
|
||||
// manifest), it falls back to one unordered bulk delete with a warning.
|
||||
func (s *sequencedDeployment) deleteRemovedFromOldRelease(oldRelease *release.Release, removedKeys map[string]bool, toBeDeleted kube.ResourceList, waiter kube.Waiter) error {
|
||||
oldManifests, err := sequence.ParseStoredManifests(oldRelease.Manifest)
|
||||
var oldPlan *sequence.Plan
|
||||
if err == nil {
|
||||
oldPlan, err = sequence.Build(oldRelease.Chart, oldManifests)
|
||||
}
|
||||
if err != nil {
|
||||
s.cfg.Logger().Warn("unable to build sequencing plan for previous release; deleting removed resources unordered", slog.Any("error", err))
|
||||
if _, errs := s.cfg.KubeClient.Delete(toBeDeleted, metav1.DeletePropagationBackground); errs != nil {
|
||||
return fmt.Errorf("deleting removed resources: %w", joinErrors(errs, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return s.deleteRemoved(oldPlan.Reverse(), removedKeys, waiter)
|
||||
}
|
||||
|
||||
// batchWaitTimeout returns the effective per-batch wait timeout:
|
||||
// min(readinessTimeout, time remaining until the overall deadline).
|
||||
func (s *sequencedDeployment) batchWaitTimeout() (time.Duration, error) {
|
||||
waitTimeout := s.readinessTimeout
|
||||
if !s.deadline.IsZero() {
|
||||
remaining := time.Until(s.deadline)
|
||||
if remaining <= 0 {
|
||||
return 0, errors.New("overall timeout exceeded before waiting for resource batch")
|
||||
}
|
||||
if waitTimeout <= 0 || remaining < waitTimeout {
|
||||
waitTimeout = remaining
|
||||
}
|
||||
}
|
||||
if waitTimeout <= 0 {
|
||||
waitTimeout = time.Minute // safe default
|
||||
}
|
||||
return waitTimeout, nil
|
||||
}
|
||||
|
||||
// waitForResources waits for the given resources to become ready, applying the
|
||||
// per-batch and overall deadline constraints. Every batch is waited on (D1):
|
||||
// leaf-group suppression was removed because it keyed on bare resource names
|
||||
// (beads xnf/p1m) and made --wait=ordered weaker than --wait for terminal
|
||||
// groups; Batch.LeafGroups is retained by the builder for display only.
|
||||
func (s *sequencedDeployment) waitForResources(resources kube.ResourceList, hasCustomReadiness bool) error {
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
waitTimeout, err := s.batchWaitTimeout()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var waiter kube.Waiter
|
||||
waitOptions := append([]kube.WaitOption(nil), s.waitOptions...)
|
||||
if hasCustomReadiness {
|
||||
waitOptions = append(waitOptions, kube.WithCustomReadinessStatusReader())
|
||||
}
|
||||
if c, ok := s.cfg.KubeClient.(kube.InterfaceWaitOptions); ok {
|
||||
waiter, err = c.GetWaiterWithOptions(s.waitStrategy, waitOptions...)
|
||||
} else {
|
||||
waiter, err = s.cfg.KubeClient.GetWaiter(s.waitStrategy)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting waiter for resource batch: %w", err)
|
||||
}
|
||||
|
||||
if s.waitForJobs {
|
||||
return waiter.WaitWithJobs(resources, waitTimeout)
|
||||
}
|
||||
return waiter.Wait(resources, waitTimeout)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,648 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"helm.sh/helm/v4/pkg/chart/common"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
kubefake "helm.sh/helm/v4/pkg/kube/fake"
|
||||
rcommon "helm.sh/helm/v4/pkg/release/common"
|
||||
release "helm.sh/helm/v4/pkg/release/v1"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func newSequencedUninstallAction(t *testing.T, kubeClient kube.Interface) *Uninstall {
|
||||
t.Helper()
|
||||
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.KubeClient = kubeClient
|
||||
|
||||
uninstall := NewUninstall(cfg)
|
||||
uninstall.DisableHooks = true
|
||||
uninstall.Timeout = 5 * time.Minute
|
||||
uninstall.WaitStrategy = kube.OrderedWaitStrategy
|
||||
|
||||
return uninstall
|
||||
}
|
||||
|
||||
func seedUninstallRelease(t *testing.T, uninstall *Uninstall, rel *release.Release) {
|
||||
t.Helper()
|
||||
require.NoError(t, uninstall.cfg.Releases.Create(rel))
|
||||
}
|
||||
|
||||
func newUninstallRelease(name string, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo, hooks ...*release.Hook) *release.Release {
|
||||
now := time.Now()
|
||||
return &release.Release{
|
||||
Name: name,
|
||||
Namespace: "spaced",
|
||||
Chart: ch,
|
||||
Config: map[string]any{},
|
||||
Manifest: manifest,
|
||||
Info: &release.Info{
|
||||
FirstDeployed: now,
|
||||
LastDeployed: now,
|
||||
Status: rcommon.StatusDeployed,
|
||||
Description: "Seeded release",
|
||||
},
|
||||
Version: 1,
|
||||
Hooks: hooks,
|
||||
SequencingInfo: sequencingInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// sourcedManifest prefixes a manifest document with the "# Source:" comment the
|
||||
// real render pipeline stores in rel.Manifest (action.go renderResourcesWithFiles).
|
||||
func sourcedManifest(path, content string) string {
|
||||
return "# Source: " + path + "\n" + content
|
||||
}
|
||||
|
||||
type commentStrippingPostRenderer struct{}
|
||||
|
||||
func (commentStrippingPostRenderer) Run(in *bytes.Buffer) (*bytes.Buffer, error) {
|
||||
var out bytes.Buffer
|
||||
for line := range strings.SplitSeq(in.String(), "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
continue
|
||||
}
|
||||
out.WriteString(line + "\n")
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func secretManifest(name string) string {
|
||||
return fmt.Sprintf(`apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: %s
|
||||
type: Opaque
|
||||
data:
|
||||
password: cGFzc3dvcmQ=
|
||||
`, name)
|
||||
}
|
||||
|
||||
func serviceAccountManifest(name string) string {
|
||||
return fmt.Sprintf(`apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: %s
|
||||
`, name)
|
||||
}
|
||||
|
||||
func expectedUninstallIDs(t *testing.T, manifest string) []string {
|
||||
t.Helper()
|
||||
|
||||
_, files, err := releaseutil.SortManifests(releaseutil.SplitManifests(manifest), nil, releaseutil.UninstallOrder)
|
||||
require.NoError(t, err)
|
||||
|
||||
ids := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
ids = append(ids, fmt.Sprintf("%s/%s", file.Head.Kind, file.Head.Metadata.Name))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func filterDeleteCalls(calls [][]string, wanted ...string) [][]string {
|
||||
want := make(map[string]struct{}, len(wanted))
|
||||
for _, id := range wanted {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
|
||||
var filtered [][]string
|
||||
for _, call := range calls {
|
||||
if len(call) != 1 {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[call[0]]; ok {
|
||||
filtered = append(filtered, call)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func operationIndex(operations []string, exact string) int {
|
||||
for i, operation := range operations {
|
||||
if operation == exact {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func newDeleteHook(name string, event release.HookEvent) *release.Hook {
|
||||
return &release.Hook{
|
||||
Name: name,
|
||||
Kind: "ConfigMap",
|
||||
Path: name,
|
||||
Manifest: configMapManifest(name, nil),
|
||||
Events: []release.HookEvent{event},
|
||||
DeletePolicies: []release.HookDeletePolicy{release.HookSucceeded},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_Sequenced_ReverseOrder(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("sequenced-uninstall"))
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"sequenced-uninstall",
|
||||
ch,
|
||||
joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
func TestUninstall_NonSequenced_Unchanged(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
|
||||
manifest := joinManifestDocs(
|
||||
configMapManifest("config", nil),
|
||||
secretManifest("secret"),
|
||||
serviceAccountManifest("service-account"),
|
||||
)
|
||||
rel := newUninstallRelease("standard-uninstall", buildChartWithTemplates(nil, withName("standard-uninstall")), manifest, nil)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, client.deleteCalls, 1)
|
||||
assert.Equal(t, expectedUninstallIDs(t, manifest), client.deleteCalls[0])
|
||||
require.Len(t, client.deleteWaitCalls, 1)
|
||||
assert.Equal(t, client.deleteCalls[0], client.deleteWaitCalls[0])
|
||||
}
|
||||
|
||||
func TestUninstall_Sequenced_WithSubcharts(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
|
||||
parent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
bar := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/bar.yaml", "bar", nil),
|
||||
}, withName("bar"))
|
||||
nginx := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/nginx.yaml", "nginx", nil),
|
||||
}, withName("nginx"))
|
||||
|
||||
bar.AddDependency(nginx)
|
||||
bar.Metadata.Dependencies = []*chart.Dependency{{Name: "nginx"}}
|
||||
parent.AddDependency(bar)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}}
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"subchart-uninstall",
|
||||
parent,
|
||||
joinManifestDocs(
|
||||
sourcedManifest("parent/charts/bar/charts/nginx/templates/nginx.yaml", configMapManifest("nginx", nil)),
|
||||
sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)),
|
||||
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reverse-DAG uninstall: parent first, then bar (its own resources before
|
||||
// recursing into bar's subchart batch), then nginx (innermost). With the
|
||||
// chart-path threading fix, nested subcharts route through their proper
|
||||
// subtree level instead of flattening.
|
||||
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}, {"ConfigMap/nginx"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
// TestUninstall_Sequenced_DeletesVendoredSubchart locks bead i42: a subchart
|
||||
// vendored into charts/ but absent from Chart.yaml dependencies deploys on
|
||||
// install (TestInstall_Sequenced_UndeclaredVendoredSubchartDeployed) but the
|
||||
// DAG-walking deleter never visited it. The plan's completeness invariant
|
||||
// guarantees it is now deleted, after the parent's own resources and before
|
||||
// declared subcharts (exact reverse of the forward plan).
|
||||
func TestUninstall_Sequenced_DeletesVendoredSubchart(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
|
||||
parent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
declared := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
}, withName("database"))
|
||||
vendored := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/vendored.yaml", "vendored", nil),
|
||||
}, withName("vendored"))
|
||||
|
||||
parent.AddDependency(declared, vendored)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "database"}}
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"vendored-uninstall",
|
||||
parent,
|
||||
joinManifestDocs(
|
||||
sourcedManifest("parent/charts/database/templates/database.yaml", configMapManifest("database", nil)),
|
||||
sourcedManifest("parent/charts/vendored/templates/vendored.yaml", configMapManifest("vendored", nil)),
|
||||
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/vendored"}, {"ConfigMap/database"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
// TestUninstall_Sequenced_DefaultWaitStrategyGatesBatches locks bead 4sj /
|
||||
// master-plan D4: ordering intent is a property of the stored release, so a
|
||||
// sequenced release uninstalled with the DEFAULT wait strategy (hookOnly, whose
|
||||
// WaitForDelete is a no-op) must upgrade its batch-gating waiter to the status
|
||||
// watcher. Hooks keep the user's strategy; this test disables hooks.
|
||||
func TestUninstall_Sequenced_DefaultWaitStrategyGatesBatches(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.KubeClient = client
|
||||
|
||||
uninstall := NewUninstall(cfg)
|
||||
uninstall.DisableHooks = true
|
||||
uninstall.Timeout = 5 * time.Minute
|
||||
uninstall.WaitStrategy = kube.HookOnlyStrategy
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("default-wait-uninstall"))
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"default-wait-uninstall",
|
||||
ch,
|
||||
joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
),
|
||||
nil,
|
||||
)
|
||||
rel.Sequenced = true
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
assert.Contains(t, client.waiterStrategies, kube.StatusWatcherStrategy)
|
||||
}
|
||||
|
||||
// TestUninstall_Sequenced_UsesStoredSourcePaths pins the D5 mechanism: the
|
||||
// uninstall plan is built purely from the stored manifest's "# Source:"
|
||||
// comments. The chart in this fixture would RE-RENDER to different resource
|
||||
// names (values drift), which used to break the fuzzy content/identity
|
||||
// matcher; the stored paths make re-rendering irrelevant.
|
||||
func TestUninstall_Sequenced_UsesStoredSourcePaths(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
|
||||
parent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent-renamed", nil),
|
||||
}, withName("parent"))
|
||||
bar := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/bar.yaml", "bar-renamed", nil),
|
||||
}, withName("bar"))
|
||||
|
||||
parent.AddDependency(bar)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}}
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"stored-source-uninstall",
|
||||
parent,
|
||||
joinManifestDocs(
|
||||
sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)),
|
||||
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
// TestUninstall_Sequenced_AfterPostRenderedInstall pins the epic's D5 premise
|
||||
// end-to-end: "# Source:" comments are appended to the stored manifest AFTER
|
||||
// post-rendering (from the filename annotation), so even a comment-stripping
|
||||
// post-renderer (kustomize-style) leaves the stored record fully
|
||||
// plan-recoverable. NOTE: this test also passes before the Stage-C rewrite
|
||||
// (the old path recovered order by re-rendering); it exists so any future
|
||||
// regression of the premise fails loudly here rather than in production.
|
||||
func TestUninstall_Sequenced_AfterPostRenderedInstall(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.buildRESTClient = statefulOwnedRESTClient(client, "test-install-release", "spaced")
|
||||
|
||||
install := newSequencedInstallAction(t, client)
|
||||
install.PostRenderer = commentStrippingPostRenderer{}
|
||||
|
||||
parent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
bar := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/bar.yaml", "bar", nil),
|
||||
}, withName("bar"))
|
||||
|
||||
parent.AddDependency(bar)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}}
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, parent))
|
||||
assert.Contains(t, rel.Manifest, "# Source: parent/charts/bar/templates/")
|
||||
assert.Contains(t, rel.Manifest, "# Source: parent/templates/")
|
||||
|
||||
uninstall := NewUninstall(install.cfg)
|
||||
uninstall.DisableHooks = true
|
||||
uninstall.Timeout = 5 * time.Minute
|
||||
uninstall.WaitStrategy = kube.OrderedWaitStrategy
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
func TestUninstall_Sequenced_KeepPolicy(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database-live.yaml", "database-live", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/database-keep.yaml", "database-keep", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
kube.ResourcePolicyAnno: kube.KeepPolicy,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("keep-policy"))
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"keep-policy",
|
||||
ch,
|
||||
joinManifestDocs(
|
||||
configMapManifest("database-live", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("database-keep", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
kube.ResourcePolicyAnno: kube.KeepPolicy,
|
||||
}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
res, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database-live"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
assert.NotContains(t, client.deleteCalls, []string{"ConfigMap/database-keep"})
|
||||
assert.Contains(t, res.Info, "[ConfigMap] database-keep")
|
||||
}
|
||||
|
||||
func TestUninstall_Sequenced_KeepHistory(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
uninstall.KeepHistory = true
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("keep-history"))
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"keep-history",
|
||||
ch,
|
||||
joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
reli, err := uninstall.cfg.Releases.Get(rel.Name, rel.Version)
|
||||
require.NoError(t, err)
|
||||
stored, err := releaserToV1Release(reli)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls)
|
||||
assert.Equal(t, rcommon.StatusUninstalled, stored.Info.Status)
|
||||
}
|
||||
|
||||
func TestUninstall_Sequenced_DryRun(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
uninstall.DryRun = true
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("dry-run-uninstall"))
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"dry-run-uninstall",
|
||||
ch,
|
||||
joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
res, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
assert.Empty(t, client.deleteCalls)
|
||||
assert.Empty(t, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
func TestUninstall_Sequenced_Hooks(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
uninstall.DisableHooks = false
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("hooked-uninstall"))
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"hooked-uninstall",
|
||||
ch,
|
||||
joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
newDeleteHook("pre-hook", release.HookPreDelete),
|
||||
newDeleteHook("post-hook", release.HookPostDelete),
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.createCalls)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.watchUntilReadyCalls)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, filterDeleteCalls(client.deleteCalls, "ConfigMap/app", "ConfigMap/database"))
|
||||
|
||||
preHookCreate := operationIndex(client.operations, "create:ConfigMap/pre-hook")
|
||||
appDelete := operationIndex(client.operations, "delete:ConfigMap/app")
|
||||
databaseWaitDelete := operationIndex(client.operations, "wait-delete:ConfigMap/database")
|
||||
postHookCreate := operationIndex(client.operations, "create:ConfigMap/post-hook")
|
||||
|
||||
require.NotEqual(t, -1, preHookCreate)
|
||||
require.NotEqual(t, -1, appDelete)
|
||||
require.NotEqual(t, -1, databaseWaitDelete)
|
||||
require.NotEqual(t, -1, postHookCreate)
|
||||
assert.Less(t, preHookCreate, appDelete)
|
||||
assert.Greater(t, postHookCreate, databaseWaitDelete)
|
||||
}
|
||||
|
||||
// TestUninstall_Sequenced_SkipsUnownedResources locks the fix for the sequenced
|
||||
// uninstall ownership gap: the sequenced delete path must verify ownership and
|
||||
// skip resources it does not own, matching the non-sequenced path, rather than
|
||||
// deleting them.
|
||||
func TestUninstall_Sequenced_SkipsUnownedResources(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
config := actionConfigFixture(t)
|
||||
config.SetLogger(slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
|
||||
uninstall := NewUninstall(config)
|
||||
uninstall.DisableHooks = true
|
||||
uninstall.KeepHistory = true
|
||||
uninstall.WaitStrategy = kube.OrderedWaitStrategy
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
}),
|
||||
}, withName("sequenced-ownership"))
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"sequenced-ownership",
|
||||
ch,
|
||||
configMapManifest("app", map[string]string{releaseutil.AnnotationResourceGroup: "app"}),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
// Build resolves every manifest to a resource that is NOT owned by this
|
||||
// release (no Helm ownership metadata). The sequenced path must skip it.
|
||||
failer := config.KubeClient.(*kubefake.FailingKubeClient)
|
||||
failer.DummyResources = kube.ResourceList{
|
||||
newDeploymentWithOwner("unowned-deploy", rel.Namespace, nil, nil),
|
||||
}
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
is.NoError(err)
|
||||
|
||||
logOutput := logBuffer.String()
|
||||
is.Contains(logOutput, "skipping delete of resource not owned by this release")
|
||||
is.Contains(logOutput, "unowned-deploy")
|
||||
}
|
||||
@ -0,0 +1,559 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"helm.sh/helm/v4/pkg/chart/common"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
ri "helm.sh/helm/v4/pkg/release"
|
||||
rcommon "helm.sh/helm/v4/pkg/release/common"
|
||||
release "helm.sh/helm/v4/pkg/release/v1"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func newSequencedUpgradeAction(t *testing.T, kubeClient kube.Interface) *Upgrade {
|
||||
t.Helper()
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.KubeClient = kubeClient
|
||||
|
||||
upgrade := NewUpgrade(cfg)
|
||||
upgrade.Namespace = "spaced"
|
||||
upgrade.Timeout = 5 * time.Minute
|
||||
upgrade.ReadinessTimeout = time.Minute
|
||||
upgrade.WaitStrategy = kube.OrderedWaitStrategy
|
||||
return upgrade
|
||||
}
|
||||
|
||||
func seedDeployedRelease(t *testing.T, upgrade *Upgrade, name string, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
rel := &release.Release{
|
||||
Name: name,
|
||||
Namespace: "spaced",
|
||||
Chart: ch,
|
||||
Config: map[string]any{},
|
||||
Manifest: manifest,
|
||||
Info: &release.Info{
|
||||
FirstDeployed: now,
|
||||
LastDeployed: now,
|
||||
Status: rcommon.StatusDeployed,
|
||||
Description: "Seeded release",
|
||||
},
|
||||
Version: 1,
|
||||
SequencingInfo: sequencingInfo,
|
||||
}
|
||||
|
||||
require.NoError(t, upgrade.cfg.Releases.Create(rel))
|
||||
}
|
||||
|
||||
func joinManifestDocs(docs ...string) string {
|
||||
return strings.Join(docs, "\n---\n")
|
||||
}
|
||||
|
||||
func updateTargets(calls []updateCall) [][]string {
|
||||
targets := make([][]string, 0, len(calls))
|
||||
for _, call := range calls {
|
||||
targets = append(targets, call.target)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func TestUpgrade_Sequenced_Basic(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("sequenced-upgrade"))
|
||||
seedDeployedRelease(t, upgrade, "sequenced-upgrade", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
upgradedChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("sequenced-upgrade"))
|
||||
|
||||
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "sequenced-upgrade", upgradedChart))
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
|
||||
require.Len(t, client.updateCalls, 2)
|
||||
assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].current)
|
||||
assert.Equal(t, []string{"ConfigMap/app"}, client.updateCalls[1].current)
|
||||
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
||||
assert.True(t, rel.Sequenced)
|
||||
assert.True(t, rel.IsSequenced())
|
||||
assert.Nil(t, rel.SequencingInfo)
|
||||
}
|
||||
|
||||
func TestUpgrade_Sequenced_NewResourceGroup(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("new-group"))
|
||||
seedDeployedRelease(t, upgrade, "new-group", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
upgradedChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("new-group"))
|
||||
|
||||
mustRunUpgrade(t, upgrade, "new-group", upgradedChart)
|
||||
|
||||
require.Len(t, client.updateCalls, 3)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
|
||||
assert.Empty(t, client.updateCalls[1].current)
|
||||
assert.Equal(t, []string{"ConfigMap/queue"}, client.updateCalls[1].created)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}, {"ConfigMap/app"}}, client.waitCalls)
|
||||
}
|
||||
|
||||
func TestUpgrade_Sequenced_RemovedResourceGroup(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("removed-group"))
|
||||
seedDeployedRelease(t, upgrade, "removed-group", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
upgradedChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("removed-group"))
|
||||
|
||||
mustRunUpgrade(t, upgrade, "removed-group", upgradedChart)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
|
||||
assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls)
|
||||
assert.Equal(t, []string{
|
||||
"update:ConfigMap/database",
|
||||
"wait:ConfigMap/database",
|
||||
"update:ConfigMap/app",
|
||||
"wait:ConfigMap/app",
|
||||
"delete:ConfigMap/queue",
|
||||
"wait-delete:ConfigMap/queue",
|
||||
}, client.operations)
|
||||
}
|
||||
|
||||
// TestUpgrade_Sequenced_NewResourceConflictAndAdoption locks bead 9ui
|
||||
// (upgrade): a sequenced upgrade must run the same existing-resource
|
||||
// conflict / adoption step over newly added resources as the default path
|
||||
// (performUpgrade) does.
|
||||
func TestUpgrade_Sequenced_NewResourceConflictAndAdoption(t *testing.T) {
|
||||
currentChartFiles := []*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
}
|
||||
currentManifest := configMapManifest("database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
})
|
||||
upgradedChartFiles := []*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}
|
||||
|
||||
t.Run("unowned existing resource conflicts before anything is applied", func(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
// queue already exists in the cluster, owned by another release.
|
||||
client.buildRESTClient = statefulOwnedRESTClient(client, "some-other-release", "spaced")
|
||||
client.createdObjects["ConfigMap/queue"] = true
|
||||
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
seedDeployedRelease(t, upgrade, "adopt-upgrade", buildChartWithTemplates(currentChartFiles, withName("adopt-upgrade")), currentManifest, nil)
|
||||
|
||||
_, err := upgrade.Run("adopt-upgrade", buildChartWithTemplates(upgradedChartFiles, withName("adopt-upgrade")), map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unable to continue with update")
|
||||
assert.Empty(t, client.updateCalls, "no batch may be applied after a conflict")
|
||||
})
|
||||
|
||||
t.Run("take-ownership adopts the existing resource in its batch", func(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.buildRESTClient = statefulOwnedRESTClient(client, "some-other-release", "spaced")
|
||||
client.createdObjects["ConfigMap/queue"] = true
|
||||
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
upgrade.TakeOwnership = true
|
||||
seedDeployedRelease(t, upgrade, "adopt-upgrade-to", buildChartWithTemplates(currentChartFiles, withName("adopt-upgrade-to")), currentManifest, nil)
|
||||
|
||||
mustRunUpgrade(t, upgrade, "adopt-upgrade-to", buildChartWithTemplates(upgradedChartFiles, withName("adopt-upgrade-to")))
|
||||
|
||||
require.Len(t, client.updateCalls, 2)
|
||||
assert.Equal(t, []string{"ConfigMap/queue"}, client.updateCalls[1].current,
|
||||
"pre-existing queue must be adopted (updated against a current object), not treated as new")
|
||||
assert.Empty(t, client.updateCalls[1].created)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpgrade_Sequenced_RemovedGroupsDeletedInReverseOrder locks bead 7yi
|
||||
// (upgrade): resources removed by an upgrade are deleted in exact reverse
|
||||
// deployment order of the OLD release's plan, with a delete-wait gating each
|
||||
// batch, not as one unordered bulk delete.
|
||||
func TestUpgrade_Sequenced_RemovedGroupsDeletedInReverseOrder(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
|
||||
group := func(name string, deps string) map[string]string {
|
||||
ann := map[string]string{releaseutil.AnnotationResourceGroup: name}
|
||||
if deps != "" {
|
||||
ann[releaseutil.AnnotationDependsOnResourceGroups] = deps
|
||||
}
|
||||
return ann
|
||||
}
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", group("database", "")),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", group("queue", `["database"]`)),
|
||||
makeConfigMapTemplate("templates/worker.yaml", "worker", group("worker", `["queue"]`)),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", group("app", `["worker"]`)),
|
||||
}, withName("reverse-removed"))
|
||||
seedDeployedRelease(t, upgrade, "reverse-removed", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", group("database", "")),
|
||||
configMapManifest("queue", group("queue", `["database"]`)),
|
||||
configMapManifest("worker", group("worker", `["queue"]`)),
|
||||
configMapManifest("app", group("app", `["worker"]`)),
|
||||
), nil)
|
||||
|
||||
upgradedChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", group("database", "")),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", group("app", `["database"]`)),
|
||||
}, withName("reverse-removed"))
|
||||
|
||||
mustRunUpgrade(t, upgrade, "reverse-removed", upgradedChart)
|
||||
|
||||
// Old plan order: database, queue, worker, app. Reverse: app, worker,
|
||||
// queue, database. Removed set: {queue, worker} -> worker deleted (and
|
||||
// delete-waited) strictly before queue.
|
||||
assert.Equal(t, [][]string{{"ConfigMap/worker"}, {"ConfigMap/queue"}}, client.deleteCalls)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/worker"}, {"ConfigMap/queue"}}, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
func TestUpgrade_NonSequencedToSequenced(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("transition-to-sequenced"))
|
||||
seedDeployedRelease(t, upgrade, "transition-to-sequenced", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "transition-to-sequenced", currentChart))
|
||||
|
||||
require.Len(t, client.updateCalls, 2)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
|
||||
assert.True(t, rel.Sequenced)
|
||||
assert.True(t, rel.IsSequenced())
|
||||
}
|
||||
|
||||
func TestUpgrade_SequencedToNonSequenced(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
upgrade.WaitStrategy = kube.StatusWatcherStrategy
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("transition-to-standard"))
|
||||
seedDeployedRelease(t, upgrade, "transition-to-standard", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "transition-to-standard", currentChart))
|
||||
|
||||
require.Len(t, client.updateCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
|
||||
require.Len(t, client.waitCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0])
|
||||
assert.Nil(t, rel.SequencingInfo)
|
||||
}
|
||||
|
||||
func TestUpgrade_Sequenced_FailureRollback(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 2
|
||||
client.waitError = assert.AnError
|
||||
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
upgrade.RollbackOnFailure = true
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("failure-rollback"))
|
||||
seedDeployedRelease(t, upgrade, "failure-rollback", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
upgradedChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("failure-rollback"))
|
||||
|
||||
rel, err := upgrade.Run("failure-rollback", upgradedChart, map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "rolled back due to rollback-on-failure")
|
||||
|
||||
failed := mustRelease(t, rel)
|
||||
assert.Equal(t, rcommon.StatusFailed, failed.Info.Status)
|
||||
|
||||
last, getErr := upgrade.cfg.Releases.Last("failure-rollback")
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, rcommon.StatusDeployed, mustRelease(t, last).Info.Status)
|
||||
}
|
||||
|
||||
func TestUpgrade_Sequenced_SubchartAdded(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
|
||||
currentParent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
currentDatabase := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
}, withName("database"))
|
||||
currentParent.AddDependency(currentDatabase)
|
||||
currentParent.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "database"},
|
||||
}
|
||||
seedDeployedRelease(t, upgrade, "subchart-added", currentParent, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("parent", nil),
|
||||
), nil)
|
||||
|
||||
upgradedParent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
upgradedDatabase := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
}, withName("database"))
|
||||
upgradedAPI := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/api.yaml", "api", nil),
|
||||
}, withName("api"))
|
||||
upgradedParent.AddDependency(upgradedDatabase, upgradedAPI)
|
||||
upgradedParent.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "database"},
|
||||
{Name: "api", DependsOn: []string{"database"}},
|
||||
}
|
||||
|
||||
mustRunUpgrade(t, upgrade, "subchart-added", upgradedParent)
|
||||
|
||||
require.Len(t, client.updateCalls, 3)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/api"}, {"ConfigMap/parent"}}, updateTargets(client.updateCalls))
|
||||
assert.Empty(t, client.updateCalls[1].current)
|
||||
assert.Equal(t, []string{"ConfigMap/api"}, client.updateCalls[1].created)
|
||||
}
|
||||
|
||||
func TestUpgrade_Sequenced_DryRun(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
upgrade.DryRunStrategy = DryRunClient
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("dry-run"))
|
||||
seedDeployedRelease(t, upgrade, "dry-run", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "dry-run", currentChart))
|
||||
|
||||
assert.Empty(t, client.updateCalls)
|
||||
assert.Empty(t, client.waitCalls)
|
||||
assert.Empty(t, client.deleteCalls)
|
||||
assert.Equal(t, "Dry run complete", rel.Info.Description)
|
||||
}
|
||||
|
||||
func TestUpgrade_Sequenced_CleanupOnFail(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 2
|
||||
client.waitError = errors.New("timed out waiting for batch")
|
||||
|
||||
upgrade := newSequencedUpgradeAction(t, client)
|
||||
upgrade.CleanupOnFail = true
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("cleanup-on-fail"))
|
||||
seedDeployedRelease(t, upgrade, "cleanup-on-fail", currentChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), nil)
|
||||
|
||||
upgradedChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("cleanup-on-fail"))
|
||||
|
||||
rel, err := upgrade.Run("cleanup-on-fail", upgradedChart, map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out waiting for batch")
|
||||
assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls)
|
||||
assert.Equal(t, rcommon.StatusFailed, mustRelease(t, rel).Info.Status)
|
||||
}
|
||||
|
||||
func mustRunUpgrade(t *testing.T, upgrade *Upgrade, name string, ch *chart.Chart) ri.Releaser {
|
||||
t.Helper()
|
||||
rel, err := upgrade.Run(name, ch, map[string]any{})
|
||||
require.NoError(t, err)
|
||||
return rel
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"helm.sh/helm/v4/pkg/chart/common"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func captureWarningOutput(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
|
||||
oldLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(handler))
|
||||
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
||||
|
||||
return &buf
|
||||
}
|
||||
|
||||
func runSequencedInstallWithWarnings(t *testing.T, templates ...*common.File) string {
|
||||
t.Helper()
|
||||
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
|
||||
install.cfg.SetLogger(handler)
|
||||
|
||||
mustRunInstall(t, install, buildChartWithTemplates(templates))
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestWarning_PartialReadinessAnnotations(t *testing.T) {
|
||||
output := runSequencedInstallWithWarnings(t,
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
kube.AnnotationReadinessSuccess: `["{.ready} == true"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Contains(t, output, "sequencing: resource \\\"app\\\" has only one of")
|
||||
assert.Contains(t, output, releaseutil.AnnotationReadinessSuccess)
|
||||
assert.Contains(t, output, releaseutil.AnnotationReadinessFailure)
|
||||
}
|
||||
|
||||
func TestWarning_IsolatedResourceGroups(t *testing.T) {
|
||||
output := runSequencedInstallWithWarnings(t,
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/cache.yaml", "cache", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "cache",
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Contains(t, output, "resource-group \\\"database\\\" is isolated")
|
||||
assert.Contains(t, output, "resource-group \\\"cache\\\" is isolated")
|
||||
}
|
||||
|
||||
func TestWarning_NonExistentGroupReference(t *testing.T) {
|
||||
output := runSequencedInstallWithWarnings(t,
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["missing"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Contains(t, output, "sequencing: group \\\"app\\\" depends-on non-existent group \\\"missing\\\"")
|
||||
}
|
||||
|
||||
func TestWarning_WellFormedSequencing(t *testing.T) {
|
||||
output := runSequencedInstallWithWarnings(t,
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
kube.AnnotationReadinessSuccess: `["{.ready} == true"]`,
|
||||
kube.AnnotationReadinessFailure: `["{.failed} == true"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Empty(t, output)
|
||||
}
|
||||
Loading…
Reference in new issue