fix: apply post-renderer to output-dir-template output (#2531)

* fix: apply post-renderer to output-dir-template output

When --output-dir and --post-renderer are both passed to helm template,
Helm writes pre-post-renderer content to files and sends post-renderer
output to stdout. This workaround strips --output-dir from helm flags,
captures the post-renderer-processed stdout, and writes it to the output
directory.

Fixes #2515

Signed-off-by: yxxhero <aiopsclub@163.com>

* test: add integration test for issue-2515 (post-renderer with output-dir-template)

Verifies that --post-renderer output is written to files when
--output-dir-template is set, instead of pre-renderer content.

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: address review comments - correct HasPrefix args, fix output dir structure, fix test mock init

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/33d92423-fc47-4080-8307-5af9b16dd9c6

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* fix: wrap file operation errors with context in post-renderer workaround

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/33d92423-fc47-4080-8307-5af9b16dd9c6

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* fix: correct chart path and use absolute case dir path in integration test

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/43b7a794-1e7b-4577-8829-deb544a1a105

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* fix: restrict --output-dir + --post-renderer workaround to Helm 3 only

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/229b14e2-b1ad-4f19-bd00-b8f7821383cd

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* fix: clean up stale templates dir on re-runs in Helm 3 post-renderer workaround

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/f6c66284-8eca-4db3-8711-c9b6d3a9c179

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* fix: detect --post-renderer=<path> form and use targeted file cleanup

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/8c9e4af4-84ae-4cbd-bc0a-8fcd9adddaed

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* feat: add Helm 4 post-renderer plugin and enable Helm 4 issue-2515 integration test

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/3da2949c-a9d6-4e16-9b4a-a7e241080089

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* fix: search recursively for YAML files in Helm 4 output-dir integration test

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/c5d33143-f611-40db-b73a-e5189d944ffd

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

* fix: limit find depth and truncate log in Helm 4 integration test fallback message

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/c5d33143-f611-40db-b73a-e5189d944ffd

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

---------

Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
yxxhero
2026-04-28 09:01:48 +08:00
committed by GitHub
co-authored by copilot-swe-agent[bot]
parent b50c9d6256
commit 9fa0529304
8 changed files with 224 additions and 8 deletions
+64 -4
View File
@@ -646,17 +646,77 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
helm.logger.Infof("Templating release=%v, chart=%v", name, redactedURL(chart))
args := []string{"template", name, chart}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
var outputToFile bool
var hasPostRenderer bool
for _, f := range flags {
if strings.HasPrefix("--output-dir", f) {
if f == "--output-dir" || strings.HasPrefix(f, "--output-dir=") {
outputToFile = true
break
}
if f == "--post-renderer" || strings.HasPrefix(f, "--post-renderer=") {
hasPostRenderer = true
}
}
if outputToFile && hasPostRenderer && helm.IsHelm3() {
// Helm 3 does not apply --post-renderer to files written by --output-dir.
// It writes pre-post-renderer content to files and sends post-renderer output to stdout.
// Helm 4 handles this correctly, so the workaround is only needed for Helm 3.
// Workaround: run without --output-dir, capture stdout (with post-renderer applied),
// and write the output to the output directory ourselves.
var outputDir string
filteredFlags := make([]string, 0, len(flags))
for i := 0; i < len(flags); i++ {
if flags[i] == "--output-dir" && i+1 < len(flags) {
outputDir = flags[i+1]
i++
continue
}
if strings.HasPrefix(flags[i], "--output-dir=") {
outputDir = strings.TrimPrefix(flags[i], "--output-dir=")
continue
}
filteredFlags = append(filteredFlags, flags[i])
}
if outputDir == "" {
return fmt.Errorf("output dir not found for template command")
}
out, err := helm.exec(append(args, filteredFlags...), map[string]string{}, nil)
if err != nil {
return err
}
templatesDir := filepath.Join(outputDir, "templates")
legacyOutputPath := filepath.Join(outputDir, name+".yaml")
outputPath := filepath.Join(templatesDir, name+".yaml")
if removeErr := os.Remove(legacyOutputPath); removeErr != nil && !os.IsNotExist(removeErr) {
return fmt.Errorf("failed to remove legacy output file %s: %w", legacyOutputPath, removeErr)
}
// Remove only the specific file written by the previous run to avoid clobbering
// unrelated files in a shared output directory.
if removeErr := os.Remove(outputPath); removeErr != nil && !os.IsNotExist(removeErr) {
return fmt.Errorf("failed to remove stale output file %s: %w", outputPath, removeErr)
}
if len(out) > 0 {
if mkdirErr := os.MkdirAll(templatesDir, 0755); mkdirErr != nil {
return fmt.Errorf("failed to create templates directory %s: %w", templatesDir, mkdirErr)
}
if writeErr := os.WriteFile(outputPath, append(out, '\n'), 0644); writeErr != nil {
return fmt.Errorf("failed to write output file %s: %w", outputPath, writeErr)
}
helm.logger.Debugf("Wrote post-renderer output to %s", outputPath)
}
return nil
}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
if outputToFile {
// With --output-dir is passed to helm-template,
// we can safely direct all the logs from it to our logger.
+68 -4
View File
@@ -21,8 +21,9 @@ import (
// Mocking the command-line runner
type mockRunner struct {
output []byte
err error
output []byte
versionOutput []byte // if set, returned for "helm version --short" probe; overrides default Helm 4 fallback
err error
}
func (mock *mockRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
@@ -30,8 +31,13 @@ func (mock *mockRunner) ExecuteStdIn(cmd string, args []string, env map[string]s
}
func (mock *mockRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
if len(mock.output) == 0 && strings.Join(args, " ") == "version --short" {
return []byte("v4.0.1+g12500dd"), nil
if strings.Join(args, " ") == "version --short" {
if mock.versionOutput != nil {
return mock.versionOutput, nil
}
if len(mock.output) == 0 {
return []byte("v4.0.1+g12500dd"), nil
}
}
return mock.output, mock.err
}
@@ -1255,6 +1261,64 @@ exec: helm --kubeconfig config --kube-context dev template release https://examp
}
}
func Test_Template_PostRendererWithOutputDir(t *testing.T) {
tests := []struct {
name string
postRendererFlag string
}{
{"separate flags", "--post-renderer"},
{"combined flag", "--post-renderer=/bin/echo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
// Use Helm 3 version for the version probe so the Helm 3 workaround is applied.
// The workaround is not needed for Helm 4, which natively applies --post-renderer to --output-dir output.
runner := &mockRunner{versionOutput: []byte("v3.20.0")}
helm, err := New("helm", HelmExecOptions{}, logger, "config", "dev", runner)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
runner.output = []byte("apiVersion: v1\nkind: Namespace\n")
var flags []string
if tt.postRendererFlag == "--post-renderer" {
flags = []string{"--post-renderer", "/bin/echo", "--output-dir", tmpDir, "--values", "file.yml"}
} else {
flags = []string{tt.postRendererFlag, "--output-dir", tmpDir, "--values", "file.yml"}
}
err = helm.TemplateRelease("myrelease", "path/to/chart", flags...)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
outputPath := filepath.Join(tmpDir, "templates", "myrelease.yaml")
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("expected output file %s to exist: %v", outputPath, err)
}
expected := "apiVersion: v1\nkind: Namespace\n\n"
if string(data) != expected {
t.Errorf("output file content:\nactual=%q\nexpect=%q", string(data), expected)
}
outputLog := buffer.String()
if strings.Contains(outputLog, "--output-dir") {
t.Errorf("helm should NOT have been called with --output-dir, got: %s", outputLog)
}
if !strings.Contains(outputLog, "--post-renderer") {
t.Errorf("helm should have been called with --post-renderer, got: %s", outputLog)
}
})
}
}
func Test_IsHelm3(t *testing.T) {
helm3Runner := mockRunner{output: []byte("v3.0.0+ge29ce2a\n")}
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)