1/*
2Copyright The Helm Authors.
3Licensed under the Apache License, Version 2.0 (the "License");
4you may not use this file except in compliance with the License.
5You may obtain a copy of the License at
6
7http://www.apache.org/licenses/LICENSE-2.0
8
9Unless required by applicable law or agreed to in writing, software
10distributed under the License is distributed on an "AS IS" BASIS,
11WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12See the License for the specific language governing permissions and
13limitations under the License.
14*/
15
16package plugin // import "helm.sh/helm/v3/pkg/plugin"
17
18import (
19	"fmt"
20	"io/ioutil"
21	"os"
22	"path/filepath"
23	"regexp"
24	"runtime"
25	"strings"
26	"unicode"
27
28	"github.com/pkg/errors"
29	"sigs.k8s.io/yaml"
30
31	"helm.sh/helm/v3/pkg/cli"
32)
33
34const PluginFileName = "plugin.yaml"
35
36// Downloaders represents the plugins capability if it can retrieve
37// charts from special sources
38type Downloaders struct {
39	// Protocols are the list of schemes from the charts URL.
40	Protocols []string `json:"protocols"`
41	// Command is the executable path with which the plugin performs
42	// the actual download for the corresponding Protocols
43	Command string `json:"command"`
44}
45
46// PlatformCommand represents a command for a particular operating system and architecture
47type PlatformCommand struct {
48	OperatingSystem string `json:"os"`
49	Architecture    string `json:"arch"`
50	Command         string `json:"command"`
51}
52
53// Metadata describes a plugin.
54//
55// This is the plugin equivalent of a chart.Metadata.
56type Metadata struct {
57	// Name is the name of the plugin
58	Name string `json:"name"`
59
60	// Version is a SemVer 2 version of the plugin.
61	Version string `json:"version"`
62
63	// Usage is the single-line usage text shown in help
64	Usage string `json:"usage"`
65
66	// Description is a long description shown in places like `helm help`
67	Description string `json:"description"`
68
69	// Command is the command, as a single string.
70	//
71	// The command will be passed through environment expansion, so env vars can
72	// be present in this command. Unless IgnoreFlags is set, this will
73	// also merge the flags passed from Helm.
74	//
75	// Note that command is not executed in a shell. To do so, we suggest
76	// pointing the command to a shell script.
77	//
78	// The following rules will apply to processing commands:
79	// - If platformCommand is present, it will be searched first
80	// - If both OS and Arch match the current platform, search will stop and the command will be executed
81	// - If OS matches and there is no more specific match, the command will be executed
82	// - If no OS/Arch match is found, the default command will be executed
83	// - If no command is present and no matches are found in platformCommand, Helm will exit with an error
84	PlatformCommand []PlatformCommand `json:"platformCommand"`
85	Command         string            `json:"command"`
86
87	// IgnoreFlags ignores any flags passed in from Helm
88	//
89	// For example, if the plugin is invoked as `helm --debug myplugin`, if this
90	// is false, `--debug` will be appended to `--command`. If this is true,
91	// the `--debug` flag will be discarded.
92	IgnoreFlags bool `json:"ignoreFlags"`
93
94	// Hooks are commands that will run on events.
95	Hooks Hooks
96
97	// Downloaders field is used if the plugin supply downloader mechanism
98	// for special protocols.
99	Downloaders []Downloaders `json:"downloaders"`
100
101	// UseTunnelDeprecated indicates that this command needs a tunnel.
102	// Setting this will cause a number of side effects, such as the
103	// automatic setting of HELM_HOST.
104	// DEPRECATED and unused, but retained for backwards compatibility with Helm 2 plugins. Remove in Helm 4
105	UseTunnelDeprecated bool `json:"useTunnel,omitempty"`
106}
107
108// Plugin represents a plugin.
109type Plugin struct {
110	// Metadata is a parsed representation of a plugin.yaml
111	Metadata *Metadata
112	// Dir is the string path to the directory that holds the plugin.
113	Dir string
114}
115
116// The following rules will apply to processing the Plugin.PlatformCommand.Command:
117// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
118// - If OS matches and there is no more specific match, the command will be prepared for execution
119// - If no OS/Arch match is found, return nil
120func getPlatformCommand(cmds []PlatformCommand) []string {
121	var command []string
122	eq := strings.EqualFold
123	for _, c := range cmds {
124		if eq(c.OperatingSystem, runtime.GOOS) {
125			command = strings.Split(os.ExpandEnv(c.Command), " ")
126		}
127		if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
128			return strings.Split(os.ExpandEnv(c.Command), " ")
129		}
130	}
131	return command
132}
133
134// PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing:
135// - If platformCommand is present, it will be searched first
136// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
137// - If OS matches and there is no more specific match, the command will be prepared for execution
138// - If no OS/Arch match is found, the default command will be prepared for execution
139// - If no command is present and no matches are found in platformCommand, will exit with an error
140//
141// It merges extraArgs into any arguments supplied in the plugin. It
142// returns the name of the command and an args array.
143//
144// The result is suitable to pass to exec.Command.
145func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
146	var parts []string
147	platCmdLen := len(p.Metadata.PlatformCommand)
148	if platCmdLen > 0 {
149		parts = getPlatformCommand(p.Metadata.PlatformCommand)
150	}
151	if platCmdLen == 0 || parts == nil {
152		parts = strings.Split(os.ExpandEnv(p.Metadata.Command), " ")
153	}
154	if len(parts) == 0 || parts[0] == "" {
155		return "", nil, fmt.Errorf("No plugin command is applicable")
156	}
157
158	main := parts[0]
159	baseArgs := []string{}
160	if len(parts) > 1 {
161		baseArgs = parts[1:]
162	}
163	if !p.Metadata.IgnoreFlags {
164		baseArgs = append(baseArgs, extraArgs...)
165	}
166	return main, baseArgs, nil
167}
168
169// validPluginName is a regular expression that validates plugin names.
170//
171// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-.
172var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$")
173
174// validatePluginData validates a plugin's YAML data.
175func validatePluginData(plug *Plugin, filepath string) error {
176	if !validPluginName.MatchString(plug.Metadata.Name) {
177		return fmt.Errorf("invalid plugin name at %q", filepath)
178	}
179	plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage)
180
181	// We could also validate SemVer, executable, and other fields should we so choose.
182	return nil
183}
184
185// sanitizeString normalize spaces and removes non-printable characters.
186func sanitizeString(str string) string {
187	return strings.Map(func(r rune) rune {
188		if unicode.IsSpace(r) {
189			return ' '
190		}
191		if unicode.IsPrint(r) {
192			return r
193		}
194		return -1
195	}, str)
196}
197
198func detectDuplicates(plugs []*Plugin) error {
199	names := map[string]string{}
200
201	for _, plug := range plugs {
202		if oldpath, ok := names[plug.Metadata.Name]; ok {
203			return fmt.Errorf(
204				"two plugins claim the name %q at %q and %q",
205				plug.Metadata.Name,
206				oldpath,
207				plug.Dir,
208			)
209		}
210		names[plug.Metadata.Name] = plug.Dir
211	}
212
213	return nil
214}
215
216// LoadDir loads a plugin from the given directory.
217func LoadDir(dirname string) (*Plugin, error) {
218	pluginfile := filepath.Join(dirname, PluginFileName)
219	data, err := ioutil.ReadFile(pluginfile)
220	if err != nil {
221		return nil, errors.Wrapf(err, "failed to read plugin at %q", pluginfile)
222	}
223
224	plug := &Plugin{Dir: dirname}
225	if err := yaml.UnmarshalStrict(data, &plug.Metadata); err != nil {
226		return nil, errors.Wrapf(err, "failed to load plugin at %q", pluginfile)
227	}
228	return plug, validatePluginData(plug, pluginfile)
229}
230
231// LoadAll loads all plugins found beneath the base directory.
232//
233// This scans only one directory level.
234func LoadAll(basedir string) ([]*Plugin, error) {
235	plugins := []*Plugin{}
236	// We want basedir/*/plugin.yaml
237	scanpath := filepath.Join(basedir, "*", PluginFileName)
238	matches, err := filepath.Glob(scanpath)
239	if err != nil {
240		return plugins, errors.Wrapf(err, "failed to find plugins in %q", scanpath)
241	}
242
243	if matches == nil {
244		return plugins, nil
245	}
246
247	for _, yaml := range matches {
248		dir := filepath.Dir(yaml)
249		p, err := LoadDir(dir)
250		if err != nil {
251			return plugins, err
252		}
253		plugins = append(plugins, p)
254	}
255	return plugins, detectDuplicates(plugins)
256}
257
258// FindPlugins returns a list of YAML files that describe plugins.
259func FindPlugins(plugdirs string) ([]*Plugin, error) {
260	found := []*Plugin{}
261	// Let's get all UNIXy and allow path separators
262	for _, p := range filepath.SplitList(plugdirs) {
263		matches, err := LoadAll(p)
264		if err != nil {
265			return matches, err
266		}
267		found = append(found, matches...)
268	}
269	return found, nil
270}
271
272// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
273// the plugin subsystem itself needs access to the environment variables
274// created here.
275func SetupPluginEnv(settings *cli.EnvSettings, name, base string) {
276	env := settings.EnvVars()
277	env["HELM_PLUGIN_NAME"] = name
278	env["HELM_PLUGIN_DIR"] = base
279	for key, val := range env {
280		os.Setenv(key, val)
281	}
282}
283