diff --git a/README.md b/README.md index 388cc677e..4cdb8b1b0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,68 @@ $ rancher login https:// -t my-secret-token > **Note:** When entering your ``, include the port that was exposed while you installed Rancher Server. +### External Config Helper Support + +The Rancher CLI supports external config helpers for enhanced credential management and integration with external systems like credential stores, password managers, or CI/CD pipelines. + +#### Using Config Helpers + +You can specify a config helper using the `--config-helper` flag or the `RANCHER_CONFIG_HELPER` environment variable: + +```bash +# Use an external helper script +$ rancher --config-helper /path/to/my-helper login + +# Use environment variable +$ export RANCHER_CONFIG_HELPER=/path/to/my-helper +$ rancher login + +# Use built-in file-based config (default) +$ rancher --config-helper built-in login +``` + +#### Creating Config Helpers + +Config helpers are executable scripts or programs that handle loading and storing Rancher CLI configuration. They must support two commands: + +**Loading Configuration:** +```bash +helper-script get +``` +Should output the complete Rancher CLI configuration as JSON to stdout. + +**Storing Configuration:** +```bash +helper-script store '{"Servers":{"server1":{...}},"CurrentServer":"server1"}' +``` +Should persist the provided JSON configuration. + +**Example Helper Script:** +```bash +#!/bin/bash +case "$1" in + get) + # Load config from your external system + gopass show -o -y rancher-config + ;; + + store) + # Store config to your external system + echo $2 | gopass insert -f rancher-config + ;; + *) + echo "Usage: $0 {get|store}" + exit 1 + ;; +esac +``` + +This feature enables integration with: +- Corporate credential management systems +- Cloud provider secret stores (AWS Secrets Manager, Azure Key Vault, etc.) +- CI/CD pipeline secret injection +- Multi-environment configuration management + ## Usage Run `rancher --help` for a list of available commands. diff --git a/cmd/common.go b/cmd/common.go index 08d522f7c..f67dd32fb 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -287,8 +287,15 @@ func GetConfigPath(ctx *cli.Context) string { } func loadConfig(ctx *cli.Context) (config.Config, error) { - path := GetConfigPath(ctx) - return config.LoadFromPath(path) + switch ctx.GlobalString("config-helper") { + case "built-in": + path := GetConfigPath(ctx) + return config.LoadFromPath(path) + default: + // allow loading of rancher config by triggering an + // external helper executable + return config.LoadWithHelper(ctx.GlobalString("config-helper")) + } } func lookupConfig(ctx *cli.Context) (*config.ServerConfig, error) { diff --git a/cmd/common_test.go b/cmd/common_test.go index f067015e4..845c20c4e 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/url" + "os" + "path/filepath" "strconv" "testing" "time" @@ -292,3 +294,55 @@ func TestNewHTTPClient(t *testing.T) { require.Nil(t, proxyURL) }) } + +// TestConfigHelperIntegration tests that the config helper functionality +// integrates properly with the CLI loading mechanism +func TestConfigHelperIntegration(t *testing.T) { + t.Parallel() + + t.Run("loadConfig uses built-in helper by default", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + // Create a temporary config file + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + require.NoError(err) + defer os.RemoveAll(dir) + + configPath := filepath.Join(dir, "cli2.json") + configContent := `{"Servers":{"test":{"url":"https://test.com"}},"CurrentServer":"test"}` + err = os.WriteFile(configPath, []byte(configContent), 0600) + require.NoError(err) + + // Test that config.LoadFromPath works with built-in helper + conf, err := config.LoadFromPath(configPath) + require.NoError(err) + assert.Equal("built-in", conf.Helper) + assert.Equal("test", conf.CurrentServer) + }) + + t.Run("external helper integration works", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + // Create a mock helper script + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + require.NoError(err) + defer os.RemoveAll(dir) + + helperScript := `#!/bin/bash +echo '{"Servers":{"helper-test":{"url":"https://helper.com"}},"CurrentServer":"helper-test"}'` + helperPath := filepath.Join(dir, "test-helper") + err = os.WriteFile(helperPath, []byte(helperScript), 0755) + require.NoError(err) + + // Test that config.LoadWithHelper works + conf, err := config.LoadWithHelper(helperPath) + require.NoError(err) + assert.Equal(helperPath, conf.Helper) + assert.Equal("helper-test", conf.CurrentServer) + assert.Empty(conf.Path) // Path should be empty for helper-loaded configs + }) +} diff --git a/cmd/kubectl_token_test.go b/cmd/kubectl_token_test.go index 43753817f..e66cacbef 100644 --- a/cmd/kubectl_token_test.go +++ b/cmd/kubectl_token_test.go @@ -126,6 +126,7 @@ func TestCacheCredential(t *testing.T) { flagSet := flag.NewFlagSet("test", 0) flagSet.String("server", "rancher.example.com", "doc") flagSet.String("config", t.TempDir(), "doc") + flagSet.String("config-helper", "built-in", "") cliCtx := cli.NewContext(nil, flagSet, nil) serverConfig, err := lookupServerConfig(cliCtx) diff --git a/cmd/server_test.go b/cmd/server_test.go index 1e65b0484..595a22479 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -272,5 +272,6 @@ func newTestConfig() *config.Config { "server2": {URL: "https://myserver-2.com"}, "server3": {URL: "https://myserver-3.com"}, }, + Helper: "built-in", } } diff --git a/config/config.go b/config/config.go index f4e2f273e..d3ed3b184 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -24,6 +25,8 @@ type Config struct { Path string `json:"path,omitempty"` // CurrentServer the user has in focus CurrentServer string + // Helper executable to store config + Helper string `json:"helper,omitempty"` } // ServerConfig holds the config for each server the user has setup @@ -50,6 +53,7 @@ func LoadFromPath(path string) (Config, error) { cf := Config{ Path: path, Servers: make(map[string]*ServerConfig), + Helper: "built-in", } content, err := os.ReadFile(path) @@ -70,6 +74,26 @@ func LoadFromPath(path string) (Config, error) { return cf, nil } +// fetch rancher cli config by executing an external helper +func LoadWithHelper(helper string) (Config, error) { + cf := Config{ + Path: "", + Servers: make(map[string]*ServerConfig), + Helper: helper, + } + + cmd := exec.Command(helper, "get") + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + content, err := cmd.Output() + if err != nil { + return cf, err + } + + err = json.Unmarshal(content, &cf) + return cf, err +} + // GetFilePermissionWarnings returns the following warnings based on the file permission: // - one warning if the file is group-readable // - one warning if the file is world-readable @@ -102,6 +126,17 @@ func GetFilePermissionWarnings(path string) ([]string, error) { } func (c Config) Write() error { + switch c.Helper { + case "built-in": + return c.writeNative() + default: + // if rancher config was loaded by external helper + // use the same helper to persist the config + return c.writeWithHelper() + } +} + +func (c Config) writeNative() error { err := os.MkdirAll(filepath.Dir(c.Path), 0700) if err != nil { return err @@ -118,6 +153,19 @@ func (c Config) Write() error { return json.NewEncoder(output).Encode(c) } +func (c Config) writeWithHelper() error { + logrus.Infof("Saving config with helper %s", c.Helper) + jsonConfig, err := json.Marshal(c) + if err != nil { + return err + } + cmd := exec.Command(c.Helper, "store", string(jsonConfig)) + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + err = cmd.Run() + return err +} + func (c Config) FocusedServer() (*ServerConfig, error) { currentServer, found := c.Servers[c.CurrentServer] if !found || currentServer == nil { diff --git a/config/config_test.go b/config/config_test.go index c3b9da2cc..b57120d0e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,11 +1,14 @@ package config import ( + "fmt" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( @@ -155,6 +158,7 @@ func TestLoadFromPath(t *testing.T) { }, }, CurrentServer: "rancherDefault", + Helper: "built-in", }, }, { @@ -162,6 +166,7 @@ func TestLoadFromPath(t *testing.T) { content: invalidFile, expectedConf: Config{ Servers: map[string]*ServerConfig{}, + Helper: "built-in", }, expectedErr: true, }, @@ -171,6 +176,7 @@ func TestLoadFromPath(t *testing.T) { expectedConf: Config{ Servers: map[string]*ServerConfig{}, CurrentServer: "", + Helper: "built-in", }, }, } @@ -209,3 +215,258 @@ func TestLoadFromPath(t *testing.T) { }) } } + +func TestLoadWithHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + helperScript string + expectedConf Config + expectedErr bool + }{ + { + name: "valid helper response", + helperScript: `#!/bin/bash +echo '{"Servers":{"test":{"accessKey":"key","url":"https://test.com"}},"CurrentServer":"test"}'`, + expectedConf: Config{ + Servers: map[string]*ServerConfig{ + "test": { + AccessKey: "key", + URL: "https://test.com", + }, + }, + CurrentServer: "test", + }, + }, + { + name: "helper not found", + helperScript: "", + expectedErr: true, + }, + { + name: "helper returns invalid json", + helperScript: `#!/bin/bash +echo 'invalid json'`, + expectedErr: true, + }, + { + name: "helper exits with error", + helperScript: `#!/bin/bash +exit 1`, + expectedErr: true, + }, + { + name: "helper returns empty output", + helperScript: `#!/bin/bash +echo ''`, + expectedErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + var helperPath string + if tt.helperScript != "" { + // Create a temporary helper script + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + assert.NoError(err) + defer os.RemoveAll(dir) + + helperPath = filepath.Join(dir, "test-helper") + err = os.WriteFile(helperPath, []byte(tt.helperScript), 0755) + assert.NoError(err) + } else { + // Use non-existent helper + helperPath = "non-existent-helper" + } + + conf, err := LoadWithHelper(helperPath) + if tt.expectedErr { + assert.Error(err) + return + } + + assert.NoError(err) + assert.Equal(helperPath, conf.Helper) + assert.Equal(tt.expectedConf.CurrentServer, conf.CurrentServer) + assert.Equal(len(tt.expectedConf.Servers), len(conf.Servers)) + assert.Empty(conf.Path) // Path should be empty for helper-loaded configs + }) + } +} + +func TestConfigWrite(t *testing.T) { + t.Parallel() + + t.Run("writes using native method for built-in helper", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + assert.NoError(err) + defer os.RemoveAll(dir) + + path := filepath.Join(dir, "cli2.json") + conf := Config{ + Path: path, + Helper: "built-in", + Servers: make(map[string]*ServerConfig), + CurrentServer: "test", + } + + err = conf.Write() + assert.NoError(err) + + // Verify file was created + _, err = os.Stat(path) + assert.NoError(err) + }) + + t.Run("writes using helper method for external helper", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + // Create a mock helper that accepts store commands + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + assert.NoError(err) + defer os.RemoveAll(dir) + + helperScript := `#!/bin/bash +if [ "$1" = "store" ]; then + # Just exit successfully for the test + exit 0 +fi +exit 1` + + helperPath := filepath.Join(dir, "test-helper") + err = os.WriteFile(helperPath, []byte(helperScript), 0755) + assert.NoError(err) + + conf := Config{ + Helper: helperPath, + Servers: make(map[string]*ServerConfig), + CurrentServer: "test", + } + + err = conf.Write() + assert.NoError(err) + }) + + t.Run("helper write fails when helper not found", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + conf := Config{ + Helper: "non-existent-helper", + Servers: make(map[string]*ServerConfig), + CurrentServer: "test", + } + + err := conf.Write() + assert.Error(err) + }) + + t.Run("helper write fails when helper exits with error", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + // Create a mock helper that fails + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + assert.NoError(err) + defer os.RemoveAll(dir) + + helperScript := `#!/bin/bash +exit 1` + + helperPath := filepath.Join(dir, "test-helper") + err = os.WriteFile(helperPath, []byte(helperScript), 0755) + assert.NoError(err) + + conf := Config{ + Helper: helperPath, + Servers: make(map[string]*ServerConfig), + CurrentServer: "test", + } + + err = conf.Write() + assert.Error(err) + }) +} + +func TestHelperProtocol(t *testing.T) { + t.Parallel() + + t.Run("helper receives correct get command", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + // Create a helper that logs its arguments + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + require.NoError(err) + defer os.RemoveAll(dir) + + logFile := filepath.Join(dir, "args.log") + helperScript := fmt.Sprintf(`#!/bin/bash +echo "$@" > %s +echo '{"Servers":{},"CurrentServer":""}'`, logFile) + + helperPath := filepath.Join(dir, "test-helper") + err = os.WriteFile(helperPath, []byte(helperScript), 0755) + require.NoError(err) + + _, err = LoadWithHelper(helperPath) + require.NoError(err) + + // Check that the helper was called with "get" + logContent, err := os.ReadFile(logFile) + require.NoError(err) + assert.Contains(string(logContent), "get") + }) + + t.Run("helper receives correct store command and data", func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + // Skip on Windows since bash scripts don't work there + if runtime.GOOS == "windows" { + t.Skip("Skipping bash script test on Windows") + } + + // Create a helper that logs its arguments + dir, err := os.MkdirTemp("", "rancher-cli-test-*") + require.NoError(err) + defer os.RemoveAll(dir) + + logFile := filepath.Join(dir, "store.log") + helperScript := fmt.Sprintf(`#!/bin/bash +echo "$1" > %s +echo "$2" >> %s`, logFile, logFile) + + helperPath := filepath.Join(dir, "test-helper") + err = os.WriteFile(helperPath, []byte(helperScript), 0755) + require.NoError(err) + + conf := Config{ + Helper: helperPath, + Servers: make(map[string]*ServerConfig), + CurrentServer: "test", + } + + err = conf.Write() + require.NoError(err) + + // Check that the helper was called with "store" and JSON data + logContent, err := os.ReadFile(logFile) + require.NoError(err) + lines := string(logContent) + assert.Contains(lines, "store") + assert.Contains(lines, "test") // Should contain the CurrentServer value + }) +} diff --git a/main.go b/main.go index f3327bf47..48381c838 100644 --- a/main.go +++ b/main.go @@ -104,6 +104,12 @@ func mainErr() error { EnvVar: "RANCHER_CONFIG_DIR", Value: configDir, }, + cli.StringFlag{ + Name: "config-helper", + Usage: "Helper executable to load/store config", + EnvVar: "RANCHER_CONFIG_HELPER", + Value: os.ExpandEnv("built-in"), + }, } app.Commands = []cli.Command{ cmd.ClusterCommand(),