mirror of https://github.com/helm/helm
Implements HIP-0025 to give chart authors first-class control over deployment ordering of chart resources and subcharts. Helm operators opt in via --wait=ordered (or WaitStrategy=ordered in the SDK); default behavior for Chart v2 is unchanged. == Foundations == - DAG abstraction (pkg/chart/v2/util/dag.go) with topological batch output and cycle detection. - Resource-group annotation parsing and dependency tracking (pkg/release/v1/util/resource_group.go) for helm.sh/resource-group and helm.sh/depends-on/resource-groups; resource IDs are apiVersion/Kind/Namespace/Name to disambiguate cross-namespace. - Subchart DAG (pkg/chart/v2/util/subchart_dag.go) reading depends-on on Chart.yaml dependencies and the helm.sh/depends-on/subcharts annotation. BuildSubchartDAG inspects c.Dependencies() (post ProcessDependencies) so it correctly respects conditions, tags, and aliases - addresses joejulian's review feedback on metadata heuristics. - DependsOn []string field on chart.Dependency (pkg/chart/v2/dependency.go). - SequencingInfo metadata stored on the release object (pkg/release/v1/release.go) so rollback knows whether a revision was sequenced. - Custom readiness via helm.sh/readiness-success and helm.sh/readiness-failure JSONPath expressions (pkg/kube/readiness.go); falls back to kstatus if either is missing. Failure conditions take precedence over success. == Action integration == - pkg/action/sequencing.go: sequencedDeployment with per-batch deadline via min(), context.Done() honored at build/create/wait phases, and isolated/partial-readiness warnings emitted once per batch (not per poll tick). - Install, upgrade, rollback, and uninstall actions consume WaitStrategy=ordered. Sequenced uninstall and rollback are gated on the release's stored SequencingInfo to enforce the HIP "reverse install order" semantic. - ReadinessTimeout (default 1m) is capped by --timeout and applied per batch. - Manifest path recovery for nested subcharts on rollback/uninstall. == CLI == - --wait=ordered on install, upgrade, rollback, AND uninstall. The AddOrderedWaitFlag helper in pkg/cmd/flags.go is shared across all four commands. - --readiness-timeout flag with docstring clarifying that "ready" is determined by kstatus signals or custom readiness annotations, and that vanilla Jobs need --wait-for-jobs. - helm template emits "## START resource-group: <chart> <name>" / "## END resource-group: ..." delimiters when --wait=ordered. Falls back to flat manifest output with a warning if YAML parsing fails. == Lint == - pkg/chart/v2/lint/rules/sequencing.go: ErrorSev for circular subchart deps, partial readiness annotations, and orphan helm.sh/depends-on/resource-groups references. Empty annotation values are treated as absent (matches runtime behavior). == Tests + fixtures == - Unit tests for DAG, subchart DAG, lint rules, readiness JSONPath, resource-group parsing, sequencing action, ordered template output, and CLI flag wiring. Includes context-cancellation coverage for sequencedDeployment per joejulian's request. - Integration testchart at pkg/cmd/testdata/testcharts/sequenced-chart/ exercising parent->subchart and resource-group ordering. == Backward compatibility == Sequencing is gated on WaitStrategy == OrderedWaitStrategy. Charts without HIP-0025 annotations or --wait=ordered behave exactly as before. The depends-on field on Chart.yaml dependencies is silently accepted and unknown to upstream-stable lint (forward-compat fix to be tracked separately once HIP is accepted). Refs: HIP-0025 Addresses: joejulian and Copilot review feedback on PR #32038 Signed-off-by: Rohit Gudi <50377477+caretak3r@users.noreply.github.com>
parent
bdf2747c5f
commit
cb339cd59c
@ -0,0 +1,125 @@
|
||||
/*
|
||||
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])
|
||||
require.Len(t, client.waitCalls, 3)
|
||||
assert.Equal(t, client.createCalls[:2], client.waitCalls[:2])
|
||||
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,392 @@
|
||||
/*
|
||||
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)
|
||||
require.NotNil(t, rel.SequencingInfo)
|
||||
assert.Equal(t, *sequencingInfo, *rel.SequencingInfo)
|
||||
}
|
||||
|
||||
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.Nil(t, latestRollbackRelease(t, rollback, "rollback-to-standard").SequencingInfo)
|
||||
}
|
||||
|
||||
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.Nil(t, latestRollbackRelease(t, rollback, "rollback-transition").SequencingInfo)
|
||||
}
|
||||
|
||||
func TestRollback_SequencingInfoPreserved(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")
|
||||
require.NotNil(t, rel.SequencingInfo)
|
||||
assert.Equal(t, *sequencingInfo, *rel.SequencingInfo)
|
||||
}
|
||||
|
||||
func TestRollback_Sequenced_Failure(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 2
|
||||
client.waitError = errors.New("timed out waiting for batch")
|
||||
|
||||
rollback := newRollbackAction(t, client)
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("rollback-failure"))
|
||||
seedRollbackRelease(t, rollback, "rollback-failure", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-failure"))
|
||||
seedRollbackRelease(t, rollback, "rollback-failure", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
err := rollback.Run("rollback-failure")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out waiting for batch")
|
||||
|
||||
require.Len(t, client.updateCalls, 2)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}}, updateTargets(client.updateCalls))
|
||||
assert.Empty(t, client.deleteCalls)
|
||||
|
||||
assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-failure").Info.Status)
|
||||
assert.Equal(t, rcommon.StatusSuperseded, storedRollbackRelease(t, rollback, "rollback-failure", 2).Info.Status)
|
||||
}
|
||||
|
||||
func TestRollback_Sequenced_CleanupOnFail(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 2
|
||||
client.waitError = errors.New("timed out waiting for batch")
|
||||
|
||||
rollback := newRollbackAction(t, client)
|
||||
rollback.CleanupOnFail = true
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
}, withName("rollback-cleanup"))
|
||||
seedRollbackRelease(t, rollback, "rollback-cleanup", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("queue", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "queue",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-cleanup"))
|
||||
seedRollbackRelease(t, rollback, "rollback-cleanup", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
err := rollback.Run("rollback-cleanup")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out waiting for batch")
|
||||
assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls)
|
||||
assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-cleanup").Info.Status)
|
||||
}
|
||||
|
||||
func TestRollback_Sequenced_DryRun(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
rollback := newRollbackAction(t, client)
|
||||
rollback.DryRunStrategy = DryRunClient
|
||||
|
||||
targetChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
}, withName("rollback-dry-run"))
|
||||
seedRollbackRelease(t, rollback, "rollback-dry-run", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
|
||||
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
|
||||
configMapManifest("app", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "app",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
|
||||
|
||||
currentChart := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
makeConfigMapTemplate("templates/app.yaml", "app", nil),
|
||||
}, withName("rollback-dry-run"))
|
||||
seedRollbackRelease(t, rollback, "rollback-dry-run", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
|
||||
configMapManifest("database", nil),
|
||||
configMapManifest("app", nil),
|
||||
), nil)
|
||||
|
||||
rollback.Version = 1
|
||||
require.NoError(t, rollback.Run("rollback-dry-run"))
|
||||
|
||||
assert.Empty(t, client.updateCalls)
|
||||
assert.Empty(t, client.waitCalls)
|
||||
assert.Empty(t, client.deleteCalls)
|
||||
assert.Equal(t, 2, latestRollbackRelease(t, rollback, "rollback-dry-run").Version)
|
||||
}
|
||||
@ -0,0 +1,511 @@
|
||||
/*
|
||||
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"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chartv2 "helm.sh/helm/v4/pkg/chart/v2"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
)
|
||||
|
||||
// computeDeadline returns a deadline based on the given timeout duration.
|
||||
// If timeout is zero or negative, it returns the zero time (no deadline).
|
||||
func computeDeadline(timeout time.Duration) time.Time {
|
||||
if timeout <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Now().Add(timeout)
|
||||
}
|
||||
|
||||
// GroupManifestsByDirectSubchart groups manifests by the direct subchart they belong to.
|
||||
// The current chart's own manifests are returned under the empty string key "".
|
||||
// Subcharts are keyed by their immediate name under the first `<chartName>/charts/<subchart>/`
|
||||
// segment found in the manifest source path.
|
||||
// Nested subcharts (e.g., `<chartName>/charts/sub/charts/nested/`) are grouped under
|
||||
// the direct subchart name ("sub"), since nested sequencing is handled recursively.
|
||||
func GroupManifestsByDirectSubchart(manifests []releaseutil.Manifest, chartName string) map[string][]releaseutil.Manifest {
|
||||
result := make(map[string][]releaseutil.Manifest)
|
||||
if chartName == "" {
|
||||
// Fallback: assign everything to parent
|
||||
result[""] = append(result[""], manifests...)
|
||||
return result
|
||||
}
|
||||
|
||||
chartsPrefix := chartName + "/charts/"
|
||||
for _, m := range manifests {
|
||||
if !strings.HasPrefix(m.Name, chartsPrefix) {
|
||||
// Parent chart manifest
|
||||
result[""] = append(result[""], m)
|
||||
continue
|
||||
}
|
||||
// Extract the direct subchart name (first segment after "<chartName>/charts/")
|
||||
rest := m.Name[len(chartsPrefix):]
|
||||
// rest is like "subchart1/templates/deploy.yaml" or "subchart1/charts/nested/..."
|
||||
subchartName, _, ok := strings.Cut(rest, "/")
|
||||
if !ok {
|
||||
// Unlikely: a file directly under charts/ with no subdirectory
|
||||
result[""] = append(result[""], m)
|
||||
continue
|
||||
}
|
||||
result[subchartName] = append(result[subchartName], m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildManifestYAML concatenates the Content fields of the given manifests into a single
|
||||
// YAML stream suitable for passing to KubeClient.Build().
|
||||
func buildManifestYAML(manifests []releaseutil.Manifest) string {
|
||||
if len(manifests) == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
for i, m := range manifests {
|
||||
if i > 0 {
|
||||
buf.WriteString("---\n")
|
||||
}
|
||||
buf.WriteString(m.Content)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// sequencedDeployment performs ordered installation or upgrade of chart resources.
|
||||
// It handles the two-level DAG: first subchart ordering, then resource-group
|
||||
// ordering within each chart level.
|
||||
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
|
||||
|
||||
// Upgrade-specific fields. When upgradeMode is true, createAndWait delegates
|
||||
// to updateAndWait which calls KubeClient.Update() instead of Create().
|
||||
upgradeMode bool
|
||||
currentResources kube.ResourceList // full set of old (current) resources
|
||||
upgradeCSAFieldManager bool // upgrade client-side apply field manager
|
||||
createdResources kube.ResourceList
|
||||
}
|
||||
|
||||
// deployChartLevel deploys all resources for a single chart level in sequenced order.
|
||||
// It first handles subcharts in dependency order (recursively), then deploys the
|
||||
// parent chart's own resource-group batches.
|
||||
func (s *sequencedDeployment) deployChartLevel(ctx context.Context, chrt *chartv2.Chart, manifests []releaseutil.Manifest) error {
|
||||
// Group manifests by direct subchart
|
||||
grouped := GroupManifestsByDirectSubchart(manifests, chrt.Name())
|
||||
|
||||
// Build subchart DAG and deploy in topological order
|
||||
dag, err := chartutil.BuildSubchartDAG(chrt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building subchart DAG for %s: %w", chrt.Name(), err)
|
||||
}
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting subchart batches for %s: %w", chrt.Name(), err)
|
||||
}
|
||||
|
||||
// Deploy each subchart batch in order
|
||||
for batchIdx, batch := range batches {
|
||||
for _, subchartName := range batch {
|
||||
subManifests := grouped[subchartName]
|
||||
if len(subManifests) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the subchart chart object for recursive nested sequencing
|
||||
subChart := findSubchart(chrt, subchartName)
|
||||
if subChart == nil {
|
||||
// Subchart not found in chart object (may have been disabled or aliased differently)
|
||||
// Fall back to flat resource-group deployment for these manifests
|
||||
s.cfg.Logger().Warn("subchart not found in chart dependencies; deploying without subchart sequencing",
|
||||
"subchart", subchartName,
|
||||
"batch", batchIdx,
|
||||
)
|
||||
if err := s.deployResourceGroupBatches(ctx, subManifests); err != nil {
|
||||
return fmt.Errorf("deploying subchart %s resources: %w", subchartName, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Recursively deploy the subchart (handles its own nested subcharts and resource-groups)
|
||||
if err := s.deployChartLevel(ctx, subChart, subManifests); err != nil {
|
||||
return fmt.Errorf("deploying subchart %s: %w", subchartName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy parent chart's own resources (after all subchart batches complete)
|
||||
parentManifests := grouped[""]
|
||||
if len(parentManifests) > 0 {
|
||||
if err := s.deployResourceGroupBatches(ctx, parentManifests); err != nil {
|
||||
return fmt.Errorf("deploying %s own resources: %w", chrt.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnIfPartialReadinessAnnotations logs a warning for each resource that has
|
||||
// only one of the helm.sh/readiness-success / helm.sh/readiness-failure annotations.
|
||||
// Both annotations must be present for custom readiness evaluation to work; a resource
|
||||
// with only one will fall back to kstatus.
|
||||
func warnIfPartialReadinessAnnotations(logger *slog.Logger, manifests []releaseutil.Manifest) {
|
||||
for _, m := range manifests {
|
||||
if m.Head == nil || m.Head.Metadata == nil {
|
||||
continue
|
||||
}
|
||||
ann := m.Head.Metadata.Annotations
|
||||
_, hasSuccess := ann[kube.AnnotationReadinessSuccess]
|
||||
_, hasFailure := ann[kube.AnnotationReadinessFailure]
|
||||
if hasSuccess != hasFailure {
|
||||
logger.Warn("resource has only one readiness annotation; both helm.sh/readiness-success and helm.sh/readiness-failure must be present for custom readiness evaluation; falling back to kstatus",
|
||||
"resource", m.Head.Metadata.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// warnIfIsolatedGroups logs a warning for each resource-group that has no
|
||||
// connections to other groups (no depends-on edges and not depended on by any
|
||||
// other group). Isolated groups are valid but may indicate a misconfiguration
|
||||
// when multiple groups are present.
|
||||
func warnIfIsolatedGroups(logger *slog.Logger, result releaseutil.ResourceGroupResult) {
|
||||
if len(result.Groups) <= 1 {
|
||||
return // A single group is not isolated in a meaningful sense
|
||||
}
|
||||
for groupName := range result.Groups {
|
||||
deps := result.GroupDeps[groupName]
|
||||
isDependedOn := false
|
||||
for _, otherDeps := range result.GroupDeps {
|
||||
if slices.Contains(otherDeps, groupName) {
|
||||
isDependedOn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(deps) == 0 && !isDependedOn {
|
||||
logger.Warn("resource-group is isolated with no connections to other groups; if sequencing is intended, add helm.sh/depends-on/resource-groups annotation to related groups",
|
||||
"group", groupName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func batchHasCustomReadiness(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[kube.AnnotationReadinessSuccess] != "" || annotations[kube.AnnotationReadinessFailure] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deployResourceGroupBatches deploys manifests for a single chart level using
|
||||
// resource-group annotation DAG ordering. Resources without group annotations
|
||||
// (or with invalid ones) are deployed last.
|
||||
func (s *sequencedDeployment) deployResourceGroupBatches(ctx context.Context, manifests []releaseutil.Manifest) error {
|
||||
if len(manifests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Emit warnings for misconfigured annotations before deploying.
|
||||
warnIfPartialReadinessAnnotations(s.cfg.Logger(), manifests)
|
||||
|
||||
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing resource-group annotations: %w", err)
|
||||
}
|
||||
for _, w := range warnings {
|
||||
s.cfg.Logger().Warn("resource-group annotation warning", "warning", w)
|
||||
}
|
||||
|
||||
// If there are sequenced groups, build their DAG and deploy in order
|
||||
if len(result.Groups) > 0 {
|
||||
// Warn about groups that have no connections to other groups.
|
||||
warnIfIsolatedGroups(s.cfg.Logger(), result)
|
||||
|
||||
dag, err := releaseutil.BuildResourceGroupDAG(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building resource-group DAG: %w", err)
|
||||
}
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting resource-group batches: %w", err)
|
||||
}
|
||||
|
||||
for _, groupBatch := range batches {
|
||||
var batchManifests []releaseutil.Manifest
|
||||
for _, groupName := range groupBatch {
|
||||
batchManifests = append(batchManifests, result.Groups[groupName]...)
|
||||
}
|
||||
if err := s.createAndWait(ctx, batchManifests); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy unsequenced resources last
|
||||
if len(result.Unsequenced) > 0 {
|
||||
if err := s.createAndWait(ctx, result.Unsequenced); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// helmSequencingAnnotations lists annotation keys used internally by Helm for
|
||||
// resource sequencing. These are stripped from resources before applying to
|
||||
// Kubernetes because some (e.g. helm.sh/depends-on/resource-groups) contain
|
||||
// multiple slashes which is invalid per the K8s annotation key format.
|
||||
var helmSequencingAnnotations = []string{
|
||||
releaseutil.AnnotationDependsOnResourceGroups,
|
||||
}
|
||||
|
||||
// stripSequencingAnnotations removes Helm-internal sequencing annotations from
|
||||
// resources before they are applied to Kubernetes. This prevents K8s API
|
||||
// validation errors for annotation keys that are not valid K8s label keys.
|
||||
func stripSequencingAnnotations(resources kube.ResourceList) error {
|
||||
return resources.Visit(func(info *resource.Info, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acc, accErr := meta.Accessor(info.Object)
|
||||
if accErr != nil {
|
||||
return nil // skip non-accessible objects
|
||||
}
|
||||
annotations := acc.GetAnnotations()
|
||||
if len(annotations) == 0 {
|
||||
return nil
|
||||
}
|
||||
changed := false
|
||||
for _, key := range helmSequencingAnnotations {
|
||||
if _, exists := annotations[key]; exists {
|
||||
delete(annotations, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
acc.SetAnnotations(annotations)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// createAndWait creates (or updates, in upgrade mode) a set of manifest resources
|
||||
// and waits for them to be ready. It respects both the per-batch readiness timeout
|
||||
// and the overall operation deadline.
|
||||
func (s *sequencedDeployment) createAndWait(ctx context.Context, manifests []releaseutil.Manifest) error {
|
||||
if s.upgradeMode {
|
||||
return s.updateAndWait(ctx, manifests)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if len(manifests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
yaml := buildManifestYAML(manifests)
|
||||
resources, err := s.cfg.KubeClient.Build(bytes.NewBufferString(yaml), !s.disableOpenAPI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building resource batch: %w", err)
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := resources.Visit(setMetadataVisitor(s.releaseName, s.releaseNamespace, true)); err != nil {
|
||||
return fmt.Errorf("setting metadata for resource batch: %w", err)
|
||||
}
|
||||
|
||||
if err := stripSequencingAnnotations(resources); err != nil {
|
||||
return fmt.Errorf("stripping sequencing annotations: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
result, err := s.cfg.KubeClient.Create(resources, kube.ClientCreateOptionServerSideApply(s.serverSideApply, false))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating resource batch: %w", err)
|
||||
}
|
||||
s.createdResources = append(s.createdResources, result.Created...)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
return s.waitForResources(resources, manifests)
|
||||
}
|
||||
|
||||
// updateAndWait applies an upgrade batch using KubeClient.Update() and waits for readiness.
|
||||
// It matches current (old) resources by objectKey to compute the per-batch diff.
|
||||
func (s *sequencedDeployment) updateAndWait(_ context.Context, manifests []releaseutil.Manifest) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// Find the subset of current (old) resources that are represented in this batch.
|
||||
// Update() will handle creates (target resources not in matchingCurrent) and
|
||||
// updates (resources in both). Deletions are handled separately after all batches.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := s.cfg.KubeClient.Update(
|
||||
matchingCurrent,
|
||||
target,
|
||||
kube.ClientUpdateOptionForceReplace(s.forceReplace),
|
||||
kube.ClientUpdateOptionServerSideApply(s.serverSideApply, s.forceConflicts),
|
||||
kube.ClientUpdateOptionUpgradeClientSideFieldManager(s.upgradeCSAFieldManager),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating resource batch: %w", err)
|
||||
}
|
||||
s.createdResources = append(s.createdResources, result.Created...)
|
||||
|
||||
return s.waitForResources(target, manifests)
|
||||
}
|
||||
|
||||
// waitForResources waits for the given resources to become ready,
|
||||
// applying the per-batch and overall deadline constraints.
|
||||
func (s *sequencedDeployment) waitForResources(resources kube.ResourceList, manifests []releaseutil.Manifest) error {
|
||||
// Determine effective wait timeout: min(readinessTimeout, remaining time to overall deadline)
|
||||
waitTimeout := s.readinessTimeout
|
||||
if !s.deadline.IsZero() {
|
||||
remaining := time.Until(s.deadline)
|
||||
if remaining <= 0 {
|
||||
return 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
|
||||
}
|
||||
|
||||
var err error
|
||||
var waiter kube.Waiter
|
||||
waitOptions := append([]kube.WaitOption(nil), s.waitOptions...)
|
||||
if batchHasCustomReadiness(manifests) {
|
||||
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)
|
||||
}
|
||||
|
||||
// findSubchart finds the subchart chart object within chrt's direct dependencies by name or alias.
|
||||
// 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 *chartv2.Chart, nameOrAlias string) *chartv2.Chart {
|
||||
// Build a map from subchart chart name → alias from the parent's dependency declarations.
|
||||
aliasMap := make(map[string]string) // chart name → effective name (alias or name)
|
||||
if chrt.Metadata != nil {
|
||||
for _, dep := range chrt.Metadata.Dependencies {
|
||||
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
|
||||
}
|
||||
@ -0,0 +1,724 @@
|
||||
/*
|
||||
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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
restfake "k8s.io/client-go/rest/fake"
|
||||
|
||||
"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"
|
||||
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"
|
||||
)
|
||||
|
||||
type recordingKubeClient struct {
|
||||
kubefake.PrintingKubeClient
|
||||
createCalls [][]string
|
||||
updateCalls []updateCall
|
||||
waitCalls [][]string
|
||||
watchUntilReadyCalls [][]string
|
||||
deleteWaitCalls [][]string
|
||||
deleteCalls [][]string
|
||||
operations []string
|
||||
waitCallCount int
|
||||
waitErrorOnCall int
|
||||
waitError error
|
||||
onBuild func()
|
||||
onCreate func()
|
||||
}
|
||||
|
||||
type updateCall struct {
|
||||
current []string
|
||||
target []string
|
||||
created []string
|
||||
}
|
||||
|
||||
type recordingKubeWaiter struct {
|
||||
client *recordingKubeClient
|
||||
}
|
||||
|
||||
func newRecordingKubeClient() *recordingKubeClient {
|
||||
return &recordingKubeClient{
|
||||
PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: io.Discard},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *recordingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) {
|
||||
if c.onCreate != nil {
|
||||
c.onCreate()
|
||||
}
|
||||
ids := resourceIDs(resources)
|
||||
c.createCalls = append(c.createCalls, ids)
|
||||
c.operations = append(c.operations, "create:"+strings.Join(ids, ","))
|
||||
return &kube.Result{Created: resources}, nil
|
||||
}
|
||||
|
||||
func (c *recordingKubeClient) Delete(resources kube.ResourceList, deletionPropagation metav1.DeletionPropagation) (*kube.Result, []error) {
|
||||
ids := resourceIDs(resources)
|
||||
c.deleteCalls = append(c.deleteCalls, ids)
|
||||
c.operations = append(c.operations, "delete:"+strings.Join(ids, ","))
|
||||
return c.PrintingKubeClient.Delete(resources, deletionPropagation)
|
||||
}
|
||||
|
||||
func (c *recordingKubeClient) Update(current, target kube.ResourceList, _ ...kube.ClientUpdateOption) (*kube.Result, error) {
|
||||
currentIDs := resourceIDs(current)
|
||||
targetIDs := resourceIDs(target)
|
||||
|
||||
currentSet := make(map[string]struct{}, len(currentIDs))
|
||||
for _, id := range currentIDs {
|
||||
currentSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
var created kube.ResourceList
|
||||
var createdIDs []string
|
||||
for _, info := range target {
|
||||
id := fmt.Sprintf("%s/%s", info.Object.GetObjectKind().GroupVersionKind().Kind, info.Name)
|
||||
if _, exists := currentSet[id]; !exists {
|
||||
created = append(created, info)
|
||||
createdIDs = append(createdIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
c.updateCalls = append(c.updateCalls, updateCall{
|
||||
current: currentIDs,
|
||||
target: targetIDs,
|
||||
created: createdIDs,
|
||||
})
|
||||
c.operations = append(c.operations, "update:"+strings.Join(targetIDs, ","))
|
||||
return &kube.Result{Updated: target, Created: created}, nil
|
||||
}
|
||||
|
||||
func (c *recordingKubeClient) Build(reader io.Reader, _ bool) (kube.ResourceList, error) {
|
||||
if c.onBuild != nil {
|
||||
c.onBuild()
|
||||
}
|
||||
decoder := yamlutil.NewYAMLOrJSONDecoder(reader, 4096)
|
||||
var resources kube.ResourceList
|
||||
|
||||
for {
|
||||
var obj map[string]any
|
||||
if err := decoder.Decode(&obj); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(obj) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
u := &unstructured.Unstructured{Object: obj}
|
||||
gvk := u.GroupVersionKind()
|
||||
namespace := u.GetNamespace()
|
||||
if namespace == "" {
|
||||
namespace = "spaced"
|
||||
u.SetNamespace(namespace)
|
||||
}
|
||||
info := &resource.Info{
|
||||
Name: u.GetName(),
|
||||
Namespace: namespace,
|
||||
Object: u,
|
||||
Mapping: &meta.RESTMapping{
|
||||
Resource: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"},
|
||||
GroupVersionKind: gvk,
|
||||
Scope: meta.RESTScopeNamespace,
|
||||
},
|
||||
Client: newNotFoundRESTClient(u.GetName(), gvk),
|
||||
}
|
||||
resources.Append(info)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func (c *recordingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) {
|
||||
return c.GetWaiterWithOptions(ws)
|
||||
}
|
||||
|
||||
func (c *recordingKubeClient) GetWaiterWithOptions(_ kube.WaitStrategy, _ ...kube.WaitOption) (kube.Waiter, error) {
|
||||
return &recordingKubeWaiter{client: c}, nil
|
||||
}
|
||||
|
||||
func (w *recordingKubeWaiter) Wait(resources kube.ResourceList, _ time.Duration) error {
|
||||
return w.client.recordWait(resources)
|
||||
}
|
||||
|
||||
func (w *recordingKubeWaiter) WaitWithJobs(resources kube.ResourceList, _ time.Duration) error {
|
||||
return w.client.recordWait(resources)
|
||||
}
|
||||
|
||||
func (w *recordingKubeWaiter) WaitForDelete(resources kube.ResourceList, _ time.Duration) error {
|
||||
ids := resourceIDs(resources)
|
||||
w.client.deleteWaitCalls = append(w.client.deleteWaitCalls, ids)
|
||||
w.client.operations = append(w.client.operations, "wait-delete:"+strings.Join(ids, ","))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *recordingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error {
|
||||
ids := resourceIDs(resources)
|
||||
w.client.watchUntilReadyCalls = append(w.client.watchUntilReadyCalls, ids)
|
||||
w.client.operations = append(w.client.operations, "watch:"+strings.Join(ids, ","))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *recordingKubeClient) recordWait(resources kube.ResourceList) error {
|
||||
c.waitCallCount++
|
||||
ids := resourceIDs(resources)
|
||||
c.waitCalls = append(c.waitCalls, ids)
|
||||
c.operations = append(c.operations, "wait:"+strings.Join(ids, ","))
|
||||
if c.waitError != nil && c.waitCallCount == c.waitErrorOnCall {
|
||||
return c.waitError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newNotFoundRESTClient(name string, gvk schema.GroupVersionKind) *restfake.RESTClient {
|
||||
body, _ := json.Marshal(metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Reason: metav1.StatusReasonNotFound,
|
||||
Code: http.StatusNotFound,
|
||||
Details: &metav1.StatusDetails{
|
||||
Name: name,
|
||||
Group: gvk.Group,
|
||||
Kind: gvk.Kind,
|
||||
},
|
||||
})
|
||||
|
||||
return &restfake.RESTClient{
|
||||
GroupVersion: gvk.GroupVersion(),
|
||||
NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
|
||||
Client: restfake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func resourceIDs(resources kube.ResourceList) []string {
|
||||
ids := make([]string, 0, len(resources))
|
||||
for _, info := range resources {
|
||||
kind := info.Object.GetObjectKind().GroupVersionKind().Kind
|
||||
if kind == "" {
|
||||
kind = "Unknown"
|
||||
}
|
||||
ids = append(ids, fmt.Sprintf("%s/%s", kind, info.Name))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func newSequencedInstallAction(t *testing.T, kubeClient kube.Interface) *Install {
|
||||
t.Helper()
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.KubeClient = kubeClient
|
||||
|
||||
install := NewInstall(cfg)
|
||||
install.Namespace = "spaced"
|
||||
install.ReleaseName = "test-install-release"
|
||||
install.Timeout = 5 * time.Minute
|
||||
install.ReadinessTimeout = time.Minute
|
||||
install.WaitStrategy = kube.OrderedWaitStrategy
|
||||
return install
|
||||
}
|
||||
|
||||
func releaseFile(name, content string) *common.File {
|
||||
return &common.File{Name: name, ModTime: time.Now(), Data: []byte(content)}
|
||||
}
|
||||
|
||||
func configMapManifest(name string, annotations map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("apiVersion: v1\nkind: ConfigMap\nmetadata:\n")
|
||||
_, _ = fmt.Fprintf(&b, " name: %s\n", name)
|
||||
if len(annotations) > 0 {
|
||||
b.WriteString(" annotations:\n")
|
||||
for key, value := range annotations {
|
||||
_, _ = fmt.Fprintf(&b, " %s: %q\n", key, value)
|
||||
}
|
||||
}
|
||||
b.WriteString("data:\n key: value\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func makeConfigMapTemplate(fileName, name string, annotations map[string]string) *common.File {
|
||||
return releaseFile(fileName, configMapManifest(name, annotations))
|
||||
}
|
||||
|
||||
func makeHookTemplate(fileName, name, hook string, extraAnnotations map[string]string) *common.File {
|
||||
annotations := map[string]string{release.HookAnnotation: hook}
|
||||
maps.Copy(annotations, extraAnnotations)
|
||||
return makeConfigMapTemplate(fileName, name, annotations)
|
||||
}
|
||||
|
||||
func mustRelease(t *testing.T, rel ri.Releaser) *release.Release {
|
||||
t.Helper()
|
||||
out, err := releaserToV1Release(rel)
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSequencing_GroupManifestsByDirectSubchart(t *testing.T) {
|
||||
manifests := []releaseutil.Manifest{
|
||||
makeTestManifest("parent", "parent/templates/one.yaml", nil),
|
||||
makeTestManifest("db", "parent/charts/database/templates/one.yaml", nil),
|
||||
makeTestManifest("cache", "parent/charts/database/charts/cache/templates/one.yaml", nil),
|
||||
}
|
||||
|
||||
grouped := GroupManifestsByDirectSubchart(manifests, "parent")
|
||||
|
||||
require.Len(t, grouped[""], 1)
|
||||
require.Len(t, grouped["database"], 2)
|
||||
}
|
||||
|
||||
func TestSequencing_BuildManifestYAML(t *testing.T) {
|
||||
yaml := buildManifestYAML([]releaseutil.Manifest{
|
||||
makeTestManifest("one", "chart/templates/one.yaml", nil),
|
||||
makeTestManifest("two", "chart/templates/two.yaml", nil),
|
||||
})
|
||||
|
||||
assert.Contains(t, yaml, "name: one")
|
||||
assert.Contains(t, yaml, "name: two")
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_BasicResourceGroups(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(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"]`,
|
||||
}),
|
||||
})
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.createCalls)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
|
||||
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
||||
assert.NotNil(t, rel.SequencingInfo)
|
||||
assert.True(t, rel.SequencingInfo.Enabled)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_SubchartOrdering(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
parent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
database := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
||||
}, withName("database"))
|
||||
api := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/api.yaml", "api", nil),
|
||||
}, withName("api"))
|
||||
|
||||
parent.AddDependency(database, api)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "database"},
|
||||
{Name: "api", DependsOn: []string{"database"}},
|
||||
}
|
||||
|
||||
mustRunInstall(t, install, parent)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/api"}, {"ConfigMap/parent"}}, client.createCalls)
|
||||
assert.Equal(t, client.createCalls, client.waitCalls)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_Combined(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
parent := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
||||
}, withName("parent"))
|
||||
database := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/setup.yaml", "db-setup", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "setup",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/data.yaml", "db-data", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "data",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["setup"]`,
|
||||
}),
|
||||
}, withName("database"))
|
||||
api := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/api.yaml", "api", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "api",
|
||||
}),
|
||||
}, withName("api"))
|
||||
|
||||
parent.AddDependency(database, api)
|
||||
parent.Metadata.Dependencies = []*chart.Dependency{
|
||||
{Name: "database"},
|
||||
{Name: "api", DependsOn: []string{"database"}},
|
||||
}
|
||||
|
||||
mustRunInstall(t, install, parent)
|
||||
|
||||
assert.Equal(t, [][]string{
|
||||
{"ConfigMap/db-setup"},
|
||||
{"ConfigMap/db-data"},
|
||||
{"ConfigMap/api"},
|
||||
{"ConfigMap/parent"},
|
||||
}, client.createCalls)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_NoAnnotations(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/first.yaml", "first", nil),
|
||||
makeConfigMapTemplate("templates/second.yaml", "second", nil),
|
||||
})
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
||||
|
||||
require.Len(t, client.createCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/first", "ConfigMap/second"}, client.createCalls[0])
|
||||
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_UnsequencedLast(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/orphan.yaml", "orphan", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "orphan",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["missing"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/plain.yaml", "plain", nil),
|
||||
})
|
||||
|
||||
mustRunInstall(t, install, ch)
|
||||
|
||||
require.Len(t, client.createCalls, 2)
|
||||
assert.Equal(t, []string{"ConfigMap/database"}, client.createCalls[0])
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/orphan", "ConfigMap/plain"}, client.createCalls[1])
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_ReadinessTimeout(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 1
|
||||
client.waitError = errors.New("timed out waiting for batch")
|
||||
|
||||
install := newSequencedInstallAction(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"]`,
|
||||
}),
|
||||
})
|
||||
|
||||
rel, err := install.Run(ch, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out waiting for batch")
|
||||
assert.Len(t, client.createCalls, 1)
|
||||
|
||||
last, getErr := install.cfg.Releases.Last(install.ReleaseName)
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, rcommon.StatusFailed, mustRelease(t, last).Info.Status)
|
||||
assert.Equal(t, rcommon.StatusFailed, mustRelease(t, rel).Info.Status)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_AtomicRollback(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
client.waitErrorOnCall = 1
|
||||
client.waitError = errors.New("timed out waiting for batch")
|
||||
|
||||
install := newSequencedInstallAction(t, client)
|
||||
install.RollbackOnFailure = 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"]`,
|
||||
}),
|
||||
})
|
||||
|
||||
_, err := install.Run(ch, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "rollback-on-failure")
|
||||
assert.NotEmpty(t, client.deleteCalls)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_DryRun(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
install.DryRunStrategy = DryRunServer
|
||||
|
||||
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"]`,
|
||||
}),
|
||||
})
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
||||
assert.Empty(t, client.createCalls)
|
||||
assert.Empty(t, client.waitCalls)
|
||||
assert.Equal(t, "Dry run complete", rel.Info.Description)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_CyclicDependency(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/a.yaml", "a", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "a",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["b"]`,
|
||||
}),
|
||||
makeConfigMapTemplate("templates/b.yaml", "b", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "b",
|
||||
releaseutil.AnnotationDependsOnResourceGroups: `["a"]`,
|
||||
}),
|
||||
})
|
||||
|
||||
_, err := install.Run(ch, nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "cycle") || strings.Contains(err.Error(), "circular"))
|
||||
assert.Empty(t, client.createCalls)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_SequencingInfoStored(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeConfigMapTemplate("templates/config.yaml", "config", nil),
|
||||
})
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
||||
|
||||
require.NotNil(t, rel.SequencingInfo)
|
||||
assert.True(t, rel.SequencingInfo.Enabled)
|
||||
assert.Equal(t, string(kube.OrderedWaitStrategy), rel.SequencingInfo.Strategy)
|
||||
|
||||
stored, err := install.cfg.Releases.Get(rel.Name, rel.Version)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(kube.OrderedWaitStrategy), mustRelease(t, stored).SequencingInfo.Strategy)
|
||||
}
|
||||
|
||||
func TestInstall_NonSequenced_Unchanged(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
install.WaitStrategy = kube.StatusWatcherStrategy
|
||||
|
||||
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"]`,
|
||||
}),
|
||||
})
|
||||
|
||||
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
||||
|
||||
require.Len(t, client.createCalls, 1)
|
||||
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.createCalls[0])
|
||||
assert.Nil(t, rel.SequencingInfo)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_HookResourcesExcludedFromDAG(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeHookTemplate("templates/pre-hook.yaml", "pre-hook", release.HookPreInstall.String(), map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
})
|
||||
|
||||
mustRunInstall(t, install, ch)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/database"}}, client.createCalls)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}}, client.watchUntilReadyCalls)
|
||||
}
|
||||
|
||||
func TestInstall_Sequenced_HooksAtStandardPositions(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
install := newSequencedInstallAction(t, client)
|
||||
|
||||
ch := buildChartWithTemplates([]*common.File{
|
||||
makeHookTemplate("templates/pre-hook.yaml", "pre-hook", release.HookPreInstall.String(), map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeHookTemplate("templates/post-hook.yaml", "post-hook", release.HookPostInstall.String(), map[string]string{
|
||||
releaseutil.AnnotationResourceGroup: "database",
|
||||
}),
|
||||
})
|
||||
|
||||
mustRunInstall(t, install, ch)
|
||||
|
||||
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/database"}, {"ConfigMap/post-hook"}}, client.createCalls)
|
||||
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.watchUntilReadyCalls)
|
||||
}
|
||||
|
||||
func TestSequencing_WarnIfPartialReadinessAnnotations(t *testing.T) {
|
||||
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) })
|
||||
|
||||
warnIfPartialReadinessAnnotations(slog.Default(), []releaseutil.Manifest{
|
||||
makeTestManifest("cm", "chart/templates/cm.yaml", map[string]string{
|
||||
kube.AnnotationReadinessSuccess: `["{.ready} == true"]`,
|
||||
}),
|
||||
})
|
||||
|
||||
assert.Contains(t, buf.String(), "readiness")
|
||||
}
|
||||
|
||||
func TestSequencedDeployment_CreateAndWait_RespectsContextCancellation(t *testing.T) {
|
||||
newSequencedDeploymentForTest := func(client kube.Interface) *sequencedDeployment {
|
||||
t.Helper()
|
||||
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.KubeClient = client
|
||||
|
||||
return &sequencedDeployment{
|
||||
cfg: cfg,
|
||||
releaseName: "demo",
|
||||
releaseNamespace: "spaced",
|
||||
waitStrategy: kube.OrderedWaitStrategy,
|
||||
readinessTimeout: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("context canceled before build", func(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
sd := newSequencedDeploymentForTest(client)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := sd.createAndWait(ctx, []releaseutil.Manifest{
|
||||
makeTestManifest("cm", "chart/templates/cm.yaml", nil),
|
||||
})
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
assert.Empty(t, client.createCalls)
|
||||
assert.Empty(t, client.waitCalls)
|
||||
})
|
||||
|
||||
t.Run("context canceled after build before create", func(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
client.onBuild = cancel
|
||||
|
||||
sd := newSequencedDeploymentForTest(client)
|
||||
|
||||
err := sd.createAndWait(ctx, []releaseutil.Manifest{
|
||||
makeTestManifest("cm", "chart/templates/cm.yaml", nil),
|
||||
})
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
assert.Empty(t, client.createCalls)
|
||||
assert.Empty(t, client.waitCalls)
|
||||
})
|
||||
|
||||
t.Run("context canceled after create before wait", func(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
client.onCreate = cancel
|
||||
|
||||
sd := newSequencedDeploymentForTest(client)
|
||||
|
||||
err := sd.createAndWait(ctx, []releaseutil.Manifest{
|
||||
makeTestManifest("cm", "chart/templates/cm.yaml", nil),
|
||||
})
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Len(t, client.createCalls, 1)
|
||||
assert.Empty(t, client.waitCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func makeTestManifest(name, sourcePath string, annotations map[string]string) releaseutil.Manifest {
|
||||
content := configMapManifest(name, annotations)
|
||||
head := &releaseutil.SimpleHead{
|
||||
Version: v1.SchemeGroupVersion.String(),
|
||||
Kind: "ConfigMap",
|
||||
}
|
||||
head.Metadata = &struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}{
|
||||
Name: name,
|
||||
Annotations: annotations,
|
||||
}
|
||||
return releaseutil.Manifest{Name: sourcePath, Content: content, Head: head}
|
||||
}
|
||||
|
||||
func mustRunInstall(t *testing.T, install *Install, ch *chart.Chart) ri.Releaser {
|
||||
t.Helper()
|
||||
rel, err := install.RunWithContext(context.Background(), ch, map[string]any{})
|
||||
require.NoError(t, err)
|
||||
return rel
|
||||
}
|
||||
@ -0,0 +1,413 @@
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"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 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,
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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(
|
||||
configMapManifest("nginx", nil),
|
||||
configMapManifest("bar", nil),
|
||||
configMapManifest("parent", nil),
|
||||
),
|
||||
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
|
||||
)
|
||||
seedUninstallRelease(t, uninstall, rel)
|
||||
|
||||
_, err := uninstall.Run(rel.Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Nested subcharts within a subchart's subtree are flattened into one delete
|
||||
// batch because GroupManifestsByDirectSubchart receives full-path manifest
|
||||
// names ("parent/charts/bar/charts/nginx/...") that don't match the nested
|
||||
// chart prefix ("bar/charts/"). This is a known limitation.
|
||||
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/nginx", "ConfigMap/bar"}}, client.deleteCalls)
|
||||
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
|
||||
}
|
||||
|
||||
func TestUninstall_Sequenced_KeepPolicy(t *testing.T) {
|
||||
client := newRecordingKubeClient()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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)
|
||||
}
|
||||
@ -0,0 +1,459 @@
|
||||
/*
|
||||
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)
|
||||
require.NotNil(t, rel.SequencingInfo)
|
||||
assert.True(t, rel.SequencingInfo.Enabled)
|
||||
assert.Equal(t, string(kube.OrderedWaitStrategy), rel.SequencingInfo.Strategy)
|
||||
}
|
||||
|
||||
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",
|
||||
}, client.operations)
|
||||
}
|
||||
|
||||
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))
|
||||
require.NotNil(t, rel.SequencingInfo)
|
||||
assert.True(t, rel.SequencingInfo.Enabled)
|
||||
}
|
||||
|
||||
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,113 @@
|
||||
/*
|
||||
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, "only one readiness annotation")
|
||||
assert.Contains(t, output, "resource=app")
|
||||
}
|
||||
|
||||
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 is isolated")
|
||||
assert.Contains(t, output, "group=database")
|
||||
assert.Contains(t, output, "group=cache")
|
||||
}
|
||||
|
||||
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, "resource-group annotation warning")
|
||||
assert.Contains(t, output, "group")
|
||||
assert.Contains(t, output, "app")
|
||||
assert.Contains(t, output, "non-existent group")
|
||||
assert.Contains(t, output, "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,210 @@
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"path"
|
||||
"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"
|
||||
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 {
|
||||
// Chart load errors are already reported by other lint rules.
|
||||
return
|
||||
}
|
||||
|
||||
// ProcessDependencies must run before validateSubchartSequencing:
|
||||
// it prunes disabled subcharts from c.Dependencies() and applies
|
||||
// alias renames, which BuildSubchartDAG relies on.
|
||||
if err := chartutil.ProcessDependencies(c, values); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateSubchartSequencing(c))
|
||||
validateRenderedSequencingAnnotations(linter, c, namespace, values)
|
||||
}
|
||||
|
||||
func validateSubchartSequencing(c *chart.Chart) error {
|
||||
if c.Metadata == nil || len(c.Metadata.Dependencies) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dag, err := chartutil.BuildSubchartDAG(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dag.GetBatches(); err != nil {
|
||||
return fmt.Errorf("subchart circular dependency detected: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRenderedSequencingAnnotations(linter *support.Linter, c *chart.Chart, namespace string, values map[string]any) {
|
||||
if err := chartutil.ProcessDependencies(c, values); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
options := common.ReleaseOptions{
|
||||
Name: "test-release",
|
||||
Namespace: namespace,
|
||||
}
|
||||
caps := common.DefaultCapabilities.Copy()
|
||||
|
||||
coalescedValues, err := commonutil.CoalesceValues(c, values)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
valuesToRender, err := commonutil.ToRenderValues(c, coalescedValues, options, caps)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var renderEngine engine.Engine
|
||||
renderEngine.LintMode = true
|
||||
|
||||
renderedContentMap, err := renderEngine.Render(c, valuesToRender)
|
||||
if err != nil {
|
||||
// Template rendering errors are already reported by the Templates lint rule.
|
||||
return
|
||||
}
|
||||
|
||||
manifestsByChart := make(map[string][]releaseutil.Manifest)
|
||||
for templatePath, content := range renderedContentMap {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
chartPath := renderedTemplateChartPath(templatePath)
|
||||
for _, manifest := range parseRenderedManifests(content) {
|
||||
validateReadinessAnnotations(linter, templatePath, manifest)
|
||||
manifestsByChart[chartPath] = append(manifestsByChart[chartPath], manifest)
|
||||
}
|
||||
}
|
||||
|
||||
for chartPath, manifests := range manifestsByChart {
|
||||
validateResourceGroupAnnotations(linter, chartPath, manifests)
|
||||
}
|
||||
}
|
||||
|
||||
func renderedTemplateChartPath(templatePath string) string {
|
||||
if chartPath, _, ok := strings.Cut(templatePath, "/templates/"); ok {
|
||||
return chartPath
|
||||
}
|
||||
|
||||
return path.Dir(templatePath)
|
||||
}
|
||||
|
||||
func parseRenderedManifests(content string) []releaseutil.Manifest {
|
||||
rawManifests := releaseutil.SplitManifests(content)
|
||||
manifests := make([]releaseutil.Manifest, 0, len(rawManifests))
|
||||
|
||||
for manifestName, raw := range rawManifests {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var head releaseutil.SimpleHead
|
||||
if err := yaml.Unmarshal([]byte(raw), &head); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if head.Metadata != nil && head.Metadata.Name != "" {
|
||||
manifestName = head.Metadata.Name
|
||||
}
|
||||
|
||||
manifests = append(manifests, releaseutil.Manifest{
|
||||
Name: manifestName,
|
||||
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
|
||||
hasSuccess := strings.TrimSpace(annotations[kube.AnnotationReadinessSuccess]) != ""
|
||||
hasFailure := strings.TrimSpace(annotations[kube.AnnotationReadinessFailure]) != ""
|
||||
if hasSuccess == hasFailure {
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
func validateResourceGroupAnnotations(linter *support.Linter, chartPath string, manifests []releaseutil.Manifest) {
|
||||
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
|
||||
// HIP-0025: lint must fail on orphan resource-group dependencies and
|
||||
// malformed annotation JSON. Runtime falls back to the unsequenced batch
|
||||
// for graceful recovery, but the chart author should fix these at lint time.
|
||||
for _, warning := range warnings {
|
||||
linter.RunLinterRule(support.ErrorSev, chartPath, fmt.Errorf("%s", warning))
|
||||
}
|
||||
if err != nil {
|
||||
linter.RunLinterRule(support.ErrorSev, chartPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
dag, err := releaseutil.BuildResourceGroupDAG(result)
|
||||
if err != nil {
|
||||
linter.RunLinterRule(support.ErrorSev, chartPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := dag.GetBatches(); err != nil {
|
||||
linter.RunLinterRule(support.ErrorSev, chartPath, fmt.Errorf("resource-group circular dependency detected: %w", 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,238 @@
|
||||
/*
|
||||
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_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_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",
|
||||
},
|
||||
}
|
||||
|
||||
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_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,129 @@
|
||||
/*
|
||||
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 so that
|
||||
// c.Dependencies() reflects the post-processed state (disabled subcharts
|
||||
// pruned, aliases applied).
|
||||
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
|
||||
}
|
||||
|
||||
byName := make(map[string]bool, len(c.Metadata.Dependencies))
|
||||
for _, dep := range c.Metadata.Dependencies {
|
||||
if dep == nil {
|
||||
continue
|
||||
}
|
||||
name := effectiveDependencyName(dep)
|
||||
if loaded[name] {
|
||||
byName[name] = true
|
||||
dag.AddNode(name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, dep := range c.Metadata.Dependencies {
|
||||
if dep == nil {
|
||||
continue
|
||||
}
|
||||
name := effectiveDependencyName(dep)
|
||||
if !byName[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, prerequisite := range dep.DependsOn {
|
||||
if err := addSubchartEdge(dag, byName, name, prerequisite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateParentSubchartDependencies(c.Metadata.Annotations[AnnotationDependsOnSubcharts], byName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dag, nil
|
||||
}
|
||||
|
||||
func validateParentSubchartDependencies(annotation string, byName 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 !byName[prerequisite] {
|
||||
return fmt.Errorf("annotation %s references unknown or disabled subchart %q", AnnotationDependsOnSubcharts, prerequisite)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addSubchartEdge(dag *DAG, byName map[string]bool, subchartName, prerequisite string) error {
|
||||
if !byName[prerequisite] {
|
||||
return fmt.Errorf("subchart %q depends-on unknown or disabled subchart %q", subchartName, prerequisite)
|
||||
}
|
||||
if err := dag.AddEdge(prerequisite, subchartName); err != nil {
|
||||
return fmt.Errorf("adding sequencing edge %s→%s: %w", prerequisite, subchartName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveDependencyName(dep *chart.Dependency) string {
|
||||
if dep.Alias != "" {
|
||||
return dep.Alias
|
||||
}
|
||||
return dep.Name
|
||||
}
|
||||
@ -0,0 +1,332 @@
|
||||
/*
|
||||
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()
|
||||
|
||||
c := newChart("parent",
|
||||
aliasedDependency("postgres", "primary-db"),
|
||||
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_MetadataOnlyNoLoadedDeps locks in the post-rewrite
|
||||
// contract: when c.Metadata.Dependencies is non-empty but c.Dependencies()
|
||||
// is empty (e.g., chart loaded but ProcessDependencies disabled everything),
|
||||
// the DAG should have no nodes and produce no error.
|
||||
func TestBuildSubchartDAG_MetadataOnlyNoLoadedDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "ghost", Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Note: no AddDependency call — c.Dependencies() is empty.
|
||||
|
||||
batches := batchesForChart(t, c)
|
||||
assert.Empty(t, batches, "no loaded 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,
|
||||
}
|
||||
}
|
||||
|
||||
func aliasedDependency(name, alias string, dependsOn ...string) *chart.Dependency {
|
||||
return &chart.Dependency{
|
||||
Name: name,
|
||||
Alias: alias,
|
||||
Enabled: true,
|
||||
DependsOn: dependsOn,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
## 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
|
||||
helm.sh/depends-on/resource-groups: '["databases"]'
|
||||
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,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,41 @@
|
||||
/*
|
||||
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))
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
/*
|
||||
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"
|
||||
|
||||
"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{}
|
||||
|
||||
func newCustomReadinessStatusReader() engine.StatusReader {
|
||||
return &customReadinessStatusReader{}
|
||||
}
|
||||
|
||||
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(resource, u)
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) ReadStatusForObject(_ context.Context, _ engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
identifier, err := object.RuntimeToObjMeta(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.readStatus(identifier, resource)
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) readStatus(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, err := EvaluateCustomReadiness(resource, successExprs, failureExprs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if useKstatus {
|
||||
return computeKstatusStatus(identifier, resource)
|
||||
}
|
||||
|
||||
switch result {
|
||||
case ReadinessReady:
|
||||
return &event.ResourceStatus{
|
||||
Identifier: identifier,
|
||||
Status: status.CurrentStatus,
|
||||
Message: "custom readiness conditions met",
|
||||
}, nil
|
||||
case ReadinessFailed:
|
||||
return &event.ResourceStatus{
|
||||
Identifier: identifier,
|
||||
Status: status.FailedStatus,
|
||||
Message: "custom readiness failure condition met",
|
||||
}, nil
|
||||
default:
|
||||
return &event.ResourceStatus{
|
||||
Identifier: identifier,
|
||||
Status: status.InProgressStatus,
|
||||
Message: "waiting for custom readiness conditions",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func computeKstatusStatus(identifier object.ObjMetadata, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
result, err := status.Compute(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &event.ResourceStatus{
|
||||
Identifier: identifier,
|
||||
Status: result.Status,
|
||||
Message: result.Message,
|
||||
}, nil
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
/*
|
||||
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 (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
|
||||
"github.com/fluxcd/cli-utils/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
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
|
||||
}{
|
||||
{
|
||||
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
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
readers: []engine.StatusReader{newCustomReadinessStatusReader()},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCustomReadinessStatusReader(t *testing.T) {
|
||||
opts := &waitOptions{}
|
||||
WithCustomReadinessStatusReader()(opts)
|
||||
assert.True(t, opts.enableCustomReadinessStatusReader)
|
||||
}
|
||||
|
||||
func TestCustomReadinessStatusReaderReadStatusForObject(t *testing.T) {
|
||||
reader := newCustomReadinessStatusReader()
|
||||
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)
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
/*
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
// 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.
|
||||
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"
|
||||
)
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func EvaluateCustomReadiness(obj *unstructured.Unstructured, successExprs, failureExprs []string) (ReadinessStatus, bool, 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 per batch by warnIfPartialReadinessAnnotations
|
||||
// in the sequencing layer, not here (which runs on every poll tick).
|
||||
return ReadinessPending, true, nil
|
||||
}
|
||||
|
||||
statusObj, found, err := unstructured.NestedMap(obj.Object, "status")
|
||||
if err != nil || !found {
|
||||
return ReadinessPending, false, nil
|
||||
}
|
||||
|
||||
statusWrapper := map[string]any{"status": statusObj}
|
||||
|
||||
for _, expr := range failureExprs {
|
||||
met, err := evaluateExpression(statusWrapper, expr)
|
||||
if err != nil {
|
||||
return ReadinessPending, false, fmt.Errorf("evaluating failure expression %q: %w", expr, err)
|
||||
}
|
||||
if met {
|
||||
return ReadinessFailed, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, expr := range successExprs {
|
||||
met, err := evaluateExpression(statusWrapper, expr)
|
||||
if err != nil {
|
||||
return ReadinessPending, false, fmt.Errorf("evaluating success expression %q: %w", expr, err)
|
||||
}
|
||||
if met {
|
||||
return ReadinessReady, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ReadinessPending, false, 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
|
||||
}
|
||||
|
||||
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("operator %q not supported for boolean values", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "==":
|
||||
return actual == expected, nil
|
||||
case "!=":
|
||||
return actual != expected, nil
|
||||
default:
|
||||
return false, fmt.Errorf("operator %q not supported for string values", op)
|
||||
}
|
||||
}
|
||||
|
||||
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,350 @@
|
||||
/*
|
||||
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"
|
||||
"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, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{tt.expr},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
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, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.phase} == "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
|
||||
result, useKstatus, err = EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} != "Failed"`},
|
||||
[]string{`{.phase} == "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
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 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")
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
/*
|
||||
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)
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
/*
|
||||
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"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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,356 @@
|
||||
/*
|
||||
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")
|
||||
}
|
||||
|
||||
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