diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index 4b061266..5e4fe29a 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -646,28 +646,61 @@ 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 - - for _, f := range flags { - if strings.HasPrefix("--output-dir", f) { - outputToFile = true - break + // Scan flags to detect --output-dir and --post-renderer. + var outputDir string + var hasPostRenderer bool + for i, f := range flags { + if f == "--output-dir" && i+1 < len(flags) { + outputDir = flags[i+1] + } + if f == "--post-renderer" { + hasPostRenderer = true } } - if outputToFile { - // With --output-dir is passed to helm-template, + outputToFile := outputDir != "" + + // When both --output-dir and --post-renderer are used, Helm writes template + // files to the output directory before invoking the post-renderer, which + // means the post-renderer never sees the rendered manifests and the files + // contain unprocessed (pre-post-renderer) content. + // + // To work around this, we strip --output-dir from the flags so that Helm + // sends all rendered manifests through the post-renderer to stdout, and + // then we write the post-rendered output to the output directory ourselves, + // recreating the file-per-template structure using the "# Source:" comment + // that Helm embeds in each rendered document. + if outputToFile && hasPostRenderer { + var filteredFlags []string + for i := 0; i < len(flags); i++ { + if flags[i] == "--output-dir" { + i++ // skip the path value as well + continue + } + filteredFlags = append(filteredFlags, flags[i]) + } + flags = filteredFlags + } + + out, err := helm.exec(append(args, flags...), map[string]string{}, nil) + + switch { + case outputToFile && hasPostRenderer: + // Write the post-rendered manifests to the output directory. + if writeErr := writeRenderedManifests(outputDir, out); writeErr != nil { + return fmt.Errorf("failed to write post-rendered templates to output dir %s: %w", outputDir, writeErr) + } + case outputToFile: + // With --output-dir passed to helm-template, // we can safely direct all the logs from it to our logger. // // It's safe because anything written to stdout by helm-template with output-dir is logs, // like excessive `wrote path/to/output/dir/chart/template/file.yaml` messages, - // but manifets. + // but not manifests. // // See https://github.com/roboll/helmfile/pull/1691#issuecomment-805636021 for more information. helm.info(out) - } else { + default: // Always write to stdout for use with e.g. `helmfile template | kubectl apply -f -` helm.write(nil, out) } @@ -675,6 +708,87 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string) return err } +// writeRenderedManifests writes YAML manifests to the output directory. +// It splits the rendered output by "# Source:" comments to recreate the +// per-template file structure that Helm's --output-dir normally provides. +// Documents that do not carry a "# Source:" comment (e.g. resources added +// by a post-renderer) are collected and written to a single fallback file +// named "manifest.yaml" at the root of the output directory. +func writeRenderedManifests(outputDir string, content []byte) error { + if len(content) == 0 { + return nil + } + + // Split on the YAML document separator. Helm emits "---\n" between + // documents; trim leading/trailing whitespace from each chunk. + rawDocs := bytes.Split(content, []byte("\n---")) + + // fileContents maps relative path → accumulated YAML content. + fileContents := make(map[string][]byte) + // fileOrder preserves insertion order so output is deterministic. + var fileOrder []string + var unsourced [][]byte + + for _, raw := range rawDocs { + doc := bytes.TrimSpace(raw) + if len(doc) == 0 { + continue + } + + // Look for a "# Source: " comment on any line of the document. + var sourcePath string + for _, line := range bytes.Split(doc, []byte("\n")) { + if bytes.HasPrefix(line, []byte("# Source: ")) { + sourcePath = string(bytes.TrimSpace(bytes.TrimPrefix(line, []byte("# Source: ")))) + break + } + } + + if sourcePath != "" { + if _, seen := fileContents[sourcePath]; !seen { + fileOrder = append(fileOrder, sourcePath) + fileContents[sourcePath] = append([]byte("---\n"), doc...) + } else { + fileContents[sourcePath] = append(fileContents[sourcePath], []byte("\n---\n")...) + fileContents[sourcePath] = append(fileContents[sourcePath], doc...) + } + } else { + unsourced = append(unsourced, doc) + } + } + + // Write per-source files. + for _, relPath := range fileOrder { + fullPath := filepath.Join(outputDir, relPath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", fullPath, err) + } + if err := os.WriteFile(fullPath, fileContents[relPath], 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", fullPath, err) + } + } + + // Write unsourced documents (e.g. injected by a post-renderer) to a + // single fallback file. + if len(unsourced) > 0 { + var combined []byte + for _, doc := range unsourced { + combined = append(combined, []byte("---\n")...) + combined = append(combined, doc...) + combined = append(combined, '\n') + } + fallback := filepath.Join(outputDir, "manifest.yaml") + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", outputDir, err) + } + if err := os.WriteFile(fallback, combined, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", fallback, err) + } + } + + return nil +} + func (helm *execer) DiffRelease(context HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error { diffMsg := fmt.Sprintf("Comparing release=%v, chart=%v, namespace=%v\n", name, redactedURL(chart), namespace) if context.Writer != nil && !suppressDiff { diff --git a/pkg/helmexec/exec_test.go b/pkg/helmexec/exec_test.go index c468c8f5..ee44b2d6 100644 --- a/pkg/helmexec/exec_test.go +++ b/pkg/helmexec/exec_test.go @@ -1255,6 +1255,76 @@ exec: helm --kubeconfig config --kube-context dev template release https://examp } } +func Test_WriteRenderedManifests(t *testing.T) { + t.Run("sourced documents are written to per-file paths", func(t *testing.T) { + dir := t.TempDir() + content := []byte("---\n# Source: chart/templates/deployment.yaml\napiVersion: apps/v1\nkind: Deployment\n\n---\n# Source: chart/templates/service.yaml\napiVersion: v1\nkind: Service\n") + require.NoError(t, writeRenderedManifests(dir, content)) + + deployment, err := os.ReadFile(filepath.Join(dir, "chart/templates/deployment.yaml")) + require.NoError(t, err) + require.Contains(t, string(deployment), "kind: Deployment") + + service, err := os.ReadFile(filepath.Join(dir, "chart/templates/service.yaml")) + require.NoError(t, err) + require.Contains(t, string(service), "kind: Service") + }) + + t.Run("documents without Source comment are written to manifest.yaml", func(t *testing.T) { + dir := t.TempDir() + content := []byte("---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: prometheus\n") + require.NoError(t, writeRenderedManifests(dir, content)) + + manifest, err := os.ReadFile(filepath.Join(dir, "manifest.yaml")) + require.NoError(t, err) + require.Contains(t, string(manifest), "kind: Namespace") + }) + + t.Run("empty content is a no-op", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, writeRenderedManifests(dir, nil)) + require.NoError(t, writeRenderedManifests(dir, []byte{})) + }) + + t.Run("mixed sourced and unsourced documents", func(t *testing.T) { + dir := t.TempDir() + content := []byte("---\n# Source: chart/templates/deployment.yaml\napiVersion: apps/v1\nkind: Deployment\n\n---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: added-by-post-renderer\n") + require.NoError(t, writeRenderedManifests(dir, content)) + + deployment, err := os.ReadFile(filepath.Join(dir, "chart/templates/deployment.yaml")) + require.NoError(t, err) + require.Contains(t, string(deployment), "kind: Deployment") + + manifest, err := os.ReadFile(filepath.Join(dir, "manifest.yaml")) + require.NoError(t, err) + require.Contains(t, string(manifest), "kind: Namespace") + }) +} + +func Test_TemplateRelease_OutputDirWithPostRenderer(t *testing.T) { + // When both --output-dir and --post-renderer are specified, TemplateRelease + // should strip --output-dir from the flags forwarded to helm so that the + // post-renderer actually receives the rendered manifests. + outputDir := t.TempDir() + + manifests := "---\n# Source: chart/templates/configmap.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n" + runner := &mockRunner{output: []byte(manifests)} + logger := NewLogger(io.Discard, "error") + helm, err := New("helm", HelmExecOptions{}, logger, "", "", runner) + require.NoError(t, err) + + err = helm.TemplateRelease("release", "chart", + "--output-dir", outputDir, + "--post-renderer", "./my-post-renderer.sh", + ) + require.NoError(t, err) + + // The file should exist and contain the (mock) post-rendered content. + content, err := os.ReadFile(filepath.Join(outputDir, "chart/templates/configmap.yaml")) + require.NoError(t, err) + require.Contains(t, string(content), "kind: ConfigMap") +} + 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)