feat(cli): Add Plugin Support to the Argo CD CLI (#20074)

Signed-off-by: nitishfy <justnitish06@gmail.com>
Co-authored-by: Alexandre Gaudreault <alexandre_gaudreault@intuit.com>
This commit is contained in:
Nitish Kumar 2025-05-06 04:42:33 +05:30 committed by GitHub
parent 9a738b2880
commit 6cf29619ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1025 additions and 9 deletions

View file

@ -0,0 +1,143 @@
package commands
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/argoproj/argo-cd/v3/util/cli"
log "github.com/sirupsen/logrus"
)
// DefaultPluginHandler implements the PluginHandler interface
type DefaultPluginHandler struct {
ValidPrefixes []string
lookPath func(file string) (string, error)
run func(cmd *exec.Cmd) error
}
// NewDefaultPluginHandler instantiates the DefaultPluginHandler
func NewDefaultPluginHandler(validPrefixes []string) *DefaultPluginHandler {
return &DefaultPluginHandler{
ValidPrefixes: validPrefixes,
lookPath: exec.LookPath,
run: func(cmd *exec.Cmd) error {
return cmd.Run()
},
}
}
// HandleCommandExecutionError processes the error returned from executing the command.
// It handles both standard Argo CD commands and plugin commands. We don't require to return
// error but we are doing it to cover various test scenarios.
func (h *DefaultPluginHandler) HandleCommandExecutionError(err error, isArgocdCLI bool, args []string) error {
// the log level needs to be setup manually here since the initConfig()
// set by the cobra.OnInitialize() was never executed because cmd.Execute()
// gave us a non-nil error.
initConfig()
cli.SetLogFormat("text")
// If it's an unknown command error, attempt to handle it as a plugin.
// Unfortunately, cobra doesn't handle this error, so we need to assume
// that error consists of substring "unknown command".
// https://github.com/spf13/cobra/pull/2167
if isArgocdCLI && strings.Contains(err.Error(), "unknown command") {
pluginPath, pluginErr := h.handlePluginCommand(args[1:])
// IMP: If a plugin doesn't exist, the returned path will be empty along with nil error
// This means the command is neither a normal Argo CD Command nor a plugin.
if pluginErr != nil {
// If plugin handling fails, report the plugin error and exit
fmt.Printf("Error: %v\n", pluginErr)
return pluginErr
} else if pluginPath == "" {
fmt.Printf("Error: %v\nRun 'argocd --help' for usage.\n", err)
return err
}
} else {
// If it's any other error (not an unknown command), report it directly and exit
fmt.Printf("Error: %v\n", err)
return err
}
return nil
}
// handlePluginCommand is responsible for finding and executing a plugin when a command isn't recognized as a built-in command
func (h *DefaultPluginHandler) handlePluginCommand(cmdArgs []string) (string, error) {
foundPluginPath := ""
path, found := h.lookForPlugin(cmdArgs[0])
if !found {
return foundPluginPath, nil
}
foundPluginPath = path
// Execute the plugin that is found
if err := h.executePlugin(foundPluginPath, cmdArgs[1:], os.Environ()); err != nil {
return foundPluginPath, err
}
return foundPluginPath, nil
}
// lookForPlugin looks for a plugin in the PATH that starts with argocd prefix
func (h *DefaultPluginHandler) lookForPlugin(filename string) (string, bool) {
for _, prefix := range h.ValidPrefixes {
pluginName := fmt.Sprintf("%s-%s", prefix, filename)
path, err := h.lookPath(pluginName)
if err != nil {
// error if a plugin is found in a relative path
if errors.Is(err, exec.ErrDot) {
log.Errorf("Plugin '%s' found in relative path: %v", pluginName, err)
} else {
log.Warnf("error looking for plugin '%s': %v", pluginName, err)
}
continue
}
if len(path) == 0 {
return "", false
}
return path, true
}
return "", false
}
// executePlugin implements PluginHandler and executes a plugin found
func (h *DefaultPluginHandler) executePlugin(executablePath string, cmdArgs, environment []string) error {
cmd := h.command(executablePath, cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = environment
err := h.run(cmd)
if err != nil {
return err
}
return nil
}
// command creates a new command for all OSs
func (h *DefaultPluginHandler) command(name string, arg ...string) *exec.Cmd {
cmd := &exec.Cmd{
Path: name,
Args: append([]string{name}, arg...),
}
if filepath.Base(name) == name {
lp, err := h.lookPath(name)
if lp != "" && err != nil {
// 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
}

View file

@ -0,0 +1,311 @@
package commands
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
argocdclient "github.com/argoproj/argo-cd/v3/pkg/apiclient"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupPluginPath sets the PATH to the directory where plugins are stored for testing purpose
func setupPluginPath(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
require.NoError(t, err)
testdataPath := filepath.Join(wd, "testdata")
t.Setenv("PATH", testdataPath)
}
// TestNormalCommandWithPlugin ensures that a standard ArgoCD command executes correctly
// even when a plugin with the same name exists in the PATH
func TestNormalCommandWithPlugin(t *testing.T) {
setupPluginPath(t)
_ = NewDefaultPluginHandler([]string{"argocd"})
args := []string{"argocd", "version", "--short", "--client"}
buf := new(bytes.Buffer)
cmd := NewVersionCmd(&argocdclient.ClientOptions{}, nil)
cmd.SetArgs(args[1:])
cmd.SetOut(buf)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.NoError(t, err)
output := buf.String()
assert.Equal(t, "argocd: v99.99.99+unknown\n", output)
}
// TestPluginExecution verifies that a plugin found in the PATH executes successfully following the correct naming conventions
func TestPluginExecution(t *testing.T) {
setupPluginPath(t)
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
cmd := NewCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
tests := []struct {
name string
args []string
expectedPluginErr string
}{
{
name: "'argocd-foo' binary exists in the PATH",
args: []string{"argocd", "foo"},
expectedPluginErr: "",
},
{
name: "'argocd-demo_plugin' binary exists in the PATH",
args: []string{"argocd", "demo_plugin"},
expectedPluginErr: "",
},
{
name: "'my-plugin' binary exists in the PATH",
args: []string{"argocd", "my-plugin"},
expectedPluginErr: "unknown command \"my-plugin\" for \"argocd\"",
},
{
name: "'argocd_my-plugin' binary exists in the PATH",
args: []string{"argocd", "my-plugin"},
expectedPluginErr: "unknown command \"my-plugin\" for \"argocd\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd.SetArgs(tt.args[1:])
err := cmd.Execute()
require.Error(t, err)
// since the command is not a valid argocd command, check for plugin execution
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, tt.args)
if tt.expectedPluginErr == "" {
require.NoError(t, pluginErr)
} else {
require.EqualError(t, pluginErr, tt.expectedPluginErr)
}
})
}
}
// TestNormalCommandError checks for an error when executing a normal ArgoCD command with invalid flags
func TestNormalCommandError(t *testing.T) {
setupPluginPath(t)
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
args := []string{"argocd", "version", "--non-existent-flag"}
cmd := NewVersionCmd(&argocdclient.ClientOptions{}, nil)
cmd.SetArgs(args[1:])
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, args)
assert.EqualError(t, pluginErr, "unknown flag: --non-existent-flag")
}
// TestUnknownCommandNoPlugin tests the scenario when the command is neither a normal ArgoCD command
// nor exists as a plugin
func TestUnknownCommandNoPlugin(t *testing.T) {
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
cmd := NewCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
args := []string{"argocd", "non-existent"}
cmd.SetArgs(args[1:])
err := cmd.Execute()
require.Error(t, err)
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, args)
require.Error(t, pluginErr)
assert.Equal(t, err, pluginErr)
}
// TestPluginNoExecutePermission verifies the behavior when a plugin doesn't have executable permissions
func TestPluginNoExecutePermission(t *testing.T) {
setupPluginPath(t)
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
cmd := NewCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
args := []string{"argocd", "no-permission"}
cmd.SetArgs(args[1:])
err := cmd.Execute()
require.Error(t, err)
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, args)
require.Error(t, pluginErr)
assert.EqualError(t, pluginErr, "unknown command \"no-permission\" for \"argocd\"")
}
// TestPluginExecutionError checks for errors that occur during plugin execution
func TestPluginExecutionError(t *testing.T) {
setupPluginPath(t)
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
cmd := NewCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
args := []string{"argocd", "error"}
cmd.SetArgs(args[1:])
err := cmd.Execute()
require.Error(t, err)
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, args)
require.Error(t, pluginErr)
assert.EqualError(t, pluginErr, "exit status 1")
}
// TestPluginInRelativePathIgnored ensures that plugins in a relative path, even if the path is included in PATH,
// are ignored and not executed.
func TestPluginInRelativePathIgnored(t *testing.T) {
setupPluginPath(t)
relativePath := "./relative-plugins"
err := os.MkdirAll(relativePath, 0o755)
require.NoError(t, err)
defer os.RemoveAll(relativePath)
relativePluginPath := filepath.Join(relativePath, "argocd-ignore-plugin")
err = os.WriteFile(relativePluginPath, []byte("#!/bin/bash\necho 'This should not execute'\n"), 0o755)
require.NoError(t, err)
t.Setenv("PATH", os.Getenv("PATH")+string(os.PathListSeparator)+relativePath)
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
cmd := NewCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
args := []string{"argocd", "ignore-plugin"}
cmd.SetArgs(args[1:])
err = cmd.Execute()
require.Error(t, err)
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, args)
require.Error(t, pluginErr)
assert.EqualError(t, pluginErr, "unknown command \"ignore-plugin\" for \"argocd\"")
}
// TestPluginFlagParsing checks that the flags are parsed correctly by the plugin handler
func TestPluginFlagParsing(t *testing.T) {
setupPluginPath(t)
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
tests := []struct {
name string
args []string
shouldFail bool
expectedErrMsg string
}{
{
name: "Valid flags",
args: []string{"argocd", "test-plugin", "--flag1", "value1", "--flag2", "value2"},
shouldFail: false,
expectedErrMsg: "",
},
{
name: "Unknown flag",
args: []string{"argocd", "test-plugin", "--flag3", "invalid"},
shouldFail: true,
expectedErrMsg: "exit status 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(tt.args[1:])
err := cmd.Execute()
require.Error(t, err)
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, tt.args)
if tt.shouldFail {
require.Error(t, pluginErr)
assert.Equal(t, tt.expectedErrMsg, pluginErr.Error(), "Unexpected error message")
} else {
require.NoError(t, pluginErr, "Expected no error for valid flags")
}
})
}
}
// TestPluginStatusCode checks for a correct status code that a plugin binary would generate
func TestPluginStatusCode(t *testing.T) {
setupPluginPath(t)
pluginHandler := NewDefaultPluginHandler([]string{"argocd"})
tests := []struct {
name string
args []string
wantStatus int
throwErr bool
}{
{
name: "plugin generates the successful exit code",
args: []string{"argocd", "status-code-plugin", "--flag1", "value1"},
wantStatus: 0,
throwErr: false,
},
{
name: "plugin generates an error status code",
args: []string{"argocd", "status-code-plugin", "--flag3", "value3"},
wantStatus: 1,
throwErr: true,
},
{
name: "plugin generates a status code for an invalid command",
args: []string{"argocd", "status-code-plugin", "invalid"},
wantStatus: 127,
throwErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(tt.args[1:])
err := cmd.Execute()
require.Error(t, err)
pluginErr := pluginHandler.HandleCommandExecutionError(err, true, tt.args)
if !tt.throwErr {
require.NoError(t, pluginErr)
} else {
require.Error(t, pluginErr)
var exitErr *exec.ExitError
if errors.As(pluginErr, &exitErr) {
assert.Equal(t, tt.wantStatus, exitErr.ExitCode(), "unexpected exit code")
} else {
t.Fatalf("expected an exit error, got: %v", pluginErr)
}
}
})
}
}

