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>
parent
179841fd04
commit
c18503939e
@ -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,320 @@
|
||||
/*
|
||||
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 := annotations[releaseutil.AnnotationReadinessSuccess]
|
||||
_, hasFailure := 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 annotations[releaseutil.AnnotationReadinessSuccess] != "" && annotations[releaseutil.AnnotationReadinessFailure] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -0,0 +1,732 @@
|
||||
/*
|
||||
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_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,171 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package sequence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
func TestPlanReverse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifests := []releaseutil.Manifest{
|
||||
makeManifest("base", "parent/templates/base.yaml", groupAnnotations("base")),
|
||||
makeManifest("left", "parent/templates/left.yaml", groupAnnotations("left", "base")),
|
||||
makeManifest("right", "parent/templates/right.yaml", groupAnnotations("right", "base")),
|
||||
makeManifest("top", "parent/templates/top.yaml", groupAnnotations("top", "left", "right")),
|
||||
makeManifest("plain", "parent/templates/plain.yaml", nil),
|
||||
}
|
||||
plan, err := Build(newChart("parent"), manifests)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(plan.Batches), 4)
|
||||
|
||||
original := batchSummaries(plan)
|
||||
reversed := plan.Reverse()
|
||||
require.NotNil(t, reversed)
|
||||
require.Len(t, reversed.Batches, len(plan.Batches))
|
||||
|
||||
for i := range reversed.Batches {
|
||||
assert.Equal(t, original[len(original)-1-i], summarizeBatch(reversed.Batches[i]))
|
||||
}
|
||||
assert.Equal(t, original, batchSummaries(plan), "Reverse must not mutate the original plan")
|
||||
assert.Equal(t, plan.Levels, reversed.Levels)
|
||||
assert.Equal(t, plan.Warnings, reversed.Warnings)
|
||||
assert.Equal(t, original, batchSummaries(reversed.Reverse()))
|
||||
}
|
||||
|
||||
func TestDisplayPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]string{
|
||||
"parent": "parent",
|
||||
"parent/charts/db": "parent/db",
|
||||
"parent/charts/db/charts/redis": "parent/db/redis",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for input, expected := range tests {
|
||||
assert.Equal(t, expected, DisplayPath(input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchManifests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := makeManifest("first", "parent/templates/first.yaml", nil)
|
||||
second := makeManifest("second", "parent/templates/second.yaml", nil)
|
||||
third := makeManifest("third", "parent/templates/third.yaml", nil)
|
||||
batch := Batch{
|
||||
Groups: []Group{
|
||||
{Name: "a", Manifests: []releaseutil.Manifest{first, second}},
|
||||
{Name: "b", Manifests: []releaseutil.Manifest{third}},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []releaseutil.Manifest{first, second, third}, batch.Manifests())
|
||||
}
|
||||
|
||||
func TestReadinessAnnotationValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "helm.sh/readiness-success", releaseutil.AnnotationReadinessSuccess)
|
||||
assert.Equal(t, "helm.sh/readiness-failure", releaseutil.AnnotationReadinessFailure)
|
||||
}
|
||||
|
||||
func TestPackageImportPurity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries, err := os.ReadDir(".")
|
||||
require.NoError(t, err)
|
||||
|
||||
allowedHelmImports := map[string]bool{
|
||||
"helm.sh/helm/v4/pkg/chart/v2": true,
|
||||
"helm.sh/helm/v4/pkg/chart/v2/util": true,
|
||||
"helm.sh/helm/v4/pkg/release/v1/util": true,
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
file, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, parser.ImportsOnly)
|
||||
require.NoError(t, err)
|
||||
for _, imported := range file.Imports {
|
||||
path, err := strconv.Unquote(imported.Path.Value)
|
||||
require.NoError(t, err)
|
||||
if strings.HasPrefix(path, "helm.sh/helm/v4/") {
|
||||
assert.Truef(t, allowedHelmImports[path], "forbidden helm import %q in %s", path, entry.Name())
|
||||
}
|
||||
assert.Falsef(t, strings.HasPrefix(path, "k8s.io/"), "forbidden Kubernetes import %q in %s", path, entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStoredManifests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stream := strings.Join([]string{
|
||||
"---\n# Source: parent/templates/a.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n annotations:\n example.com/key: value\n",
|
||||
"---\n# Source: parent/charts/db/templates/b.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n",
|
||||
"---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n",
|
||||
}, "")
|
||||
|
||||
parsed, err := ParseStoredManifests(stream)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, parsed, 3)
|
||||
assert.Equal(t, []string{"parent/templates/a.yaml", "parent/charts/db/templates/b.yaml", "manifest-2"}, manifestNames(parsed))
|
||||
assert.Equal(t, "ConfigMap", parsed[0].Head.Kind)
|
||||
assert.Equal(t, "a", parsed[0].Head.Metadata.Name)
|
||||
assert.Equal(t, "value", parsed[0].Head.Metadata.Annotations["example.com/key"])
|
||||
assert.Equal(t, "b", parsed[1].Head.Metadata.Name)
|
||||
assert.Equal(t, "c", parsed[2].Head.Metadata.Name)
|
||||
|
||||
_, err = ParseStoredManifests("---\n# Source: bad.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: [\n")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "YAML parse error")
|
||||
|
||||
flat, err := Build(nil, parsed)
|
||||
require.NoError(t, err)
|
||||
assertPlanComplete(t, flat, parsed)
|
||||
require.Len(t, flat.Batches, 1)
|
||||
assert.Empty(t, flat.Batches[0].ChartPath)
|
||||
|
||||
db := makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db"))
|
||||
app := makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db"))
|
||||
stored := fmt.Sprintf("---\n# Source: %s\n%s---\n# Source: %s\n%s", db.Name, db.Content, app.Name, app.Content)
|
||||
parsedStored, err := ParseStoredManifests(stored)
|
||||
require.NoError(t, err)
|
||||
|
||||
fromStored, err := Build(newChart("parent"), parsedStored)
|
||||
require.NoError(t, err)
|
||||
fromDirect, err := Build(newChart("parent"), []releaseutil.Manifest{db, app})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, batchSummaries(fromDirect), batchSummaries(fromStored))
|
||||
assertPlanComplete(t, fromStored, parsedStored)
|
||||
}
|
||||
@ -0,0 +1,247 @@
|
||||
/*
|
||||
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 := firstMissingDependency(result.Groups, groupDeps[groupName])
|
||||
if missingDep == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"group %q depends-on non-existent group %q; moving group to unsequenced batch",
|
||||
groupName,
|
||||
missingDep,
|
||||
))
|
||||
result.Unsequenced = append(result.Unsequenced, groupManifests...)
|
||||
delete(result.Groups, groupName)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, groupName := range groupOrder {
|
||||
if _, ok := result.Groups[groupName]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
result.GroupDeps[groupName] = groupDeps[groupName]
|
||||
}
|
||||
|
||||
return result, warnings, nil
|
||||
}
|
||||
|
||||
// BuildResourceGroupDAG constructs a DAG from the resource-group parse result.
|
||||
// Each group becomes a DAG node and every dependency becomes an edge.
|
||||
func BuildResourceGroupDAG(result ResourceGroupResult) (*chartutil.DAG, error) {
|
||||
dag := chartutil.NewDAG()
|
||||
|
||||
for groupName := range result.Groups {
|
||||
dag.AddNode(groupName)
|
||||
}
|
||||
|
||||
for groupName, deps := range result.GroupDeps {
|
||||
for _, dep := range deps {
|
||||
if err := dag.AddEdge(dep, groupName); err != nil {
|
||||
return nil, fmt.Errorf("resource-group sequencing edge %s→%s: %w", dep, groupName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dag, nil
|
||||
}
|
||||
|
||||
func resourceGroupName(manifest Manifest) (string, bool) {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil || len(manifest.Head.Metadata.Annotations) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
groupName, ok := manifest.Head.Metadata.Annotations[AnnotationResourceGroup]
|
||||
if !ok || groupName == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return groupName, true
|
||||
}
|
||||
|
||||
func resourceGroupDependencies(manifest Manifest) ([]string, string, error) {
|
||||
annotations := manifest.Head.Metadata.Annotations
|
||||
rawDeps, ok := annotations[AnnotationDependsOnResourceGroups]
|
||||
if !ok {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
var deps []string
|
||||
if err := json.Unmarshal([]byte(rawDeps), &deps); err != nil {
|
||||
return nil, fmt.Sprintf(
|
||||
"manifest %q: invalid JSON in %s annotation: %v; moving to unsequenced batch",
|
||||
manifest.Name,
|
||||
AnnotationDependsOnResourceGroups,
|
||||
err,
|
||||
), err
|
||||
}
|
||||
|
||||
return deps, "", nil
|
||||
}
|
||||
|
||||
func firstMissingDependency(groups map[string][]Manifest, deps []string) string {
|
||||
for _, dep := range deps {
|
||||
if _, ok := groups[dep]; !ok {
|
||||
return dep
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func appendUniqueStrings(existing []string, values ...string) []string {
|
||||
seen := make(map[string]struct{}, len(existing))
|
||||
for _, value := range existing {
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
existing = append(existing, value)
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
func resourceGroupResourceID(manifest Manifest) string {
|
||||
if manifest.Head == nil || manifest.Head.Metadata == nil || manifest.Head.Metadata.Name == "" {
|
||||
return manifest.Name
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s/%s/%s", manifest.Head.Version, manifest.Head.Kind, manifest.Head.Metadata.Namespace, manifest.Head.Metadata.Name)
|
||||
}
|
||||
@ -0,0 +1,381 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseResourceGroups_NoAnnotations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("plain-a", "chart/templates/plain-a.yaml", nil),
|
||||
makeManifest("plain-b", "chart/templates/plain-b.yaml", nil),
|
||||
)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
assert.Empty(t, result.GroupDeps)
|
||||
require.Len(t, result.Unsequenced, 2)
|
||||
assert.Equal(t, []string{"chart/templates/plain-a.yaml", "chart/templates/plain-b.yaml"}, manifestPaths(result.Unsequenced))
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_GroupWithDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
require.Len(t, result.Groups, 2)
|
||||
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
|
||||
assert.Empty(t, result.Unsequenced)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_MultipleResourcesPerGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database-config", "chart/templates/database-config.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("database-secret", "chart/templates/database-secret.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
require.Len(t, result.Groups["database"], 2)
|
||||
assert.Equal(t, []string{
|
||||
"chart/templates/database-config.yaml",
|
||||
"chart/templates/database-secret.yaml",
|
||||
}, manifestPaths(result.Groups["database"]))
|
||||
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_ResourceAssignedToMultipleGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := ParseResourceGroups([]Manifest{
|
||||
makeManifest("shared", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("shared", "chart/templates/cache.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "cache",
|
||||
}),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "assigned to multiple resource groups")
|
||||
assert.ErrorContains(t, err, "database")
|
||||
assert.ErrorContains(t, err, "cache")
|
||||
}
|
||||
|
||||
// TestParseResourceGroups_SameNameDifferentNamespaces locks the contract
|
||||
// that two resources sharing apiVersion+kind+name but living in distinct
|
||||
// namespaces are NOT collapsed into a single resource ID. Without the
|
||||
// namespace dimension in resourceGroupResourceID, this scenario would
|
||||
// raise a false "assigned to multiple resource groups" error.
|
||||
func TestParseResourceGroups_SameNameDifferentNamespaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m1 := makeManifest("shared", "chart/templates/a.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "groupA",
|
||||
})
|
||||
m1.Head.Metadata.Namespace = "ns-a"
|
||||
m2 := makeManifest("shared", "chart/templates/b.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "groupB",
|
||||
})
|
||||
m2.Head.Metadata.Namespace = "ns-b"
|
||||
|
||||
result, warnings, err := ParseResourceGroups([]Manifest{m1, m2})
|
||||
|
||||
require.NoError(t, err, "same-name resources in different namespaces must not collide")
|
||||
assert.Empty(t, warnings)
|
||||
assert.Len(t, result.Groups["groupA"], 1)
|
||||
assert.Len(t, result.Groups["groupB"], 1)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_NonExistentGroupReferenceWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["missing"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
require.Len(t, result.Unsequenced, 1)
|
||||
assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced))
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Contains(t, warnings[0], "non-existent group")
|
||||
assert.Contains(t, warnings[0], "missing")
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_CascadingMissingDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifests := []Manifest{
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
AnnotationDependsOnResourceGroups: `["missing"]`,
|
||||
}),
|
||||
}
|
||||
|
||||
for range 64 {
|
||||
result, warnings := parseResourceGroups(t, manifests...)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
assert.Empty(t, result.GroupDeps)
|
||||
require.Len(t, result.Unsequenced, 2)
|
||||
assert.ElementsMatch(t, []string{
|
||||
"chart/templates/app.yaml",
|
||||
"chart/templates/database.yaml",
|
||||
}, manifestPaths(result.Unsequenced))
|
||||
require.Len(t, warnings, 2)
|
||||
assert.Contains(t, warnings[0], `group "database" depends-on non-existent group "missing"`)
|
||||
assert.Contains(t, warnings[1], `group "app" depends-on non-existent group "database"`)
|
||||
|
||||
dag, err := BuildResourceGroupDAG(result)
|
||||
require.NoError(t, err)
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_InvalidDependsOnJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `not-valid-json`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Empty(t, result.Groups)
|
||||
require.Len(t, result.Unsequenced, 1)
|
||||
assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced))
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Contains(t, warnings[0], "invalid JSON")
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_MixedSequencedAndUnsequenced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings := parseResourceGroups(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("plain", "chart/templates/plain.yaml", nil),
|
||||
)
|
||||
|
||||
require.Len(t, result.Groups, 1)
|
||||
assert.Contains(t, result.Groups, "database")
|
||||
require.Len(t, result.Unsequenced, 1)
|
||||
assert.Equal(t, []string{"chart/templates/plain.yaml"}, manifestPaths(result.Unsequenced))
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestResourceGroupDAG_CycleDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, _ := parseResourceGroups(t,
|
||||
makeManifest("a", "chart/templates/a.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "a",
|
||||
AnnotationDependsOnResourceGroups: `["c"]`,
|
||||
}),
|
||||
makeManifest("b", "chart/templates/b.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "b",
|
||||
AnnotationDependsOnResourceGroups: `["a"]`,
|
||||
}),
|
||||
makeManifest("c", "chart/templates/c.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "c",
|
||||
AnnotationDependsOnResourceGroups: `["b"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
dag, err := BuildResourceGroupDAG(result)
|
||||
require.NoError(t, err)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, batches)
|
||||
assert.ErrorContains(t, err, "cycle")
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_DeduplicatesDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("app-config", "chart/templates/app-config.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
makeManifest("app-secret", "chart/templates/app-secret.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"database"}, result.GroupDeps["app"])
|
||||
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_IsolatedGroupsRemainInBatch0(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("metrics", "chart/templates/metrics.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "metrics",
|
||||
}),
|
||||
makeManifest("app", "chart/templates/app.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "app",
|
||||
AnnotationDependsOnResourceGroups: `["database"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Equal(t, [][]string{{"database", "metrics"}, {"app"}}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func TestParseResourceGroups_ComplexDAG(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, warnings, batches := parseResourceGroupBatches(t,
|
||||
makeManifest("cache", "chart/templates/cache.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "cache",
|
||||
}),
|
||||
makeManifest("database", "chart/templates/database.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "database",
|
||||
}),
|
||||
makeManifest("queue", "chart/templates/queue.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "queue",
|
||||
}),
|
||||
makeManifest("api", "chart/templates/api.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "api",
|
||||
AnnotationDependsOnResourceGroups: `["database","queue"]`,
|
||||
}),
|
||||
makeManifest("worker", "chart/templates/worker.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "worker",
|
||||
AnnotationDependsOnResourceGroups: `["cache","queue"]`,
|
||||
}),
|
||||
makeManifest("frontend", "chart/templates/frontend.yaml", map[string]string{
|
||||
AnnotationResourceGroup: "frontend",
|
||||
AnnotationDependsOnResourceGroups: `["api"]`,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.Equal(t, [][]string{
|
||||
{"cache", "database", "queue"},
|
||||
{"api", "worker"},
|
||||
{"frontend"},
|
||||
}, batches)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func parseResourceGroups(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string) {
|
||||
t.Helper()
|
||||
|
||||
result, warnings, err := ParseResourceGroups(manifests)
|
||||
require.NoError(t, err)
|
||||
|
||||
return result, warnings
|
||||
}
|
||||
|
||||
func parseResourceGroupBatches(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string, [][]string) {
|
||||
t.Helper()
|
||||
|
||||
result, warnings := parseResourceGroups(t, manifests...)
|
||||
dag, err := BuildResourceGroupDAG(result)
|
||||
require.NoError(t, err)
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
require.NoError(t, err)
|
||||
|
||||
return result, warnings, batches
|
||||
}
|
||||
|
||||
func makeManifest(name, sourcePath string, annotations map[string]string) Manifest {
|
||||
var content strings.Builder
|
||||
fmt.Fprintf(&content, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: %s\n", name)
|
||||
if len(annotations) > 0 {
|
||||
content.WriteString(" annotations:\n")
|
||||
for key, value := range annotations {
|
||||
fmt.Fprintf(&content, " %s: %q\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
head := &SimpleHead{
|
||||
Version: "v1",
|
||||
Kind: "ConfigMap",
|
||||
Metadata: &struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}{
|
||||
Name: name,
|
||||
Annotations: annotations,
|
||||
},
|
||||
}
|
||||
|
||||
return Manifest{
|
||||
Name: sourcePath,
|
||||
Content: content.String(),
|
||||
Head: head,
|
||||
}
|
||||
}
|
||||
|
||||
func manifestPaths(manifests []Manifest) []string {
|
||||
paths := make([]string, 0, len(manifests))
|
||||
for _, manifest := range manifests {
|
||||
paths = append(paths, manifest.Name)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
Loading…
Reference in new issue