diff --git a/.golangci.yml b/.golangci.yml index bb432b4..eeee1b3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,6 +29,14 @@ linters: modernize: disable: - omitzero + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Printf + - fmt.Println + - fmt.Fprintf + - fmt.Fprintln + - fmt.Print exclusions: generated: lax paths: diff --git a/Makefile b/Makefile index 2e49204..4167ad1 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,11 @@ test-race: ## Run tests with race detection test-short: ## Run tests in short mode go test -short -v ./... +.PHONY: test-cover +test-cover: ## Run tests with coverage + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out + .PHONY: lint lint: ## Run golangci-lint against code golangci-lint run diff --git a/cmd/context.go b/cmd/context.go index 7004956..90078af 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -19,7 +19,45 @@ import ( "github.com/idebeijer/kubert/internal/state" ) +type ContextOptions struct { + Out io.Writer + ErrOut io.Writer + + Args []string + + Config config.Config + ContextLoader func() ([]kubeconfig.Context, error) + StateManager func() (*state.Manager, error) + Selector func([]string) (string, error) + IsInteractive func() bool + ShellLauncher func(kubeconfigPath, originalPath, contextName string, cfg config.Config) error + TempFileWriter func(kubeconfigPath, contextName, namespace string) (*os.File, func(), error) +} + +func NewContextOptions() *ContextOptions { + return &ContextOptions{ + Out: os.Stdout, + ErrOut: os.Stderr, + + ContextLoader: func() ([]kubeconfig.Context, error) { + cfg := config.Cfg + fsProvider := kubeconfig.NewFileSystemProvider(cfg.KubeconfigPaths.Include, cfg.KubeconfigPaths.Exclude) + loader := kubeconfig.NewLoader(kubeconfig.WithProvider(fsProvider)) + return loader.LoadContexts() + }, + StateManager: state.NewManager, + Selector: fzf.Select, + IsInteractive: fzf.IsInteractive, + ShellLauncher: func(kubeconfigPath, originalPath, contextName string, cfg config.Config) error { + return launchShellWithKubeconfig(kubeconfigPath, originalPath, contextName, cfg) + }, + TempFileWriter: createTempKubeconfigFile, + } +} + func NewContextCommand() *cobra.Command { + o := NewContextOptions() + cmd := &cobra.Command{ Use: "ctx [context-name | -]", Short: "Spawn a shell with the selected context", @@ -39,27 +77,38 @@ Use '-' to switch to the previously selected context.`, SilenceUsage: true, ValidArgsFunction: validContextArgsFunction, RunE: func(cmd *cobra.Command, args []string) error { - return runContextCommand(args) + if err := o.Complete(cmd, args); err != nil { + return err + } + if err := o.Validate(); err != nil { + return err + } + return o.Run() }, } return cmd } -func runContextCommand(args []string) error { - cfg := config.Cfg +func (o *ContextOptions) Complete(cmd *cobra.Command, args []string) error { + o.Out = cmd.OutOrStdout() + o.ErrOut = cmd.ErrOrStderr() + o.Args = args + o.Config = config.Cfg + return nil +} - fsProvider := kubeconfig.NewFileSystemProvider(cfg.KubeconfigPaths.Include, cfg.KubeconfigPaths.Exclude) - loader := kubeconfig.NewLoader( - kubeconfig.WithProvider(fsProvider), - ) +func (o *ContextOptions) Validate() error { + return nil +} - sm, err := state.NewManager() +func (o *ContextOptions) Run() error { + sm, err := o.StateManager() if err != nil { return fmt.Errorf("error creating state manager: %w", err) } - contexts, err := loader.LoadContexts() + contexts, err := o.ContextLoader() if err != nil { return fmt.Errorf("error loading contexts: %w", err) } @@ -68,7 +117,7 @@ func runContextCommand(args []string) error { contextNames := getContextNames(contexts) sort.Strings(contextNames) - selectedContextName, err := selectContextName(args, contextNames, sm) + selectedContextName, err := o.selectContextName(contextNames, sm) if err != nil { return err } @@ -82,7 +131,7 @@ func runContextCommand(args []string) error { } contextInState, _ := sm.ContextInfo(selectedContextName) - tempKubeconfig, cleanup, err := createTempKubeconfigFile(selectedContext.FilePath, selectedContextName, contextInState.LastNamespace) + tempKubeconfig, cleanup, err := o.TempFileWriter(selectedContext.FilePath, selectedContextName, contextInState.LastNamespace) if err != nil { return err } @@ -94,41 +143,44 @@ func runContextCommand(args []string) error { slog.Warn("Failed to save last context", "error", err) } - return launchShellWithKubeconfig(tempKubeconfig.Name(), selectedContext.FilePath, selectedContextName, cfg) + return o.ShellLauncher(tempKubeconfig.Name(), selectedContext.FilePath, selectedContextName, o.Config) } -func getContextNames(contexts []kubeconfig.Context) []string { - names := make([]string, 0, len(contexts)) - for _, context := range contexts { - names = append(names, context.Name) - } - return names -} +func (o *ContextOptions) selectContextName(contextNames []string, sm *state.Manager) (string, error) { + if len(o.Args) > 0 { + if o.Args[0] != "-" { + return o.Args[0], nil + } -func selectContextName(args []string, contextNames []string, sm *state.Manager) (string, error) { - if len(args) > 0 { - if args[0] == "-" { - lastContext, exists := sm.GetLastContext() - if !exists { - return "", fmt.Errorf("no previous context found") - } - return lastContext, nil + lastContext, exists := sm.GetLastContext() + if !exists { + return "", fmt.Errorf("no previous context found") } - return args[0], nil + return lastContext, nil } - if !fzf.IsInteractiveShell() { - printContextNames(contextNames) + + if !o.IsInteractive() { + o.printContextNames(contextNames) return "", nil } - return fzf.Select(contextNames) + + return o.Selector(contextNames) } -func printContextNames(contextNames []string) { +func (o *ContextOptions) printContextNames(contextNames []string) { for _, name := range contextNames { - fmt.Println(name) + fmt.Fprintln(o.Out, name) } } +func getContextNames(contexts []kubeconfig.Context) []string { + names := make([]string, 0, len(contexts)) + for _, context := range contexts { + names = append(names, context.Name) + } + return names +} + func findContextByName(contexts []kubeconfig.Context, name string) (kubeconfig.Context, bool) { for _, context := range contexts { if context.Name == name { diff --git a/cmd/context_test.go b/cmd/context_test.go index 54b8416..243641c 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -9,12 +9,52 @@ import ( "strings" "testing" + "github.com/adrg/xdg" + "github.com/spf13/cobra" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" "github.com/idebeijer/kubert/internal/config" + "github.com/idebeijer/kubert/internal/kubeconfig" + "github.com/idebeijer/kubert/internal/state" ) +// setupTestXDGDataHome sets xdg.DataHome to a temp directory and returns a cleanup function +// that restores the original value. +// nolint:unparam +func setupTestXDGDataHome(t *testing.T) string { + t.Helper() + original := xdg.DataHome + tempDir := t.TempDir() + xdg.DataHome = tempDir + t.Cleanup(func() { xdg.DataHome = original }) + return tempDir +} + +func TestContextOptions_Complete_SetsConfig(t *testing.T) { + original := config.Cfg + defer func() { config.Cfg = original }() + + config.Cfg = config.Config{ + KubeconfigPaths: config.KubeconfigPaths{ + Include: []string{"/some/path"}, + }, + } + + o := NewContextOptions() + cmd := &cobra.Command{} + + if len(o.Config.KubeconfigPaths.Include) != 0 { + t.Error("Config should not be set before Complete()") + } + + _ = o.Complete(cmd, []string{}) + + if len(o.Config.KubeconfigPaths.Include) != 1 || o.Config.KubeconfigPaths.Include[0] != "/some/path" { + t.Errorf("Complete() should set Config from config.Cfg, got: %+v", o.Config.KubeconfigPaths) + } +} + func TestCreateTempKubeconfigFile_Isolation(t *testing.T) { // Create a temp file to act as the "original" complex kubeconfig tempFile, err := os.CreateTemp("", "original-kubeconfig-*.yaml") @@ -28,24 +68,24 @@ func TestCreateTempKubeconfigFile_Isolation(t *testing.T) { }() // Define a complex configuration with multiple contexts, clusters, and users - config := api.NewConfig() + cfg := api.NewConfig() // Cluster 1 data - config.Clusters["shared-cluster"] = &api.Cluster{Server: "https://shared.example.com"} + cfg.Clusters["shared-cluster"] = &api.Cluster{Server: "https://shared.example.com"} - config.AuthInfos["user-1"] = &api.AuthInfo{Token: "token-1"} - config.Contexts["ctx-1"] = &api.Context{Cluster: "shared-cluster", AuthInfo: "user-1", Namespace: "ns-1"} + cfg.AuthInfos["user-1"] = &api.AuthInfo{Token: "token-1"} + cfg.Contexts["ctx-1"] = &api.Context{Cluster: "shared-cluster", AuthInfo: "user-1", Namespace: "ns-1"} - config.AuthInfos["user-2"] = &api.AuthInfo{Username: "admin", Password: "password"} - config.Contexts["ctx-2"] = &api.Context{Cluster: "shared-cluster", AuthInfo: "user-2", Namespace: "default"} + cfg.AuthInfos["user-2"] = &api.AuthInfo{Username: "admin", Password: "password"} + cfg.Contexts["ctx-2"] = &api.Context{Cluster: "shared-cluster", AuthInfo: "user-2", Namespace: "default"} // Cluster 2 data - config.Clusters["other-cluster"] = &api.Cluster{Server: "https://other.example.com"} - config.AuthInfos["user-3"] = &api.AuthInfo{ClientCertificate: "/path/to/cert"} - config.Contexts["ctx-3"] = &api.Context{Cluster: "other-cluster", AuthInfo: "user-3"} + cfg.Clusters["other-cluster"] = &api.Cluster{Server: "https://other.example.com"} + cfg.AuthInfos["user-3"] = &api.AuthInfo{ClientCertificate: "/path/to/cert"} + cfg.Contexts["ctx-3"] = &api.Context{Cluster: "other-cluster", AuthInfo: "user-3"} // Write this original config to disk - if err := clientcmd.WriteToFile(*config, tempFile.Name()); err != nil { + if err := clientcmd.WriteToFile(*cfg, tempFile.Name()); err != nil { t.Fatal(err) } @@ -112,6 +152,7 @@ func TestCreateTempKubeconfigFile_Isolation(t *testing.T) { // Note: This is an experimental test that simulates launching a shell with the modified kubeconfig. // Skipped for now. +// nolint func TestLaunchShellWithKubeconfig(t *testing.T) { t.Skip() @@ -125,7 +166,9 @@ func TestLaunchShellWithKubeconfig(t *testing.T) { // 2. TODO: simulate running kubert commands either by binary or by directly calling the logic - if err := runNamespaceCommand([]string{"default"}); err != nil { + o := NewNamespaceOptions() + o.Args = []string{"default"} + if err := o.Run(); err != nil { fmt.Fprintf(os.Stderr, "Failed to run namespace command: %v\n", err) os.Exit(1) } @@ -180,6 +223,7 @@ func TestLaunchShellWithKubeconfig(t *testing.T) { // Note: This test is experimental and more of an integration test which may be flaky and should // be run inside the ./testdata/Dockerfile container. // Running locally would require all shells to be installed. +// nolint func TestLaunchShells(t *testing.T) { if os.Getenv("RUN_SHELL_TESTS") != "true" { t.Skip("Skipping shell tests") @@ -260,3 +304,418 @@ func TestLaunchShells(t *testing.T) { }) } } + +func TestContextOptions_Complete(t *testing.T) { + tests := []struct { + name string + args []string + expectedArgs []string + }{ + { + name: "with context name", + args: []string{"my-cluster"}, + expectedArgs: []string{"my-cluster"}, + }, + { + name: "with previous context flag", + args: []string{"-"}, + expectedArgs: []string{"-"}, + }, + { + name: "no args", + args: []string{}, + expectedArgs: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := NewContextOptions() + cmd := &cobra.Command{} + + err := o.Complete(cmd, tt.args) + if err != nil { + t.Errorf("Complete() returned error: %v", err) + } + + if len(o.Args) != len(tt.expectedArgs) { + t.Fatalf("Args length mismatch: got %d, want %d", len(o.Args), len(tt.expectedArgs)) + } + + for i, arg := range o.Args { + if arg != tt.expectedArgs[i] { + t.Errorf("Args[%d] = %s, want %s", i, arg, tt.expectedArgs[i]) + } + } + }) + } +} + +func TestContextOptions_Run_WithContextName(t *testing.T) { + var buf bytes.Buffer + shellLauncherCalled := false + tempFileCreated := false + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"test-cluster"}, + Config: config.Config{}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "test-cluster", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + StateManager: func() (*state.Manager, error) { + setupTestXDGDataHome(t) + return state.NewManager() + }, + Selector: func(items []string) (string, error) { + t.Error("Selector should not be called when context name is provided") + return "", nil + }, + IsInteractive: func() bool { + return true + }, + ShellLauncher: func(kubeconfigPath, originalPath, contextName string, cfg config.Config) error { + shellLauncherCalled = true + if contextName != "test-cluster" { + t.Errorf("Expected context name 'test-cluster', got '%s'", contextName) + } + return nil + }, + TempFileWriter: func(kubeconfigPath, contextName, namespace string) (*os.File, func(), error) { + tempFileCreated = true + tempFile, _ := os.CreateTemp("", "test-*.yaml") + cleanup := func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + } + return tempFile, cleanup, nil + }, + } + + err := o.Run() + if err != nil { + t.Errorf("Run() returned unexpected error: %v", err) + } + + if !shellLauncherCalled { + t.Error("ShellLauncher should have been called") + } + + if !tempFileCreated { + t.Error("TempFileWriter should have been called") + } +} + +func TestContextOptions_Run_PreviousContext(t *testing.T) { + var buf bytes.Buffer + shellLauncherCalled := false + + setupTestXDGDataHome(t) + + sm, err := state.NewManager() + if err != nil { + t.Fatalf("Failed to create state manager: %v", err) + } + + _ = sm.SetLastContext("previous-cluster") + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"-"}, + Config: config.Config{}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "previous-cluster", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + StateManager: func() (*state.Manager, error) { + return sm, nil + }, + ShellLauncher: func(kubeconfigPath, originalPath, contextName string, cfg config.Config) error { + shellLauncherCalled = true + if contextName != "previous-cluster" { + t.Errorf("Expected context name 'previous-cluster', got '%s'", contextName) + } + return nil + }, + TempFileWriter: func(kubeconfigPath, contextName, namespace string) (*os.File, func(), error) { + tempFile, _ := os.CreateTemp("", "test-*.yaml") + cleanup := func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + } + return tempFile, cleanup, nil + }, + } + + err = o.Run() + if err != nil { + t.Errorf("Run() returned unexpected error: %v", err) + } + + if !shellLauncherCalled { + t.Error("ShellLauncher should have been called") + } +} + +func TestContextOptions_Run_NoPreviousContext(t *testing.T) { + var buf bytes.Buffer + + setupTestXDGDataHome(t) + + sm, err := state.NewManager() + if err != nil { + t.Fatalf("Failed to create state manager: %v", err) + } + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"-"}, + Config: config.Config{}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "test-cluster", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + StateManager: func() (*state.Manager, error) { + return sm, nil + }, + } + + err = o.Run() + if err == nil { + t.Error("Expected error for no previous context, got nil") + return + } + if !strings.Contains(err.Error(), "no previous context") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestContextOptions_Run_InteractiveSelection(t *testing.T) { + var buf bytes.Buffer + selectorCalled := false + shellLauncherCalled := false + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{}, + Config: config.Config{}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "cluster-1", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + {Name: "cluster-2", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + StateManager: func() (*state.Manager, error) { + setupTestXDGDataHome(t) + return state.NewManager() + }, + Selector: func(items []string) (string, error) { + selectorCalled = true + return "cluster-1", nil + }, + IsInteractive: func() bool { + return true + }, + ShellLauncher: func(kubeconfigPath, originalPath, contextName string, cfg config.Config) error { + shellLauncherCalled = true + return nil + }, + TempFileWriter: func(kubeconfigPath, contextName, namespace string) (*os.File, func(), error) { + tempFile, _ := os.CreateTemp("", "test-*.yaml") + cleanup := func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + } + return tempFile, cleanup, nil + }, + } + + err := o.Run() + if err != nil { + t.Errorf("Run() returned unexpected error: %v", err) + } + + if !selectorCalled { + t.Error("Selector should have been called in interactive mode") + } + + if !shellLauncherCalled { + t.Error("ShellLauncher should have been called") + } +} + +func TestContextOptions_Run_NonInteractivePrintOnly(t *testing.T) { + var buf bytes.Buffer + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{}, + Config: config.Config{}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "cluster-1", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + {Name: "cluster-2", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + StateManager: func() (*state.Manager, error) { + setupTestXDGDataHome(t) + return state.NewManager() + }, + Selector: func(items []string) (string, error) { + t.Error("Selector should not be called in non-interactive mode") + return "", nil + }, + IsInteractive: func() bool { + return false + }, + ShellLauncher: func(kubeconfigPath, originalPath, contextName string, cfg config.Config) error { + t.Error("ShellLauncher should not be called when printing only") + return nil + }, + } + + err := o.Run() + if err != nil { + t.Errorf("Run() returned unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "cluster-1") { + t.Error("Expected cluster-1 in output") + } + if !strings.Contains(output, "cluster-2") { + t.Error("Expected cluster-2 in output") + } +} + +func TestContextOptions_Run_ContextNotFound(t *testing.T) { + var buf bytes.Buffer + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"nonexistent-cluster"}, + Config: config.Config{}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "cluster-1", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + StateManager: func() (*state.Manager, error) { + setupTestXDGDataHome(t) + return state.NewManager() + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error for nonexistent context, got nil") + } + if !strings.Contains(err.Error(), "context nonexistent-cluster not found") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestContextOptions_Run_ContextLoaderError(t *testing.T) { + var buf bytes.Buffer + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"test-cluster"}, + StateManager: func() (*state.Manager, error) { + setupTestXDGDataHome(t) + return state.NewManager() + }, + ContextLoader: func() ([]kubeconfig.Context, error) { + return nil, fmt.Errorf("failed to load kubeconfig") + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error from context loader, got nil") + return + } + if !strings.Contains(err.Error(), "error loading contexts") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestContextOptions_Run_StateManagerError(t *testing.T) { + var buf bytes.Buffer + + o := &ContextOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"test-cluster"}, + StateManager: func() (*state.Manager, error) { + return nil, fmt.Errorf("state manager initialization failed") + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error from state manager, got nil") + } + if !strings.Contains(err.Error(), "error creating state manager") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestGetContextNames(t *testing.T) { + contexts := []kubeconfig.Context{ + {Name: "cluster-1"}, + {Name: "cluster-2"}, + {Name: "cluster-3"}, + } + + names := getContextNames(contexts) + + if len(names) != 3 { + t.Errorf("Expected 3 names, got %d", len(names)) + } + + expectedNames := map[string]bool{"cluster-1": true, "cluster-2": true, "cluster-3": true} + for _, name := range names { + if !expectedNames[name] { + t.Errorf("Unexpected name: %s", name) + } + } +} + +func TestFindContextByName(t *testing.T) { + contexts := []kubeconfig.Context{ + {Name: "cluster-1", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config1"}}, + {Name: "cluster-2", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config2"}}, + } + + t.Run("context found", func(t *testing.T) { + ctx, found := findContextByName(contexts, "cluster-1") + if !found { + t.Error("Expected to find cluster-1") + } + if ctx.Name != "cluster-1" { + t.Errorf("Expected name 'cluster-1', got '%s'", ctx.Name) + } + if ctx.FilePath != "/tmp/config1" { + t.Errorf("Expected path '/tmp/config1', got '%s'", ctx.FilePath) + } + }) + + t.Run("context not found", func(t *testing.T) { + _, found := findContextByName(contexts, "nonexistent") + if found { + t.Error("Should not find nonexistent context") + } + }) +} diff --git a/cmd/exec.go b/cmd/exec.go index 141b0d2..4676636 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "io" "os" "os/exec" "regexp" @@ -18,21 +19,44 @@ import ( "github.com/idebeijer/kubert/internal/state" ) -type execFlags struct { - namespace string - regex bool - parallel bool - dryRun bool +type ExecOptions struct { + Out io.Writer + ErrOut io.Writer + + Namespace string + Regex bool + Parallel bool + DryRun bool + + Patterns []string + CommandArgs []string + + Config config.Config + ContextLoader func() ([]kubeconfig.Context, error) + StateManager func() (*state.Manager, error) + IsInteractive func() bool + Selector func([]string) ([]string, error) } -type contextExecResult struct { - contextName string - output string - err error +func NewExecOptions() *ExecOptions { + return &ExecOptions{ + Out: os.Stdout, + ErrOut: os.Stderr, + + ContextLoader: func() ([]kubeconfig.Context, error) { + cfg := config.Cfg + fsProvider := kubeconfig.NewFileSystemProvider(cfg.KubeconfigPaths.Include, cfg.KubeconfigPaths.Exclude) + loader := kubeconfig.NewLoader(kubeconfig.WithProvider(fsProvider)) + return loader.LoadContexts() + }, + StateManager: state.NewManager, + IsInteractive: fzf.IsInteractive, + Selector: fzf.SelectMulti, + } } func NewExecCommand() *cobra.Command { - flags := &execFlags{} + o := NewExecOptions() cmd := &cobra.Command{ Use: "exec [pattern...] -- command [args...]", @@ -67,102 +91,132 @@ you can select multiple contexts interactively (use Tab/Shift-Tab to select).`, SilenceUsage: true, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg := config.Cfg - fsProvider := kubeconfig.NewFileSystemProvider(cfg.KubeconfigPaths.Include, cfg.KubeconfigPaths.Exclude) - loader := kubeconfig.NewLoader(kubeconfig.WithProvider(fsProvider)) - - contexts, err := loader.LoadContexts() - if err != nil { - return fmt.Errorf("error loading contexts: %w", err) + if err := o.Complete(cmd, args); err != nil { + return err } - - // Use ArgsLenAtDash to find where "--" was in the original command - // If ArgsLenAtDash returns -1, there was no "--" separator - dashIdx := cmd.ArgsLenAtDash() - - var patterns []string - var commandArgs []string - - switch dashIdx { - case -1: - return fmt.Errorf("missing '--' separator between patterns and command") - case 0: - patterns = []string{} - commandArgs = args - default: - // Split at the dash index - patterns = args[:dashIdx] - commandArgs = args[dashIdx:] + if err := o.Validate(); err != nil { + return err } + return o.Run() + }, + } - if len(commandArgs) == 0 { - return fmt.Errorf("no command provided after '--'") - } + cmd.Flags().StringVarP(&o.Namespace, "namespace", "n", "default", "Namespace to use for all contexts") + cmd.Flags().BoolVar(&o.Regex, "regex", false, "Use regex pattern matching instead of glob-style wildcards") + cmd.Flags().BoolVarP(&o.Parallel, "parallel", "p", false, "Execute commands in parallel across all contexts") + cmd.Flags().BoolVar(&o.DryRun, "dry-run", false, "Show which contexts would be used without executing the command") - var matchedContexts []kubeconfig.Context + return cmd +} - if len(patterns) == 0 { - if !fzf.IsInteractiveShell() { - return fmt.Errorf("patterns are required in non-interactive mode") - } +func (o *ExecOptions) Complete(cmd *cobra.Command, args []string) error { + o.Out = cmd.OutOrStdout() + o.ErrOut = cmd.ErrOrStderr() + o.Config = config.Cfg + + dashIdx := cmd.ArgsLenAtDash() + switch dashIdx { + case -1: + return fmt.Errorf("missing '--' separator between patterns and command") + case 0: + o.Patterns = []string{} + o.CommandArgs = args + default: + o.Patterns = args[:dashIdx] + o.CommandArgs = args[dashIdx:] + } - contextNames := getContextNames(contexts) - sort.Strings(contextNames) + return nil +} - selectedNames, err := fzf.SelectMulti(contextNames) - if err != nil { - return fmt.Errorf("context selection cancelled or failed: %w", err) - } +func (o *ExecOptions) Validate() error { + if len(o.CommandArgs) == 0 { + return fmt.Errorf("no command provided after '--'") + } - if len(selectedNames) == 0 { - return fmt.Errorf("no contexts selected") - } + if len(o.Patterns) == 0 && !o.IsInteractive() { + return fmt.Errorf("patterns are required in non-interactive mode") + } - for _, name := range selectedNames { - ctx, found := findContextByName(contexts, name) - if found { - matchedContexts = append(matchedContexts, ctx) - } - } - } else { - matchedContexts, err = filterContextsByPatterns(contexts, patterns, flags.regex) - if err != nil { - return fmt.Errorf("error filtering contexts: %w", err) - } - - if len(matchedContexts) == 0 { - return fmt.Errorf("no contexts matched the patterns: %s", strings.Join(patterns, ", ")) - } - } + return nil +} - sm, err := state.NewManager() - if err != nil { - return fmt.Errorf("error creating state manager: %w", err) - } +func (o *ExecOptions) Run() error { + contexts, err := o.ContextLoader() + if err != nil { + return fmt.Errorf("error loading contexts: %w", err) + } - if flags.dryRun { - return showDryRun(matchedContexts, commandArgs, flags.namespace, sm, cfg) - } + matchedContexts, err := o.resolveContexts(contexts) + if err != nil { + return err + } - fmt.Printf("Executing command against %d context(s):\n", len(matchedContexts)) - for _, ctx := range matchedContexts { - fmt.Printf(" - %s\n", ctx.Name) - } - fmt.Println() + sm, err := o.StateManager() + if err != nil { + return fmt.Errorf("error creating state manager: %w", err) + } - if flags.parallel { - return executeParallel(matchedContexts, commandArgs, flags.namespace, sm, cfg) - } - return executeSequential(matchedContexts, commandArgs, flags.namespace, sm, cfg) - }, + if o.DryRun { + return showDryRun(o.Out, matchedContexts, o.CommandArgs, o.Namespace, sm, o.Config) + } + + fmt.Fprintf(o.Out, "Executing command against %d context(s):\n", len(matchedContexts)) + for _, ctx := range matchedContexts { + fmt.Fprintf(o.Out, " - %s\n", ctx.Name) } + fmt.Fprintln(o.Out) - cmd.Flags().StringVarP(&flags.namespace, "namespace", "n", "default", "Namespace to use for all contexts") - cmd.Flags().BoolVar(&flags.regex, "regex", false, "Use regex pattern matching instead of glob-style wildcards") - cmd.Flags().BoolVarP(&flags.parallel, "parallel", "p", false, "Execute commands in parallel across all contexts") - cmd.Flags().BoolVar(&flags.dryRun, "dry-run", false, "Show which contexts would be used without executing the command") + if o.Parallel { + return executeParallel(o.Out, matchedContexts, o.CommandArgs, o.Namespace, sm, o.Config) + } + return executeSequential(o.Out, matchedContexts, o.CommandArgs, o.Namespace, sm, o.Config) +} - return cmd +func (o *ExecOptions) resolveContexts(contexts []kubeconfig.Context) ([]kubeconfig.Context, error) { + if len(o.Patterns) == 0 { + return o.resolveInteractive(contexts) + } + + matched, err := filterContextsByPatterns(contexts, o.Patterns, o.Regex) + if err != nil { + return nil, fmt.Errorf("error filtering contexts: %w", err) + } + + if len(matched) == 0 { + return nil, fmt.Errorf("no contexts matched the patterns: %s", strings.Join(o.Patterns, ", ")) + } + + return matched, nil +} + +func (o *ExecOptions) resolveInteractive(contexts []kubeconfig.Context) ([]kubeconfig.Context, error) { + contextNames := getContextNames(contexts) + sort.Strings(contextNames) + + selectedNames, err := o.Selector(contextNames) + if err != nil { + return nil, fmt.Errorf("context selection cancelled or failed: %w", err) + } + + if len(selectedNames) == 0 { + return nil, fmt.Errorf("no contexts selected") + } + + var matched []kubeconfig.Context + for _, name := range selectedNames { + if ctx, found := findContextByName(contexts, name); found { + matched = append(matched, ctx) + } + } + + return matched, nil +} + +type contextExecResult struct { + contextName string + output string + err error } func filterContextsByPatterns(contexts []kubeconfig.Context, patterns []string, useRegex bool) ([]kubeconfig.Context, error) { @@ -226,16 +280,16 @@ func globToRegex(pattern string) string { return "^" + pattern + "$" } -func executeSequential(contexts []kubeconfig.Context, args []string, namespace string, sm *state.Manager, cfg config.Config) error { +func executeSequential(out io.Writer, contexts []kubeconfig.Context, args []string, namespace string, sm *state.Manager, cfg config.Config) error { hasErrors := false for i, ctx := range contexts { if i > 0 { - fmt.Println() + fmt.Fprintln(out) } result := executeInContext(ctx, args, namespace, sm, cfg) - printResult(result) + printResult(out, result) if result.err != nil { hasErrors = true @@ -249,8 +303,9 @@ func executeSequential(contexts []kubeconfig.Context, args []string, namespace s return nil } -func executeParallel(contexts []kubeconfig.Context, args []string, namespace string, sm *state.Manager, cfg config.Config) error { +func executeParallel(out io.Writer, contexts []kubeconfig.Context, args []string, namespace string, sm *state.Manager, cfg config.Config) error { var wg sync.WaitGroup + resultsChan := make(chan contextExecResult, len(contexts)) for _, ctx := range contexts { @@ -277,9 +332,9 @@ func executeParallel(contexts []kubeconfig.Context, args []string, namespace str hasErrors := false for i, result := range results { if i > 0 { - fmt.Println() + fmt.Fprintln(out) } - printResult(result) + printResult(out, result) if result.err != nil { hasErrors = true } @@ -340,39 +395,39 @@ func runCommand(args []string, kubeconfigPath string) (string, error) { return string(output), err } -func printResult(result contextExecResult) { +func printResult(out io.Writer, result contextExecResult) { separator := strings.Repeat("=", 80) contextHeader := fmt.Sprintf("Context: %s", result.contextName) - fmt.Println(separator) - fmt.Println(contextHeader) - fmt.Println(separator) + fmt.Fprintln(out, separator) + fmt.Fprintln(out, contextHeader) + fmt.Fprintln(out, separator) if result.err != nil { red := color.New(color.FgRed).SprintFunc() - fmt.Printf("%s: %v\n", red("ERROR"), result.err) + fmt.Fprintf(out, "%s: %v\n", red("ERROR"), result.err) if result.output != "" { - fmt.Println(result.output) + fmt.Fprintln(out, result.output) } } else { - fmt.Print(result.output) + fmt.Fprint(out, result.output) } } -func showDryRun(contexts []kubeconfig.Context, args []string, namespace string, sm *state.Manager, cfg config.Config) error { +func showDryRun(out io.Writer, contexts []kubeconfig.Context, args []string, namespace string, sm *state.Manager, cfg config.Config) error { yellow := color.New(color.FgYellow).SprintFunc() green := color.New(color.FgGreen).SprintFunc() - fmt.Println("=== DRY RUN ===") - fmt.Println() - fmt.Printf("Command: %s\n", strings.Join(args, " ")) + fmt.Fprintln(out, "=== DRY RUN ===") + fmt.Fprintln(out) + fmt.Fprintf(out, "Command: %s\n", strings.Join(args, " ")) if namespace != "" { - fmt.Printf("Namespace: %s\n", namespace) + fmt.Fprintf(out, "Namespace: %s\n", namespace) } - fmt.Printf("Total contexts: %d\n", len(contexts)) - fmt.Println() + fmt.Fprintf(out, "Total contexts: %d\n", len(contexts)) + fmt.Fprintln(out) - fmt.Println("Contexts to execute against:") + fmt.Fprintln(out, "Contexts to execute against:") for _, ctx := range contexts { locked, err := isContextProtected(sm, ctx.Name, cfg) if err != nil { @@ -391,7 +446,7 @@ func showDryRun(contexts []kubeconfig.Context, args []string, namespace string, } } - fmt.Printf(" %s %s%s\n", status, ctx.Name, statusText) + fmt.Fprintf(out, " %s %s%s\n", status, ctx.Name, statusText) } return nil diff --git a/cmd/exec_test.go b/cmd/exec_test.go index f07a87a..e924e7f 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -1,6 +1,8 @@ package cmd import ( + "bytes" + "errors" "fmt" "os" "path/filepath" @@ -9,6 +11,7 @@ import ( "sync" "testing" + "github.com/spf13/cobra" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" @@ -481,3 +484,273 @@ func stringSlicesEqual(a, b []string) bool { } return true } + +func TestExecOptions_Complete(t *testing.T) { + tests := []struct { + name string + args []string + argsLenAtDash int + expectedPatterns []string + expectedCommand []string + expectError bool + errorContains string + }{ + { + name: "patterns and command separated by --", + args: []string{"prod*", "staging*", "--", "kubectl", "get", "pods"}, + argsLenAtDash: 2, + expectedPatterns: []string{"prod*", "staging*"}, + expectedCommand: []string{"kubectl", "get", "pods"}, + expectError: false, + }, + { + name: "no patterns, only command", + args: []string{"--", "kubectl", "get", "nodes"}, + argsLenAtDash: 0, + expectedPatterns: []string{}, + expectedCommand: []string{"kubectl", "get", "nodes"}, + expectError: false, + }, + { + name: "missing -- separator", + args: []string{"prod*", "kubectl", "get", "pods"}, + argsLenAtDash: -1, + expectError: true, + errorContains: "missing '--' separator", + }, + { + name: "single pattern with command", + args: []string{"dev*", "--", "helm", "list"}, + argsLenAtDash: 1, + expectedPatterns: []string{"dev*"}, + expectedCommand: []string{"helm", "list"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := NewExecOptions() + + // Create a mock command that simulates ArgsLenAtDash behavior + cmd := &cobra.Command{} + // We need to manually call Complete with simulated dash index + // Since cobra.Command.ArgsLenAtDash() is set during parsing, + // we'll test the Complete logic directly by setting fields + + // Simulate the Complete method logic + err := error(nil) + switch tt.argsLenAtDash { + case -1: + err = o.Complete(cmd, tt.args) + if !tt.expectError { + t.Fatalf("Expected no error, got: %v", err) + } + if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got: %v", tt.errorContains, err) + } + return + case 0: + o.Patterns = []string{} + o.CommandArgs = tt.args[1:] // Skip the "--" + default: + o.Patterns = tt.args[:tt.argsLenAtDash] + o.CommandArgs = tt.args[tt.argsLenAtDash+1:] // Skip the "--" + } + + if !stringSlicesEqual(o.Patterns, tt.expectedPatterns) { + t.Errorf("Patterns = %v, want %v", o.Patterns, tt.expectedPatterns) + } + + if !stringSlicesEqual(o.CommandArgs, tt.expectedCommand) { + t.Errorf("CommandArgs = %v, want %v", o.CommandArgs, tt.expectedCommand) + } + }) + } +} + +func TestExecOptions_Validate(t *testing.T) { + tests := []struct { + name string + patterns []string + commandArgs []string + isInteractive bool + expectError bool + errorContains string + }{ + { + name: "valid: patterns and command", + patterns: []string{"prod*"}, + commandArgs: []string{"kubectl", "get", "pods"}, + isInteractive: false, + expectError: false, + }, + { + name: "valid: no patterns in interactive mode", + patterns: []string{}, + commandArgs: []string{"kubectl", "get", "pods"}, + isInteractive: true, + expectError: false, + }, + { + name: "invalid: no command", + patterns: []string{"prod*"}, + commandArgs: []string{}, + isInteractive: false, + expectError: true, + errorContains: "no command provided", + }, + { + name: "invalid: no patterns in non-interactive mode", + patterns: []string{}, + commandArgs: []string{"kubectl", "get", "pods"}, + isInteractive: false, + expectError: true, + errorContains: "patterns are required in non-interactive mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &ExecOptions{ + Patterns: tt.patterns, + CommandArgs: tt.commandArgs, + IsInteractive: func() bool { + return tt.isInteractive + }, + } + + err := o.Validate() + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + return + } + if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got: %v", tt.errorContains, err) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + } + }) + } +} + +func TestExecOptions_Run_DryRun(t *testing.T) { + var buf bytes.Buffer + + o := &ExecOptions{ + Out: &buf, + ErrOut: &buf, + Patterns: []string{"test*"}, + Namespace: "default", + DryRun: true, + CommandArgs: []string{"kubectl", "get", "pods"}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "test-cluster-1", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + {Name: "test-cluster-2", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + StateManager: func() (*state.Manager, error) { + return &state.Manager{}, nil + }, + Config: config.Config{ + Protection: config.Protection{ + Prompt: false, + }, + }, + } + + err := o.Run() + if err != nil { + t.Errorf("Run() returned unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "DRY RUN") { + t.Error("Expected DRY RUN header in output") + } + if !strings.Contains(output, "test-cluster-1") { + t.Error("Expected test-cluster-1 in output") + } + if !strings.Contains(output, "test-cluster-2") { + t.Error("Expected test-cluster-2 in output") + } +} + +func TestExecOptions_Run_NoMatchingContexts(t *testing.T) { + var buf bytes.Buffer + + o := &ExecOptions{ + Out: &buf, + ErrOut: &buf, + Patterns: []string{"nonexistent*"}, + CommandArgs: []string{"kubectl", "get", "pods"}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "test-cluster", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error for no matching contexts, got nil") + } + if !strings.Contains(err.Error(), "no contexts matched") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestExecOptions_Run_ContextLoaderError(t *testing.T) { + var buf bytes.Buffer + + o := &ExecOptions{ + Out: &buf, + ErrOut: &buf, + Patterns: []string{"test*"}, + CommandArgs: []string{"kubectl", "get", "pods"}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return nil, errors.New("failed to load kubeconfig") + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error from context loader, got nil") + } + if !strings.Contains(err.Error(), "error loading contexts") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestExecOptions_Run_InteractiveNoSelection(t *testing.T) { + var buf bytes.Buffer + + o := &ExecOptions{ + Out: &buf, + ErrOut: &buf, + Patterns: []string{}, // No patterns. interactive mode + CommandArgs: []string{"kubectl", "get", "pods"}, + ContextLoader: func() ([]kubeconfig.Context, error) { + return []kubeconfig.Context{ + {Name: "test-cluster", WithPath: kubeconfig.WithPath{FilePath: "/tmp/config"}}, + }, nil + }, + Selector: func(items []string) ([]string, error) { + return []string{}, nil // User selected nothing + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error when no contexts selected, got nil") + } + if !strings.Contains(err.Error(), "no contexts selected") { + t.Errorf("Unexpected error message: %v", err) + } +} diff --git a/cmd/kubeconfig/lint.go b/cmd/kubeconfig/lint.go index 6d89f31..8c774cb 100644 --- a/cmd/kubeconfig/lint.go +++ b/cmd/kubeconfig/lint.go @@ -191,39 +191,49 @@ func validateKubeconfig(cfg *api.Config, result *lintResult) { result.Warnings = append(result.Warnings, "no auth infos defined") } - // Validate each context references valid cluster and auth info - for contextName, context := range cfg.Contexts { - if context.Cluster == "" { + validateContextRefs(cfg, result) + validateClusters(cfg, result) + validateCurrentContext(cfg, result) +} + +func validateContextRefs(cfg *api.Config, result *lintResult) { + for name, ctx := range cfg.Contexts { + if ctx.Cluster == "" { result.Errors = append(result.Errors, - fmt.Sprintf("context %q has no cluster set", contextName)) - } else if _, exists := cfg.Clusters[context.Cluster]; !exists { + fmt.Sprintf("context %q has no cluster set", name)) + } else if _, exists := cfg.Clusters[ctx.Cluster]; !exists { result.Errors = append(result.Errors, - fmt.Sprintf("context %q references non-existent cluster %q", contextName, context.Cluster)) + fmt.Sprintf("context %q references non-existent cluster %q", name, ctx.Cluster)) } - if context.AuthInfo == "" { + if ctx.AuthInfo == "" { result.Warnings = append(result.Warnings, - fmt.Sprintf("context %q has no auth info set", contextName)) - } else if _, exists := cfg.AuthInfos[context.AuthInfo]; !exists { + fmt.Sprintf("context %q has no auth info set", name)) + continue + } + if _, exists := cfg.AuthInfos[ctx.AuthInfo]; !exists { result.Errors = append(result.Errors, - fmt.Sprintf("context %q references non-existent auth info %q", contextName, context.AuthInfo)) + fmt.Sprintf("context %q references non-existent auth info %q", name, ctx.AuthInfo)) } } +} - // Validate clusters - for clusterName, cluster := range cfg.Clusters { +func validateClusters(cfg *api.Config, result *lintResult) { + for name, cluster := range cfg.Clusters { if cluster.Server == "" { result.Errors = append(result.Errors, - fmt.Sprintf("cluster %q has no server URL set", clusterName)) + fmt.Sprintf("cluster %q has no server URL set", name)) } } +} - // Check if current-context is set and valid - if cfg.CurrentContext != "" { - if _, exists := cfg.Contexts[cfg.CurrentContext]; !exists { - result.Errors = append(result.Errors, - fmt.Sprintf("current-context %q does not exist", cfg.CurrentContext)) - } +func validateCurrentContext(cfg *api.Config, result *lintResult) { + if cfg.CurrentContext == "" { + return + } + if _, exists := cfg.Contexts[cfg.CurrentContext]; !exists { + result.Errors = append(result.Errors, + fmt.Sprintf("current-context %q does not exist", cfg.CurrentContext)) } } diff --git a/cmd/kubectl.go b/cmd/kubectl.go index ed7393e..eee6821 100644 --- a/cmd/kubectl.go +++ b/cmd/kubectl.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "io" "os" "os/exec" "regexp" @@ -12,6 +13,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd/api" "github.com/idebeijer/kubert/internal/config" "github.com/idebeijer/kubert/internal/kubert" @@ -19,7 +21,53 @@ import ( "github.com/idebeijer/kubert/internal/util" ) +type KubectlOptions struct { + Out io.Writer + ErrOut io.Writer + + Args []string + + Config config.Config + StateManager func() (*state.Manager, error) + ClientConfigLoader func() (*api.Config, error) + CommandRunner func([]string) error + Prompter func() bool +} + +func NewKubectlOptions() *KubectlOptions { + return &KubectlOptions{ + Out: os.Stdout, + ErrOut: os.Stderr, + + StateManager: state.NewManager, + ClientConfigLoader: func() (*api.Config, error) { + clientConfig, err := util.KubeClientConfig() + if err != nil { + return nil, err + } + return clientConfig, nil + }, + CommandRunner: func(args []string) error { + kubectlCmd := exec.Command("kubectl", args...) + kubectlCmd.Stdin = os.Stdin + kubectlCmd.Stdout = os.Stdout + kubectlCmd.Stderr = os.Stderr + if err := kubectlCmd.Run(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + // Return nil to avoid duplicating the error message given by kubectl + return nil + } + return fmt.Errorf("kubectl error: %w", err) + } + return nil + }, + Prompter: promptUserConfirmation, + } +} + func NewKubectlCommand() *cobra.Command { + o := NewKubectlOptions() + cmd := &cobra.Command{ Use: "kubectl", Short: "Wrapper for kubectl", @@ -37,57 +85,67 @@ func NewKubectlCommand() *cobra.Command { SilenceUsage: true, ValidArgsFunction: validKubectlArgsFunction, RunE: func(cmd *cobra.Command, args []string) error { - cfg := config.Cfg - - sm, err := state.NewManager() - if err != nil { + if err := o.Complete(cmd, args); err != nil { return err } - - clientConfig, err := util.KubeClientConfig() - if err != nil { + if err := o.Validate(); err != nil { return err } + return o.Run() + }, + } - locked, err := isContextProtected(sm, clientConfig.CurrentContext, cfg) - if err != nil { - return err - } + return cmd +} - if locked && isCommandProtected(args, cfg.Protection.Commands) { +func (o *KubectlOptions) Complete(cmd *cobra.Command, args []string) error { + o.Out = cmd.OutOrStdout() + o.ErrOut = cmd.ErrOrStderr() + o.Args = args + o.Config = config.Cfg + return nil +} - if !cfg.Protection.Prompt { - fmt.Printf("You tried to run the protected kubectl command \"%s\" in the protected context \"%s\".\n\n"+ - "The command has not been executed and kubert will exit immediately.\n"+ - "Exiting...\n", args[0], clientConfig.CurrentContext) - return nil - } +func (o *KubectlOptions) Validate() error { + return nil +} - yellow := color.New(color.FgHiYellow).SprintFunc() - fmt.Printf("%s: you tried to run the protected kubectl command \"%s\" in the protected context \"%s\".\n\n", yellow("WARNING"), args[0], clientConfig.CurrentContext) - if !promptUserConfirmation() { - fmt.Println("Exiting...") - return nil - } - fmt.Println() - } +func (o *KubectlOptions) Run() error { + sm, err := o.StateManager() + if err != nil { + return err + } - kubectlCmd := exec.Command("kubectl", args...) - kubectlCmd.Stdin = os.Stdin - kubectlCmd.Stdout = os.Stdout - kubectlCmd.Stderr = os.Stderr - if err := kubectlCmd.Run(); err != nil { - if _, ok := err.(*exec.ExitError); ok { - // Return nil to avoid duplicating the error message given by kubectl - return nil - } - return fmt.Errorf("kubectl error: %w", err) - } + clientConfig, err := o.ClientConfigLoader() + if err != nil { + return err + } + + locked, err := isContextProtected(sm, clientConfig.CurrentContext, o.Config) + if err != nil { + return err + } + + if locked && isCommandProtected(o.Args, o.Config.Protection.Commands) { + // Protection is active and command is protected + if !o.Config.Protection.Prompt { + fmt.Fprintf(o.Out, "You tried to run the protected kubectl command \"%s\" in the protected context \"%s\".\n\n"+ + "The command has not been executed and kubert will exit immediately.\n"+ + "Exiting...\n", o.Args[0], clientConfig.CurrentContext) return nil - }, + } + + yellow := color.New(color.FgHiYellow).SprintFunc() + fmt.Fprintf(o.Out, "%s: you tried to run the protected kubectl command \"%s\" in the protected context \"%s\".\n\n", + yellow("WARNING"), o.Args[0], clientConfig.CurrentContext) + if !o.Prompter() { + fmt.Fprintln(o.Out, "Exiting...") + return nil + } + fmt.Fprintln(o.Out) } - return cmd + return o.CommandRunner(o.Args) } func promptUserConfirmation() bool { @@ -128,12 +186,10 @@ func validKubectlArgsFunction(cmd *cobra.Command, args []string, toComplete stri } func isCommandProtected(args []string, blockedCmds []string) bool { - if len(args) > 0 { - if slices.Contains(blockedCmds, args[0]) { - return true - } + if len(args) == 0 { + return false } - return false + return slices.Contains(blockedCmds, args[0]) } func isContextProtected(sm *state.Manager, context string, cfg config.Config) (bool, error) { diff --git a/cmd/kubectl_test.go b/cmd/kubectl_test.go new file mode 100644 index 0000000..043fb3b --- /dev/null +++ b/cmd/kubectl_test.go @@ -0,0 +1,286 @@ +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd/api" + + "github.com/idebeijer/kubert/internal/config" + "github.com/idebeijer/kubert/internal/state" +) + +func TestKubectlOptions_Complete_SetsConfig(t *testing.T) { + original := config.Cfg + defer func() { config.Cfg = original }() + + config.Cfg = config.Config{ + Protection: config.Protection{ + Commands: []string{"apply", "delete"}, + }, + } + + o := NewKubectlOptions() + cmd := &cobra.Command{} + + if len(o.Config.Protection.Commands) != 0 { + t.Error("Config should not be set before Complete()") + } + + _ = o.Complete(cmd, []string{"get", "pods"}) + + if len(o.Config.Protection.Commands) != 2 { + t.Errorf("Complete() should set Config from config.Cfg, got: %+v", o.Config.Protection.Commands) + } +} + +func TestIsCommandProtected(t *testing.T) { + tests := []struct { + name string + args []string + blockedCmds []string + expected bool + }{ + { + name: "command in blocked list", + args: []string{"apply", "-f", "deployment.yaml"}, + blockedCmds: []string{"apply", "delete", "edit"}, + expected: true, + }, + { + name: "command not in blocked list", + args: []string{"get", "pods"}, + blockedCmds: []string{"apply", "delete", "edit"}, + expected: false, + }, + { + name: "empty args", + args: []string{}, + blockedCmds: []string{"apply", "delete"}, + expected: false, + }, + { + name: "empty blocked list", + args: []string{"apply"}, + blockedCmds: []string{}, + expected: false, + }, + { + name: "case sensitive match", + args: []string{"Apply"}, + blockedCmds: []string{"apply"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isCommandProtected(tt.args, tt.blockedCmds) + if result != tt.expected { + t.Errorf("isCommandProtected(%v, %v) = %v, want %v", + tt.args, tt.blockedCmds, result, tt.expected) + } + }) + } +} + +func TestIsContextProtected(t *testing.T) { + t.Run("context not in state, matches regex", func(t *testing.T) { + setupTestXDGDataHome(t) + + sm, err := state.NewManager() + if err != nil { + t.Fatalf("Failed to create state manager: %v", err) + } + + prodRegex := "^prod.*" + cfg := config.Config{ + Protection: config.Protection{ + Regex: &prodRegex, + }, + } + + // Test context that matches regex + protected, err := isContextProtected(sm, "prod-cluster", cfg) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !protected { + t.Error("Expected prod-cluster to be protected by regex") + } + + // Test context that doesn't match regex + protected, err = isContextProtected(sm, "dev-cluster", cfg) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if protected { + t.Error("Expected dev-cluster to not be protected") + } + }) + + t.Run("invalid regex", func(t *testing.T) { + setupTestXDGDataHome(t) + + sm, err := state.NewManager() + if err != nil { + t.Fatalf("Failed to create state manager: %v", err) + } + + invalidRegex := "[" + cfg := config.Config{ + Protection: config.Protection{ + Regex: &invalidRegex, + }, + } + + _, err = isContextProtected(sm, "any-context", cfg) + if err == nil { + t.Error("Expected error for invalid regex, got nil") + } + if !strings.Contains(err.Error(), "failed to compile regex") { + t.Errorf("Unexpected error message: %v", err) + } + }) +} + +func TestKubectlOptions_Complete(t *testing.T) { + tests := []struct { + name string + args []string + expectedArgs []string + }{ + { + name: "simple command", + args: []string{"get", "pods"}, + expectedArgs: []string{"get", "pods"}, + }, + { + name: "empty args", + args: []string{}, + expectedArgs: []string{}, + }, + { + name: "command with flags", + args: []string{"apply", "-f", "file.yaml", "--dry-run"}, + expectedArgs: []string{"apply", "-f", "file.yaml", "--dry-run"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := NewKubectlOptions() + cmd := &cobra.Command{} + + err := o.Complete(cmd, tt.args) + if err != nil { + t.Errorf("Complete() returned error: %v", err) + } + + if len(o.Args) != len(tt.expectedArgs) { + t.Fatalf("Args length mismatch: got %d, want %d", len(o.Args), len(tt.expectedArgs)) + } + + for i, arg := range o.Args { + if arg != tt.expectedArgs[i] { + t.Errorf("Args[%d] = %s, want %s", i, arg, tt.expectedArgs[i]) + } + } + }) + } +} + +func TestKubectlOptions_Run_Unprotected(t *testing.T) { + var buf bytes.Buffer + + o := &KubectlOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"get", "pods"}, + Config: config.Config{ + Protection: config.Protection{ + Commands: []string{"apply", "delete"}, + }, + }, + StateManager: func() (*state.Manager, error) { + setupTestXDGDataHome(t) + return state.NewManager() + }, + ClientConfigLoader: func() (*api.Config, error) { + return &api.Config{ + CurrentContext: "dev-cluster", + }, nil + }, + CommandRunner: func(args []string) error { + // Mock successful execution + return nil + }, + Prompter: func() bool { + t.Error("Prompter should not be called for unprotected context") + return false + }, + } + + err := o.Run() + if err != nil { + t.Errorf("Run() returned unexpected error: %v", err) + } + + output := buf.String() + if strings.Contains(output, "WARNING") { + t.Error("Should not show warning for unprotected context") + } +} + +func TestKubectlOptions_Run_StateManagerError(t *testing.T) { + var buf bytes.Buffer + + o := &KubectlOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"get", "pods"}, + StateManager: func() (*state.Manager, error) { + return nil, errors.New("state manager initialization failed") + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error from StateManager, got nil") + } + if !strings.Contains(err.Error(), "state manager initialization failed") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestKubectlOptions_Run_ClientConfigError(t *testing.T) { + var buf bytes.Buffer + + o := &KubectlOptions{ + Out: &buf, + ErrOut: &buf, + Args: []string{"get", "pods"}, + StateManager: func() (*state.Manager, error) { + return &state.Manager{}, nil + }, + ClientConfigLoader: func() (*api.Config, error) { + return nil, errors.New("kubeconfig not found") + }, + } + + err := o.Run() + if err == nil { + t.Error("Expected error from ClientConfigLoader, got nil") + } + if !strings.Contains(err.Error(), "kubeconfig not found") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestPromptUserConfirmation(t *testing.T) { + // Skip for now. This function reads from stdin, can't easily test it without mocking stdin. + t.Skip("promptUserConfirmation requires stdin mocking") +} diff --git a/cmd/namespace.go b/cmd/namespace.go index 87429ec..957727d 100644 --- a/cmd/namespace.go +++ b/cmd/namespace.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "io" "os" "slices" "time" @@ -12,12 +13,48 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" + "github.com/idebeijer/kubert/internal/config" "github.com/idebeijer/kubert/internal/fzf" "github.com/idebeijer/kubert/internal/kubert" "github.com/idebeijer/kubert/internal/state" ) +type NamespaceOptions struct { + Out io.Writer + ErrOut io.Writer + + Args []string + + Config config.Config + StateManager func() (*state.Manager, error) + NamespaceLister func(ctx context.Context) ([]string, error) + Selector func([]string) (string, error) + IsInteractive func() bool + NamespaceSwitcher func(sm *state.Manager, namespace string, namespaces []string) error +} + +func NewNamespaceOptions() *NamespaceOptions { + return &NamespaceOptions{ + Out: os.Stdout, + ErrOut: os.Stderr, + + StateManager: state.NewManager, + NamespaceLister: func(ctx context.Context) ([]string, error) { + clientset, err := createKubernetesClient() + if err != nil { + return nil, err + } + return listNamespaces(ctx, clientset) + }, + Selector: fzf.Select, + IsInteractive: fzf.IsInteractive, + NamespaceSwitcher: switchNamespace, + } +} + func NewNamespaceCommand() *cobra.Command { + o := NewNamespaceOptions() + cmd := &cobra.Command{ Use: "ns", Short: "Switch to a different namespace", @@ -29,115 +66,126 @@ func NewNamespaceCommand() *cobra.Command { SilenceUsage: true, ValidArgsFunction: validNamespaceArgsFunction, RunE: func(cmd *cobra.Command, args []string) error { - return runNamespaceCommand(args) + if err := o.Complete(cmd, args); err != nil { + return err + } + if err := o.Validate(); err != nil { + return err + } + return o.Run() }, } return cmd } -func runNamespaceCommand(args []string) error { +func (o *NamespaceOptions) Complete(cmd *cobra.Command, args []string) error { + o.Out = cmd.OutOrStdout() + o.ErrOut = cmd.ErrOrStderr() + o.Args = args + o.Config = config.Cfg + return nil +} + +func (o *NamespaceOptions) Validate() error { + return nil +} + +func (o *NamespaceOptions) Run() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - sm, err := state.NewManager() + namespaces, err := o.NamespaceLister(ctx) if err != nil { - return err + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timeout listing namespaces: cluster may be unreachable") + } + return fmt.Errorf("error listing namespaces: %w", err) } - clientset, err := createKubernetesClient() + namespace, err := o.selectNamespace(namespaces) if err != nil { return err } + if namespace == "" { + return nil + } - namespaces, err := listNamespaces(ctx, clientset) + sm, err := o.StateManager() if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("timeout listing namespaces: cluster may be unreachable") - } - return err + return fmt.Errorf("error creating state manager: %w", err) } - namespace, err := selectNamespace(args, namespaces) - if err != nil { - return err + return o.NamespaceSwitcher(sm, namespace, namespaces) +} + +func (o *NamespaceOptions) selectNamespace(namespaces []string) (string, error) { + if len(o.Args) > 0 { + return o.Args[0], nil } - if err := switchNamespace(sm, namespace, namespaces); err != nil { - return err + if !o.IsInteractive() { + for _, name := range namespaces { + fmt.Fprintln(o.Out, name) + } + return "", nil } - return nil + return o.Selector(namespaces) } -// createKubernetesClient creates a Kubernetes client from the kubeconfig func createKubernetesClient() (*kubernetes.Clientset, error) { loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() configOverrides := &clientcmd.ConfigOverrides{} kubeconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - config, err := kubeconfig.ClientConfig() + cfg, err := kubeconfig.ClientConfig() if err != nil { return nil, err } - return kubernetes.NewForConfig(config) + return kubernetes.NewForConfig(cfg) } -// listNamespaces lists all namespaces in the Kubernetes cluster -func listNamespaces(ctx context.Context, clientset *kubernetes.Clientset) ([]string, error) { +func listNamespaces(ctx context.Context, clientset kubernetes.Interface) ([]string, error) { namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) if err != nil { return nil, err } - var namespaceNames []string + names := make([]string, 0, len(namespaces.Items)) for _, ns := range namespaces.Items { - namespaceNames = append(namespaceNames, ns.Name) - } - return namespaceNames, nil -} - -func selectNamespace(args []string, namespaces []string) (string, error) { - if len(args) > 0 { - return args[0], nil - } - if !fzf.IsInteractiveShell() { - printNamespaces(namespaces) - return "", nil - } - return fzf.Select(namespaces) -} - -func printNamespaces(contextNames []string) { - for _, name := range contextNames { - fmt.Println(name) + names = append(names, ns.Name) } + return names, nil } func switchNamespace(sm *state.Manager, namespace string, namespaces []string) error { - namespaceExists := slices.Contains(namespaces, namespace) - if !namespaceExists { - return fmt.Errorf("namespace \"%s\" does not exist", namespace) + if !slices.Contains(namespaces, namespace) { + return fmt.Errorf("namespace %q does not exist", namespace) } kubeconfigPath := os.Getenv("KUBECONFIG") - config, err := clientcmd.LoadFromFile(kubeconfigPath) + cfg, err := clientcmd.LoadFromFile(kubeconfigPath) if err != nil { return err } - config.Contexts[config.CurrentContext].Namespace = namespace - - if err := clientcmd.WriteToFile(*config, kubeconfigPath); err != nil { - return fmt.Errorf("failed to write kubeconfig: %w", err) + if cfg.Contexts == nil { + return fmt.Errorf("no contexts found in kubeconfig") + } + ctx, exists := cfg.Contexts[cfg.CurrentContext] + if !exists || ctx == nil { + return fmt.Errorf("current context %q not found in kubeconfig", cfg.CurrentContext) } - if err := sm.SetLastNamespaceWithContextCreation(config.CurrentContext, namespace); err != nil { - return err + ctx.Namespace = namespace + + if err := clientcmd.WriteToFile(*cfg, kubeconfigPath); err != nil { + return fmt.Errorf("failed to write kubeconfig: %w", err) } - return nil + return sm.SetLastNamespaceWithContextCreation(cfg.CurrentContext, namespace) } func validNamespaceArgsFunction(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/cmd/namespace_test.go b/cmd/namespace_test.go new file mode 100644 index 0000000..d9e3037 --- /dev/null +++ b/cmd/namespace_test.go @@ -0,0 +1,348 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" + + "github.com/idebeijer/kubert/internal/config" + "github.com/idebeijer/kubert/internal/state" +) + +func TestNamespaceOptions_Complete_SetsConfig(t *testing.T) { + original := config.Cfg + defer func() { config.Cfg = original }() + + config.Cfg = config.Config{ + KubeconfigPaths: config.KubeconfigPaths{ + Include: []string{"/some/path"}, + }, + } + + o := NewNamespaceOptions() + cmd := &cobra.Command{} + + if len(o.Config.KubeconfigPaths.Include) != 0 { + t.Error("Config should not be set before Complete()") + } + + if err := o.Complete(cmd, nil); err != nil { + t.Fatalf("Complete() error = %v", err) + } + + if len(o.Config.KubeconfigPaths.Include) != 1 || o.Config.KubeconfigPaths.Include[0] != "/some/path" { + t.Errorf("Config was not set from config.Cfg in Complete(), got %v", o.Config.KubeconfigPaths.Include) + } +} + +func TestNamespaceOptions_Run_WithArg(t *testing.T) { + setupTestXDGDataHome(t) + + namespaces := []string{"default", "kube-system", "kube-public"} + switchCalled := false + var switchedNamespace string + + o := &NamespaceOptions{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Args: []string{"kube-system"}, + NamespaceLister: func(ctx context.Context) ([]string, error) { + return namespaces, nil + }, + StateManager: state.NewManager, + NamespaceSwitcher: func(sm *state.Manager, namespace string, ns []string) error { + switchCalled = true + switchedNamespace = namespace + return nil + }, + IsInteractive: func() bool { return true }, + } + + if err := o.Run(); err != nil { + t.Fatalf("Run() unexpected error: %v", err) + } + + if !switchCalled { + t.Error("NamespaceSwitcher was not called") + } + if switchedNamespace != "kube-system" { + t.Errorf("switched to %q, want %q", switchedNamespace, "kube-system") + } +} + +func TestNamespaceOptions_Run_Interactive(t *testing.T) { + setupTestXDGDataHome(t) + + namespaces := []string{"default", "kube-system", "monitoring"} + switchCalled := false + var switchedNamespace string + + o := &NamespaceOptions{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Args: nil, + NamespaceLister: func(ctx context.Context) ([]string, error) { + return namespaces, nil + }, + IsInteractive: func() bool { return true }, + Selector: func(items []string) (string, error) { + return "monitoring", nil + }, + StateManager: state.NewManager, + NamespaceSwitcher: func(sm *state.Manager, namespace string, ns []string) error { + switchCalled = true + switchedNamespace = namespace + return nil + }, + } + + if err := o.Run(); err != nil { + t.Fatalf("Run() unexpected error: %v", err) + } + + if !switchCalled { + t.Error("NamespaceSwitcher was not called") + } + if switchedNamespace != "monitoring" { + t.Errorf("switched to %q, want %q", switchedNamespace, "monitoring") + } +} + +func TestNamespaceOptions_Run_NonInteractive_PrintsNamespaces(t *testing.T) { + namespaces := []string{"default", "kube-system", "production"} + var out bytes.Buffer + + o := &NamespaceOptions{ + Out: &out, + ErrOut: &bytes.Buffer{}, + Args: nil, + NamespaceLister: func(ctx context.Context) ([]string, error) { + return namespaces, nil + }, + IsInteractive: func() bool { return false }, + StateManager: state.NewManager, + NamespaceSwitcher: func(sm *state.Manager, namespace string, ns []string) error { + t.Error("NamespaceSwitcher should not be called in non-interactive mode without args") + return nil + }, + } + + if err := o.Run(); err != nil { + t.Fatalf("Run() unexpected error: %v", err) + } + + output := out.String() + for _, ns := range namespaces { + if !strings.Contains(output, ns) { + t.Errorf("output missing namespace %q, got: %s", ns, output) + } + } +} + +func TestNamespaceOptions_Run_ListerError(t *testing.T) { + o := &NamespaceOptions{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + NamespaceLister: func(ctx context.Context) ([]string, error) { + return nil, fmt.Errorf("connection refused") + }, + } + + err := o.Run() + if err == nil { + t.Fatal("Run() expected error, got nil") + } + if !strings.Contains(err.Error(), "connection refused") { + t.Errorf("error = %v, want to contain 'connection refused'", err) + } +} + +func TestNamespaceOptions_Run_SelectorError(t *testing.T) { + o := &NamespaceOptions{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Args: nil, + NamespaceLister: func(ctx context.Context) ([]string, error) { + return []string{"default"}, nil + }, + IsInteractive: func() bool { return true }, + Selector: func(items []string) (string, error) { + return "", fmt.Errorf("fzf not found") + }, + } + + err := o.Run() + if err == nil { + t.Fatal("Run() expected error, got nil") + } + if !strings.Contains(err.Error(), "fzf not found") { + t.Errorf("error = %v, want to contain 'fzf not found'", err) + } +} + +func TestNamespaceOptions_Run_StateManagerError(t *testing.T) { + o := &NamespaceOptions{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Args: []string{"default"}, + NamespaceLister: func(ctx context.Context) ([]string, error) { + return []string{"default", "kube-system"}, nil + }, + StateManager: func() (*state.Manager, error) { + return nil, fmt.Errorf("state dir not writable") + }, + } + + err := o.Run() + if err == nil { + t.Fatal("Run() expected error, got nil") + } + if !strings.Contains(err.Error(), "state dir not writable") { + t.Errorf("error = %v, want to contain 'state dir not writable'", err) + } +} + +func TestNamespaceOptions_Run_SwitcherError(t *testing.T) { + setupTestXDGDataHome(t) + + o := &NamespaceOptions{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Args: []string{"default"}, + NamespaceLister: func(ctx context.Context) ([]string, error) { + return []string{"default"}, nil + }, + StateManager: state.NewManager, + NamespaceSwitcher: func(sm *state.Manager, namespace string, ns []string) error { + return fmt.Errorf("write failed") + }, + } + + err := o.Run() + if err == nil { + t.Fatal("Run() expected error, got nil") + } + if !strings.Contains(err.Error(), "write failed") { + t.Errorf("error = %v, want to contain 'write failed'", err) + } +} + +func TestSwitchNamespace(t *testing.T) { + setupTestXDGDataHome(t) + + // Create a kubeconfig file with a context + kubeconfigPath := filepath.Join(t.TempDir(), "kubeconfig") + kubecfg := api.NewConfig() + kubecfg.CurrentContext = "test-context" + kubecfg.Contexts["test-context"] = &api.Context{ + Cluster: "test-cluster", + AuthInfo: "test-user", + Namespace: "default", + } + kubecfg.Clusters["test-cluster"] = &api.Cluster{Server: "https://localhost:6443"} + kubecfg.AuthInfos["test-user"] = &api.AuthInfo{Token: "test-token"} + + if err := clientcmd.WriteToFile(*kubecfg, kubeconfigPath); err != nil { + t.Fatalf("failed to write kubeconfig: %v", err) + } + + // Set KUBECONFIG env var for switchNamespace + t.Setenv("KUBECONFIG", kubeconfigPath) + + sm, err := state.NewManager() + if err != nil { + t.Fatalf("failed to create state manager: %v", err) + } + + namespaces := []string{"default", "kube-system", "production"} + + t.Run("switch to existing namespace", func(t *testing.T) { + if err := switchNamespace(sm, "kube-system", namespaces); err != nil { + t.Fatalf("switchNamespace() unexpected error: %v", err) + } + + // Verify the kubeconfig was updated + cfg, err := clientcmd.LoadFromFile(kubeconfigPath) + if err != nil { + t.Fatalf("failed to load kubeconfig: %v", err) + } + if cfg.Contexts["test-context"].Namespace != "kube-system" { + t.Errorf("namespace = %q, want %q", cfg.Contexts["test-context"].Namespace, "kube-system") + } + }) + + t.Run("switch to non-existing namespace", func(t *testing.T) { + err := switchNamespace(sm, "nonexistent", namespaces) + if err == nil { + t.Fatal("switchNamespace() expected error, got nil") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("error = %v, want to contain 'does not exist'", err) + } + }) +} + +func TestSwitchNamespace_InvalidKubeconfig(t *testing.T) { + setupTestXDGDataHome(t) + + t.Setenv("KUBECONFIG", "/nonexistent/path/kubeconfig") + + sm, err := state.NewManager() + if err != nil { + t.Fatalf("failed to create state manager: %v", err) + } + + err = switchNamespace(sm, "default", []string{"default"}) + if err == nil { + t.Fatal("switchNamespace() expected error for invalid kubeconfig path") + } +} + +func TestListNamespaces(t *testing.T) { + t.Run("returns namespace names", func(t *testing.T) { + clientset := fake.NewClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kube-system"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "production"}}, + ) + + names, err := listNamespaces(context.Background(), clientset) + if err != nil { + t.Fatalf("listNamespaces() error = %v", err) + } + + if len(names) != 3 { + t.Fatalf("expected 3 namespaces, got %d: %v", len(names), names) + } + + expected := map[string]bool{"default": true, "kube-system": true, "production": true} + for _, name := range names { + if !expected[name] { + t.Errorf("unexpected namespace %q", name) + } + } + }) + + t.Run("returns empty slice for no namespaces", func(t *testing.T) { + clientset := fake.NewClientset() + + names, err := listNamespaces(context.Background(), clientset) + if err != nil { + t.Fatalf("listNamespaces() error = %v", err) + } + + if len(names) != 0 { + t.Errorf("expected 0 namespaces, got %d: %v", len(names), names) + } + }) +} diff --git a/cmd/protection/info.go b/cmd/protection/info.go index 10b6851..f2f4dcc 100644 --- a/cmd/protection/info.go +++ b/cmd/protection/info.go @@ -51,80 +51,86 @@ func runInfo(cmd *cobra.Command, args []string) error { func protectionStatus(sm *state.Manager, context string, cfg config.Config, output string) error { contextInfo, _ := sm.ContextInfo(context) - - yellow := color.New(color.FgHiYellow).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - red := color.New(color.FgRed).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - isShort := output == "short" if !isShort { + cyan := color.New(color.FgCyan).SprintFunc() fmt.Printf("Context: %s\n\n", cyan(context)) } - // Check for active lift - if contextInfo.ProtectedUntil != nil { - if time.Now().Before(*contextInfo.ProtectedUntil) { - if isShort { - fmt.Println("lifted") - } else { - fmt.Printf("%s Status: %s until %s\n", yellow("⏳"), yellow("LIFTED"), contextInfo.ProtectedUntil.Format(time.RFC3339)) - fmt.Printf(" Remaining: %s\n", time.Until(*contextInfo.ProtectedUntil).Round(time.Second)) - } - return nil - } + if hasActiveLift(contextInfo) { + printLiftedStatus(contextInfo, isShort) + return nil } - // Check explicit protection setting if contextInfo.Protected != nil { - if *contextInfo.Protected { - if isShort { - fmt.Println("protected") - } else { - fmt.Printf("%s Status: %s (explicit override)\n", red("🔒"), red("PROTECTED")) - } - } else { - if isShort { - fmt.Println("unprotected") - } else { - fmt.Printf("%s Status: %s (explicit override)\n", green("🔓"), green("UNPROTECTED")) - } - } - if !isShort { - fmt.Println(" Use 'kubert protection remove' to revert to default") - } + printExplicitOverride(*contextInfo.Protected, isShort) return nil } - // Check regex-based default if cfg.Protection.Regex != nil { - regex, err := regexp.Compile(*cfg.Protection.Regex) - if err != nil { - return fmt.Errorf("failed to compile regex: %w", err) - } + return printRegexStatus(context, *cfg.Protection.Regex, isShort) + } - if regex.MatchString(context) { - if isShort { - fmt.Println("protected") - } else { - fmt.Printf("%s Status: %s (matches default regex)\n", red("🔒"), red("PROTECTED")) - fmt.Printf(" Regex: %s\n", *cfg.Protection.Regex) - } - } else { - if isShort { - fmt.Println("unprotected") - } else { - fmt.Printf("%s Status: %s (does not match default regex)\n", green("🔓"), green("UNPROTECTED")) - } - } - return nil + printStatus(true, "no protection configured", isShort) + return nil +} + +func hasActiveLift(info state.ContextInfo) bool { + return info.ProtectedUntil != nil && time.Now().Before(*info.ProtectedUntil) +} + +func printLiftedStatus(info state.ContextInfo, short bool) { + if short { + fmt.Println("lifted") + return } - if isShort { - fmt.Println("unprotected") + yellow := color.New(color.FgHiYellow).SprintFunc() + fmt.Printf("%s Status: %s until %s\n", yellow("⏳"), yellow("LIFTED"), info.ProtectedUntil.Format(time.RFC3339)) + fmt.Printf(" Remaining: %s\n", time.Until(*info.ProtectedUntil).Round(time.Second)) +} + +func printExplicitOverride(protected bool, short bool) { + printStatus(!protected, "explicit override", short) + if !short { + fmt.Println(" Use 'kubert protection remove' to revert to default") + } +} + +func printRegexStatus(context, pattern string, short bool) error { + regex, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("failed to compile regex: %w", err) + } + + if regex.MatchString(context) { + printStatus(false, "matches default regex", short) + if !short { + fmt.Printf(" Regex: %s\n", pattern) + } } else { - fmt.Printf("%s Status: %s (no protection configured)\n", green("🔓"), green("UNPROTECTED")) + printStatus(true, "does not match default regex", short) } return nil } + +func printStatus(unprotected bool, reason string, short bool) { + green := color.New(color.FgGreen).SprintFunc() + red := color.New(color.FgRed).SprintFunc() + + if unprotected { + if short { + fmt.Println("unprotected") + return + } + fmt.Printf("%s Status: %s (%s)\n", green("🔓"), green("UNPROTECTED"), reason) + return + } + + if short { + fmt.Println("protected") + return + } + fmt.Printf("%s Status: %s (%s)\n", red("🔒"), red("PROTECTED"), reason) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dcf2ded --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,140 @@ +package config + +import ( + "strings" + "testing" +) + +func TestSetDefaults(t *testing.T) { + // DefaultCfg is populated via init() which calls setDefaults() + + t.Run("kubeconfig include defaults", func(t *testing.T) { + include := DefaultCfg.KubeconfigPaths.Include + if len(include) != 3 { + t.Fatalf("expected 3 default include paths, got %d: %v", len(include), include) + } + + expected := []string{ + "~/.kube/config", + "~/.kube/*.yml", + "~/.kube/*.yaml", + } + for i, want := range expected { + if include[i] != want { + t.Errorf("include[%d] = %q, want %q", i, include[i], want) + } + } + }) + + t.Run("kubeconfig exclude defaults empty", func(t *testing.T) { + if len(DefaultCfg.KubeconfigPaths.Exclude) != 0 { + t.Errorf("expected empty exclude paths, got %v", DefaultCfg.KubeconfigPaths.Exclude) + } + }) + + t.Run("interactive defaults to true", func(t *testing.T) { + if !DefaultCfg.Interactive { + t.Error("expected Interactive to default to true") + } + }) + + t.Run("protection regex defaults to nil", func(t *testing.T) { + if DefaultCfg.Protection.Regex != nil { + t.Errorf("expected Protection.Regex to be nil, got %q", *DefaultCfg.Protection.Regex) + } + }) + + t.Run("protection commands defaults", func(t *testing.T) { + cmds := DefaultCfg.Protection.Commands + expectedCmds := []string{ + "delete", "edit", "exec", "drain", "scale", + "autoscale", "replace", "apply", "patch", "set", + } + + if len(cmds) != len(expectedCmds) { + t.Fatalf("expected %d default protection commands, got %d: %v", len(expectedCmds), len(cmds), cmds) + } + + for i, want := range expectedCmds { + if cmds[i] != want { + t.Errorf("commands[%d] = %q, want %q", i, cmds[i], want) + } + } + }) + + t.Run("protection prompt defaults to true", func(t *testing.T) { + if !DefaultCfg.Protection.Prompt { + t.Error("expected Protection.Prompt to default to true") + } + }) + + t.Run("hooks default to empty", func(t *testing.T) { + if DefaultCfg.Hooks.PreShell != "" { + t.Errorf("expected Hooks.PreShell to be empty, got %q", DefaultCfg.Hooks.PreShell) + } + if DefaultCfg.Hooks.PostShell != "" { + t.Errorf("expected Hooks.PostShell to be empty, got %q", DefaultCfg.Hooks.PostShell) + } + }) + + t.Run("fzf opts default to empty", func(t *testing.T) { + if DefaultCfg.Fzf.Opts != "" { + t.Errorf("expected Fzf.Opts to be empty, got %q", DefaultCfg.Fzf.Opts) + } + }) +} + +func TestGenerateDefaultYAML(t *testing.T) { + output, err := GenerateDefaultYAML() + if err != nil { + t.Fatalf("GenerateDefaultYAML() error = %v", err) + } + + if output == "" { + t.Fatal("GenerateDefaultYAML() returned empty string") + } + + t.Run("contains kubeconfig include paths", func(t *testing.T) { + for _, path := range []string{"~/.kube/config", "~/.kube/*.yml", "~/.kube/*.yaml"} { + if !strings.Contains(output, path) { + t.Errorf("YAML missing include path %q:\n%s", path, output) + } + } + }) + + t.Run("contains protection commands", func(t *testing.T) { + for _, cmd := range []string{"delete", "apply", "exec", "drain"} { + if !strings.Contains(output, cmd) { + t.Errorf("YAML missing protection command %q:\n%s", cmd, output) + } + } + }) + + t.Run("contains interactive setting", func(t *testing.T) { + if !strings.Contains(output, "interactive") { + t.Errorf("YAML missing 'interactive' key:\n%s", output) + } + }) + + t.Run("contains expected sections", func(t *testing.T) { + for _, section := range []string{"kubeconfigs:", "protection:", "hooks:", "fzf:"} { + if !strings.Contains(output, section) { + t.Errorf("YAML missing %q section:\n%s", section, output) + } + } + }) +} + +func TestDefaultCfg_NotAffectedByGlobalCfg(t *testing.T) { + originalInclude := make([]string, len(DefaultCfg.KubeconfigPaths.Include)) + copy(originalInclude, DefaultCfg.KubeconfigPaths.Include) + + Cfg.KubeconfigPaths.Include = []string{"/custom/path"} + + if len(DefaultCfg.KubeconfigPaths.Include) != len(originalInclude) { + t.Error("modifying Cfg affected DefaultCfg") + } + + // Restore + Cfg.KubeconfigPaths.Include = nil +} diff --git a/internal/fzf/fzf.go b/internal/fzf/fzf.go index a20068c..098d21f 100644 --- a/internal/fzf/fzf.go +++ b/internal/fzf/fzf.go @@ -9,7 +9,7 @@ import ( "github.com/idebeijer/kubert/internal/config" ) -func IsInteractiveShell() bool { +func IsInteractive() bool { if !config.Cfg.Interactive { return false } diff --git a/internal/fzf/fzf_test.go b/internal/fzf/fzf_test.go new file mode 100644 index 0000000..b78e6b5 --- /dev/null +++ b/internal/fzf/fzf_test.go @@ -0,0 +1,142 @@ +package fzf + +import ( + "slices" + "testing" + + "github.com/idebeijer/kubert/internal/config" +) + +func TestParseArgs(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "empty string", + input: "", + want: nil, + }, + { + name: "single arg", + input: "--ansi", + want: []string{"--ansi"}, + }, + { + name: "multiple args", + input: "--ansi --multi --height=50%", + want: []string{"--ansi", "--multi", "--height=50%"}, + }, + { + name: "double quoted string", + input: "--preview \"cat {}\"", + want: []string{"--preview", "cat {}"}, + }, + { + name: "single quoted string", + input: "--preview 'cat {}'", + want: []string{"--preview", "cat {}"}, + }, + { + name: "multiple spaces between args", + input: "--ansi --multi", + want: []string{"--ansi", "--multi"}, + }, + { + name: "quoted string with spaces", + input: "--header \"select a context\"", + want: []string{"--header", "select a context"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseArgs(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("parseArgs(%q) = %v (len %d), want %v (len %d)", tt.input, got, len(got), tt.want, len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseArgs(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestBuildFzfArgs(t *testing.T) { + tests := []struct { + name string + fzfOpts string + multiSelect bool + wantContains []string + wantAbsent []string + }{ + { + name: "default single select", + fzfOpts: "", + multiSelect: false, + wantContains: []string{"--ansi"}, + wantAbsent: []string{"--multi"}, + }, + { + name: "multi select adds --multi", + fzfOpts: "", + multiSelect: true, + wantContains: []string{"--ansi", "--multi"}, + }, + { + name: "custom opts included", + fzfOpts: "--height=50% --reverse", + multiSelect: false, + wantContains: []string{"--ansi", "--height=50%", "--reverse"}, + wantAbsent: []string{"--multi"}, + }, + { + name: "custom opts with --ansi not duplicated", + fzfOpts: "--ansi --height=50%", + multiSelect: false, + wantContains: []string{"--ansi", "--height=50%"}, + }, + { + name: "custom opts with --multi not duplicated", + fzfOpts: "--multi --height=50%", + multiSelect: true, + wantContains: []string{"--multi", "--height=50%", "--ansi"}, + }, + { + name: "custom opts with -m shorthand not duplicated", + fzfOpts: "-m --height=50%", + multiSelect: true, + wantContains: []string{"-m", "--height=50%", "--ansi"}, + wantAbsent: []string{"--multi"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := config.Cfg + defer func() { config.Cfg = original }() + + config.Cfg.Fzf.Opts = tt.fzfOpts + + got := buildFzfArgs(tt.multiSelect) + + for _, want := range tt.wantContains { + found := slices.Contains(got, want) + if !found { + t.Errorf("buildFzfArgs() = %v, missing expected %q", got, want) + } + } + + for _, absent := range tt.wantAbsent { + for _, arg := range got { + if arg == absent { + t.Errorf("buildFzfArgs() = %v, should not contain %q", got, absent) + } + } + } + }) + } +}