From 524fa4bcdabfa3e4bc8e2a540e7ce104b63a3976 Mon Sep 17 00:00:00 2001
From: bryanl <bryanliles@gmail.com>
Date: Sun, 25 Feb 2018 12:37:28 -0500
Subject: [PATCH] add undocumented plugin support to ks

Signed-off-by: bryanl <bryanliles@gmail.com>
---
 Gopkg.toml                |   3 -
 cmd/root.go               |  56 ++++++++++++++
 plugin/plugin.go          | 150 ++++++++++++++++++++++++++++++++++++++
 plugin/plugin_test.go     | 138 +++++++++++++++++++++++++++++++++++
 plugin/testdata/hello.tar | Bin 0 -> 3584 bytes
 5 files changed, 344 insertions(+), 3 deletions(-)
 create mode 100644 plugin/plugin.go
 create mode 100644 plugin/plugin_test.go
 create mode 100644 plugin/testdata/hello.tar

diff --git a/Gopkg.toml b/Gopkg.toml
index af3ba07a..9e1c3e28 100644
--- a/Gopkg.toml
+++ b/Gopkg.toml
@@ -115,6 +115,3 @@
 [[constraint]]
   name = "k8s.io/client-go"
   version = "5.0.0"
-[[constraint]]
-  branch = "master"
-  name = "github.com/shibukawa/configdir"
diff --git a/cmd/root.go b/cmd/root.go
index 97970a34..3e11b252 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -30,6 +30,7 @@ import (
 
 	"github.com/ksonnet/ksonnet/component"
 	"github.com/ksonnet/ksonnet/metadata"
+	"github.com/ksonnet/ksonnet/plugin"
 	str "github.com/ksonnet/ksonnet/strings"
 	"github.com/ksonnet/ksonnet/template"
 	"github.com/pkg/errors"
@@ -109,6 +110,57 @@ application configuration to remote clusters.
 
 		return nil
 	},
