From 5bbe84ec4f6bd70a740498682a4e060ce459c42c Mon Sep 17 00:00:00 2001 From: Dmitriy Aratin Date: Sun, 25 Jan 2026 23:45:21 +0700 Subject: [PATCH 1/2] feat(template): add --skip-chart-dir and --skip-templates-dir flags Add two new flags to make --output-dir fully configurable: - --skip-chart-dir: skips adding the chart name directory - --skip-templates-dir: skips adding the templates subdirectory This allows users to customize the output directory structure for better alignment with GitOps workflows where manifests need to be stored in specific file structures. Example usage: helm template my-app ./mychart --output-dir gitops/cluster/app \ --skip-chart-dir --skip-templates-dir Before: gitops/cluster/app/mychart/templates/deployment.yaml After: gitops/cluster/app/deployment.yaml Fixes #31747 Signed-off-by: Dmitriy Aratin --- pkg/action/action.go | 47 +++++++++++++++-- pkg/action/action_test.go | 107 +++++++++++++++++++++++++++++++++++--- pkg/action/install.go | 8 ++- pkg/action/upgrade.go | 2 +- pkg/cmd/template.go | 44 ++++++++++++++-- 5 files changed, 192 insertions(+), 16 deletions(-) diff --git a/pkg/action/action.go b/pkg/action/action.go index c2a27940f..c0a444032 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -213,13 +213,48 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { return reconstructed, nil } +// transformManifestPath modifies the manifest path based on the skipChartNameDir and skipTemplatesDir flags. +// The input path is typically in the format "chart-name/templates/file.yaml" or "chart-name/charts/subchart/templates/file.yaml" +// - skipChartNameDir: removes the root chart name directory +// - skipTemplatesDir: removes all "templates" directories from the path +func transformManifestPath(name string, skipChartNameDir, skipTemplatesDir bool) string { + if !skipChartNameDir && !skipTemplatesDir { + return name + } + + parts := strings.Split(name, "/") + if len(parts) == 0 { + return name + } + + var result []string + + for i, part := range parts { + // Skip the first part (chart name) if skipChartNameDir is true + if i == 0 && skipChartNameDir { + continue + } + // Skip "templates" directories if skipTemplatesDir is true + if skipTemplatesDir && part == "templates" { + continue + } + result = append(result, part) + } + + if len(result) == 0 { + return name + } + + return strings.Join(result, "/") +} + // renderResources renders the templates in a chart // // TODO: This function is badly in need of a refactor. // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // // 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) { +func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret, skipChartNameDir, skipTemplatesDir bool) ([]*release.Hook, *bytes.Buffer, string, error) { var hs []*release.Hook b := bytes.NewBuffer(nil) @@ -336,11 +371,12 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, if outputDir == "" { fmt.Fprintf(b, "---\n# Source: %s\n%s\n", crd.Filename, string(crd.File.Data[:])) } else { - err = writeToFile(outputDir, crd.Filename, string(crd.File.Data[:]), fileWritten[crd.Filename]) + transformedName := transformManifestPath(crd.Filename, skipChartNameDir, skipTemplatesDir) + err = writeToFile(outputDir, transformedName, string(crd.File.Data[:]), fileWritten[transformedName]) if err != nil { return hs, b, "", err } - fileWritten[crd.Filename] = true + fileWritten[transformedName] = true } } } @@ -361,11 +397,12 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, // output dir is only used by `helm template`. In the next major // release, we should move this logic to template only as it is not // used by install or upgrade - err = writeToFile(newDir, m.Name, m.Content, fileWritten[m.Name]) + transformedName := transformManifestPath(m.Name, skipChartNameDir, skipTemplatesDir) + err = writeToFile(newDir, transformedName, m.Content, fileWritten[transformedName]) if err != nil { return hs, b, "", err } - fileWritten[m.Name] = true + fileWritten[transformedName] = true } } diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 85ee42d64..5e57abfa6 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -799,7 +799,7 @@ func TestRenderResources_PostRenderer_Success(t *testing.T) { hooks, buf, notes, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, false, false, ) assert.NoError(t, err) @@ -842,7 +842,7 @@ func TestRenderResources_PostRenderer_Error(t *testing.T) { _, _, _, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, false, false, ) assert.Error(t, err) @@ -870,7 +870,7 @@ func TestRenderResources_PostRenderer_MergeError(t *testing.T) { _, _, _, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, false, false, ) assert.Error(t, err) @@ -892,7 +892,7 @@ func TestRenderResources_PostRenderer_SplitError(t *testing.T) { _, _, _, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, false, false, ) assert.Error(t, err) @@ -913,7 +913,7 @@ func TestRenderResources_PostRenderer_Integration(t *testing.T) { hooks, buf, notes, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, false, false, ) assert.NoError(t, err) @@ -949,7 +949,7 @@ func TestRenderResources_NoPostRenderer(t *testing.T) { hooks, buf, notes, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - nil, false, false, false, + nil, false, false, false, false, false, ) assert.NoError(t, err) @@ -974,3 +974,98 @@ func TestInteractWithServer(t *testing.T) { assert.False(t, interactWithServer(DryRunClient)) assert.True(t, interactWithServer(DryRunServer)) } + +func TestTransformManifestPath(t *testing.T) { + tests := []struct { + name string + input string + skipChartNameDir bool + skipTemplatesDir bool + expected string + }{ + { + name: "no transformation", + input: "mychart/templates/deployment.yaml", + skipChartNameDir: false, + skipTemplatesDir: false, + expected: "mychart/templates/deployment.yaml", + }, + { + name: "skip chart name only", + input: "mychart/templates/deployment.yaml", + skipChartNameDir: true, + skipTemplatesDir: false, + expected: "templates/deployment.yaml", + }, + { + name: "skip templates dir only", + input: "mychart/templates/deployment.yaml", + skipChartNameDir: false, + skipTemplatesDir: true, + expected: "mychart/deployment.yaml", + }, + { + name: "skip both chart name and templates dir", + input: "mychart/templates/deployment.yaml", + skipChartNameDir: true, + skipTemplatesDir: true, + expected: "deployment.yaml", + }, + { + name: "subchart path - skip chart name", + input: "mychart/charts/subchart/templates/deployment.yaml", + skipChartNameDir: true, + skipTemplatesDir: false, + expected: "charts/subchart/templates/deployment.yaml", + }, + { + name: "subchart path - skip templates", + input: "mychart/charts/subchart/templates/deployment.yaml", + skipChartNameDir: false, + skipTemplatesDir: true, + expected: "mychart/charts/subchart/deployment.yaml", + }, + { + name: "subchart path - skip both", + input: "mychart/charts/subchart/templates/deployment.yaml", + skipChartNameDir: true, + skipTemplatesDir: true, + expected: "charts/subchart/deployment.yaml", + }, + { + name: "crds path - skip chart name", + input: "mychart/crds/crd.yaml", + skipChartNameDir: true, + skipTemplatesDir: false, + expected: "crds/crd.yaml", + }, + { + name: "crds path - skip templates (no effect)", + input: "mychart/crds/crd.yaml", + skipChartNameDir: false, + skipTemplatesDir: true, + expected: "mychart/crds/crd.yaml", + }, + { + name: "single filename - skip both returns original", + input: "deployment.yaml", + skipChartNameDir: true, + skipTemplatesDir: true, + expected: "deployment.yaml", + }, + { + name: "empty string", + input: "", + skipChartNameDir: true, + skipTemplatesDir: true, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := transformManifestPath(tt.input, tt.skipChartNameDir, tt.skipTemplatesDir) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/action/install.go b/pkg/action/install.go index 0fe1f1a6e..2b589cad9 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -127,6 +127,12 @@ type Install struct { // Used by helm template to add the release as part of OutputDir path // OutputDir/ UseReleaseName bool + // SkipChartNameDir skips adding the chart name directory when writing to OutputDir + // When true: OutputDir/templates/file.yaml instead of OutputDir/chart-name/templates/file.yaml + SkipChartNameDir bool + // SkipTemplatesDir skips adding the "templates" subdirectory when writing to OutputDir + // When true: OutputDir/chart-name/file.yaml instead of OutputDir/chart-name/templates/file.yaml + SkipTemplatesDir bool // TakeOwnership will ignore the check for helm annotations and take ownership of the resources. TakeOwnership bool PostRenderer postrenderer.PostRenderer @@ -355,7 +361,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st rel := i.createRelease(chrt, vals, i.Labels) 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) + 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, i.SkipChartNameDir, i.SkipTemplatesDir) // Even for errors, attach this if available if manifestDoc != nil { rel.Manifest = manifestDoc.String() diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 4c93855b1..a25be7b75 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -296,7 +296,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str return nil, nil, false, err } - hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret) + hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, false, false) if err != nil { return nil, nil, false, err } diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index 14f85042b..e4eb678e7 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -132,12 +132,13 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if client.UseReleaseName { newDir = filepath.Join(client.OutputDir, client.ReleaseName) } - _, err := os.Stat(filepath.Join(newDir, m.Path)) + transformedPath := transformManifestPath(m.Path, client.SkipChartNameDir, client.SkipTemplatesDir) + _, err := os.Stat(filepath.Join(newDir, transformedPath)) if err == nil { - fileWritten[m.Path] = true + fileWritten[transformedPath] = true } - err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) + err = writeToFile(newDir, transformedPath, m.Manifest, fileWritten[transformedPath]) if err != nil { return err } @@ -214,6 +215,8 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)") f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.") + f.BoolVar(&client.SkipChartNameDir, "skip-chart-dir", false, "skip adding the chart name directory when writing to output-dir") + f.BoolVar(&client.SkipTemplatesDir, "skip-templates-dir", false, "skip adding the templates subdirectory when writing to output-dir") f.String( "dry-run", "client", @@ -275,3 +278,38 @@ func ensureDirectoryForFile(file string) error { return os.MkdirAll(baseDir, 0755) } + +// transformManifestPath modifies the manifest path based on the skipChartNameDir and skipTemplatesDir flags. +// The input path is typically in the format "chart-name/templates/file.yaml" or "chart-name/charts/subchart/templates/file.yaml" +// - skipChartNameDir: removes the root chart name directory +// - skipTemplatesDir: removes all "templates" directories from the path +func transformManifestPath(name string, skipChartNameDir, skipTemplatesDir bool) string { + if !skipChartNameDir && !skipTemplatesDir { + return name + } + + parts := strings.Split(name, "/") + if len(parts) == 0 { + return name + } + + var result []string + + for i, part := range parts { + // Skip the first part (chart name) if skipChartNameDir is true + if i == 0 && skipChartNameDir { + continue + } + // Skip "templates" directories if skipTemplatesDir is true + if skipTemplatesDir && part == "templates" { + continue + } + result = append(result, part) + } + + if len(result) == 0 { + return name + } + + return strings.Join(result, "/") +} From 0e9b006b16946a9c150172eef7eeb8ebbbeb6a2f Mon Sep 17 00:00:00 2001 From: Dmitriy Aratin Date: Wed, 28 Jan 2026 23:15:28 +0700 Subject: [PATCH 2/2] refactor(template): consolidate TransformManifestPath in action package - Export TransformManifestPath from pkg/action instead of duplicating - Add check to skip empty path parts (handles leading/double slashes) - Remove duplicate function from pkg/cmd/template.go Co-Authored-By: Claude Opus 4.5 Signed-off-by: Dmitriy Aratin --- pkg/action/action.go | 12 ++++++++---- pkg/action/action_test.go | 2 +- pkg/cmd/template.go | 39 ++------------------------------------- 3 files changed, 11 insertions(+), 42 deletions(-) diff --git a/pkg/action/action.go b/pkg/action/action.go index c0a444032..0dd48f447 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -213,11 +213,11 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { return reconstructed, nil } -// transformManifestPath modifies the manifest path based on the skipChartNameDir and skipTemplatesDir flags. +// TransformManifestPath modifies the manifest path based on the skipChartNameDir and skipTemplatesDir flags. // The input path is typically in the format "chart-name/templates/file.yaml" or "chart-name/charts/subchart/templates/file.yaml" // - skipChartNameDir: removes the root chart name directory // - skipTemplatesDir: removes all "templates" directories from the path -func transformManifestPath(name string, skipChartNameDir, skipTemplatesDir bool) string { +func TransformManifestPath(name string, skipChartNameDir, skipTemplatesDir bool) string { if !skipChartNameDir && !skipTemplatesDir { return name } @@ -230,6 +230,10 @@ func transformManifestPath(name string, skipChartNameDir, skipTemplatesDir bool) var result []string for i, part := range parts { + // Skip empty parts (e.g., from leading slash or double slashes) + if part == "" { + continue + } // Skip the first part (chart name) if skipChartNameDir is true if i == 0 && skipChartNameDir { continue @@ -371,7 +375,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, if outputDir == "" { fmt.Fprintf(b, "---\n# Source: %s\n%s\n", crd.Filename, string(crd.File.Data[:])) } else { - transformedName := transformManifestPath(crd.Filename, skipChartNameDir, skipTemplatesDir) + transformedName := TransformManifestPath(crd.Filename, skipChartNameDir, skipTemplatesDir) err = writeToFile(outputDir, transformedName, string(crd.File.Data[:]), fileWritten[transformedName]) if err != nil { return hs, b, "", err @@ -397,7 +401,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, // output dir is only used by `helm template`. In the next major // release, we should move this logic to template only as it is not // used by install or upgrade - transformedName := transformManifestPath(m.Name, skipChartNameDir, skipTemplatesDir) + transformedName := TransformManifestPath(m.Name, skipChartNameDir, skipTemplatesDir) err = writeToFile(newDir, transformedName, m.Content, fileWritten[transformedName]) if err != nil { return hs, b, "", err diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 5e57abfa6..38e4fba83 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -1064,7 +1064,7 @@ func TestTransformManifestPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := transformManifestPath(tt.input, tt.skipChartNameDir, tt.skipTemplatesDir) + result := TransformManifestPath(tt.input, tt.skipChartNameDir, tt.skipTemplatesDir) assert.Equal(t, tt.expected, result) }) } diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index e4eb678e7..afd9b584c 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -132,7 +132,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if client.UseReleaseName { newDir = filepath.Join(client.OutputDir, client.ReleaseName) } - transformedPath := transformManifestPath(m.Path, client.SkipChartNameDir, client.SkipTemplatesDir) + transformedPath := action.TransformManifestPath(m.Path, client.SkipChartNameDir, client.SkipTemplatesDir) _, err := os.Stat(filepath.Join(newDir, transformedPath)) if err == nil { fileWritten[transformedPath] = true @@ -277,39 +277,4 @@ func ensureDirectoryForFile(file string) error { } return os.MkdirAll(baseDir, 0755) -} - -// transformManifestPath modifies the manifest path based on the skipChartNameDir and skipTemplatesDir flags. -// The input path is typically in the format "chart-name/templates/file.yaml" or "chart-name/charts/subchart/templates/file.yaml" -// - skipChartNameDir: removes the root chart name directory -// - skipTemplatesDir: removes all "templates" directories from the path -func transformManifestPath(name string, skipChartNameDir, skipTemplatesDir bool) string { - if !skipChartNameDir && !skipTemplatesDir { - return name - } - - parts := strings.Split(name, "/") - if len(parts) == 0 { - return name - } - - var result []string - - for i, part := range parts { - // Skip the first part (chart name) if skipChartNameDir is true - if i == 0 && skipChartNameDir { - continue - } - // Skip "templates" directories if skipTemplatesDir is true - if skipTemplatesDir && part == "templates" { - continue - } - result = append(result, part) - } - - if len(result) == 0 { - return name - } - - return strings.Join(result, "/") -} +} \ No newline at end of file