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