diff --git a/.goreleaser.yml b/.goreleaser.yml index 938a495e7d..13c716cba3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -6,23 +6,19 @@ env: before: hooks: - go mod download - builds: - id: jx # Path to main.go file or main package. # Default is `.`. main: ./cmd/main.go - # Binary name. # Can be a path (e.g. `bin/app`) to wrap the binary in a directory. # Default is the name of the project directory. binary: jx - # Custom ldflags templates. # Default is `-s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}} -X main.builtBy=goreleaser`. ldflags: - -X "{{.Env.ROOTPACKAGE}}/pkg/cmd/version.Version={{.Env.VERSION}}" -X "{{.Env.ROOTPACKAGE}}/pkg/cmd/version.Revision={{.Env.REV}}" -X "{{.Env.ROOTPACKAGE}}/pkg/cmd/version.Branch={{.Env.BRANCH}}" -X "{{.Env.ROOTPACKAGE}}/pkg/cmd/version.BuildDate={{.Env.BUILDDATE}}" -X "{{.Env.ROOTPACKAGE}}/pkg/cmd/version.GoVersion={{.Env.GOVERSION}}" - # GOOS list to build for. # For more info refer to: https://golang.org/doc/install/source#environment # Defaults are darwin and linux. @@ -30,64 +26,55 @@ builds: - windows - darwin - linux - # GOARCH to build for. # For more info refer to: https://golang.org/doc/install/source#environment # Defaults are 386 and amd64. goarch: - amd64 - - arm - arm64 - + ignore: + - goos: windows + goarch: arm64 archives: - name_template: "jx-{{ .Os }}-{{ .Arch }}" format_overrides: - goos: windows format: zip - checksum: # You can change the name of the checksums file. # Default is `jx_{{ .Version }}_checksums.txt`. name_template: "jx-checksums.txt" - # Algorithm to be used. # Accepted options are sha256, sha512, sha1, crc32, md5, sha224 and sha384. # Default is sha256. algorithm: sha256 - changelog: # set it to true if you wish to skip the changelog generation disable: true - release: # If set to true, will not auto-publish the release. # Default is false. draft: false - # If set to auto, will mark the release as not ready for production # in case there is an indicator for this in the tag e.g. v1.0.0-rc1 # If set to true, will mark the release as not ready for production. # Default is false. prerelease: true - # You can change the name of the GitHub release. # Default is `{{.Tag}}` name_template: "{{.Env.VERSION}}" - sboms: - artifacts: archive - - signs: -- cmd: cosign - env: - - COSIGN_EXPERIMENTAL=1 - certificate: '${artifact}.pem' - output: true - artifacts: all - args: - - sign-blob - - --yes=true - - '--output-certificate=${certificate}' - - '--output-signature=${signature}' - - '${artifact}' + - cmd: cosign + env: + - COSIGN_EXPERIMENTAL=1 + certificate: '${artifact}.pem' + output: true + artifacts: all + args: + - sign-blob + - --yes=true + - '--output-certificate=${certificate}' + - '--output-signature=${signature}' + - '${artifact}' diff --git a/go.mod b/go.mod index 79d81cae82..f6f1dac45e 100644 --- a/go.mod +++ b/go.mod @@ -9,13 +9,13 @@ require ( github.com/jenkins-x/jx-logging/v3 v3.0.17 github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rhysd/go-github-selfupdate v1.2.3 - github.com/spf13/cobra v1.8.0 + github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 - k8s.io/api v0.31.1 - k8s.io/apimachinery v0.31.1 - k8s.io/client-go v0.31.1 - sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 + k8s.io/api v0.31.2 + k8s.io/apimachinery v0.31.2 + k8s.io/client-go v0.31.2 + sigs.k8s.io/kustomize/kyaml v0.17.1 ) @@ -24,6 +24,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.4 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/creack/pty v1.1.18 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/fatih/color v1.15.0 // indirect diff --git a/go.sum b/go.sum index 3a16db3a99..85b0035988 100644 --- a/go.sum +++ b/go.sum @@ -14,10 +14,11 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -149,8 +150,8 @@ github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNl github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -256,12 +257,12 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= -k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= -k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= -k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= -k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/api v0.31.2 h1:3wLBbL5Uom/8Zy98GRPXpJ254nEFpl+hwndmk9RwmL0= +k8s.io/api v0.31.2/go.mod h1:bWmGvrGPssSK1ljmLzd3pwCQ9MgoTsRCuK35u6SygUk= +k8s.io/apimachinery v0.31.2 h1:i4vUt2hPK56W6mlT7Ry+AO8eEsyxMD1U44NR22CLTYw= +k8s.io/apimachinery v0.31.2/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.2 h1:Y2F4dxU5d3AQj+ybwSMqQnpZH9F30//1ObxOKlTI9yc= +k8s.io/client-go v0.31.2/go.mod h1:NPa74jSVR/+eez2dFsEIHNa+3o09vtNaWwWwb1qSxSs= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20241009091222-67ed5848f094 h1:MErs8YA0abvOqJ8gIupA1Tz6PKXYUw34XsGlA7uSL1k= @@ -270,8 +271,8 @@ k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 h1:MDF6h2H/h4tbzmtIKTuctcwZmY0tY k8s.io/utils v0.0.0-20240921022957-49e7df575cb6/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 h1:W6cLQc5pnqM7vh3b7HvGNfXrJ/xL6BDMS0v1V/HHg5U= -sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3/go.mod h1:JWP1Fj0VWGHyw3YUPjXSQnRnrwezrZSrApfX5S0nIag= +sigs.k8s.io/kustomize/kyaml v0.17.1 h1:TnxYQxFXzbmNG6gOINgGWQt09GghzgTP6mIurOgrLCQ= +sigs.k8s.io/kustomize/kyaml v0.17.1/go.mod h1:9V0mCjIEYjlXuCdYsSXvyoy2BTsLESH7TlGV81S282U= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= diff --git a/pkg/cmd/namespace/namespace.go b/pkg/cmd/namespace/namespace.go index 7c84172464..bc540dbb23 100644 --- a/pkg/cmd/namespace/namespace.go +++ b/pkg/cmd/namespace/namespace.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "sort" + "strings" "github.com/jenkins-x/jx-helpers/v3/pkg/cobras/helper" "github.com/jenkins-x/jx-helpers/v3/pkg/cobras/templates" @@ -77,9 +78,28 @@ func NewCmdNamespace() (*cobra.Command, *Options) { cmd := &cobra.Command{ Use: "namespace", Aliases: []string{"ns"}, + Args: cobra.MaximumNArgs(1), Short: "View or change the current namespace context in the current Kubernetes cluster", Long: cmdLong, Example: cmdExample, + ValidArgsFunction: func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, _, err := kube.LazyCreateKubeClientAndNamespace(o.KubeClient, "") + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + names, err := getNamespaceNames(client) + + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + var contextNames []string + for _, v := range names { + if strings.HasPrefix(v, toComplete) { + contextNames = append(contextNames, v) + } + } + return contextNames, cobra.ShellCompDirectiveNoFileComp + }, Run: func(_ *cobra.Command, args []string) { o.Args = args err := o.Run() diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 6864739bac..946b5f9f53 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -3,16 +3,8 @@ package cmd import ( "fmt" "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "sort" "strings" - "syscall" - "github.com/blang/semver" - "github.com/jenkins-x/jx-api/v4/pkg/client/clientset/versioned" "github.com/jenkins-x/jx-helpers/v3/pkg/cobras" "github.com/jenkins-x/jx-helpers/v3/pkg/cobras/helper" "github.com/jenkins-x/jx-helpers/v3/pkg/cobras/templates" @@ -26,7 +18,6 @@ import ( "github.com/jenkins-x/jx/pkg/cmd/upgrade" "github.com/jenkins-x/jx/pkg/cmd/version" "github.com/jenkins-x/jx/pkg/plugins" - "github.com/spf13/cobra" ) @@ -36,6 +27,17 @@ func Main(args []string) *cobra.Command { Use: "jx", Short: "Jenkins X 3.x command line", Run: runHelp, + // Hook before and after Run initialize and write profiles to disk, + // respectively. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + + if cmd.Name() == cobra.ShellCompRequestCmd || cmd.Name() == cobra.ShellCompNoDescRequestCmd { + // This is the __complete or __completeNoDesc command which + // indicates shell completion has been requested. + plugins.SetupPluginCompletion(cmd, args) + } + return nil + }, } po := &templates.Options{} @@ -48,10 +50,10 @@ func Main(args []string) *cobra.Command { if err != nil { log.Logger().Errorf("%v", err) } - return pluginCommandGroups, po.ManagedPluginsEnabled + return pluginCommandGroups, false } doCmd := func(cmd *cobra.Command, args []string) { - handleCommand(po, cmd, args, getPluginCommandGroups) + handleCommand(cmd, args) } generalCommands := []*cobra.Command{ @@ -159,23 +161,17 @@ func Main(args []string) *cobra.Command { filters := []string{"options"} templates.ActsAsRootCommand(cmd, filters, getPluginCommandGroups, groups...) - handleCommand(po, cmd, args, getPluginCommandGroups) + handleCommand(cmd, args) return cmd } -func handleCommand(po *templates.Options, cmd *cobra.Command, args []string, getPluginCommandGroups func() (templates.PluginCommandGroups, bool)) { - managedPlugins := &managedPluginHandler{ - JXClient: po.JXClient, - Namespace: po.Namespace, - } - localPlugins := &localPluginHandler{} +func handleCommand(cmd *cobra.Command, args []string) { if len(args) == 0 { args = os.Args } if len(args) > 1 { cmdPathPieces := args[1:] - pluginDir, err := homedir.DefaultPluginBinDir() if err != nil { log.Logger().Errorf("%v", err) @@ -185,13 +181,18 @@ func handleCommand(po *templates.Options, cmd *cobra.Command, args []string, get // only look for suitable executables if // the specified command does not already exist if _, _, err := cmd.Find(cmdPathPieces); err != nil { - if _, managedPluginsEnabled := getPluginCommandGroups(); managedPluginsEnabled { - if err := handleEndpointExtensions(managedPlugins, cmdPathPieces, pluginDir); err != nil { - log.Logger().Errorf("%v", err) - os.Exit(1) + var cmdName string // first "non-flag" arguments + for _, arg := range cmdPathPieces { + if !strings.HasPrefix(arg, "-") { + cmdName = arg + break } - } else { - if err := handleEndpointExtensions(localPlugins, cmdPathPieces, pluginDir); err != nil { + } + switch cmdName { + case "help", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd, "completion": + // Don't search for a plugin + default: + if err := handleEndpointExtensions(cmdPathPieces, pluginDir); err != nil { log.Logger().Errorf("%v", err) os.Exit(1) } @@ -206,6 +207,13 @@ func aliasCommand(rootCmd *cobra.Command, fn func(cmd *cobra.Command, args []str Use: name, Short: "alias for: " + strings.Join(realArgs, " "), Aliases: aliases, + ValidArgsFunction: func(_ *cobra.Command, completeArgs []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cmd, pluginArgs, err := rootCmd.Find(args) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return plugins.PluginCompletion(cmd, append(pluginArgs, completeArgs...), toComplete) + }, Run: func(_ *cobra.Command, args []string) { realArgs = append(realArgs, args...) log.Logger().Debugf("about to invoke alias: %s", strings.Join(realArgs, " ")) @@ -220,113 +228,7 @@ func runHelp(cmd *cobra.Command, _ []string) { cmd.Help() //nolint:errcheck } -// 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 { - // Lookup receives a potential filename and returns - // a full or relative path to an executable, if one - // exists at the given filename, or an error. - Lookup(filename string, pluginBinDir string) (string, error) - // 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 -} - -type managedPluginHandler struct { - JXClient versioned.Interface - Namespace string - localPluginHandler -} - -// Lookup implements PluginHandler -func (h *managedPluginHandler) Lookup(filename, pluginBinDir string) (string, error) { - return h.localPluginHandler.Lookup(filename, pluginBinDir) -} - -// Execute implements PluginHandler -func (h *managedPluginHandler) Execute(executablePath string, cmdArgs, environment []string) error { - return h.localPluginHandler.Execute(executablePath, cmdArgs, environment) -} - -type localPluginHandler struct{} - -// Lookup implements PluginHandler -func (*localPluginHandler) Lookup(filename, pluginBinDir string) (string, error) { - path, err := exec.LookPath(filename) - if err != nil { - path, err = findStandardPlugin(pluginBinDir, filename) - if err != nil { - return "", fmt.Errorf("failed to load plugin %s: %w", filename, err) - } - } - return path, nil -} - -func findStandardPlugin(dir, name string) (string, error) { - file, err := os.Open(dir) - if err != nil { - return "", fmt.Errorf("failed to read plugin dir %s: %w", dir, err) - } - defer file.Close() - files, err := file.Readdirnames(0) - if err != nil { - return "", fmt.Errorf("failed to read plugin dir %s: %w", dir, err) - } - pluginPattern, err := regexp.Compile("^" + name + "-([0-9.]+)$") - if err != nil { - return "", err - } - - vers := make([]string, 0) - for _, plugin := range files { - res := pluginPattern.FindStringSubmatch(plugin) - if len(res) > 1 { - vers = append(vers, res[1]) - } - } - - if len(vers) > 0 { - vs := make([]semver.Version, 0) - for _, r := range vers { - v, err := semver.Parse(r) - if err == nil { - vs = append(vs, v) - } - } - - sort.Sort(sort.Reverse(semver.Versions(vs))) - if len(vs) > 0 { - return filepath.Join(dir, name+"-"+vs[0].String()), nil - } - } - return plugins.InstallStandardPlugin(dir, name) -} - -// Execute implements PluginHandler -func (*localPluginHandler) 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". - // ToDo: Look at sanitizing the inputs passed to syscall exec, may be move away from syscall as it's deprecated. - return syscall.Exec(executablePath, append([]string{executablePath}, cmdArgs...), environment) //nolint -} - -func handleEndpointExtensions(pluginHandler PluginHandler, cmdArgs []string, pluginBinDir string) error { +func handleEndpointExtensions(cmdArgs []string, pluginBinDir string) error { var remainingArgs []string // all "non-flag" arguments for idx := range cmdArgs { @@ -355,7 +257,7 @@ func handleEndpointExtensions(pluginHandler PluginHandler, cmdArgs []string, plu // lets see if there's a local build of the plugin on the PATH for developers... if path == "" { - path, err = pluginHandler.Lookup(commandName, pluginBinDir) + path, err = plugins.Lookup(commandName, pluginBinDir) } if path != "" { foundBinaryPath = path @@ -379,26 +281,5 @@ func handleEndpointExtensions(pluginHandler PluginHandler, cmdArgs []string, plu // invoke cmd binary relaying the current environment and args given // remainingArgs will always have at least one element. // execute will make remainingArgs[0] the "binary name". - return pluginHandler.Execute(foundBinaryPath, nextArgs, environ) -} - -// FindPluginBinary tries to find the jx-foo binary plugin in the plugins dir `~/.jx/plugins/jx/bin` dir ` -func FindPluginBinary(pluginDir, commandName string) string { - if pluginDir != "" { - files, err := os.ReadDir(pluginDir) - if err != nil { - log.Logger().Debugf("failed to read plugin dir %s", err.Error()) - } else { - prefix := commandName + "-" - for _, f := range files { - name := f.Name() - if strings.HasPrefix(name, prefix) { - path := filepath.Join(pluginDir, name) - log.Logger().Debugf("found plugin %s at %s", commandName, path) - return path - } - } - } - } - return "" + return plugins.Execute(foundBinaryPath, nextArgs, environ) } diff --git a/pkg/plugins/helpers.go b/pkg/plugins/helpers.go index fcd8af17b2..6af32d5e2c 100644 --- a/pkg/plugins/helpers.go +++ b/pkg/plugins/helpers.go @@ -1,17 +1,27 @@ package plugins import ( + "bytes" "fmt" "io" "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" "runtime" + "sort" + "strconv" "strings" + "syscall" + + "github.com/blang/semver" + "github.com/spf13/cobra" jenkinsv1 "github.com/jenkins-x/jx-api/v4/pkg/apis/jenkins.io/v1" "github.com/jenkins-x/jx-helpers/v3/pkg/extensions" "github.com/jenkins-x/jx-helpers/v3/pkg/homedir" "github.com/jenkins-x/jx-helpers/v3/pkg/httphelpers" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/json" ) @@ -184,3 +194,261 @@ func InstallStandardPlugin(dir, name string) (string, error) { } return extensions.EnsurePluginInstalled(plugin, dir) } + +func AllPlugins() (validArgs []string) { + pluginBinDir, err := homedir.DefaultPluginBinDir() + for k := range Plugins { + validArgs = append(validArgs, Plugins[k].Name) + } + if err != nil { + return + } + file, err := os.Open(pluginBinDir) + if err != nil { + return + } + defer file.Close() + files, err := file.Readdirnames(0) + if err != nil { + return + } + pluginPattern := regexp.MustCompile("^jx-(.*)-[0-9.]+$") + for _, plugin := range files { + res := pluginPattern.FindStringSubmatch(plugin) + if len(res) > 1 && PluginMap["jx-"+res[1]] == nil { + validArgs = append(validArgs, res[1]) + } + } + return +} + +// SetupPluginCompletion adds a Cobra command to the command tree for each +// plugin. This is only done when performing shell completion that relate +// to plugins. +func SetupPluginCompletion(cmd *cobra.Command, args []string) { + root := cmd.Root() + if len(args) == 0 { + return + } + if strings.HasPrefix(args[0], "-") { + // Plugins are not supported if the first argument is a flag, + // so no need to add them in that case. + return + } + + registerPluginCommands(root, true) +} + +// registerPluginCommand allows adding Cobra command to the command tree or extracting them for usage in +// e.g. the help function or for registering the completion function +func registerPluginCommands(rootCmd *cobra.Command, list bool) (cmds []*cobra.Command) { + var userDefinedCommands []*cobra.Command + + for _, plugin := range AllPlugins() { + var args []string + + rawPluginArgs := strings.Split(plugin, "-") + pluginArgs := rawPluginArgs[:1] + if list { + pluginArgs = rawPluginArgs + } + + // Iterate through all segments, for kubectl-my_plugin-sub_cmd, we will end up with + // two iterations: one for my_plugin and one for sub_cmd. + for _, arg := range pluginArgs { + // Underscores (_) in plugin's filename are replaced with dashes(-) + // e.g. foo_bar -> foo-bar + args = append(args, strings.ReplaceAll(arg, "_", "-")) + } + + // In order to avoid that the same plugin command is added more than once, + // find the lowest command given args from the root command + parentCmd, remainingArgs, _ := rootCmd.Find(args) + if parentCmd == nil { + parentCmd = rootCmd + } + + for _, remainingArg := range remainingArgs { + cmd := &cobra.Command{ + Use: remainingArg, + // Add a description that will be shown with completion choices. + // Make each one different by including the plugin name to avoid + // all plugins being grouped in a single line during completion for zsh. + Short: fmt.Sprintf("The command %s is a plugin", remainingArg), + DisableFlagParsing: true, + // Allow plugins to provide their own completion choices + ValidArgsFunction: PluginCompletion, + // A Run is required for it to be a valid command + Run: func(_ *cobra.Command, _ []string) {}, + } + // Add the plugin command to the list of user defined commands + userDefinedCommands = append(userDefinedCommands, cmd) + + if list { + parentCmd.AddCommand(cmd) + parentCmd = cmd + } + } + } + + return userDefinedCommands +} + +// PluginCompletion deals with shell completion beyond the plugin name, it allows to complete +// plugin arguments and flags. +// It will call the plugin with __complete as the first argument. +// +// This completion command should print the completion choices to stdout, which is suported by default +// for commands developed with Cobra. +// The rest of the arguments will be the arguments for the plugin currently +// on the command-line. For example, if a user types: +// +// jx myplugin arg1 arg2 a +// +// the plugin executable will be called with arguments: "__complete" "arg1" "arg2" "a". +// And if a user types: +// +// jx myplugin arg1 arg2 +// +// the completion executable will be called with arguments: "__complete" "arg1" "arg2" "". Notice the empty +// last argument which indicates that a new word should be completed but that the user has not +// typed anything for it yet. +// +// JX's plugin completion logic supports Cobra's ShellCompDirective system. This means a plugin +// can optionally print : as its very last line to provide +// directives to the shell on how to perform completion. If this directive is not present, the +// cobra.ShellCompDirectiveDefault will be used. Please see Cobra's documentation for more details: +// https://github.com/spf13/cobra/blob/master/shell_completions.md#dynamic-completion-of-nouns +func PluginCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Recreate the plugin name from the commandPath + pluginName := strings.ReplaceAll(strings.ReplaceAll(cmd.CommandPath(), "-", "_"), " ", "-") + + pluginDir, err := homedir.DefaultPluginBinDir() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + path, err := Lookup(pluginName, pluginDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + newArgs := make([]string, len(args)+2) //nolint:mnd + newArgs[0] = "__complete" + for i, arg := range args { + newArgs[i+1] = arg + } + newArgs[len(newArgs)-1] = toComplete + cobra.CompDebugln(fmt.Sprintf("About to call: %s %s", path, strings.Join(args, " ")), true) + return getPluginCompletions(path, newArgs, os.Environ()) +} + +// getPluginCompletions receives an executable's filepath, a slice +// of arguments, and a slice of environment variables +// to relay to the executable. +func getPluginCompletions(executablePath string, cmdArgs, environment []string) ([]string, cobra.ShellCompDirective) { + buf := new(bytes.Buffer) + + prog := exec.Command(executablePath, cmdArgs...) + prog.Stdin = os.Stdin + prog.Stdout = buf + prog.Stderr = os.Stderr + prog.Env = environment + + var comps []string + directive := cobra.ShellCompDirectiveNoFileComp + if err := prog.Run(); err == nil { + for _, comp := range strings.Split(buf.String(), "\n") { + // Remove any empty lines + if comp != "" { + comps = append(comps, comp) + } + } + + // Check if the last line of output is of the form :, which + // indicates a Cobra ShellCompDirective. We do this for plugins + // that use Cobra or the ones that wish to use this directive to + // communicate a special behavior for the shell. + if len(comps) > 0 { + lastLine := comps[len(comps)-1] + if len(lastLine) > 1 && lastLine[0] == ':' { + if strInt, err := strconv.Atoi(lastLine[1:]); err == nil { + directive = cobra.ShellCompDirective(strInt) + comps = comps[:len(comps)-1] + } + } + } + } + return comps, directive +} + +func FindStandardPlugin(dir, name string) (string, error) { + file, err := os.Open(dir) + if err != nil { + return "", fmt.Errorf("failed to read plugin dir %s: %w", dir, err) + } + defer file.Close() + files, err := file.Readdirnames(0) + if err != nil { + return "", fmt.Errorf("failed to read plugin dir %s: %w", dir, err) + } + pluginPattern, err := regexp.Compile("^" + name + "-([0-9.]+)$") + if err != nil { + return "", err + } + + vers := make([]string, 0) + for _, plugin := range files { + res := pluginPattern.FindStringSubmatch(plugin) + if len(res) > 1 { + vers = append(vers, res[1]) + } + } + + if len(vers) > 0 { + vs := make([]semver.Version, 0) + for _, r := range vers { + v, err := semver.Parse(r) + if err == nil { + vs = append(vs, v) + } + } + + sort.Sort(sort.Reverse(semver.Versions(vs))) + if len(vs) > 0 { + return filepath.Join(dir, name+"-"+vs[0].String()), nil + } + } + return InstallStandardPlugin(dir, name) +} + +func Lookup(filename, pluginBinDir string) (string, error) { + path, err := exec.LookPath(filename) + if err != nil { + path, err = FindStandardPlugin(pluginBinDir, filename) + if err != nil { + return "", fmt.Errorf("failed to load plugin %s: %w", filename, err) + } + } + return path, nil +} + +func 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". + // ToDo: Look at sanitizing the inputs passed to syscall exec, may be move away from syscall as it's deprecated. + return syscall.Exec(executablePath, append([]string{executablePath}, cmdArgs...), environment) //nolint +}