From 00542abacfb60e7bc5f29a36e934f57aee820832 Mon Sep 17 00:00:00 2001 From: caretak3r <50377477+caretak3r@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:27:29 -0500 Subject: [PATCH] feat(spec): Task 9 - helm template resource-group delimiters When --wait=ordered is used with helm template, output manifests in resource-group DAG order with START/END delimiters per HIP-0025 spec. Unsequenced resources are output last without delimiters. --- pkg/cmd/template.go | 206 ++++++++++++------ pkg/cmd/template_test.go | 5 + .../output/template-ordered-delimiters.txt | 33 +++ .../testcharts/sequenced-chart/Chart.yaml | 5 + .../templates/aa-databases-configmap.yaml | 8 + .../templates/bb-app-configmap.yaml | 9 + .../templates/cc-unsequenced-configmap.yaml | 6 + 7 files changed, 206 insertions(+), 66 deletions(-) create mode 100644 pkg/cmd/testdata/output/template-ordered-delimiters.txt create mode 100644 pkg/cmd/testdata/testcharts/sequenced-chart/Chart.yaml create mode 100644 pkg/cmd/testdata/testcharts/sequenced-chart/templates/aa-databases-configmap.yaml create mode 100644 pkg/cmd/testdata/testcharts/sequenced-chart/templates/bb-app-configmap.yaml create mode 100644 pkg/cmd/testdata/testcharts/sequenced-chart/templates/cc-unsequenced-configmap.yaml diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index 047fd60df..226955223 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "io/fs" + "log/slog" "os" "path/filepath" "regexp" @@ -37,6 +38,7 @@ import ( "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/kube" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) @@ -117,84 +119,100 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { // We ignore a potential error here because, when the --debug flag was specified, // we always want to print the YAML, even if it is not valid. The error is still returned afterwards. if rel != nil { - var manifests bytes.Buffer - fmt.Fprintln(&manifests, strings.TrimSpace(rel.Manifest)) - if !client.DisableHooks { - fileWritten := make(map[string]bool) - for _, m := range rel.Hooks { - if skipTests && isTestHook(m) { - continue + // When --wait=ordered is set and no file filters or output-dir are active, + // render manifests in resource-group DAG order with delimiters. + if client.WaitStrategy == kube.OrderedWaitStrategy && len(showFiles) == 0 && client.OutputDir == "" { + if err2 := renderOrderedTemplate(strings.TrimSpace(rel.Manifest), out); err2 != nil { + return err2 + } + if !client.DisableHooks { + for _, m := range rel.Hooks { + if skipTests && isTestHook(m) { + continue + } + fmt.Fprintf(out, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) } - if client.OutputDir == "" { - fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) - } else { - newDir := client.OutputDir - if client.UseReleaseName { - newDir = filepath.Join(client.OutputDir, client.ReleaseName) + } + } else { + var manifests bytes.Buffer + fmt.Fprintln(&manifests, strings.TrimSpace(rel.Manifest)) + if !client.DisableHooks { + fileWritten := make(map[string]bool) + for _, m := range rel.Hooks { + if skipTests && isTestHook(m) { + continue } - _, err := os.Stat(filepath.Join(newDir, m.Path)) - if err == nil { - fileWritten[m.Path] = true + if client.OutputDir == "" { + fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) + } else { + newDir := client.OutputDir + if client.UseReleaseName { + newDir = filepath.Join(client.OutputDir, client.ReleaseName) + } + _, err := os.Stat(filepath.Join(newDir, m.Path)) + if err == nil { + fileWritten[m.Path] = true + } + + err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) + if err != nil { + return err + } } - err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) - if err != nil { - return err - } } - } - } - // if we have a list of files to render, then check that each of the - // provided files exists in the chart. - if len(showFiles) > 0 { - // This is necessary to ensure consistent manifest ordering when using --show-only - // with globs or directory names. - splitManifests := releaseutil.SplitManifests(manifests.String()) - manifestsKeys := make([]string, 0, len(splitManifests)) - for k := range splitManifests { - manifestsKeys = append(manifestsKeys, k) - } - sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys)) - - manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)") - var manifestsToRender []string - for _, f := range showFiles { - missing := true - // Use linux-style filepath separators to unify user's input path - f = filepath.ToSlash(f) - for _, manifestKey := range manifestsKeys { - manifest := splitManifests[manifestKey] - submatch := manifestNameRegex.FindStringSubmatch(manifest) - if len(submatch) == 0 { - continue + // if we have a list of files to render, then check that each of the + // provided files exists in the chart. + if len(showFiles) > 0 { + // This is necessary to ensure consistent manifest ordering when using --show-only + // with globs or directory names. + splitManifests := releaseutil.SplitManifests(manifests.String()) + manifestsKeys := make([]string, 0, len(splitManifests)) + for k := range splitManifests { + manifestsKeys = append(manifestsKeys, k) + } + sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys)) + + manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)") + var manifestsToRender []string + for _, f := range showFiles { + missing := true + // Use linux-style filepath separators to unify user's input path + f = filepath.ToSlash(f) + for _, manifestKey := range manifestsKeys { + manifest := splitManifests[manifestKey] + submatch := manifestNameRegex.FindStringSubmatch(manifest) + if len(submatch) == 0 { + continue + } + manifestName := submatch[1] + // manifest.Name is rendered using linux-style filepath separators on Windows as + // well as macOS/linux. + manifestPathSplit := strings.Split(manifestName, "/") + // manifest.Path is connected using linux-style filepath separators on Windows as + // well as macOS/linux + manifestPath := strings.Join(manifestPathSplit, "/") + + // if the filepath provided matches a manifest path in the + // chart, render that manifest + if matched, _ := filepath.Match(f, manifestPath); !matched { + continue + } + manifestsToRender = append(manifestsToRender, manifest) + missing = false } - manifestName := submatch[1] - // manifest.Name is rendered using linux-style filepath separators on Windows as - // well as macOS/linux. - manifestPathSplit := strings.Split(manifestName, "/") - // manifest.Path is connected using linux-style filepath separators on Windows as - // well as macOS/linux - manifestPath := strings.Join(manifestPathSplit, "/") - - // if the filepath provided matches a manifest path in the - // chart, render that manifest - if matched, _ := filepath.Match(f, manifestPath); !matched { - continue + if missing { + return fmt.Errorf("could not find template %s in chart", f) } - manifestsToRender = append(manifestsToRender, manifest) - missing = false } - if missing { - return fmt.Errorf("could not find template %s in chart", f) + for _, m := range manifestsToRender { + fmt.Fprintf(out, "---\n%s\n", m) } + } else { + fmt.Fprintf(out, "%s", manifests.String()) } - for _, m := range manifestsToRender { - fmt.Fprintf(out, "---\n%s\n", m) - } - } else { - fmt.Fprintf(out, "%s", manifests.String()) } } @@ -225,6 +243,62 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return cmd } +// renderOrderedTemplate outputs manifests in resource-group DAG order with +// ## START resource-group / ## END resource-group delimiters per the HIP-0025 spec. +// Resources without a resource-group annotation are output after all sequenced groups. +func renderOrderedTemplate(manifest string, out io.Writer) error { + if manifest == "" { + return nil + } + + rawManifests := releaseutil.SplitManifests(manifest) + _, sortedManifests, err := releaseutil.SortManifests(rawManifests, nil, releaseutil.InstallOrder) + if err != nil { + return fmt.Errorf("sorting manifests for ordered output: %w", err) + } + + result, warnings := releaseutil.ParseResourceGroups(sortedManifests) + for _, w := range warnings { + slog.Warn("resource-group annotation warning during helm template", "warning", w) + } + + // If no resource-groups present, output in sorted install order without delimiters. + if len(result.Groups) == 0 { + for _, m := range sortedManifests { + fmt.Fprintf(out, "---\n%s\n", m.Content) + } + return nil + } + + dag, err := releaseutil.BuildResourceGroupDAG(result) + if err != nil { + return fmt.Errorf("building resource-group DAG for ordered output: %w", err) + } + batches, err := dag.GetBatches() + if err != nil { + return fmt.Errorf("computing resource-group batches for ordered output: %w", err) + } + + // Output each batch's groups with START/END delimiters. + // m.Content already contains the "# Source:" comment from rel.Manifest. + for _, batch := range batches { + for _, groupName := range batch { + fmt.Fprintf(out, "## START resource-group: %s\n", groupName) + for _, m := range result.Groups[groupName] { + fmt.Fprintf(out, "---\n%s\n", m.Content) + } + fmt.Fprintf(out, "## END resource-group: %s\n", groupName) + } + } + + // Output unsequenced resources without delimiters. + for _, m := range result.Unsequenced { + fmt.Fprintf(out, "---\n%s\n", m.Content) + } + + return nil +} + func isTestHook(h *release.Hook) bool { return slices.Contains(h.Events, release.HookTest) } diff --git a/pkg/cmd/template_test.go b/pkg/cmd/template_test.go index 5bcccf5d0..c127d000f 100644 --- a/pkg/cmd/template_test.go +++ b/pkg/cmd/template_test.go @@ -166,6 +166,11 @@ func TestTemplateCmd(t *testing.T) { cmd: fmt.Sprintf("template '%s' -f %s/extra_values.yaml", chartPath, chartPath), golden: "output/template-subchart-cm-set-file.txt", }, + { + name: "template with ordered wait strategy shows resource group delimiters", + cmd: "template --wait=ordered 'testdata/testcharts/sequenced-chart'", + golden: "output/template-ordered-delimiters.txt", + }, } runTestCmd(t, tests) } diff --git a/pkg/cmd/testdata/output/template-ordered-delimiters.txt b/pkg/cmd/testdata/output/template-ordered-delimiters.txt new file mode 100644 index 000000000..2ec83b7fb --- /dev/null +++ b/pkg/cmd/testdata/output/template-ordered-delimiters.txt @@ -0,0 +1,33 @@ +## START resource-group: databases +--- +# Source: sequenced-chart/templates/aa-databases-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: database-config + annotations: + helm.sh/resource-group: databases +data: + host: localhost +## END resource-group: databases +## START resource-group: app +--- +# Source: sequenced-chart/templates/bb-app-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + annotations: + helm.sh/resource-group: app + helm.sh/depends-on/resource-groups: '["databases"]' +data: + db_host: localhost +## END resource-group: app +--- +# Source: sequenced-chart/templates/cc-unsequenced-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: unsequenced-config +data: + key: value diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/Chart.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/Chart.yaml new file mode 100644 index 000000000..c2f918e62 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: sequenced-chart +description: A test chart with resource-group sequencing annotations +type: application +version: 0.1.0 diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/templates/aa-databases-configmap.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/aa-databases-configmap.yaml new file mode 100644 index 000000000..8ea614d38 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/aa-databases-configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: database-config + annotations: + helm.sh/resource-group: databases +data: + host: localhost diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/templates/bb-app-configmap.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/bb-app-configmap.yaml new file mode 100644 index 000000000..0472c0318 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/bb-app-configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + annotations: + helm.sh/resource-group: app + helm.sh/depends-on/resource-groups: '["databases"]' +data: + db_host: localhost diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/templates/cc-unsequenced-configmap.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/cc-unsequenced-configmap.yaml new file mode 100644 index 000000000..03454569c --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/cc-unsequenced-configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: unsequenced-config +data: + key: value