Merge pull request #31217 from helm/plugin-postrender-type

[HIP-0026] Move Postrenderer to a plugin type
pull/31218/head
Scott Rigby 5 days ago committed by GitHub
commit bd29e5edc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -46,8 +46,9 @@ type ConfigGetter struct {
Protocols []string `yaml:"protocols"` Protocols []string `yaml:"protocols"`
} }
func (c *ConfigCLI) GetType() string { return "cli/v1" } // ConfigPostrenderer represents the configuration for postrenderer plugins
func (c *ConfigGetter) GetType() string { return "getter/v1" } // there are no runtime-independent configurations for postrenderer/v1 plugin type
type ConfigPostrenderer struct{}
func (c *ConfigCLI) Validate() error { func (c *ConfigCLI) Validate() error {
// Config validation for CLI plugins // Config validation for CLI plugins
@ -66,6 +67,11 @@ func (c *ConfigGetter) Validate() error {
return nil return nil
} }
func (c *ConfigPostrenderer) Validate() error {
// Config validation for postrenderer plugins
return nil
}
func remarshalConfig[T Config](configData map[string]any) (Config, error) { func remarshalConfig[T Config](configData map[string]any) (Config, error) {
data, err := yaml.Marshal(configData) data, err := yaml.Marshal(configData)
if err != nil { if err != nil {

@ -163,6 +163,31 @@ func TestLoadDirGetter(t *testing.T) {
assert.Equal(t, expect, plug.Metadata()) assert.Equal(t, expect, plug.Metadata())
} }
func TestPostRenderer(t *testing.T) {
dirname := "testdata/plugdir/good/postrenderer-v1"
expect := Metadata{
Name: "postrenderer-v1",
Version: "1.2.3",
Type: "postrenderer/v1",
APIVersion: "v1",
Runtime: "subprocess",
Config: &ConfigPostrenderer{},
RuntimeConfig: &RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{
{
Command: "${HELM_PLUGIN_DIR}/sed-test.sh",
},
},
},
}
plug, err := LoadDir(dirname)
require.NoError(t, err)
assert.Equal(t, dirname, plug.Dir())
assert.Equal(t, expect, plug.Metadata())
}
func TestDetectDuplicates(t *testing.T) { func TestDetectDuplicates(t *testing.T) {
plugs := []Plugin{ plugs := []Plugin{
mockSubprocessCLIPlugin(t, "foo"), mockSubprocessCLIPlugin(t, "foo"),
@ -195,13 +220,14 @@ func TestLoadAll(t *testing.T) {
plugsMap[p.Metadata().Name] = p plugsMap[p.Metadata().Name] = p
} }
assert.Len(t, plugsMap, 6) assert.Len(t, plugsMap, 7)
assert.Contains(t, plugsMap, "downloader") assert.Contains(t, plugsMap, "downloader")
assert.Contains(t, plugsMap, "echo-legacy") assert.Contains(t, plugsMap, "echo-legacy")
assert.Contains(t, plugsMap, "echo-v1") assert.Contains(t, plugsMap, "echo-v1")
assert.Contains(t, plugsMap, "getter") assert.Contains(t, plugsMap, "getter")
assert.Contains(t, plugsMap, "hello-legacy") assert.Contains(t, plugsMap, "hello-legacy")
assert.Contains(t, plugsMap, "hello-v1") assert.Contains(t, plugsMap, "hello-v1")
assert.Contains(t, plugsMap, "postrenderer-v1")
} }
func TestFindPlugins(t *testing.T) { func TestFindPlugins(t *testing.T) {
@ -228,7 +254,7 @@ func TestFindPlugins(t *testing.T) {
{ {
name: "normal", name: "normal",
plugdirs: "./testdata/plugdir/good", plugdirs: "./testdata/plugdir/good",
expected: 6, expected: 7,
}, },
} }
for _, c := range cases { for _, c := range cases {

@ -31,7 +31,7 @@ type Metadata struct {
// Name is the name of the plugin // Name is the name of the plugin
Name string Name string
// Type of plugin (eg, cli/v1, getter/v1) // Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1)
Type string Type string
// Runtime specifies the runtime type (subprocess, wasm) // Runtime specifies the runtime type (subprocess, wasm)
@ -191,6 +191,8 @@ func convertMetadataConfig(pluginType string, configRaw map[string]any) (Config,
config, err = remarshalConfig[*ConfigCLI](configRaw) config, err = remarshalConfig[*ConfigCLI](configRaw)
case "getter/v1": case "getter/v1":
config, err = remarshalConfig[*ConfigGetter](configRaw) config, err = remarshalConfig[*ConfigGetter](configRaw)
case "postrenderer/v1":
config, err = remarshalConfig[*ConfigPostrenderer](configRaw)
default: default:
return nil, fmt.Errorf("unsupported plugin type: %s", pluginType) return nil, fmt.Errorf("unsupported plugin type: %s", pluginType)
} }

@ -27,7 +27,7 @@ type MetadataV1 struct {
// Name is the name of the plugin // Name is the name of the plugin
Name string `yaml:"name"` Name string `yaml:"name"`
// Type of plugin (eg, cli/v1, getter/v1) // Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1)
Type string `yaml:"type"` Type string `yaml:"type"`
// Runtime specifies the runtime type (subprocess, wasm) // Runtime specifies the runtime type (subprocess, wasm)

@ -16,9 +16,11 @@ limitations under the License.
package plugin package plugin
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"syscall" "syscall"
@ -36,7 +38,7 @@ type SubprocessProtocolCommand struct {
Command string `yaml:"command"` Command string `yaml:"command"`
} }
// RuntimeConfigSubprocess represents configuration for subprocess runtime // RuntimeConfigSubprocess implements RuntimeConfig for RuntimeSubprocess
type RuntimeConfigSubprocess struct { type RuntimeConfigSubprocess struct {
// PlatformCommand is a list containing a plugin command, with a platform selector and support for args. // PlatformCommand is a list containing a plugin command, with a platform selector and support for args.
PlatformCommands []PlatformCommand `yaml:"platformCommand"` PlatformCommands []PlatformCommand `yaml:"platformCommand"`
@ -73,7 +75,7 @@ type RuntimeSubprocess struct{}
var _ Runtime = (*RuntimeSubprocess)(nil) var _ Runtime = (*RuntimeSubprocess)(nil)
// CreateRuntime implementation for RuntimeConfig // CreatePlugin implementation for Runtime
func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) { func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
return &SubprocessPluginRuntime{ return &SubprocessPluginRuntime{
metadata: *metadata, metadata: *metadata,
@ -82,7 +84,7 @@ func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (
}, nil }, nil
} }
// RuntimeSubprocess implements the Runtime interface for subprocess execution // SubprocessPluginRuntime implements the Plugin interface for subprocess execution
type SubprocessPluginRuntime struct { type SubprocessPluginRuntime struct {
metadata Metadata metadata Metadata
pluginDir string pluginDir string
@ -105,6 +107,8 @@ func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Outp
return r.runCLI(input) return r.runCLI(input)
case schema.InputMessageGetterV1: case schema.InputMessageGetterV1:
return r.runGetter(input) return r.runGetter(input)
case schema.InputMessagePostRendererV1:
return r.runPostrenderer(input)
default: default:
return nil, fmt.Errorf("unsupported subprocess plugin type %q", r.metadata.Type) return nil, fmt.Errorf("unsupported subprocess plugin type %q", r.metadata.Type)
} }
@ -216,6 +220,62 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) {
}, nil }, nil
} }
func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) {
if _, ok := input.Message.(schema.InputMessagePostRendererV1); !ok {
return nil, fmt.Errorf("plugin %q input message does not implement InputMessagePostRendererV1", r.metadata.Name)
}
msg := input.Message.(schema.InputMessagePostRendererV1)
extraArgs := msg.ExtraArgs
settings := msg.Settings
// Setup plugin environment
SetupPluginEnv(settings, r.metadata.Name, r.pluginDir)
cmds := r.RuntimeConfig.PlatformCommands
if len(cmds) == 0 && len(r.RuntimeConfig.Command) > 0 {
cmds = []PlatformCommand{{Command: r.RuntimeConfig.Command}}
}
command, args, err := PrepareCommands(cmds, true, extraArgs)
if err != nil {
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
}
// TODO de-duplicate code here by calling RuntimeSubprocess.invokeWithEnv()
cmd := exec.Command(
command,
args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
go func() {
defer stdin.Close()
io.Copy(stdin, msg.Manifests)
}()
postRendered := &bytes.Buffer{}
stderr := &bytes.Buffer{}
//cmd.Env = pluginExec.env
cmd.Stdout = postRendered
cmd.Stderr = stderr
if err := executeCmd(cmd, r.metadata.Name); err != nil {
slog.Info("plugin execution failed", slog.String("stderr", stderr.String()))
return nil, err
}
return &Output{
Message: &schema.OutputMessagePostRendererV1{
Manifests: postRendered,
},
}, nil
}
// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because // SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
// the plugin subsystem itself needs access to the environment variables // the plugin subsystem itself needs access to the environment variables
// created here. // created here.

@ -14,16 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// Package postrender contains an interface that can be implemented for custom package schema
// post-renderers and an exec implementation that can be used for arbitrary
// binaries and scripts import (
package postrender "bytes"
import "bytes" "helm.sh/helm/v4/pkg/cli"
)
type PostRenderer interface {
// Run expects a single buffer filled with Helm rendered manifests. It // InputMessagePostRendererV1 implements Input.Message
// expects the modified results to be returned on a separate buffer or an type InputMessagePostRendererV1 struct {
// error if there was an issue or failure while running the post render step Manifests *bytes.Buffer `json:"manifests"`
Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) // from CLI --post-renderer-args
ExtraArgs []string `json:"extraArgs"`
Settings *cli.EnvSettings `json:"settings"`
}
type OutputMessagePostRendererV1 struct {
Manifests *bytes.Buffer `json:"manifests"`
} }

@ -0,0 +1,8 @@
name: "postrenderer-v1"
version: "1.2.3"
type: postrenderer/v1
apiVersion: v1
runtime: subprocess
runtimeConfig:
platformCommand:
- command: "${HELM_PLUGIN_DIR}/sed-test.sh"

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

@ -43,7 +43,7 @@ import (
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/engine" "helm.sh/helm/v4/pkg/engine"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/postrender" "helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/registry"
releaseutil "helm.sh/helm/v4/pkg/release/util" releaseutil "helm.sh/helm/v4/pkg/release/util"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
@ -176,7 +176,7 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) {
// TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed
// //
// This code has to do with writing files to disk. // This code has to do with writing files to disk.
func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) {
var hs []*release.Hook var hs []*release.Hook
b := bytes.NewBuffer(nil) b := bytes.NewBuffer(nil)

@ -48,7 +48,7 @@ import (
"helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
"helm.sh/helm/v4/pkg/postrender" "helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/registry"
releaseutil "helm.sh/helm/v4/pkg/release/util" releaseutil "helm.sh/helm/v4/pkg/release/util"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
@ -124,7 +124,7 @@ type Install struct {
UseReleaseName bool UseReleaseName bool
// TakeOwnership will ignore the check for helm annotations and take ownership of the resources. // TakeOwnership will ignore the check for helm annotations and take ownership of the resources.
TakeOwnership bool TakeOwnership bool
PostRenderer postrender.PostRenderer PostRenderer postrenderer.PostRenderer
// Lock to control raceconditions when the process receives a SIGTERM // Lock to control raceconditions when the process receives a SIGTERM
Lock sync.Mutex Lock sync.Mutex
} }

@ -31,7 +31,7 @@ import (
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/postrender" "helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/registry"
releaseutil "helm.sh/helm/v4/pkg/release/util" releaseutil "helm.sh/helm/v4/pkg/release/util"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
@ -114,7 +114,7 @@ type Upgrade struct {
// //
// If this is non-nil, then after templates are rendered, they will be sent to the // If this is non-nil, then after templates are rendered, they will be sent to the
// post renderer before sending to the Kubernetes API server. // post renderer before sending to the Kubernetes API server.
PostRenderer postrender.PostRenderer PostRenderer postrenderer.PostRenderer
// DisableOpenAPIValidation controls whether OpenAPI validation is enforced. // DisableOpenAPIValidation controls whether OpenAPI validation is enforced.
DisableOpenAPIValidation bool DisableOpenAPIValidation bool
// Get missing dependencies // Get missing dependencies

@ -31,11 +31,12 @@ import (
"k8s.io/klog/v2" "k8s.io/klog/v2"
"helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cli/output"
"helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cli/values"
"helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/postrender" "helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/repo" "helm.sh/helm/v4/pkg/repo"
) )
@ -164,16 +165,18 @@ func (o *outputValue) Set(s string) error {
return nil return nil
} }
func bindPostRenderFlag(cmd *cobra.Command, varRef *postrender.PostRenderer) { // TODO there is probably a better way to pass cobra settings than as a param
p := &postRendererOptions{varRef, "", []string{}} func bindPostRenderFlag(cmd *cobra.Command, varRef *postrenderer.PostRenderer, settings *cli.EnvSettings) {
cmd.Flags().Var(&postRendererString{p}, postRenderFlag, "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path") 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)") cmd.Flags().Var(&postRendererArgsSlice{p}, postRenderArgsFlag, "an argument to the post-renderer (can specify multiple)")
} }
type postRendererOptions struct { type postRendererOptions struct {
renderer *postrender.PostRenderer renderer *postrenderer.PostRenderer
binaryPath string pluginName string
args []string args []string
settings *cli.EnvSettings
} }
type postRendererString struct { type postRendererString struct {
@ -181,7 +184,7 @@ type postRendererString struct {
} }
func (p *postRendererString) String() string { func (p *postRendererString) String() string {
return p.options.binaryPath return p.options.pluginName
} }
func (p *postRendererString) Type() string { func (p *postRendererString) Type() string {
@ -192,11 +195,11 @@ func (p *postRendererString) Set(val string) error {
if val == "" { if val == "" {
return nil return nil
} }
if p.options.binaryPath != "" { if p.options.pluginName != "" {
return fmt.Errorf("cannot specify --post-renderer flag more than once") return fmt.Errorf("cannot specify --post-renderer flag more than once")
} }
p.options.binaryPath = val p.options.pluginName = val
pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...)
if err != nil { if err != nil {
return err return err
} }
@ -221,11 +224,11 @@ func (p *postRendererArgsSlice) Set(val string) error {
// a post-renderer defined by a user may accept empty arguments // a post-renderer defined by a user may accept empty arguments
p.options.args = append(p.options.args, val) p.options.args = append(p.options.args, val)
if p.options.binaryPath == "" { if p.options.pluginName == "" {
return nil return nil
} }
// overwrite if already create PostRenderer by `post-renderer` flags // overwrite if already create PostRenderer by `post-renderer` flags
pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...)
if err != nil { if err != nil {
return err return err
} }

@ -101,20 +101,22 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) {
func TestPostRendererFlagSetOnce(t *testing.T) { func TestPostRendererFlagSetOnce(t *testing.T) {
cfg := action.Configuration{} cfg := action.Configuration{}
client := action.NewInstall(&cfg) client := action.NewInstall(&cfg)
settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
str := postRendererString{ str := postRendererString{
options: &postRendererOptions{ options: &postRendererOptions{
renderer: &client.PostRenderer, renderer: &client.PostRenderer,
settings: settings,
}, },
} }
// Set the binary once // Set the plugin name once
err := str.Set("echo") err := str.Set("postrenderer-v1")
require.NoError(t, err) require.NoError(t, err)
// Set the binary again to the same value is not ok // Set the plugin name again to the same value is not ok
err = str.Set("echo") err = str.Set("postrenderer-v1")
require.Error(t, err) require.Error(t, err)
// Set the binary again to a different value is not ok // Set the plugin name again to a different value is not ok
err = str.Set("cat") err = str.Set("cat")
require.Error(t, err) require.Error(t, err)
} }

@ -179,7 +179,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f := cmd.Flags() f := cmd.Flags()
f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag")
bindOutputFlag(cmd, &outfmt) bindOutputFlag(cmd, &outfmt)
bindPostRenderFlag(cmd, &client.PostRenderer) bindPostRenderFlag(cmd, &client.PostRenderer, settings)
return cmd return cmd
} }

@ -203,7 +203,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion")
f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)") f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)")
f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.") f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.")
bindPostRenderFlag(cmd, &client.PostRenderer) bindPostRenderFlag(cmd, &client.PostRenderer, settings)
return cmd return cmd
} }

