diff --git a/plugins/argocd/argocd.go b/plugins/argocd/argocd.go index 25baa535b..222897879 100644 --- a/plugins/argocd/argocd.go +++ b/plugins/argocd/argocd.go @@ -9,10 +9,15 @@ import ( func ArgocdCLI() schema.Executable { return schema.Executable{ - Name: "Argo CD CLI", - Runs: []string{"argocd"}, - DocsURL: sdk.URL("https://argo-cd.readthedocs.io/en/stable/user-guide/commands/argocd/"), - NeedsAuth: needsauth.NotForHelpOrVersion(), + Name: "Argo CD CLI", + Runs: []string{"argocd"}, + DocsURL: sdk.URL("https://argo-cd.readthedocs.io/en/stable/user-guide/commands/argocd/"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + // login and relogin pick their server without --server or ARGOCD_SERVER, and never need the stored token. + needsauth.NotForCommand("login"), + needsauth.NotForCommand("relogin"), + ), Uses: []schema.CredentialUsage{ { Name: credname.AuthToken, diff --git a/plugins/argocd/argocd_test.go b/plugins/argocd/argocd_test.go new file mode 100644 index 000000000..4e440c5dd --- /dev/null +++ b/plugins/argocd/argocd_test.go @@ -0,0 +1,24 @@ +package argocd + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk/plugintest" +) + +func TestArgocdCLINeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, ArgocdCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "yes for app list": { + Args: []string{"app", "list"}, + ExpectedNeedsAuth: true, + }, + "no for login": { + Args: []string{"login", "localhost:8080"}, + ExpectedNeedsAuth: false, + }, + "no for relogin": { + Args: []string{"relogin"}, + ExpectedNeedsAuth: false, + }, + }) +} diff --git a/plugins/argocd/auth_token.go b/plugins/argocd/auth_token.go index df1de7774..81a438d94 100644 --- a/plugins/argocd/auth_token.go +++ b/plugins/argocd/auth_token.go @@ -36,7 +36,7 @@ func AuthToken() schema.CredentialType { Optional: true, }, }, - DefaultProvisioner: provision.EnvVars(envVarMapping), + DefaultProvisioner: addressAwareProvisioner{Provisioner: provision.EnvVars(envVarMapping)}, Importer: importer.TryAll( importer.TryEnvVarPair(envVarMapping), TryArgocdConfigFile(), diff --git a/plugins/argocd/provisioner.go b/plugins/argocd/provisioner.go new file mode 100644 index 000000000..b2dc66440 --- /dev/null +++ b/plugins/argocd/provisioner.go @@ -0,0 +1,110 @@ +package argocd + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +// addressAwareProvisioner skips provisioning when argocd targets a server other than the item's +// Address, since ARGOCD_SERVER and ARGOCD_AUTH_TOKEN override the selected argocd context. +type addressAwareProvisioner struct { + sdk.Provisioner +} + +func (p addressAwareProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + if address := in.ItemFields[fieldname.Address]; address != "" { + target := targetServer(in.HomeDir, out.CommandLine) + if target != "" && normalizeServer(target) != normalizeServer(address) { + return + } + } + p.Provisioner.Provision(ctx, in, out) +} + +type configFile struct { + CurrentContext string `yaml:"current-context"` + Contexts []Context `yaml:"contexts"` +} + +func readConfig(homeDir string, args []string) *configFile { + contents, err := os.ReadFile(configPath(homeDir, args)) + if err != nil { + return nil + } + var config configFile + if err := importer.FileContents(contents).ToYAML(&config); err != nil { + return nil + } + return &config +} + +// targetServer returns the server argocd would use without the plugin, or "" if unknown. +func targetServer(homeDir string, args []string) string { + if server := flagValue(args, "--server"); server != "" { + return server + } + if server := os.Getenv("ARGOCD_SERVER"); server != "" { + return server + } + + config := readConfig(homeDir, args) + if config == nil { + return "" + } + name := flagValue(args, "--argocd-context") + if name == "" { + name = config.CurrentContext + } + for _, c := range config.Contexts { + if c.Name == name { + return c.Server + } + } + return "" +} + +// configPath mirrors localconfig.DefaultConfigDir in argo-cd. +func configPath(homeDir string, args []string) string { + if path := flagValue(args, "--config"); path != "" { + return path + } + if dir := os.Getenv("ARGOCD_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "config") + } + legacyDir := filepath.Join(homeDir, ".argocd") + if _, err := os.Stat(legacyDir); err == nil { + return filepath.Join(legacyDir, "config") + } + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, "argocd", "config") + } + return filepath.Join(homeDir, ".config", "argocd", "config") +} + +// flagValue returns the value of the last "--flag value" or "--flag=value" before "--". +func flagValue(args []string, flag string) string { + var value string + for i := 0; i < len(args) && args[i] != "--"; i++ { + if args[i] == flag && i+1 < len(args) { + i++ + value = args[i] + } else if v, ok := strings.CutPrefix(args[i], flag+"="); ok { + value = v + } + } + return value +} + +func normalizeServer(server string) string { + server = strings.ToLower(strings.TrimSpace(server)) + server = strings.TrimPrefix(server, "https://") + server = strings.TrimPrefix(server, "http://") + server = strings.TrimSuffix(server, "/") + return strings.TrimSuffix(server, ":443") +} diff --git a/plugins/argocd/provisioner_test.go b/plugins/argocd/provisioner_test.go new file mode 100644 index 000000000..db3bd84b1 --- /dev/null +++ b/plugins/argocd/provisioner_test.go @@ -0,0 +1,124 @@ +package argocd + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + prodServer = "argocd.prod.example.com" + localServer = "localhost:8080" + itemToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhcmdvY2QifQ.EXAMPLE" +) + +func argocdConfig(currentContext string) string { + return "contexts:\n" + + "- name: prod\n server: " + prodServer + "\n" + + "- name: local\n server: " + localServer + "\n" + + "current-context: " + currentContext + "\n" +} + +func TestAddressAwareProvisioner(t *testing.T) { + prodItem := map[sdk.FieldName]string{fieldname.AuthToken: itemToken, fieldname.Address: prodServer} + provisioned := map[string]string{"ARGOCD_AUTH_TOKEN": itemToken, "ARGOCD_SERVER": prodServer} + notProvisioned := map[string]string{} + currentLocal := map[string]string{".config/argocd/config": argocdConfig("local")} + currentProd := map[string]string{".config/argocd/config": argocdConfig("prod")} + + cases := map[string]struct { + Files map[string]string // relative to the home directory + Env map[string]string + Args []string + ItemFields map[sdk.FieldName]string + ExpectedEnv map[string]string + }{ + "provisions without an argocd config": { + ItemFields: prodItem, + ExpectedEnv: provisioned, + }, + "provisions when current-context matches the address": { + Files: currentProd, + ItemFields: prodItem, + ExpectedEnv: provisioned, + }, + "skips when current-context targets another server": { + Files: currentLocal, + ItemFields: prodItem, + ExpectedEnv: notProvisioned, + }, + "skips when --argocd-context targets another server": { + Files: currentProd, + Args: []string{"app", "list", "--argocd-context", "local"}, + ItemFields: prodItem, + ExpectedEnv: notProvisioned, + }, + "provisions when --argocd-context matches the address": { + Files: currentLocal, + Args: []string{"app", "list", "--argocd-context", "prod"}, + ItemFields: prodItem, + ExpectedEnv: provisioned, + }, + "skips when --server targets another server": { + Files: currentProd, + Args: []string{"app", "list", "--server=" + localServer}, + ItemFields: prodItem, + ExpectedEnv: notProvisioned, + }, + "ignores --server after --": { + Files: currentProd, + Args: []string{"app", "list", "--", "--server", localServer}, + ItemFields: prodItem, + ExpectedEnv: provisioned, + }, + "skips when ARGOCD_SERVER targets another server": { + Files: currentProd, + Env: map[string]string{"ARGOCD_SERVER": localServer}, + ItemFields: prodItem, + ExpectedEnv: notProvisioned, + }, + "compares addresses ignoring case, scheme, trailing slash and default port": { + Args: []string{"app", "list", "--server", "prod.example.com:443"}, + ItemFields: map[sdk.FieldName]string{fieldname.AuthToken: itemToken, fieldname.Address: "https://Prod.Example.com/"}, + ExpectedEnv: map[string]string{"ARGOCD_AUTH_TOKEN": itemToken, "ARGOCD_SERVER": "https://Prod.Example.com/"}, + }, + "prefers the legacy ~/.argocd config over ~/.config/argocd": { + Files: map[string]string{".config/argocd/config": argocdConfig("prod"), ".argocd/config": argocdConfig("local")}, + ItemFields: prodItem, + ExpectedEnv: notProvisioned, + }, + "provisions when the config can't be parsed": { + Files: map[string]string{".config/argocd/config": "contexts: [not: valid"}, + ItemFields: prodItem, + ExpectedEnv: provisioned, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + for _, key := range []string{"ARGOCD_SERVER", "ARGOCD_CONFIG_DIR", "XDG_CONFIG_HOME"} { + t.Setenv(key, c.Env[key]) + } + for path, contents := range c.Files { + fullPath := filepath.Join(home, path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o700)) + require.NoError(t, os.WriteFile(fullPath, []byte(contents), 0o600)) + } + + out := sdk.ProvisionOutput{Environment: map[string]string{}, CommandLine: append([]string{"argocd"}, c.Args...)} + AuthToken().DefaultProvisioner.Provision(context.Background(), sdk.ProvisionInput{ + HomeDir: home, + ItemFields: c.ItemFields, + }, &out) + + assert.Equal(t, c.ExpectedEnv, out.Environment) + }) + } +}