diff --git a/README.md b/README.md index 8edc478..3f56321 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A kubectl plugin that runs commands against every context in your kubeconfig fil - Run kubectl commands against all contexts simultaneously - Parallel execution with configurable batching (default: 25 contexts at a time) - Include/exclude contexts by name pattern -- Support for `version`, `get`, `logs`, `wait`, `top`, `events`, `api-resources`, `api-versions`, and `auth` subcommands +- Support for `list`, `version`, `get`, `logs`, `wait`, `top`, `events`, `api-resources`, `api-versions`, and `auth` subcommands - Streaming log output with `-f` flag across all contexts - Watch mode with `-w`/`--watch` flag on `get` and `events` subcommands - Flexible output formatting: @@ -122,6 +122,21 @@ kubectl x --exclude dev --exclude staging get pods kubectl x --include prod --exclude "us-west" get pods ``` +### List Command + +List all contexts from your kubeconfig, one per line. Respects `--include` and `--exclude` filters, making it useful for previewing which contexts a command will target before running it: + +```bash +# List all contexts +kubectl x list + +# List only prod contexts +kubectl x list --include prod + +# List prod contexts, excluding US West +kubectl x list --include prod --exclude "us-west" +``` + ### Version Command Run `kubectl version` against all contexts: diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..80f12f2 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all matching contexts", + Long: `List all contexts from kubeconfig, optionally filtered by --include and --exclude.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList() + }, +} + +func runList() error { + contexts, err := getContexts() + if err != nil { + return err + } + for _, ctx := range contexts { + fmt.Println(ctx) + } + return nil +} diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..23c6413 --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func writeMinimalKubeconfig(t *testing.T, contextNames []string) string { + t.Helper() + tmpDir := t.TempDir() + path := tmpDir + "/kubeconfig" + + var contexts []map[string]interface{} + for _, name := range contextNames { + contexts = append(contexts, map[string]interface{}{"name": name}) + } + data, err := yaml.Marshal(map[string]interface{}{ + "apiVersion": "v1", + "kind": "Config", + "contexts": contexts, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0600)) + return path +} + +func captureList(t *testing.T) (string, error) { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + io.Copy(&buf, r) + close(done) + }() + + runErr := runList() + w.Close() + <-done + os.Stdout = old + r.Close() + return buf.String(), runErr +} + +func TestRunList(t *testing.T) { + t.Run("lists all contexts", func(t *testing.T) { + path := writeMinimalKubeconfig(t, []string{"dev-use1-gkg2", "prod-usw2-ejlr", "prod-use1-arj3"}) + t.Setenv("KUBECONFIG", path) + + out, err := captureList(t) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + assert.Equal(t, []string{"dev-use1-gkg2", "prod-usw2-ejlr", "prod-use1-arj3"}, lines) + }) + + t.Run("respects --include filter", func(t *testing.T) { + path := writeMinimalKubeconfig(t, []string{"dev-use1-gkg2", "prod-usw2-ejlr", "prod-use1-arj3"}) + t.Setenv("KUBECONFIG", path) + filterPatterns = []string{"prod"} + t.Cleanup(func() { filterPatterns = []string{} }) + + out, err := captureList(t) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + assert.Equal(t, []string{"prod-usw2-ejlr", "prod-use1-arj3"}, lines) + }) + + t.Run("respects --exclude filter", func(t *testing.T) { + path := writeMinimalKubeconfig(t, []string{"dev-use1-gkg2", "prod-usw2-ejlr", "prod-use1-arj3"}) + t.Setenv("KUBECONFIG", path) + excludePatterns = []string{"prod"} + t.Cleanup(func() { excludePatterns = []string{} }) + + out, err := captureList(t) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + assert.Equal(t, []string{"dev-use1-gkg2"}, lines) + }) + + t.Run("returns error when no contexts match filter", func(t *testing.T) { + path := writeMinimalKubeconfig(t, []string{"dev-use1-gkg2"}) + t.Setenv("KUBECONFIG", path) + filterPatterns = []string{"prod"} + t.Cleanup(func() { filterPatterns = []string{} }) + + _, err := captureList(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "no contexts match filter patterns") + }) +} diff --git a/cmd/root.go b/cmd/root.go index ce8ded7..366723d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ func init() { rootCmd.PersistentFlags().StringArrayVar(&filterPatterns, "filter", []string{}, "Alias for --include") rootCmd.PersistentFlags().MarkDeprecated("filter", "use --include instead") rootCmd.PersistentFlags().StringArrayVarP(&excludePatterns, "exclude", "e", []string{}, "Exclude contexts by name using regex pattern (can be specified multiple times for OR logic)") + rootCmd.AddCommand(listCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(getCmd) rootCmd.AddCommand(logsCmd) diff --git a/cmd/root_test.go b/cmd/root_test.go index 70a8709..8964116 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -14,7 +14,7 @@ func TestRootCmd(t *testing.T) { } func TestRootCmdHasSubcommands(t *testing.T) { - expected := []string{"version", "get", "logs", "top", "wait", "events", "api-resources", "api-versions", "auth"} + expected := []string{"list", "version", "get", "logs", "top", "wait", "events", "api-resources", "api-versions", "auth"} registered := make(map[string]bool) for _, cmd := range rootCmd.Commands() { registered[cmd.Use] = true