From 51b8d8a6eeda62b1dda037d717a515a9662a5932 Mon Sep 17 00:00:00 2001 From: Adam Reese Date: Thu, 6 Apr 2017 11:28:09 -0700 Subject: [PATCH] feat(helm): add plugin management commands Add plugin management subcommands for installing and removing plugins to `$HELM_HOST/plugins`. Install accepts a vcs url or a local directory. ``` $ helm plugin install http://github.com/adamreese/helm-env Installed plugin: env $ helm plugin list NAME VERSION DESCRIPTION env 0.1.0 Print out the helm environment. $ helm plugin remove env Removed plugin: env ``` closes #1977 --- cmd/helm/helm.go | 10 +- cmd/helm/{plugins.go => load_plugins.go} | 13 +- cmd/helm/plugin.go | 72 +++++++++ cmd/helm/plugin_install.go | 84 ++++++++++ cmd/helm/plugin_list.go | 63 ++++++++ cmd/helm/plugin_remove.go | 92 +++++++++++ cmd/helm/{plugins_test.go => plugin_test.go} | 0 cmd/helm/printer.go | 8 + glide.lock | 8 +- glide.yaml | 2 + pkg/plugin/cache/cache.go | 74 +++++++++ pkg/plugin/hooks.go | 33 ++++ pkg/plugin/installer/base.go | 48 ++++++ pkg/plugin/installer/doc.go | 17 ++ pkg/plugin/installer/installer.go | 72 +++++++++ pkg/plugin/installer/local_installer.go | 49 ++++++ pkg/plugin/installer/local_installer_test.go | 64 ++++++++ pkg/plugin/installer/vcs_installer.go | 145 ++++++++++++++++++ pkg/plugin/installer/vcs_installer_test.go | 90 +++++++++++ pkg/plugin/plugin.go | 21 ++- pkg/plugin/plugin_test.go | 5 +- pkg/plugin/testdata/plugdir/echo/plugin.yaml | 2 + pkg/plugin/testdata/plugdir/hello/plugin.yaml | 3 + 23 files changed, 962 insertions(+), 13 deletions(-) rename cmd/helm/{plugins.go => load_plugins.go} (96%) create mode 100644 cmd/helm/plugin.go create mode 100644 cmd/helm/plugin_install.go create mode 100644 cmd/helm/plugin_list.go create mode 100644 cmd/helm/plugin_remove.go rename cmd/helm/{plugins_test.go => plugin_test.go} (100%) create mode 100644 pkg/plugin/cache/cache.go create mode 100644 pkg/plugin/hooks.go create mode 100644 pkg/plugin/installer/base.go create mode 100644 pkg/plugin/installer/doc.go create mode 100644 pkg/plugin/installer/installer.go create mode 100644 pkg/plugin/installer/local_installer.go create mode 100644 pkg/plugin/installer/local_installer_test.go create mode 100644 pkg/plugin/installer/vcs_installer.go create mode 100644 pkg/plugin/installer/vcs_installer_test.go diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index 4bfc0d1dd06..4eee6795791 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -136,12 +136,16 @@ func newRootCmd(out io.Writer) *cobra.Command { addFlagsTLS(newStatusCmd(nil, out)), addFlagsTLS(newUpgradeCmd(nil, out)), + addFlagsTLS(newReleaseTestCmd(nil, out)), + addFlagsTLS(newResetCmd(nil, out)), + addFlagsTLS(newVersionCmd(nil, out)), newCompletionCmd(out), newHomeCmd(out), newInitCmd(out), - addFlagsTLS(newResetCmd(nil, out)), - addFlagsTLS(newVersionCmd(nil, out)), - addFlagsTLS(newReleaseTestCmd(nil, out)), + newResetCmd(nil, out), + newVersionCmd(nil, out), + newReleaseTestCmd(nil, out), + newPluginCmd(out), // Hidden documentation generator command: 'helm docs' newDocsCmd(out), diff --git a/cmd/helm/plugins.go b/cmd/helm/load_plugins.go similarity index 96% rename from cmd/helm/plugins.go rename to cmd/helm/load_plugins.go index ba1df5565e6..f33161b31bc 100644 --- a/cmd/helm/plugins.go +++ b/cmd/helm/load_plugins.go @@ -31,6 +31,13 @@ import ( const pluginEnvVar = "HELM_PLUGIN" +func pluginDirs(home helmpath.Home) string { + if dirs := os.Getenv(pluginEnvVar); dirs != "" { + return dirs + } + return home.Plugins() +} + // loadPlugins loads plugins into the command list. // // This follows a different pattern than the other commands because it has @@ -43,11 +50,7 @@ func loadPlugins(baseCmd *cobra.Command, home helmpath.Home, out io.Writer) { return } - plugdirs := os.Getenv(pluginEnvVar) - if plugdirs == "" { - plugdirs = home.Plugins() - } - + plugdirs := pluginDirs(home) found, err := findPlugins(plugdirs) if err != nil { fmt.Fprintf(os.Stderr, "failed to load plugins: %s", err) diff --git a/cmd/helm/plugin.go b/cmd/helm/plugin.go new file mode 100644 index 00000000000..58290eec3b9 --- /dev/null +++ b/cmd/helm/plugin.go @@ -0,0 +1,72 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 main + +import ( + "fmt" + "io" + "os" + "os/exec" + + "k8s.io/helm/pkg/helm/helmpath" + "k8s.io/helm/pkg/plugin" + + "github.com/spf13/cobra" +) + +const pluginHelp = ` +Manage client-side Helm plugins. +` + +func newPluginCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin", + Short: "add, list, or remove Helm plugins", + Long: pluginHelp, + } + cmd.AddCommand( + newPluginInstallCmd(out), + newPluginListCmd(out), + newPluginRemoveCmd(out), + ) + return cmd +} + +// runHook will execute a plugin hook. +func runHook(p *plugin.Plugin, event string, home helmpath.Home) error { + hook := p.Metadata.Hooks.Get(event) + if hook == "" { + 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()) + + debug("running %s hook: %s", event, prog) + + setupEnv(p.Metadata.Name, p.Dir, home.Plugins(), home) + prog.Stdout, prog.Stderr = os.Stdout, os.Stderr + if err := prog.Run(); err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + os.Stderr.Write(eerr.Stderr) + return fmt.Errorf("plugin %s hook for %q exited with error", event, p.Metadata.Name) + } + return err + } + return nil +} diff --git a/cmd/helm/plugin_install.go b/cmd/helm/plugin_install.go new file mode 100644 index 00000000000..befa1d8fda6 --- /dev/null +++ b/cmd/helm/plugin_install.go @@ -0,0 +1,84 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 main + +import ( + "fmt" + "io" + + "k8s.io/helm/pkg/helm/helmpath" + "k8s.io/helm/pkg/plugin" + "k8s.io/helm/pkg/plugin/installer" + + "github.com/spf13/cobra" +) + +type pluginInstallCmd struct { + source string + version string + home helmpath.Home + out io.Writer +} + +func newPluginInstallCmd(out io.Writer) *cobra.Command { + pcmd := &pluginInstallCmd{out: out} + cmd := &cobra.Command{ + Use: "install [options] ...", + Short: "install one or more Helm plugins", + PreRunE: func(cmd *cobra.Command, args []string) error { + return pcmd.complete(args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return pcmd.run() + }, + } + cmd.Flags().StringVar(&pcmd.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed") + return cmd +} + +func (pcmd *pluginInstallCmd) complete(args []string) error { + if err := checkArgsLength(len(args), "plugin"); err != nil { + return err + } + pcmd.source = args[0] + pcmd.home = helmpath.Home(homePath()) + return nil +} + +func (pcmd *pluginInstallCmd) run() error { + installer.Debug = flagDebug + + i, err := installer.NewForSource(pcmd.source, pcmd.version, pcmd.home) + if err != nil { + return err + } + if err := installer.Install(i); err != nil { + return err + } + + debug("loading plugin from %s", i.Path()) + p, err := plugin.LoadDir(i.Path()) + if err != nil { + return err + } + + if err := runHook(p, plugin.Install, pcmd.home); err != nil { + return err + } + + fmt.Fprintf(pcmd.out, "Installed plugin: %s\n", p.Metadata.Name) + return nil +} diff --git a/cmd/helm/plugin_list.go b/cmd/helm/plugin_list.go new file mode 100644 index 00000000000..e71b7203871 --- /dev/null +++ b/cmd/helm/plugin_list.go @@ -0,0 +1,63 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 main + +import ( + "fmt" + "io" + + "k8s.io/helm/pkg/helm/helmpath" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" +) + +type pluginListCmd struct { + home helmpath.Home + out io.Writer +} + +func newPluginListCmd(out io.Writer) *cobra.Command { + pcmd := &pluginListCmd{out: out} + cmd := &cobra.Command{ + Use: "list", + Short: "list installed Helm plugins", + RunE: func(cmd *cobra.Command, args []string) error { + pcmd.home = helmpath.Home(homePath()) + return pcmd.run() + }, + } + return cmd +} + +func (pcmd *pluginListCmd) run() error { + plugdirs := pluginDirs(pcmd.home) + + debug("pluginDirs: %s", plugdirs) + + plugins, err := findPlugins(plugdirs) + if err != nil { + return err + } + + table := uitable.New() + table.AddRow("NAME", "VERSION", "DESCRIPTION") + for _, p := range plugins { + table.AddRow(p.Metadata.Name, p.Metadata.Version, p.Metadata.Description) + } + fmt.Fprintln(pcmd.out, table) + return nil +} diff --git a/cmd/helm/plugin_remove.go b/cmd/helm/plugin_remove.go new file mode 100644 index 00000000000..74fc680b711 --- /dev/null +++ b/cmd/helm/plugin_remove.go @@ -0,0 +1,92 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 main + +import ( + "fmt" + "io" + "os" + + "k8s.io/helm/pkg/helm/helmpath" + "k8s.io/helm/pkg/plugin" + + "github.com/spf13/cobra" +) + +type pluginRemoveCmd struct { + names []string + home helmpath.Home + out io.Writer +} + +func newPluginRemoveCmd(out io.Writer) *cobra.Command { + pcmd := &pluginRemoveCmd{out: out} + cmd := &cobra.Command{ + Use: "remove ...", + Short: "remove one or more Helm plugins", + PreRunE: func(cmd *cobra.Command, args []string) error { + return pcmd.complete(args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return pcmd.run() + }, + } + return cmd +} + +func (pcmd *pluginRemoveCmd) complete(args []string) error { + if err := checkArgsLength(len(args), "plugin"); err != nil { + return err + } + pcmd.names = args + pcmd.home = helmpath.Home(homePath()) + return nil +} + +func (pcmd *pluginRemoveCmd) run() error { + plugdirs := pluginDirs(pcmd.home) + debug("loading installed plugins from %s", plugdirs) + plugins, err := findPlugins(plugdirs) + if err != nil { + return err + } + + for _, name := range pcmd.names { + if found := findPlugin(plugins, name); found != nil { + if err := removePlugin(found, pcmd.home); err != nil { + return err + } + fmt.Fprintf(pcmd.out, "Removed plugin: %s\n", name) + } + } + return nil +} + +func removePlugin(p *plugin.Plugin, home helmpath.Home) error { + if err := os.Remove(p.Dir); err != nil { + return err + } + return runHook(p, plugin.Delete, home) +} + +func findPlugin(plugins []*plugin.Plugin, name string) *plugin.Plugin { + for _, p := range plugins { + if p.Metadata.Name == name { + return p + } + } + return nil +} diff --git a/cmd/helm/plugins_test.go b/cmd/helm/plugin_test.go similarity index 100% rename from cmd/helm/plugins_test.go rename to cmd/helm/plugin_test.go diff --git a/cmd/helm/printer.go b/cmd/helm/printer.go index 34b1122beff..dbffd5c44a3 100644 --- a/cmd/helm/printer.go +++ b/cmd/helm/printer.go @@ -17,6 +17,7 @@ limitations under the License. package main import ( + "fmt" "io" "text/template" "time" @@ -72,3 +73,10 @@ func tpl(t string, vals map[string]interface{}, out io.Writer) error { } return tt.Execute(out, vals) } + +func debug(format string, args ...interface{}) { + if flagDebug { + format = fmt.Sprintf("[debug] %s\n", format) + fmt.Printf(format, args...) + } +} diff --git a/glide.lock b/glide.lock index 3ea2cc6178d..522bbc0160f 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ -hash: df0fa621e6a6f80dbfeb815d9d8aa308c50346a9821e401b19b6f10782da3774 -updated: 2017-04-03T17:00:07.670429885-06:00 +hash: 6a39d319e98b1b4305c48e9b718604b723184f27a1366efcedc42d95bcbeb0c8 +updated: 2017-04-06T10:04:41.822904395-07:00 imports: - name: cloud.google.com/go version: 3b1ae45394a234c385be014e9a488f2bb6eef821 @@ -185,6 +185,8 @@ imports: version: 3f0ab6d4ab4bed1c61caf056b63a6e62190c7801 - name: github.com/Masterminds/sprig version: 23597e5f6ad0e4d590e71314bfd0251a4a3cf849 +- name: github.com/Masterminds/vcs + version: 795e20f901c3d561de52811fb3488a2cb2c8588b - name: github.com/mattn/go-runewidth version: d6bea18f789704b5f83375793155289da36a3c7f - name: github.com/mitchellh/go-wordwrap @@ -300,7 +302,7 @@ imports: - name: gopkg.in/yaml.v2 version: a83829b6f1293c91addabc89d0571c246397bbf4 - name: k8s.io/kubernetes - version: ea8f6637b639246faa14a8d5c6f864100fcb77a9 + version: 114f8911f9597be669a747ab72787e0bd74c9359 subpackages: - cmd/kubeadm/app/apis/kubeadm - cmd/kubeadm/app/apis/kubeadm/install diff --git a/glide.yaml b/glide.yaml index a703bff5b9f..7ebd3b7aee8 100644 --- a/glide.yaml +++ b/glide.yaml @@ -8,6 +8,8 @@ import: version: f62e98d28ab7ad31d707ba837a966378465c7b57 - package: github.com/spf13/pflag version: 5ccb023bc27df288a957c5e994cd44fd19619465 +- package: github.com/Masterminds/vcs + version: ~1.11.0 - package: github.com/Masterminds/sprig version: ^2.10 - package: github.com/ghodss/yaml diff --git a/pkg/plugin/cache/cache.go b/pkg/plugin/cache/cache.go new file mode 100644 index 00000000000..a1d3224c8b4 --- /dev/null +++ b/pkg/plugin/cache/cache.go @@ -0,0 +1,74 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 cache provides a key generator for vcs urls. +package cache // import "k8s.io/helm/pkg/plugin/cache" + +import ( + "net/url" + "regexp" + "strings" +) + +// Thanks glide! + +// scpSyntaxRe matches the SCP-like addresses used to access repos over SSH. +var scpSyntaxRe = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`) + +// Key generates a cache key based on a url or scp string. The key is file +// system safe. +func Key(repo string) (string, error) { + + var u *url.URL + var err error + var strip bool + if m := scpSyntaxRe.FindStringSubmatch(repo); m != nil { + // Match SCP-like syntax and convert it to a URL. + // Eg, "git@github.com:user/repo" becomes + // "ssh://git@github.com/user/repo". + u = &url.URL{ + Scheme: "ssh", + User: url.User(m[1]), + Host: m[2], + Path: "/" + m[3], + } + strip = true + } else { + u, err = url.Parse(repo) + if err != nil { + return "", err + } + } + + if strip { + u.Scheme = "" + } + + var key string + if u.Scheme != "" { + key = u.Scheme + "-" + } + if u.User != nil && u.User.Username() != "" { + key = key + u.User.Username() + "-" + } + key = key + u.Host + if u.Path != "" { + key = key + strings.Replace(u.Path, "/", "-", -1) + } + + key = strings.Replace(key, ":", "-", -1) + + return key, nil +} diff --git a/pkg/plugin/hooks.go b/pkg/plugin/hooks.go new file mode 100644 index 00000000000..1f435f9f858 --- /dev/null +++ b/pkg/plugin/hooks.go @@ -0,0 +1,33 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 plugin // import "k8s.io/helm/pkg/plugin" + +// Types of hooks +const ( + // Install is executed after the plugin is added. + Install = "install" + // Delete is executed after the plugin is removed. + Delete = "delete" +) + +// Hooks is a map of events to commands. +type Hooks map[string]string + +// Get returns a hook for an event. +func (hooks Hooks) Get(event string) string { + h, _ := hooks[event] + return h +} diff --git a/pkg/plugin/installer/base.go b/pkg/plugin/installer/base.go new file mode 100644 index 00000000000..0664dae763e --- /dev/null +++ b/pkg/plugin/installer/base.go @@ -0,0 +1,48 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 installer // import "k8s.io/helm/pkg/plugin/installer" + +import ( + "os" + "path/filepath" + + "k8s.io/helm/pkg/helm/helmpath" +) + +type base struct { + // Source is the reference to a plugin + Source string + // HelmHome is the $HELM_HOME directory + HelmHome helmpath.Home +} + +func newBase(source string, home helmpath.Home) base { + return base{source, home} +} + +// link creates a symlink from the plugin source to $HELM_HOME. +func (b *base) link(from string) error { + debug("symlinking %s to %s", from, b.Path()) + return os.Symlink(from, b.Path()) +} + +// Path is where the plugin will be symlinked to. +func (b *base) Path() string { + if b.Source == "" { + return "" + } + return filepath.Join(b.HelmHome.Plugins(), filepath.Base(b.Source)) +} diff --git a/pkg/plugin/installer/doc.go b/pkg/plugin/installer/doc.go new file mode 100644 index 00000000000..a2a66f3e1dc --- /dev/null +++ b/pkg/plugin/installer/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 installer provides an interface for installing Helm plugins. +package installer // import "k8s.io/helm/pkg/plugin/installer" diff --git a/pkg/plugin/installer/installer.go b/pkg/plugin/installer/installer.go new file mode 100644 index 00000000000..31ef9ae5340 --- /dev/null +++ b/pkg/plugin/installer/installer.go @@ -0,0 +1,72 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 installer + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "k8s.io/helm/pkg/helm/helmpath" +) + +// ErrMissingMetadata indicates that plugin.yaml is missing. +var ErrMissingMetadata = errors.New("plugin metadata (plugin.yaml) missing") + +// Debug enables verbose output. +var Debug bool + +// Installer provides an interface for installing helm client plugins. +type Installer interface { + // Install adds a plugin to $HELM_HOME. + Install() error + // Path is the directory of the installed plugin. + Path() string +} + +// Install installs a plugin to $HELM_HOME. +func Install(i Installer) error { + return i.Install() +} + +// NewForSource determines the correct Installer for the given source. +func NewForSource(source, version string, home helmpath.Home) (Installer, error) { + // Check if source is a local directory + if isLocalReference(source) { + return NewLocalInstaller(source, home) + } + return NewVCSInstaller(source, version, home) +} + +// isLocalReference checks if the source exists on the filesystem. +func isLocalReference(source string) bool { + _, err := os.Stat(source) + return err == nil +} + +// isPlugin checks if the directory contains a plugin.yaml file. +func isPlugin(dirname string) bool { + _, err := os.Stat(filepath.Join(dirname, "plugin.yaml")) + return err == nil +} + +func debug(format string, args ...interface{}) { + if Debug { + format = fmt.Sprintf("[debug] %s\n", format) + fmt.Printf(format, args...) + } +} diff --git a/pkg/plugin/installer/local_installer.go b/pkg/plugin/installer/local_installer.go new file mode 100644 index 00000000000..7ab588d60fa --- /dev/null +++ b/pkg/plugin/installer/local_installer.go @@ -0,0 +1,49 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 installer // import "k8s.io/helm/pkg/plugin/installer" + +import ( + "path/filepath" + + "k8s.io/helm/pkg/helm/helmpath" +) + +// LocalInstaller installs plugins from the filesystem. +type LocalInstaller struct { + base +} + +// NewLocalInstaller creates a new LocalInstaller. +func NewLocalInstaller(source string, home helmpath.Home) (*LocalInstaller, error) { + i := &LocalInstaller{ + base: newBase(source, home), + } + return i, nil +} + +// Install creates a symlink to the plugin directory in $HELM_HOME. +// +// Implements Installer. +func (i *LocalInstaller) Install() error { + if !isPlugin(i.Source) { + return ErrMissingMetadata + } + src, err := filepath.Abs(i.Source) + if err != nil { + return err + } + return i.link(src) +} diff --git a/pkg/plugin/installer/local_installer_test.go b/pkg/plugin/installer/local_installer_test.go new file mode 100644 index 00000000000..a2a1b541c1f --- /dev/null +++ b/pkg/plugin/installer/local_installer_test.go @@ -0,0 +1,64 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 installer // import "k8s.io/helm/pkg/plugin/installer" + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "k8s.io/helm/pkg/helm/helmpath" +) + +var _ Installer = new(LocalInstaller) + +func TestLocalInstaller(t *testing.T) { + hh, err := ioutil.TempDir("", "helm-home-") + if err != nil { + t.Fatal(err) + } + defer os.Remove(hh) + + home := helmpath.Home(hh) + if err := os.MkdirAll(home.Plugins(), 0755); err != nil { + t.Fatalf("Could not create %s: %s", home.Plugins(), err) + } + + // Make a temp dir + tdir, err := ioutil.TempDir("", "helm-installer-") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tdir) + if err := ioutil.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + source := "../testdata/plugdir/echo" + i, err := NewForSource(source, "", home) + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if err := Install(i); err != nil { + t.Error(err) + } + + if i.Path() != home.Path("plugins", "echo") { + t.Errorf("expected path '$HELM_HOME/plugins/helm-env', got %q", i.Path()) + } +} diff --git a/pkg/plugin/installer/vcs_installer.go b/pkg/plugin/installer/vcs_installer.go new file mode 100644 index 00000000000..537aca3e6fc --- /dev/null +++ b/pkg/plugin/installer/vcs_installer.go @@ -0,0 +1,145 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 installer // import "k8s.io/helm/pkg/plugin/installer" + +import ( + "os" + "sort" + + "github.com/Masterminds/semver" + "github.com/Masterminds/vcs" + + "k8s.io/helm/pkg/helm/helmpath" + "k8s.io/helm/pkg/plugin/cache" +) + +// VCSInstaller installs plugins from remote a repository. +type VCSInstaller struct { + Repo vcs.Repo + Version string + base +} + +// NewVCSInstaller creates a new VCSInstaller. +func NewVCSInstaller(source, version string, home helmpath.Home) (*VCSInstaller, error) { + key, err := cache.Key(source) + if err != nil { + return nil, err + } + cachedpath := home.Path("cache", "plugins", key) + repo, err := vcs.NewRepo(source, cachedpath) + if err != nil { + return nil, err + } + i := &VCSInstaller{ + Repo: repo, + Version: version, + base: newBase(source, home), + } + return i, err +} + +// Install clones a remote repository and creates a symlink to the plugin directory in HELM_HOME. +// +// Implements Installer. +func (i *VCSInstaller) Install() error { + if err := i.sync(i.Repo); err != nil { + return err + } + + ref, err := i.solveVersion(i.Repo) + if err != nil { + return err + } + + if err := i.setVersion(i.Repo, ref); err != nil { + return err + } + + if !isPlugin(i.Repo.LocalPath()) { + return ErrMissingMetadata + } + + return i.link(i.Repo.LocalPath()) +} + +func (i *VCSInstaller) solveVersion(repo vcs.Repo) (string, error) { + if i.Version == "" { + return "", nil + } + + if repo.IsReference(i.Version) { + return i.Version, nil + } + + // Create the constraint first to make sure it's valid before + // working on the repo. + constraint, err := semver.NewConstraint(i.Version) + if err != nil { + return "", err + } + + // Get the tags and branches (in that order) + refs, err := repo.Tags() + if err != nil { + return "", err + } + debug("found refs: %s", refs) + + // Convert and filter the list to semver.Version instances + semvers := getSemVers(refs) + + // Sort semver list + sort.Sort(sort.Reverse(semver.Collection(semvers))) + for _, v := range semvers { + if constraint.Check(v) { + // If the constrint passes get the original reference + ver := v.Original() + debug("setting to %s", ver) + return ver, nil + } + } + return "", nil +} + +// setVersion attempts to checkout the version +func (i *VCSInstaller) setVersion(repo vcs.Repo, ref string) error { + debug("setting version to %q", i.Version) + return repo.UpdateVersion(ref) +} + +// sync will clone or update a remote repo. +func (i *VCSInstaller) sync(repo vcs.Repo) error { + if _, err := os.Stat(repo.LocalPath()); os.IsNotExist(err) { + debug("cloning %s to %s", repo.Remote(), repo.LocalPath()) + return repo.Get() + } + debug("updating %s", repo.Remote()) + return repo.Update() +} + +// Filter a list of versions to only included semantic versions. The response +// is a mapping of the original version to the semantic version. +func getSemVers(refs []string) []*semver.Version { + var sv []*semver.Version + for _, r := range refs { + v, err := semver.NewVersion(r) + if err == nil { + sv = append(sv, v) + } + } + return sv +} diff --git a/pkg/plugin/installer/vcs_installer_test.go b/pkg/plugin/installer/vcs_installer_test.go new file mode 100644 index 00000000000..e340bbe5a99 --- /dev/null +++ b/pkg/plugin/installer/vcs_installer_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 installer // import "k8s.io/helm/pkg/plugin/installer" + +import ( + "io/ioutil" + "os" + "testing" + + "k8s.io/helm/pkg/helm/helmpath" + + "github.com/Masterminds/vcs" +) + +var _ Installer = new(VCSInstaller) + +type testRepo struct { + local, remote, current string + tags, branches []string + err error + vcs.Repo +} + +func (r *testRepo) LocalPath() string { return r.local } +func (r *testRepo) Remote() string { return r.remote } +func (r *testRepo) Update() error { return r.err } +func (r *testRepo) Get() error { return r.err } +func (r *testRepo) IsReference(string) bool { return false } +func (r *testRepo) Tags() ([]string, error) { return r.tags, r.err } +func (r *testRepo) Branches() ([]string, error) { return r.branches, r.err } +func (r *testRepo) UpdateVersion(version string) error { + r.current = version + return r.err +} + +func TestVCSInstaller(t *testing.T) { + hh, err := ioutil.TempDir("", "helm-home-") + if err != nil { + t.Fatal(err) + } + defer os.Remove(hh) + + home := helmpath.Home(hh) + if err := os.MkdirAll(home.Plugins(), 0755); err != nil { + t.Fatalf("Could not create %s: %s", home.Plugins(), err) + } + + source := "https://github.com/adamreese/helm-env" + repo := &testRepo{ + local: "../testdata/plugdir/echo", + tags: []string{"0.1.0", "0.1.1"}, + } + + i, err := NewForSource(source, "~0.1.0", home) + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + // ensure a VCSInstaller was returned + vcsInstaller, ok := i.(*VCSInstaller) + if !ok { + t.Error("expected a VCSInstaller") + } + + // set the testRepo in the VCSInstaller + vcsInstaller.Repo = repo + + if err := Install(i); err != nil { + t.Error(err) + } + if repo.current != "0.1.1" { + t.Errorf("expected version '0.1.1', got %q", repo.current) + } + if i.Path() != home.Path("plugins", "helm-env") { + t.Errorf("expected path '$HELM_HOME/plugins/helm-env', got %q", i.Path()) + } +} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index bc8cbb73283..d9fbf80ea03 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugin +package plugin // import "k8s.io/helm/pkg/plugin" import ( "io/ioutil" @@ -21,6 +21,7 @@ import ( "path/filepath" "strings" + "github.com/Masterminds/vcs" "github.com/ghodss/yaml" ) @@ -64,6 +65,9 @@ type Metadata struct { // Setting this will cause a number of side effects, such as the // automatic setting of HELM_HOST. UseTunnel bool `json:"useTunnel"` + + // Hooks are commands that will run on events. + Hooks Hooks } // Plugin represents a plugin. @@ -72,6 +76,17 @@ type Plugin struct { Metadata *Metadata // Dir is the string path to the directory that holds the plugin. Dir string + // Remote is the remote repo location. + Remote string +} + +func detectSource(dirname string) (string, error) { + if repo, err := vcs.NewRepo("", dirname); err == nil { + if repo.CheckLocal() { + return repo.Remote(), nil + } + } + return os.Readlink(dirname) } // PrepareCommand takes a Plugin.Command and prepares it for execution. @@ -101,6 +116,10 @@ func LoadDir(dirname string) (*Plugin, error) { } plug := &Plugin{Dir: dirname} + if src, err := detectSource(dirname); err == nil { + plug.Remote = src + } + if err := yaml.Unmarshal(data, &plug.Metadata); err != nil { return nil, err } diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go index afcf4b9b3de..e0abb910731 100644 --- a/pkg/plugin/plugin_test.go +++ b/pkg/plugin/plugin_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugin +package plugin // import "k8s.io/helm/pkg/plugin" import ( "reflect" @@ -82,6 +82,9 @@ func TestLoadDir(t *testing.T) { Command: "$HELM_PLUGIN_SELF/hello.sh", UseTunnel: true, IgnoreFlags: true, + Hooks: map[string]string{ + Install: "echo installing...", + }, } if !reflect.DeepEqual(expect, plug.Metadata) { diff --git a/pkg/plugin/testdata/plugdir/echo/plugin.yaml b/pkg/plugin/testdata/plugdir/echo/plugin.yaml index da6f656ebbc..8baa35b6d5f 100644 --- a/pkg/plugin/testdata/plugdir/echo/plugin.yaml +++ b/pkg/plugin/testdata/plugdir/echo/plugin.yaml @@ -4,3 +4,5 @@ usage: "echo something" description: |- This is a testing fixture. command: "echo Hello" +hooks: + install: "echo Installing" diff --git a/pkg/plugin/testdata/plugdir/hello/plugin.yaml b/pkg/plugin/testdata/plugdir/hello/plugin.yaml index 94b7f43dccf..cdb27b29122 100644 --- a/pkg/plugin/testdata/plugdir/hello/plugin.yaml +++ b/pkg/plugin/testdata/plugdir/hello/plugin.yaml @@ -6,3 +6,6 @@ description: |- command: "$HELM_PLUGIN_SELF/hello.sh" useTunnel: true ignoreFlags: true +install: "echo installing..." +hooks: + install: "echo installing..."