+	Args: func(cmd *cobra.Command, args []string) error {
+		if len(args) == 0 {
+			return cobra.NoArgs(cmd, args)
+		}
+
+		pluginName := args[0]
+		_, err := plugin.Find(appFs, pluginName)
+		if err != nil {
+			return cobra.NoArgs(cmd, args)
+		}
+
+		return nil
+	},
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if len(args) == 0 {
+			return cmd.Help()
+		}
+		pluginName, args := args[0], args[1:]
+		p, err := plugin.Find(appFs, pluginName)
+		if err != nil {
+			return err
+		}
+
+		return runPlugin(p, args)
+	},
+}
+
+func runPlugin(p plugin.Plugin, args []string) error {
+	env := []string{
+		fmt.Sprintf("KS_PLUGIN_DIR=%s", p.RootDir),
+		fmt.Sprintf("KS_PLUGIN_NAME=%s", p.Config.Name),
+	}
+
+	root, err := appRoot()
+	if err != nil {
+		return err
+	}
+
+	appConfig := filepath.Join(root, "app.yaml")
+	exists, err := afero.Exists(appFs, appConfig)
+	if err != nil {
+		return err
+	}
+
+	if exists {
+		env = append(env, fmt.Sprintf("KS_APP_DIR=%s", root))
+		// TODO: make kube context or something similar available to the plugin
+	}
+
+	cmd := p.BuildRunCmd(env, args)
+	return cmd.Run()
 }
 
 func logLevel(verbosity int) log.Level {
@@ -429,3 +481,7 @@ func importEnv(manager metadata.Manager, env string) (string, error) {
 
 	return fmt.Sprintf(`%s=%s`, metadata.EnvExtCodeKey, string(marshalled)), nil
 }
+
+func appRoot() (string, error) {
+	return os.Getwd()
+}
diff --git a/plugin/plugin.go b/plugin/plugin.go
new file mode 100644
index 00000000..89d68672
--- /dev/null
+++ b/plugin/plugin.go
@@ -0,0 +1,150 @@
+package plugin
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/errors"
+	"github.com/spf13/afero"
+	yaml "gopkg.in/yaml.v2"
+)
+
+// Config is configuration for a Plugin.
+type Config struct {
+	// Name is the name of the plugin.
+	Name string `yaml:"name,omitempty"`
+	// Version is the version of the plugin.
+	Version string `yaml:"version,omitempty"`
+	// Description is the plugin description.
+	Description string `yaml:"description,omitempty"`
+	// IgnoreFlags is set if this plugin will ignore flags.
+	IgnoreFlags bool `yaml:"ignore_flags,omitempty"`
+	// Command is the command that needs to be called to invoke the plugin.
+	Command string `yaml:"command,omitempty"`
+}
+
+func readConfig(fs afero.Fs, path string) (Config, error) {
+	b, err := afero.ReadFile(fs, path)
+	if err != nil {
+		return Config{}, err
+	}
+
+	var config Config
+	if err := yaml.Unmarshal(b, &config); err != nil {
+		return Config{}, err
+	}
+
+	return config, nil
+}
+
+// Plugin is a ksonnet plugin.
+type Plugin struct {
+	// RootDir is the root directory for the plugin.
+	RootDir string
+	// Config is configuration for the plugin.
+	Config Config
+}
+
+// BuildRunCmd builds a command that runs the plugin.
+func (p *Plugin) BuildRunCmd(env, args []string) *exec.Cmd {
+	bin := strings.Replace(p.Config.Command, "$KS_PLUGIN_DIR", p.RootDir, 1)
+	cmd := exec.Command(bin, args...)
+	cmd.Env = env
+
+	cmd.Stderr = os.Stderr
+	cmd.Stdout = os.Stdout
+
+	return cmd
+}
+
+// importPlugin creates a new Plugin given a path.
+func importPlugin(fs afero.Fs, path string) (Plugin, error) {
+	configPath := filepath.Join(path, "plugin.yaml")
+	exists, err := afero.Exists(fs, configPath)
+	if err != nil {
+		return Plugin{}, err
+	}
+
+	if !exists {
+		return Plugin{}, errors.Errorf("plugin in %q doesn't not a configuration", path)
+	}
+
+	config, err := readConfig(fs, configPath)
+	if err != nil {
+		return Plugin{}, errors.Wrapf(err, "read plugin configuration %q", configPath)
+	}
+
+	plugin := Plugin{
+		RootDir: path,
+		Config:  config,
+	}
+
+	return plugin, nil
+}
+
+// Find finds a plugin by name.
+func Find(fs afero.Fs, name string) (Plugin, error) {
+	plugins, err := List(fs)
+	if err != nil {
+		return Plugin{}, err
+	}
+
+	for _, plugin := range plugins {
+		if plugin.Config.Name == name {
+			return plugin, nil
+		}
+	}
+
+	return Plugin{}, errors.Errorf("%s is not a known plugin", name)
+}
+
+// List plugins
+func List(fs afero.Fs) ([]Plugin, error) {
+	rootPath, err := pluginDir()
+	if err != nil {
+		return []Plugin{}, err
+	}
+
+	exist, err := afero.Exists(fs, rootPath)
+	if err != nil {
+		return nil, err
+	}
+
+	if !exist {
+		return []Plugin{}, nil
+	}
+
+	fis, err := afero.ReadDir(fs, rootPath)
+	if err != nil {
+		return nil, err
+	}
+
+	plugins := make([]Plugin, 0)
+
+	for _, fi := range fis {
+		if fi.IsDir() {
+			path := filepath.Join(rootPath, fi.Name())
+
+			plugin, err := importPlugin(fs, path)
+			if err != nil {
+				return nil, err
+			}
+
+			plugins = append(plugins, plugin)
+		}
+	}
+
+	return plugins, nil
+}
+
+// TODO: make this work with windows
+func pluginDir() (string, error) {
+	homeDir := os.Getenv("HOME")
+	if homeDir == "" {
+		return "", errors.New("could not find home directory")
+	}
+
+	return filepath.Join(homeDir, ".config", "ksonnet", "plugins"), nil
+}
diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go
new file mode 100644
index 00000000..21078ca4
--- /dev/null
+++ b/plugin/plugin_test.go
@@ -0,0 +1,138 @@
+package plugin
+
+import (
+	"archive/tar"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestList(t *testing.T) {
+	withPluginEnv(t, func(fs afero.Fs) {
+		plugins, err := List(fs)
+		require.NoError(t, err)
+
+		require.Len(t, plugins, 1)
+
+		plugin := plugins[0]
+
+		assert.Equal(t, "/home/app/.config/ksonnet/plugins/hello", plugin.RootDir)
+		assert.Equal(t, "hello", plugin.Config.Name)
+		assert.Equal(t, "0.1.0", plugin.Config.Version)
+		assert.Equal(t, "Hello from a ksonnet plugin", plugin.Config.Description)
+		assert.Equal(t, "$KS_PLUGIN_DIR/hello.sh", plugin.Config.Command)
+	})
+}
+
+func TestFind(t *testing.T) {
+	cases := []struct {
+		name  string
+		isErr bool
+	}{
+		{
+			name: "hello",
+		},
+		{
+			name:  "missing",
+			isErr: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			withPluginEnv(t, func(fs afero.Fs) {
+				_, err := Find(fs, tc.name)
+				if tc.isErr {
+					require.Error(t, err)
+				} else {
+					require.NoError(t, err)
+				}
+			})
+		})
+	}
+}
+
+func TestBuildRunCmd(t *testing.T) {
+	plugin := Plugin{
+		RootDir: "/tmp",
+		Config:  Config{Command: "$KS_PLUGIN_DIR/runner"},
+	}
+
+	env := []string{"KS_1=1"}
+
+	args := []string{"--arg1", "--foo=2", "single"}
+	cmd := plugin.BuildRunCmd(env, args)
+	assert.Equal(t, os.Stderr, cmd.Stderr)
+	assert.Equal(t, os.Stdout, cmd.Stdout)
+	assert.Equal(t, env, cmd.Env)
+	assert.Equal(t, append([]string{"/tmp/runner"}, args...), cmd.Args, "args")
+
+}
+
+type pluginFn func(afero.Fs)
+
+func withPluginEnv(t *testing.T, fn pluginFn) {
+	ogHome := os.Getenv("HOME")
+	os.Setenv("HOME", "/home/app")
+	defer os.Setenv("HOME", ogHome)
+
+	fs := afero.NewMemMapFs()
+
+	fs.MkdirAll("/home/app/.config/ksonnet/plugins", 0755)
+	r, err := os.Open("testdata/hello.tar")
+	require.NoError(t, err)
+
+	err = untar(fs, "/home/app/.config/ksonnet/plugins", r)
+	require.NoError(t, err)
+
+	fn(fs)
+}
+
+// nolint: gocyclo
+func untar(fs afero.Fs, dst string, r io.Reader) error {
+	tr := tar.NewReader(r)
+
+	for {
+		header, err := tr.Next()
+
+		switch {
+
+		case err == io.EOF:
+			return nil
+
+		case err != nil:
+			return err
+
+		case header == nil:
+			continue
+		}
+
+		target := filepath.Join(dst, header.Name)
+
+		switch header.Typeflag {
+
+		case tar.TypeDir:
+			if _, err := fs.Stat(target); err != nil {
+				if err := fs.MkdirAll(target, 0755); err != nil {
+					return err
+				}
+			}
+
+		case tar.TypeReg:
+			f, err := fs.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
+			if err != nil {
+				return err
+			}
+			defer f.Close()
+
+			if _, err := io.Copy(f, tr); err != nil {
+				return err
+			}
+		}
+	}
+}
diff --git a/plugin/testdata/hello.tar b/plugin/testdata/hello.tar
new file mode 100644
index 0000000000000000000000000000000000000000..c9b0f3ae30d55731ea0755439bdf6a8b22ec0263
GIT binary patch
literal 3584
zcmeHH!ES>v4CTyMcx=}}0trYxPui+Z)3j>mD$r2SAVGmvs??8P2=q{O;)c3u@g<3E
z9Opeh3SLz*aAq}xP(mp<{TdG|3W+&umodOmNC+iK7!g2N?-K{;tX+-8_ie|NZY@<u
zCMGV_X?Z>oAK%eGQX5QU{;E)6_;0HHt`z=})zu8$X*^;w@ckqISP#dUf1LFElQ^Od
zpc#6(D*4a(7p&$BFjL?jxN1u&7T_Tt`^a;*yv<bEbjEplbOB!aae-XP8W?=FQV8C`
zFg<Q2>zawJ{`_IJ-n_qlJ}=)kPs@+MRQKD$y8_=jZjHd8|1(4Lbffh@{r^v*QU5eX
zVc6>*BVzSG4;z<N@9%=N6hX?`!gYC8Nbt-soHagZ+cP1ZAP#VXo#G9w)|Z9s$s%A8
Lun1TL{uBb=nFW51

literal 0
HcmV?d00001

-- 
GitLab