mirror of https://github.com/helm/helm
Merge 9cd32c1e91 into a572f23b82
commit
457d96913d
@ -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,538 @@
|
||||
/*
|
||||
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)
|
||||
}
|
||||
|
||||
// TestRollback_Sequenced_StorageDecodedChart_Subcharts guards the rollback
|
||||
// leg of bead xmn: the target revision's chart comes from release storage,
|
||||
// which drops the loaded dependency tree, so the forward plan must be
|
||||
// rebuilt from the stored metadata and manifest paths: subchart resources
|
||||
// first, then the parent's own.
|
||||
func TestRollback_Sequenced_StorageDecodedChart_Subcharts(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(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"))
|
||||
parent.AddDependency(bar)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar", Enabled: true}}
|
||||
parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["bar"]`}
|
||||
|
||||
seedRollbackRelease(t, rollback, "decoded-rollback", 1, rcommon.StatusSuperseded, storageDecodedChart(t, 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)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
seedRollbackRelease(t, rollback, "decoded-rollback", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("parent", nil),
|
||||
configMapManifest("bar", nil),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("decoded-rollback"))
|
||||
|
||||
// Forward deployment order: the subchart's batch first, then the parent's.
|
||||
assert.Equal(t, [][]string{{"ConfigMap/bar"}, {"ConfigMap/parent"}}, updateTargets(client.updateCalls))
|
||||
assert.Equal(t, [][]string{{"ConfigMap/bar"}, {"ConfigMap/parent"}}, client.waitCalls)
|
||||
}
|
||||
@ -0,0 +1,344 @@
|
||||
/*
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
sequencingKeys := releaseutil.HelmInternalSequencingAnnotations()
|
||||
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 sequencingKeys {
|
||||
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,798 @@
|
||||
/*
|
||||
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"
|
||||
"encoding/json"
|
||||
"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")
|
||||
}
|
||||
|
||||
// storageDecodedChart encodes and decodes a chart the way the release storage
|
||||
// codec does (json.Marshal in pkg/storage/driver). The memory driver used by
|
||||
// actionConfigFixture shares release pointers, so without this helper action
|
||||
// tests never see the shape real (secrets/configmaps/sql) storage produces:
|
||||
// a chart whose loaded dependency tree is gone.
|
||||
func storageDecodedChart(t *testing.T, c *chart.Chart) *chart.Chart {
|
||||
t.Helper()
|
||||
|
||||
encoded, err := json.Marshal(c)
|
||||
require.NoError(t, err)
|
||||
decoded := &chart.Chart{}
|
||||
require.NoError(t, json.Unmarshal(encoded, decoded))
|
||||
require.Empty(t, decoded.Dependencies(), "release codec is expected to drop the loaded dependency tree")
|
||||
return decoded
|
||||
}
|
||||
|
||||
// TestUninstall_Sequenced_StorageDecodedChart_NestedSubcharts reproduces the
|
||||
// live bead-xmn failure: `helm uninstall` of a sequenced nested-subchart
|
||||
// release read from REAL storage (which drops the chart's loaded dependency
|
||||
// tree) must still delete in exact reverse deployment order instead of
|
||||
// failing to build the subchart DAG and stranding the release in
|
||||
// status=uninstalling.
|
||||
func TestUninstall_Sequenced_StorageDecodedChart_NestedSubcharts(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"))
|
||||
|
||||
// Post-ProcessDependencies shape, as install stores it: enabled entries
|
||||
// only, and the parent-resources annotation that made the live uninstall
|
||||
// fail with `references unknown or disabled subchart "bar"`.
|
||||
bar.AddDependency(nginx)
|
||||
bar.Metadata.Dependencies = []*chart.Dependency{{Name: "nginx", Enabled: true}}
|
||||
parent.AddDependency(bar)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar", Enabled: true}}
|
||||
parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["bar"]`}
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"decoded-nested-uninstall",
|
||||
storageDecodedChart(t, 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)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}, {"ConfigMap/nginx"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
// TestUninstall_Sequenced_StorageDecodedChart_VendoredSubchart extends the
|
||||
// bead-i42 guard across the storage round-trip: a vendored-but-undeclared
|
||||
// subchart of a storage-decoded chart is still deleted, after the parent's
|
||||
// own resources and before declared subcharts.
|
||||
func TestUninstall_Sequenced_StorageDecodedChart_VendoredSubchart(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
uninstall := newSequencedUninstallAction(t, client)
|
||||
|
||||
parent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "database", Enabled: true}}
|
||||
parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["database"]`}
|
||||
|
||||
rel := newUninstallRelease(
|
||||
"decoded-vendored-uninstall",
|
||||
storageDecodedChart(t, 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_PlanFailure_FallsBackUnsequenced locks the
|
||||
// stuck-release fix: when the ordered plan cannot be rebuilt from the stored
|
||||
// release (here: a resource-group cycle, which Build treats as fatal), the
|
||||
// uninstall must warn and degrade to the standard unsequenced deletion so the
|
||||
// release reaches status=uninstalled instead of stranding in uninstalling.
|
||||
func TestUninstall_Sequenced_PlanFailure_FallsBackUnsequenced(t *testing.T) {
|
||||
client := newRecordingKubeClient().withoutBuildClients()
|
||||
logBuffer := &bytes.Buffer{}
|
||||
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.SetLogger(slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
cfg.KubeClient = client
|
||||
|
||||
uninstall := NewUninstall(cfg)
|
||||
uninstall.DisableHooks = true
|
||||
uninstall.KeepHistory = true
|
||||
uninstall.Timeout = 5 * time.Minute
|
||||
uninstall.WaitStrategy = kube.OrderedWaitStrategy
|
||||
|
||||
manifest := joinManifestDocs(
|
||||
configMapManifest("alpha", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "a",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["b"]`,
|
||||
}),
|
||||
configMapManifest("beta", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "b",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["a"]`,
|
||||
}),
|
||||
)
|
||||
rel := newUninstallRelease(
|
||||
"cyclic-uninstall",
|
||||
buildChartWithTemplates(nil, withName("cyclic-uninstall")),
|
||||
manifest,
|
||||
nil,
|
||||
)
|
||||
rel.Sequenced = true
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, logBuffer.String(), "deleting resources without sequencing")
|
||||
require.Len(t, client.deleteCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/alpha", "ConfigMap/beta"}, client.deleteCalls[0])
|
||||
|
||||
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, rcommon.StatusUninstalled, stored.Info.Status)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
/*
|
||||
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 v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
func TestDependencyDependsOnJSONOmitEmpty(t *testing.T) {
|
||||
dependency := Dependency{
|
||||
Name: "app",
|
||||
Repository: "https://example.com/charts",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dependency)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, string(data), "depends-on")
|
||||
}
|
||||
|
||||
func TestDependencyDependsOnJSONTag(t *testing.T) {
|
||||
dependency := Dependency{
|
||||
Name: "app",
|
||||
Repository: "https://example.com/charts",
|
||||
DependsOn: []string{"database"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dependency)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, string(data), `"depends-on":["database"]`)
|
||||
}
|
||||
|
||||
func TestDependencyDependsOnYAMLBackwardCompatibility(t *testing.T) {
|
||||
input := []byte(`
|
||||
name: app
|
||||
repository: https://example.com/charts
|
||||
`)
|
||||
|
||||
var dependency Dependency
|
||||
err := yaml.Unmarshal(input, &dependency)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Nil(t, dependency.DependsOn)
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
/*
|
||||
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 rules // import "helm.sh/helm/v4/pkg/chart/v2/lint/rules"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"helm.sh/helm/v4/pkg/chart/common"
|
||||
commonutil "helm.sh/helm/v4/pkg/chart/common/util"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/chart/v2/lint/support"
|
||||
"helm.sh/helm/v4/pkg/chart/v2/loader"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
"helm.sh/helm/v4/pkg/engine"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Sequencing runs lint rules for HIP-0025 sequencing annotations.
|
||||
func Sequencing(linter *support.Linter, namespace string, values map[string]any) {
|
||||
c, err := loader.LoadDir(linter.ChartDir)
|
||||
if err != nil {
|
||||
return // chart load errors are reported by other lint rules
|
||||
}
|
||||
if err := chartutil.ProcessDependencies(c, values); err != nil {
|
||||
linter.RunLinterRule(support.ErrorSev, linter.ChartDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Render failures are reported by the Templates rule; manifests stays nil
|
||||
// so Build still validates the top-level subchart DAG (preserving the old
|
||||
// pre-render validateSubchartSequencing coverage for broken-template charts).
|
||||
manifests := collectRenderedManifests(linter, c, namespace, values)
|
||||
|
||||
plan, err := sequence.Build(c, manifests)
|
||||
if err != nil {
|
||||
// Build's fatal classes are exactly what fails at install time:
|
||||
// subchart/resource-group cycles, unknown depends-on refs, malformed
|
||||
// helm.sh/depends-on/subcharts, multi-group assignment.
|
||||
linter.RunLinterRule(support.ErrorSev, linter.ChartDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, w := range plan.Warnings {
|
||||
path := w.ChartPath
|
||||
if path == "" {
|
||||
path = linter.ChartDir
|
||||
}
|
||||
switch w.Kind {
|
||||
case sequence.WarningKindResourceGroupDemotion:
|
||||
// Runtime recovers by demoting; the chart author must fix these.
|
||||
linter.RunLinterRule(support.ErrorSev, path, errors.New(w.Message))
|
||||
case sequence.WarningKindPartialReadiness:
|
||||
// Already reported per-template (with better context) by
|
||||
// validateReadinessAnnotations during collection.
|
||||
default: // isolated group, undeclared/unresolved subchart
|
||||
linter.RunLinterRule(support.WarningSev, path, errors.New(w.Message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectRenderedManifests(linter *support.Linter, c *chart.Chart, namespace string, values map[string]any) []releaseutil.Manifest {
|
||||
options := common.ReleaseOptions{
|
||||
Name: "test-release",
|
||||
Namespace: namespace,
|
||||
}
|
||||
caps := common.DefaultCapabilities.Copy()
|
||||
|
||||
coalescedValues, err := commonutil.CoalesceValues(c, values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
valuesToRender, err := commonutil.ToRenderValues(c, coalescedValues, options, caps)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var renderEngine engine.Engine
|
||||
renderEngine.LintMode = true
|
||||
|
||||
renderedContentMap, err := renderEngine.RenderWithContext(context.Background(), c, valuesToRender)
|
||||
if err != nil {
|
||||
// Template rendering errors are already reported by the Templates lint rule.
|
||||
return nil
|
||||
}
|
||||
|
||||
var manifests []releaseutil.Manifest
|
||||
for _, templatePath := range slices.Sorted(maps.Keys(renderedContentMap)) {
|
||||
content := renderedContentMap[templatePath]
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, manifest := range parseRenderedManifests(templatePath, content) {
|
||||
// HIP-0025 explicitly excludes hooks from sequencing: at install
|
||||
// time SortManifests routes hook resources out before resource-group
|
||||
// parsing runs, so their sequencing annotations are ignored. Mirror
|
||||
// that here — otherwise lint reads a hook's leftover
|
||||
// resource-group/depends-on annotations into the DAG and can report
|
||||
// spurious cycles or orphan errors that never occur at runtime.
|
||||
if isHookManifest(manifest) {
|
||||
continue
|
||||
}
|
||||
validateReadinessAnnotations(linter, templatePath, manifest)
|
||||
manifests = append(manifests, manifest)
|
||||
}
|
||||
}
|
||||
|
||||
return manifests
|
||||
}
|
||||
|
||||
// isHookManifest reports whether a rendered manifest is a Helm hook. Hooks
|
||||
// carry the helm.sh/hook annotation and are excluded from HIP-0025 sequencing
|
||||
// (the install path separates them via SortManifests), so the sequencing lint
|
||||
// rules must skip them too.
|
||||
func isHookManifest(manifest releaseutil.Manifest) bool {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(manifest.Head.Metadata.Annotations[release.HookAnnotation]) != ""
|
||||
}
|
||||
|
||||
func parseRenderedManifests(templatePath, content string) []releaseutil.Manifest {
|
||||
rawManifests := releaseutil.SplitManifests(content)
|
||||
manifests := make([]releaseutil.Manifest, 0, len(rawManifests))
|
||||
|
||||
for _, manifestName := range slices.Sorted(maps.Keys(rawManifests)) {
|
||||
raw := rawManifests[manifestName]
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var head releaseutil.SimpleHead
|
||||
if err := yaml.Unmarshal([]byte(raw), &head); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
manifests = append(manifests, releaseutil.Manifest{
|
||||
Name: templatePath,
|
||||
Content: raw,
|
||||
Head: &head,
|
||||
})
|
||||
}
|
||||
|
||||
return manifests
|
||||
}
|
||||
|
||||
func validateReadinessAnnotations(linter *support.Linter, templatePath string, manifest releaseutil.Manifest) {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil {
|
||||
return
|
||||
}
|
||||
|
||||
annotations := manifest.Head.Metadata.Annotations
|
||||
successRaw := strings.TrimSpace(annotations[kube.AnnotationReadinessSuccess])
|
||||
failureRaw := strings.TrimSpace(annotations[kube.AnnotationReadinessFailure])
|
||||
hasSuccess := successRaw != ""
|
||||
hasFailure := failureRaw != ""
|
||||
|
||||
if hasSuccess != hasFailure {
|
||||
linter.RunLinterRule(support.ErrorSev, templatePath, fmt.Errorf(
|
||||
"resource %q has only one of %q / %q annotations; both must be present or absent together",
|
||||
resourceDisplayName(manifest),
|
||||
kube.AnnotationReadinessSuccess,
|
||||
kube.AnnotationReadinessFailure,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Both present (or both absent). When present, the JSONPath expressions
|
||||
// must still be well-formed — the presence-symmetry check alone let
|
||||
// malformed expressions through at lint time.
|
||||
for key, raw := range map[string]string{
|
||||
kube.AnnotationReadinessSuccess: successRaw,
|
||||
kube.AnnotationReadinessFailure: failureRaw,
|
||||
} {
|
||||
if err := kube.ValidateReadinessExpressions(raw); err != nil {
|
||||
linter.RunLinterRule(support.ErrorSev, templatePath, fmt.Errorf(
|
||||
"resource %q has malformed %q annotation: %w",
|
||||
resourceDisplayName(manifest), key, err,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resourceDisplayName(manifest releaseutil.Manifest) string {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil {
|
||||
return manifest.Name
|
||||
}
|
||||
|
||||
if manifest.Head.Kind == "" {
|
||||
return manifest.Head.Metadata.Name
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s", manifest.Head.Kind, manifest.Head.Metadata.Name)
|
||||
}
|
||||
@ -0,0 +1,449 @@
|
||||
/*
|
||||
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 rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/chart/v2/lint/support"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func TestSequencing_SubchartCircularDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", DependsOn: []string{"subchart-b"}},
|
||||
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}},
|
||||
}
|
||||
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.ErrorSev, "subchart circular dependency detected")
|
||||
}
|
||||
|
||||
func TestSequencing_AliasedSubchartDependsOnOriginalName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Full loader + ProcessDependencies pipeline: an aliased subchart may be
|
||||
// referenced by its original chart name (HIP-0025: "names or aliases").
|
||||
// Before the fix this reported a spurious "unknown or disabled subchart".
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "aliased-a"},
|
||||
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}},
|
||||
}
|
||||
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
assert.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestSequencing_AmbiguousDependsOnReported(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Ambiguity is now rejected by ProcessDependencies; the lint rule must
|
||||
// surface that instead of silently skipping sequencing validation.
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "first"},
|
||||
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "second"},
|
||||
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}},
|
||||
}
|
||||
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.ErrorSev, `ambiguous subchart reference "subchart-a"`)
|
||||
}
|
||||
|
||||
func TestSequencing_SubchartAnnotationRequiresHIPListSyntax(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a"},
|
||||
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b"},
|
||||
}
|
||||
root.Metadata.Annotations = map[string]string{
|
||||
chartutil.AnnotationDependsOnSubcharts: `{"subchart-a":["subchart-b"],"subchart-b":["subchart-a"]}`,
|
||||
}
|
||||
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.ErrorSev, "JSON string array")
|
||||
}
|
||||
|
||||
func TestSequencing_SubchartAnnotationOrphanWithNoDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A chart with ZERO dependencies can still carry a
|
||||
// helm.sh/depends-on/subcharts annotation referencing a subchart that
|
||||
// doesn't exist. The lint rule previously early-returned on
|
||||
// len(Dependencies)==0 and missed this orphan reference entirely.
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Annotations = map[string]string{
|
||||
chartutil.AnnotationDependsOnSubcharts: `["does-not-exist"]`,
|
||||
}
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.ErrorSev, "unknown or disabled subchart")
|
||||
}
|
||||
|
||||
func TestSequencing_NestedSubchartCircularDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The CHILD's Chart.yaml declares a depends-on cycle between its two
|
||||
// grandchildren. HEAD's lint only validates the ROOT's subchart DAG, so
|
||||
// this chart lints clean but fails fatally at install (bead lkx).
|
||||
child := newChart("child", map[string]string{
|
||||
"templates/cm.yaml": manifestYAML("ConfigMap", "child-cm", nil),
|
||||
})
|
||||
child.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "grandchild-a", Version: "0.1.0", Repository: "file://charts/grandchild-a", DependsOn: []string{"grandchild-b"}},
|
||||
{Name: "grandchild-b", Version: "0.1.0", Repository: "file://charts/grandchild-b", DependsOn: []string{"grandchild-a"}},
|
||||
}
|
||||
child.SetDependencies(newChart("grandchild-a", nil), newChart("grandchild-b", nil))
|
||||
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "child", Version: "0.1.0", Repository: "file://charts/child"},
|
||||
}
|
||||
root.SetDependencies(child)
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.ErrorSev, "subchart circular dependency detected")
|
||||
requireMessage(t, messages, support.ErrorSev, "testchart/charts/child")
|
||||
}
|
||||
|
||||
func TestSequencing_NestedSubchartUnknownDependsOnRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
child := newChart("child", map[string]string{
|
||||
"templates/cm.yaml": manifestYAML("ConfigMap", "child-cm", nil),
|
||||
})
|
||||
child.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "grandchild-a", Version: "0.1.0", Repository: "file://charts/grandchild-a", DependsOn: []string{"missing"}},
|
||||
}
|
||||
child.SetDependencies(newChart("grandchild-a", nil))
|
||||
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "child", Version: "0.1.0", Repository: "file://charts/child"},
|
||||
}
|
||||
root.SetDependencies(child)
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.ErrorSev, `depends-on unknown or disabled subchart "missing"`)
|
||||
requireMessage(t, messages, support.ErrorSev, "testchart/charts/child")
|
||||
}
|
||||
|
||||
func TestSequencing_NestedResourceGroupCircularDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
child := newChart("child", map[string]string{
|
||||
"templates/a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "a",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["b"]'`,
|
||||
}),
|
||||
"templates/b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "b",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["a"]'`,
|
||||
}),
|
||||
})
|
||||
root := newChart("testchart", nil)
|
||||
root.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "child", Version: "0.1.0", Repository: "file://charts/child"},
|
||||
}
|
||||
root.SetDependencies(child)
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.ErrorSev, "resource-group circular dependency detected")
|
||||
requireMessage(t, messages, support.ErrorSev, "testchart/charts/child")
|
||||
}
|
||||
|
||||
func TestSequencing_IsolatedGroupWarns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Two groups, no depends-on edges between them: runtime demotes both to
|
||||
// the unsequenced batch with a warning. Lint surfaces that demotion as a
|
||||
// WARNING (not an error — the chart still deploys).
|
||||
messages := runSequencingLint(t, newChart("testchart", map[string]string{
|
||||
"templates/a.yaml": manifestYAML("ConfigMap", "cm-a", map[string]string{releaseutil.AnnotationResourceGroup: "a"}),
|
||||
"templates/b.yaml": manifestYAML("ConfigMap", "cm-b", map[string]string{releaseutil.AnnotationResourceGroup: "b"}),
|
||||
}))
|
||||
requireMessage(t, messages, support.WarningSev, "isolated")
|
||||
}
|
||||
|
||||
func TestSequencing_UndeclaredSubchartWarns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Vendored subchart present in charts/ but absent from Chart.yaml
|
||||
// dependencies: runtime deploys it after declared subcharts with a
|
||||
// warning. Lint mirrors that as a WARNING.
|
||||
sub := newChart("vendored", map[string]string{
|
||||
"templates/cm.yaml": manifestYAML("ConfigMap", "vendored-cm", nil),
|
||||
})
|
||||
root := newChart("testchart", nil)
|
||||
root.SetDependencies(sub) // deliberately NOT in root.Metadata.Dependencies
|
||||
|
||||
messages := runSequencingLint(t, root)
|
||||
requireMessage(t, messages, support.WarningSev, "not declared in Chart.yaml")
|
||||
}
|
||||
|
||||
func TestSequencing_RenderedAnnotationRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
templates map[string]string
|
||||
wantSeverity int
|
||||
wantSubstring string
|
||||
}{
|
||||
{
|
||||
name: "partial readiness annotations",
|
||||
templates: map[string]string{
|
||||
"templates/configmap.yaml": manifestYAML("ConfigMap", "partial-readiness", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "bootstrap",
|
||||
"helm.sh/readiness-success": `'["{.phase} == \"Ready\""]'`,
|
||||
}),
|
||||
},
|
||||
wantSeverity: support.ErrorSev,
|
||||
wantSubstring: "both must be present or absent together",
|
||||
},
|
||||
{
|
||||
name: "duplicate group assignment",
|
||||
templates: map[string]string{
|
||||
"templates/first.yaml": manifestYAML("ConfigMap", "shared-resource", map[string]string{releaseutil.AnnotationResourceGroup: "bootstrap"}),
|
||||
"templates/second.yaml": manifestYAML("ConfigMap", "shared-resource", map[string]string{releaseutil.AnnotationResourceGroup: "app"}),
|
||||
},
|
||||
wantSeverity: support.ErrorSev,
|
||||
wantSubstring: "assigned to multiple resource groups",
|
||||
},
|
||||
{
|
||||
name: "circular resource-group dependencies",
|
||||
templates: map[string]string{
|
||||
"templates/group-a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "a",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["c"]'`,
|
||||
}),
|
||||
"templates/group-b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "b",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["a"]'`,
|
||||
}),
|
||||
"templates/group-c.yaml": manifestYAML("ConfigMap", "group-c", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "c",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["b"]'`,
|
||||
}),
|
||||
},
|
||||
wantSeverity: support.ErrorSev,
|
||||
wantSubstring: "resource-group circular dependency detected",
|
||||
},
|
||||
{
|
||||
name: "orphan resource-group reference",
|
||||
templates: map[string]string{
|
||||
"templates/configmap.yaml": manifestYAML("ConfigMap", "orphaned-group", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["database"]'`,
|
||||
}),
|
||||
},
|
||||
wantSeverity: support.ErrorSev,
|
||||
wantSubstring: `depends-on non-existent group "database"`,
|
||||
},
|
||||
{
|
||||
name: "malformed depends-on-resource-groups annotation",
|
||||
templates: map[string]string{
|
||||
"templates/configmap.yaml": manifestYAML("ConfigMap", "bad-json", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `not-a-json-array`,
|
||||
}),
|
||||
},
|
||||
wantSeverity: support.ErrorSev,
|
||||
wantSubstring: "depends-on",
|
||||
},
|
||||
{
|
||||
// Both readiness annotations present (so the presence-symmetry
|
||||
// check passes), but the JSONPath in readiness-success is
|
||||
// malformed. Presence symmetry alone let this through; the rule
|
||||
// must validate the expressions compile.
|
||||
name: "malformed readiness JSONPath with both annotations present",
|
||||
templates: map[string]string{
|
||||
"templates/job.yaml": manifestYAML("Job", "bad-jsonpath", map[string]string{
|
||||
"helm.sh/readiness-success": `'["{.status.succeeded >= 1"]'`,
|
||||
"helm.sh/readiness-failure": `'["{.status.failed} >= 1"]'`,
|
||||
}),
|
||||
},
|
||||
wantSeverity: support.ErrorSev,
|
||||
wantSubstring: "malformed",
|
||||
},
|
||||
{
|
||||
// The comparison value's type IS statically known: an ordering
|
||||
// operator with a non-numeric literal can never evaluate at
|
||||
// runtime, so lint flags it as a definite authoring error.
|
||||
name: "ordering operator with non-numeric readiness value",
|
||||
templates: map[string]string{
|
||||
"templates/job.yaml": manifestYAML("Job", "string-ordering", map[string]string{
|
||||
"helm.sh/readiness-success": `'["{.status.phase} > \"Running\""]'`,
|
||||
"helm.sh/readiness-failure": `'["{.status.failed} >= 1"]'`,
|
||||
}),
|
||||
},
|
||||
wantSeverity: support.ErrorSev,
|
||||
wantSubstring: "requires a numeric comparison value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
messages := runSequencingLint(t, newChart("testchart", tc.templates))
|
||||
requireMessage(t, messages, tc.wantSeverity, tc.wantSubstring)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequencing_HookResourcesExcludedFromResourceGroupDAG(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A hook resource carrying sequencing annotations that WOULD form a cycle
|
||||
// with the non-hook resources must be ignored by the sequencing lint rules,
|
||||
// exactly as the install path ignores it (SortManifests routes hooks out
|
||||
// before resource-group parsing). group-b depends on group-a; the hook
|
||||
// claims group-a depends-on group-b — a cycle only if the hook is counted.
|
||||
templates := map[string]string{
|
||||
"templates/group-a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "group-a",
|
||||
}),
|
||||
"templates/group-b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "group-b",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["group-a"]'`,
|
||||
}),
|
||||
"templates/hook-job.yaml": manifestYAML("Job", "hook-job", map[string]string{
|
||||
"helm.sh/hook": "pre-install",
|
||||
releaseutil.AnnotationResourceGroup: "group-a",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `'["group-b"]'`,
|
||||
}),
|
||||
}
|
||||
|
||||
messages := runSequencingLint(t, newChart("hook-chart", templates))
|
||||
|
||||
for _, m := range messages {
|
||||
assert.NotContains(t, m.Err.Error(), "circular",
|
||||
"hook resource must be excluded from the resource-group DAG; got: %v", m.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequencing_NoMessagesWithoutSequencingAnnotations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
messages := runSequencingLint(t, newChart("plain-chart", map[string]string{
|
||||
"templates/configmap.yaml": manifestYAML("ConfigMap", "plain", nil),
|
||||
}))
|
||||
|
||||
assert.Empty(t, messages)
|
||||
}
|
||||
|
||||
func runSequencingLint(t *testing.T, c *chart.Chart) []support.Message {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
require.NoError(t, chartutil.SaveDir(c, tmpDir))
|
||||
|
||||
linter := support.Linter{ChartDir: filepath.Join(tmpDir, c.Name())}
|
||||
Sequencing(&linter, "test-namespace", nil)
|
||||
|
||||
return linter.Messages
|
||||
}
|
||||
|
||||
func requireMessage(t *testing.T, messages []support.Message, severity int, substring string) {
|
||||
t.Helper()
|
||||
|
||||
for _, message := range messages {
|
||||
if message.Severity == severity && strings.Contains(message.Err.Error(), substring) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected severity %d message containing %q, got %#v", severity, substring, messages)
|
||||
}
|
||||
|
||||
func newChart(name string, templates map[string]string) *chart.Chart {
|
||||
files := make([]*common.File, 0, len(templates))
|
||||
names := make([]string, 0, len(templates))
|
||||
for templateName := range templates {
|
||||
names = append(names, templateName)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, templateName := range names {
|
||||
files = append(files, &common.File{
|
||||
Name: templateName,
|
||||
Data: []byte(templates[templateName]),
|
||||
})
|
||||
}
|
||||
|
||||
return &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: name,
|
||||
Version: "0.1.0",
|
||||
APIVersion: chart.APIVersionV2,
|
||||
},
|
||||
Templates: files,
|
||||
}
|
||||
}
|
||||
|
||||
func manifestYAML(kind, name string, annotations map[string]string) string {
|
||||
if len(annotations) == 0 {
|
||||
return fmt.Sprintf(`apiVersion: v1
|
||||
kind: %s
|
||||
metadata:
|
||||
name: %s
|
||||
data:
|
||||
key: value
|
||||
`, kind, name)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(annotations))
|
||||
for key := range annotations {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var annotationBlock strings.Builder
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(&annotationBlock, "\n %s: %s", key, annotations[key])
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`apiVersion: v1
|
||||
kind: %s
|
||||
metadata:
|
||||
name: %s
|
||||
annotations:%s
|
||||
data:
|
||||
key: value
|
||||
`, kind, name, annotationBlock.String())
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
/*
|
||||
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 util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DAG is a directed acyclic graph of string-keyed nodes, used for resource-group
|
||||
// and subchart dependency ordering.
|
||||
//
|
||||
// Edges are directed: AddEdge("a", "b") means "b depends on a" (a must come before b).
|
||||
type DAG struct {
|
||||
nodes map[string]struct{}
|
||||
edges map[string][]string // from -> []to (dependents)
|
||||
edgeSet map[string]struct{} // "from\x00to" -> exists (dedup guard)
|
||||
inDegree map[string]int // node -> number of prerequisites
|
||||
}
|
||||
|
||||
// NewDAG creates an empty DAG.
|
||||
func NewDAG() *DAG {
|
||||
return &DAG{
|
||||
nodes: make(map[string]struct{}),
|
||||
edges: make(map[string][]string),
|
||||
edgeSet: make(map[string]struct{}),
|
||||
inDegree: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// AddNode registers a node in the DAG. Duplicate adds are idempotent.
|
||||
func (d *DAG) AddNode(name string) {
|
||||
if _, ok := d.nodes[name]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
d.nodes[name] = struct{}{}
|
||||
d.edges[name] = nil
|
||||
d.inDegree[name] = 0
|
||||
}
|
||||
|
||||
// AddEdge adds a directed edge: "to" depends on "from" (from is deployed before to).
|
||||
// Returns an error if either node is unknown or if a self-loop is requested.
|
||||
func (d *DAG) AddEdge(from, to string) error {
|
||||
if from == to {
|
||||
return fmt.Errorf("self-loop not allowed: %q", from)
|
||||
}
|
||||
if _, ok := d.nodes[from]; !ok {
|
||||
return fmt.Errorf("unknown node %q", from)
|
||||
}
|
||||
if _, ok := d.nodes[to]; !ok {
|
||||
return fmt.Errorf("unknown node %q", to)
|
||||
}
|
||||
|
||||
key := from + "\x00" + to
|
||||
if _, exists := d.edgeSet[key]; exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
d.edgeSet[key] = struct{}{}
|
||||
d.edges[from] = append(d.edges[from], to)
|
||||
d.inDegree[to]++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBatches performs a topological sort using Kahn's algorithm and returns
|
||||
// the nodes grouped into deployment batches. Each batch contains nodes that
|
||||
// can be deployed in parallel. Batches are ordered: batch 0 has no prerequisites,
|
||||
// batch 1 depends only on batch 0, etc.
|
||||
//
|
||||
// Returns an error if a cycle is detected, including the names of the nodes
|
||||
// involved in the cycle.
|
||||
func (d *DAG) GetBatches() ([][]string, error) {
|
||||
if len(d.nodes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
inDegree := make(map[string]int, len(d.inDegree))
|
||||
maps.Copy(inDegree, d.inDegree)
|
||||
|
||||
var batches [][]string
|
||||
processed := 0
|
||||
|
||||
for {
|
||||
var batch []string
|
||||
for node := range d.nodes {
|
||||
if inDegree[node] == 0 {
|
||||
batch = append(batch, node)
|
||||
inDegree[node] = -1
|
||||
}
|
||||
}
|
||||
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
sort.Strings(batch)
|
||||
batches = append(batches, batch)
|
||||
processed += len(batch)
|
||||
|
||||
for _, node := range batch {
|
||||
for _, dependent := range d.edges[node] {
|
||||
inDegree[dependent]--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if processed == len(d.nodes) {
|
||||
return batches, nil
|
||||
}
|
||||
|
||||
cycleNodes := make([]string, 0, len(d.nodes)-processed)
|
||||
for node := range d.nodes {
|
||||
if inDegree[node] > 0 {
|
||||
cycleNodes = append(cycleNodes, node)
|
||||
}
|
||||
}
|
||||
sort.Strings(cycleNodes)
|
||||
|
||||
return nil, fmt.Errorf("cycle detected among nodes: %s", strings.Join(cycleNodes, ", "))
|
||||
}
|
||||
|
||||
// Nodes returns a sorted slice of all node names in the DAG.
|
||||
func (d *DAG) Nodes() []string {
|
||||
nodes := make([]string, 0, len(d.nodes))
|
||||
for node := range d.nodes {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
// HasNode reports whether the DAG contains a node with the given name.
|
||||
func (d *DAG) HasNode(name string) bool {
|
||||
_, ok := d.nodes[name]
|
||||
return ok
|
||||
}
|
||||
@ -0,0 +1,232 @@
|
||||
/*
|
||||
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 util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDAG_BatchOrdering(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nodes []string
|
||||
edges [][2]string
|
||||
expected [][]string
|
||||
}{
|
||||
{
|
||||
name: "empty DAG",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "single node",
|
||||
nodes: []string{"a"},
|
||||
expected: [][]string{{"a"}},
|
||||
},
|
||||
{
|
||||
name: "linear chain",
|
||||
nodes: []string{"a", "b", "c"},
|
||||
edges: [][2]string{{"a", "b"}, {"b", "c"}},
|
||||
expected: [][]string{
|
||||
{"a"},
|
||||
{"b"},
|
||||
{"c"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diamond",
|
||||
nodes: []string{"a", "b", "c", "d"},
|
||||
edges: [][2]string{{"a", "b"}, {"a", "c"}, {"b", "d"}, {"c", "d"}},
|
||||
expected: [][]string{
|
||||
{"a"},
|
||||
{"b", "c"},
|
||||
{"d"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple roots",
|
||||
nodes: []string{"a", "b", "c"},
|
||||
edges: [][2]string{{"a", "c"}, {"b", "c"}},
|
||||
expected: [][]string{
|
||||
{"a", "b"},
|
||||
{"c"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate edge is idempotent",
|
||||
nodes: []string{"a", "b"},
|
||||
edges: [][2]string{{"a", "b"}, {"a", "b"}},
|
||||
expected: [][]string{
|
||||
{"a"},
|
||||
{"b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dag := buildDAG(t, tt.nodes, tt.edges)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, batches)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDAG_CycleDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dag := buildDAG(t, []string{"a", "b", "c"}, [][2]string{{"a", "b"}, {"b", "c"}, {"c", "a"}})
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, batches)
|
||||
assert.ErrorContains(t, err, "cycle")
|
||||
assert.ErrorContains(t, err, "a")
|
||||
assert.ErrorContains(t, err, "b")
|
||||
assert.ErrorContains(t, err, "c")
|
||||
}
|
||||
|
||||
func TestDAG_SelfLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dag := NewDAG()
|
||||
dag.AddNode("a")
|
||||
|
||||
err := dag.AddEdge("a", "a")
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "self-loop")
|
||||
}
|
||||
|
||||
func TestDAG_UnknownNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
match string
|
||||
}{
|
||||
{
|
||||
name: "missing source node",
|
||||
from: "missing",
|
||||
to: "a",
|
||||
match: "unknown node",
|
||||
},
|
||||
{
|
||||
name: "missing destination node",
|
||||
from: "a",
|
||||
to: "missing",
|
||||
match: "unknown node",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dag := NewDAG()
|
||||
dag.AddNode("a")
|
||||
|
||||
err := dag.AddEdge(tt.from, tt.to)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.match)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDAG_DisconnectedComponents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dag := buildDAG(t,
|
||||
[]string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta"},
|
||||
[][2]string{{"alpha", "gamma"}, {"beta", "delta"}, {"delta", "epsilon"}},
|
||||
)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, [][]string{
|
||||
{"alpha", "beta", "zeta"},
|
||||
{"delta", "gamma"},
|
||||
{"epsilon"},
|
||||
}, batches)
|
||||
}
|
||||
|
||||
func TestDAG_LargeGraph(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const nodeCount = 128
|
||||
|
||||
nodes := make([]string, 0, nodeCount)
|
||||
edges := make([][2]string, 0, nodeCount-1)
|
||||
for i := range nodeCount {
|
||||
nodes = append(nodes, fmt.Sprintf("node-%03d", i))
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
edges = append(edges, [2]string{
|
||||
fmt.Sprintf("node-%03d", i-1),
|
||||
fmt.Sprintf("node-%03d", i),
|
||||
})
|
||||
}
|
||||
|
||||
dag := buildDAG(t, nodes, edges)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batches, nodeCount)
|
||||
|
||||
for i, batch := range batches {
|
||||
assert.Equal(t, []string{fmt.Sprintf("node-%03d", i)}, batch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDAG_NodeHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dag := NewDAG()
|
||||
dag.AddNode("charlie")
|
||||
dag.AddNode("alpha")
|
||||
dag.AddNode("bravo")
|
||||
|
||||
assert.True(t, dag.HasNode("alpha"))
|
||||
assert.False(t, dag.HasNode("delta"))
|
||||
assert.Equal(t, []string{"alpha", "bravo", "charlie"}, dag.Nodes())
|
||||
}
|
||||
|
||||
func buildDAG(t *testing.T, nodes []string, edges [][2]string) *DAG {
|
||||
t.Helper()
|
||||
|
||||
dag := NewDAG()
|
||||
for _, node := range nodes {
|
||||
dag.AddNode(node)
|
||||
}
|
||||
|
||||
for _, edge := range edges {
|
||||
require.NoError(t, dag.AddEdge(edge[0], edge[1]))
|
||||
}
|
||||
|
||||
return dag
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
/*
|
||||
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 util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// AnnotationDependsOnSubcharts is the Chart.yaml annotation key for declaring
|
||||
// parent-chart dependencies on subcharts. The value is a JSON string array of
|
||||
// subchart names (or aliases) that must be ready before parent resources are
|
||||
// installed.
|
||||
AnnotationDependsOnSubcharts = "helm.sh/depends-on/subcharts"
|
||||
)
|
||||
|
||||
// BuildSubchartDAG constructs a DAG from a chart's subchart dependency declarations.
|
||||
//
|
||||
// Subchart-to-subchart ordering is read from the depends-on field on Chart.yaml
|
||||
// dependency entries. The helm.sh/depends-on/subcharts metadata annotation is
|
||||
// validated as the parent chart's dependencies on subcharts; parent resources
|
||||
// are deployed after subchart batches by the action layer.
|
||||
//
|
||||
// Subcharts are keyed by their effective name (alias if set, otherwise name).
|
||||
// ProcessDependencies must have been called on the chart first: it prunes
|
||||
// disabled subcharts, applies alias renames, and rewrites depends-on
|
||||
// references — dependency depends-on entries and the
|
||||
// helm.sh/depends-on/subcharts annotation — to effective names (see
|
||||
// resolveDependsOnReferences). BuildSubchartDAG therefore resolves references
|
||||
// against effective names only. This holds for charts decoded from release
|
||||
// storage too: they were processed before being stored, so their persisted
|
||||
// depends-on references already use effective names.
|
||||
func BuildSubchartDAG(c *chart.Chart) (*DAG, error) {
|
||||
dag := NewDAG()
|
||||
|
||||
if c == nil || c.Metadata == nil {
|
||||
return dag, nil
|
||||
}
|
||||
|
||||
// Build the set of subcharts that survived ProcessDependencies.
|
||||
loaded := make(map[string]bool, len(c.Dependencies()))
|
||||
for _, sub := range c.Dependencies() {
|
||||
loaded[sub.Name()] = true
|
||||
}
|
||||
|
||||
// Charts decoded from release storage have no loaded dependency tree:
|
||||
// Chart.dependencies is unexported, so the release codec (json.Marshal in
|
||||
// pkg/storage/driver) drops it. Metadata.Dependencies survives, and per
|
||||
// the ProcessDependencies precondition above it was already pruned to the
|
||||
// enabled set with aliases and depends-on references rewritten. Enabled
|
||||
// is only ever set true by ProcessDependencies, so it marks entries that
|
||||
// were deployable at install time; freshly parsed Chart.yaml entries
|
||||
// default to Enabled=false and stay excluded here.
|
||||
trustMetadata := len(c.Dependencies()) == 0
|
||||
|
||||
// Each loaded subchart becomes a DAG node keyed by its effective name.
|
||||
nodes := make(map[string]bool, len(c.Metadata.Dependencies))
|
||||
for _, dep := range c.Metadata.Dependencies {
|
||||
if dep == nil {
|
||||
continue
|
||||
}
|
||||
eff := effectiveDependencyName(dep)
|
||||
trustedFromMetadata := trustMetadata && dep.Enabled
|
||||
if (!loaded[eff] && !trustedFromMetadata) || nodes[eff] {
|
||||
continue
|
||||
}
|
||||
nodes[eff] = true
|
||||
dag.AddNode(eff)
|
||||
}
|
||||
|
||||
for _, dep := range c.Metadata.Dependencies {
|
||||
if dep == nil {
|
||||
continue
|
||||
}
|
||||
eff := effectiveDependencyName(dep)
|
||||
if !nodes[eff] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, prerequisite := range dep.DependsOn {
|
||||
if !nodes[prerequisite] {
|
||||
return nil, fmt.Errorf("subchart %q depends-on unknown or disabled subchart %q", eff, prerequisite)
|
||||
}
|
||||
if err := dag.AddEdge(prerequisite, eff); err != nil {
|
||||
return nil, fmt.Errorf("adding sequencing edge %s→%s: %w", prerequisite, eff, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateParentSubchartDependencies(c.Metadata.Annotations[AnnotationDependsOnSubcharts], nodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dag, nil
|
||||
}
|
||||
|
||||
// resolveDependsOnReferences rewrites depends-on references — Chart.yaml
|
||||
// dependency depends-on entries and the helm.sh/depends-on/subcharts
|
||||
// annotation — from a subchart's original chart name to its effective name
|
||||
// (alias if set, otherwise name). It must run while original names are still
|
||||
// present in c.Metadata.Dependencies, i.e. before processDependencyEnabled
|
||||
// applies its Name = Alias rewrite; that rewrite makes original names
|
||||
// unrecoverable. The rewritten references are what release storage persists
|
||||
// and what BuildSubchartDAG consumes, including at uninstall time when the
|
||||
// chart is decoded from the release record.
|
||||
//
|
||||
// A reference matching more than one subchart (e.g. the original name of a
|
||||
// chart pulled in under two aliases) is rejected as ambiguous. References
|
||||
// matching no dependency are left unchanged so BuildSubchartDAG can report
|
||||
// them as unknown or disabled, and a malformed annotation is left for
|
||||
// BuildSubchartDAG's JSON parse error. Resolution is idempotent: effective
|
||||
// names resolve to themselves, so reprocessing an already-processed chart is
|
||||
// a no-op.
|
||||
func resolveDependsOnReferences(c *chart.Chart) error {
|
||||
refs := newSubchartRefs()
|
||||
for _, dep := range c.Metadata.Dependencies {
|
||||
if dep == nil {
|
||||
continue
|
||||
}
|
||||
eff := effectiveDependencyName(dep)
|
||||
refs.register(eff, eff)
|
||||
refs.register(dep.Name, eff)
|
||||
}
|
||||
|
||||
for _, dep := range c.Metadata.Dependencies {
|
||||
if dep == nil {
|
||||
continue
|
||||
}
|
||||
for i, ref := range dep.DependsOn {
|
||||
eff, found, isAmbiguous := refs.resolve(ref)
|
||||
if isAmbiguous {
|
||||
return fmt.Errorf("subchart %q depends-on ambiguous subchart reference %q; reference it by alias to disambiguate", effectiveDependencyName(dep), ref)
|
||||
}
|
||||
if found {
|
||||
dep.DependsOn[i] = eff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolveAnnotationDependsOn(c, refs)
|
||||
}
|
||||
|
||||
// resolveAnnotationDependsOn rewrites the parent chart's
|
||||
// helm.sh/depends-on/subcharts annotation entries to effective subchart names.
|
||||
func resolveAnnotationDependsOn(c *chart.Chart, refs *subchartRefs) error {
|
||||
annotation := strings.TrimSpace(c.Metadata.Annotations[AnnotationDependsOnSubcharts])
|
||||
if annotation == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var prerequisites []string
|
||||
if err := json.Unmarshal([]byte(annotation), &prerequisites); err != nil {
|
||||
// Malformed JSON is reported by BuildSubchartDAG with full context.
|
||||
return nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i, ref := range prerequisites {
|
||||
eff, found, isAmbiguous := refs.resolve(ref)
|
||||
if isAmbiguous {
|
||||
return fmt.Errorf("annotation %s references ambiguous subchart %q; reference it by alias to disambiguate", AnnotationDependsOnSubcharts, ref)
|
||||
}
|
||||
if found && eff != ref {
|
||||
prerequisites[i] = eff
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(prerequisites)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-encoding %s annotation: %w", AnnotationDependsOnSubcharts, err)
|
||||
}
|
||||
c.Metadata.Annotations[AnnotationDependsOnSubcharts] = string(encoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
// subchartRefs resolves depends-on references to effective subchart names
|
||||
// while processing dependencies, i.e. while original chart names are still
|
||||
// present. A reference may be a subchart's effective name (alias if set,
|
||||
// otherwise name) or its original chart name. A reference that maps to more
|
||||
// than one subchart is recorded as ambiguous and rejected on use, rather than
|
||||
// being silently resolved to one of them.
|
||||
type subchartRefs struct {
|
||||
byRef map[string]string
|
||||
ambiguous map[string]bool
|
||||
}
|
||||
|
||||
func newSubchartRefs() *subchartRefs {
|
||||
return &subchartRefs{
|
||||
byRef: make(map[string]string),
|
||||
ambiguous: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// register maps ref to the effective subchart name eff. If ref already resolves
|
||||
// to a different subchart, it is flagged ambiguous.
|
||||
func (s *subchartRefs) register(ref, eff string) {
|
||||
if ref == "" {
|
||||
return
|
||||
}
|
||||
if existing, ok := s.byRef[ref]; ok && existing != eff {
|
||||
s.ambiguous[ref] = true
|
||||
return
|
||||
}
|
||||
s.byRef[ref] = eff
|
||||
}
|
||||
|
||||
// resolve returns the effective subchart name for ref. found is false for an
|
||||
// unknown reference; isAmbiguous is true when ref maps to multiple subcharts.
|
||||
func (s *subchartRefs) resolve(ref string) (eff string, found, isAmbiguous bool) {
|
||||
if s.ambiguous[ref] {
|
||||
return "", false, true
|
||||
}
|
||||
eff, found = s.byRef[ref]
|
||||
return eff, found, false
|
||||
}
|
||||
|
||||
func validateParentSubchartDependencies(annotation string, nodes map[string]bool) error {
|
||||
annotation = strings.TrimSpace(annotation)
|
||||
if annotation == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var prerequisites []string
|
||||
if err := json.Unmarshal([]byte(annotation), &prerequisites); err != nil {
|
||||
return fmt.Errorf("parsing %s annotation as JSON string array: %w", AnnotationDependsOnSubcharts, err)
|
||||
}
|
||||
|
||||
for _, prerequisite := range prerequisites {
|
||||
if !nodes[prerequisite] {
|
||||
return fmt.Errorf("annotation %s references unknown or disabled subchart %q", AnnotationDependsOnSubcharts, prerequisite)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveDependencyName(dep *chart.Dependency) string {
|
||||
if dep.Alias != "" {
|
||||
return dep.Alias
|
||||
}
|
||||
return dep.Name
|
||||
}
|
||||
@ -0,0 +1,554 @@
|
||||
/*
|
||||
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 util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
)
|
||||
|
||||
func TestBuildSubchartDAG_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
batches := batchesForChart(t, newChart("parent"))
|
||||
assert.Empty(t, batches)
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_NoDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
enabledDependency("nginx"),
|
||||
enabledDependency("rabbitmq"),
|
||||
enabledDependency("postgres"),
|
||||
)
|
||||
|
||||
assertBatches(t, c, [][]string{{"nginx", "postgres", "rabbitmq"}})
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_LinearOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
enabledDependency("postgres"),
|
||||
enabledDependency("rabbitmq", "postgres"),
|
||||
enabledDependency("app", "rabbitmq"),
|
||||
)
|
||||
|
||||
assertBatches(t, c, [][]string{{"postgres"}, {"rabbitmq"}, {"app"}})
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_AliasResolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Simulates post-ProcessDependencies state: the aliased entry has had
|
||||
// Name rewritten to its alias, and depends-on entries were rewritten to
|
||||
// effective names. (The real-pipeline path is covered by the
|
||||
// TestProcessDependencies_* tests below.)
|
||||
c := newChart("parent",
|
||||
&chart.Dependency{Name: "primary-db", Alias: "primary-db", Enabled: true},
|
||||
enabledDependency("app", "primary-db"),
|
||||
)
|
||||
|
||||
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_DisabledSubchart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
&chart.Dependency{Name: "cache", Enabled: false},
|
||||
enabledDependency("app", "cache"),
|
||||
)
|
||||
|
||||
_, err := BuildSubchartDAG(c)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `depends-on unknown or disabled subchart "cache"`)
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_DisabledSubchartNotReferenced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
&chart.Dependency{Name: "cache", Enabled: false},
|
||||
enabledDependency("app"),
|
||||
)
|
||||
|
||||
assertBatches(t, c, [][]string{{"app"}})
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_CycleDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
enabledDependency("a", "b"),
|
||||
enabledDependency("b", "c"),
|
||||
enabledDependency("c", "a"),
|
||||
)
|
||||
|
||||
dag, err := BuildSubchartDAG(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, batches)
|
||||
assert.ErrorContains(t, err, "cycle")
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_AnnotationBasedParentDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
enabledDependency("postgres"),
|
||||
enabledDependency("nginx"),
|
||||
)
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["nginx"]`,
|
||||
}
|
||||
|
||||
assertBatches(t, c, [][]string{{"nginx", "postgres"}})
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_HIPExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("foo",
|
||||
enabledDependency("nginx"),
|
||||
enabledDependency("rabbitmq"),
|
||||
enabledDependency("bar", "nginx", "rabbitmq"),
|
||||
)
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["bar", "rabbitmq"]`,
|
||||
}
|
||||
|
||||
assertBatches(t, c, [][]string{{"nginx", "rabbitmq"}, {"bar"}})
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_MixedDeclarations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
enabledDependency("database"),
|
||||
enabledDependency("api", "database"),
|
||||
enabledDependency("worker"),
|
||||
)
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["worker"]`,
|
||||
}
|
||||
|
||||
assertBatches(t, c, [][]string{{"database", "worker"}, {"api"}})
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_InvalidAnnotationJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent", enabledDependency("api"))
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["api",`,
|
||||
}
|
||||
|
||||
_, err := BuildSubchartDAG(c)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "parsing "+AnnotationDependsOnSubcharts+" annotation")
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_ObjectAnnotationRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent",
|
||||
enabledDependency("postgres"),
|
||||
enabledDependency("nginx"),
|
||||
)
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `{"nginx":["postgres"]}`,
|
||||
}
|
||||
|
||||
_, err := BuildSubchartDAG(c)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "JSON string array")
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_NonExistentReference(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent", enabledDependency("app", "missing"))
|
||||
|
||||
_, err := BuildSubchartDAG(c)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `depends-on unknown or disabled subchart "missing"`)
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_AnnotationUnknownSubchart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newChart("parent", enabledDependency("postgres"))
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["app"]`,
|
||||
}
|
||||
|
||||
_, err := BuildSubchartDAG(c)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "unknown or disabled subchart")
|
||||
}
|
||||
|
||||
func TestBuildSubchartDAG_NestedSubcharts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := newChart("parent",
|
||||
enabledDependency("database"),
|
||||
enabledDependency("application", "database"),
|
||||
)
|
||||
nested := newChart("application",
|
||||
enabledDependency("cache"),
|
||||
enabledDependency("worker", "cache"),
|
||||
)
|
||||
// Replace auto-stubs with real chart objects so nested DAG validation works.
|
||||
root.SetDependencies(
|
||||
&chart.Chart{Metadata: &chart.Metadata{Name: "database"}},
|
||||
nested,
|
||||
)
|
||||
|
||||
assertBatches(t, root, [][]string{{"database"}, {"application"}})
|
||||
assertBatches(t, nested, [][]string{{"cache"}, {"worker"}})
|
||||
}
|
||||
|
||||
// TestBuildSubchartDAG_StorageDecodedMetadataTrusted: a chart decoded from
|
||||
// release storage has Metadata.Dependencies (pruned to the enabled set and
|
||||
// alias-rewritten by ProcessDependencies before it was stored, Enabled=true)
|
||||
// but an EMPTY loaded-dependency tree — the release codec drops the
|
||||
// unexported Chart.dependencies field. Those metadata entries must be
|
||||
// trusted, or uninstall/rollback of any sequenced release fails (bead xmn).
|
||||
func TestBuildSubchartDAG_StorageDecodedMetadataTrusted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "db", Enabled: true},
|
||||
{Name: "app", Enabled: true, DependsOn: []string{"db"}},
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["app"]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Note: no AddDependency call — c.Dependencies() is empty, as after a
|
||||
// storage round-trip.
|
||||
|
||||
assertBatches(t, c, [][]string{{"db"}, {"app"}})
|
||||
}
|
||||
|
||||
// TestBuildSubchartDAG_MetadataOnlyNotEnabled_Ignored: freshly parsed
|
||||
// Chart.yaml entries default to Enabled=false — only ProcessDependencies sets
|
||||
// it true. Without loaded dependencies (e.g. lint before `helm dependency
|
||||
// build`) such entries stay excluded, preserving the pre-existing contract
|
||||
// for unprocessed charts.
|
||||
func TestBuildSubchartDAG_MetadataOnlyNotEnabled_Ignored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "ghost"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
batches := batchesForChart(t, c)
|
||||
assert.Empty(t, batches, "unprocessed metadata-only deps should yield empty DAG")
|
||||
}
|
||||
|
||||
// TestBuildSubchartDAG_AnnotationReferencesUnloadedDep verifies that an
|
||||
// annotation referencing a subchart present in metadata but pruned from
|
||||
// c.Dependencies() (e.g., disabled by ProcessDependencies) produces an error.
|
||||
func TestBuildSubchartDAG_AnnotationReferencesUnloadedDep(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "loaded-dep", Enabled: true},
|
||||
{Name: "pruned-dep", Enabled: true},
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["pruned-dep"]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Only loaded-dep is in c.Dependencies(); pruned-dep is not.
|
||||
c.AddDependency(&chart.Chart{Metadata: &chart.Metadata{Name: "loaded-dep"}})
|
||||
|
||||
_, err := BuildSubchartDAG(c)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `unknown or disabled subchart "pruned-dep"`)
|
||||
}
|
||||
|
||||
func assertBatches(t *testing.T, c *chart.Chart, expected [][]string) {
|
||||
t.Helper()
|
||||
assert.Equal(t, expected, batchesForChart(t, c))
|
||||
}
|
||||
|
||||
func batchesForChart(t *testing.T, c *chart.Chart) [][]string {
|
||||
t.Helper()
|
||||
|
||||
dag, err := BuildSubchartDAG(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
|
||||
return batches
|
||||
}
|
||||
|
||||
func newChart(name string, deps ...*chart.Dependency) *chart.Chart {
|
||||
c := &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: name,
|
||||
Dependencies: deps,
|
||||
},
|
||||
}
|
||||
// Simulate post-ProcessDependencies state: enabled deps appear in
|
||||
// c.Dependencies() under their effective name (alias if set).
|
||||
for _, dep := range deps {
|
||||
if dep == nil || !dep.Enabled {
|
||||
continue
|
||||
}
|
||||
subName := dep.Alias
|
||||
if subName == "" {
|
||||
subName = dep.Name
|
||||
}
|
||||
c.AddDependency(&chart.Chart{
|
||||
Metadata: &chart.Metadata{Name: subName},
|
||||
})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func enabledDependency(name string, dependsOn ...string) *chart.Dependency {
|
||||
return &chart.Dependency{
|
||||
Name: name,
|
||||
Enabled: true,
|
||||
DependsOn: dependsOn,
|
||||
}
|
||||
}
|
||||
|
||||
// pipelineChart builds a parent chart whose loaded dependencies are named
|
||||
// after the entries' ORIGINAL chart names, exactly as loader.LoadDir produces
|
||||
// them, so tests exercise the real ProcessDependencies pipeline (alias rename
|
||||
// + depends-on resolution) instead of hand-constructing post-processed state.
|
||||
func pipelineChart(deps ...*chart.Dependency) *chart.Chart {
|
||||
c := &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Version: "0.1.0",
|
||||
APIVersion: chart.APIVersionV2,
|
||||
Dependencies: deps,
|
||||
},
|
||||
}
|
||||
added := make(map[string]bool)
|
||||
for _, dep := range deps {
|
||||
if added[dep.Name] {
|
||||
continue
|
||||
}
|
||||
added[dep.Name] = true
|
||||
c.AddDependency(&chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: dep.Name,
|
||||
Version: "0.1.0",
|
||||
APIVersion: chart.APIVersionV2,
|
||||
},
|
||||
})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func pipelineDependency(name, alias string, dependsOn ...string) *chart.Dependency {
|
||||
return &chart.Dependency{
|
||||
Name: name,
|
||||
Version: "0.1.0",
|
||||
Alias: alias,
|
||||
DependsOn: dependsOn,
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessDependencies_ResolvesDependsOnByOriginalName is the regression
|
||||
// test for hip-0025-92r. Through the real pipeline, processDependencyEnabled
|
||||
// rewrites an aliased entry's Name to its alias; before the fix that made the
|
||||
// original chart name unrecoverable and depends-on: ["postgres"] failed to
|
||||
// resolve at DAG build time.
|
||||
func TestProcessDependencies_ResolvesDependsOnByOriginalName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := pipelineChart(
|
||||
pipelineDependency("postgres", "primary-db"),
|
||||
pipelineDependency("app", "", "postgres"),
|
||||
)
|
||||
|
||||
require.NoError(t, ProcessDependencies(c, map[string]any{}))
|
||||
|
||||
// The alias rename ran, and the depends-on reference was rewritten to the
|
||||
// effective name — which is also what release storage persists, keeping
|
||||
// the sequenced uninstall path working.
|
||||
assert.Equal(t, "primary-db", c.Metadata.Dependencies[0].Name)
|
||||
assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn)
|
||||
|
||||
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
|
||||
}
|
||||
|
||||
func TestProcessDependencies_ResolvesDependsOnByAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := pipelineChart(
|
||||
pipelineDependency("postgres", "primary-db"),
|
||||
pipelineDependency("app", "", "primary-db"),
|
||||
)
|
||||
|
||||
require.NoError(t, ProcessDependencies(c, map[string]any{}))
|
||||
|
||||
assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn)
|
||||
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
|
||||
}
|
||||
|
||||
func TestProcessDependencies_AmbiguousDependsOnRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The same chart aliased twice makes its original name ambiguous;
|
||||
// referencing it by that name must be rejected, not silently resolved.
|
||||
c := pipelineChart(
|
||||
pipelineDependency("postgres", "db1"),
|
||||
pipelineDependency("postgres", "db2"),
|
||||
pipelineDependency("app", "", "postgres"),
|
||||
)
|
||||
|
||||
err := ProcessDependencies(c, map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `ambiguous subchart reference "postgres"`)
|
||||
}
|
||||
|
||||
func TestProcessDependencies_RewritesSubchartAnnotation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := pipelineChart(
|
||||
pipelineDependency("postgres", "primary-db"),
|
||||
pipelineDependency("app", ""),
|
||||
)
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["postgres", "app"]`,
|
||||
}
|
||||
|
||||
require.NoError(t, ProcessDependencies(c, map[string]any{}))
|
||||
|
||||
assert.Equal(t, `["primary-db","app"]`, c.Metadata.Annotations[AnnotationDependsOnSubcharts])
|
||||
assertBatches(t, c, [][]string{{"app", "primary-db"}})
|
||||
}
|
||||
|
||||
func TestProcessDependencies_AmbiguousAnnotationRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := pipelineChart(
|
||||
pipelineDependency("postgres", "db1"),
|
||||
pipelineDependency("postgres", "db2"),
|
||||
)
|
||||
c.Metadata.Annotations = map[string]string{
|
||||
AnnotationDependsOnSubcharts: `["postgres"]`,
|
||||
}
|
||||
|
||||
err := ProcessDependencies(c, map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `references ambiguous subchart "postgres"`)
|
||||
}
|
||||
|
||||
func TestProcessDependencies_UnknownDependsOnReportedByDAG(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Unknown references are left alone by the resolver so BuildSubchartDAG
|
||||
// keeps reporting them with its established error message.
|
||||
c := pipelineChart(
|
||||
pipelineDependency("app", "", "missing"),
|
||||
)
|
||||
|
||||
require.NoError(t, ProcessDependencies(c, map[string]any{}))
|
||||
|
||||
_, err := BuildSubchartDAG(c)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `unknown or disabled subchart "missing"`)
|
||||
}
|
||||
|
||||
func TestProcessDependencies_DependsOnRewriteIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := pipelineChart(
|
||||
pipelineDependency("postgres", "primary-db"),
|
||||
pipelineDependency("app", "", "postgres"),
|
||||
)
|
||||
|
||||
require.NoError(t, ProcessDependencies(c, map[string]any{}))
|
||||
// Lint and the sequenced-uninstall re-render run ProcessDependencies again
|
||||
// on the already-processed chart; the second pass must neither error nor
|
||||
// corrupt the rewritten references.
|
||||
require.NoError(t, ProcessDependencies(c, map[string]any{}))
|
||||
|
||||
assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn)
|
||||
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
|
||||
}
|
||||
|
||||
func TestProcessDependencies_PlainAndAliasedSameChart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// "svc" is deployed both under its own name and under an alias: the bare
|
||||
// name matches two subcharts and is ambiguous, while the alias resolves.
|
||||
ambiguous := pipelineChart(
|
||||
pipelineDependency("svc", ""),
|
||||
pipelineDependency("svc", "svc2"),
|
||||
pipelineDependency("app", "", "svc"),
|
||||
)
|
||||
err := ProcessDependencies(ambiguous, map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `ambiguous subchart reference "svc"`)
|
||||
|
||||
byAlias := pipelineChart(
|
||||
pipelineDependency("svc", ""),
|
||||
pipelineDependency("svc", "svc2"),
|
||||
pipelineDependency("app", "", "svc2"),
|
||||
)
|
||||
require.NoError(t, ProcessDependencies(byAlias, map[string]any{}))
|
||||
assertBatches(t, byAlias, [][]string{{"svc", "svc2"}, {"app"}})
|
||||
}
|
||||
|
||||
func TestProcessDependencies_SwappedAliasesAmbiguous(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// x is aliased to y while y is aliased to x: each bare name now refers to
|
||||
// two different subcharts, so references to either are rejected as
|
||||
// ambiguous rather than silently resolved to one of them.
|
||||
c := pipelineChart(
|
||||
pipelineDependency("x", "y"),
|
||||
pipelineDependency("y", "x"),
|
||||
pipelineDependency("app", "", "x"),
|
||||
)
|
||||
|
||||
err := ProcessDependencies(c, map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, `ambiguous subchart reference "x"`)
|
||||
}
|
||||
@ -0,0 +1,229 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"helm.sh/helm/v4/pkg/action"
|
||||
"helm.sh/helm/v4/pkg/chart/common"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/cli/values"
|
||||
"helm.sh/helm/v4/pkg/cmd/require"
|
||||
"helm.sh/helm/v4/pkg/release/v1/sequence"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
const dagDesc = `
|
||||
Print the resource sequencing DAG (directed acyclic graph) for a chart.
|
||||
|
||||
This is a development and troubleshooting command for HIP-0025 sequencing. It
|
||||
loads the chart, evaluates conditional dependencies against the provided values,
|
||||
renders templates locally, and prints the deployment order that
|
||||
'helm install --wait=ordered' would use:
|
||||
|
||||
- Subchart deployment batches, derived from Chart.yaml dependency 'depends-on'
|
||||
fields and the 'helm.sh/depends-on/subcharts' annotation.
|
||||
- Resource-group batches per chart level, derived from the
|
||||
'helm.sh/resource-group' and 'helm.sh/depends-on/resource-groups'
|
||||
annotations on rendered manifests.
|
||||
|
||||
Cycles in either DAG are reported as errors. Manifests that lack a
|
||||
'helm.sh/resource-group' annotation, or whose group was demoted because of a
|
||||
missing dependency, are listed as "Unsequenced" and would be deployed after the
|
||||
sequenced batches.
|
||||
|
||||
Hooks are not part of any sequencing DAG (HIP-0025) and are omitted from the
|
||||
output. No cluster connection is required; the chart is rendered with
|
||||
client-side dry-run.
|
||||
`
|
||||
|
||||
func newDagCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
|
||||
client := action.NewInstall(cfg)
|
||||
valueOpts := &values.Options{}
|
||||
var kubeVersion string
|
||||
var extraAPIs []string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "dag CHART",
|
||||
Short: "print the resource sequencing DAG for a chart",
|
||||
Long: dagDesc,
|
||||
Args: require.MinimumNArgs(1),
|
||||
ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return compInstall(args, toComplete, client)
|
||||
},
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
if kubeVersion != "" {
|
||||
parsed, err := common.ParseKubeVersion(kubeVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid kube version %q: %w", kubeVersion, err)
|
||||
}
|
||||
client.KubeVersion = parsed
|
||||
}
|
||||
|
||||
registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile,
|
||||
client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("missing registry client: %w", err)
|
||||
}
|
||||
client.SetRegistryClient(registryClient)
|
||||
|
||||
// Render the chart locally without touching the cluster. Hooks are not
|
||||
// sequenced per HIP-0025, so we suppress them from the rendered output
|
||||
// to keep the DAG view focused on install-phase resources.
|
||||
client.DryRunStrategy = action.DryRunClient
|
||||
client.ReleaseName = "release-name"
|
||||
client.Replace = true
|
||||
client.APIVersions = common.VersionSet(extraAPIs)
|
||||
client.DisableHooks = true
|
||||
|
||||
rel, err := runInstall(args, client, valueOpts, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == nil || rel.Chart == nil {
|
||||
return errors.New("no chart rendered")
|
||||
}
|
||||
|
||||
return printSequencingDAG(rel.Chart, strings.TrimSpace(rel.Manifest), out)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
addValueOptionsFlags(f, valueOpts)
|
||||
addChartPathOptionsFlags(f, &client.ChartPathOptions)
|
||||
f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion")
|
||||
f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)")
|
||||
f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before printing the DAG")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printSequencingDAG walks a processed chart and its rendered manifest stream,
|
||||
// printing the subchart deployment batches and per-chart resource-group batches
|
||||
// in the same order 'helm install --wait=ordered' would deploy them.
|
||||
func printSequencingDAG(chrt *chart.Chart, manifest string, out io.Writer) error {
|
||||
var manifests []releaseutil.Manifest
|
||||
if manifest != "" {
|
||||
parsed, err := sequence.ParseStoredManifests(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing rendered manifests: %w", err)
|
||||
}
|
||||
manifests = parsed
|
||||
}
|
||||
plan, err := sequence.Build(chrt, manifests)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logSequencePlanWarnings(plan)
|
||||
|
||||
levelByPath := make(map[string]*sequence.ChartLevel, len(plan.Levels))
|
||||
for i := range plan.Levels {
|
||||
levelByPath[plan.Levels[i].Path] = &plan.Levels[i]
|
||||
}
|
||||
|
||||
groupBatchesByPath := make(map[string][]sequence.Batch)
|
||||
unsequencedByPath := make(map[string]sequence.Batch)
|
||||
for _, batch := range plan.Batches {
|
||||
switch batch.Kind {
|
||||
case sequence.BatchKindGroups:
|
||||
groupBatchesByPath[batch.ChartPath] = append(groupBatchesByPath[batch.ChartPath], batch)
|
||||
case sequence.BatchKindUnsequenced:
|
||||
unsequencedByPath[batch.ChartPath] = batch
|
||||
}
|
||||
}
|
||||
|
||||
var printLevel func(level *sequence.ChartLevel)
|
||||
printLevel = func(level *sequence.ChartLevel) {
|
||||
indent := strings.Repeat(" ", level.Depth)
|
||||
fmt.Fprintf(out, "%sChart: %s\n", indent, level.Path)
|
||||
|
||||
if len(level.SubchartBatches) == 0 {
|
||||
fmt.Fprintf(out, "%s Subchart batches: (none)\n", indent)
|
||||
} else {
|
||||
fmt.Fprintf(out, "%s Subchart batches:\n", indent)
|
||||
for i, batch := range level.SubchartBatches {
|
||||
fmt.Fprintf(out, "%s Batch %d: %s\n", indent, i+1, strings.Join(batch, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
resourceIndent := indent + " "
|
||||
groupBatches := groupBatchesByPath[level.Path]
|
||||
if len(groupBatches) == 0 {
|
||||
fmt.Fprintf(out, "%sResource-group batches: (none)\n", resourceIndent)
|
||||
} else {
|
||||
fmt.Fprintf(out, "%sResource-group batches:\n", resourceIndent)
|
||||
for i, batch := range groupBatches {
|
||||
names := make([]string, 0, len(batch.Groups))
|
||||
for _, group := range batch.Groups {
|
||||
names = append(names, group.Name)
|
||||
}
|
||||
fmt.Fprintf(out, "%s Batch %d: %s\n", resourceIndent, i+1, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if batch, ok := unsequencedByPath[level.Path]; ok {
|
||||
names := make([]string, 0, len(batch.Manifests()))
|
||||
for _, manifest := range batch.Manifests() {
|
||||
names = append(names, unsequencedResourceLabel(manifest))
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Fprintf(out, "%sUnsequenced (deployed last): %s\n", resourceIndent, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
printChild := func(name string) {
|
||||
if slices.Contains(level.Unresolved, name) {
|
||||
fmt.Fprintf(out, "%s (subchart %q metadata unavailable; sequenced structurally from manifests)\n", indent, name)
|
||||
}
|
||||
if child := levelByPath[level.Path+"/charts/"+name]; child != nil {
|
||||
printLevel(child)
|
||||
}
|
||||
}
|
||||
|
||||
for _, batch := range level.SubchartBatches {
|
||||
for _, name := range batch {
|
||||
printChild(name)
|
||||
}
|
||||
}
|
||||
for _, name := range level.Undeclared {
|
||||
fmt.Fprintf(out, "%s Undeclared subchart %q (deployed unsequenced):\n", indent, name)
|
||||
printChild(name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(plan.Levels) > 0 {
|
||||
printLevel(&plan.Levels[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsequencedResourceLabel(m releaseutil.Manifest) string {
|
||||
if m.Head != nil && m.Head.Metadata != nil && m.Head.Metadata.Name != "" {
|
||||
if m.Head.Kind != "" {
|
||||
return fmt.Sprintf("%s/%s", m.Head.Kind, m.Head.Metadata.Name)
|
||||
}
|
||||
return m.Head.Metadata.Name
|
||||
}
|
||||
return m.Name
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDagCmd(t *testing.T) {
|
||||
chartPath := "testdata/testcharts/sequenced-chart"
|
||||
|
||||
tests := []cmdTestCase{
|
||||
{
|
||||
name: "sequenced chart prints subchart and resource-group batches",
|
||||
cmd: fmt.Sprintf("dag '%s'", chartPath),
|
||||
golden: "output/dag-sequenced-chart.txt",
|
||||
},
|
||||
{
|
||||
name: "sequenced chart demotes isolated groups",
|
||||
cmd: "dag 'testdata/testcharts/sequenced-isolated-chart'",
|
||||
golden: "output/dag-sequenced-isolated.txt",
|
||||
},
|
||||
}
|
||||
runTestCmd(t, tests)
|
||||
}
|
||||
|
||||
func TestDagCmd_RequiresChartArg(t *testing.T) {
|
||||
_, _, err := executeActionCommandC(storageFixture(), "dag")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when chart argument is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDagCmd_NonexistentChart(t *testing.T) {
|
||||
_, _, err := executeActionCommandC(storageFixture(), "dag testdata/testcharts/does-not-exist")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent chart path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does-not-exist") &&
|
||||
!strings.Contains(strings.ToLower(err.Error()), "no such file") &&
|
||||
!strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
t.Fatalf("expected error to mention the missing chart, got: %v", err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
Chart: sequenced-chart
|
||||
Subchart batches:
|
||||
Batch 1: worker
|
||||
Resource-group batches:
|
||||
Batch 1: databases
|
||||
Batch 2: app
|
||||
Unsequenced (deployed last): ConfigMap/unsequenced-config
|
||||
Chart: sequenced-chart/charts/worker
|
||||
Subchart batches: (none)
|
||||
Resource-group batches:
|
||||
Batch 1: bootstrap
|
||||
@ -0,0 +1,6 @@
|
||||
Chart: sequenced-isolated-chart
|
||||
Subchart batches: (none)
|
||||
Resource-group batches:
|
||||
Batch 1: alpha
|
||||
Batch 2: beta
|
||||
Unsequenced (deployed last): ConfigMap/cm-gamma, ConfigMap/cm-plain
|
||||
@ -0,0 +1,47 @@
|
||||
## START resource-group: sequenced-chart/worker bootstrap
|
||||
---
|
||||
# Source: sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: worker-bootstrap
|
||||
annotations:
|
||||
helm.sh/resource-group: bootstrap
|
||||
data:
|
||||
worker: ready
|
||||
|
||||
## END resource-group: sequenced-chart/worker bootstrap
|
||||
## START resource-group: sequenced-chart databases
|
||||
---
|
||||
# Source: sequenced-chart/templates/aa-databases-configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: database-config
|
||||
annotations:
|
||||
helm.sh/resource-group: databases
|
||||
data:
|
||||
host: localhost
|
||||
|
||||
## END resource-group: sequenced-chart databases
|
||||
## START resource-group: sequenced-chart app
|
||||
---
|
||||
# Source: sequenced-chart/templates/bb-app-configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
annotations:
|
||||
helm.sh/resource-group: app
|
||||
data:
|
||||
db_host: localhost
|
||||
|
||||
## END resource-group: sequenced-chart app
|
||||
---
|
||||
# Source: sequenced-chart/templates/cc-unsequenced-configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: unsequenced-config
|
||||
data:
|
||||
key: value
|
||||
@ -0,0 +1,45 @@
|
||||
## START resource-group: sequenced-isolated-chart alpha
|
||||
---
|
||||
# Source: sequenced-isolated-chart/templates/aa-alpha.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-alpha
|
||||
annotations:
|
||||
helm.sh/resource-group: alpha
|
||||
data:
|
||||
key: alpha
|
||||
|
||||
## END resource-group: sequenced-isolated-chart alpha
|
||||
## START resource-group: sequenced-isolated-chart beta
|
||||
---
|
||||
# Source: sequenced-isolated-chart/templates/bb-beta.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-beta
|
||||
annotations:
|
||||
helm.sh/resource-group: beta
|
||||
data:
|
||||
key: beta
|
||||
|
||||
## END resource-group: sequenced-isolated-chart beta
|
||||
---
|
||||
# Source: sequenced-isolated-chart/templates/dd-plain.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-plain
|
||||
data:
|
||||
key: plain
|
||||
|
||||
---
|
||||
# Source: sequenced-isolated-chart/templates/cc-gamma.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-gamma
|
||||
annotations:
|
||||
helm.sh/resource-group: gamma
|
||||
data:
|
||||
key: gamma
|
||||
@ -0,0 +1,9 @@
|
||||
apiVersion: v2
|
||||
name: sequenced-chart
|
||||
description: A test chart with ordered template output
|
||||
type: application
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: worker
|
||||
version: 0.1.0
|
||||
repository: file://charts/worker
|
||||
@ -0,0 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: worker
|
||||
description: A subchart used to test ordered template output
|
||||
type: application
|
||||
version: 0.1.0
|
||||
@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: worker-bootstrap
|
||||
annotations:
|
||||
helm.sh/resource-group: bootstrap
|
||||
data:
|
||||
worker: ready
|
||||
@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: database-config
|
||||
annotations:
|
||||
helm.sh/resource-group: databases
|
||||
data:
|
||||
host: localhost
|
||||
@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
annotations:
|
||||
helm.sh/resource-group: app
|
||||
helm.sh/depends-on/resource-groups: '["databases"]'
|
||||
data:
|
||||
db_host: localhost
|
||||
@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: unsequenced-config
|
||||
data:
|
||||
key: value
|
||||
@ -0,0 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: sequenced-isolated-chart
|
||||
description: A test chart with an isolated resource group
|
||||
type: application
|
||||
version: 0.1.0
|
||||
@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-alpha
|
||||
annotations:
|
||||
helm.sh/resource-group: alpha
|
||||
data:
|
||||
key: alpha
|
||||
@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-beta
|
||||
annotations:
|
||||
helm.sh/resource-group: beta
|
||||
helm.sh/depends-on/resource-groups: '["alpha"]'
|
||||
data:
|
||||
key: beta
|
||||
@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-gamma
|
||||
annotations:
|
||||
helm.sh/resource-group: gamma
|
||||
data:
|
||||
key: gamma
|
||||
@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cm-plain
|
||||
data:
|
||||
key: plain
|
||||
@ -0,0 +1,54 @@
|
||||
/*
|
||||
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 kube
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClientGetWaiterWithOptions_OrderedWaitStrategy(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
waiter, err := client.GetWaiterWithOptions(OrderedWaitStrategy)
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, &statusWaiter{}, waiter)
|
||||
}
|
||||
|
||||
func TestClientGetWaiterWithOptions_UnknownStrategyListsOrdered(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
waiter, err := client.GetWaiterWithOptions(WaitStrategy("bogus"))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, waiter)
|
||||
assert.Contains(t, err.Error(), string(OrderedWaitStrategy))
|
||||
}
|
||||
|
||||
func TestNewStatusWatcherCustomReadinessFlag(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
w, err := client.newStatusWatcher(WithCustomReadinessStatusReader())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, w.customReadiness)
|
||||
assert.Empty(t, w.readers, "custom readiness must not inject a reader into the shared reader list")
|
||||
|
||||
w, err = client.newStatusWatcher()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, w.customReadiness)
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
|
||||
"github.com/fluxcd/cli-utils/pkg/object"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
type customReadinessStatusReader struct {
|
||||
logger *slog.Logger
|
||||
// fallback is the reader chain the enclosing wait method would use if
|
||||
// custom readiness were disabled. Resources that do not carry both
|
||||
// readiness annotations are delegated to it, so built-in readers (the
|
||||
// composite Deployment/ReplicaSet/StatefulSet readers, the Job reader in
|
||||
// WaitWithJobs) and caller-supplied readers keep their semantics.
|
||||
fallback engine.StatusReader
|
||||
// warnedExpressions dedups authoring-mistake warnings so a bad expression
|
||||
// logs once per resource rather than on every watch event. Keys are
|
||||
// "<ObjMetadata.String()>|<expression>". The reader is invoked
|
||||
// concurrently by the status watcher, hence sync.Map.
|
||||
warnedExpressions sync.Map
|
||||
}
|
||||
|
||||
// newCustomReadinessStatusReader wraps fallback with per-resource custom
|
||||
// readiness evaluation. fallback must be non-nil and must support every
|
||||
// GroupKind this reader can receive (every chain built in statuswait.go
|
||||
// terminates in a generic reader that supports all GroupKinds).
|
||||
func newCustomReadinessStatusReader(logger *slog.Logger, fallback engine.StatusReader) engine.StatusReader {
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &customReadinessStatusReader{logger: logger, fallback: fallback}
|
||||
}
|
||||
|
||||
// Supports returns true for every GroupKind: annotated resources of any kind
|
||||
// are evaluated here, and everything else is delegated to the fallback chain.
|
||||
func (*customReadinessStatusReader) Supports(schema.GroupKind) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) {
|
||||
u := &unstructured.Unstructured{}
|
||||
u.SetGroupVersionKind(schema.GroupVersionKind{
|
||||
Group: resource.GroupKind.Group,
|
||||
Kind: resource.GroupKind.Kind,
|
||||
})
|
||||
if err := reader.Get(ctx, client.ObjectKey{Namespace: resource.Namespace, Name: resource.Name}, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.readStatus(ctx, reader, resource, u)
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
identifier, err := object.RuntimeToObjMeta(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.readStatus(ctx, reader, identifier, resource)
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) readStatus(ctx context.Context, reader engine.ClusterReader, identifier object.ObjMetadata, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
annotations := resource.GetAnnotations()
|
||||
|
||||
successExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessSuccess])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessSuccess, identifier.Namespace, identifier.Name, err)
|
||||
}
|
||||
failureExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessFailure])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessFailure, identifier.Namespace, identifier.Name, err)
|
||||
}
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(resource, successExprs, failureExprs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if useKstatus {
|
||||
// Missing or partial annotations: this resource does not opt in to
|
||||
// custom readiness, so the default reader chain decides its status.
|
||||
return r.fallback.ReadStatusForObject(ctx, reader, resource)
|
||||
}
|
||||
|
||||
for _, w := range warnings {
|
||||
key := identifier.String() + "|" + w.Expression
|
||||
if _, alreadyWarned := r.warnedExpressions.LoadOrStore(key, struct{}{}); alreadyWarned {
|
||||
continue
|
||||
}
|
||||
r.logger.Warn("custom readiness expression cannot be evaluated; treating condition as not met",
|
||||
"kind", identifier.GroupKind.Kind,
|
||||
"namespace", identifier.Namespace,
|
||||
"name", identifier.Name,
|
||||
"expression", w.Expression,
|
||||
"detail", w.Detail,
|
||||
)
|
||||
}
|
||||
|
||||
st, message := status.InProgressStatus, "waiting for custom readiness conditions"
|
||||
switch result {
|
||||
case ReadinessReady:
|
||||
st, message = status.CurrentStatus, "custom readiness conditions met"
|
||||
case ReadinessFailed:
|
||||
st, message = status.FailedStatus, "custom readiness failure condition met"
|
||||
default:
|
||||
// ReadinessPending (and any future status) keep the in-progress
|
||||
// defaults; name any skipped expression so an eventual wait timeout
|
||||
// error is self-explanatory.
|
||||
if len(warnings) > 0 {
|
||||
message = fmt.Sprintf("waiting for custom readiness conditions (%d expression(s) skipped: %s)", len(warnings), warnings[0].Detail)
|
||||
}
|
||||
}
|
||||
return &event.ResourceStatus{
|
||||
Identifier: identifier,
|
||||
Status: st,
|
||||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
@ -0,0 +1,617 @@
|
||||
/*
|
||||
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 kube
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/clusterreader"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
|
||||
"github.com/fluxcd/cli-utils/pkg/object"
|
||||
"github.com/fluxcd/cli-utils/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic/fake"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
)
|
||||
|
||||
func TestStatusWaitWithCustomReadinessReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
manifest string
|
||||
expectErrStrs []string
|
||||
notExpectErrStrs []string
|
||||
}{
|
||||
{
|
||||
name: "custom readiness makes resource current",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ready-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Ready
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "custom readiness failure stops wait",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: failed-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Failed
|
||||
`,
|
||||
expectErrStrs: []string{"resource ConfigMap/default/failed-config not ready. status: Failed"},
|
||||
},
|
||||
{
|
||||
name: "resources without annotations fall back to kstatus",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: current-config
|
||||
namespace: default
|
||||
`,
|
||||
},
|
||||
{
|
||||
// END-TO-END pin for hip-0025-gy6: an ordering operator on a
|
||||
// non-numeric value must leave the resource InProgress (and let
|
||||
// the wait time out per-resource) — NOT kill the status reporter
|
||||
// and abort the whole batch.
|
||||
name: "ordering operator on non-numeric value does not abort the wait",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: bad-ordering
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} > \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Running
|
||||
`,
|
||||
expectErrStrs: []string{"resource ConfigMap/default/bad-ordering not ready. status: InProgress", "skipped"},
|
||||
notExpectErrStrs: []string{"failed to compute object status"},
|
||||
},
|
||||
{
|
||||
name: "ordering on numeric string value compares numerically",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: numeric-string
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} >= 5"]'
|
||||
helm.sh/readiness-failure: '["{.failed} >= 1"]'
|
||||
status:
|
||||
phase: "5"
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "incomparable expression skipped when another success condition is met",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mixed-success
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} > \"Ready\"", "{.phase} == \"Running\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Running
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Partial annotations (only one of success/failure): custom
|
||||
// evaluation must NOT engage; the resource is judged by the
|
||||
// default chain (a bare ConfigMap is Current), even though the
|
||||
// success expression is unmet.
|
||||
name: "partial annotation falls back to default readiness",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: partial-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
status:
|
||||
phase: NotReady
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newTestClient(t)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
fakeMapper := testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))
|
||||
waiter := statusWaiter{
|
||||
client: fakeClient,
|
||||
restMapper: fakeMapper,
|
||||
customReadiness: true,
|
||||
}
|
||||
|
||||
objs := getRuntimeObjFromManifests(t, []string{tt.manifest})
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
|
||||
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
|
||||
err := waiter.Wait(resourceList, time.Second)
|
||||
if tt.expectErrStrs == nil {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
for _, expected := range tt.expectErrStrs {
|
||||
assert.Contains(t, err.Error(), expected)
|
||||
}
|
||||
for _, unexpected := range tt.notExpectErrStrs {
|
||||
assert.NotContains(t, err.Error(), unexpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCustomReadinessStatusReader(t *testing.T) {
|
||||
opts := &waitOptions{}
|
||||
WithCustomReadinessStatusReader()(opts)
|
||||
assert.True(t, opts.enableCustomReadinessStatusReader)
|
||||
}
|
||||
|
||||
func TestCustomReadinessStatusReaderReadStatusForObject(t *testing.T) {
|
||||
reader := newCustomReadinessStatusReader(nil, statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))))
|
||||
u := &unstructured.Unstructured{}
|
||||
u.SetAPIVersion("v1")
|
||||
u.SetKind("ConfigMap")
|
||||
u.SetName("ready-config")
|
||||
u.SetNamespace("default")
|
||||
u.SetAnnotations(map[string]string{
|
||||
AnnotationReadinessSuccess: `["{.phase} == \"Ready\""]`,
|
||||
AnnotationReadinessFailure: `["{.phase} == \"Failed\""]`,
|
||||
})
|
||||
require.NoError(t, unstructured.SetNestedField(u.Object, "Ready", "status", "phase"))
|
||||
|
||||
result, err := reader.ReadStatusForObject(context.Background(), nil, u)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, status.CurrentStatus, result.Status)
|
||||
}
|
||||
|
||||
func TestCustomReadinessStatusReaderWarnsOnceForIncomparableExpression(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
reader := newCustomReadinessStatusReader(slog.New(slog.NewTextHandler(&buf, nil)), statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))))
|
||||
|
||||
u := &unstructured.Unstructured{}
|
||||
u.SetAPIVersion("v1")
|
||||
u.SetKind("ConfigMap")
|
||||
u.SetName("bad-ordering")
|
||||
u.SetNamespace("default")
|
||||
u.SetAnnotations(map[string]string{
|
||||
AnnotationReadinessSuccess: `["{.phase} > \"Ready\""]`,
|
||||
AnnotationReadinessFailure: `["{.failed} >= 1"]`,
|
||||
})
|
||||
require.NoError(t, unstructured.SetNestedField(u.Object, "Running", "status", "phase"))
|
||||
|
||||
for range 2 {
|
||||
result, err := reader.ReadStatusForObject(context.Background(), nil, u)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, status.InProgressStatus, result.Status)
|
||||
assert.Contains(t, result.Message, "skipped")
|
||||
assert.Contains(t, result.Message, "ordering operators")
|
||||
}
|
||||
|
||||
logged := buf.String()
|
||||
assert.Equal(t, 1, strings.Count(logged, "treating condition as not met"),
|
||||
"warning must be deduplicated to once per resource+expression")
|
||||
assert.Contains(t, logged, "bad-ordering")
|
||||
assert.Contains(t, logged, "ordering operators")
|
||||
}
|
||||
|
||||
// The composite Deployment reader is the fallback observable: it populates
|
||||
// GeneratedResources with the child ReplicaSets it aggregated, which neither
|
||||
// the generic kstatus reader nor a bare status.Compute ever does. (In
|
||||
// cli-utils v1.2.2 the composite readers compute the same top-level status as
|
||||
// status.Compute, so GeneratedResources is the distinguishing signal that the
|
||||
// default chain — not an internal kstatus shortcut — handled the resource.)
|
||||
func TestCustomReadinessReaderDelegatesToFallbackChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deploymentYAML := func(annotations string) string {
|
||||
return `
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web
|
||||
namespace: default
|
||||
generation: 1` + annotations + `
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
status:
|
||||
observedGeneration: 1
|
||||
replicas: 1
|
||||
updatedReplicas: 1
|
||||
readyReplicas: 1
|
||||
availableReplicas: 1
|
||||
conditions:
|
||||
- type: Available
|
||||
status: "True"
|
||||
reason: MinimumReplicasAvailable
|
||||
`
|
||||
}
|
||||
replicaSetManifest := `
|
||||
apiVersion: apps/v1
|
||||
kind: ReplicaSet
|
||||
metadata:
|
||||
name: web-abc123
|
||||
namespace: default
|
||||
generation: 1
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
status:
|
||||
observedGeneration: 1
|
||||
replicas: 1
|
||||
readyReplicas: 1
|
||||
availableReplicas: 1
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
annotations string
|
||||
expectStatus status.Status
|
||||
expectGeneratedResults bool
|
||||
}{
|
||||
{
|
||||
// No annotations: must be delegated to the composite chain.
|
||||
name: "non-annotated deployment handled by composite reader",
|
||||
annotations: "",
|
||||
expectStatus: status.CurrentStatus,
|
||||
expectGeneratedResults: true,
|
||||
},
|
||||
{
|
||||
// One annotation only: partial config must also delegate.
|
||||
name: "partial-annotated deployment handled by composite reader",
|
||||
annotations: `
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.readyReplicas} >= 1"]'`,
|
||||
expectStatus: status.CurrentStatus,
|
||||
expectGeneratedResults: true,
|
||||
},
|
||||
{
|
||||
// Both annotations: custom evaluation engages (unmet expression
|
||||
// keeps it InProgress) and the fallback chain is bypassed.
|
||||
name: "fully annotated deployment evaluated by expressions",
|
||||
annotations: `
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.readyReplicas} >= 2"]'
|
||||
helm.sh/readiness-failure: '["{.failed} >= 1"]'`,
|
||||
expectStatus: status.InProgressStatus,
|
||||
expectGeneratedResults: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeMapper := testutil.NewFakeRESTMapper(
|
||||
appsv1.SchemeGroupVersion.WithKind("Deployment"),
|
||||
appsv1.SchemeGroupVersion.WithKind("ReplicaSet"),
|
||||
v1.SchemeGroupVersion.WithKind("Pod"),
|
||||
)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
objs := getRuntimeObjFromManifests(t, []string{deploymentYAML(tt.annotations), replicaSetManifest})
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
clusterReader := &clusterreader.DynamicClusterReader{
|
||||
DynamicClient: fakeClient,
|
||||
Mapper: fakeMapper,
|
||||
}
|
||||
reader := newCustomReadinessStatusReader(nil, statusreaders.NewStatusReader(fakeMapper))
|
||||
|
||||
deployment := objs[0].(*unstructured.Unstructured)
|
||||
result, err := reader.ReadStatusForObject(context.Background(), clusterReader, deployment)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectStatus, result.Status)
|
||||
if tt.expectGeneratedResults {
|
||||
require.NotEmpty(t, result.GeneratedResources,
|
||||
"fallback composite reader must aggregate the child ReplicaSet")
|
||||
assert.Equal(t, "web-abc123", result.GeneratedResources[0].Identifier.Name)
|
||||
} else {
|
||||
assert.Empty(t, result.GeneratedResources)
|
||||
assert.Contains(t, result.Message, "custom readiness")
|
||||
}
|
||||
|
||||
// The identifier-based entry point must route identically.
|
||||
id, err := object.RuntimeToObjMeta(deployment)
|
||||
require.NoError(t, err)
|
||||
byID, err := reader.ReadStatus(context.Background(), clusterReader, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectStatus, byID.Status)
|
||||
assert.Equal(t, tt.expectGeneratedResults, len(byID.GeneratedResources) > 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A plain (non-annotated) Job in a batch that enables custom readiness must
|
||||
// keep helm's completion gating: helm's job reader holds a started Job
|
||||
// InProgress until a Complete/Failed condition, while plain kstatus reports a
|
||||
// started Job as Current. Before the fix the custom readiness reader shadowed
|
||||
// the job reader and the wait returned early.
|
||||
func TestStatusWaitWithJobsMixedCustomReadiness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startedJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: slow-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
startTime: 2025-02-06T16:34:20-05:00
|
||||
active: 1
|
||||
ready: 1
|
||||
`
|
||||
completeJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: slow-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
succeeded: 1
|
||||
conditions:
|
||||
- type: Complete
|
||||
status: "True"
|
||||
`
|
||||
readyConfigMapManifest := `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: gated-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Ready
|
||||
`
|
||||
pendingConfigMapManifest := `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: gated-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Pending
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
manifests []string
|
||||
expectErrStrs []string
|
||||
notExpectErrStrs []string
|
||||
}{
|
||||
{
|
||||
name: "plain job keeps completion gating in mixed batch",
|
||||
manifests: []string{startedJobManifest, readyConfigMapManifest},
|
||||
expectErrStrs: []string{
|
||||
"resource Job/default/slow-job not ready. status: InProgress",
|
||||
},
|
||||
notExpectErrStrs: []string{"ConfigMap"},
|
||||
},
|
||||
{
|
||||
name: "annotated resource keeps expression gating in mixed batch",
|
||||
manifests: []string{completeJobManifest, pendingConfigMapManifest},
|
||||
expectErrStrs: []string{
|
||||
"resource ConfigMap/default/gated-config not ready. status: InProgress",
|
||||
},
|
||||
notExpectErrStrs: []string{"Job/default/slow-job"},
|
||||
},
|
||||
{
|
||||
name: "batch completes when job is complete and expression met",
|
||||
manifests: []string{completeJobManifest, readyConfigMapManifest},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newTestClient(t)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
fakeMapper := testutil.NewFakeRESTMapper(
|
||||
batchv1.SchemeGroupVersion.WithKind("Job"),
|
||||
v1.SchemeGroupVersion.WithKind("ConfigMap"),
|
||||
)
|
||||
waiter := statusWaiter{
|
||||
client: fakeClient,
|
||||
restMapper: fakeMapper,
|
||||
customReadiness: true,
|
||||
}
|
||||
objs := getRuntimeObjFromManifests(t, tt.manifests)
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
|
||||
err := waiter.WaitWithJobs(resourceList, 2*time.Second)
|
||||
if tt.expectErrStrs == nil {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
for _, expected := range tt.expectErrStrs {
|
||||
assert.Contains(t, err.Error(), expected)
|
||||
}
|
||||
for _, unexpected := range tt.notExpectErrStrs {
|
||||
assert.NotContains(t, err.Error(), unexpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WatchUntilReady is the hook path: hook readiness must be governed by the
|
||||
// hook readers (helm's job/pod readers + always-ready for everything else),
|
||||
// never by the readiness annotations, even when custom readiness is enabled
|
||||
// on the waiter. Two directions:
|
||||
// - a started-but-incomplete hook Job must still time out (kstatus would
|
||||
// wrongly report a started Job as Current);
|
||||
// - an annotated non-Job hook must complete even though its success
|
||||
// expression is unmet (annotations are ignored for hooks).
|
||||
func TestWatchUntilReadyIgnoresCustomReadiness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startedJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: hook-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
startTime: 2025-02-06T16:34:20-05:00
|
||||
active: 1
|
||||
`
|
||||
completeJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: hook-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
succeeded: 1
|
||||
conditions:
|
||||
- type: Complete
|
||||
status: "True"
|
||||
`
|
||||
annotatedConfigMapManifest := `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: hook-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Pending
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
manifests []string
|
||||
expectErrStrs []string
|
||||
}{
|
||||
{
|
||||
name: "incomplete hook job still gated by hook job reader",
|
||||
manifests: []string{startedJobManifest},
|
||||
expectErrStrs: []string{
|
||||
"resource Job/default/hook-job not ready. status: InProgress",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "annotated hook resource ignores readiness annotations",
|
||||
manifests: []string{annotatedConfigMapManifest},
|
||||
},
|
||||
{
|
||||
name: "complete hook job succeeds",
|
||||
manifests: []string{completeJobManifest},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newTestClient(t)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
fakeMapper := testutil.NewFakeRESTMapper(
|
||||
batchv1.SchemeGroupVersion.WithKind("Job"),
|
||||
v1.SchemeGroupVersion.WithKind("ConfigMap"),
|
||||
)
|
||||
waiter := statusWaiter{
|
||||
client: fakeClient,
|
||||
restMapper: fakeMapper,
|
||||
customReadiness: true,
|
||||
}
|
||||
objs := getRuntimeObjFromManifests(t, tt.manifests)
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
|
||||
err := waiter.WatchUntilReady(resourceList, 2*time.Second)
|
||||
if tt.expectErrStrs == nil {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
for _, expected := range tt.expectErrStrs {
|
||||
assert.Contains(t, err.Error(), expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,369 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/util/jsonpath"
|
||||
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
// The custom-readiness annotation keys' canonical home is
|
||||
// pkg/release/v1/util, next to the other sequencing annotations, so the pure
|
||||
// plan builder (pkg/release/v1/sequence) can read them without importing
|
||||
// pkg/kube. These value-identical aliases keep existing kube callers working.
|
||||
const (
|
||||
// AnnotationReadinessSuccess declares custom readiness success conditions.
|
||||
AnnotationReadinessSuccess = releaseutil.AnnotationReadinessSuccess
|
||||
|
||||
// AnnotationReadinessFailure declares custom readiness failure conditions.
|
||||
// Failure conditions take precedence over success conditions.
|
||||
AnnotationReadinessFailure = releaseutil.AnnotationReadinessFailure
|
||||
)
|
||||
|
||||
// ReadinessStatus represents the evaluated readiness of a resource.
|
||||
type ReadinessStatus int
|
||||
|
||||
const (
|
||||
// ReadinessPending means neither success nor failure conditions are met.
|
||||
ReadinessPending ReadinessStatus = iota
|
||||
// ReadinessReady means at least one success condition is true.
|
||||
ReadinessReady
|
||||
// ReadinessFailed means at least one failure condition is true.
|
||||
ReadinessFailed
|
||||
)
|
||||
|
||||
func (r ReadinessStatus) String() string {
|
||||
switch r {
|
||||
case ReadinessPending:
|
||||
return "Pending"
|
||||
case ReadinessReady:
|
||||
return "Ready"
|
||||
case ReadinessFailed:
|
||||
return "Failed"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ExpressionWarning describes a readiness expression that could not be
|
||||
// evaluated against the observed object state and was therefore treated as
|
||||
// "condition not met" rather than failing the whole wait.
|
||||
type ExpressionWarning struct {
|
||||
// Expression is the original annotation expression, e.g. `{.phase} > "Ready"`.
|
||||
Expression string
|
||||
// Detail is a human-readable reason, including the observed value.
|
||||
Detail string
|
||||
}
|
||||
|
||||
// errIncomparableOrdering marks a comparison that cannot succeed against the
|
||||
// observed values: an ordering operator (<, <=, >, >=) applied to operands
|
||||
// that are not both numeric. The actual value's type is only known at runtime,
|
||||
// so lint cannot always catch this. EvaluateCustomReadiness downgrades it to
|
||||
// an ExpressionWarning — the expression counts as "condition not met",
|
||||
// mirroring how a missing status key is treated — instead of returning an
|
||||
// error that would abort the entire wait.
|
||||
var errIncomparableOrdering = errors.New("ordering operators (<, <=, >, >=) require numeric values")
|
||||
|
||||
// EvaluateCustomReadiness evaluates custom readiness expressions against a
|
||||
// resource's .status field.
|
||||
//
|
||||
// When both successExprs and failureExprs are empty or nil, or when only one
|
||||
// side is provided, the caller should fall back to kstatus and useKstatus will
|
||||
// be returned as true.
|
||||
//
|
||||
// Expressions that cannot be evaluated against the observed values (an
|
||||
// ordering operator applied to non-numeric operands) are treated as
|
||||
// "condition not met" and reported in the returned warnings rather than as an
|
||||
// error. Expressions after the first met condition are not evaluated. All
|
||||
// other evaluation problems (invalid JSONPath, unsupported operator) are
|
||||
// returned as errors.
|
||||
func EvaluateCustomReadiness(obj *unstructured.Unstructured, successExprs, failureExprs []string) (ReadinessStatus, bool, []ExpressionWarning, error) {
|
||||
hasSuccess := len(successExprs) > 0
|
||||
hasFailure := len(failureExprs) > 0
|
||||
|
||||
if !hasSuccess || !hasFailure {
|
||||
// Partial annotations: fall back to kstatus. The warning for this
|
||||
// case is emitted once at plan-build time by the sequence plan
|
||||
// builder (pkg/release/v1/sequence), not here (which runs on every
|
||||
// poll tick).
|
||||
return ReadinessPending, true, nil, nil
|
||||
}
|
||||
|
||||
statusObj, found, err := unstructured.NestedMap(obj.Object, "status")
|
||||
if err != nil || !found {
|
||||
return ReadinessPending, false, nil, nil
|
||||
}
|
||||
|
||||
statusWrapper := map[string]any{"status": statusObj}
|
||||
|
||||
var warnings []ExpressionWarning
|
||||
|
||||
for _, expr := range failureExprs {
|
||||
met, err := evaluateExpression(statusWrapper, expr)
|
||||
if err != nil {
|
||||
if errors.Is(err, errIncomparableOrdering) {
|
||||
warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()})
|
||||
continue
|
||||
}
|
||||
return ReadinessPending, false, warnings, fmt.Errorf("evaluating failure expression %q: %w", expr, err)
|
||||
}
|
||||
if met {
|
||||
return ReadinessFailed, false, warnings, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, expr := range successExprs {
|
||||
met, err := evaluateExpression(statusWrapper, expr)
|
||||
if err != nil {
|
||||
if errors.Is(err, errIncomparableOrdering) {
|
||||
warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()})
|
||||
continue
|
||||
}
|
||||
return ReadinessPending, false, warnings, fmt.Errorf("evaluating success expression %q: %w", expr, err)
|
||||
}
|
||||
if met {
|
||||
return ReadinessReady, false, warnings, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ReadinessPending, false, warnings, nil
|
||||
}
|
||||
|
||||
func parseReadinessExpressions(annotation string) ([]string, error) {
|
||||
annotation = strings.TrimSpace(annotation)
|
||||
if annotation == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var exprs []string
|
||||
if err := json.Unmarshal([]byte(annotation), &exprs); err != nil {
|
||||
return nil, fmt.Errorf("parsing readiness annotation JSON: %w", err)
|
||||
}
|
||||
|
||||
for i, expr := range exprs {
|
||||
exprs[i] = strings.TrimSpace(expr)
|
||||
}
|
||||
|
||||
return exprs, nil
|
||||
}
|
||||
|
||||
// ValidateReadinessExpressions parses a readiness annotation value (a JSON
|
||||
// array of "{<jsonpath>} <op> <value>" expressions) and verifies that every
|
||||
// expression is well-formed and compiles to a valid JSONPath. It does not
|
||||
// evaluate anything against a live object — it is the static-validation entry
|
||||
// point used by `helm lint` so malformed expressions are caught at author time
|
||||
// rather than silently failing readiness at install time. An empty or absent
|
||||
// annotation is valid (returns nil). It also rejects ordering operators
|
||||
// (<, <=, >, >=) whose comparison value is not numeric, since such a
|
||||
// comparison can never evaluate at runtime.
|
||||
func ValidateReadinessExpressions(annotation string) error {
|
||||
exprs, err := parseReadinessExpressions(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, expr := range exprs {
|
||||
path, op, val, err := parseReadinessExpression(expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, err := readinessJSONPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := jsonpath.New("readiness").Parse(template); err != nil {
|
||||
return fmt.Errorf("invalid JSONPath %q: %w", template, err)
|
||||
}
|
||||
// An ordering comparison can only ever succeed numerically, and the
|
||||
// literal's type is statically known: a non-numeric comparison value
|
||||
// with <, <=, >, >= is a definite authoring error.
|
||||
switch op {
|
||||
case "<", "<=", ">", ">=":
|
||||
if _, ok := tryParseFloat(trimReadinessValue(val)); !ok {
|
||||
return fmt.Errorf("expression %q: ordering operator %q requires a numeric comparison value, got %s", expr, op, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func evaluateExpression(obj map[string]any, expr string) (bool, error) {
|
||||
path, op, rawVal, err := parseReadinessExpression(expr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
template, err := readinessJSONPath(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
jp := jsonpath.New("readiness")
|
||||
if err := jp.Parse(template); err != nil {
|
||||
return false, fmt.Errorf("invalid JSONPath %q: %w", template, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jp.Execute(&buf, obj); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return compareValues(strings.TrimSpace(buf.String()), op, rawVal)
|
||||
}
|
||||
|
||||
func readinessJSONPath(path string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(path, ".status.") || strings.HasPrefix(path, ".status["):
|
||||
// Already rooted at .status — avoid double-prefixing
|
||||
return "{" + path + "}", nil
|
||||
case strings.HasPrefix(path, "."):
|
||||
return "{.status" + path + "}", nil
|
||||
case strings.HasPrefix(path, "["):
|
||||
return "{.status" + path + "}", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid JSONPath %q: path must start with . or [", path)
|
||||
}
|
||||
}
|
||||
|
||||
// parseReadinessExpression parses "{<jsonpath>} <operator> <value>" into its parts.
|
||||
func parseReadinessExpression(expr string) (path, op, val string, err error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return "", "", "", errors.New("expression cannot be empty")
|
||||
}
|
||||
if !strings.HasPrefix(expr, "{") {
|
||||
return "", "", "", fmt.Errorf("expression must start with {<jsonpath>}: %q", expr)
|
||||
}
|
||||
|
||||
closeBrace := strings.Index(expr, "}")
|
||||
if closeBrace < 0 {
|
||||
return "", "", "", fmt.Errorf("expression missing closing }: %q", expr)
|
||||
}
|
||||
|
||||
path = strings.TrimSpace(expr[1:closeBrace])
|
||||
if path == "" {
|
||||
return "", "", "", fmt.Errorf("expression missing JSONPath: %q", expr)
|
||||
}
|
||||
|
||||
rest := strings.TrimSpace(expr[closeBrace+1:])
|
||||
if rest == "" {
|
||||
return "", "", "", fmt.Errorf("expression missing operator and value: %q", expr)
|
||||
}
|
||||
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) == 0 {
|
||||
return "", "", "", fmt.Errorf("expression missing operator: %q", expr)
|
||||
}
|
||||
|
||||
switch parts[0] {
|
||||
case "==", "!=", "<", "<=", ">", ">=":
|
||||
op = parts[0]
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("unsupported operator %q in expression %q", parts[0], expr)
|
||||
}
|
||||
|
||||
val = strings.TrimSpace(rest[len(op):])
|
||||
if val == "" {
|
||||
return "", "", "", fmt.Errorf("expression missing comparison value: %q", expr)
|
||||
}
|
||||
|
||||
return path, op, val, nil
|
||||
}
|
||||
|
||||
func compareValues(actual, op, expected string) (bool, error) {
|
||||
actual = strings.TrimSpace(actual)
|
||||
expected = trimReadinessValue(strings.TrimSpace(expected))
|
||||
|
||||
if actualFloat, ok := tryParseFloat(actual); ok {
|
||||
if expectedFloat, ok := tryParseFloat(expected); ok {
|
||||
return compareNumeric(actualFloat, op, expectedFloat)
|
||||
}
|
||||
}
|
||||
|
||||
if actualBool, actualIsBool := tryParseBool(actual); actualIsBool {
|
||||
if expectedBool, expectedIsBool := tryParseBool(expected); expectedIsBool {
|
||||
switch op {
|
||||
case "==":
|
||||
return actualBool == expectedBool, nil
|
||||
case "!=":
|
||||
return actualBool != expectedBool, nil
|
||||
default:
|
||||
return false, fmt.Errorf("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "==":
|
||||
return actual == expected, nil
|
||||
case "!=":
|
||||
return actual != expected, nil
|
||||
default:
|
||||
return false, fmt.Errorf("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering)
|
||||
}
|
||||
}
|
||||
|
||||
func trimReadinessValue(value string) string {
|
||||
if len(value) >= 2 {
|
||||
if value[0] == '"' && value[len(value)-1] == '"' {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
if value[0] == '\'' && value[len(value)-1] == '\'' {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func tryParseFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func tryParseBool(value string) (bool, bool) {
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func compareNumeric(actual float64, op string, expected float64) (bool, error) {
|
||||
switch op {
|
||||
case "==":
|
||||
return actual == expected, nil
|
||||
case "!=":
|
||||
return actual != expected, nil
|
||||
case "<":
|
||||
return actual < expected, nil
|
||||
case "<=":
|
||||
return actual <= expected, nil
|
||||
case ">":
|
||||
return actual > expected, nil
|
||||
case ">=":
|
||||
return actual >= expected, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,526 @@
|
||||
/*
|
||||
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 kube
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func makeUnstructuredWithStatus(t *testing.T, statusFields map[string]any) *unstructured.Unstructured {
|
||||
t.Helper()
|
||||
|
||||
obj := &unstructured.Unstructured{}
|
||||
obj.SetAPIVersion("v1")
|
||||
obj.SetKind("ConfigMap")
|
||||
obj.SetName("test-resource")
|
||||
|
||||
if statusFields != nil {
|
||||
require.NoError(t, unstructured.SetNestedField(obj.Object, statusFields, "status"))
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_BothNil_UsesKstatus(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
|
||||
result, useKstatus, _, err = EvaluateCustomReadiness(obj, []string{}, []string{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OnlySuccess_FallsBackToKstatus(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(obj, []string{`{.ready} == true`}, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OnlyFailure_FallsBackToKstatus(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, []string{`{.failed} == true`})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_SuccessTrue(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"succeeded": int64(1),
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.succeeded} == 1`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_FailurePrecedesSuccess(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"succeeded": int64(1),
|
||||
"failed": int64(1),
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.succeeded} == 1`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessFailed, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_ORSemantics_AnySuccessTrue(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Succeeded",
|
||||
"another": "nope",
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.another} == "yes"`, `{.phase} == "Succeeded"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_NeitherConditionMet(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"succeeded": int64(0),
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.succeeded} == 1`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_MissingStatusField(t *testing.T) {
|
||||
t.Run("missing status object", func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
})
|
||||
|
||||
t.Run("missing status field", func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_NumericComparisonOperators(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
status map[string]any
|
||||
}{
|
||||
{name: "equal", expr: `{.succeeded} == 1`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "not equal", expr: `{.succeeded} != 2`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "less than", expr: `{.succeeded} < 2`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "less than or equal", expr: `{.succeeded} <= 1`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "greater than", expr: `{.succeeded} > 0`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "greater than or equal", expr: `{.succeeded} >= 1`, status: map[string]any{"succeeded": int64(1)}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, tt.status)
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{tt.expr},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_BooleanComparison(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
status map[string]any
|
||||
}{
|
||||
{name: "boolean equals", expr: `{.ready} == true`, status: map[string]any{"ready": true}},
|
||||
{name: "boolean not equals", expr: `{.ready} != false`, status: map[string]any{"ready": true}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, tt.status)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{tt.expr},
|
||||
[]string{`{.failed} == true`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_StringComparison(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Running",
|
||||
})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.phase} == "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
|
||||
result, useKstatus, warnings, err = EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} != "Failed"`},
|
||||
[]string{`{.phase} == "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_InvalidJSONPath(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Running",
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase[} == "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
assert.ErrorContains(t, err, "invalid JSONPath")
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_InvalidOperator(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Running",
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} <> "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
assert.ErrorContains(t, err, "unsupported operator")
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestParseReadinessExpression(t *testing.T) {
|
||||
t.Run("valid expressions", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantPath string
|
||||
wantOp string
|
||||
wantVal string
|
||||
}{
|
||||
{name: "string equality", expr: `{.phase} == "Running"`, wantPath: ".phase", wantOp: "==", wantVal: `"Running"`},
|
||||
{name: "numeric greater than or equal", expr: `{.count} >= 3`, wantPath: ".count", wantOp: ">=", wantVal: "3"},
|
||||
{name: "boolean not equal", expr: `{.ready} != false`, wantPath: ".ready", wantOp: "!=", wantVal: "false"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path, op, val, err := parseReadinessExpression(tt.expr)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantPath, path)
|
||||
assert.Equal(t, tt.wantOp, op)
|
||||
assert.Equal(t, tt.wantVal, val)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid expressions", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantErrPart string
|
||||
}{
|
||||
{name: "missing opening brace", expr: `.phase == "Running"`, wantErrPart: "must start"},
|
||||
{name: "missing closing brace", expr: `{.phase == "Running"`, wantErrPart: "missing closing"},
|
||||
{name: "missing operator", expr: `{.phase} "Running"`, wantErrPart: "unsupported operator"},
|
||||
{name: "unsupported operator", expr: `{.phase} <> "Running"`, wantErrPart: "unsupported operator"},
|
||||
{name: "missing value", expr: `{.phase} ==`, wantErrPart: "missing comparison value"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, _, err := parseReadinessExpression(tt.expr)
|
||||
assert.ErrorContains(t, err, tt.wantErrPart)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadinessJSONPath(t *testing.T) {
|
||||
// Locks the contract that paths already rooted at .status do NOT get
|
||||
// .status double-prepended. Chart authors commonly write
|
||||
// `{.status.phase}` (the canonical form in K8s docs); without the
|
||||
// guard, the effective JSONPath becomes `.status.status.phase` and
|
||||
// the readiness gate silently fails to find any value.
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "already rooted at .status. — no double-prepend", path: ".status.phase", want: "{.status.phase}"},
|
||||
{name: "already rooted at .status[ — no double-prepend", path: `.status['observedGeneration']`, want: `{.status['observedGeneration']}`},
|
||||
{name: "non-status dot path gets .status prefix", path: ".phase", want: "{.status.phase}"},
|
||||
{name: "bracket-only path gets .status prefix", path: "[0]", want: "{.status[0]}"},
|
||||
{name: "invalid path rejected", path: "phase", wantErr: "must start with . or ["},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := readinessJSONPath(tt.path)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadinessExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
annotation string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty annotation is valid", annotation: ""},
|
||||
{name: "well-formed single expression", annotation: `["{.status.phase} == \"Ready\""]`},
|
||||
{name: "well-formed multiple expressions", annotation: `["{.status.succeeded} >= 1", "{.status.ready} == true"]`},
|
||||
{name: "not a JSON array", annotation: `not-json`, wantErr: "parsing readiness annotation JSON"},
|
||||
{name: "missing closing brace", annotation: `["{.status.succeeded >= 1"]`, wantErr: "missing closing"},
|
||||
{name: "path not rooted at . or [", annotation: `["{status.phase} == \"Ready\""]`, wantErr: "must start with . or ["},
|
||||
{name: "ordering operator with quoted string value", annotation: `["{.status.phase} > \"Running\""]`, wantErr: "requires a numeric comparison value"},
|
||||
{name: "ordering operator with boolean value", annotation: `["{.status.ready} >= true"]`, wantErr: "requires a numeric comparison value"},
|
||||
{name: "ordering operator with quoted numeric value is valid", annotation: `["{.status.replicas} >= \"3\""]`},
|
||||
{name: "ordering operator with unquoted non-numeric value", annotation: `["{.status.phase} > Running"]`, wantErr: "requires a numeric comparison value"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateReadinessExpressions(tt.annotation)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadinessExpressionsJSON(t *testing.T) {
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
exprs, err := parseReadinessExpressions(`[]`)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, exprs)
|
||||
})
|
||||
|
||||
t.Run("malformed annotation JSON", func(t *testing.T) {
|
||||
_, err := parseReadinessExpressions(`["{.ready} == true"`)
|
||||
assert.ErrorContains(t, err, "parsing readiness annotation JSON")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OrderingOnNonNumericString_TreatedAsNotMet(t *testing.T) {
|
||||
for _, op := range []string{"<", "<=", ">", ">="} {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
|
||||
expr := fmt.Sprintf(`{.phase} %s "Ready"`, op)
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{expr},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Equal(t, expr, warnings[0].Expression)
|
||||
assert.Contains(t, warnings[0].Detail, "ordering operators")
|
||||
assert.Contains(t, warnings[0].Detail, `"Running"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_IncomparableFailureExpression_Symmetric(t *testing.T) {
|
||||
// A type-mismatched FAILURE expression must mean "failure condition not
|
||||
// met" — not "failed" and not an error — so a satisfied success
|
||||
// expression still marks the resource ready, with the mistake surfaced
|
||||
// as a warning.
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.phase} > "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Equal(t, `{.phase} > "Failed"`, warnings[0].Expression)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_NumericStringCoercionPreserved(t *testing.T) {
|
||||
// A string status value that parses as a number keeps comparing
|
||||
// numerically — only genuinely non-numeric values changed behavior.
|
||||
tests := []struct{ name, expr string }{
|
||||
{name: "unquoted numeric literal", expr: `{.phase} >= 5`},
|
||||
{name: "quoted numeric literal", expr: `{.phase} >= "5"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "5"})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{tt.expr},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OrderingOnBooleans_TreatedAsNotMet(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"ready": true})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.ready} >= true`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
require.Len(t, warnings, 1)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_AllSuccessExpressionsIncomparable_Pending(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
|
||||
|
||||
result, _, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} > "Ready"`, `{.phase} < "Ready"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
assert.Len(t, warnings, 2)
|
||||
}
|
||||
|
||||
func TestCompareValues_OrderingOnNonNumeric_ReturnsSentinel(t *testing.T) {
|
||||
met, err := compareValues("Running", ">", `"Ready"`)
|
||||
require.ErrorIs(t, err, errIncomparableOrdering)
|
||||
assert.False(t, met)
|
||||
|
||||
// A numeric actual against a non-numeric literal is equally incomparable.
|
||||
met, err = compareValues("5", ">", `"high"`)
|
||||
require.ErrorIs(t, err, errIncomparableOrdering)
|
||||
assert.False(t, met)
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
/*
|
||||
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 v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReleaseSequencingInfoBackwardCompatibility(t *testing.T) {
|
||||
release := Release{Name: "demo"}
|
||||
|
||||
data, err := json.Marshal(&release)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(data), "sequencing_info")
|
||||
|
||||
var decoded Release
|
||||
err = json.Unmarshal([]byte(`{"name":"demo"}`), &decoded)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, decoded.SequencingInfo)
|
||||
}
|
||||
|
||||
func TestReleaseSequencingInfoRoundTrip(t *testing.T) {
|
||||
release := Release{
|
||||
Name: "demo",
|
||||
SequencingInfo: &SequencingInfo{
|
||||
Enabled: true,
|
||||
Strategy: "ordered",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(&release)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), `"sequencing_info"`)
|
||||
|
||||
var decoded Release
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded.SequencingInfo)
|
||||
assert.True(t, decoded.SequencingInfo.Enabled)
|
||||
assert.Equal(t, "ordered", decoded.SequencingInfo.Strategy)
|
||||
}
|
||||
|
||||
func TestReleaseIsSequencedLegacyDecode(t *testing.T) {
|
||||
var decoded Release
|
||||
err := json.Unmarshal([]byte(`{"name":"demo","sequencing_info":{"enabled":true}}`), &decoded)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, decoded.Sequenced)
|
||||
assert.True(t, decoded.IsSequenced())
|
||||
}
|
||||
|
||||
func TestReleaseSequencedRoundTrip(t *testing.T) {
|
||||
release := Release{Name: "demo", Sequenced: true}
|
||||
data, err := json.Marshal(&release)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), `"sequenced":true`)
|
||||
|
||||
var decoded Release
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
assert.True(t, decoded.Sequenced)
|
||||
assert.True(t, decoded.IsSequenced())
|
||||
assert.Nil(t, decoded.SequencingInfo)
|
||||
}
|
||||
|
||||
func TestReleaseNotSequencedOmitted(t *testing.T) {
|
||||
release := Release{Name: "demo"}
|
||||
data, err := json.Marshal(&release)
|
||||
require.NoError(t, err)
|
||||
// Non-sequenced release JSON is byte-identical to pre-schema-change output.
|
||||
assert.NotContains(t, string(data), `"sequenced"`)
|
||||
assert.NotContains(t, string(data), `"sequencing_info"`)
|
||||
assert.False(t, release.IsSequenced())
|
||||
}
|
||||
|
||||
func TestReleaseIsSequencedDisabledLegacyInfo(t *testing.T) {
|
||||
release := Release{Name: "demo", SequencingInfo: &SequencingInfo{Enabled: false}}
|
||||
assert.False(t, release.IsSequenced())
|
||||
}
|
||||
@ -0,0 +1,359 @@
|
||||
/*
|
||||
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 sequence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
type builder struct {
|
||||
plan *Plan
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// FindSubchart resolves a direct dependency by effective name (alias if set,
|
||||
// else name) — the single home for the chart-walker subchart lookup. It uses
|
||||
// the parent chart's Metadata.Dependencies to resolve aliases, since the alias
|
||||
// is stored on the Dependency struct in Chart.yaml, not on the subchart's own
|
||||
// Metadata.
|
||||
func FindSubchart(chrt *chart.Chart, nameOrAlias string) *chart.Chart {
|
||||
if chrt == nil {
|
||||
return nil
|
||||
}
|
||||
aliasMap := make(map[string]string) // chart name → effective name (alias or name)
|
||||
if chrt.Metadata != nil {
|
||||
for _, dep := range chrt.Metadata.Dependencies {
|
||||
if dep == nil {
|
||||
continue
|
||||
}
|
||||
effective := dep.Name
|
||||
if dep.Alias != "" {
|
||||
effective = dep.Alias
|
||||
}
|
||||
aliasMap[dep.Name] = effective
|
||||
}
|
||||
}
|
||||
for _, dep := range chrt.Dependencies() {
|
||||
effective := dep.Name()
|
||||
if alias, ok := aliasMap[dep.Name()]; ok {
|
||||
effective = alias
|
||||
}
|
||||
if effective == nameOrAlias || dep.Name() == nameOrAlias {
|
||||
return dep
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build constructs the deployment plan for chrt's rendered manifests.
|
||||
//
|
||||
// Preconditions: manifests are hook-free (SortManifests output or a stored
|
||||
// rel.Manifest — hooks are stored separately) and chrt has been through
|
||||
// ProcessDependencies. Storage-decoded charts satisfy this in metadata only:
|
||||
// the release codec drops the loaded dependency tree (Chart.dependencies is
|
||||
// unexported), so only the root chart object and its pruned, alias-rewritten
|
||||
// Metadata.Dependencies survive. Build handles that shape: the root subchart
|
||||
// DAG is built from the trusted metadata (see BuildSubchartDAG) and nested
|
||||
// levels are walked structurally from manifest "# Source:" paths
|
||||
// (buildStructuralLevel). Build does not re-filter hooks.
|
||||
// chrt == nil yields a flat single-level plan at ChartPath "".
|
||||
//
|
||||
// Errors (fatal — the caller must not proceed):
|
||||
// - subchart DAG construction failure: depends-on referencing unknown/disabled
|
||||
// subcharts, malformed helm.sh/depends-on/subcharts JSON (BuildSubchartDAG)
|
||||
// - cycle in a subchart DAG or a resource-group DAG (any level)
|
||||
// - a resource assigned to two different groups (ParseResourceGroups)
|
||||
//
|
||||
// Warnings (non-fatal, returned in Plan.Warnings; the manifest is still placed):
|
||||
// - invalid depends-on/resource-groups JSON on a resource → demoted to unsequenced
|
||||
// - group referencing a missing group → whole group demoted (transitively)
|
||||
// - isolated group among ≥2 groups → demoted to unsequenced
|
||||
// - resource with only one of the two readiness annotations (falls back to kstatus)
|
||||
// - rendered subchart not declared in Chart.yaml (deployed after declared subcharts)
|
||||
// - rendered subchart not resolvable to a chart object and with ≥2 nested
|
||||
// sibling subcharts (structural walk cannot recover sibling ordering)
|
||||
//
|
||||
// Postcondition: every input manifest appears in exactly one batch;
|
||||
// len(manifests) == Σ len(batch.Manifests()). Unit-enforced.
|
||||
//
|
||||
// Build is deterministic: identical input yields an identical plan.
|
||||
func Build(chrt *chart.Chart, manifests []releaseutil.Manifest) (*Plan, error) {
|
||||
b := &builder{plan: &Plan{}}
|
||||
if chrt == nil {
|
||||
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: "", Depth: 0})
|
||||
if err := b.appendResourceGroupBatches("", 0, manifests); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.plan, nil
|
||||
}
|
||||
|
||||
if err := b.buildLevel(chrt, manifests, chrt.Name(), 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.plan, nil
|
||||
}
|
||||
|
||||
func (b *builder) warnf(kind WarningKind, chartPath, format string, args ...any) {
|
||||
b.plan.Warnings = append(b.plan.Warnings, Warning{
|
||||
Kind: kind,
|
||||
ChartPath: chartPath,
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *builder) buildLevel(c *chart.Chart, manifests []releaseutil.Manifest, chartPath string, depth int) error {
|
||||
levelIdx := len(b.plan.Levels)
|
||||
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: chartPath, Depth: depth})
|
||||
|
||||
grouped := GroupManifestsByDirectSubchart(manifests, chartPath)
|
||||
|
||||
dag, err := chartutil.BuildSubchartDAG(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building subchart DAG for %s: %w", chartPath, err)
|
||||
}
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
if err != nil {
|
||||
return fmt.Errorf("subchart circular dependency detected in %s: %w", chartPath, err)
|
||||
}
|
||||
b.plan.Levels[levelIdx].SubchartBatches = batches
|
||||
|
||||
declared := make(map[string]bool, len(batches))
|
||||
for _, batch := range batches {
|
||||
for _, name := range batch {
|
||||
declared[name] = true
|
||||
if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range slices.Sorted(maps.Keys(grouped)) {
|
||||
if name == "" || declared[name] {
|
||||
continue
|
||||
}
|
||||
b.plan.Levels[levelIdx].Undeclared = append(b.plan.Levels[levelIdx].Undeclared, name)
|
||||
b.warnf(WarningKindUndeclaredSubchart, chartPath, "rendered subchart %q is not declared in Chart.yaml dependencies; sequencing it after declared subcharts", name)
|
||||
if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
slices.Sort(b.plan.Levels[levelIdx].Unresolved)
|
||||
|
||||
return b.appendResourceGroupBatches(chartPath, depth, grouped[""])
|
||||
}
|
||||
|
||||
func (b *builder) buildSubchart(parent *chart.Chart, chartPath, name string, manifests []releaseutil.Manifest, depth, parentLevelIdx int) error {
|
||||
if len(manifests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
subPath := chartPath + "/charts/" + name
|
||||
sub := FindSubchart(parent, name)
|
||||
if sub == nil {
|
||||
b.plan.Levels[parentLevelIdx].Unresolved = append(b.plan.Levels[parentLevelIdx].Unresolved, name)
|
||||
return b.buildStructuralLevel(subPath, depth+1, manifests)
|
||||
}
|
||||
|
||||
return b.buildLevel(sub, manifests, subPath, depth+1)
|
||||
}
|
||||
|
||||
// buildStructuralLevel sequences a subtree whose chart object is unavailable.
|
||||
// Charts decoded from release storage lose their loaded dependency tree (the
|
||||
// release codec serializes only exported fields, and Chart.dependencies is
|
||||
// not one), so at uninstall/rollback time nested subchart levels have
|
||||
// manifests but no chart. The tree structure is recovered from the manifests'
|
||||
// "# Source:" path prefixes and walked exactly like buildLevel — subchart
|
||||
// subtrees first, then the level's own resource-group batches (the group
|
||||
// annotations live on the manifests and fully survive storage). The only
|
||||
// ordering information lost with the chart is depends-on edges BETWEEN
|
||||
// sibling subcharts: siblings are walked in name order, and a warning is
|
||||
// recorded when a level has two or more.
|
||||
func (b *builder) buildStructuralLevel(chartPath string, depth int, manifests []releaseutil.Manifest) error {
|
||||
levelIdx := len(b.plan.Levels)
|
||||
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: chartPath, Depth: depth})
|
||||
|
||||
grouped := GroupManifestsByDirectSubchart(manifests, chartPath)
|
||||
subcharts := slices.DeleteFunc(slices.Sorted(maps.Keys(grouped)), func(name string) bool { return name == "" })
|
||||
|
||||
if len(subcharts) > 0 {
|
||||
b.plan.Levels[levelIdx].SubchartBatches = [][]string{subcharts}
|
||||
}
|
||||
if len(subcharts) >= 2 {
|
||||
b.warnf(WarningKindUnresolvedSubchart, chartPath, "chart metadata for %s is unavailable; sequencing its subcharts %v in name order (depends-on between them, if any, is not recoverable)", chartPath, subcharts)
|
||||
}
|
||||
for _, name := range subcharts {
|
||||
if err := b.buildStructuralLevel(chartPath+"/charts/"+name, depth+1, grouped[name]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return b.appendResourceGroupBatches(chartPath, depth, grouped[""])
|
||||
}
|
||||
|
||||
func (b *builder) appendResourceGroupBatches(chartPath string, depth int, manifests []releaseutil.Manifest) error {
|
||||
if len(manifests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, manifest := range manifests {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil {
|
||||
continue
|
||||
}
|
||||
annotations := manifest.Head.Metadata.Annotations
|
||||
_, hasSuccess := annotations[releaseutil.AnnotationReadinessSuccess]
|
||||
_, hasFailure := annotations[releaseutil.AnnotationReadinessFailure]
|
||||
if hasSuccess != hasFailure {
|
||||
b.warnf(WarningKindPartialReadiness, chartPath, "resource %q has only one of %s and %s; falling back to kstatus readiness", manifest.Head.Metadata.Name, releaseutil.AnnotationReadinessSuccess, releaseutil.AnnotationReadinessFailure)
|
||||
}
|
||||
}
|
||||
|
||||
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing resource-group annotations for %s: %w", chartPath, err)
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
b.plan.Warnings = append(b.plan.Warnings, Warning{
|
||||
Kind: WarningKindResourceGroupDemotion,
|
||||
ChartPath: chartPath,
|
||||
Message: warning,
|
||||
})
|
||||
}
|
||||
|
||||
unsequenced := result.Unsequenced
|
||||
if len(result.Groups) >= 2 {
|
||||
dependents := resourceGroupDependents(result.GroupDeps)
|
||||
for _, groupName := range slices.Sorted(maps.Keys(result.Groups)) {
|
||||
if len(result.GroupDeps[groupName]) != 0 || dependents[groupName] {
|
||||
continue
|
||||
}
|
||||
b.warnf(WarningKindIsolatedGroup, chartPath, "resource-group %q is isolated (no depends-on edges and no dependents); deploying it in the unsequenced batch after sequenced groups", groupName)
|
||||
unsequenced = append(unsequenced, result.Groups[groupName]...)
|
||||
delete(result.Groups, groupName)
|
||||
delete(result.GroupDeps, groupName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Groups) > 0 {
|
||||
dag, err := releaseutil.BuildResourceGroupDAG(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building resource-group DAG for %s: %w", chartPath, err)
|
||||
}
|
||||
|
||||
groupBatches, err := dag.GetBatches()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resource-group circular dependency detected in %s: %w", chartPath, err)
|
||||
}
|
||||
|
||||
dependents := resourceGroupDependents(result.GroupDeps)
|
||||
for _, groupBatch := range groupBatches {
|
||||
batch := Batch{
|
||||
ChartPath: chartPath,
|
||||
Depth: depth,
|
||||
Kind: BatchKindGroups,
|
||||
Wait: true,
|
||||
}
|
||||
for _, groupName := range groupBatch {
|
||||
batch.Groups = append(batch.Groups, Group{
|
||||
Name: groupName,
|
||||
Manifests: result.Groups[groupName],
|
||||
})
|
||||
if !dependents[groupName] {
|
||||
batch.LeafGroups = append(batch.LeafGroups, groupName)
|
||||
}
|
||||
}
|
||||
batch.HasCustomReadiness = hasCustomReadiness(batch.Manifests())
|
||||
b.plan.Batches = append(b.plan.Batches, batch)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unsequenced) > 0 {
|
||||
b.plan.Batches = append(b.plan.Batches, Batch{
|
||||
ChartPath: chartPath,
|
||||
Depth: depth,
|
||||
Kind: BatchKindUnsequenced,
|
||||
Groups: []Group{{Name: "", Manifests: unsequenced}},
|
||||
Wait: true,
|
||||
HasCustomReadiness: hasCustomReadiness(unsequenced),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceGroupDependents(groupDeps map[string][]string) map[string]bool {
|
||||
dependents := make(map[string]bool)
|
||||
for _, deps := range groupDeps {
|
||||
for _, dep := range deps {
|
||||
dependents[dep] = true
|
||||
}
|
||||
}
|
||||
return dependents
|
||||
}
|
||||
|
||||
func hasCustomReadiness(manifests []releaseutil.Manifest) bool {
|
||||
for _, manifest := range manifests {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil {
|
||||
continue
|
||||
}
|
||||
annotations := manifest.Head.Metadata.Annotations
|
||||
if annotations == nil {
|
||||
continue
|
||||
}
|
||||
if annotations[releaseutil.AnnotationReadinessSuccess] != "" && annotations[releaseutil.AnnotationReadinessFailure] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,75 @@
|
||||
/*
|
||||
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 sequence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
// ParseStoredManifests parses a stored release manifest stream (rel.Manifest)
|
||||
// or `helm template` output into named manifests, recovering source-template
|
||||
// paths from "# Source:" comments. Documents without a Source comment keep
|
||||
// their positional "manifest-%d" split key, and therefore route to the
|
||||
// top-level/flat portion of a plan built from them. Returns an error only on
|
||||
// YAML head-parse failure.
|
||||
func ParseStoredManifests(manifest string) ([]releaseutil.Manifest, error) {
|
||||
rawManifests := releaseutil.SplitManifests(manifest)
|
||||
keys := make([]string, 0, len(rawManifests))
|
||||
for key := range rawManifests {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Sort(releaseutil.BySplitManifestsOrder(keys))
|
||||
|
||||
manifests := make([]releaseutil.Manifest, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
content := rawManifests[key]
|
||||
name := manifestSourcePath(content)
|
||||
if name == "" {
|
||||
name = key
|
||||
}
|
||||
|
||||
var head releaseutil.SimpleHead
|
||||
if err := yaml.Unmarshal([]byte(content), &head); err != nil {
|
||||
return nil, fmt.Errorf("YAML parse error on %s: %w", name, err)
|
||||
}
|
||||
|
||||
manifests = append(manifests, releaseutil.Manifest{
|
||||
Name: name,
|
||||
Content: content,
|
||||
Head: &head,
|
||||
})
|
||||
}
|
||||
|
||||
return manifests, nil
|
||||
}
|
||||
|
||||
func manifestSourcePath(content string) string {
|
||||
for line := range strings.SplitSeq(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if source, ok := strings.CutPrefix(line, "# Source: "); ok {
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
/*
|
||||
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 sequence computes the ordered deployment plan for a release's
|
||||
// rendered manifests per HIP-0025: a two-level walk over the chart's subchart
|
||||
// DAG and each chart level's resource-group DAG, flattened into an ordered
|
||||
// list of apply-and-wait batches. The package is pure — it never talks to a
|
||||
// cluster and must not import pkg/kube, pkg/action, or pkg/cmd — so the same
|
||||
// plan is usable by install/upgrade drivers, uninstall/rollback (reversed),
|
||||
// `helm template`/`helm dag` rendering, and lint.
|
||||
package sequence
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
// BatchKind distinguishes the two batch flavors a chart level produces.
|
||||
type BatchKind uint8
|
||||
|
||||
const (
|
||||
// BatchKindGroups: one topological level of the resource-group DAG —
|
||||
// one or more groups deployable in parallel.
|
||||
BatchKindGroups BatchKind = iota
|
||||
// BatchKindUnsequenced: a chart level's trailing batch — resources with no
|
||||
// group annotation, resources demoted for invalid depends-on JSON,
|
||||
// groups demoted for missing references, and isolated groups.
|
||||
BatchKindUnsequenced
|
||||
)
|
||||
|
||||
// Group is a named set of manifests deployed as one unit within a batch.
|
||||
type Group struct {
|
||||
Name string // "" only inside a BatchKindUnsequenced batch
|
||||
Manifests []releaseutil.Manifest // input order preserved
|
||||
}
|
||||
|
||||
// Batch is one apply-and-wait unit. Drivers process Batches strictly in order.
|
||||
type Batch struct {
|
||||
// ChartPath is the manifest path-prefix of the owning chart level:
|
||||
// "parent", "parent/charts/db", "parent/charts/db/charts/redis".
|
||||
// "" when the plan was built without a chart (flat fallback).
|
||||
ChartPath string
|
||||
// Depth is the chart-nesting depth (0 = top level). Display-only.
|
||||
Depth int
|
||||
Kind BatchKind
|
||||
// Groups: for BatchKindGroups, ≥1 groups sorted by Name (one DAG level);
|
||||
// for BatchKindUnsequenced, exactly one Group{Name: ""}.
|
||||
Groups []Group
|
||||
// Wait: gate on readiness (install/upgrade) or deletion (reversed plans)
|
||||
// before starting the next batch. The builder sets true for every batch;
|
||||
// the field exists so a strict-HIP mode can flip leaf-only batches later.
|
||||
Wait bool
|
||||
// HasCustomReadiness: at least one manifest in the batch carries BOTH
|
||||
// helm.sh/readiness-success and helm.sh/readiness-failure (drivers add
|
||||
// kube.WithCustomReadinessStatusReader for such batches).
|
||||
HasCustomReadiness bool
|
||||
// LeafGroups: names of groups in this batch with no dependents in this
|
||||
// chart level's DAG. Informational — display and future policy only.
|
||||
LeafGroups []string
|
||||
}
|
||||
|
||||
// Manifests flattens the batch's groups in order. Convenience for drivers.
|
||||
func (b Batch) Manifests() []releaseutil.Manifest {
|
||||
var manifests []releaseutil.Manifest
|
||||
for _, group := range b.Groups {
|
||||
manifests = append(manifests, group.Manifests...)
|
||||
}
|
||||
return manifests
|
||||
}
|
||||
|
||||
// WarningKind classifies a Warning so consumers can map runtime demotion
|
||||
// classes to severities without string-matching messages.
|
||||
type WarningKind uint8
|
||||
|
||||
const (
|
||||
// WarningKindResourceGroupDemotion: a resource or group demoted to the
|
||||
// unsequenced batch by ParseResourceGroups (malformed depends-on JSON,
|
||||
// reference to a non-existent group). Lint treats these as errors.
|
||||
WarningKindResourceGroupDemotion WarningKind = iota
|
||||
// WarningKindIsolatedGroup: a group with no edges demoted to unsequenced.
|
||||
WarningKindIsolatedGroup
|
||||
// WarningKindPartialReadiness: only one of the two readiness annotations
|
||||
// present; the resource falls back to kstatus.
|
||||
WarningKindPartialReadiness
|
||||
// WarningKindUndeclaredSubchart: rendered subchart absent from Chart.yaml.
|
||||
WarningKindUndeclaredSubchart
|
||||
// WarningKindUnresolvedSubchart: rendered subchart dir with no resolvable
|
||||
// chart object (typical for nested levels of storage-decoded charts) whose
|
||||
// structural walk found ≥2 sibling subcharts, so their depends-on order,
|
||||
// if any was declared, could not be recovered.
|
||||
WarningKindUnresolvedSubchart
|
||||
)
|
||||
|
||||
// Warning is a non-fatal misconfiguration surfaced during Build.
|
||||
// Drivers log them; lint maps them to rule severities.
|
||||
type Warning struct {
|
||||
Kind WarningKind
|
||||
ChartPath string // "" for chart-independent warnings
|
||||
Message string
|
||||
}
|
||||
|
||||
// ChartLevel summarizes one chart's slot in the walk, in pre-order.
|
||||
// Consumed by `helm dag` (subchart-batch display) and diagnostics.
|
||||
type ChartLevel struct {
|
||||
Path string // manifest path-prefix (as Batch.ChartPath)
|
||||
Depth int
|
||||
SubchartBatches [][]string // declared subcharts per parallel DAG batch (effective names)
|
||||
Undeclared []string // rendered subcharts absent from Chart.yaml deps (sorted)
|
||||
Unresolved []string // rendered subchart dirs with no resolvable chart object (sorted)
|
||||
}
|
||||
|
||||
// Plan is the complete deployment sequence for one release's manifests.
|
||||
type Plan struct {
|
||||
Batches []Batch // forward (install) order
|
||||
Levels []ChartLevel // pre-order chart traversal
|
||||
Warnings []Warning
|
||||
}
|
||||
|
||||
// Reverse returns a new Plan whose Batches are in exact reverse order, for
|
||||
// uninstall and removed-resource deletion (HIP: uninstall is the exact reverse
|
||||
// of install). Levels and Warnings are shared unchanged; Wait retains its
|
||||
// meaning ("gate before next batch", i.e. wait-for-delete).
|
||||
func (p *Plan) Reverse() *Plan {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reversed := make([]Batch, len(p.Batches))
|
||||
for i, batch := range p.Batches {
|
||||
reversed[len(p.Batches)-1-i] = batch
|
||||
}
|
||||
|
||||
return &Plan{
|
||||
Batches: reversed,
|
||||
Levels: p.Levels,
|
||||
Warnings: p.Warnings,
|
||||
}
|
||||
}
|
||||
|
||||
// DisplayPath converts a manifest path-prefix to the HIP display form used by
|
||||
// template markers and `helm dag`: "parent/charts/db/charts/redis" → "parent/db/redis".
|
||||
// (Chart names cannot contain '/', so replacing "/charts/" is unambiguous;
|
||||
// a subchart literally named "charts" is pathological and unsupported.)
|
||||
func DisplayPath(chartPath string) string {
|
||||
return strings.ReplaceAll(chartPath, "/charts/", "/")
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
/*
|
||||
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 sequence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func TestPlanReverse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifests := []releaseutil.Manifest{
|
||||
makeManifest("base", "parent/templates/base.yaml", groupAnnotations("base")),
|
||||
makeManifest("left", "parent/templates/left.yaml", groupAnnotations("left", "base")),
|
||||
makeManifest("right", "parent/templates/right.yaml", groupAnnotations("right", "base")),
|
||||
makeManifest("top", "parent/templates/top.yaml", groupAnnotations("top", "left", "right")),
|
||||
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||
}
|
||||
plan, err := Build(newChart("parent"), manifests)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(plan.Batches), 4)
|
||||
|
||||
original := batchSummaries(plan)
|
||||
reversed := plan.Reverse()
|
||||
require.NotNil(t, reversed)
|
||||
require.Len(t, reversed.Batches, len(plan.Batches))
|
||||
|
||||
for i := range reversed.Batches {
|
||||
assert.Equal(t, original[len(original)-1-i], summarizeBatch(reversed.Batches[i]))
|
||||
}
|
||||
assert.Equal(t, original, batchSummaries(plan), "Reverse must not mutate the original plan")
|
||||
assert.Equal(t, plan.Levels, reversed.Levels)
|
||||
assert.Equal(t, plan.Warnings, reversed.Warnings)
|
||||
assert.Equal(t, original, batchSummaries(reversed.Reverse()))
|
||||
}
|
||||
|
||||
func TestDisplayPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]string{
|
||||
"parent": "parent",
|
||||
"parent/charts/db": "parent/db",
|
||||
"parent/charts/db/charts/redis": "parent/db/redis",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for input, expected := range tests {
|
||||
assert.Equal(t, expected, DisplayPath(input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchManifests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := makeManifest("first", "parent/templates/first.yaml", nil)
|
||||
second := makeManifest("second", "parent/templates/second.yaml", nil)
|
||||
third := makeManifest("third", "parent/templates/third.yaml", nil)
|
||||
batch := Batch{
|
||||
Groups: []Group{
|
||||
{Name: "a", Manifests: []releaseutil.Manifest{first, second}},
|
||||
{Name: "b", Manifests: []releaseutil.Manifest{third}},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []releaseutil.Manifest{first, second, third}, batch.Manifests())
|
||||
}
|
||||
|
||||
func TestReadinessAnnotationValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "helm.sh/readiness-success", releaseutil.AnnotationReadinessSuccess)
|
||||
assert.Equal(t, "helm.sh/readiness-failure", releaseutil.AnnotationReadinessFailure)
|
||||
}
|
||||
|
||||
func TestPackageImportPurity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries, err := os.ReadDir(".")
|
||||
require.NoError(t, err)
|
||||
|
||||
allowedHelmImports := map[string]bool{
|
||||
"helm.sh/helm/v4/pkg/chart/v2": true,
|
||||
"helm.sh/helm/v4/pkg/chart/v2/util": true,
|
||||
"helm.sh/helm/v4/pkg/release/v1/util": true,
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
file, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, parser.ImportsOnly)
|
||||
require.NoError(t, err)
|
||||
for _, imported := range file.Imports {
|
||||
path, err := strconv.Unquote(imported.Path.Value)
|
||||
require.NoError(t, err)
|
||||
if strings.HasPrefix(path, "helm.sh/helm/v4/") {
|
||||
assert.Truef(t, allowedHelmImports[path], "forbidden helm import %q in %s", path, entry.Name())
|
||||
}
|
||||
assert.Falsef(t, strings.HasPrefix(path, "k8s.io/"), "forbidden Kubernetes import %q in %s", path, entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStoredManifests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stream := strings.Join([]string{
|
||||
"---\n# Source: parent/templates/a.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n annotations:\n example.com/key: value\n",
|
||||
"---\n# Source: parent/charts/db/templates/b.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n",
|
||||
"---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n",
|
||||
}, "")
|
||||
|
||||
parsed, err := ParseStoredManifests(stream)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, parsed, 3)
|
||||
assert.Equal(t, []string{"parent/templates/a.yaml", "parent/charts/db/templates/b.yaml", "manifest-2"}, manifestNames(parsed))
|
||||
assert.Equal(t, "ConfigMap", parsed[0].Head.Kind)
|
||||
assert.Equal(t, "a", parsed[0].Head.Metadata.Name)
|
||||
assert.Equal(t, "value", parsed[0].Head.Metadata.Annotations["example.com/key"])
|
||||
assert.Equal(t, "b", parsed[1].Head.Metadata.Name)
|
||||
assert.Equal(t, "c", parsed[2].Head.Metadata.Name)
|
||||
|
||||
_, err = ParseStoredManifests("---\n# Source: bad.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: [\n")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "YAML parse error")
|
||||
|
||||
flat, err := Build(nil, parsed)
|
||||
require.NoError(t, err)
|
||||
assertPlanComplete(t, flat, parsed)
|
||||
require.Len(t, flat.Batches, 1)
|
||||
assert.Empty(t, flat.Batches[0].ChartPath)
|
||||
|
||||
db := makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db"))
|
||||
app := makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db"))
|
||||
stored := fmt.Sprintf("---\n# Source: %s\n%s---\n# Source: %s\n%s", db.Name, db.Content, app.Name, app.Content)
|
||||
parsedStored, err := ParseStoredManifests(stored)
|
||||
require.NoError(t, err)
|
||||
|
||||
fromStored, err := Build(newChart("parent"), parsedStored)
|
||||
require.NoError(t, err)
|
||||
fromDirect, err := Build(newChart("parent"), []releaseutil.Manifest{db, app})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, batchSummaries(fromDirect), batchSummaries(fromStored))
|
||||
assertPlanComplete(t, fromStored, parsedStored)
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
/*
|
||||
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 util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// AnnotationResourceGroup declares the resource-group a resource belongs to.
|
||||
AnnotationResourceGroup = "helm.sh/resource-group"
|
||||
|
||||
// AnnotationDependsOnResourceGroups declares prerequisite resource-groups for a
|
||||
// resource's group as a JSON string array.
|
||||
AnnotationDependsOnResourceGroups = "helm.sh/depends-on/resource-groups"
|
||||
|
||||
// AnnotationReadinessSuccess declares custom readiness success conditions.
|
||||
// Its value is a JSON array of expressions in the form:
|
||||
// "{.fieldPath} <operator> <value>"
|
||||
// If any success condition evaluates to true, the resource is considered ready.
|
||||
// This is the canonical home of the key (moved from pkg/kube, which keeps
|
||||
// value-identical aliases) so the pure sequencing plan builder can read it
|
||||
// without importing cluster machinery.
|
||||
AnnotationReadinessSuccess = "helm.sh/readiness-success"
|
||||
|
||||
// AnnotationReadinessFailure declares custom readiness failure conditions.
|
||||
// Its value is a JSON array of expressions in the form:
|
||||
// "{.fieldPath} <operator> <value>"
|
||||
// If any failure condition evaluates to true, the resource is considered failed.
|
||||
// Failure conditions take precedence over success conditions.
|
||||
AnnotationReadinessFailure = "helm.sh/readiness-failure"
|
||||
)
|
||||
|
||||
// helmInternalSequencingAnnotations lists annotation keys used by Helm for
|
||||
// resource sequencing that are NOT valid Kubernetes annotation keys (their
|
||||
// names contain multiple `/` separators). Helm strips these before applying
|
||||
// resources to the API server, and before printing manifests via
|
||||
// `helm template` so downstream tooling like `kubectl apply` accepts the
|
||||
// output unmodified.
|
||||
//
|
||||
// It is unexported, and the regex in manifest.go is compiled from it once at
|
||||
// init. External callers read it through the HelmInternalSequencingAnnotations
|
||||
// accessor (which returns a copy) so the canonical list — and the regex derived
|
||||
// from it — cannot be mutated, avoiding the silent-no-op and data-race footguns
|
||||
// of an exported mutable slice.
|
||||
var helmInternalSequencingAnnotations = []string{
|
||||
AnnotationDependsOnResourceGroups,
|
||||
}
|
||||
|
||||
// HelmInternalSequencingAnnotations returns a copy of the helm-internal
|
||||
// sequencing annotation keys that Helm strips from rendered output. A copy is
|
||||
// returned so callers cannot mutate the canonical list.
|
||||
func HelmInternalSequencingAnnotations() []string {
|
||||
return slices.Clone(helmInternalSequencingAnnotations)
|
||||
}
|
||||
|
||||
// ResourceGroupResult holds the output of ParseResourceGroups.
|
||||
type ResourceGroupResult struct {
|
||||
// Groups maps group name to manifests assigned to that group.
|
||||
Groups map[string][]Manifest
|
||||
|
||||
// GroupDeps maps group name to the group names it depends on.
|
||||
GroupDeps map[string][]string
|
||||
|
||||
// Unsequenced contains manifests that should be deployed outside the DAG.
|
||||
Unsequenced []Manifest
|
||||
}
|
||||
|
||||
// ParseResourceGroups extracts resource-group annotations from rendered
|
||||
// manifests and partitions them into sequenced groups and unsequenced manifests.
|
||||
//
|
||||
// Manifests without a resource-group annotation are treated as unsequenced.
|
||||
// Invalid depends-on annotations emit a warning and demote that manifest to the
|
||||
// unsequenced batch. References to unknown groups emit a warning and demote the
|
||||
// entire referencing group to the unsequenced batch. If the same resource is
|
||||
// assigned to different groups, ParseResourceGroups returns an error.
|
||||
func ParseResourceGroups(manifests []Manifest) (ResourceGroupResult, []string, error) {
|
||||
result := ResourceGroupResult{
|
||||
Groups: make(map[string][]Manifest),
|
||||
GroupDeps: make(map[string][]string),
|
||||
}
|
||||
var warnings []string
|
||||
|
||||
resourceAssignments := make(map[string]string)
|
||||
groupDeps := make(map[string][]string)
|
||||
groupOrder := make([]string, 0)
|
||||
|
||||
for _, manifest := range manifests {
|
||||
groupName, hasGroup := resourceGroupName(manifest)
|
||||
if !hasGroup {
|
||||
result.Unsequenced = append(result.Unsequenced, manifest)
|
||||
continue
|
||||
}
|
||||
|
||||
resourceID := resourceGroupResourceID(manifest)
|
||||
if existingGroup, ok := resourceAssignments[resourceID]; ok && existingGroup != groupName {
|
||||
return ResourceGroupResult{}, warnings, fmt.Errorf(
|
||||
"resource %q assigned to multiple resource groups %q and %q",
|
||||
resourceID,
|
||||
existingGroup,
|
||||
groupName,
|
||||
)
|
||||
}
|
||||
resourceAssignments[resourceID] = groupName
|
||||
|
||||
deps, warning, err := resourceGroupDependencies(manifest)
|
||||
if err != nil {
|
||||
warnings = append(warnings, warning)
|
||||
result.Unsequenced = append(result.Unsequenced, manifest)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := result.Groups[groupName]; !ok {
|
||||
groupOrder = append(groupOrder, groupName)
|
||||
}
|
||||
|
||||
result.Groups[groupName] = append(result.Groups[groupName], manifest)
|
||||
groupDeps[groupName] = appendUniqueStrings(groupDeps[groupName], deps...)
|
||||
}
|
||||
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
|
||||
for _, groupName := range groupOrder {
|
||||
groupManifests, ok := result.Groups[groupName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
missingDep := firstMissingDependency(result.Groups, groupDeps[groupName])
|
||||
if missingDep == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"group %q depends-on non-existent group %q; moving group to unsequenced batch",
|
||||
groupName,
|
||||
missingDep,
|
||||
))
|
||||
result.Unsequenced = append(result.Unsequenced, groupManifests...)
|
||||
delete(result.Groups, groupName)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, groupName := range groupOrder {
|
||||
if _, ok := result.Groups[groupName]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
result.GroupDeps[groupName] = groupDeps[groupName]
|
||||
}
|
||||
|
||||
return result, warnings, nil
|
||||
}
|
||||
|
||||
// BuildResourceGroupDAG constructs a DAG from the resource-group parse result.
|
||||
// Each group becomes a DAG node and every dependency becomes an edge.
|
||||
func BuildResourceGroupDAG(result ResourceGroupResult) (*chartutil.DAG, error) {
|
||||
dag := chartutil.NewDAG()
|
||||
|
||||
for groupName := range result.Groups {
|
||||
dag.AddNode(groupName)
|
||||
}
|
||||
|
||||
for groupName, deps := range result.GroupDeps {
|
||||
for _, dep := range deps {
|
||||
if err := dag.AddEdge(dep, groupName); err != nil {
|
||||
return nil, fmt.Errorf("resource-group sequencing edge %s→%s: %w", dep, groupName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dag, nil
|
||||
}
|
||||
|
||||
func resourceGroupName(manifest Manifest) (string, bool) {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil || len(manifest.Head.Metadata.Annotations) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
groupName, ok := manifest.Head.Metadata.Annotations[AnnotationResourceGroup]
|
||||
if !ok || groupName == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return groupName, true
|
||||
}
|
||||
|
||||
func resourceGroupDependencies(manifest Manifest) ([]string, string, error) {
|
||||
annotations := manifest.Head.Metadata.Annotations
|
||||
rawDeps, ok := annotations[AnnotationDependsOnResourceGroups]
|
||||
if !ok {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
var deps []string
|
||||
if err := json.Unmarshal([]byte(rawDeps), &deps); err != nil {
|
||||
return nil, fmt.Sprintf(
|
||||
"manifest %q: invalid JSON in %s annotation: %v; moving to unsequenced batch",
|
||||
manifest.Name,
|
||||
AnnotationDependsOnResourceGroups,
|
||||
err,
|
||||
), err
|
||||
}
|
||||
|
||||
return deps, "", nil
|
||||
}
|
||||
|
||||
func firstMissingDependency(groups map[string][]Manifest, deps []string) string {
|
||||
for _, dep := range deps {
|
||||
if _, ok := groups[dep]; !ok {
|
||||
return dep
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func appendUniqueStrings(existing []string, values ...string) []string {
|
||||
seen := make(map[string]struct{}, len(existing))
|
||||
for _, value := range existing {
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
existing = append(existing, value)
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
func resourceGroupResourceID(manifest Manifest) string {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil || manifest.Head.Metadata.Name == "" {
|
||||
return manifest.Name
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s/%s/%s", manifest.Head.Version, manifest.Head.Kind, manifest.Head.Metadata.Namespace, manifest.Head.Metadata.Name)
|
||||
}
|
||||
@ -0,0 +1,381 @@
|
||||
/*
|
||||
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 util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseResourceGroups_NoAnnotations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("plain-a", "chart/templates/plain-a.yaml", nil),
|
||||
makeManifest("plain-b", "chart/templates/plain-b.yaml", nil),
|
||||
)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
assert.Empty(t, result.GroupDeps)
|
||||
require.Len(t, result.Unsequenced, 2)
|
||||
assert.Equal(t, []string{"chart/templates/plain-a.yaml", "chart/templates/plain-b.yaml"}, manifestPaths(result.Unsequenced))
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_GroupWithDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
require.Len(t, result.Groups, 2)
|
||||
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
|
||||
assert.Empty(t, result.Unsequenced)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_MultipleResourcesPerGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database-config", "chart/templates/database-config.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("database-secret", "chart/templates/database-secret.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
require.Len(t, result.Groups["database"], 2)
|
||||
assert.Equal(t, []string{
|
||||
"chart/templates/database-config.yaml",
|
||||
"chart/templates/database-secret.yaml",
|
||||
}, manifestPaths(result.Groups["database"]))
|
||||
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_ResourceAssignedToMultipleGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := ParseResourceGroups([]Manifest{
|
||||
makeManifest("shared", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("shared", "chart/templates/cache.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "cache",
|
||||
}),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "assigned to multiple resource groups")
|
||||
assert.ErrorContains(t, err, "database")
|
||||
assert.ErrorContains(t, err, "cache")
|
||||
}
|
||||
|
||||
// TestParseResourceGroups_SameNameDifferentNamespaces locks the contract
|
||||
// that two resources sharing apiVersion+kind+name but living in distinct
|
||||
// namespaces are NOT collapsed into a single resource ID. Without the
|
||||
// namespace dimension in resourceGroupResourceID, this scenario would
|
||||
// raise a false "assigned to multiple resource groups" error.
|
||||
func TestParseResourceGroups_SameNameDifferentNamespaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m1 := makeManifest("shared", "chart/templates/a.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "groupA",
|
||||
})
|
||||
m1.Head.Metadata.Namespace = "ns-a"
|
||||
m2 := makeManifest("shared", "chart/templates/b.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "groupB",
|
||||
})
|
||||
m2.Head.Metadata.Namespace = "ns-b"
|
||||
|
||||
result, warnings, err := ParseResourceGroups([]Manifest{m1, m2})
|
||||
|
||||
require.NoError(t, err, "same-name resources in different namespaces must not collide")
|
||||
assert.Empty(t, warnings)
|
||||
assert.Len(t, result.Groups["groupA"], 1)
|
||||
assert.Len(t, result.Groups["groupB"], 1)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_NonExistentGroupReferenceWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["missing"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
require.Len(t, result.Unsequenced, 1)
|
||||
assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced))
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Contains(t, warnings[0], "non-existent group")
|
||||
assert.Contains(t, warnings[0], "missing")
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_CascadingMissingDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifests := []Manifest{
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
AnnotationDependsOnResourceGroups: `["missing"]`,
|
||||
}),
|
||||
}
|
||||
|
||||
for range 64 {
|
||||
result, warnings := parseResourceGroups(t, manifests...)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
assert.Empty(t, result.GroupDeps)
|
||||
require.Len(t, result.Unsequenced, 2)
|
||||
assert.ElementsMatch(t, []string{
|
||||
"chart/templates/app.yaml",
|
||||
"chart/templates/database.yaml",
|
||||
}, manifestPaths(result.Unsequenced))
|
||||
require.Len(t, warnings, 2)
|
||||
assert.Contains(t, warnings[0], `group "database" depends-on non-existent group "missing"`)
|
||||
assert.Contains(t, warnings[1], `group "app" depends-on non-existent group "database"`)
|
||||
|
||||
dag, err := BuildResourceGroupDAG(result)
|
||||
require.NoError(t, err)
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_InvalidDependsOnJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `not-valid-json`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
require.Len(t, result.Unsequenced, 1)
|
||||
assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced))
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Contains(t, warnings[0], "invalid JSON")
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_MixedSequencedAndUnsequenced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("plain", "chart/templates/plain.yaml", nil),
|
||||
)
|
||||
|
||||
require.Len(t, result.Groups, 1)
|
||||
assert.Contains(t, result.Groups, "database")
|
||||
require.Len(t, result.Unsequenced, 1)
|
||||
assert.Equal(t, []string{"chart/templates/plain.yaml"}, manifestPaths(result.Unsequenced))
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestResourceGroupDAG_CycleDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, _ := parseResourceGroups(t,
|
||||
makeManifest("a", "chart/templates/a.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "a",
|
||||
AnnotationDependsOnResourceGroups: `["c"]`,
|
||||
}),
|
||||
makeManifest("b", "chart/templates/b.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "b",
|
||||
AnnotationDependsOnResourceGroups: `["a"]`,
|
||||
}),
|
||||
makeManifest("c", "chart/templates/c.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "c",
|
||||
AnnotationDependsOnResourceGroups: `["b"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
dag, err := BuildResourceGroupDAG(result)
|
||||
require.NoError(t, err)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, batches)
|
||||
assert.ErrorContains(t, err, "cycle")
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_DeduplicatesDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("app-config", "chart/templates/app-config.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeManifest("app-secret", "chart/templates/app-secret.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"database"}, result.GroupDeps["app"])
|
||||
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_IsolatedGroupsRemainInBatch0(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("metrics", "chart/templates/metrics.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "metrics",
|
||||
}),
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Equal(t, [][]string{{"database", "metrics"}, {"app"}}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_ComplexDAG(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("cache", "chart/templates/cache.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "cache",
|
||||
}),
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("queue", "chart/templates/queue.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "queue",
|
||||
}),
|
||||
makeManifest("api", "chart/templates/api.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "api",
|
||||
AnnotationDependsOnResourceGroups: `["database","queue"]`,
|
||||
}),
|
||||
makeManifest("worker", "chart/templates/worker.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "worker",
|
||||
AnnotationDependsOnResourceGroups: `["cache","queue"]`,
|
||||
}),
|
||||
makeManifest("frontend", "chart/templates/frontend.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "frontend",
|
||||
AnnotationDependsOnResourceGroups: `["api"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Equal(t, [][]string{
|
||||
{"cache", "database", "queue"},
|
||||
{"api", "worker"},
|
||||
{"frontend"},
|
||||
}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func parseResourceGroups(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string) {
|
||||
t.Helper()
|
||||
|
||||
result, warnings, err := ParseResourceGroups(manifests)
|
||||
require.NoError(t, err)
|
||||
|
||||
return result, warnings
|
||||
}
|
||||
|
||||
func parseResourceGroupBatches(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string, [][]string) {
|
||||
t.Helper()
|
||||
|
||||
result, warnings := parseResourceGroups(t, manifests...)
|
||||
dag, err := BuildResourceGroupDAG(result)
|
||||
require.NoError(t, err)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
|
||||
return result, warnings, batches
|
||||
}
|
||||
|
||||
func makeManifest(name, sourcePath string, annotations map[string]string) Manifest {
|
||||
var content strings.Builder
|
||||
fmt.Fprintf(&content, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: %s\n", name)
|
||||
if len(annotations) > 0 {
|
||||
content.WriteString(" annotations:\n")
|
||||
for key, value := range annotations {
|
||||
fmt.Fprintf(&content, " %s: %q\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
head := &SimpleHead{
|
||||
Version: "v1",
|
||||
Kind: "ConfigMap",
|
||||
Metadata: &struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}{
|
||||
Name: name,
|
||||
Annotations: annotations,
|
||||
},
|
||||
}
|
||||
|
||||
return Manifest{
|
||||
Name: sourcePath,
|
||||
Content: content.String(),
|
||||
Head: head,
|
||||
}
|
||||
}
|
||||
|
||||
func manifestPaths(manifests []Manifest) []string {
|
||||
paths := make([]string, 0, len(manifests))
|
||||
for _, manifest := range manifests {
|
||||
paths = append(paths, manifest.Name)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
Loading…
Reference in new issue