View file

@ -0,0 +1,15 @@
#!/bin/bash
if [[ "$1" == "version" ]]
then
echo "1.0.0"
exit 0
fi
if [[ "$1" == "config" ]]
then
echo "$KUBECONFIG"
exit 0
fi
echo "I am a plugin named argocd-demo_plugin"

17
cmd/argocd/commands/testdata/argocd-error vendored Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
if [[ "$1" == "version" ]]
then
echo "1.0.0"
exit 0
fi
if [[ "$1" == "config" ]]
then
echo "$KUBECONFIG"
exit 0
fi
# Default behavior: simulate an error
echo "Error: I am a plugin named argocd-error, and I encountered an error." >&2
exit 1

15
cmd/argocd/commands/testdata/argocd-foo vendored Executable file
View file

@ -0,0 +1,15 @@
#!/bin/bash
if [[ "$1" == "version" ]]
then
echo "1.0.0"
exit 0
fi
if [[ "$1" == "config" ]]
then
echo "$KUBECONFIG"
exit 0
fi
echo "I am a plugin named argocd-foo"

View file

@ -0,0 +1,3 @@
#!/bin/bash
echo "I can't be executed since I don't have executable permissions"

View file

@ -0,0 +1,16 @@
#!/bin/bash
case "$1" in
"--flag1")
echo "Flag1 detected: $2"
exit 0
;;
"--flag3")
echo "Unknown argument: --flag3" >&2
exit 1
;;
*)
echo "Plugin not found or invalid command" >&2
exit 127
;;
esac

