Skip to content

Commit

Permalink
Extend with plugins (#2733)
Browse files Browse the repository at this point in the history
* Add support for external process plugins.

This adds support for external tools as plugins.

If the provided command is a known command, then it's executed,
otherwise, odo attempts to find a matching executable on the path with
the prefix "odo-" and execute that instead.

e.g. "odo tkn list" would attempt to execute "odo-tkn" and pass the args
on.

* Add support for Windows.

* Skip on windows - plugins may not work.

* Move the plugin handler test to a Gingko integration test.
  • Loading branch information
bigkevmcd committed Apr 21, 2020
1 parent 0a3f5de commit 0a68b6a
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 0 deletions.
23 changes: 23 additions & 0 deletions pkg/odo/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"flag"
"fmt"
"os"
"strings"

"github.com/openshift/odo/pkg/odo/cli/application"
Expand All @@ -12,6 +13,7 @@ import (
"github.com/openshift/odo/pkg/odo/cli/debug"
"github.com/openshift/odo/pkg/odo/cli/login"
"github.com/openshift/odo/pkg/odo/cli/logout"
"github.com/openshift/odo/pkg/odo/cli/plugins"
"github.com/openshift/odo/pkg/odo/cli/preference"
"github.com/openshift/odo/pkg/odo/cli/project"
"github.com/openshift/odo/pkg/odo/cli/service"
Expand Down Expand Up @@ -103,8 +105,29 @@ More information such as logs or what components you've deployed can be accessed
To see a full list of commands, run 'odo --help'`
)

const pluginPrefix = "odo"

// NewCmdOdo creates a new root command for odo
func NewCmdOdo(name, fullName string) *cobra.Command {
rootCmd := odoRootCmd(name, fullName)

if len(os.Args) > 1 {
cmdPathPieces := os.Args[1:]
// only look for suitable extension executables if
// the specified command does not already exist
cmd, _, err := rootCmd.Find(cmdPathPieces)
if err == nil && cmd != rootCmd {
return rootCmd
}
handleErr := plugins.HandleCommand(plugins.NewExecHandler(pluginPrefix), cmdPathPieces)
if handleErr != nil {
return rootCmd
}
}
return rootCmd
}

func odoRootCmd(name, fullName string) *cobra.Command {
// rootCmd represents the base command when called without any subcommands
rootCmd := &cobra.Command{
Use: name,
Expand Down
105 changes: 105 additions & 0 deletions pkg/odo/cli/plugins/plugin_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package plugins

import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
)

// HandleCommand receives a PluginHandler and command-line arguments and attempts to find
// a plugin executable on the PATH that satisfies the given arguments.
func HandleCommand(handler PluginHandler, args []string) error {
foundBinary, remaining := findBinary(handler, args)
if foundBinary == "" {
return nil
}

if err := handler.Execute(foundBinary, args[len(remaining):], os.Environ()); err != nil {
return err
}
return nil
}

type execFunc func(string, []string, []string) (err error)

// NewExecHandler creates and returns a new ExecHandler configured with
// the prefix.
func NewExecHandler(prefix string) *ExecHandler {
return &ExecHandler{
Prefix: prefix,
Exec: syscall.Exec,
}
}

// PluginHandler provides functionality for finding and executing external
// plugins.
type PluginHandler interface {
// Lookup should return the full path to an executable for the provided
// command, or "" if no matching command can be found.
Lookup(command string) string

// Execute should execute the provided path, passing in the args and env.
Execute(filename string, args, env []string) error
}

// ExecHandler implements PluginHandler using the "os/exec" package.
type ExecHandler struct {
Prefix string
Exec execFunc
}

// Lookup implements PluginHandler, using
// https://golang.org/pkg/os/exec/#LookPath to search for the command.
func (h *ExecHandler) Lookup(command string) string {
if runtime.GOOS == "windows" {
command = command + ".exe"
}
path, err := exec.LookPath(fmt.Sprintf("%s-%s", h.Prefix, command))
if err == nil && len(path) != 0 {
return path
}
return ""
}

// Execute implements PluginHandler.Execute
func (h *ExecHandler) Execute(filename string, args, env []string) error {
// Windows does not support exec syscall.
if runtime.GOOS == "windows" {
cmd := exec.Command(filename, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = env
err := cmd.Run()
if err == nil {
os.Exit(0)
}
return err
}
return h.Exec(filename, append([]string{filename}, args...), env)
}

func findBinary(handler PluginHandler, args []string) (string, []string) {
found := ""
remaining := []string{}
for idx := range args {
if strings.HasPrefix(args[idx], "-") {
break
}
remaining = append(remaining, strings.Replace(args[idx], "-", "_", -1))
}
for len(remaining) > 0 {
path := handler.Lookup(strings.Join(remaining, "-"))
if path == "" {
remaining = remaining[:len(remaining)-1]
continue
}

found = path
break
}
return found, remaining
}
63 changes: 63 additions & 0 deletions tests/integration/plugin_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package integration

import (
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"runtime"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"

"github.com/openshift/odo/pkg/odo/cli/plugins"
)

var sampleScript = []byte(`
#!/bin/sh
echo 'hello'
`)

var _ = Describe("odo plugin functionality", func() {
var tempDir string
var origPath = os.Getenv("PATH")
var handler plugins.PluginHandler
var _ = BeforeEach(func() {
var err error
tempDir, err = ioutil.TempDir(os.TempDir(), "odo")
Expect(err).NotTo(HaveOccurred())
os.Setenv("PATH", fmt.Sprintf("%s:%s", origPath, tempDir))
var baseScriptName = "tst-script"
scriptName := path.Join(tempDir, baseScriptName)
err = ioutil.WriteFile(scriptName, sampleScript, 0755)
Expect(err).NotTo(HaveOccurred())
handler = plugins.NewExecHandler("tst")
})

var _ = AfterEach(func() {
err := os.RemoveAll(tempDir)
Expect(err).NotTo(HaveOccurred())
os.Setenv("PATH", origPath)
})

Context("when an executable with the correct prefix exists on the path", func() {
It("finds the plugin", func() {
if runtime.GOOS == "windows" {
Skip("doesn't find scripts on Windows platform")
}
found := handler.Lookup("script")
Expect(found).Should(Equal(filepath.Join(tempDir, "tst-script")))
})
})

Context("when no executable with the correct prefix exists on the path", func() {
It("does not find the plugin", func() {
if runtime.GOOS == "windows" {
Skip("doesn't find scripts on Windows platform")
}
found := handler.Lookup("unknown")
Expect(found).Should(Equal(""))
})
})
})

0 comments on commit 0a68b6a

Please sign in to comment.