mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-18 04:45:42 +02:00
fix: ensure post-renderer is applied when --output-dir-template is used
When helmfile template is run with both --output-dir (from --output-dir-template) and --post-renderer, Helm writes template files to the output directory before invoking the post-renderer, bypassing it entirely. Fix this by stripping --output-dir from the flags passed to Helm when a post-renderer is also specified. Helm then sends all rendered manifests through the post-renderer to stdout. writeRenderedManifests() captures that output and writes it to the output directory, splitting documents by their '# Source:' comments to recreate the per-template file structure. Documents without a '# Source:' comment (e.g. resources injected by the post-renderer) are collected into a single fallback 'manifest.yaml' file. Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/38cec14e-e49f-4e90-918e-d400de52bd8e Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
This commit is contained in:
co-authored by
yxxhero
parent
f6652485d1
commit
c6aa46234c
+126
-12
@@ -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: <path>" 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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user