Skip to content
Open
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
13 changes: 9 additions & 4 deletions plugins/argocd/argocd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions plugins/argocd/argocd_test.go
Original file line number Diff line number Diff line change
@@ -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,
},
})
}
2 changes: 1 addition & 1 deletion plugins/argocd/auth_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
110 changes: 110 additions & 0 deletions plugins/argocd/provisioner.go
Original file line number Diff line number Diff line change
@@ -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")
}
124 changes: 124 additions & 0 deletions plugins/argocd/provisioner_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading