From 29bf8c09ea66aba6e5f788174f400164bce9b059 Mon Sep 17 00:00:00 2001 From: Josh Dolitsky Date: Fri, 3 Dec 2021 15:34:04 -0600 Subject: [PATCH] Add support for uploader plugins Signed-off-by: Josh Dolitsky --- internal/experimental/action/push.go | 8 +- internal/experimental/pusher/pluginpusher.go | 102 ++++++++++++++++++ internal/experimental/pusher/pusher.go | 37 ++++++- internal/experimental/pusher/pusher_test.go | 17 ++- .../testdata/plugins/testpusher/plugin.yaml | 15 +++ .../testdata/plugins/testpusher/push.sh | 8 ++ .../testdata/plugins/testpusher2/plugin.yaml | 10 ++ .../testdata/plugins/testpusher2/push.sh | 8 ++ pkg/plugin/plugin.go | 14 +++ pkg/plugin/plugin_test.go | 39 ++++++- .../plugdir/good/uploader/plugin.yaml | 11 ++ 11 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 internal/experimental/pusher/pluginpusher.go create mode 100644 internal/experimental/pusher/testdata/plugins/testpusher/plugin.yaml create mode 100755 internal/experimental/pusher/testdata/plugins/testpusher/push.sh create mode 100644 internal/experimental/pusher/testdata/plugins/testpusher2/plugin.yaml create mode 100755 internal/experimental/pusher/testdata/plugins/testpusher2/push.sh create mode 100644 pkg/plugin/testdata/plugdir/good/uploader/plugin.yaml diff --git a/internal/experimental/action/push.go b/internal/experimental/action/push.go index b125ae1f4..96db90698 100644 --- a/internal/experimental/action/push.go +++ b/internal/experimental/action/push.go @@ -30,6 +30,8 @@ import ( // // It provides the implementation of 'helm push'. type Push struct { + action.ChartPathOptions + Settings *cli.EnvSettings cfg *action.Configuration } @@ -60,7 +62,11 @@ func (p *Push) Run(chartRef string, remote string) (string, error) { c := uploader.ChartUploader{ Out: &out, Pushers: pusher.All(p.Settings), - Options: []pusher.Option{}, + Options: []pusher.Option{ + pusher.WithBasicAuth(p.Username, p.Password), + pusher.WithTLSClientConfig(p.CertFile, p.KeyFile, p.CaFile), + pusher.WithInsecureSkipVerifyTLS(p.InsecureSkipTLSverify), + }, } if registry.IsOCI(remote) { diff --git a/internal/experimental/pusher/pluginpusher.go b/internal/experimental/pusher/pluginpusher.go new file mode 100644 index 000000000..bdd848e1b --- /dev/null +++ b/internal/experimental/pusher/pluginpusher.go @@ -0,0 +1,102 @@ +/* +Copyright The Helm Authors. +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 pusher + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/plugin" +) + +// collectPlugins scans for pusher plugins. +// This will load plugins according to the cli. +func collectPlugins(settings *cli.EnvSettings) (Providers, error) { + plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + if err != nil { + return nil, err + } + var result Providers + for _, plugin := range plugins { + for _, uploader := range plugin.Metadata.Uploaders { + result = append(result, Provider{ + Schemes: uploader.Protocols, + New: NewPluginPusher( + uploader.Command, + settings, + plugin.Metadata.Name, + plugin.Dir, + ), + }) + } + } + return result, nil +} + +// pluginPusher is a generic type to invoke custom uploaders, +// implemented in plugins. +type pluginPusher struct { + command string + settings *cli.EnvSettings + name string + base string + opts options +} + +// Push runs uploader plugin command +func (p *pluginPusher) Push(chartRef, href string, options ...Option) error { + for _, opt := range options { + opt(&p.opts) + } + commands := strings.Split(p.command, " ") + argv := append(commands[1:], p.opts.certFile, p.opts.keyFile, p.opts.caFile, href, chartRef) + prog := exec.Command(filepath.Join(p.base, commands[0]), argv...) + plugin.SetupPluginEnv(p.settings, p.name, p.base) + prog.Env = os.Environ() + buf := bytes.NewBuffer(nil) + prog.Stdout = buf + prog.Stderr = os.Stderr + if err := prog.Run(); err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + os.Stderr.Write(eerr.Stderr) + return errors.Errorf("plugin %q exited with error", p.command) + } + return err + } + return nil +} + +// NewPluginPusher constructs a valid plugin pusher +func NewPluginPusher(command string, settings *cli.EnvSettings, name, base string) Constructor { + return func(options ...Option) (Pusher, error) { + result := &pluginPusher{ + command: command, + settings: settings, + name: name, + base: base, + } + for _, opt := range options { + opt(&result.opts) + } + return result, nil + } +} diff --git a/internal/experimental/pusher/pusher.go b/internal/experimental/pusher/pusher.go index 32c1351e9..0c209b685 100644 --- a/internal/experimental/pusher/pusher.go +++ b/internal/experimental/pusher/pusher.go @@ -27,13 +27,43 @@ import ( // // Pushers may or may not ignore these parameters as they are passed in. type options struct { - registryClient *registry.Client + certFile string + keyFile string + caFile string + insecureSkipVerifyTLS bool + username string + password string + registryClient *registry.Client } // Option allows specifying various settings configurable by the user for overriding the defaults // used when performing Push operations with the Pusher. type Option func(*options) +// WithBasicAuth sets the request's Authorization header to use the provided credentials +func WithBasicAuth(username, password string) Option { + return func(opts *options) { + opts.username = username + opts.password = password + } +} + +// WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked +func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option { + return func(opts *options) { + opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS + } +} + +// WithTLSClientConfig sets the client auth with the provided credentials. +func WithTLSClientConfig(certFile, keyFile, caFile string) Option { + return func(opts *options) { + opts.certFile = certFile + opts.keyFile = keyFile + opts.caFile = caFile + } +} + // WithRegistryClient sets the registryClient option. func WithRegistryClient(client *registry.Client) Option { return func(opts *options) { @@ -88,8 +118,11 @@ var ociProvider = Provider{ } // All finds all of the registered pushers as a list of Provider instances. -// Currently, just the built-in pushers are collected. +// Currently, the built-in pushers and the discovered plugins with uploader +// notations are collected. func All(settings *cli.EnvSettings) Providers { result := Providers{ociProvider} + pluginDownloaders, _ := collectPlugins(settings) + result = append(result, pluginDownloaders...) return result } diff --git a/internal/experimental/pusher/pusher_test.go b/internal/experimental/pusher/pusher_test.go index a99b1a5db..51609b032 100644 --- a/internal/experimental/pusher/pusher_test.go +++ b/internal/experimental/pusher/pusher_test.go @@ -22,6 +22,8 @@ import ( "helm.sh/helm/v3/pkg/cli" ) +const pluginDir = "testdata/plugins" + func TestProvider(t *testing.T) { p := Provider{ []string{"one", "three"}, @@ -53,15 +55,26 @@ func TestProviders(t *testing.T) { func TestAll(t *testing.T) { env := cli.New() + env.PluginsDirectory = pluginDir + all := All(env) - if len(all) != 1 { - t.Errorf("expected 1 provider (OCI), got %d", len(all)) + if len(all) != 3 { + t.Errorf("expected 3 providers (OCI plus two plugins), got %d", len(all)) + } + + if _, err := all.ByScheme("test2"); err != nil { + t.Error(err) } } func TestByScheme(t *testing.T) { env := cli.New() + env.PluginsDirectory = pluginDir + g := All(env) + if _, err := g.ByScheme("test"); err != nil { + t.Error(err) + } if _, err := g.ByScheme(registry.OCIScheme); err != nil { t.Error(err) } diff --git a/internal/experimental/pusher/testdata/plugins/testpusher/plugin.yaml b/internal/experimental/pusher/testdata/plugins/testpusher/plugin.yaml new file mode 100644 index 000000000..0a17eed86 --- /dev/null +++ b/internal/experimental/pusher/testdata/plugins/testpusher/plugin.yaml @@ -0,0 +1,15 @@ +name: "testpusher" +version: "0.1.0" +usage: "Push a package to a test:// remote" +description: |- + Print the environment that the plugin was given, then exit. + + This registers the test:// protocol. + +command: "$HELM_PLUGIN_DIR/push.sh" +ignoreFlags: true +uploaders: +#- command: "$HELM_PLUGIN_DIR/push.sh" +- command: "echo" + protocols: + - "test" diff --git a/internal/experimental/pusher/testdata/plugins/testpusher/push.sh b/internal/experimental/pusher/testdata/plugins/testpusher/push.sh new file mode 100755 index 000000000..cdd992369 --- /dev/null +++ b/internal/experimental/pusher/testdata/plugins/testpusher/push.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +echo ENVIRONMENT +env + +echo "" +echo ARGUMENTS +echo $@ diff --git a/internal/experimental/pusher/testdata/plugins/testpusher2/plugin.yaml b/internal/experimental/pusher/testdata/plugins/testpusher2/plugin.yaml new file mode 100644 index 000000000..825648408 --- /dev/null +++ b/internal/experimental/pusher/testdata/plugins/testpusher2/plugin.yaml @@ -0,0 +1,10 @@ +name: "testpusher2" +version: "0.1.0" +usage: "Push a different package to a test2:// remote" +description: "Handle test2 scheme" +command: "$HELM_PLUGIN_DIR/push.sh" +ignoreFlags: true +uploaders: +- command: "echo" + protocols: + - "test2" diff --git a/internal/experimental/pusher/testdata/plugins/testpusher2/push.sh b/internal/experimental/pusher/testdata/plugins/testpusher2/push.sh new file mode 100755 index 000000000..cdd992369 --- /dev/null +++ b/internal/experimental/pusher/testdata/plugins/testpusher2/push.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +echo ENVIRONMENT +env + +echo "" +echo ARGUMENTS +echo $@ diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 1399b7116..801d2ffe8 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -43,6 +43,16 @@ type Downloaders struct { Command string `json:"command"` } +// Uploaders represents the plugins capability if it can publish +// charts to special sources +type Uploaders struct { + // Protocols are the list of schemes from the charts URL. + Protocols []string `json:"protocols"` + // Command is the executable path with which the plugin performs + // the actual upload for the corresponding Protocols + Command string `json:"command"` +} + // PlatformCommand represents a command for a particular operating system and architecture type PlatformCommand struct { OperatingSystem string `json:"os"` @@ -98,6 +108,10 @@ type Metadata struct { // for special protocols. Downloaders []Downloaders `json:"downloaders"` + // Uploaders field is used if the plugin supply uploader mechanism + // for special protocols. + Uploaders []Uploaders `json:"uploaders"` + // UseTunnelDeprecated indicates that this command needs a tunnel. // Setting this will cause a number of side effects, such as the // automatic setting of HELM_HOST. diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go index 3b44a6eb5..ec9d6b96c 100644 --- a/pkg/plugin/plugin_test.go +++ b/pkg/plugin/plugin_test.go @@ -242,6 +242,36 @@ func TestDownloader(t *testing.T) { } } +func TestUploader(t *testing.T) { + dirname := "testdata/plugdir/good/uploader" + plug, err := LoadDir(dirname) + if err != nil { + t.Fatalf("error loading uploader plugin: %s", err) + } + + if plug.Dir != dirname { + t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir) + } + + expect := &Metadata{ + Name: "uploader", + Version: "1.2.3", + Usage: "usage", + Description: "upload something", + Command: "echo Hello", + Uploaders: []Uploaders{ + { + Protocols: []string{"myprotocol", "myprotocols"}, + Command: "echo Upload", + }, + }, + } + + if !reflect.DeepEqual(expect, plug.Metadata) { + t.Fatalf("Expected metadata %v, got %v", expect, plug.Metadata) + } +} + func TestLoadAll(t *testing.T) { // Verify that empty dir loads: @@ -257,8 +287,8 @@ func TestLoadAll(t *testing.T) { t.Fatalf("Could not load %q: %s", basedir, err) } - if l := len(plugs); l != 3 { - t.Fatalf("expected 3 plugins, found %d", l) + if l := len(plugs); l != 4 { + t.Fatalf("expected 4 plugins, found %d", l) } if plugs[0].Metadata.Name != "downloader" { @@ -270,6 +300,9 @@ func TestLoadAll(t *testing.T) { if plugs[2].Metadata.Name != "hello" { t.Errorf("Expected second plugin to be hello, got %q", plugs[1].Metadata.Name) } + if plugs[3].Metadata.Name != "uploader" { + t.Errorf("Expected second plugin to be hello, got %q", plugs[1].Metadata.Name) + } } func TestFindPlugins(t *testing.T) { @@ -296,7 +329,7 @@ func TestFindPlugins(t *testing.T) { { name: "normal", plugdirs: "./testdata/plugdir/good", - expected: 3, + expected: 4, }, } for _, c := range cases { diff --git a/pkg/plugin/testdata/plugdir/good/uploader/plugin.yaml b/pkg/plugin/testdata/plugdir/good/uploader/plugin.yaml new file mode 100644 index 000000000..5dbe1514c --- /dev/null +++ b/pkg/plugin/testdata/plugdir/good/uploader/plugin.yaml @@ -0,0 +1,11 @@ +name: "uploader" +version: "1.2.3" +usage: "usage" +description: |- + upload something +command: "echo Hello" +uploaders: + - protocols: + - "myprotocol" + - "myprotocols" + command: "echo Upload"