View file

@ -0,0 +1,16 @@
#!/bin/bash
# Ensure we receive the expected flags and values
while [[ "$#" -gt 0 ]]; do
case $1 in
--flag1) FLAG1="$2"; shift ;;
--flag2) FLAG2="$2"; shift ;;
--help) echo "Usage: argocd test-plugin --flag1 value1 --flag2 value2"; exit 0 ;;
*) echo "Unknown argument: $1"; exit 1 ;;
esac
shift
done
# Output the parsed flag values
echo "Flag1: ${FLAG1}"
echo "Flag2: ${FLAG2}"

4
cmd/argocd/commands/testdata/argocd-version vendored Executable file
View file

@ -0,0 +1,4 @@
#!/bin/bash
echo "Fetching the version of Argo CD..."
argocd version --short --client

View file

@ -0,0 +1,7 @@
#!/bin/sh
echo "Hello from argocd_my-plugin"
if [ "$#" -gt 0 ]; then
echo "Arguments received: $@"
fi

7
cmd/argocd/commands/testdata/my-plugin vendored Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
echo "Hello from my-plugin"
if [ "$#" -gt 0 ]; then
echo "Arguments received: $@"
fi

View file

@ -1,7 +1,9 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
@ -39,11 +41,12 @@ func main() {
binaryName = val
}
isCLI := false
isArgocdCLI := false
switch binaryName {
case "argocd", "argocd-linux-amd64", "argocd-darwin-amd64", "argocd-windows-amd64.exe":
command = cli.NewCommand()
isCLI = true
isArgocdCLI = true
case "argocd-server":
command = apiserver.NewCommand()
case "argocd-application-controller":
@ -52,7 +55,7 @@ func main() {
command = reposerver.NewCommand()
case "argocd-cmp-server":
command = cmpserver.NewCommand()
isCLI = true
isArgocdCLI = true
case "argocd-commit-server":
command = commitserver.NewCommand()
case "argocd-dex":
@ -61,19 +64,41 @@ func main() {
command = notification.NewCommand()
case "argocd-git-ask-pass":
command = gitaskpass.NewCommand()
isCLI = true
isArgocdCLI = true
case "argocd-applicationset-controller":
command = applicationset.NewCommand()
case "argocd-k8s-auth":
command = k8sauth.NewCommand()
isCLI = true
isArgocdCLI = true
default:
command = cli.NewCommand()
isCLI = true
isArgocdCLI = true
}
util.SetAutoMaxProcs(isCLI)
util.SetAutoMaxProcs(isArgocdCLI)
if err := command.Execute(); err != nil {
os.Exit(1)
if isArgocdCLI {
// silence errors and usages since we'll be printing them manually.
// This is because if we execute a plugin, the initial
// errors and usage are always going to get printed that we don't want.
command.SilenceErrors = true
command.SilenceUsage = true
}
err := command.Execute()
// if the err is non-nil, try to look for various scenarios
// such as if the error is from the execution of a normal argocd command,
// unknown command error or any other.
if err != nil {
pluginHandler := cli.NewDefaultPluginHandler([]string{"argocd"})
pluginErr := pluginHandler.HandleCommandExecutionError(err, isArgocdCLI, os.Args)
if pluginErr != nil {
var exitErr *exec.ExitError
if errors.As(pluginErr, &exitErr) {
// Return the actual plugin exit code
os.Exit(exitErr.ExitCode())
}
// Fallback to exit code 1 if the error isn't an exec.ExitError
os.Exit(1)
}
}
}

201
docs/user-guide/plugins.md Normal file
View file

@ -0,0 +1,201 @@
# Plugins
## Overview
This guide demonstrates how to write plugins for the
`argocd` CLI tool. Plugins are a way to extend `argocd` CLI with new sub-commands,
allowing for custom features which are not part of the default distribution
of the `argocd` CLI..
If you would like to take a look at the original proposal, head over to this [enhancement proposal](../proposals/argocd-cli-pluin.md).
It covers how the plugin mechanism works, its benefits, motivations, and the goals it aims to achieve.
## Prerequisites
You need to have a working `argocd` binary installed locally. You can follow
the [cli installation documentation](https://argo-cd.readthedocs.io/en/stable/cli_installation/) to install the binary.
## Create `argocd` plugins
A plugin is a standalone executable file whose name begins with argocd-.
To install a plugin, move its executable file to any directory included in your PATH.
Ensure that the PATH configuration specifies the full absolute path to the executable,
not a relative path. `argocd` allows plugins to add custom commands such as
`argocd my-plugin arg1 arg2 --flag1` by executing a `argocd-my-plugin` binary in the PATH.
## Limitations
1. It is currently not possible to create plugins that overwrite existing
`argocd` commands. For example, creating a plugin such as `argocd-version`
will cause the plugin to never get executed, as the existing `argocd version`
command will always take precedence over it. Due to this limitation, it is
also not possible to use plugins to add new subcommands to existing `argocd` commands.
For example, adding a subcommand `argocd cluster upgrade` by naming your plugin
`argocd-cluster` will cause the plugin to be ignored.
2. It is currently not possible to parse the global flags set by `argocd` CLI. For example,
if you have set any global flag value such as `--logformat` value to `text`, the plugin will
not parse the global flags and pass the default value to the `--logformat` flag which is `json`.
The flag parsing will work exactly the same way for existing `argocd` commands which means executing a
existing argocd command such as `argocd cluster list` will correctly parse the flag value as `text`.
## Conditions for an `argocd` plugin
Any binary that you would want to execute as an `argocd` plugin need to satisfy the following three conditions:
1. The binary should start with `argocd-` as the prefix name. For example,
`argocd-demo-plugin` or `argocd-demo_plugin` is a valid binary name but not
`argocd_demo-plugin` or `argocd_demo_plugin`.
2. The binary should have executable permissions otherwise it will be ignored.
3. The binary should reside anywhere in the system's absolute PATH.
## Writing `argocd` plugins
### Naming a plugin
An Argo CD plugins filename must start with `argocd-`. The subcommands implemented
by the plugin are determined by the portion of the filename after the `argocd-` prefix.
Anything after `argocd-` will become a subcommand for `argocd`.
For example, A plugin named `argocd-demo-plugin` is invoked when the user types:
```bash
argocd demo-plugin [args] [flags]
```
The `argocd` CLI determines which plugin to invoke based on the subcommands provided.
For example, executing the following command:
```bash
argocd my-custom-command [args] [flags]
```
will lead to the execution of plugin named `argocd-my-custom-command` if it is present in the PATH.
### Writing a plugin
A plugin can be written in any programming language or script that allows you to write command-line commands.
A plugin determines which command path it wishes to implement based on its name.
For example, If a binary named `argocd-demo-plugin` is available in your system's absolute PATH, and the user runs the following command:
```bash
argocd demo-plugin subcommand1 --flag=true
```
Argo CD will translate and execute the corresponding plugin with the following command:
```bash
argocd-demo-plugin subcommand1 --flag=true
```
Similarly, if a plugin named `argocd-demo-demo-plugin` is found in the absolute PATH, and the user invokes:
```bash
argocd demo-demo-plugin subcommand2 subcommand3 --flag=true
```
Argo CD will execute the plugin as:
```bash
argocd-demo-demo-plugin subcommand2 subcommand3 --flag=true
```
### Example plugin
```bash
#!/bin/bash
# Check if the argocd CLI is installed
if ! command -v argocd &> /dev/null; then
echo "Error: Argo CD CLI (argocd) is not installed. Please install it first."
exit 1
fi
if [[ "$1" == "version" ]]
then
echo "displaying argocd version..."
argocd version
exit 0
fi
echo "I am a plugin named argocd-foo"
```
### Using a plugin
To use a plugin, make the plugin executable:
```bash
sudo chmod +x ./argocd-foo
```
and place it anywhere in your `PATH`:
```bash
sudo mv ./argocd-foo /usr/local/bin
```
You may now invoke your plugin as a argocd command:
```bash
argocd foo
```
This would give the following output
```bash
I am a plugin named argocd-foo
```
All args and flags are passed as-is to the executable:
```bash
argocd foo version
```
This would give the following output
```bash
DEBU[0000] command does not exist, looking for a plugin...
displaying argocd version...
2025/01/16 13:24:36 maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined
argocd: v2.13.0-rc2+0f083c9
BuildDate: 2024-09-20T11:59:25Z
GitCommit: 0f083c9e58638fc292cf064e294a1aa53caa5630
GitTreeState: clean
GoVersion: go1.22.7
Compiler: gc
Platform: linux/amd64
argocd-server: v2.13.0-rc2+0f083c9
BuildDate: 2024-09-20T11:59:25Z
GitCommit: 0f083c9e58638fc292cf064e294a1aa53caa5630
GitTreeState: clean
GoVersion: go1.22.7
Compiler: gc
Platform: linux/amd64
Kustomize Version: v5.4.3 2024-07-19T16:40:33Z
Helm Version: v3.15.2+g1a500d5
Kubectl Version: v0.31.0
Jsonnet Version: v0.20.0
```
## Distributing `argocd` plugins
If youve developed an Argo CD plugin for others to use,
you should carefully consider how to package, distribute, and
deliver updates to ensure a smooth installation and upgrade process
for your users.
### Native / platform specific package management
You can distribute your plugin using traditional package managers,
such as `apt` or `yum` for Linux, `Chocolatey` for Windows, and `Homebrew` for macOS.
These package managers are well-suited for distributing plugins as they can
place executables directly into the user's PATH, making them easily accessible.
However, as a plugin author, choosing this approach comes with the responsibility of
maintaining and updating the plugin's distribution packages across multiple platforms
for every release. This includes testing for compatibility, ensuring timely updates,
and managing versioning to provide a seamless experience for your users.
### Source code
You can publish the source code of your plugin, for example,
in a Git repository. This allows users to access and inspect
the code directly. Users who want to install the plugin will need
to fetch the code, set up a suitable build environment (if the plugin requires compiling),
and manually deploy it.

View file

@ -166,6 +166,7 @@ nav:
- user-guide/tool_detection.md
- user-guide/projects.md
- user-guide/private-repositories.md
- user-guide/plugins.md
- user-guide/multiple_sources.md
- GnuPG verification: user-guide/gpg-verification.md
- user-guide/auto_sync.md

View file

@ -1,6 +1,8 @@
package e2e
import (
"os"
"path/filepath"
"testing"
"github.com/argoproj/gitops-engine/pkg/health"
@ -13,6 +15,25 @@ import (
. "github.com/argoproj/argo-cd/v3/test/e2e/fixture/app"
)
// createTestPlugin creates a temporary Argo CD CLI plugin script for testing purposes.
// The script is written to a temporary directory with executable permissions.
func createTestPlugin(t *testing.T, name, content string) string {
t.Helper()
tmpDir := t.TempDir()
pluginPath := filepath.Join(tmpDir, "argocd-"+name)
require.NoError(t, os.WriteFile(pluginPath, []byte(content), 0o755))
// Ensure the plugin is cleaned up properly
t.Cleanup(func() {
_ = os.Remove(pluginPath)
})
return pluginPath
}
// TestCliAppCommand verifies the basic Argo CD CLI commands for app synchronization and listing.
func TestCliAppCommand(t *testing.T) {
Given(t).
Path("hook").
@ -38,3 +59,209 @@ func TestCliAppCommand(t *testing.T) {
assert.Contains(t, NormalizeOutput(output), expected)
})
}
// TestNormalArgoCDCommandsExecuteOverPluginsWithSameName verifies that normal Argo CD CLI commands
// take precedence over plugins with the same name when both exist in the path.
func TestNormalArgoCDCommandsExecuteOverPluginsWithSameName(t *testing.T) {
pluginScript := `#!/bin/bash
echo "I am a plugin, not Argo CD!"
exit 0`
pluginPath := createTestPlugin(t, "app", pluginScript)
origPath := os.Getenv("PATH")
t.Cleanup(func() {
t.Setenv("PATH", origPath)
})
t.Setenv("PATH", filepath.Dir(pluginPath)+":"+origPath)
Given(t).
Path("hook").
When().
CreateApp().
And(func() {
output, err := RunCli("app", "sync", Name(), "--timeout", "90")
require.NoError(t, err)
assert.NotContains(t, NormalizeOutput(output), "I am a plugin, not Argo CD!")
vars := map[string]any{"Name": Name(), "Namespace": DeploymentNamespace()}
assert.Contains(t, NormalizeOutput(output), Tmpl(t, `Pod {{.Namespace}} pod Synced Progressing pod/pod created`, vars))
assert.Contains(t, NormalizeOutput(output), Tmpl(t, `Pod {{.Namespace}} hook Succeeded Sync pod/hook created`, vars))
}).
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(HealthIs(health.HealthStatusHealthy)).
And(func(_ *Application) {
output, err := RunCli("app", "list")
require.NoError(t, err)
assert.NotContains(t, NormalizeOutput(output), "I am a plugin, not Argo CD!")
expected := Tmpl(
t,
`{{.Name}} https://kubernetes.default.svc {{.Namespace}} default Synced Healthy Manual <none>`,
map[string]any{"Name": Name(), "Namespace": DeploymentNamespace()})
assert.Contains(t, NormalizeOutput(output), expected)
})
}
// TestCliPluginExecution tests the execution of a valid Argo CD CLI plugin.
func TestCliPluginExecution(t *testing.T) {
pluginScript := `#!/bin/bash
echo "Hello from myplugin"
exit 0`
pluginPath := createTestPlugin(t, "myplugin", pluginScript)
origPath := os.Getenv("PATH")
t.Cleanup(func() {
t.Setenv("PATH", origPath)
})
t.Setenv("PATH", filepath.Dir(pluginPath)+":"+origPath)
output, err := RunPluginCli("", "myplugin")
require.NoError(t, err)
assert.Contains(t, NormalizeOutput(output), "Hello from myplugin")
}
// TestCliPluginExecutionConditions tests for plugin execution conditions
func TestCliPluginExecutionConditions(t *testing.T) {
createValidPlugin := func(t *testing.T, name string, executable bool) string {
t.Helper()
script := `#!/bin/bash
echo "Hello from $0"
exit 0
`
pluginPath := createTestPlugin(t, name, script)
if executable {
require.NoError(t, os.Chmod(pluginPath, 0o755))
} else {
require.NoError(t, os.Chmod(pluginPath, 0o644))
}
return pluginPath
}
createInvalidPlugin := func(t *testing.T, name string) string {
t.Helper()
script := `#!/bin/bash
echo "Hello from $0"
exit 0
`
tmpDir := t.TempDir()
pluginPath := filepath.Join(tmpDir, "argocd_"+name) // this is an invalid plugin name format
require.NoError(t, os.WriteFile(pluginPath, []byte(script), 0o755))
return pluginPath
}
// 'argocd-valid-plugin' is a valid plugin name
validPlugin := createValidPlugin(t, "valid-plugin", true)
// 'argocd_invalid-plugin' is an invalid plugin name
invalidPlugin := createInvalidPlugin(t, "invalid-plugin")
// 'argocd-nonexec-plugin' is a valid plugin name but lacks executable permissions
noExecPlugin := createValidPlugin(t, "noexec-plugin", false)
origPath := os.Getenv("PATH")
defer func() {
t.Setenv("PATH", origPath)
}()
t.Setenv("PATH", filepath.Dir(validPlugin)+":"+filepath.Dir(invalidPlugin)+":"+filepath.Dir(noExecPlugin)+":"+origPath)
output, err := RunPluginCli("", "valid-plugin")
require.NoError(t, err)
assert.Contains(t, NormalizeOutput(output), "Hello from")
_, err = RunPluginCli("", "invalid-plugin")
require.Error(t, err)
_, err = RunPluginCli("", "noexec-plugin")
// expects error since plugin lacks executable permissions
require.Error(t, err)
}
// TestCliPluginStatusCodes verifies that a plugin returns the correct exit codes based on its execution.
func TestCliPluginStatusCodes(t *testing.T) {
pluginScript := `#!/bin/bash
case "$1" in
"success") exit 0 ;;
"error1") exit 1 ;;
"error2") exit 2 ;;
*) echo "Unknown argument: $1"; exit 3 ;;
esac`
pluginPath := createTestPlugin(t, "error-plugin", pluginScript)
origPath := os.Getenv("PATH")
t.Cleanup(func() {
t.Setenv("PATH", origPath)
})
t.Setenv("PATH", filepath.Dir(pluginPath)+":"+origPath)
output, err := RunPluginCli("", "error-plugin", "success")
require.NoError(t, err)
assert.Contains(t, NormalizeOutput(output), "")
_, err = RunPluginCli("", "error-plugin", "error1")
require.Error(t, err)
assert.Contains(t, err.Error(), "exit status 1")
_, err = RunPluginCli("", "error-plugin", "error2")
require.Error(t, err)
assert.Contains(t, err.Error(), "exit status 2")
_, err = RunPluginCli("", "error-plugin", "unknown")
require.Error(t, err)
assert.Contains(t, err.Error(), "exit status 3")
}
// TestCliPluginStdinHandling verifies that a CLI plugin correctly handles input from stdin.
func TestCliPluginStdinHandling(t *testing.T) {
pluginScript := `#!/bin/bash
input=$(cat)
echo "Received: $input"
exit 0`
pluginPath := createTestPlugin(t, "stdin-plugin", pluginScript)
origPath := os.Getenv("PATH")
t.Cleanup(func() {
t.Setenv("PATH", origPath)
})
t.Setenv("PATH", filepath.Dir(pluginPath)+":"+origPath)
testCases := []struct {
name string
stdin string
expected string
}{
{
"Single line input",
"Hello, ArgoCD!",
"Received: Hello, ArgoCD!",
},
{
"Multiline input",
"Line1\nLine2\nLine3",
"Received: Line1\nLine2\nLine3",
},
{
"Empty input",
"",
"Received:",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
output, err := RunPluginCli(tc.stdin, "stdin-plugin")
require.NoError(t, err)
assert.Contains(t, NormalizeOutput(output), tc.expected)
})
}
}

View file

@ -1006,6 +1006,7 @@ func EnsureCleanState(t *testing.T, opts ...TestOption) {
}).Info("clean state")
}
// RunCliWithRetry executes an Argo CD CLI command with retry logic.
func RunCliWithRetry(maxRetries int, args ...string) (string, error) {
var out string
var err error
@ -1019,10 +1020,12 @@ func RunCliWithRetry(maxRetries int, args ...string) (string, error) {
return out, err
}
// RunCli executes an Argo CD CLI command with no stdin input and default server authentication.
func RunCli(args ...string) (string, error) {
return RunCliWithStdin("", false, args...)
}
// RunCliWithStdin executes an Argo CD CLI command with optional stdin input and authentication.
func RunCliWithStdin(stdin string, isKubeConextOnlyCli bool, args ...string) (string, error) {
if plainText {
args = append(args, "--plaintext")
@ -1038,6 +1041,11 @@ func RunCliWithStdin(stdin string, isKubeConextOnlyCli bool, args ...string) (st
return RunWithStdin(stdin, "", "../../dist/argocd", args...)
}
// RunPluginCli executes an Argo CD CLI plugin with optional stdin input.
func RunPluginCli(stdin string, args ...string) (string, error) {
return RunWithStdin(stdin, "", "../../dist/argocd", args...)
}
func Patch(t *testing.T, path string, jsonPatch string) {
t.Helper()
log.WithFields(log.Fields{"path": path, "jsonPatch": jsonPatch}).Info("patching")