mirror of https://github.com/helm/helm
Resource-group annotation parsing into a DAG, the SequencingInfo field recorded on releases for sequenced uninstall/rollback, and the helm-internal annotation stripping helper. Backward-compatible JSON round-trip for releases stored without SequencingInfo. Refs: HIP-0025 Signed-off-by: Rohit Gudi <50377477+caretak3r@users.noreply.github.com>pull/32314/head
parent
31d35d4e46
commit
7fe983e2ea
@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReleaseSequencingInfoBackwardCompatibility(t *testing.T) {
|
||||||
|
release := Release{Name: "demo"}
|
||||||
|
|
||||||
|
data, err := json.Marshal(&release)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(data), "sequencing_info")
|
||||||
|
|
||||||
|
var decoded Release
|
||||||
|
err = json.Unmarshal([]byte(`{"name":"demo"}`), &decoded)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, decoded.SequencingInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseSequencingInfoRoundTrip(t *testing.T) {
|
||||||
|
release := Release{
|
||||||
|
Name: "demo",
|
||||||
|
SequencingInfo: &SequencingInfo{
|
||||||
|
Enabled: true,
|
||||||
|
Strategy: "ordered",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(&release)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(data), `"sequencing_info"`)
|
||||||
|
|
||||||
|
var decoded Release
|
||||||
|
err = json.Unmarshal(data, &decoded)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, decoded.SequencingInfo)
|
||||||
|
assert.True(t, decoded.SequencingInfo.Enabled)
|
||||||
|
assert.Equal(t, "ordered", decoded.SequencingInfo.Strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseIsSequencedLegacyDecode(t *testing.T) {
|
||||||
|
var decoded Release
|
||||||
|
err := json.Unmarshal([]byte(`{"name":"demo","sequencing_info":{"enabled":true}}`), &decoded)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, decoded.Sequenced)
|
||||||
|
assert.True(t, decoded.IsSequenced())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseSequencedRoundTrip(t *testing.T) {
|
||||||
|
release := Release{Name: "demo", Sequenced: true}
|
||||||
|
data, err := json.Marshal(&release)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(data), `"sequenced":true`)
|
||||||
|
|
||||||
|
var decoded Release
|
||||||
|
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||||
|
assert.True(t, decoded.Sequenced)
|
||||||
|
assert.True(t, decoded.IsSequenced())
|
||||||
|
assert.Nil(t, decoded.SequencingInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseNotSequencedOmitted(t *testing.T) {
|
||||||
|
release := Release{Name: "demo"}
|
||||||
|
data, err := json.Marshal(&release)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Non-sequenced release JSON is byte-identical to pre-schema-change output.
|
||||||
|
assert.NotContains(t, string(data), `"sequenced"`)
|
||||||
|
assert.NotContains(t, string(data), `"sequencing_info"`)
|
||||||
|
assert.False(t, release.IsSequenced())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseIsSequencedDisabledLegacyInfo(t *testing.T) {
|
||||||
|
release := Release{Name: "demo", SequencingInfo: &SequencingInfo{Enabled: false}}
|
||||||
|
assert.False(t, release.IsSequenced())
|
||||||
|
}
|
||||||
@ -0,0 +1,328 @@
|
|||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sequence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||||
|
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||||
|
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type builder struct {
|
||||||
|
plan *Plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupManifestsByDirectSubchart groups manifests by the direct subchart they belong to.
|
||||||
|
// chartPath is the full path-prefix for the current chart level — at the top level
|
||||||
|
// it is the chart name (e.g. "parent"); at deeper recursion levels it is the joined
|
||||||
|
// path through "/charts/" segments (e.g. "parent/charts/sub").
|
||||||
|
// The current chart level's own manifests are returned under the empty string key "".
|
||||||
|
// Direct subcharts are keyed by their immediate directory name under
|
||||||
|
// "<chartPath>/charts/<subchart>/". Nested grandchildren are grouped under their
|
||||||
|
// direct subchart parent ("sub"), since nested sequencing is handled recursively.
|
||||||
|
func GroupManifestsByDirectSubchart(manifests []releaseutil.Manifest, chartPath string) map[string][]releaseutil.Manifest {
|
||||||
|
result := make(map[string][]releaseutil.Manifest)
|
||||||
|
if chartPath == "" {
|
||||||
|
result[""] = append(result[""], manifests...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
chartsPrefix := chartPath + "/charts/"
|
||||||
|
for _, m := range manifests {
|
||||||
|
if !strings.HasPrefix(m.Name, chartsPrefix) {
|
||||||
|
result[""] = append(result[""], m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := m.Name[len(chartsPrefix):]
|
||||||
|
subchartName, _, ok := strings.Cut(rest, "/")
|
||||||
|
if !ok {
|
||||||
|
result[""] = append(result[""], m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[subchartName] = append(result[subchartName], m)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSubchart resolves a direct dependency by effective name (alias if set,
|
||||||
|
// else name) — the single home for the chart-walker subchart lookup. It uses
|
||||||
|
// the parent chart's Metadata.Dependencies to resolve aliases, since the alias
|
||||||
|
// is stored on the Dependency struct in Chart.yaml, not on the subchart's own
|
||||||
|
// Metadata.
|
||||||
|
func FindSubchart(chrt *chart.Chart, nameOrAlias string) *chart.Chart {
|
||||||
|
if chrt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
aliasMap := make(map[string]string) // chart name → effective name (alias or name)
|
||||||
|
if chrt.Metadata != nil {
|
||||||
|
for _, dep := range chrt.Metadata.Dependencies {
|
||||||
|
if dep == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
effective := dep.Name
|
||||||
|
if dep.Alias != "" {
|
||||||
|
effective = dep.Alias
|
||||||
|
}
|
||||||
|
aliasMap[dep.Name] = effective
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dep := range chrt.Dependencies() {
|
||||||
|
effective := dep.Name()
|
||||||
|
if alias, ok := aliasMap[dep.Name()]; ok {
|
||||||
|
effective = alias
|
||||||
|
}
|
||||||
|
if effective == nameOrAlias || dep.Name() == nameOrAlias {
|
||||||
|
return dep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build constructs the deployment plan for chrt's rendered manifests.
|
||||||
|
//
|
||||||
|
// Preconditions: manifests are hook-free (SortManifests output or a stored
|
||||||
|
// rel.Manifest — hooks are stored separately) and chrt has been through
|
||||||
|
// ProcessDependencies (true for both freshly-loaded and storage-decoded
|
||||||
|
// charts, per BuildSubchartDAG's contract). Build does not re-filter hooks.
|
||||||
|
// chrt == nil yields a flat single-level plan at ChartPath "".
|
||||||
|
//
|
||||||
|
// Errors (fatal — the caller must not proceed):
|
||||||
|
// - subchart DAG construction failure: depends-on referencing unknown/disabled
|
||||||
|
// subcharts, malformed helm.sh/depends-on/subcharts JSON (BuildSubchartDAG)
|
||||||
|
// - cycle in a subchart DAG or a resource-group DAG (any level)
|
||||||
|
// - a resource assigned to two different groups (ParseResourceGroups)
|
||||||
|
//
|
||||||
|
// Warnings (non-fatal, returned in Plan.Warnings; the manifest is still placed):
|
||||||
|
// - invalid depends-on/resource-groups JSON on a resource → demoted to unsequenced
|
||||||
|
// - group referencing a missing group → whole group demoted (transitively)
|
||||||
|
// - isolated group among ≥2 groups → demoted to unsequenced
|
||||||
|
// - resource with only one of the two readiness annotations (falls back to kstatus)
|
||||||
|
// - rendered subchart not declared in Chart.yaml (deployed after declared subcharts)
|
||||||
|
// - rendered subchart not resolvable to a chart object (flat fallback at its path)
|
||||||
|
//
|
||||||
|
// Postcondition: every input manifest appears in exactly one batch;
|
||||||
|
// len(manifests) == Σ len(batch.Manifests()). Unit-enforced.
|
||||||
|
//
|
||||||
|
// Build is deterministic: identical input yields an identical plan.
|
||||||
|
func Build(chrt *chart.Chart, manifests []releaseutil.Manifest) (*Plan, error) {
|
||||||
|
b := &builder{plan: &Plan{}}
|
||||||
|
if chrt == nil {
|
||||||
|
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: "", Depth: 0})
|
||||||
|
if err := b.appendResourceGroupBatches("", 0, manifests); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.buildLevel(chrt, manifests, chrt.Name(), 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) warnf(chartPath, format string, args ...any) {
|
||||||
|
b.plan.Warnings = append(b.plan.Warnings, Warning{
|
||||||
|
ChartPath: chartPath,
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) buildLevel(c *chart.Chart, manifests []releaseutil.Manifest, chartPath string, depth int) error {
|
||||||
|
levelIdx := len(b.plan.Levels)
|
||||||
|
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: chartPath, Depth: depth})
|
||||||
|
|
||||||
|
grouped := GroupManifestsByDirectSubchart(manifests, chartPath)
|
||||||
|
|
||||||
|
dag, err := chartutil.BuildSubchartDAG(c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("building subchart DAG for %s: %w", chartPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batches, err := dag.GetBatches()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting subchart batches for %s: %w", chartPath, err)
|
||||||
|
}
|
||||||
|
b.plan.Levels[levelIdx].SubchartBatches = batches
|
||||||
|
|
||||||
|
declared := make(map[string]bool, len(batches))
|
||||||
|
for _, batch := range batches {
|
||||||
|
for _, name := range batch {
|
||||||
|
declared[name] = true
|
||||||
|
if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range slices.Sorted(maps.Keys(grouped)) {
|
||||||
|
if name == "" || declared[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.plan.Levels[levelIdx].Undeclared = append(b.plan.Levels[levelIdx].Undeclared, name)
|
||||||
|
b.warnf(chartPath, "rendered subchart %q is not declared in Chart.yaml dependencies; sequencing it after declared subcharts", name)
|
||||||
|
if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(b.plan.Levels[levelIdx].Unresolved)
|
||||||
|
|
||||||
|
return b.appendResourceGroupBatches(chartPath, depth, grouped[""])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) buildSubchart(parent *chart.Chart, chartPath, name string, manifests []releaseutil.Manifest, depth, parentLevelIdx int) error {
|
||||||
|
if len(manifests) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subPath := chartPath + "/charts/" + name
|
||||||
|
sub := FindSubchart(parent, name)
|
||||||
|
if sub == nil {
|
||||||
|
b.plan.Levels[parentLevelIdx].Unresolved = append(b.plan.Levels[parentLevelIdx].Unresolved, name)
|
||||||
|
b.warnf(chartPath, "subchart %q not found in chart dependencies; deploying its manifests without subchart sequencing", name)
|
||||||
|
return b.appendResourceGroupBatches(subPath, depth+1, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.buildLevel(sub, manifests, subPath, depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) appendResourceGroupBatches(chartPath string, depth int, manifests []releaseutil.Manifest) error {
|
||||||
|
if len(manifests) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, manifest := range manifests {
|
||||||
|
if manifest.Head == nil || manifest.Head.Metadata == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
annotations := manifest.Head.Metadata.Annotations
|
||||||
|
hasSuccess := readinessAnnotationSet(annotations, releaseutil.AnnotationReadinessSuccess)
|
||||||
|
hasFailure := readinessAnnotationSet(annotations, releaseutil.AnnotationReadinessFailure)
|
||||||
|
if hasSuccess != hasFailure {
|
||||||
|
b.warnf(chartPath, "resource %q has only one of %s and %s; falling back to kstatus readiness", manifest.Head.Metadata.Name, releaseutil.AnnotationReadinessSuccess, releaseutil.AnnotationReadinessFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing resource-group annotations for %s: %w", chartPath, err)
|
||||||
|
}
|
||||||
|
for _, warning := range warnings {
|
||||||
|
b.plan.Warnings = append(b.plan.Warnings, Warning{
|
||||||
|
ChartPath: chartPath,
|
||||||
|
Message: warning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsequenced := result.Unsequenced
|
||||||
|
if len(result.Groups) >= 2 {
|
||||||
|
dependents := resourceGroupDependents(result.GroupDeps)
|
||||||
|
for _, groupName := range slices.Sorted(maps.Keys(result.Groups)) {
|
||||||
|
if len(result.GroupDeps[groupName]) != 0 || dependents[groupName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.warnf(chartPath, "resource-group %q is isolated (no depends-on edges and no dependents); deploying it in the unsequenced batch after sequenced groups", groupName)
|
||||||
|
unsequenced = append(unsequenced, result.Groups[groupName]...)
|
||||||
|
delete(result.Groups, groupName)
|
||||||
|
delete(result.GroupDeps, groupName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Groups) > 0 {
|
||||||
|
dag, err := releaseutil.BuildResourceGroupDAG(result)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("building resource-group DAG for %s: %w", chartPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groupBatches, err := dag.GetBatches()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting resource-group batches for %s: %w", chartPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependents := resourceGroupDependents(result.GroupDeps)
|
||||||
|
for _, groupBatch := range groupBatches {
|
||||||
|
batch := Batch{
|
||||||
|
ChartPath: chartPath,
|
||||||
|
Depth: depth,
|
||||||
|
Kind: BatchKindGroups,
|
||||||
|
Wait: true,
|
||||||
|
}
|
||||||
|
for _, groupName := range groupBatch {
|
||||||
|
batch.Groups = append(batch.Groups, Group{
|
||||||
|
Name: groupName,
|
||||||
|
Manifests: result.Groups[groupName],
|
||||||
|
})
|
||||||
|
if !dependents[groupName] {
|
||||||
|
batch.LeafGroups = append(batch.LeafGroups, groupName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch.HasCustomReadiness = hasCustomReadiness(batch.Manifests())
|
||||||
|
b.plan.Batches = append(b.plan.Batches, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unsequenced) > 0 {
|
||||||
|
b.plan.Batches = append(b.plan.Batches, Batch{
|
||||||
|
ChartPath: chartPath,
|
||||||
|
Depth: depth,
|
||||||
|
Kind: BatchKindUnsequenced,
|
||||||
|
Groups: []Group{{Name: "", Manifests: unsequenced}},
|
||||||
|
Wait: true,
|
||||||
|
HasCustomReadiness: hasCustomReadiness(unsequenced),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceGroupDependents(groupDeps map[string][]string) map[string]bool {
|
||||||
|
dependents := make(map[string]bool)
|
||||||
|
for _, deps := range groupDeps {
|
||||||
|
for _, dep := range deps {
|
||||||
|
dependents[dep] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dependents
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCustomReadiness(manifests []releaseutil.Manifest) bool {
|
||||||
|
for _, manifest := range manifests {
|
||||||
|
if manifest.Head == nil || manifest.Head.Metadata == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
annotations := manifest.Head.Metadata.Annotations
|
||||||
|
if annotations == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if readinessAnnotationSet(annotations, releaseutil.AnnotationReadinessSuccess) && readinessAnnotationSet(annotations, releaseutil.AnnotationReadinessFailure) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// readinessAnnotationSet reports whether a readiness annotation carries a
|
||||||
|
// usable value. Whitespace-only values count as unset, matching
|
||||||
|
// parseReadinessExpressions in pkg/kube, which trims and discards empty
|
||||||
|
// expressions at evaluation time.
|
||||||
|
func readinessAnnotationSet(annotations map[string]string, key string) bool {
|
||||||
|
return strings.TrimSpace(annotations[key]) != ""
|
||||||
|
}
|
||||||
@ -0,0 +1,759 @@
|
|||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sequence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||||
|
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||||
|
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild_NilChart_FlatPlan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")),
|
||||||
|
makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")),
|
||||||
|
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(nil, manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []ChartLevel{{Path: "", Depth: 0}}, plan.Levels)
|
||||||
|
assert.Equal(t, []batchSummary{
|
||||||
|
{ChartPath: "", Depth: 0, Kind: BatchKindGroups, Groups: []string{"db"}},
|
||||||
|
{ChartPath: "", Depth: 0, Kind: BatchKindGroups, Groups: []string{"app"}},
|
||||||
|
{ChartPath: "", Depth: 0, Kind: BatchKindUnsequenced, Groups: []string{""}},
|
||||||
|
}, batchSummaries(plan))
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_EmptyChart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []ChartLevel{{Path: "parent", Depth: 0}}, plan.Levels)
|
||||||
|
assert.Empty(t, plan.Batches)
|
||||||
|
assert.Empty(t, plan.Warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_NoAnnotations_SingleFlatBatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("a", "parent/templates/a.yaml", nil),
|
||||||
|
makeManifest("b", "parent/templates/b.yaml", nil),
|
||||||
|
makeManifest("c", "parent/templates/c.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Batches, 1)
|
||||||
|
assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind)
|
||||||
|
assert.Equal(t, manifestNames(manifests), manifestNames(plan.Batches[0].Manifests()))
|
||||||
|
assert.True(t, plan.Batches[0].Wait)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_ResourceGroupOrdering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")),
|
||||||
|
makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")),
|
||||||
|
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []batchSummary{
|
||||||
|
{ChartPath: "parent", Depth: 0, Kind: BatchKindGroups, Groups: []string{"db"}},
|
||||||
|
{ChartPath: "parent", Depth: 0, Kind: BatchKindGroups, Groups: []string{"app"}},
|
||||||
|
{ChartPath: "parent", Depth: 0, Kind: BatchKindUnsequenced, Groups: []string{""}},
|
||||||
|
}, batchSummaries(plan))
|
||||||
|
for _, batch := range plan.Batches {
|
||||||
|
assert.True(t, batch.Wait)
|
||||||
|
}
|
||||||
|
assert.Empty(t, plan.Batches[0].LeafGroups)
|
||||||
|
assert.Equal(t, []string{"app"}, plan.Batches[1].LeafGroups)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_LeafGroups_Diamond(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("base", "parent/templates/base.yaml", groupAnnotations("base")),
|
||||||
|
makeManifest("left", "parent/templates/left.yaml", groupAnnotations("left", "base")),
|
||||||
|
makeManifest("right", "parent/templates/right.yaml", groupAnnotations("right", "base")),
|
||||||
|
makeManifest("top", "parent/templates/top.yaml", groupAnnotations("top", "left", "right")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, [][]string{{"base"}, {"left", "right"}, {"top"}}, batchGroupNames(plan))
|
||||||
|
assert.Empty(t, plan.Batches[0].LeafGroups)
|
||||||
|
assert.Empty(t, plan.Batches[1].LeafGroups)
|
||||||
|
assert.Equal(t, []string{"top"}, plan.Batches[2].LeafGroups)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_NestedSubcharts_ThreeLevels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
grand := newChart("grand")
|
||||||
|
child := newChart("child", enabledDependency("grand"))
|
||||||
|
child.SetDependencies(grand)
|
||||||
|
parent := newChart("parent", enabledDependency("child"))
|
||||||
|
parent.SetDependencies(child)
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("parent", "parent/templates/parent.yaml", nil),
|
||||||
|
makeManifest("child", "parent/charts/child/templates/child.yaml", nil),
|
||||||
|
makeManifest("grand", "parent/charts/child/charts/grand/templates/grand.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(parent, manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"parent/charts/child/charts/grand",
|
||||||
|
"parent/charts/child",
|
||||||
|
"parent",
|
||||||
|
}, batchChartPaths(plan))
|
||||||
|
assert.Equal(t, []int{2, 1, 0}, batchDepths(plan))
|
||||||
|
assert.Equal(t, []ChartLevel{
|
||||||
|
{Path: "parent", Depth: 0, SubchartBatches: [][]string{{"child"}}},
|
||||||
|
{Path: "parent/charts/child", Depth: 1, SubchartBatches: [][]string{{"grand"}}},
|
||||||
|
{Path: "parent/charts/child/charts/grand", Depth: 2},
|
||||||
|
}, plan.Levels)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_SubchartDependencyOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := newChart(
|
||||||
|
"parent",
|
||||||
|
enabledDependency("postgres"),
|
||||||
|
enabledDependency("rabbitmq", "postgres"),
|
||||||
|
enabledDependency("app", "rabbitmq"),
|
||||||
|
)
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("app", "parent/charts/app/templates/app.yaml", nil),
|
||||||
|
makeManifest("rabbitmq", "parent/charts/rabbitmq/templates/rabbitmq.yaml", nil),
|
||||||
|
makeManifest("postgres", "parent/charts/postgres/templates/postgres.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(parent, manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"parent/charts/postgres", "parent/charts/rabbitmq", "parent/charts/app"}, batchChartPaths(plan))
|
||||||
|
require.Len(t, plan.Levels, 4)
|
||||||
|
assert.Equal(t, [][]string{{"postgres"}, {"rabbitmq"}, {"app"}}, plan.Levels[0].SubchartBatches)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_Aliases_RealPipeline(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := pipelineChart(
|
||||||
|
pipelineDependency("postgres", "primary-db"),
|
||||||
|
pipelineDependency("app", "", "postgres"),
|
||||||
|
)
|
||||||
|
require.NoError(t, chartutil.ProcessDependencies(parent, map[string]any{}))
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("app", "parent/charts/app/templates/app.yaml", nil),
|
||||||
|
makeManifest("primary", "parent/charts/primary-db/templates/primary.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(parent, manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"parent/charts/primary-db", "parent/charts/app"}, batchChartPaths(plan))
|
||||||
|
require.NotEmpty(t, plan.Levels)
|
||||||
|
assert.Equal(t, [][]string{{"primary-db"}, {"app"}}, plan.Levels[0].SubchartBatches)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_SubchartCycle_Fatal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := newChart("parent", enabledDependency("a", "b"), enabledDependency("b", "a"))
|
||||||
|
plan, err := Build(parent, []releaseutil.Manifest{makeManifest("a", "parent/charts/a/templates/a.yaml", nil)})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, plan)
|
||||||
|
assert.Contains(t, err.Error(), "cycle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_UnknownDependsOnRef_Fatal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := newChart("parent", enabledDependency("app", "missing"))
|
||||||
|
plan, err := Build(parent, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, plan)
|
||||||
|
assert.Contains(t, err.Error(), `depends-on unknown or disabled subchart "missing"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_ResourceGroupCycle_Fatal_NestedLevel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := newChart("parent", enabledDependency("child"))
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("a", "parent/charts/child/templates/a.yaml", groupAnnotations("a", "b")),
|
||||||
|
makeManifest("b", "parent/charts/child/templates/b.yaml", groupAnnotations("b", "a")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(parent, manifests)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, plan)
|
||||||
|
assert.Contains(t, err.Error(), "cycle")
|
||||||
|
assert.Contains(t, err.Error(), "parent/charts/child")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_MultiGroupAssignment_Fatal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeTypedManifest("same", "parent/templates/one.yaml", "v1", "ConfigMap", "default", groupAnnotations("one")),
|
||||||
|
makeTypedManifest("same", "parent/templates/two.yaml", "v1", "ConfigMap", "default", groupAnnotations("two")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, plan)
|
||||||
|
assert.Contains(t, err.Error(), "assigned to multiple resource groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_IsolatedGroupDemoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")),
|
||||||
|
makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")),
|
||||||
|
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||||
|
makeManifest("metrics", "parent/templates/metrics.yaml", groupAnnotations("metrics")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, [][]string{{"db"}, {"app"}, {""}}, batchGroupNames(plan))
|
||||||
|
assert.NotContains(t, slices.Concat(batchGroupNames(plan)[:2]...), "metrics")
|
||||||
|
require.Len(t, plan.Batches, 3)
|
||||||
|
assert.Equal(t, BatchKindUnsequenced, plan.Batches[2].Kind)
|
||||||
|
assert.ElementsMatch(t, []string{"parent/templates/plain.yaml", "parent/templates/metrics.yaml"}, manifestNames(plan.Batches[2].Manifests()))
|
||||||
|
assertWarningContains(t, plan, "parent", "isolated", "metrics")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_TwoGroupChain_NotDemoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")),
|
||||||
|
makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, [][]string{{"db"}, {"app"}}, batchGroupNames(plan))
|
||||||
|
assertNoWarningContains(t, plan, "isolated")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_SingleGroup_NotDemoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("solo", "parent/templates/solo.yaml", groupAnnotations("solo")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, [][]string{{"solo"}}, batchGroupNames(plan))
|
||||||
|
assert.Equal(t, BatchKindGroups, plan.Batches[0].Kind)
|
||||||
|
assertNoWarningContains(t, plan, "isolated")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_AllGroupsIsolated_AllDemoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("beta", "parent/templates/beta.yaml", groupAnnotations("beta")),
|
||||||
|
makeManifest("alpha", "parent/templates/alpha.yaml", groupAnnotations("alpha")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Batches, 1)
|
||||||
|
assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind)
|
||||||
|
assert.Equal(t, []string{"parent/templates/alpha.yaml", "parent/templates/beta.yaml"}, manifestNames(plan.Batches[0].Manifests()))
|
||||||
|
require.Len(t, plan.Warnings, 2)
|
||||||
|
assert.Contains(t, plan.Warnings[0].Message, "alpha")
|
||||||
|
assert.Contains(t, plan.Warnings[1].Message, "beta")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_MissingGroupDep_TransitiveDemotion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "nope")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Batches, 1)
|
||||||
|
assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind)
|
||||||
|
assert.Equal(t, []string{"parent/templates/app.yaml"}, manifestNames(plan.Batches[0].Manifests()))
|
||||||
|
assertWarningContains(t, plan, "parent", "depends-on non-existent group")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_InvalidDependsOnJSON_Demoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
annotations := groupAnnotations("app")
|
||||||
|
annotations[releaseutil.AnnotationDependsOnResourceGroups] = "not-json"
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("app", "parent/templates/app.yaml", annotations),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Batches, 1)
|
||||||
|
assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind)
|
||||||
|
assert.Equal(t, []string{"parent/templates/app.yaml"}, manifestNames(plan.Batches[0].Manifests()))
|
||||||
|
assertWarningContains(t, plan, "parent", "invalid JSON")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_UndeclaredSubchartIncluded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := newChart("parent", enabledDependency("declared"))
|
||||||
|
parent.AddDependency(newChart("vendored"))
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("declared", "parent/charts/declared/templates/declared.yaml", nil),
|
||||||
|
makeManifest("vendored", "parent/charts/vendored/templates/vendored.yaml", nil),
|
||||||
|
makeManifest("parent", "parent/templates/parent.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(parent, manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"parent/charts/declared", "parent/charts/vendored", "parent"}, batchChartPaths(plan))
|
||||||
|
require.NotEmpty(t, plan.Levels)
|
||||||
|
assert.Equal(t, []string{"vendored"}, plan.Levels[0].Undeclared)
|
||||||
|
assertWarningContains(t, plan, "parent", "not declared")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
|
||||||
|
reversedPaths := batchChartPaths(plan.Reverse())
|
||||||
|
assert.Contains(t, reversedPaths, "parent/charts/vendored")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_UnresolvableSubchart_FlatFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("db", "parent/charts/ghost/templates/db.yaml", groupAnnotations("db")),
|
||||||
|
makeManifest("app", "parent/charts/ghost/templates/app.yaml", groupAnnotations("app", "db")),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertWarningContains(t, plan, "parent", "not found in chart dependencies")
|
||||||
|
require.Len(t, plan.Levels, 1)
|
||||||
|
assert.Equal(t, []string{"ghost"}, plan.Levels[0].Unresolved)
|
||||||
|
assert.Equal(t, []string{"parent/charts/ghost", "parent/charts/ghost"}, batchChartPaths(plan))
|
||||||
|
assert.Equal(t, []int{1, 1}, batchDepths(plan))
|
||||||
|
assert.Equal(t, [][]string{{"db"}, {"app"}}, batchGroupNames(plan))
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_HookManifestNotFiltered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("hook", "parent/templates/hook.yaml", map[string]string{"helm.sh/hook": "pre-install"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Batches, 1)
|
||||||
|
assert.Equal(t, []string{"parent/templates/hook.yaml"}, manifestNames(plan.Batches[0].Manifests()))
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_HasCustomReadiness(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
groupA := groupAnnotations("a")
|
||||||
|
groupA[releaseutil.AnnotationReadinessSuccess] = `{.status.ready} == true`
|
||||||
|
groupA[releaseutil.AnnotationReadinessFailure] = `{.status.failed} == true`
|
||||||
|
groupB := groupAnnotations("b", "a")
|
||||||
|
groupB[releaseutil.AnnotationReadinessSuccess] = `{.status.ready} == true`
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("a", "parent/templates/a.yaml", groupA),
|
||||||
|
makeManifest("b", "parent/templates/b.yaml", groupB),
|
||||||
|
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Batches, 3)
|
||||||
|
assert.True(t, plan.Batches[0].HasCustomReadiness)
|
||||||
|
assert.False(t, plan.Batches[1].HasCustomReadiness)
|
||||||
|
assert.False(t, plan.Batches[2].HasCustomReadiness)
|
||||||
|
assertWarningContains(t, plan, "parent", "only one of")
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_WhitespaceReadinessAnnotationsAreUnset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Whitespace-only values on both annotations: no custom readiness and no
|
||||||
|
// partial-readiness warning.
|
||||||
|
groupA := groupAnnotations("a")
|
||||||
|
groupA[releaseutil.AnnotationReadinessSuccess] = " "
|
||||||
|
groupA[releaseutil.AnnotationReadinessFailure] = ""
|
||||||
|
// Whitespace-only success with a real failure expression is partial.
|
||||||
|
groupB := groupAnnotations("b", "a")
|
||||||
|
groupB[releaseutil.AnnotationReadinessSuccess] = " "
|
||||||
|
groupB[releaseutil.AnnotationReadinessFailure] = `{.status.failed} == true`
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("a", "parent/templates/a.yaml", groupA),
|
||||||
|
makeManifest("b", "parent/templates/b.yaml", groupB),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Batches, 2)
|
||||||
|
assert.False(t, plan.Batches[0].HasCustomReadiness)
|
||||||
|
assert.False(t, plan.Batches[1].HasCustomReadiness)
|
||||||
|
require.Len(t, plan.Warnings, 1)
|
||||||
|
assertWarningContains(t, plan, "parent", "only one of", `"b"`)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_Deterministic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
chartOne, manifestsOne := kitchenSinkFixture()
|
||||||
|
chartTwo, manifestsTwo := kitchenSinkFixture()
|
||||||
|
|
||||||
|
planOne, err := Build(chartOne, manifestsOne)
|
||||||
|
require.NoError(t, err)
|
||||||
|
planTwo, err := Build(chartTwo, manifestsTwo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, planOne, planTwo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_Completeness_KitchenSink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
chrt, manifests := kitchenSinkFixture()
|
||||||
|
plan, err := Build(chrt, manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertPlanComplete(t, plan, manifests)
|
||||||
|
for _, batch := range plan.Batches {
|
||||||
|
assert.NotEmpty(t, batch.Manifests())
|
||||||
|
if batch.Kind == BatchKindUnsequenced {
|
||||||
|
require.Len(t, batch.Groups, 1)
|
||||||
|
assert.Empty(t, batch.Groups[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchSummary struct {
|
||||||
|
ChartPath string
|
||||||
|
Depth int
|
||||||
|
Kind BatchKind
|
||||||
|
Groups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeBatch(batch Batch) batchSummary {
|
||||||
|
return batchSummary{
|
||||||
|
ChartPath: batch.ChartPath,
|
||||||
|
Depth: batch.Depth,
|
||||||
|
Kind: batch.Kind,
|
||||||
|
Groups: groupNames(batch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchSummaries(plan *Plan) []batchSummary {
|
||||||
|
summaries := make([]batchSummary, 0, len(plan.Batches))
|
||||||
|
for _, batch := range plan.Batches {
|
||||||
|
summaries = append(summaries, summarizeBatch(batch))
|
||||||
|
}
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchGroupNames(plan *Plan) [][]string {
|
||||||
|
names := make([][]string, 0, len(plan.Batches))
|
||||||
|
for _, batch := range plan.Batches {
|
||||||
|
names = append(names, groupNames(batch))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchChartPaths(plan *Plan) []string {
|
||||||
|
paths := make([]string, 0, len(plan.Batches))
|
||||||
|
for _, batch := range plan.Batches {
|
||||||
|
paths = append(paths, batch.ChartPath)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchDepths(plan *Plan) []int {
|
||||||
|
depths := make([]int, 0, len(plan.Batches))
|
||||||
|
for _, batch := range plan.Batches {
|
||||||
|
depths = append(depths, batch.Depth)
|
||||||
|
}
|
||||||
|
return depths
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupNames(batch Batch) []string {
|
||||||
|
names := make([]string, 0, len(batch.Groups))
|
||||||
|
for _, group := range batch.Groups {
|
||||||
|
names = append(names, group.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestNames(manifests []releaseutil.Manifest) []string {
|
||||||
|
names := make([]string, 0, len(manifests))
|
||||||
|
for _, manifest := range manifests {
|
||||||
|
names = append(names, manifest.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPlanComplete(t *testing.T, plan *Plan, manifests []releaseutil.Manifest) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NotNil(t, plan)
|
||||||
|
expected := make(map[string]int, len(manifests))
|
||||||
|
for _, manifest := range manifests {
|
||||||
|
expected[manifest.Name]++
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := make(map[string]int, len(manifests))
|
||||||
|
var total int
|
||||||
|
for _, batch := range plan.Batches {
|
||||||
|
for _, manifest := range batch.Manifests() {
|
||||||
|
actual[manifest.Name]++
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, len(manifests), total)
|
||||||
|
assert.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertWarningContains(t *testing.T, plan *Plan, chartPath string, parts ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, warning := range plan.Warnings {
|
||||||
|
if warning.ChartPath != chartPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := true
|
||||||
|
for _, part := range parts {
|
||||||
|
if !strings.Contains(warning.Message, part) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Failf(t, "missing warning", "chartPath=%q parts=%v warnings=%v", chartPath, parts, plan.Warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoWarningContains(t *testing.T, plan *Plan, part string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, warning := range plan.Warnings {
|
||||||
|
assert.NotContains(t, warning.Message, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeManifest(name, sourcePath string, annotations map[string]string) releaseutil.Manifest {
|
||||||
|
return makeTypedManifest(name, sourcePath, "v1", "ConfigMap", "", annotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTypedManifest(name, sourcePath, version, kind, namespace string, annotations map[string]string) releaseutil.Manifest {
|
||||||
|
annotations = maps.Clone(annotations)
|
||||||
|
var content strings.Builder
|
||||||
|
fmt.Fprintf(&content, "apiVersion: %s\nkind: %s\nmetadata:\n name: %s\n", version, kind, name)
|
||||||
|
if namespace != "" {
|
||||||
|
fmt.Fprintf(&content, " namespace: %s\n", namespace)
|
||||||
|
}
|
||||||
|
if len(annotations) > 0 {
|
||||||
|
content.WriteString(" annotations:\n")
|
||||||
|
for _, key := range slices.Sorted(maps.Keys(annotations)) {
|
||||||
|
fmt.Fprintf(&content, " %s: %q\n", key, annotations[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
head := &releaseutil.SimpleHead{
|
||||||
|
Version: version,
|
||||||
|
Kind: kind,
|
||||||
|
Metadata: &struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
Annotations map[string]string `json:"annotations"`
|
||||||
|
}{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
Annotations: annotations,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return releaseutil.Manifest{
|
||||||
|
Name: sourcePath,
|
||||||
|
Content: content.String(),
|
||||||
|
Head: head,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupAnnotations(group string, deps ...string) map[string]string {
|
||||||
|
annotations := map[string]string{
|
||||||
|
releaseutil.AnnotationResourceGroup: group,
|
||||||
|
}
|
||||||
|
if len(deps) > 0 {
|
||||||
|
encoded, err := json.Marshal(deps)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
annotations[releaseutil.AnnotationDependsOnResourceGroups] = string(encoded)
|
||||||
|
}
|
||||||
|
return annotations
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChart(name string, deps ...*chart.Dependency) *chart.Chart {
|
||||||
|
c := &chart.Chart{
|
||||||
|
Metadata: &chart.Metadata{
|
||||||
|
Name: name,
|
||||||
|
Version: "0.1.0",
|
||||||
|
APIVersion: chart.APIVersionV2,
|
||||||
|
Dependencies: deps,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, dep := range deps {
|
||||||
|
if dep == nil || !dep.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subName := dep.Alias
|
||||||
|
if subName == "" {
|
||||||
|
subName = dep.Name
|
||||||
|
}
|
||||||
|
c.AddDependency(newChart(subName))
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabledDependency(name string, dependsOn ...string) *chart.Dependency {
|
||||||
|
return &chart.Dependency{
|
||||||
|
Name: name,
|
||||||
|
Version: "0.1.0",
|
||||||
|
Enabled: true,
|
||||||
|
DependsOn: dependsOn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aliasedDependency(name, alias string, dependsOn ...string) *chart.Dependency {
|
||||||
|
return &chart.Dependency{
|
||||||
|
Name: name,
|
||||||
|
Version: "0.1.0",
|
||||||
|
Alias: alias,
|
||||||
|
Enabled: true,
|
||||||
|
DependsOn: dependsOn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipelineChart(deps ...*chart.Dependency) *chart.Chart {
|
||||||
|
c := &chart.Chart{
|
||||||
|
Metadata: &chart.Metadata{
|
||||||
|
Name: "parent",
|
||||||
|
Version: "0.1.0",
|
||||||
|
APIVersion: chart.APIVersionV2,
|
||||||
|
Dependencies: deps,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
added := make(map[string]bool)
|
||||||
|
for _, dep := range deps {
|
||||||
|
if added[dep.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
added[dep.Name] = true
|
||||||
|
c.AddDependency(&chart.Chart{
|
||||||
|
Metadata: &chart.Metadata{
|
||||||
|
Name: dep.Name,
|
||||||
|
Version: "0.1.0",
|
||||||
|
APIVersion: chart.APIVersionV2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipelineDependency(name, alias string, dependsOn ...string) *chart.Dependency {
|
||||||
|
return &chart.Dependency{
|
||||||
|
Name: name,
|
||||||
|
Version: "0.1.0",
|
||||||
|
Alias: alias,
|
||||||
|
DependsOn: dependsOn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kitchenSinkFixture() (*chart.Chart, []releaseutil.Manifest) {
|
||||||
|
primary := newChart("primary-db")
|
||||||
|
worker := newChart("worker")
|
||||||
|
app := newChart("app", enabledDependency("worker"))
|
||||||
|
app.SetDependencies(worker)
|
||||||
|
vendored := newChart("vendored")
|
||||||
|
|
||||||
|
parent := newChart(
|
||||||
|
"parent",
|
||||||
|
aliasedDependency("postgres", "primary-db"),
|
||||||
|
enabledDependency("app", "primary-db"),
|
||||||
|
)
|
||||||
|
parent.SetDependencies(primary, app, vendored)
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("primary", "parent/charts/primary-db/templates/primary.yaml", nil),
|
||||||
|
makeManifest("worker", "parent/charts/app/charts/worker/templates/worker.yaml", nil),
|
||||||
|
makeManifest("app-sub", "parent/charts/app/templates/app.yaml", groupAnnotations("app-sub")),
|
||||||
|
makeManifest("vendored", "parent/charts/vendored/templates/vendored.yaml", nil),
|
||||||
|
makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")),
|
||||||
|
makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")),
|
||||||
|
makeManifest("metrics", "parent/templates/metrics.yaml", groupAnnotations("metrics")),
|
||||||
|
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||||
|
}
|
||||||
|
return parent, manifests
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sequence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
|
||||||
|
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseStoredManifests parses a stored release manifest stream (rel.Manifest)
|
||||||
|
// or `helm template` output into named manifests, recovering source-template
|
||||||
|
// paths from "# Source:" comments. Documents without a Source comment keep
|
||||||
|
// their positional "manifest-%d" split key, and therefore route to the
|
||||||
|
// top-level/flat portion of a plan built from them. Returns an error only on
|
||||||
|
// YAML head-parse failure.
|
||||||
|
func ParseStoredManifests(manifest string) ([]releaseutil.Manifest, error) {
|
||||||
|
rawManifests := releaseutil.SplitManifests(manifest)
|
||||||
|
keys := make([]string, 0, len(rawManifests))
|
||||||
|
for key := range rawManifests {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Sort(releaseutil.BySplitManifestsOrder(keys))
|
||||||
|
|
||||||
|
manifests := make([]releaseutil.Manifest, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
content := rawManifests[key]
|
||||||
|
name := manifestSourcePath(content)
|
||||||
|
if name == "" {
|
||||||
|
name = key
|
||||||
|
}
|
||||||
|
|
||||||
|
var head releaseutil.SimpleHead
|
||||||
|
if err := yaml.Unmarshal([]byte(content), &head); err != nil {
|
||||||
|
return nil, fmt.Errorf("YAML parse error on %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifests = append(manifests, releaseutil.Manifest{
|
||||||
|
Name: name,
|
||||||
|
Content: content,
|
||||||
|
Head: &head,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifests, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestSourcePath(content string) string {
|
||||||
|
for line := range strings.SplitSeq(content, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if source, ok := strings.CutPrefix(line, "# Source: "); ok {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Package sequence computes the ordered deployment plan for a release's
|
||||||
|
// rendered manifests per HIP-0025: a two-level walk over the chart's subchart
|
||||||
|
// DAG and each chart level's resource-group DAG, flattened into an ordered
|
||||||
|
// list of apply-and-wait batches. The package is pure — it never talks to a
|
||||||
|
// cluster and must not import pkg/kube, pkg/action, or pkg/cmd — so the same
|
||||||
|
// plan is usable by install/upgrade drivers, uninstall/rollback (reversed),
|
||||||
|
// `helm template`/`helm dag` rendering, and lint.
|
||||||
|
package sequence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchKind distinguishes the two batch flavors a chart level produces.
|
||||||
|
type BatchKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BatchKindGroups: one topological level of the resource-group DAG —
|
||||||
|
// one or more groups deployable in parallel.
|
||||||
|
BatchKindGroups BatchKind = iota
|
||||||
|
// BatchKindUnsequenced: a chart level's trailing batch — resources with no
|
||||||
|
// group annotation, resources demoted for invalid depends-on JSON,
|
||||||
|
// groups demoted for missing references, and isolated groups.
|
||||||
|
BatchKindUnsequenced
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group is a named set of manifests deployed as one unit within a batch.
|
||||||
|
type Group struct {
|
||||||
|
Name string // "" only inside a BatchKindUnsequenced batch
|
||||||
|
Manifests []releaseutil.Manifest // input order preserved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch is one apply-and-wait unit. Drivers process Batches strictly in order.
|
||||||
|
type Batch struct {
|
||||||
|
// ChartPath is the manifest path-prefix of the owning chart level:
|
||||||
|
// "parent", "parent/charts/db", "parent/charts/db/charts/redis".
|
||||||
|
// "" when the plan was built without a chart (flat fallback).
|
||||||
|
ChartPath string
|
||||||
|
// Depth is the chart-nesting depth (0 = top level). Display-only.
|
||||||
|
Depth int
|
||||||
|
Kind BatchKind
|
||||||
|
// Groups: for BatchKindGroups, ≥1 groups sorted by Name (one DAG level);
|
||||||
|
// for BatchKindUnsequenced, exactly one Group{Name: ""}.
|
||||||
|
Groups []Group
|
||||||
|
// Wait: gate on readiness (install/upgrade) or deletion (reversed plans)
|
||||||
|
// before starting the next batch. The builder sets true for every batch;
|
||||||
|
// the field exists so a strict-HIP mode can flip leaf-only batches later.
|
||||||
|
Wait bool
|
||||||
|
// HasCustomReadiness: at least one manifest in the batch carries BOTH
|
||||||
|
// helm.sh/readiness-success and helm.sh/readiness-failure (drivers add
|
||||||
|
// kube.WithCustomReadinessStatusReader for such batches).
|
||||||
|
HasCustomReadiness bool
|
||||||
|
// LeafGroups: names of groups in this batch with no dependents in this
|
||||||
|
// chart level's DAG. Informational — display and future policy only.
|
||||||
|
LeafGroups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifests flattens the batch's groups in order. Convenience for drivers.
|
||||||
|
func (b Batch) Manifests() []releaseutil.Manifest {
|
||||||
|
var manifests []releaseutil.Manifest
|
||||||
|
for _, group := range b.Groups {
|
||||||
|
manifests = append(manifests, group.Manifests...)
|
||||||
|
}
|
||||||
|
return manifests
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning is a non-fatal misconfiguration surfaced during Build.
|
||||||
|
// Drivers log them; lint promotes them to errors.
|
||||||
|
type Warning struct {
|
||||||
|
ChartPath string // "" for chart-independent warnings
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChartLevel summarizes one chart's slot in the walk, in pre-order.
|
||||||
|
// Consumed by `helm dag` (subchart-batch display) and diagnostics.
|
||||||
|
type ChartLevel struct {
|
||||||
|
Path string // manifest path-prefix (as Batch.ChartPath)
|
||||||
|
Depth int
|
||||||
|
SubchartBatches [][]string // declared subcharts per parallel DAG batch (effective names)
|
||||||
|
Undeclared []string // rendered subcharts absent from Chart.yaml deps (sorted)
|
||||||
|
Unresolved []string // rendered subchart dirs with no resolvable chart object (sorted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan is the complete deployment sequence for one release's manifests.
|
||||||
|
type Plan struct {
|
||||||
|
Batches []Batch // forward (install) order
|
||||||
|
Levels []ChartLevel // pre-order chart traversal
|
||||||
|
Warnings []Warning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse returns a new Plan whose Batches are in exact reverse order, for
|
||||||
|
// uninstall and removed-resource deletion (HIP: uninstall is the exact reverse
|
||||||
|
// of install). Levels and Warnings are shared unchanged; Wait retains its
|
||||||
|
// meaning ("gate before next batch", i.e. wait-for-delete).
|
||||||
|
func (p *Plan) Reverse() *Plan {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reversed := make([]Batch, len(p.Batches))
|
||||||
|
for i, batch := range p.Batches {
|
||||||
|
reversed[len(p.Batches)-1-i] = batch
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Plan{
|
||||||
|
Batches: reversed,
|
||||||
|
Levels: p.Levels,
|
||||||
|
Warnings: p.Warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayPath converts a manifest path-prefix to the HIP display form used by
|
||||||
|
// template markers and `helm dag`: "parent/charts/db/charts/redis" → "parent/db/redis".
|
||||||
|
// (Chart names cannot contain '/', so replacing "/charts/" is unambiguous;
|
||||||
|
// a subchart literally named "charts" is pathological and unsupported.)
|
||||||
|
func DisplayPath(chartPath string) string {
|
||||||
|
return strings.ReplaceAll(chartPath, "/charts/", "/")
|
||||||
|
}
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sequence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanReverse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifests := []releaseutil.Manifest{
|
||||||
|
makeManifest("base", "parent/templates/base.yaml", groupAnnotations("base")),
|
||||||
|
makeManifest("left", "parent/templates/left.yaml", groupAnnotations("left", "base")),
|
||||||
|
makeManifest("right", "parent/templates/right.yaml", groupAnnotations("right", "base")),
|
||||||
|
makeManifest("top", "parent/templates/top.yaml", groupAnnotations("top", "left", "right")),
|
||||||
|
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||||
|
}
|
||||||
|
plan, err := Build(newChart("parent"), manifests)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(plan.Batches), 4)
|
||||||
|
|
||||||
|
original := batchSummaries(plan)
|
||||||
|
reversed := plan.Reverse()
|
||||||
|
require.NotNil(t, reversed)
|
||||||
|
require.Len(t, reversed.Batches, len(plan.Batches))
|
||||||
|
|
||||||
|
for i := range reversed.Batches {
|
||||||
|
assert.Equal(t, original[len(original)-1-i], summarizeBatch(reversed.Batches[i]))
|
||||||
|
}
|
||||||
|
assert.Equal(t, original, batchSummaries(plan), "Reverse must not mutate the original plan")
|
||||||
|
assert.Equal(t, plan.Levels, reversed.Levels)
|
||||||
|
assert.Equal(t, plan.Warnings, reversed.Warnings)
|
||||||
|
assert.Equal(t, original, batchSummaries(reversed.Reverse()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := map[string]string{
|
||||||
|
"parent": "parent",
|
||||||
|
"parent/charts/db": "parent/db",
|
||||||
|
"parent/charts/db/charts/redis": "parent/db/redis",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, expected := range tests {
|
||||||
|
assert.Equal(t, expected, DisplayPath(input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchManifests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
first := makeManifest("first", "parent/templates/first.yaml", nil)
|
||||||
|
second := makeManifest("second", "parent/templates/second.yaml", nil)
|
||||||
|
third := makeManifest("third", "parent/templates/third.yaml", nil)
|
||||||
|
batch := Batch{
|
||||||
|
Groups: []Group{
|
||||||
|
{Name: "a", Manifests: []releaseutil.Manifest{first, second}},
|
||||||
|
{Name: "b", Manifests: []releaseutil.Manifest{third}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []releaseutil.Manifest{first, second, third}, batch.Manifests())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadinessAnnotationValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, "helm.sh/readiness-success", releaseutil.AnnotationReadinessSuccess)
|
||||||
|
assert.Equal(t, "helm.sh/readiness-failure", releaseutil.AnnotationReadinessFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageImportPurity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
allowedHelmImports := map[string]bool{
|
||||||
|
"helm.sh/helm/v4/pkg/chart/v2": true,
|
||||||
|
"helm.sh/helm/v4/pkg/chart/v2/util": true,
|
||||||
|
"helm.sh/helm/v4/pkg/release/v1/util": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, parser.ImportsOnly)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, imported := range file.Imports {
|
||||||
|
path, err := strconv.Unquote(imported.Path.Value)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if strings.HasPrefix(path, "helm.sh/helm/v4/") {
|
||||||
|
assert.Truef(t, allowedHelmImports[path], "forbidden helm import %q in %s", path, entry.Name())
|
||||||
|
}
|
||||||
|
assert.Falsef(t, strings.HasPrefix(path, "k8s.io/"), "forbidden Kubernetes import %q in %s", path, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStoredManifests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stream := "---\n# Source: parent/templates/a.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n annotations:\n example.com/key: value\n" +
|
||||||
|
"---\n# Source: parent/charts/db/templates/b.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n" +
|
||||||
|
"---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n"
|
||||||
|
|
||||||
|
parsed, err := ParseStoredManifests(stream)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, parsed, 3)
|
||||||
|
assert.Equal(t, []string{"parent/templates/a.yaml", "parent/charts/db/templates/b.yaml", "manifest-2"}, manifestNames(parsed))
|
||||||
|
assert.Equal(t, "ConfigMap", parsed[0].Head.Kind)
|
||||||
|
assert.Equal(t, "a", parsed[0].Head.Metadata.Name)
|
||||||
|
assert.Equal(t, "value", parsed[0].Head.Metadata.Annotations["example.com/key"])
|
||||||
|
assert.Equal(t, "b", parsed[1].Head.Metadata.Name)
|
||||||
|
assert.Equal(t, "c", parsed[2].Head.Metadata.Name)
|
||||||
|
|
||||||
|
_, err = ParseStoredManifests("---\n# Source: bad.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: [\n")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "YAML parse error")
|
||||||
|
|
||||||
|
flat, err := Build(nil, parsed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertPlanComplete(t, flat, parsed)
|
||||||
|
require.Len(t, flat.Batches, 1)
|
||||||
|
assert.Empty(t, flat.Batches[0].ChartPath)
|
||||||
|
|
||||||
|
db := makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db"))
|
||||||
|
app := makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db"))
|
||||||
|
stored := fmt.Sprintf("---\n# Source: %s\n%s---\n# Source: %s\n%s", db.Name, db.Content, app.Name, app.Content)
|
||||||
|
parsedStored, err := ParseStoredManifests(stored)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fromStored, err := Build(newChart("parent"), parsedStored)
|
||||||
|
require.NoError(t, err)
|
||||||
|
fromDirect, err := Build(newChart("parent"), []releaseutil.Manifest{db, app})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, batchSummaries(fromDirect), batchSummaries(fromStored))
|
||||||
|
assertPlanComplete(t, fromStored, parsedStored)
|
||||||
|
}
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
|
||||||
|
// AnnotationReadinessSuccess declares custom readiness success conditions.
|
||||||
|
// Its value is a JSON array of expressions in the form:
|
||||||
|
// "{.fieldPath} <operator> <value>"
|
||||||
|
// If any success condition evaluates to true, the resource is considered ready.
|
||||||
|
// This is the canonical home of the key (moved from pkg/kube, which keeps
|
||||||
|
// value-identical aliases) so the pure sequencing plan builder can read it
|
||||||
|
// without importing cluster machinery.
|
||||||
|
AnnotationReadinessSuccess = "helm.sh/readiness-success"
|
||||||
|
|
||||||
|
// AnnotationReadinessFailure declares custom readiness failure conditions.
|
||||||
|
// Its value is a JSON array of expressions in the form:
|
||||||
|
// "{.fieldPath} <operator> <value>"
|
||||||
|
// If any failure condition evaluates to true, the resource is considered failed.
|
||||||
|
// Failure conditions take precedence over success conditions.
|
||||||
|
AnnotationReadinessFailure = "helm.sh/readiness-failure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelmInternalSequencingAnnotations lists annotation keys used by Helm for
|
||||||
|
// resource sequencing that are NOT valid Kubernetes annotation keys (their
|
||||||
|
// names contain multiple `/` separators). Helm strips these before applying
|
||||||
|
// resources to the API server, and before printing manifests via
|
||||||
|
// `helm template` so downstream tooling like `kubectl apply` accepts the
|
||||||
|
// output unmodified.
|
||||||
|
var HelmInternalSequencingAnnotations = []string{
|
||||||
|
AnnotationDependsOnResourceGroups,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, hasMissing := firstMissingDependency(result.Groups, groupDeps[groupName])
|
||||||
|
if !hasMissing {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstMissingDependency returns the first dependency that does not name an
|
||||||
|
// existing group, and whether one was found. The boolean disambiguates an
|
||||||
|
// empty-string dependency (e.g. annotation value [""]) from "nothing missing":
|
||||||
|
// "" never names a group, so it must be reported missing and demoted rather
|
||||||
|
// than silently surviving to fail DAG construction.
|
||||||
|
func firstMissingDependency(groups map[string][]Manifest, deps []string) (string, bool) {
|
||||||
|
for _, dep := range deps {
|
||||||
|
if _, ok := groups[dep]; !ok {
|
||||||
|
return dep, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
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,399 @@
|
|||||||
|
/*
|
||||||
|
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)
|
||||||
|
require.ErrorContains(t, err, "assigned to multiple resource groups")
|
||||||
|
require.ErrorContains(t, err, "database")
|
||||||
|
require.ErrorContains(t, err, "cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseResourceGroups_SameNameDifferentNamespaces locks the contract
|
||||||
|
// that two resources sharing apiVersion+kind+name but living in distinct
|
||||||
|
// namespaces are NOT collapsed into a single resource ID. Without the
|
||||||
|
// namespace dimension in resourceGroupResourceID, this scenario would
|
||||||
|
// raise a false "assigned to multiple resource groups" error.
|
||||||
|
func TestParseResourceGroups_SameNameDifferentNamespaces(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m1 := makeManifest("shared", "chart/templates/a.yaml", map[string]string{
|
||||||
|
AnnotationResourceGroup: "groupA",
|
||||||
|
})
|
||||||
|
m1.Head.Metadata.Namespace = "ns-a"
|
||||||
|
m2 := makeManifest("shared", "chart/templates/b.yaml", map[string]string{
|
||||||
|
AnnotationResourceGroup: "groupB",
|
||||||
|
})
|
||||||
|
m2.Head.Metadata.Namespace = "ns-b"
|
||||||
|
|
||||||
|
result, warnings, err := ParseResourceGroups([]Manifest{m1, m2})
|
||||||
|
|
||||||
|
require.NoError(t, err, "same-name resources in different namespaces must not collide")
|
||||||
|
assert.Empty(t, warnings)
|
||||||
|
assert.Len(t, result.Groups["groupA"], 1)
|
||||||
|
assert.Len(t, result.Groups["groupB"], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResourceGroups_NonExistentGroupReferenceWarning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result, warnings := parseResourceGroups(t,
|
||||||
|
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||||
|
AnnotationResourceGroup: "app",
|
||||||
|
AnnotationDependsOnResourceGroups: `["missing"]`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, result.Groups)
|
||||||
|
require.Len(t, result.Unsequenced, 1)
|
||||||
|
assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced))
|
||||||
|
require.Len(t, warnings, 1)
|
||||||
|
assert.Contains(t, warnings[0], "non-existent group")
|
||||||
|
assert.Contains(t, warnings[0], "missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResourceGroups_EmptyStringDependencyDemoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// [""] must be treated like any other unknown group reference (warning +
|
||||||
|
// demotion) instead of surviving to fail DAG construction on node "".
|
||||||
|
result, warnings := parseResourceGroups(t,
|
||||||
|
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||||
|
AnnotationResourceGroup: "app",
|
||||||
|
AnnotationDependsOnResourceGroups: `[""]`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, result.Groups)
|
||||||
|
require.Len(t, result.Unsequenced, 1)
|
||||||
|
require.Len(t, warnings, 1)
|
||||||
|
assert.Contains(t, warnings[0], `depends-on non-existent group ""`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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