Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add Plugin Support to the Argo CD CLI #20074

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 174 additions & 1 deletion cmd/argocd/commands/root.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package commands

import (
pluginError "errors"
"fmt"

"github.com/spf13/cobra"
"k8s.io/client-go/tools/clientcmd"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"

"github.com/argoproj/argo-cd/v2/cmd/argocd/commands/admin"
"github.com/argoproj/argo-cd/v2/cmd/argocd/commands/initialize"
Expand All @@ -18,6 +24,88 @@
"github.com/argoproj/argo-cd/v2/util/localconfig"
)

type ArgoCDCLIOptions struct {
PluginHandler PluginHandler
Arguments []string
}

// PluginHandler parses command line arguments
// and performs executable filename lookups to search
// for valid plugin files, and execute found plugins.
type PluginHandler interface {
// LookForPlugin will iterate over a list of given prefixes
// in order to recognize valid plugin filenames.
// The first filepath to match a prefix is returned.
LookForPlugin(filename string) (string, bool)
// ExecutePlugin receives an executable's filepath, a slice
// of arguments, and a slice of environment variables
// to relay to the executable.
ExecutePlugin(executablePath string, cmdArgs, environment []string) error
}

// DefaultPluginHandler implements the PluginHandler interface
type DefaultPluginHandler struct {
ValidPrefixes []string
}

func NewDefaultPluginHandler(validPrefixes []string) *DefaultPluginHandler {
return &DefaultPluginHandler{
ValidPrefixes: validPrefixes,
}
}

// LookForPlugin implements PluginHandler
func (h *DefaultPluginHandler) LookForPlugin(filename string) (string, bool) {
for _, prefix := range h.ValidPrefixes {
path, err := exec.LookPath(fmt.Sprintf("%s-%s", prefix, filename))
if shouldSkipOnLookPathErr(err) || len(path) == 0 {
continue
}
return path, true
}
return "", false
}

// ExecutePlugin implements PluginHandler
func (h *DefaultPluginHandler) ExecutePlugin(executablePath string, cmdArgs, environment []string) error {
// Windows does not support exec syscall.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows doesn't support syscall but all OSs support cmd.Run().
Why not having the plugin execution handled by a Command in all OSs and simplifying this code?

if runtime.GOOS == "windows" {
cmd := 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
}

return syscall.Exec(executablePath, append([]string{executablePath}, cmdArgs...), environment)
}
Comment on lines +27 to +86
Copy link
Collaborator

@leoluz leoluz Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please extract this code in a dedicated file with proper unit tests.
Suggestion: util/cli/plugin.go


func Command(name string, arg ...string) *exec.Cmd {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add docs to this command explaining how it works.

cmd := &exec.Cmd{
Path: name,
Args: append([]string{name}, arg...),
}
if filepath.Base(name) == name {
lp, err := exec.LookPath(name)
if lp != "" && !shouldSkipOnLookPathErr(err) {
// Update cmd.Path even if err is non-nil.
// If err is ErrDot (especially on Windows), lp may include a resolved
// extension (like .exe or .bat) that should be preserved.
cmd.Path = lp
}
}
return cmd
}

func shouldSkipOnLookPathErr(err error) bool {
return err != nil && !pluginError.Is(err, exec.ErrDot)
}

func init() {
cobra.OnInitialize(initConfig)
}
Expand All @@ -27,6 +115,91 @@
cli.SetLogLevel(cmdutil.LogLevel)
}

func NewDefaultArgoCDCommand() *cobra.Command {
return NewDefaultArgoCDCommandWithArgs(ArgoCDCLIOptions{
PluginHandler: NewDefaultPluginHandler([]string{"argocd"}),
Arguments: os.Args,
})
}

func NewDefaultArgoCDCommandWithArgs(o ArgoCDCLIOptions) *cobra.Command {
cmd := NewCommand()

if o.PluginHandler == nil {
return cmd
}

if len(o.Arguments) > 1 {
cmdPathPieces := o.Arguments[1:]

if _, _, err := cmd.Find(cmdPathPieces); err != nil {
var cmdName string
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, 1); err != nil {
fmt.Errorf("Error: %v\n", err)

Check failure on line 149 in cmd/argocd/commands/root.go

View workflow job for this annotation

GitHub Actions / Lint Go code

non-wrapping format verb for fmt.Errorf. Use `%w` to format errors (errorlint)
os.Exit(1)
}
}
}
}

return cmd
}

func HandlePluginCommand(pluginHandler PluginHandler, cmdArgs []string, minArgs int) error {
var remainingArgs []string // this will contain all "non-flag" arguments
for _, arg := range cmdArgs {
// if you encounter a flag, break the loop
if strings.HasPrefix(arg, "-") {
break
}
remainingArgs = append(remainingArgs, strings.Replace(arg, "-", "_", -1))

Check failure on line 166 in cmd/argocd/commands/root.go

View workflow job for this annotation

GitHub Actions / Lint Go code

wrapperFunc: use strings.ReplaceAll method in `strings.Replace(arg, "-", "_", -1)` (gocritic)
}

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])
}

foundPluginPath := ""

for len(remainingArgs) > 0 {
path, found := pluginHandler.LookForPlugin(strings.Join(remainingArgs, "-"))
if !found {
remainingArgs = remainingArgs[:len(remainingArgs)-1]
if len(remainingArgs) < minArgs {
break
}

continue
}

foundPluginPath = path
break
}

if len(foundPluginPath) == 0 {
return nil
}

// Execute the plugin that is found
if err := pluginHandler.ExecutePlugin(foundPluginPath, cmdArgs[len(remainingArgs):], os.Environ()); err != nil {
return err
}

return nil
}

// NewCommand returns a new instance of an argocd command
func NewCommand() *cobra.Command {
var (
Expand Down
10 changes: 6 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (

"github.com/spf13/cobra"

_ "go.uber.org/automaxprocs"

appcontroller "github.com/argoproj/argo-cd/v2/cmd/argocd-application-controller/commands"
applicationset "github.com/argoproj/argo-cd/v2/cmd/argocd-applicationset-controller/commands"
cmpserver "github.com/argoproj/argo-cd/v2/cmd/argocd-cmp-server/commands"
Expand All @@ -26,14 +24,18 @@ const (

func main() {
var command *cobra.Command
o := cli.ArgoCDCLIOptions{
PluginHandler: cli.NewDefaultPluginHandler([]string{"argocd"}),
Arguments: os.Args,
}

binaryName := filepath.Base(os.Args[0])
if val := os.Getenv(binaryNameEnv); val != "" {
binaryName = val
}
switch binaryName {
case "argocd", "argocd-linux-amd64", "argocd-darwin-amd64", "argocd-windows-amd64.exe":
command = cli.NewCommand()
command = cli.NewDefaultArgoCDCommandWithArgs(o)
case "argocd-server":
command = apiserver.NewCommand()
case "argocd-application-controller":
Expand All @@ -53,7 +55,7 @@ func main() {
case "argocd-k8s-auth":
command = k8sauth.NewCommand()
default:
command = cli.NewCommand()
command = cli.NewDefaultArgoCDCommand()
}

if err := command.Execute(); err != nil {
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@ require (
go.opentelemetry.io/otel/metric v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.30.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/automaxprocs v1.5.3
gomodules.xyz/envconfig v1.3.1-0.20190308184047-426f31af0d45 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
gomodules.xyz/notify v0.1.1 // indirect
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -808,8 +808,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
Expand Down Expand Up @@ -994,8 +992,6 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
Expand Down
Loading