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 <ottodavid@gmx.net>
pull/32510/head
David Otto 2 months ago
parent ae877c8bfb
commit df8fb0652d
No known key found for this signature in database
GPG Key ID: 91AFB89EB289AB5C

@ -184,88 +184,123 @@ func (o *outputValue) Set(s string) error {
return nil 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) { func bindPostRenderFlag(cmd *cobra.Command, varRef *postrenderer.PostRenderer, settings *cli.EnvSettings) {
p := &postRendererOptions{varRef, "", []string{}, settings} o := &postRendererChainOptions{renderer: varRef, settings: 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(&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(&postRendererArgsSlice{p}, postRenderArgsFlag, "an argument to the post-renderer (can specify multiple)") 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 { type postRendererOptions struct {
renderer *postrenderer.PostRenderer
pluginName string pluginName string
args []string args []string
}
type postRendererChainOptions struct {
renderer *postrenderer.PostRenderer
postRenderers []*postRendererOptions
settings *cli.EnvSettings settings *cli.EnvSettings
} }
type postRendererString struct { func (o *postRendererChainOptions) rebuildRendererChain() error {
options *postRendererOptions 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 *postRendererString) String() string { func (p *postRendererNameFlag) String() string {
return p.options.pluginName 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 { func (p *postRendererNameFlag) Type() string {
return "postRendererString" return "string"
} }
func (p *postRendererString) Set(val string) error { func (p *postRendererNameFlag) Set(val string) error {
if val == "" { if val == "" {
return nil return nil
} }
if p.options.pluginName != "" { p.postRendererChainOptions.postRenderers = append(p.postRendererChainOptions.postRenderers, &postRendererOptions{pluginName: val})
return errors.New("cannot specify --post-renderer flag more than once") err := p.postRendererChainOptions.rebuildRendererChain()
}
p.options.pluginName = val
pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...)
if err != nil {
return err return err
} }
*p.options.renderer = pr
return nil type postRendererArgsFlag struct {
options *postRendererChainOptions
} }
type postRendererArgsSlice struct { // lastRenderer returns the most recently specified --post-renderer entry, to
options *postRendererOptions // 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) String() string { func (p *postRendererArgsFlag) String() string {
return "[" + strings.Join(p.options.args, ",") + "]" renderer, err := p.lastRenderer()
if err != nil {
return "[]"
}
return "[" + strings.Join(renderer.args, ",") + "]"
} }
func (p *postRendererArgsSlice) Type() string { func (p *postRendererArgsFlag) Type() string {
return "postRendererArgsSlice" return "args"
} }
func (p *postRendererArgsSlice) Set(val string) error { func (p *postRendererArgsFlag) Set(val string) error {
// a post-renderer defined by a user may accept empty arguments renderer, err := p.lastRenderer()
p.options.args = append(p.options.args, val) if err != nil {
return err
}
if p.options.pluginName == "" { renderer.args = append(renderer.args, val)
return nil
err = p.options.rebuildRendererChain()
return err
} }
// 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) Append(val string) error {
renderer, err := p.lastRenderer()
if err != nil { if err != nil {
return err return err
} }
*p.options.renderer = pr renderer.args = append(renderer.args, val)
return nil return nil
} }
func (p *postRendererArgsSlice) Append(val string) error { func (p *postRendererArgsFlag) Replace(val []string) error {
p.options.args = append(p.options.args, val) renderer, err := p.lastRenderer()
if err != nil {
return err
}
renderer.args = val
return nil return nil
} }
func (p *postRendererArgsSlice) Replace(val []string) error { func (p *postRendererArgsFlag) GetSlice() []string {
p.options.args = val renderer, err := p.lastRenderer()
if err != nil {
return nil return nil
} }
return renderer.args
func (p *postRendererArgsSlice) GetSlice() []string {
return p.options.args
} }
func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirective) { func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirective) {

@ -17,7 +17,9 @@ limitations under the License.
package cmd package cmd
import ( import (
"bytes"
"fmt" "fmt"
"runtime"
"testing" "testing"
"time" "time"
@ -25,6 +27,7 @@ import (
"helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/action"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/release/common" "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
) )
@ -99,22 +102,68 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) {
runTestCmd(t, tests) runTestCmd(t, tests)
} }
func TestPostRendererFlagSetOnce(t *testing.T) { func TestPostRendererFlagAllowsMultiple(t *testing.T) {
cfg := action.Configuration{} cfg := action.Configuration{}
client := action.NewInstall(&cfg) client := action.NewInstall(&cfg)
settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
str := postRendererString{ str := postRendererNameFlag{
options: &postRendererOptions{ postRendererChainOptions: &postRendererChainOptions{
renderer: &client.PostRenderer, renderer: &client.PostRenderer,
settings: settings, settings: settings,
}, },
} }
// Set the plugin name once // Setting the plugin name once is ok
require.NoError(t, str.Set("postrenderer-v1")) require.NoError(t, str.Set("postrenderer-v1"))
require.NotNil(t, client.PostRenderer)
// Set the plugin name again to the same value is not ok // Setting a second plugin name chains it after the first
require.Error(t, str.Set("postrenderer-v1")) 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 // Args apply to the most recently added renderer (the second one)
require.Error(t, str.Set("cat")) require.NoError(t, argsFlag.Set("CUSTOM"))
out, err = client.PostRenderer.Run(bytes.NewBufferString("FOOTEST"))
require.NoError(t, err)
require.Contains(t, out.String(), "CUSTOM")
} }

@ -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"

@ -0,0 +1,6 @@
#!/bin/sh
if [ $# -eq 0 ]; then
sed s/BARTEST/BAZTEST/g <&0
else
sed s/BARTEST/"$*"/g <&0
fi

@ -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
}

@ -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)
}
Loading…
Cancel
Save