From df8fb0652dcb5e6f461e5be448d3d0b11a8175e1 Mon Sep 17 00:00:00 2001 From: David Otto Date: Tue, 4 Aug 2026 12:08:47 +0200 Subject: [PATCH] feat(cmd): support chaining multiple post-renderers Allow --post-renderer to be specified multiple times to chain post-renderers together. Each renderer's output is piped into the next in the order specified. The --post-renderer-args flag now applies to the most recently specified --post-renderer. Signed-off-by: David Otto --- pkg/cmd/flags.go | 125 +++++++++++------- pkg/cmd/flags_test.go | 65 +++++++-- .../postrenderer-v1-second/plugin.yaml | 8 ++ .../postrenderer-v1-second/sed-test.sh | 6 + pkg/postrenderer/chain.go | 48 +++++++ pkg/postrenderer/chain_test.go | 79 +++++++++++ 6 files changed, 278 insertions(+), 53 deletions(-) create mode 100644 pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/plugin.yaml create mode 100755 pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/sed-test.sh create mode 100644 pkg/postrenderer/chain.go create mode 100644 pkg/postrenderer/chain_test.go diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index 45da90d0e..de7eb958a 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -184,88 +184,123 @@ func (o *outputValue) Set(s string) error { return nil } -// TODO there is probably a better way to pass cobra settings than as a param func bindPostRenderFlag(cmd *cobra.Command, varRef *postrenderer.PostRenderer, settings *cli.EnvSettings) { - p := &postRendererOptions{varRef, "", []string{}, settings} - cmd.Flags().Var(&postRendererString{p}, postRenderFlag, "the name of a postrenderer type plugin to be used for post rendering. If it exists, the plugin will be used") - cmd.Flags().Var(&postRendererArgsSlice{p}, postRenderArgsFlag, "an argument to the post-renderer (can specify multiple)") + o := &postRendererChainOptions{renderer: varRef, settings: settings} + cmd.Flags().Var(&postRendererNameFlag{o}, postRenderFlag, "the name of a postrenderer type plugin to be used for post rendering. If it exists, the plugin will be used. Can be specified multiple times to chain post-renderers; each renderer's output is piped into the next") + cmd.Flags().Var(&postRendererArgsFlag{o}, postRenderArgsFlag, "an argument to the post-renderer (can specify multiple). Applies to the most recently specified --post-renderer flag") } type postRendererOptions struct { - renderer *postrenderer.PostRenderer pluginName string args []string - settings *cli.EnvSettings } -type postRendererString struct { - options *postRendererOptions +type postRendererChainOptions struct { + renderer *postrenderer.PostRenderer + postRenderers []*postRendererOptions + settings *cli.EnvSettings } -func (p *postRendererString) String() string { - return p.options.pluginName +func (o *postRendererChainOptions) rebuildRendererChain() error { + renderers := make([]postrenderer.PostRenderer, 0, len(o.postRenderers)) + for _, e := range o.postRenderers { + renderer, err := postrenderer.NewPostRendererPlugin(o.settings, e.pluginName, e.args...) + if err != nil { + return err + } + renderers = append(renderers, renderer) + } + + *o.renderer = postrenderer.NewChain(renderers...) + return nil +} + +type postRendererNameFlag struct { + postRendererChainOptions *postRendererChainOptions +} + +func (p *postRendererNameFlag) String() string { + names := make([]string, 0, len(p.postRendererChainOptions.postRenderers)) + for _, renderer := range p.postRendererChainOptions.postRenderers { + names = append(names, renderer.pluginName) + } + return strings.Join(names, ",") } -func (p *postRendererString) Type() string { - return "postRendererString" +func (p *postRendererNameFlag) Type() string { + return "string" } -func (p *postRendererString) Set(val string) error { +func (p *postRendererNameFlag) Set(val string) error { if val == "" { return nil } - if p.options.pluginName != "" { - return errors.New("cannot specify --post-renderer flag more than once") - } - p.options.pluginName = val - pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...) - if err != nil { - return err - } - *p.options.renderer = pr - return nil + p.postRendererChainOptions.postRenderers = append(p.postRendererChainOptions.postRenderers, &postRendererOptions{pluginName: val}) + err := p.postRendererChainOptions.rebuildRendererChain() + return err } -type postRendererArgsSlice struct { - options *postRendererOptions +type postRendererArgsFlag struct { + options *postRendererChainOptions } -func (p *postRendererArgsSlice) String() string { - return "[" + strings.Join(p.options.args, ",") + "]" +// lastRenderer returns the most recently specified --post-renderer entry, to +// which --post-renderer-args values are applied. +func (p *postRendererArgsFlag) lastRenderer() (*postRendererOptions, error) { + if len(p.options.postRenderers) == 0 { + return nil, errors.New("--post-renderer-args must follow a --post-renderer flag") + } + return p.options.postRenderers[len(p.options.postRenderers)-1], nil } -func (p *postRendererArgsSlice) Type() string { - return "postRendererArgsSlice" +func (p *postRendererArgsFlag) String() string { + renderer, err := p.lastRenderer() + if err != nil { + return "[]" + } + return "[" + strings.Join(renderer.args, ",") + "]" } -func (p *postRendererArgsSlice) Set(val string) error { - // a post-renderer defined by a user may accept empty arguments - p.options.args = append(p.options.args, val) +func (p *postRendererArgsFlag) Type() string { + return "args" +} - if p.options.pluginName == "" { - return nil - } - // overwrite if already create PostRenderer by `post-renderer` flags - pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...) +func (p *postRendererArgsFlag) Set(val string) error { + renderer, err := p.lastRenderer() if err != nil { return err } - *p.options.renderer = pr - return nil + + renderer.args = append(renderer.args, val) + + err = p.options.rebuildRendererChain() + return err } -func (p *postRendererArgsSlice) Append(val string) error { - p.options.args = append(p.options.args, val) +func (p *postRendererArgsFlag) Append(val string) error { + renderer, err := p.lastRenderer() + if err != nil { + return err + } + renderer.args = append(renderer.args, val) return nil } -func (p *postRendererArgsSlice) Replace(val []string) error { - p.options.args = val +func (p *postRendererArgsFlag) Replace(val []string) error { + renderer, err := p.lastRenderer() + if err != nil { + return err + } + renderer.args = val return nil } -func (p *postRendererArgsSlice) GetSlice() []string { - return p.options.args +func (p *postRendererArgsFlag) GetSlice() []string { + renderer, err := p.lastRenderer() + if err != nil { + return nil + } + return renderer.args } func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirective) { diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go index 09dc61638..db013d897 100644 --- a/pkg/cmd/flags_test.go +++ b/pkg/cmd/flags_test.go @@ -17,7 +17,9 @@ limitations under the License. package cmd import ( + "bytes" "fmt" + "runtime" "testing" "time" @@ -25,6 +27,7 @@ import ( "helm.sh/helm/v4/pkg/action" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -99,22 +102,68 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) { runTestCmd(t, tests) } -func TestPostRendererFlagSetOnce(t *testing.T) { +func TestPostRendererFlagAllowsMultiple(t *testing.T) { cfg := action.Configuration{} client := action.NewInstall(&cfg) settings.PluginsDirectory = "testdata/helmhome/helm/plugins" - str := postRendererString{ - options: &postRendererOptions{ + str := postRendererNameFlag{ + postRendererChainOptions: &postRendererChainOptions{ renderer: &client.PostRenderer, settings: settings, }, } - // Set the plugin name once + // Setting the plugin name once is ok require.NoError(t, str.Set("postrenderer-v1")) + require.NotNil(t, client.PostRenderer) - // Set the plugin name again to the same value is not ok - require.Error(t, str.Set("postrenderer-v1")) + // Setting a second plugin name chains it after the first + require.NoError(t, str.Set("postrenderer-v1-second")) + require.NotNil(t, client.PostRenderer) + require.IsType(t, &postrenderer.Chain{}, client.PostRenderer) +} + +func TestPostRendererArgs_WithoutPrecedingRendererErrors(t *testing.T) { + cfg := action.Configuration{} + client := action.NewInstall(&cfg) + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + args := postRendererArgsFlag{ + options: &postRendererChainOptions{ + renderer: &client.PostRenderer, + settings: settings, + }, + } + require.Error(t, args.Set("ARG1")) +} + +func TestPostRendererChain_RunsInOrder(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping on windows: test uses a sed-based plugin") + } + cfg := action.Configuration{} + client := action.NewInstall(&cfg) + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + + options := &postRendererChainOptions{ + renderer: &client.PostRenderer, + settings: settings, + } + str := postRendererNameFlag{postRendererChainOptions: options} + argsFlag := postRendererArgsFlag{options: options} + + // First renderer: FOOTEST -> BARTEST + require.NoError(t, str.Set("postrenderer-v1")) + // Second renderer: BARTEST -> BAZTEST + require.NoError(t, str.Set("postrenderer-v1-second")) + + require.NotNil(t, client.PostRenderer) + + out, err := client.PostRenderer.Run(bytes.NewBufferString("FOOTEST")) + require.NoError(t, err) + require.Contains(t, out.String(), "BAZTEST") - // Set the plugin name again to a different value is not ok - require.Error(t, str.Set("cat")) + // Args apply to the most recently added renderer (the second one) + require.NoError(t, argsFlag.Set("CUSTOM")) + out, err = client.PostRenderer.Run(bytes.NewBufferString("FOOTEST")) + require.NoError(t, err) + require.Contains(t, out.String(), "CUSTOM") } diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/plugin.yaml new file mode 100644 index 000000000..eeea73c47 --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/plugin.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +name: "postrenderer-v1-second" +version: "1.2.3" +type: postrenderer/v1 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: "${HELM_PLUGIN_DIR}/sed-test.sh" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/sed-test.sh b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/sed-test.sh new file mode 100755 index 000000000..fe101efac --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1-second/sed-test.sh @@ -0,0 +1,6 @@ +#!/bin/sh +if [ $# -eq 0 ]; then + sed s/BARTEST/BAZTEST/g <&0 +else + sed s/BARTEST/"$*"/g <&0 +fi diff --git a/pkg/postrenderer/chain.go b/pkg/postrenderer/chain.go new file mode 100644 index 000000000..78251f78d --- /dev/null +++ b/pkg/postrenderer/chain.go @@ -0,0 +1,48 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postrenderer + +import ( + "bytes" + "fmt" +) + +// Chain is a PostRenderer that runs a sequence of PostRenderers, feeding the +// output of each renderer as the input to the next. This allows multiple +// post-renderer plugins to be composed into a single pipeline. +type Chain struct { + renderers []PostRenderer +} + +// NewChain creates a Chain that will run the given renderers in order. An +// empty chain is valid and behaves as a no-op renderer that returns its +// input unchanged. +func NewChain(renderers ...PostRenderer) *Chain { + return &Chain{renderers: renderers} +} + +func (c *Chain) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { + manifests := renderedManifests + for i, renderer := range c.renderers { + var err error + manifests, err = renderer.Run(manifests) + if err != nil { + return nil, fmt.Errorf("post-renderer %d/%d failed: %w", i+1, len(c.renderers), err) + } + } + return manifests, nil +} diff --git a/pkg/postrenderer/chain_test.go b/pkg/postrenderer/chain_test.go new file mode 100644 index 000000000..b1aa90c74 --- /dev/null +++ b/pkg/postrenderer/chain_test.go @@ -0,0 +1,79 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postrenderer + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeRenderer is a simple PostRenderer used for chain testing. It appends +// its suffix to the input, or returns an error if configured to fail. +type fakeRenderer struct { + suffix string + failErr error +} + +func (f *fakeRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { + if f.failErr != nil { + return nil, f.failErr + } + out := bytes.NewBufferString(renderedManifests.String() + f.suffix) + return out, nil +} + +func TestChain_Empty(t *testing.T) { + c := NewChain() + out, err := c.Run(bytes.NewBufferString("hello")) + require.NoError(t, err) + assert.Equal(t, "hello", out.String()) +} + +func TestChain_Single(t *testing.T) { + c := NewChain(&fakeRenderer{suffix: "-a"}) + out, err := c.Run(bytes.NewBufferString("hello")) + require.NoError(t, err) + assert.Equal(t, "hello-a", out.String()) +} + +func TestChain_Multiple_OrderPreserved(t *testing.T) { + c := NewChain( + &fakeRenderer{suffix: "-a"}, + &fakeRenderer{suffix: "-b"}, + &fakeRenderer{suffix: "-c"}, + ) + out, err := c.Run(bytes.NewBufferString("hello")) + require.NoError(t, err) + assert.Equal(t, "hello-a-b-c", out.String()) +} + +func TestChain_ErrorStopsPipeline(t *testing.T) { + boom := errors.New("boom") + c := NewChain( + &fakeRenderer{suffix: "-a"}, + &fakeRenderer{failErr: boom}, + &fakeRenderer{suffix: "-c"}, + ) + out, err := c.Run(bytes.NewBufferString("hello")) + require.Error(t, err) + assert.Nil(t, out) + assert.ErrorIs(t, err, boom) +}