@ -0,0 +1,8 @@
name: "postrenderer-v1"
version: "1.2.3"
type: postrenderer/v1
apiVersion: v1
runtime: subprocess
runtimeConfig:
platformCommand:
- command: "${HELM_PLUGIN_DIR}/sed-test.sh"

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

@ -300,7 +300,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
addChartPathOptionsFlags(f, &client.ChartPathOptions) addChartPathOptionsFlags(f, &client.ChartPathOptions)
addValueOptionsFlags(f, valueOpts) addValueOptionsFlags(f, valueOpts)
bindOutputFlag(cmd, &outfmt) bindOutputFlag(cmd, &outfmt)
bindPostRenderFlag(cmd, &client.PostRenderer) bindPostRenderFlag(cmd, &client.PostRenderer, settings)
AddWaitFlag(cmd, &client.WaitStrategy) AddWaitFlag(cmd, &client.WaitStrategy)
cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts")
cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts")

@ -1,114 +0,0 @@
/*
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 postrender
import (
"bytes"
"fmt"
"io"
"os/exec"
"path/filepath"
)
type execRender struct {
binaryPath string
args []string
}
// NewExec returns a PostRenderer implementation that calls the provided binary.
// It returns an error if the binary cannot be found. If the path does not
// contain any separators, it will search in $PATH, otherwise it will resolve
// any relative paths to a fully qualified path
func NewExec(binaryPath string, args ...string) (PostRenderer, error) {
fullPath, err := getFullPath(binaryPath)
if err != nil {
return nil, err
}
return &execRender{fullPath, args}, nil
}
// Run the configured binary for the post render
func (p *execRender) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) {
cmd := exec.Command(p.binaryPath, p.args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
var postRendered = &bytes.Buffer{}
var stderr = &bytes.Buffer{}
cmd.Stdout = postRendered
cmd.Stderr = stderr
go func() {
defer stdin.Close()
io.Copy(stdin, renderedManifests)
}()
err = cmd.Run()
if err != nil {
return nil, fmt.Errorf("error while running command %s. error output:\n%s: %w", p.binaryPath, stderr.String(), err)
}
// If the binary returned almost nothing, it's likely that it didn't
// successfully render anything
if len(bytes.TrimSpace(postRendered.Bytes())) == 0 {
return nil, fmt.Errorf("post-renderer %q produced empty output", p.binaryPath)
}
return postRendered, nil
}
// getFullPath returns the full filepath to the binary to execute. If the path
// does not contain any separators, it will search in $PATH, otherwise it will
// resolve any relative paths to a fully qualified path
func getFullPath(binaryPath string) (string, error) {
// NOTE(thomastaylor312): I am leaving this code commented out here. During
// the implementation of post-render, it was brought up that if we are
// relying on plugins, we should actually use the plugin system so it can
// properly handle multiple OSs. This will be a feature add in the future,
// so I left this code for reference. It can be deleted or reused once the
// feature is implemented
// Manually check the plugin dir first
// if !strings.Contains(binaryPath, string(filepath.Separator)) {
// // First check the plugin dir
// pluginDir := helmpath.DataPath("plugins") // Default location
// // If location for plugins is explicitly set, check there
// if v, ok := os.LookupEnv("HELM_PLUGINS"); ok {
// pluginDir = v
// }
// // The plugins variable can actually contain multiple paths, so loop through those
// for _, p := range filepath.SplitList(pluginDir) {
// _, err := os.Stat(filepath.Join(p, binaryPath))
// if err != nil && !errors.Is(err, fs.ErrNotExist) {
// return "", err
// } else if err == nil {
// binaryPath = filepath.Join(p, binaryPath)
// break
// }
// }
// }
// Now check for the binary using the given path or check if it exists in
// the path and is executable
checkedPath, err := exec.LookPath(binaryPath)
if err != nil {
return "", fmt.Errorf("unable to find binary at %s: %w", binaryPath, err)
}
return filepath.Abs(checkedPath)
}

@ -1,193 +0,0 @@
/*
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 postrender
import (
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testingScript = `#!/bin/sh
if [ $# -eq 0 ]; then
sed s/FOOTEST/BARTEST/g <&0
else
sed s/FOOTEST/"$*"/g <&0
fi
`
func TestGetFullPath(t *testing.T) {
is := assert.New(t)
t.Run("full path resolves correctly", func(t *testing.T) {
testpath := setupTestingScript(t)
fullPath, err := getFullPath(testpath)
is.NoError(err)
is.Equal(testpath, fullPath)
})
t.Run("relative path resolves correctly", func(t *testing.T) {
testpath := setupTestingScript(t)
currentDir, err := os.Getwd()
require.NoError(t, err)
relative, err := filepath.Rel(currentDir, testpath)
require.NoError(t, err)
fullPath, err := getFullPath(relative)
is.NoError(err)
is.Equal(testpath, fullPath)
})
t.Run("binary in PATH resolves correctly", func(t *testing.T) {
testpath := setupTestingScript(t)
t.Setenv("PATH", filepath.Dir(testpath))
fullPath, err := getFullPath(filepath.Base(testpath))
is.NoError(err)
is.Equal(testpath, fullPath)
})
// NOTE(thomastaylor312): See note in getFullPath for more details why this
// is here
// t.Run("binary in plugin path resolves correctly", func(t *testing.T) {
// testpath, cleanup := setupTestingScript(t)
// defer cleanup()
// realPath := os.Getenv("HELM_PLUGINS")
// os.Setenv("HELM_PLUGINS", filepath.Dir(testpath))
// defer func() {
// os.Setenv("HELM_PLUGINS", realPath)
// }()
// fullPath, err := getFullPath(filepath.Base(testpath))
// is.NoError(err)
// is.Equal(testpath, fullPath)
// })
// t.Run("binary in multiple plugin paths resolves correctly", func(t *testing.T) {
// testpath, cleanup := setupTestingScript(t)
// defer cleanup()
// realPath := os.Getenv("HELM_PLUGINS")
// os.Setenv("HELM_PLUGINS", filepath.Dir(testpath)+string(os.PathListSeparator)+"/another/dir")
// defer func() {
// os.Setenv("HELM_PLUGINS", realPath)
// }()
// fullPath, err := getFullPath(filepath.Base(testpath))
// is.NoError(err)
// is.Equal(testpath, fullPath)
// })
}
func TestExecRun(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
t.Skip("skipping on windows")
}
is := assert.New(t)
testpath := setupTestingScript(t)
renderer, err := NewExec(testpath)
require.NoError(t, err)
output, err := renderer.Run(bytes.NewBufferString("FOOTEST"))
is.NoError(err)
is.Contains(output.String(), "BARTEST")
}
func TestExecRunWithNoOutput(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
t.Skip("skipping on windows")
}
is := assert.New(t)
testpath := setupTestingScript(t)
renderer, err := NewExec(testpath)
require.NoError(t, err)
_, err = renderer.Run(bytes.NewBufferString(""))
is.Error(err)
}
func TestNewExecWithOneArgsRun(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
t.Skip("skipping on windows")
}
is := assert.New(t)
testpath := setupTestingScript(t)
renderer, err := NewExec(testpath, "ARG1")
require.NoError(t, err)
output, err := renderer.Run(bytes.NewBufferString("FOOTEST"))
is.NoError(err)
is.Contains(output.String(), "ARG1")
}
func TestNewExecWithTwoArgsRun(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
t.Skip("skipping on windows")
}
is := assert.New(t)
testpath := setupTestingScript(t)
renderer, err := NewExec(testpath, "ARG1", "ARG2")
require.NoError(t, err)
output, err := renderer.Run(bytes.NewBufferString("FOOTEST"))
is.NoError(err)
is.Contains(output.String(), "ARG1 ARG2")
}
func setupTestingScript(t *testing.T) (filepath string) {
t.Helper()
tempdir := t.TempDir()
f, err := os.CreateTemp(tempdir, "post-render-test.sh")
if err != nil {
t.Fatalf("unable to create tempfile for testing: %s", err)
}
_, err = f.WriteString(testingScript)
if err != nil {
t.Fatalf("unable to write tempfile for testing: %s", err)
}
err = f.Chmod(0o755)
if err != nil {
t.Fatalf("unable to make tempfile executable for testing: %s", err)
}
err = f.Close()
if err != nil {
t.Fatalf("unable to close tempfile after writing: %s", err)
}
return f.Name()
}

@ -0,0 +1,85 @@
/*
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"
"context"
"fmt"
"path/filepath"
"helm.sh/helm/v4/internal/plugin/schema"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/pkg/cli"
)
// PostRenderer is an interface different plugin runtimes
// it may be also be used without the factory for custom post-renderers
type PostRenderer interface {
// Run expects a single buffer filled with Helm rendered manifests. It
// expects the modified results to be returned on a separate buffer or an
// error if there was an issue or failure while running the post render step
Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error)
}
// NewPostRendererPlugin creates a PostRenderer that uses the plugin's Runtime
func NewPostRendererPlugin(settings *cli.EnvSettings, pluginName string, args ...string) (PostRenderer, error) {
descriptor := plugin.Descriptor{
Name: pluginName,
Type: "postrenderer/v1",
}
p, err := plugin.FindPlugin(filepath.SplitList(settings.PluginsDirectory), descriptor)
if err != nil {
return nil, err
}
return &postRendererPlugin{
plugin: p,
args: args,
settings: settings,
}, nil
}
// postRendererPlugin implements PostRenderer by delegating to the plugin's Runtime
type postRendererPlugin struct {
plugin plugin.Plugin
args []string
settings *cli.EnvSettings
}
// Run implements PostRenderer by using the plugin's Runtime
func (r *postRendererPlugin) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) {
input := &plugin.Input{
Message: schema.InputMessagePostRendererV1{
ExtraArgs: r.args,
Manifests: renderedManifests,
Settings: r.settings,
},
}
output, err := r.plugin.Invoke(context.Background(), input)
if err != nil {
return nil, fmt.Errorf("failed to invoke post-renderer plugin %q: %w", r.plugin.Metadata().Name, err)
}
outputMessage := output.Message.(*schema.OutputMessagePostRendererV1)
// If the binary returned almost nothing, it's likely that it didn't
// successfully render anything
if len(bytes.TrimSpace(outputMessage.Manifests.Bytes())) == 0 {
return nil, fmt.Errorf("post-renderer %q produced empty output", r.plugin.Metadata().Name)
}
return outputMessage.Manifests, nil
}

@ -0,0 +1,89 @@
/*
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"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/pkg/cli"
)
func TestNewPostRenderPluginRunWithNoOutput(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
t.Skip("skipping on windows")
}
is := assert.New(t)
s := cli.New()
s.PluginsDirectory = "testdata/plugins"
name := "postrenderer-v1"
base := filepath.Join(s.PluginsDirectory, name)
plugin.SetupPluginEnv(s, name, base)
renderer, err := NewPostRendererPlugin(s, name, "")
require.NoError(t, err)
_, err = renderer.Run(bytes.NewBufferString(""))
is.Error(err)
}
func TestNewPostRenderPluginWithOneArgsRun(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
t.Skip("skipping on windows")
}
is := assert.New(t)
s := cli.New()
s.PluginsDirectory = "testdata/plugins"
name := "postrenderer-v1"
base := filepath.Join(s.PluginsDirectory, name)
plugin.SetupPluginEnv(s, name, base)
renderer, err := NewPostRendererPlugin(s, name, "ARG1")
require.NoError(t, err)
output, err := renderer.Run(bytes.NewBufferString("FOOTEST"))
is.NoError(err)
is.Contains(output.String(), "ARG1")
}
func TestNewPostRenderPluginWithTwoArgsRun(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
t.Skip("skipping on windows")
}
is := assert.New(t)
s := cli.New()
s.PluginsDirectory = "testdata/plugins"
name := "postrenderer-v1"
base := filepath.Join(s.PluginsDirectory, name)
plugin.SetupPluginEnv(s, name, base)
renderer, err := NewPostRendererPlugin(s, name, "ARG1", "ARG2")
require.NoError(t, err)
output, err := renderer.Run(bytes.NewBufferString("FOOTEST"))
is.NoError(err)
is.Contains(output.String(), "ARG1 ARG2")
}

@ -0,0 +1,8 @@
name: "postrenderer-v1"
version: "1.2.3"
type: postrenderer/v1
apiVersion: v1
runtime: subprocess
runtimeConfig:
platformCommand:
- command: "${HELM_PLUGIN_DIR}/sed-test.sh"

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