From 3dc5035270a9c6ccf4d896c7bf65c758676cc0e5 Mon Sep 17 00:00:00 2001 From: 24sama Date: Tue, 16 Nov 2021 17:01:36 +0800 Subject: [PATCH] dev-v2.0.0: support plugin mode Signed-off-by: 24sama --- cmd/ctl/options/io_options.go | 30 +++++ cmd/ctl/plugin/list.go | 237 ++++++++++++++++++++++++++++++++++ cmd/ctl/plugin/plugin.go | 37 ++++++ cmd/ctl/root.go | 169 +++++++++++++++++++++++- pkg/binaries/k3s.go | 2 +- 5 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 cmd/ctl/options/io_options.go create mode 100644 cmd/ctl/plugin/list.go create mode 100644 cmd/ctl/plugin/plugin.go diff --git a/cmd/ctl/options/io_options.go b/cmd/ctl/options/io_options.go new file mode 100644 index 00000000..02532fd2 --- /dev/null +++ b/cmd/ctl/options/io_options.go @@ -0,0 +1,30 @@ +/* + Copyright 2021 The KubeSphere 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 options + +import "io" + +// IOStreams provides the standard names for iostreams. This is useful for embedding and for unit testing. +// Inconsistent and different names make it hard to read and review code +type IOStreams struct { + // In think, os.Stdin + In io.Reader + // Out think, os.Stdout + Out io.Writer + // ErrOut think, os.Stderr + ErrOut io.Writer +} diff --git a/cmd/ctl/plugin/list.go b/cmd/ctl/plugin/list.go new file mode 100644 index 00000000..13d09064 --- /dev/null +++ b/cmd/ctl/plugin/list.go @@ -0,0 +1,237 @@ +/* + Copyright 2021 The KubeSphere 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 plugin + +import ( + "bytes" + "fmt" + "github.com/kubesphere/kubekey/cmd/ctl/options" + "github.com/spf13/cobra" + "io/ioutil" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "os" + "path/filepath" + "runtime" + "strings" +) + +type PluginListOptions struct { + Verifier PathVerifier + NameOnly bool + PluginPaths []string + options.IOStreams +} + +// NewCmdPluginList provides a way to list all plugin executables visible to kubectl +func NewCmdPluginList(streams options.IOStreams) *cobra.Command { + o := &PluginListOptions{ + IOStreams: streams, + } + + cmd := &cobra.Command{ + Use: "list", + Short: i18n.T("List all visible plugin executables on a user's PATH"), + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(cmd)) + cmdutil.CheckErr(o.Run()) + }, + } + + cmd.Flags().BoolVar(&o.NameOnly, "name-only", o.NameOnly, "If true, display only the binary name of each plugin, rather than its full path") + return cmd +} + +func (o *PluginListOptions) Complete(cmd *cobra.Command) error { + o.Verifier = &CommandOverrideVerifier{ + root: cmd.Root(), + seenPlugins: make(map[string]string), + } + + o.PluginPaths = filepath.SplitList(os.Getenv("PATH")) + return nil +} + +func (o *PluginListOptions) Run() error { + pluginsFound := false + isFirstFile := true + pluginErrors := []error{} + pluginWarnings := 0 + + for _, dir := range uniquePathsList(o.PluginPaths) { + if len(strings.TrimSpace(dir)) == 0 { + continue + } + + files, err := ioutil.ReadDir(dir) + if err != nil { + if _, ok := err.(*os.PathError); ok { + fmt.Fprintf(o.ErrOut, "Unable to read directory %q from your PATH: %v. Skipping...\n", dir, err) + continue + } + + pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to read directory %q in your PATH: %v", dir, err)) + continue + } + + for _, f := range files { + if f.IsDir() { + continue + } + if !hasValidPrefix(f.Name(), ValidPluginFilenamePrefixes) { + continue + } + + if isFirstFile { + fmt.Fprintf(o.Out, "The following compatible plugins are available:\n\n") + pluginsFound = true + isFirstFile = false + } + + pluginPath := f.Name() + if !o.NameOnly { + pluginPath = filepath.Join(dir, pluginPath) + } + + fmt.Fprintf(o.Out, "%s\n", pluginPath) + if errs := o.Verifier.Verify(filepath.Join(dir, f.Name())); len(errs) != 0 { + for _, err := range errs { + fmt.Fprintf(o.ErrOut, " - %s\n", err) + pluginWarnings++ + } + } + } + } + + if !pluginsFound { + pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to find any kubectl plugins in your PATH")) + } + + if pluginWarnings > 0 { + if pluginWarnings == 1 { + pluginErrors = append(pluginErrors, fmt.Errorf("error: one plugin warning was found")) + } else { + pluginErrors = append(pluginErrors, fmt.Errorf("error: %v plugin warnings were found", pluginWarnings)) + } + } + if len(pluginErrors) > 0 { + errs := bytes.NewBuffer(nil) + for _, e := range pluginErrors { + fmt.Fprintln(errs, e) + } + return fmt.Errorf("%s", errs.String()) + } + + return nil +} + +type PathVerifier interface { + // Verify determines if a given path is valid + Verify(path string) []error +} + +type CommandOverrideVerifier struct { + root *cobra.Command + seenPlugins map[string]string +} + +// Verify implements PathVerifier and determines if a given path +// is valid depending on whether or not it overwrites an existing +// kubectl command path, or a previously seen plugin. +func (v *CommandOverrideVerifier) Verify(path string) []error { + if v.root == nil { + return []error{fmt.Errorf("unable to verify path with nil root")} + } + + // extract the plugin binary name + segs := strings.Split(path, "/") + binName := segs[len(segs)-1] + + cmdPath := strings.Split(binName, "-") + if len(cmdPath) > 1 { + // the first argument is always "kubectl" for a plugin binary + cmdPath = cmdPath[1:] + } + + errors := []error{} + + if isExec, err := isExecutable(path); err == nil && !isExec { + errors = append(errors, fmt.Errorf("warning: %s identified as a kubectl plugin, but it is not executable", path)) + } else if err != nil { + errors = append(errors, fmt.Errorf("error: unable to identify %s as an executable file: %v", path, err)) + } + + if existingPath, ok := v.seenPlugins[binName]; ok { + errors = append(errors, fmt.Errorf("warning: %s is overshadowed by a similarly named plugin: %s", path, existingPath)) + } else { + v.seenPlugins[binName] = path + } + + if cmd, _, err := v.root.Find(cmdPath); err == nil { + errors = append(errors, fmt.Errorf("warning: %s overwrites existing command: %q", binName, cmd.CommandPath())) + } + + return errors +} + +func isExecutable(fullPath string) (bool, error) { + info, err := os.Stat(fullPath) + if err != nil { + return false, err + } + + if runtime.GOOS == "windows" { + fileExt := strings.ToLower(filepath.Ext(fullPath)) + + switch fileExt { + case ".bat", ".cmd", ".com", ".exe", ".ps1": + return true, nil + } + return false, nil + } + + if m := info.Mode(); !m.IsDir() && m&0111 != 0 { + return true, nil + } + + return false, nil +} + +// uniquePathsList deduplicates a given slice of strings without +// sorting or otherwise altering its order in any way. +func uniquePathsList(paths []string) []string { + seen := map[string]bool{} + newPaths := []string{} + for _, p := range paths { + if seen[p] { + continue + } + seen[p] = true + newPaths = append(newPaths, p) + } + return newPaths +} + +func hasValidPrefix(filepath string, validPrefixes []string) bool { + for _, prefix := range validPrefixes { + if !strings.HasPrefix(filepath, prefix+"-") { + continue + } + return true + } + return false +} diff --git a/cmd/ctl/plugin/plugin.go b/cmd/ctl/plugin/plugin.go new file mode 100644 index 00000000..2c2376ba --- /dev/null +++ b/cmd/ctl/plugin/plugin.go @@ -0,0 +1,37 @@ +/* + Copyright 2021 The KubeSphere 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 plugin + +import ( + "github.com/kubesphere/kubekey/cmd/ctl/options" + "github.com/spf13/cobra" +) + +var ( + ValidPluginFilenamePrefixes = []string{"kk"} +) + +func NewCmdPlugin(streams options.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin [flags]", + DisableFlagsInUseLine: true, + Short: "Provides utilities for interacting with plugins", + } + + cmd.AddCommand(NewCmdPluginList(streams)) + return cmd +} diff --git a/cmd/ctl/root.go b/cmd/ctl/root.go index 514b7270..eeeec1ce 100644 --- a/cmd/ctl/root.go +++ b/cmd/ctl/root.go @@ -17,29 +17,81 @@ limitations under the License. package ctl import ( + "fmt" "github.com/kubesphere/kubekey/cmd/ctl/add" "github.com/kubesphere/kubekey/cmd/ctl/cert" "github.com/kubesphere/kubekey/cmd/ctl/completion" "github.com/kubesphere/kubekey/cmd/ctl/create" "github.com/kubesphere/kubekey/cmd/ctl/delete" initOs "github.com/kubesphere/kubekey/cmd/ctl/init" + "github.com/kubesphere/kubekey/cmd/ctl/options" + "github.com/kubesphere/kubekey/cmd/ctl/plugin" "github.com/kubesphere/kubekey/cmd/ctl/upgrade" "github.com/kubesphere/kubekey/cmd/ctl/version" "github.com/spf13/cobra" + "os" + "os/exec" + "runtime" + "strings" + "syscall" ) -func NewDefaultKubeKeyCommand() *cobra.Command { - return NewDefaultKubeKeyCommandWithArgs() +type KubeKeyOptions struct { + PluginHandler PluginHandler + Arguments []string + + options.IOStreams } -func NewDefaultKubeKeyCommandWithArgs() *cobra.Command { - cmd := NewKubeKeyCommand() +func NewDefaultKubeKeyCommand() *cobra.Command { + return NewDefaultKubeKeyCommandWithArgs(KubeKeyOptions{ + PluginHandler: NewDefaultPluginHandler(plugin.ValidPluginFilenamePrefixes), + Arguments: os.Args, + + IOStreams: options.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}, + }) +} + +func NewDefaultKubeKeyCommandWithArgs(o KubeKeyOptions) *cobra.Command { + cmd := NewKubeKeyCommand(o) + + if o.PluginHandler == nil { + return cmd + } + + if len(o.Arguments) > 1 { + cmdPathPieces := o.Arguments[1:] + + // only look for suitable extension executables if + // the specified command does not already exist + if _, _, err := cmd.Find(cmdPathPieces); err != nil { + // Also check the commands that will be added by Cobra. + // These commands are only added once rootCmd.Execute() is called, so we + // need to check them explicitly here. + var cmdName string // first "non-flag" arguments + for _, arg := range cmdPathPieces { + if !strings.HasPrefix(arg, "-") { + cmdName = arg + break + } + } + + switch cmdName { + case "help", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: + // Don't search for a plugin + default: + if err := HandlePluginCommand(o.PluginHandler, cmdPathPieces); err != nil { + os.Exit(1) + } + } + } + } return cmd } // NewKubeKeyCommand creates a new kubekey root command -func NewKubeKeyCommand() *cobra.Command { +func NewKubeKeyCommand(o KubeKeyOptions) *cobra.Command { cmds := &cobra.Command{ Use: "kk", Short: "Kubernetes/KubeSphere Deploy Tool", @@ -57,7 +109,114 @@ func NewKubeKeyCommand() *cobra.Command { cmds.AddCommand(upgrade.NewCmdUpgrade()) cmds.AddCommand(cert.NewCmdCerts()) + cmds.AddCommand(plugin.NewCmdPlugin(o.IOStreams)) + cmds.AddCommand(completion.NewCmdCompletion()) cmds.AddCommand(version.NewCmdVersion()) return cmds } + +// PluginHandler is capable of parsing command line arguments +// and performing executable filename lookups to search +// for valid plugin files, and execute found plugins. +type PluginHandler interface { + // exists at the given filename, or a boolean false. + // Lookup will iterate over a list of given prefixes + // in order to recognize valid plugin filenames. + // The first filepath to match a prefix is returned. + Lookup(filename string) (string, bool) + // Execute receives an executable's filepath, a slice + // of arguments, and a slice of environment variables + // to relay to the executable. + Execute(executablePath string, cmdArgs, environment []string) error +} + +// DefaultPluginHandler implements PluginHandler +type DefaultPluginHandler struct { + ValidPrefixes []string +} + +// NewDefaultPluginHandler instantiates the DefaultPluginHandler with a list of +// given filename prefixes used to identify valid plugin filenames. +func NewDefaultPluginHandler(validPrefixes []string) *DefaultPluginHandler { + return &DefaultPluginHandler{ + ValidPrefixes: validPrefixes, + } +} + +// Lookup implements PluginHandler +func (h *DefaultPluginHandler) Lookup(filename string) (string, bool) { + for _, prefix := range h.ValidPrefixes { + path, err := exec.LookPath(fmt.Sprintf("%s-%s", prefix, filename)) + if err != nil || len(path) == 0 { + continue + } + return path, true + } + + return "", false +} + +// Execute implements PluginHandler +func (h *DefaultPluginHandler) Execute(executablePath string, cmdArgs, environment []string) error { + + // Windows does not support exec syscall. + if runtime.GOOS == "windows" { + cmd := exec.Command(executablePath, cmdArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = environment + err := cmd.Run() + if err == nil { + os.Exit(0) + } + return err + } + + // invoke cmd binary relaying the environment and args given + // append executablePath to cmdArgs, as execve will make first argument the "binary name". + return syscall.Exec(executablePath, append([]string{executablePath}, cmdArgs...), environment) +} + +// HandlePluginCommand receives a pluginHandler and command-line arguments and attempts to find +// a plugin executable on the PATH that satisfies the given arguments. +func HandlePluginCommand(pluginHandler PluginHandler, cmdArgs []string) error { + var remainingArgs []string // all "non-flag" arguments + for _, arg := range cmdArgs { + if strings.HasPrefix(arg, "-") { + break + } + remainingArgs = append(remainingArgs, strings.Replace(arg, "-", "_", -1)) + } + + if len(remainingArgs) == 0 { + // the length of cmdArgs is at least 1 + return fmt.Errorf("flags cannot be placed before plugin name: %s", cmdArgs[0]) + } + + foundBinaryPath := "" + + // attempt to find binary, starting at longest possible name with given cmdArgs + for len(remainingArgs) > 0 { + path, found := pluginHandler.Lookup(strings.Join(remainingArgs, "-")) + if !found { + remainingArgs = remainingArgs[:len(remainingArgs)-1] + continue + } + + foundBinaryPath = path + break + } + + if len(foundBinaryPath) == 0 { + return nil + } + + // invoke cmd binary relaying the current environment and args given + if err := pluginHandler.Execute(foundBinaryPath, cmdArgs[len(remainingArgs):], os.Environ()); err != nil { + return err + } + + return nil +} diff --git a/pkg/binaries/k3s.go b/pkg/binaries/k3s.go index 5e0c3949..3cac8b98 100644 --- a/pkg/binaries/k3s.go +++ b/pkg/binaries/k3s.go @@ -54,7 +54,7 @@ func K3sFilesDownloadHTTP(kubeConf *common.KubeConf, filepath, version, arch str if k3s.Arch == kubekeyapiv1alpha2.DefaultArch { k3s.Url = fmt.Sprintf("https://github.com/k3s-io/k3s/releases/download/%s+k3s1/k3s", k3s.Version) } else { - k3s.Url = fmt.Sprintf("https://github.com/k3s-io/k3s/releases/download/%s+k3s1/k3s%s", k3s.Version, k3s.Arch) + k3s.Url = fmt.Sprintf("https://github.com/k3s-io/k3s/releases/download/%s+k3s1/k3s-%s", k3s.Version, k3s.Arch) } kubecni.Url = fmt.Sprintf("https://github.com/containernetworking/plugins/releases/download/%s/cni-plugins-linux-%s-%s.tgz", kubecni.Version, kubecni.Arch, kubecni.Version) helm.Url = fmt.Sprintf("https://get.helm.sh/helm-%s-linux-%s.tar.gz", helm.Version, helm.Arch)