mirror of https://github.com/helm/helm
Adds pkg/action/sequencing.go with GroupManifestsByDirectSubchart and sequencedDeployment, which deploys chart resources in subchart and resource-group topological batches. Extends action.go to expose sorted manifests from the render pipeline via renderResourcesWithFiles. Wires up performSequencedInstall in install.go when --wait=ordered is used.pull/31992/head
parent
1e41788adc
commit
85488eed18
@ -0,0 +1,269 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package action
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chartv2 "helm.sh/helm/v4/pkg/chart/v2"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
// GroupManifestsByDirectSubchart groups manifests by the direct subchart they belong to.
|
||||
// The parent chart's own manifests (templates directly under `<chartName>/templates/`) are
|
||||
// returned under the empty string key "".
|
||||
// Subcharts are keyed by their immediate name under `<chartName>/charts/<subchart>/`.
|
||||
// Nested subcharts (e.g., `<chartName>/charts/sub/charts/nested/`) are grouped under
|
||||
// the direct subchart name ("sub"), since nested sequencing is handled recursively.
|
||||
func GroupManifestsByDirectSubchart(manifests []releaseutil.Manifest, chartName string) map[string][]releaseutil.Manifest {
|
||||
result := make(map[string][]releaseutil.Manifest)
|
||||
if chartName == "" {
|
||||
// Fallback: assign everything to parent
|
||||
result[""] = append(result[""], manifests...)
|
||||
return result
|
||||
}
|
||||
|
||||
chartsPrefix := chartName + "/charts/"
|
||||
for _, m := range manifests {
|
||||
if !strings.HasPrefix(m.Name, chartsPrefix) {
|
||||
// Parent chart manifest
|
||||
result[""] = append(result[""], m)
|
||||
continue
|
||||
}
|
||||
// Extract the direct subchart name (first segment after "<chartName>/charts/")
|
||||
rest := m.Name[len(chartsPrefix):]
|
||||
// rest is like "subchart1/templates/deploy.yaml" or "subchart1/charts/nested/..."
|
||||
idx := strings.Index(rest, "/")
|
||||
if idx < 0 {
|
||||
// Unlikely: a file directly under charts/ with no subdirectory
|
||||
result[""] = append(result[""], m)
|
||||
continue
|
||||
}
|
||||
subchartName := rest[:idx]
|
||||
result[subchartName] = append(result[subchartName], m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildManifestYAML concatenates the Content fields of the given manifests into a single
|
||||
// YAML stream suitable for passing to KubeClient.Build().
|
||||
func buildManifestYAML(manifests []releaseutil.Manifest) string {
|
||||
if len(manifests) == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
for _, m := range manifests {
|
||||
buf.WriteString(m.Content)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// sequencedDeployment performs ordered installation of chart resources.
|
||||
// It handles the two-level DAG: first subchart ordering, then resource-group
|
||||
// ordering within each chart level.
|
||||
type sequencedDeployment struct {
|
||||
cfg *Configuration
|
||||
disableOpenAPI bool
|
||||
serverSideApply bool
|
||||
forceConflicts bool
|
||||
forceReplace bool
|
||||
waitStrategy kube.WaitStrategy
|
||||
waitOptions []kube.WaitOption
|
||||
waitForJobs bool
|
||||
timeout time.Duration
|
||||
readinessTimeout time.Duration
|
||||
deadline time.Time // overall operation deadline
|
||||
}
|
||||
|
||||
// deployChartLevel deploys all resources for a single chart level in sequenced order.
|
||||
// It first handles subcharts in dependency order (recursively), then deploys the
|
||||
// parent chart's own resource-group batches.
|
||||
func (s *sequencedDeployment) deployChartLevel(ctx context.Context, chrt *chartv2.Chart, manifests []releaseutil.Manifest) error {
|
||||
// Group manifests by direct subchart
|
||||
grouped := GroupManifestsByDirectSubchart(manifests, chrt.Name())
|
||||
|
||||
// Build subchart DAG and deploy in topological order
|
||||
dag, err := chartutil.BuildSubchartDAG(chrt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building subchart DAG for %s: %w", chrt.Name(), err)
|
||||
}
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting subchart batches for %s: %w", chrt.Name(), err)
|
||||
}
|
||||
|
||||
// Deploy each subchart batch in order
|
||||
for batchIdx, batch := range batches {
|
||||
for _, subchartName := range batch {
|
||||
subManifests := grouped[subchartName]
|
||||
if len(subManifests) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the subchart chart object for recursive nested sequencing
|
||||
subChart := findSubchart(chrt, subchartName)
|
||||
if subChart == nil {
|
||||
// Subchart not found in chart object (may have been disabled or aliased differently)
|
||||
// Fall back to flat resource-group deployment for these manifests
|
||||
slog.Warn("subchart not found in chart dependencies; deploying without subchart sequencing",
|
||||
"subchart", subchartName,
|
||||
"batch", batchIdx,
|
||||
)
|
||||
if err := s.deployResourceGroupBatches(ctx, subManifests); err != nil {
|
||||
return fmt.Errorf("deploying subchart %s resources: %w", subchartName, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Recursively deploy the subchart (handles its own nested subcharts and resource-groups)
|
||||
if err := s.deployChartLevel(ctx, subChart, subManifests); err != nil {
|
||||
return fmt.Errorf("deploying subchart %s: %w", subchartName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy parent chart's own resources (after all subchart batches complete)
|
||||
parentManifests := grouped[""]
|
||||
if len(parentManifests) > 0 {
|
||||
if err := s.deployResourceGroupBatches(ctx, parentManifests); err != nil {
|
||||
return fmt.Errorf("deploying %s own resources: %w", chrt.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deployResourceGroupBatches deploys manifests for a single chart level using
|
||||
// resource-group annotation DAG ordering. Resources without group annotations
|
||||
// (or with invalid ones) are deployed last.
|
||||
func (s *sequencedDeployment) deployResourceGroupBatches(ctx context.Context, manifests []releaseutil.Manifest) error {
|
||||
if len(manifests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, warnings := releaseutil.ParseResourceGroups(manifests)
|
||||
for _, w := range warnings {
|
||||
slog.Warn("resource-group annotation warning", "warning", w)
|
||||
}
|
||||
|
||||
// If there are sequenced groups, build their DAG and deploy in order
|
||||
if len(result.Groups) > 0 {
|
||||
dag, err := releaseutil.BuildResourceGroupDAG(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building resource-group DAG: %w", err)
|
||||
}
|
||||
|
||||
batches, err := dag.GetBatches()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting resource-group batches: %w", err)
|
||||
}
|
||||
|
||||
for _, groupBatch := range batches {
|
||||
var batchManifests []releaseutil.Manifest
|
||||
for _, groupName := range groupBatch {
|
||||
batchManifests = append(batchManifests, result.Groups[groupName]...)
|
||||
}
|
||||
if err := s.createAndWait(ctx, batchManifests); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy unsequenced resources last
|
||||
if len(result.Unsequenced) > 0 {
|
||||
if err := s.createAndWait(ctx, result.Unsequenced); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAndWait creates a set of manifest resources and waits for them to be ready.
|
||||
// It respects both the per-batch readiness timeout and the overall operation deadline.
|
||||
func (s *sequencedDeployment) createAndWait(ctx context.Context, manifests []releaseutil.Manifest) error {
|
||||
if len(manifests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
yaml := buildManifestYAML(manifests)
|
||||
resources, err := s.cfg.KubeClient.Build(bytes.NewBufferString(yaml), !s.disableOpenAPI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building resource batch: %w", err)
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = s.cfg.KubeClient.Create(resources, kube.ClientCreateOptionServerSideApply(s.serverSideApply, false))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating resource batch: %w", err)
|
||||
}
|
||||
|
||||
// Determine effective wait timeout: min(readinessTimeout, remaining time to overall deadline)
|
||||
waitTimeout := s.readinessTimeout
|
||||
if !s.deadline.IsZero() {
|
||||
remaining := time.Until(s.deadline)
|
||||
if remaining <= 0 {
|
||||
return fmt.Errorf("overall timeout exceeded before waiting for resource batch")
|
||||
}
|
||||
if waitTimeout <= 0 || remaining < waitTimeout {
|
||||
waitTimeout = remaining
|
||||
}
|
||||
}
|
||||
if waitTimeout <= 0 {
|
||||
waitTimeout = time.Minute // safe default
|
||||
}
|
||||
|
||||
var waiter kube.Waiter
|
||||
if c, ok := s.cfg.KubeClient.(kube.InterfaceWaitOptions); ok {
|
||||
waiter, err = c.GetWaiterWithOptions(s.waitStrategy, s.waitOptions...)
|
||||
} else {
|
||||
waiter, err = s.cfg.KubeClient.GetWaiter(s.waitStrategy)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting waiter for resource batch: %w", err)
|
||||
}
|
||||
|
||||
if s.waitForJobs {
|
||||
return waiter.WaitWithJobs(resources, waitTimeout)
|
||||
}
|
||||
return waiter.Wait(resources, waitTimeout)
|
||||
}
|
||||
|
||||
// findSubchart finds the subchart chart object within chrt's direct dependencies by name or alias.
|
||||
func findSubchart(chrt *chartv2.Chart, nameOrAlias string) *chartv2.Chart {
|
||||
for _, dep := range chrt.Dependencies() {
|
||||
alias := dep.Metadata.Annotations["alias"]
|
||||
if alias == "" {
|
||||
alias = dep.Name()
|
||||
}
|
||||
if alias == nameOrAlias || dep.Name() == nameOrAlias {
|
||||
return dep
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
// makeTestManifest creates a minimal Manifest for testing.
|
||||
func makeTestManifest(name, sourcePath string, annotations map[string]string) releaseutil.Manifest {
|
||||
content := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + "\n"
|
||||
if len(annotations) > 0 {
|
||||
content += " annotations:\n"
|
||||
for k, v := range annotations {
|
||||
content += " " + k + ": \"" + v + "\"\n"
|
||||
}
|
||||
}
|
||||
head := &releaseutil.SimpleHead{}
|
||||
head.Metadata = &struct {
|
||||
Name string `json:"name"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}{
|
||||
Name: name,
|
||||
Annotations: annotations,
|
||||
}
|
||||
return releaseutil.Manifest{
|
||||
Name: sourcePath,
|
||||
Content: content,
|
||||
Head: head,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupManifestsByDirectSubchart(t *testing.T) {
|
||||
manifests := []releaseutil.Manifest{
|
||||
makeTestManifest("cm-parent", "mychart/templates/cm.yaml", nil),
|
||||
makeTestManifest("deploy-db", "mychart/charts/database/templates/deploy.yaml", nil),
|
||||
makeTestManifest("svc-db", "mychart/charts/database/templates/svc.yaml", nil),
|
||||
makeTestManifest("cm-app", "mychart/charts/app/templates/cm.yaml", nil),
|
||||
// nested subchart — belongs to "database" at parent level
|
||||
makeTestManifest("cm-nested", "mychart/charts/database/charts/cache/templates/cm.yaml", nil),
|
||||
}
|
||||
|
||||
groups := GroupManifestsByDirectSubchart(manifests, "mychart")
|
||||
|
||||
// Parent chart
|
||||
if len(groups[""]) != 1 {
|
||||
t.Errorf("expected 1 parent manifest, got %d", len(groups[""]))
|
||||
}
|
||||
if groups[""][0].Name != "mychart/templates/cm.yaml" {
|
||||
t.Errorf("unexpected parent manifest: %s", groups[""][0].Name)
|
||||
}
|
||||
|
||||
// database subchart (including its nested subchart at this level)
|
||||
if len(groups["database"]) != 3 {
|
||||
t.Errorf("expected 3 database manifests (including nested), got %d: %v",
|
||||
len(groups["database"]), manifestNames(groups["database"]))
|
||||
}
|
||||
|
||||
// app subchart
|
||||
if len(groups["app"]) != 1 {
|
||||
t.Errorf("expected 1 app manifest, got %d", len(groups["app"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupManifestsByDirectSubchart_EmptyChartName(t *testing.T) {
|
||||
// Edge case: chart name is empty — everything goes to parent
|
||||
manifests := []releaseutil.Manifest{
|
||||
makeTestManifest("cm", "templates/cm.yaml", nil),
|
||||
}
|
||||
groups := GroupManifestsByDirectSubchart(manifests, "")
|
||||
if len(groups[""]) != 1 {
|
||||
t.Errorf("expected 1 parent manifest for empty chart name, got %d", len(groups[""]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupManifestsByDirectSubchart_OnlySubcharts(t *testing.T) {
|
||||
manifests := []releaseutil.Manifest{
|
||||
makeTestManifest("deploy", "chart/charts/sub1/templates/deploy.yaml", nil),
|
||||
makeTestManifest("svc", "chart/charts/sub2/templates/svc.yaml", nil),
|
||||
}
|
||||
groups := GroupManifestsByDirectSubchart(manifests, "chart")
|
||||
if len(groups[""]) != 0 {
|
||||
t.Errorf("expected no parent manifests, got %d", len(groups[""]))
|
||||
}
|
||||
if len(groups["sub1"]) != 1 {
|
||||
t.Errorf("expected 1 sub1 manifest, got %d", len(groups["sub1"]))
|
||||
}
|
||||
if len(groups["sub2"]) != 1 {
|
||||
t.Errorf("expected 1 sub2 manifest, got %d", len(groups["sub2"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifestYAML(t *testing.T) {
|
||||
manifests := []releaseutil.Manifest{
|
||||
makeTestManifest("cm1", "chart/templates/cm1.yaml", nil),
|
||||
makeTestManifest("cm2", "chart/templates/cm2.yaml", nil),
|
||||
}
|
||||
result := buildManifestYAML(manifests)
|
||||
if result == "" {
|
||||
t.Error("expected non-empty YAML output")
|
||||
}
|
||||
// Should contain content from both manifests
|
||||
if !contains(result, "name: cm1") {
|
||||
t.Error("expected cm1 in output")
|
||||
}
|
||||
if !contains(result, "name: cm2") {
|
||||
t.Error("expected cm2 in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifestYAML_Empty(t *testing.T) {
|
||||
result := buildManifestYAML(nil)
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string for nil manifests, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// contains checks if s contains substr.
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestInstallRelease_OrderedWaitStrategy verifies that --wait=ordered installs
|
||||
// succeed end-to-end using the fake kube client.
|
||||
func TestInstallRelease_OrderedWaitStrategy(t *testing.T) {
|
||||
config := actionConfigFixture(t)
|
||||
instAction := NewInstall(config)
|
||||
instAction.Namespace = "spaced"
|
||||
instAction.ReleaseName = "seq-test"
|
||||
instAction.WaitStrategy = kube.OrderedWaitStrategy
|
||||
instAction.Timeout = 5 * time.Minute
|
||||
instAction.ReadinessTimeout = time.Minute
|
||||
|
||||
ch := buildChart(withSampleTemplates())
|
||||
_, err := instAction.RunWithContext(context.Background(), ch, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("ordered install failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallRelease_OrderedWaitStrategy_NilChart ensures a nil chart doesn't panic.
|
||||
func TestInstallRelease_ReadinessTimeoutValidation(t *testing.T) {
|
||||
config := actionConfigFixture(t)
|
||||
instAction := NewInstall(config)
|
||||
instAction.Namespace = "spaced"
|
||||
instAction.ReleaseName = "timeout-test"
|
||||
instAction.WaitStrategy = kube.OrderedWaitStrategy
|
||||
instAction.Timeout = 5
|
||||
instAction.ReadinessTimeout = 10 // exceeds Timeout
|
||||
|
||||
ch := buildChart(withSampleTemplates())
|
||||
_, err := instAction.RunWithContext(context.Background(), ch, map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ReadinessTimeout > Timeout, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func manifestNames(ms []releaseutil.Manifest) []string {
|
||||
names := make([]string, len(ms))
|
||||
for i, m := range ms {
|
||||
names[i] = m.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
Loading…
Reference in new issue