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