Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added multi-platform plugin hook support #12962

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
21 changes: 14 additions & 7 deletions cmd/helm/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,26 @@ func newPluginCmd(out io.Writer) *cobra.Command {

// runHook will execute a plugin hook.
func runHook(p *plugin.Plugin, event string) error {
hook := p.Metadata.Hooks[event]
if hook == "" {
var cmd string
var cmdArgs []string

plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)

command := p.Metadata.Hooks[event]
if command != "" {
cmd = "sh"
cmdArgs = []string{"-c", command}
}

main, argv, err := plugin.PrepareCommands(p.Metadata.PlatformHooks[event], cmd, cmdArgs, []string{})
if err != nil {
return nil
}

prog := exec.Command("sh", "-c", hook)
// TODO make this work on windows
// I think its ... ¯\_(ツ)_/¯
// prog := exec.Command("cmd", "/C", p.Metadata.Hooks.Install())
prog := exec.Command(main, argv...)

debug("running %s hook: %s", event, prog)

plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
if err := prog.Run(); err != nil {
if eerr, ok := err.(*exec.ExitError); ok {
Expand Down
3 changes: 3 additions & 0 deletions pkg/plugin/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@ const (
Update = "update"
)

// PlatformHooks is a map of events to a command for a particular operating system and architecture.
type PlatformHooks map[string][]PlatformCommand

// Hooks is a map of events to commands.
type Hooks map[string]string
135 changes: 106 additions & 29 deletions pkg/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ type Downloaders struct {

// PlatformCommand represents a command for a particular operating system and architecture
type PlatformCommand struct {
OperatingSystem string `json:"os"`
Architecture string `json:"arch"`
Command string `json:"command"`
OperatingSystem string `json:"os"`
Architecture string `json:"arch"`
Command string `json:"command"`
Args []string `json:"args"`
}

// Metadata describes a plugin.
Expand All @@ -65,23 +66,32 @@ type Metadata struct {
// Description is a long description shown in places like `helm help`
Description string `json:"description"`

// Command is the command, as a single string.
// PlatformCommand is the plugin command, with a platform selector and support for args.
//
// The command will be passed through environment expansion, so env vars can
// The command and args will be passed through environment expansion, so env vars can
// be present in this command. Unless IgnoreFlags is set, this will
// also merge the flags passed from Helm.
//
// Note that command is not executed in a shell. To do so, we suggest
// Note that the command is not executed in a shell. To do so, we suggest
// pointing the command to a shell script.
//
// The following rules will apply to processing commands:
// - If platformCommand is present, it will be searched first
// The following rules will apply to processing platform commands:
// - If PlatformCommand is present, it will be searched first
// - If both OS and Arch match the current platform, search will stop and the command will be executed
// - If OS matches and there is no more specific match, the command will be executed
// - If no OS/Arch match is found, the default command will be executed
// - If no command is present and no matches are found in platformCommand, Helm will exit with an error
PlatformCommand []PlatformCommand `json:"platformCommand"`
Command string `json:"command"`

// Command is the plugin command, as a single string. The command will be ignored if a valid PlatformCommand is found.
//
// The command will be passed through environment expansion, so env vars can
// be present in this command. Unless IgnoreFlags is set, this will
// also merge the flags passed from Helm.
//
// Note that command is not executed in a shell. To do so, we suggest
// pointing the command to a shell script.
Command string `json:"command"`

// IgnoreFlags ignores any flags passed in from Helm
//
Expand All @@ -90,7 +100,28 @@ type Metadata struct {
// the `--debug` flag will be discarded.
IgnoreFlags bool `json:"ignoreFlags"`

// Hooks are commands that will run on events.
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
//
// The command and args will be passed through environment expansion, so env vars can
// be present in the command.
//
// Note that the command is not executed in a shell. To do so, we suggest
// pointing the command to a shell script.
//
// The following rules will apply to processing platform commands:
// - If PlatformHooks is present, it will be searched first
// - If both OS and Arch match the current platform, search will stop and the command will be executed
// - If OS matches and there is no more specific match, the command will be executed
// - If no OS/Arch match is found, the default command will be executed
// - If no command is present and no matches are found in platformCommand, Helm will skip the event
PlatformHooks PlatformHooks `json:"platformHooks"`

// Hooks are commands that will run on plugin events, as a single string.
//
// The command will be passed through environment expansion, so env vars can
// be present in this command.
//
// Note that the command is executed in the sh shell.
Hooks Hooks

// Downloaders field is used if the plugin supply downloader mechanism
Expand All @@ -116,21 +147,29 @@ type Plugin struct {
// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
// - If OS matches and there is no more specific match, the command will be prepared for execution
// - If no OS/Arch match is found, return nil
func getPlatformCommand(cmds []PlatformCommand) []string {
var command []string
func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) {
var command, args []string

eq := strings.EqualFold
for _, c := range cmds {
if len(c.Architecture) > 0 && !eq(c.Architecture, runtime.GOARCH) {
continue
}

if eq(c.OperatingSystem, runtime.GOOS) {
command = strings.Split(c.Command, " ")
args = c.Args
}

if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
return strings.Split(c.Command, " ")
return strings.Split(c.Command, " "), c.Args
}
}
return command

return command, args
}

// PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing:
// PrepareCommands takes a []Plugin.PlatformCommand, a Plugin.Command and will applying the following processing:
// - If platformCommand is present, it will be searched first
// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
// - If OS matches and there is no more specific match, the command will be prepared for execution
Expand All @@ -141,33 +180,71 @@ func getPlatformCommand(cmds []PlatformCommand) []string {
// returns the name of the command and an args array.
//
// The result is suitable to pass to exec.Command.
func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
var parts []string
platCmdLen := len(p.Metadata.PlatformCommand)
if platCmdLen > 0 {
parts = getPlatformCommand(p.Metadata.PlatformCommand)
func PrepareCommands(cmds []PlatformCommand, command string, commandArgs []string, extraArgs []string) (string, []string, error) {
var cmdParts, args []string
expandEnv := true

cmdsLen := len(cmds)
if cmdsLen > 0 {
cmdParts, args = getPlatformCommand(cmds)
}
if platCmdLen == 0 || parts == nil {
parts = strings.Split(p.Metadata.Command, " ")
if cmdsLen == 0 || cmdParts == nil {
cmdParts = strings.Split(command, " ")
args = commandArgs
expandEnv = false
}
if len(parts) == 0 || parts[0] == "" {
if len(cmdParts) == 0 || cmdParts[0] == "" {
return "", nil, fmt.Errorf("no plugin command is applicable")
}

main := os.ExpandEnv(parts[0])
main := os.ExpandEnv(cmdParts[0])
baseArgs := []string{}
if len(parts) > 1 {
for _, cmdpart := range parts[1:] {
cmdexp := os.ExpandEnv(cmdpart)
baseArgs = append(baseArgs, cmdexp)
if len(cmdParts) > 1 {
for _, cmdPart := range cmdParts[1:] {
if expandEnv {
baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
} else {
baseArgs = append(baseArgs, cmdPart)
}
}
}
if !p.Metadata.IgnoreFlags {

for _, arg := range args {
if expandEnv {
baseArgs = append(baseArgs, os.ExpandEnv(arg))
} else {
baseArgs = append(baseArgs, arg)
}
}

if len(extraArgs) > 0 {
baseArgs = append(baseArgs, extraArgs...)
}

return main, baseArgs, nil
}

// PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing:
// - If platformCommand is present, it will be searched first
// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
// - If OS matches and there is no more specific match, the command will be prepared for execution
// - If no OS/Arch match is found, the default command will be prepared for execution
// - If no command is present and no matches are found in platformCommand, will exit with an error
//
// It merges extraArgs into any arguments supplied in the plugin. It
// returns the name of the command and an args array.
//
// The result is suitable to pass to exec.Command.
func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
var extraArgsIn []string

if !p.Metadata.IgnoreFlags {
extraArgsIn = extraArgs
}

return PrepareCommands(p.Metadata.PlatformCommand, p.Metadata.Command, []string{}, extraArgsIn)
}

// validPluginName is a regular expression that validates plugin names.
//
// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-.
Expand Down
107 changes: 102 additions & 5 deletions pkg/plugin/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
"helm.sh/helm/v3/pkg/cli"
)

func checkCommand(p *Plugin, extraArgs []string, osStrCmp string, t *testing.T) {
func checkPlatformCommand(p *Plugin, extraArgs []string, osStrCmp string, t *testing.T) {
cmd, args, err := p.PrepareCommand(extraArgs)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -76,7 +76,7 @@ func TestPrepareCommand(t *testing.T) {
}
argv := []string{"--debug", "--foo", "bar"}

checkCommand(p, argv, "foo", t)
checkPlatformCommand(p, argv, "foo", t)
}

func TestPlatformPrepareCommand(t *testing.T) {
Expand Down Expand Up @@ -118,7 +118,7 @@ func TestPlatformPrepareCommand(t *testing.T) {
}

argv := []string{"--debug", "--foo", "bar"}
checkCommand(p, argv, osStrCmp, t)
checkPlatformCommand(p, argv, osStrCmp, t)
}

func TestPartialPlatformPrepareCommand(t *testing.T) {
Expand All @@ -145,7 +145,7 @@ func TestPartialPlatformPrepareCommand(t *testing.T) {
}

argv := []string{"--debug", "--foo", "bar"}
checkCommand(p, argv, osStrCmp, t)
checkPlatformCommand(p, argv, osStrCmp, t)
}

func TestNoPrepareCommand(t *testing.T) {
Expand Down Expand Up @@ -180,6 +180,93 @@ func TestNoMatchPrepareCommand(t *testing.T) {
}
}

func TestPrepareCommands(t *testing.T) {
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}
extraArgs := []string{"--debug", "--foo", "bar"}

expectedIndex := 1
resultArgs := append(cmds[expectedIndex].Args, extraArgs...)

cmd, args, err := PrepareCommands(cmds, "pwsh", []string{"-c", "echo \"error\""}, extraArgs)
if err != nil {
t.Fatal(err)
}
if cmd != cmds[expectedIndex].Command {
t.Fatalf("Expected %q, got %q", cmds[expectedIndex].Command, cmd)
}
if !reflect.DeepEqual(args, resultArgs) {
t.Fatalf("Expected %v, got %v", resultArgs, args)
}
}

func TestPrepareCommandsNoArch(t *testing.T) {
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
}

expectedIndex := 1

cmd, args, err := PrepareCommands(cmds, "pwsh", []string{"-c", "echo \"error\""}, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != cmds[expectedIndex].Command {
t.Fatalf("Expected %q, got %q", cmds[expectedIndex].Command, cmd)
}
if !reflect.DeepEqual(args, cmds[expectedIndex].Args) {
t.Fatalf("Expected %v, got %v", cmds[expectedIndex].Args, args)
}
}

func TestPrepareCommandsFallback(t *testing.T) {
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
}

command := "sh"
commandArgs := []string{"-c", "echo \"test\""}

cmd, args, err := PrepareCommands(cmds, command, commandArgs, []string{})
if err != nil {
t.Fatal(err)
}
if cmd != command {
t.Fatalf("Expected %q, got %q", command, cmd)
}
if !reflect.DeepEqual(args, commandArgs) {
t.Fatalf("Expected %v, got %v", commandArgs, args)
}
}

func TestPrepareCommandsNoMatch(t *testing.T) {
cmds := []PlatformCommand{
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
}

if _, _, err := PrepareCommands(cmds, "", []string{}, []string{}); err == nil {
t.Fatalf("Expected error to be returned")
}
}

func TestPrepareCommandsNoCommands(t *testing.T) {
cmds := []PlatformCommand{}

if _, _, err := PrepareCommands(cmds, "", []string{}, []string{}); err == nil {
t.Fatalf("Expected error to be returned")
}
}

func TestLoadDir(t *testing.T) {
dirname := "testdata/plugdir/good/hello"
plug, err := LoadDir(dirname)
Expand All @@ -196,8 +283,18 @@ func TestLoadDir(t *testing.T) {
Version: "0.1.0",
Usage: "usage",
Description: "description",
Command: "$HELM_PLUGIN_DIR/hello.sh",
PlatformCommand: []PlatformCommand{
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
},
Command: "${HELM_PLUGIN_DIR}/hello.sh",
IgnoreFlags: true,
PlatformHooks: map[string][]PlatformCommand{
Install: {
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
},
},
Hooks: map[string]string{
Install: "echo installing...",
},
Expand Down