diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 83a2e0b25..e8bf4e356 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -46,8 +46,9 @@ type ConfigGetter struct { Protocols []string `yaml:"protocols"` } -func (c *ConfigCLI) GetType() string { return "cli/v1" } -func (c *ConfigGetter) GetType() string { return "getter/v1" } +// ConfigPostrenderer represents the configuration for postrenderer plugins +// there are no runtime-independent configurations for postrenderer/v1 plugin type +type ConfigPostrenderer struct{} func (c *ConfigCLI) Validate() error { // Config validation for CLI plugins @@ -66,6 +67,11 @@ func (c *ConfigGetter) Validate() error { return nil } +func (c *ConfigPostrenderer) Validate() error { + // Config validation for postrenderer plugins + return nil +} + func remarshalConfig[T Config](configData map[string]any) (Config, error) { data, err := yaml.Marshal(configData) if err != nil { diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index 81ef26e02..63d930cbe 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -163,6 +163,31 @@ func TestLoadDirGetter(t *testing.T) { 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) { plugs := []Plugin{ mockSubprocessCLIPlugin(t, "foo"), @@ -195,13 +220,14 @@ func TestLoadAll(t *testing.T) { plugsMap[p.Metadata().Name] = p } - assert.Len(t, plugsMap, 6) + assert.Len(t, plugsMap, 7) assert.Contains(t, plugsMap, "downloader") assert.Contains(t, plugsMap, "echo-legacy") assert.Contains(t, plugsMap, "echo-v1") assert.Contains(t, plugsMap, "getter") assert.Contains(t, plugsMap, "hello-legacy") assert.Contains(t, plugsMap, "hello-v1") + assert.Contains(t, plugsMap, "postrenderer-v1") } func TestFindPlugins(t *testing.T) { @@ -228,7 +254,7 @@ func TestFindPlugins(t *testing.T) { { name: "normal", plugdirs: "./testdata/plugdir/good", - expected: 6, + expected: 7, }, } for _, c := range cases { diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index bb7e9409f..fbe7a16b8 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -31,7 +31,7 @@ type Metadata struct { // Name is the name of the plugin Name string - // Type of plugin (eg, cli/v1, getter/v1) + // Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1) Type string // 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) case "getter/v1": config, err = remarshalConfig[*ConfigGetter](configRaw) + case "postrenderer/v1": + config, err = remarshalConfig[*ConfigPostrenderer](configRaw) default: return nil, fmt.Errorf("unsupported plugin type: %s", pluginType) } diff --git a/internal/plugin/metadata_v1.go b/internal/plugin/metadata_v1.go index 654aa8900..81dbc2e20 100644 --- a/internal/plugin/metadata_v1.go +++ b/internal/plugin/metadata_v1.go @@ -27,7 +27,7 @@ type MetadataV1 struct { // Name is the name of the plugin 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"` // Runtime specifies the runtime type (subprocess, wasm) diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index 163f0621f..e7faeed36 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -16,9 +16,11 @@ limitations under the License. package plugin import ( + "bytes" "context" "fmt" "io" + "log/slog" "os" "os/exec" "syscall" @@ -36,7 +38,7 @@ type SubprocessProtocolCommand struct { Command string `yaml:"command"` } -// RuntimeConfigSubprocess represents configuration for subprocess runtime +// RuntimeConfigSubprocess implements RuntimeConfig for RuntimeSubprocess type RuntimeConfigSubprocess struct { // PlatformCommand is a list containing a plugin command, with a platform selector and support for args. PlatformCommands []PlatformCommand `yaml:"platformCommand"` @@ -73,7 +75,7 @@ type RuntimeSubprocess struct{} var _ Runtime = (*RuntimeSubprocess)(nil) -// CreateRuntime implementation for RuntimeConfig +// CreatePlugin implementation for Runtime func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) { return &SubprocessPluginRuntime{ metadata: *metadata, @@ -82,7 +84,7 @@ func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) ( }, nil } -// RuntimeSubprocess implements the Runtime interface for subprocess execution +// SubprocessPluginRuntime implements the Plugin interface for subprocess execution type SubprocessPluginRuntime struct { metadata Metadata pluginDir string @@ -105,6 +107,8 @@ func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Outp return r.runCLI(input) case schema.InputMessageGetterV1: return r.runGetter(input) + case schema.InputMessagePostRendererV1: + return r.runPostrenderer(input) default: 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 } +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 // the plugin subsystem itself needs access to the environment variables // created here. diff --git a/pkg/postrender/postrender.go b/internal/plugin/schema/postrenderer.go similarity index 50% rename from pkg/postrender/postrender.go rename to internal/plugin/schema/postrenderer.go index 3af384290..0f0c09369 100644 --- a/pkg/postrender/postrender.go +++ b/internal/plugin/schema/postrenderer.go @@ -14,16 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package postrender contains an interface that can be implemented for custom -// post-renderers and an exec implementation that can be used for arbitrary -// binaries and scripts -package postrender - -import "bytes" - -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) +package schema + +import ( + "bytes" + + "helm.sh/helm/v4/pkg/cli" +) + +// InputMessagePostRendererV1 implements Input.Message +type InputMessagePostRendererV1 struct { + Manifests *bytes.Buffer `json:"manifests"` + // from CLI --post-renderer-args + ExtraArgs []string `json:"extraArgs"` + Settings *cli.EnvSettings `json:"settings"` +} + +type OutputMessagePostRendererV1 struct { + Manifests *bytes.Buffer `json:"manifests"` } diff --git a/internal/plugin/testdata/plugdir/good/postrenderer-v1/plugin.yaml b/internal/plugin/testdata/plugdir/good/postrenderer-v1/plugin.yaml new file mode 100644 index 000000000..30f1599b4 --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/postrenderer-v1/plugin.yaml @@ -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" diff --git a/internal/plugin/testdata/plugdir/good/postrenderer-v1/sed-test.sh b/internal/plugin/testdata/plugdir/good/postrenderer-v1/sed-test.sh new file mode 100755 index 000000000..a016e398f --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/postrenderer-v1/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 diff --git a/pkg/action/action.go b/pkg/action/action.go index 38c8b6729..7b8fa3c34 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -43,7 +43,7 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/engine" "helm.sh/helm/v4/pkg/kube" - "helm.sh/helm/v4/pkg/postrender" + "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/registry" releaseutil "helm.sh/helm/v4/pkg/release/util" 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 // // 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 b := bytes.NewBuffer(nil) diff --git a/pkg/action/install.go b/pkg/action/install.go index 276009b5c..5ca499d64 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -48,7 +48,7 @@ import ( "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/kube" 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" releaseutil "helm.sh/helm/v4/pkg/release/util" release "helm.sh/helm/v4/pkg/release/v1" @@ -124,7 +124,7 @@ type Install struct { UseReleaseName bool // TakeOwnership will ignore the check for helm annotations and take ownership of the resources. TakeOwnership bool - PostRenderer postrender.PostRenderer + PostRenderer postrenderer.PostRenderer // Lock to control raceconditions when the process receives a SIGTERM Lock sync.Mutex } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 63646c12b..f7fbd490f 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -31,7 +31,7 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" - "helm.sh/helm/v4/pkg/postrender" + "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/registry" releaseutil "helm.sh/helm/v4/pkg/release/util" 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 // post renderer before sending to the Kubernetes API server. - PostRenderer postrender.PostRenderer + PostRenderer postrenderer.PostRenderer // DisableOpenAPIValidation controls whether OpenAPI validation is enforced. DisableOpenAPIValidation bool // Get missing dependencies diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index d11073e5f..98881c795 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -31,11 +31,12 @@ import ( "k8s.io/klog/v2" "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/values" "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/kube" - "helm.sh/helm/v4/pkg/postrender" + "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/repo" ) @@ -164,16 +165,18 @@ func (o *outputValue) Set(s string) error { return nil } -func bindPostRenderFlag(cmd *cobra.Command, varRef *postrender.PostRenderer) { - p := &postRendererOptions{varRef, "", []string{}} - 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") +// 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)") } type postRendererOptions struct { - renderer *postrender.PostRenderer - binaryPath string + renderer *postrenderer.PostRenderer + pluginName string args []string + settings *cli.EnvSettings } type postRendererString struct { @@ -181,7 +184,7 @@ type postRendererString struct { } func (p *postRendererString) String() string { - return p.options.binaryPath + return p.options.pluginName } func (p *postRendererString) Type() string { @@ -192,11 +195,11 @@ func (p *postRendererString) Set(val string) error { if val == "" { return nil } - if p.options.binaryPath != "" { + if p.options.pluginName != "" { return fmt.Errorf("cannot specify --post-renderer flag more than once") } - p.options.binaryPath = val - pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) + p.options.pluginName = val + pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...) if err != nil { return err } @@ -221,11 +224,11 @@ 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) - if p.options.binaryPath == "" { + if p.options.pluginName == "" { return nil } // 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 { return err } diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go index cbc2e6419..dce748a6b 100644 --- a/pkg/cmd/flags_test.go +++ b/pkg/cmd/flags_test.go @@ -101,20 +101,22 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) { func TestPostRendererFlagSetOnce(t *testing.T) { cfg := action.Configuration{} client := action.NewInstall(&cfg) + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" str := postRendererString{ options: &postRendererOptions{ renderer: &client.PostRenderer, + settings: settings, }, } - // Set the binary once - err := str.Set("echo") + // Set the plugin name once + err := str.Set("postrenderer-v1") require.NoError(t, err) - // Set the binary again to the same value is not ok - err = str.Set("echo") + // Set the plugin name again to the same value is not ok + err = str.Set("postrenderer-v1") 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") require.Error(t, err) } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 361d91e5f..c4e121c1f 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -179,7 +179,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") bindOutputFlag(cmd, &outfmt) - bindPostRenderFlag(cmd, &client.PostRenderer) + bindPostRenderFlag(cmd, &client.PostRenderer, settings) return cmd } diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index ac20a45b3..c93b5395b 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -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.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.") - bindPostRenderFlag(cmd, &client.PostRenderer) + bindPostRenderFlag(cmd, &client.PostRenderer, settings) return cmd } diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml new file mode 100644 index 000000000..30f1599b4 --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml @@ -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" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh new file mode 100755 index 000000000..a016e398f --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/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 diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 74061caf7..c8fbf8bd3 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -300,7 +300,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { addChartPathOptionsFlags(f, &client.ChartPathOptions) addValueOptionsFlags(f, valueOpts) bindOutputFlag(cmd, &outfmt) - bindPostRenderFlag(cmd, &client.PostRenderer) + bindPostRenderFlag(cmd, &client.PostRenderer, settings) AddWaitFlag(cmd, &client.WaitStrategy) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") diff --git a/pkg/postrender/exec.go b/pkg/postrender/exec.go deleted file mode 100644 index 16d9c09ce..000000000 --- a/pkg/postrender/exec.go +++ /dev/null @@ -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) -} diff --git a/pkg/postrender/exec_test.go b/pkg/postrender/exec_test.go deleted file mode 100644 index a10ad2cc4..000000000 --- a/pkg/postrender/exec_test.go +++ /dev/null @@ -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() -} diff --git a/pkg/postrenderer/postrenderer.go b/pkg/postrenderer/postrenderer.go new file mode 100644 index 000000000..2107cc465 --- /dev/null +++ b/pkg/postrenderer/postrenderer.go @@ -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 +} diff --git a/pkg/postrenderer/postrenderer_test.go b/pkg/postrenderer/postrenderer_test.go new file mode 100644 index 000000000..9addd481d --- /dev/null +++ b/pkg/postrenderer/postrenderer_test.go @@ -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") +} diff --git a/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml b/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml new file mode 100644 index 000000000..30f1599b4 --- /dev/null +++ b/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml @@ -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" diff --git a/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh b/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh new file mode 100755 index 000000000..a016e398f --- /dev/null +++ b/pkg/postrenderer/testdata/plugins/postrenderer-v1/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