From 85488eed1823dddb9fa36575e7a5cc86ac37e510 Mon Sep 17 00:00:00 2001 From: caretak3r <50377477+caretak3r@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:54:50 -0500 Subject: [PATCH] =?UTF-8?q?feat(spec):=20Task=206=20=E2=80=94=20sequenced?= =?UTF-8?q?=20install=20action=20with=20two-level=20DAG=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/action/action.go | 29 ++-- pkg/action/install.go | 92 +++++++++++- pkg/action/sequencing.go | 269 ++++++++++++++++++++++++++++++++++ pkg/action/sequencing_test.go | 192 ++++++++++++++++++++++++ 4 files changed, 569 insertions(+), 13 deletions(-) create mode 100644 pkg/action/sequencing.go create mode 100644 pkg/action/sequencing_test.go diff --git a/pkg/action/action.go b/pkg/action/action.go index c2a27940f..b08637b2a 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -220,17 +220,24 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { // // This code has to do with writing files to disk. func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { + hs, b, notes, _, err := cfg.renderResourcesWithFiles(ch, values, releaseName, outputDir, subNotes, useReleaseName, includeCrds, pr, interactWithRemote, enableDNS, hideSecret) + return hs, b, notes, err +} + +// renderResourcesWithFiles is the canonical render implementation, also returning the sorted +// []Manifest slice for use by sequenced install/upgrade. +func (cfg *Configuration) renderResourcesWithFiles(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, []releaseutil.Manifest, error) { var hs []*release.Hook b := bytes.NewBuffer(nil) caps, err := cfg.getCapabilities() if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } if ch.Metadata.KubeVersion != "" { if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) { - return hs, b, "", fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version) + return hs, b, "", nil, fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version) } } @@ -243,7 +250,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, if interactWithRemote && cfg.RESTClientGetter != nil { restConfig, err := cfg.RESTClientGetter.ToRESTConfig() if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } e := engine.New(restConfig) e.EnableDNS = enableDNS @@ -259,7 +266,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, } if err2 != nil { - return hs, b, "", err2 + return hs, b, "", nil, err2 } // NOTES.txt gets rendered like all the other files, but because it's not a hook nor a resource, @@ -293,19 +300,19 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, // Merge files as stream of documents for sending to post renderer merged, err := annotateAndMerge(files) if err != nil { - return hs, b, notes, fmt.Errorf("error merging manifests: %w", err) + return hs, b, notes, nil, fmt.Errorf("error merging manifests: %w", err) } // Run the post renderer postRendered, err := pr.Run(bytes.NewBufferString(merged)) if err != nil { - return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err) + return hs, b, notes, nil, fmt.Errorf("error while running post render on files: %w", err) } // Use the file list and contents received from the post renderer files, err = splitAndDeannotate(postRendered.String()) if err != nil { - return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err) + return hs, b, notes, nil, fmt.Errorf("error while parsing post rendered output: %w", err) } } @@ -325,7 +332,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, } fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content) } - return hs, b, "", err + return hs, b, "", nil, err } // Aggregate all valid manifests into one big doc. @@ -338,7 +345,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, } else { err = writeToFile(outputDir, crd.Filename, string(crd.File.Data[:]), fileWritten[crd.Filename]) if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } fileWritten[crd.Filename] = true } @@ -363,13 +370,13 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, // used by install or upgrade err = writeToFile(newDir, m.Name, m.Content, fileWritten[m.Name]) if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } fileWritten[m.Name] = true } } - return hs, b, notes, nil + return hs, b, notes, manifests, nil } // RESTClientGetter gets the rest client diff --git a/pkg/action/install.go b/pkg/action/install.go index 6b318fdf3..c48331e3d 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -362,8 +362,14 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st rel := i.createRelease(chrt, vals, i.Labels) + // For ordered installs, also capture the per-file manifest list for DAG grouping. + var sortedManifests []releaseutil.Manifest var manifestDoc *bytes.Buffer - rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret) + if i.WaitStrategy == kube.OrderedWaitStrategy { + rel.Hooks, manifestDoc, rel.Info.Notes, sortedManifests, err = i.cfg.renderResourcesWithFiles(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret) + } else { + rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret) + } // Even for errors, attach this if available if manifestDoc != nil { rel.Manifest = manifestDoc.String() @@ -457,7 +463,11 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return rel, err } - rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources) + if i.WaitStrategy == kube.OrderedWaitStrategy { + rel, err = i.performSequencedInstallCtx(ctx, chrt, rel, sortedManifests) + } else { + rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources) + } if err != nil { rel, err = i.failRelease(rel, err) } @@ -486,6 +496,84 @@ func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, t } } +// performSequencedInstallCtx runs performSequencedInstall in a goroutine and +// respects context cancellation, mirroring the pattern in performInstallCtx. +func (i *Install) performSequencedInstallCtx(ctx context.Context, chrt *chart.Chart, rel *release.Release, manifests []releaseutil.Manifest) (*release.Release, error) { + type Msg struct { + r *release.Release + e error + } + resultChan := make(chan Msg, 1) + + go func() { + i.goroutineCount.Add(1) + rel, err := i.performSequencedInstall(ctx, chrt, rel, manifests) + resultChan <- Msg{rel, err} + i.goroutineCount.Add(-1) + }() + select { + case <-ctx.Done(): + return rel, ctx.Err() + case msg := <-resultChan: + return msg.r, msg.e + } +} + +// performSequencedInstall deploys chart resources in DAG-ordered batches when +// --wait=ordered is active. Subcharts are deployed in dependency order, and within +// each chart, resource-groups are deployed in annotation-declared order. +func (i *Install) performSequencedInstall(ctx context.Context, chrt *chart.Chart, rel *release.Release, manifests []releaseutil.Manifest) (*release.Release, error) { + // pre-install hooks + if !i.DisableHooks { + if err := i.cfg.execHook(rel, release.HookPreInstall, i.WaitStrategy, i.WaitOptions, i.Timeout, i.ServerSideApply); err != nil { + return rel, fmt.Errorf("failed pre-install: %s", err) + } + } + + // Determine per-batch readiness timeout (default 1 minute when unset) + readinessTimeout := i.ReadinessTimeout + if readinessTimeout <= 0 { + readinessTimeout = time.Minute + } + + sd := &sequencedDeployment{ + cfg: i.cfg, + disableOpenAPI: i.DisableOpenAPIValidation, + serverSideApply: i.ServerSideApply, + forceConflicts: i.ForceConflicts, + forceReplace: i.ForceReplace, + waitStrategy: i.WaitStrategy, + waitOptions: i.WaitOptions, + waitForJobs: i.WaitForJobs, + timeout: i.Timeout, + readinessTimeout: readinessTimeout, + deadline: time.Now().Add(i.Timeout), + } + + if err := sd.deployChartLevel(ctx, chrt, manifests); err != nil { + return rel, err + } + + // post-install hooks + if !i.DisableHooks { + if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.WaitOptions, i.Timeout, i.ServerSideApply); err != nil { + return rel, fmt.Errorf("failed post-install: %s", err) + } + } + + if len(i.Description) > 0 { + rel.SetStatus(rcommon.StatusDeployed, i.Description) + } else { + rel.SetStatus(rcommon.StatusDeployed, "Install complete") + } + + if err := i.recordRelease(rel); err != nil { + i.cfg.Logger().Error("failed to record the release", slog.Any("error", err)) + } + + return rel, nil +} + // getGoroutineCount return the number of running routines func (i *Install) getGoroutineCount() int32 { return i.goroutineCount.Load() diff --git a/pkg/action/sequencing.go b/pkg/action/sequencing.go new file mode 100644 index 000000000..404040b18 --- /dev/null +++ b/pkg/action/sequencing.go @@ -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 `/templates/`) are +// returned under the empty string key "". +// Subcharts are keyed by their immediate name under `/charts//`. +// Nested subcharts (e.g., `/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 "/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 +} diff --git a/pkg/action/sequencing_test.go b/pkg/action/sequencing_test.go new file mode 100644 index 000000000..e3e8b7af7 --- /dev/null +++ b/pkg/action/sequencing_test.go @@ -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 +}