diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ae1013f..4b774f7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,4 +17,5 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: - version: v1.54 \ No newline at end of file + version: v1.54 + args: --timeout=10m \ No newline at end of file diff --git a/pkg/cmd/iexec.go b/pkg/cmd/iexec.go index 83f1fe9..566b7f0 100644 --- a/pkg/cmd/iexec.go +++ b/pkg/cmd/iexec.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "k8s.io/client-go/kubernetes" "github.com/pkg/errors" @@ -170,8 +171,21 @@ func (o *IExecOptions) Run(args []string) error { RemoteCmd: o.remoteCmd, } - r := iexec.NewIexec(o.clientCfg, config) + // 1) Create a real Kubernetes clientset from o.clientCfg. + clientset, err := kubernetes.NewForConfig(o.clientCfg) + if err != nil { + log.Fatalf("Failed to create kubernetes client: %v", err) + } + + // 2) Create your real implementations for the interfaces. + ui := iexec.NewPromptUITerminal() // TerminalUI + k8s := iexec.NewRealK8sClient(clientset) // K8sClient + execRunner := iexec.NewSpdyExecRunner() // ExecRunner + + // 3) Pass them to NewIexec along with your config. + r := iexec.NewIexec(o.clientCfg, config, ui, k8s, execRunner) + // 4) Run! if err := r.Do(); err != nil { log.Fatal(err) } diff --git a/pkg/iexec/exec_runner_spdy.go b/pkg/iexec/exec_runner_spdy.go new file mode 100644 index 0000000..b6e2667 --- /dev/null +++ b/pkg/iexec/exec_runner_spdy.go @@ -0,0 +1,95 @@ +package iexec + +import ( + "context" + "fmt" + "os" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "golang.org/x/term" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" +) + +type SpdyExecRunner struct{} + +func NewSpdyExecRunner() *SpdyExecRunner { + return &SpdyExecRunner{} +} + +// sizeQueue is a buffered channel for TerminalSize +type sizeQueue chan remotecommand.TerminalSize + +// Next implements the TerminalSizeQueue interface +func (s sizeQueue) Next() *remotecommand.TerminalSize { + size, ok := <-s + if !ok { + return nil + } + return &size +} + +func (r *SpdyExecRunner) RunExec(restCfg *rest.Config, pod corev1.Pod, container corev1.Container, cmd []string) error { + client, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return errors.Wrap(err, "unable to create kubernetes client") + } + + req := client.CoreV1().RESTClient(). + Post(). + Namespace(pod.Namespace). + Resource("pods"). + Name(pod.Name). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: container.Name, + Command: cmd, + Stdin: true, + Stdout: true, + Stderr: true, + TTY: true, + }, scheme.ParameterCodec) + + log.WithField("URL", req.URL()).Debug("SPDY Exec request") + + executor, err := remotecommand.NewSPDYExecutor(restCfg, "POST", req.URL()) + if err != nil { + return errors.Wrap(err, "unable to create SPDY executor") + } + + // Put terminal into raw mode + fd := int(os.Stdin.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + return errors.Wrap(err, "unable to init terminal raw mode") + } + defer func() { + _ = term.Restore(fd, oldState) + }() + + // Get terminal size + w, h, err := term.GetSize(fd) + if err != nil { + log.Errorf("Error getting terminal size: %v", err) + } + sz := make(sizeQueue, 1) + sz <- remotecommand.TerminalSize{Width: uint16(w), Height: uint16(h)} + + // Stream + if err := executor.StreamWithContext(context.Background(), remotecommand.StreamOptions{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + Tty: true, + TerminalSizeQueue: sz, + }); err != nil { + return errors.Wrap(err, "SPDY Stream failed") + } + + fmt.Println() + return nil +} diff --git a/pkg/iexec/iexec.go b/pkg/iexec/iexec.go index 7f2ce38..b249ab9 100644 --- a/pkg/iexec/iexec.go +++ b/pkg/iexec/iexec.go @@ -1,27 +1,12 @@ package iexec import ( - "context" - "fmt" - "os" - "github.com/pkg/errors" - - "github.com/manifoldco/promptui" log "github.com/sirupsen/logrus" - "golang.org/x/term" - corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/kubernetes/scheme" - - // auth needed for proxy. - _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/remotecommand" ) -type sizeQueue chan remotecommand.TerminalSize - +// Iexecer remains for backward compatibility if needed type Iexecer interface { Do() error } @@ -36,12 +21,18 @@ type Config struct { RemoteCmd []string } +// Iexec is our main struct, now with interfaces for UI, K8s fetching, and exec type Iexec struct { restConfig *rest.Config config *Config + + ui TerminalUI + k8sClient K8sClient + exec ExecRunner } -func NewIexec(restConfig *rest.Config, config *Config) *Iexec { +// NewIexec constructs the Iexec with your chosen implementations +func NewIexec(restConfig *rest.Config, config *Config, ui TerminalUI, k8s K8sClient, exec ExecRunner) *Iexec { log.WithFields(log.Fields{ "containerFilter": config.ContainerFilter, "remote command": config.RemoteCmd, @@ -52,90 +43,52 @@ func NewIexec(restConfig *rest.Config, config *Config) *Iexec { "LabelSelector": config.LabelSelector, }).Debug("iexec config values...") - return &Iexec{restConfig: restConfig, config: config} -} - -func selectPod(pods []corev1.Pod, config Config) (corev1.Pod, error) { - if len(pods) == 1 { - return pods[0], nil - } - - templates := podTemplate - - if config.Naked { - templates = podTemplateNaked - } - - podsPrompt := promptui.Select{ - Label: "Select Pod", - Items: pods, - Templates: templates, - IsVimMode: config.VimMode, - } - - i, _, err := podsPrompt.Run() - if err != nil { - return pods[i], errors.Wrap(err, "unable to run prompt") - } - - return pods[i], nil -} - -func containerPrompt(containers []corev1.Container, config Config) (corev1.Container, error) { - if len(containers) == 1 { - return containers[0], nil - } - - templates := containerTemplates - - if config.Naked { - templates = containerTemplatesNaked - } - - containersPrompt := promptui.Select{ - Label: "Select Container", - Items: containers, - Templates: templates, - IsVimMode: config.VimMode, + return &Iexec{ + restConfig: restConfig, + config: config, + ui: ui, + k8sClient: k8s, + exec: exec, } - - i, _, err := containersPrompt.Run() - if err != nil { - return containers[i], errors.Wrap(err, "unable to get prompt") - } - - return containers[i], nil } +// Do is our main plugin flow, now purely orchestrating the calls func (r *Iexec) Do() error { - client, err := kubernetes.NewForConfig(r.restConfig) + // 1. Fetch pods + podsList, err := r.k8sClient.FetchAllPods(r.config.Namespace, r.config.LabelSelector) if err != nil { - return errors.Wrap(err, "unable to get kubernetes for config") + return errors.Wrap(err, "fetchAllPods failed") } - pods, err := getAllPods(client, r.config.Namespace, r.config.LabelSelector) + // 2. Match/Filter pods + filtered, err := r.k8sClient.MatchPods(podsList, *r.config) if err != nil { - return err + return errors.Wrap(err, "matchPods failed") } - - filteredPods, err := r.matchPods(pods) - if err != nil { - return err + pods := filtered.Items + if len(pods) == 0 { + return errors.New("no matching pods found") } - pod, err := selectPod(filteredPods.Items, *r.config) + // 3. Prompt user to select a pod + pod, err := r.ui.SelectPod(pods, *r.config) if err != nil { - return err + return errors.Wrap(err, "SelectPod failed") } - containers, err := matchContainers(pod, *r.config) + // 4. Match containers + containers, err := r.k8sClient.MatchContainers(pod, *r.config) if err != nil { - return err + return errors.Wrap(err, "matchContainers failed") + } + if len(containers) == 0 { + return errors.New("no matching containers found") } - container, err := containerPrompt(containers, *r.config) + // 5. Prompt user to select a container + container, err := r.ui.SelectContainer(containers, *r.config) if err != nil { - return err + return errors.Wrap(err, "SelectContainer failed") } log.WithFields(log.Fields{ @@ -144,83 +97,10 @@ func (r *Iexec) Do() error { "namespace": r.config.Namespace, }).Info("Exec to pod...") - err = exec(r.restConfig, pod, container, r.config.RemoteCmd) - if err != nil { - return err - } - return nil -} - -func exec(restCfg *rest.Config, pod corev1.Pod, container corev1.Container, cmd []string) error { - client, err := kubernetes.NewForConfig(restCfg) - if err != nil { - return errors.Wrap(err, "unable to get kubernetes client config") + // 6. Execute remote command + if err := r.exec.RunExec(r.restConfig, pod, container, r.config.RemoteCmd); err != nil { + return errors.Wrap(err, "RunExec failed") } - req := client.CoreV1().RESTClient(). - Post(). - Namespace(pod.GetNamespace()). - Resource("pods"). - Name(pod.GetName()). - SubResource("exec"). - VersionedParams(&corev1.PodExecOptions{ - Container: container.Name, - Command: cmd, - Stdin: true, - Stdout: true, - Stderr: true, - TTY: true, - }, scheme.ParameterCodec) - - log.WithFields(log.Fields{ - "URL": req.URL(), - }).Debug("Request") - - exec, err := remotecommand.NewSPDYExecutor(restCfg, "POST", req.URL()) - if err != nil { - return errors.Wrap(err, "unable to execute remote command") - } - - fd := int(os.Stdin.Fd()) - - // Put the terminal into raw mode to prevent it echoing characters twice. - oldState, err := term.MakeRaw(fd) - if err != nil { - return errors.Wrap(err, "unable to init terminal") - } - - termWidth, termHeight, _ := term.GetSize(fd) - termSize := remotecommand.TerminalSize{Width: uint16(termWidth), Height: uint16(termHeight)} - s := make(sizeQueue, 1) - s <- termSize - - defer func() { - err := term.Restore(fd, oldState) - if err != nil { - log.Error(err) - } - }() - - // Connect this process' std{in,out,err} to the remote shell process. - err = exec.StreamWithContext(context.Background(), remotecommand.StreamOptions{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - Tty: true, - TerminalSizeQueue: s, - }) - if err != nil { - return errors.Wrap(err, "unable to stream shell process") - } - - fmt.Println() return nil } - -func (s sizeQueue) Next() *remotecommand.TerminalSize { - size, ok := <-s - if !ok { - return nil - } - return &size -} diff --git a/pkg/iexec/iexec_test.go b/pkg/iexec/iexec_test.go new file mode 100644 index 0000000..d491ff6 --- /dev/null +++ b/pkg/iexec/iexec_test.go @@ -0,0 +1,113 @@ +package iexec + +import ( + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/rest" +) + +// Mock UI (no promptui usage) +type MockUI struct { + SelectPodFn func([]corev1.Pod, Config) (corev1.Pod, error) + SelectContainerFn func([]corev1.Container, Config) (corev1.Container, error) +} + +func (m *MockUI) SelectPod(pods []corev1.Pod, cfg Config) (corev1.Pod, error) { + return m.SelectPodFn(pods, cfg) +} +func (m *MockUI) SelectContainer(containers []corev1.Container, cfg Config) (corev1.Container, error) { + return m.SelectContainerFn(containers, cfg) +} + +// Mock K8s +type MockK8sClient struct { + FetchAllPodsFn func(namespace, labelSelector string) (*corev1.PodList, error) + MatchPodsFn func(pods *corev1.PodList, config Config) (*corev1.PodList, error) + MatchContainersFn func(pod corev1.Pod, config Config) ([]corev1.Container, error) +} + +func (m *MockK8sClient) FetchAllPods(ns, lbl string) (*corev1.PodList, error) { + return m.FetchAllPodsFn(ns, lbl) +} +func (m *MockK8sClient) MatchPods(pods *corev1.PodList, cfg Config) (*corev1.PodList, error) { + return m.MatchPodsFn(pods, cfg) +} +func (m *MockK8sClient) MatchContainers(pod corev1.Pod, cfg Config) ([]corev1.Container, error) { + return m.MatchContainersFn(pod, cfg) +} + +// Mock Exec +type MockExecRunner struct { + RunExecFn func(*rest.Config, corev1.Pod, corev1.Container, []string) error +} + +func (m *MockExecRunner) RunExec(r *rest.Config, p corev1.Pod, c corev1.Container, cmd []string) error { + return m.RunExecFn(r, p, c, cmd) +} + +// Example Test +func TestIexec_Do_Success(t *testing.T) { + ui := &MockUI{ + SelectPodFn: func(pods []corev1.Pod, cfg Config) (corev1.Pod, error) { + return pods[0], nil + }, + SelectContainerFn: func(containers []corev1.Container, cfg Config) (corev1.Container, error) { + return containers[0], nil + }, + } + k8sMock := &MockK8sClient{ + FetchAllPodsFn: func(ns, lbl string) (*corev1.PodList, error) { + return &corev1.PodList{ + Items: []corev1.Pod{{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test-container"}}, + }, + }}, + }, nil + }, + MatchPodsFn: func(pods *corev1.PodList, cfg Config) (*corev1.PodList, error) { + return pods, nil + }, + MatchContainersFn: func(pod corev1.Pod, cfg Config) ([]corev1.Container, error) { + return pod.Spec.Containers, nil + }, + } + execMock := &MockExecRunner{ + RunExecFn: func(r *rest.Config, p corev1.Pod, c corev1.Container, cmd []string) error { + return nil + }, + } + + cfg := &Config{Namespace: "default"} + iex := NewIexec(&rest.Config{}, cfg, ui, k8sMock, execMock) + + if err := iex.Do(); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +// Example Test - Error scenario +func TestIexec_Do_FetchPodsError(t *testing.T) { + ui := &MockUI{} + k8sMock := &MockK8sClient{ + FetchAllPodsFn: func(ns, lbl string) (*corev1.PodList, error) { + return nil, errors.New("error fetching pods") + }, + MatchPodsFn: nil, + MatchContainersFn: nil, + } + execMock := &MockExecRunner{} + + cfg := &Config{Namespace: "default"} + iex := NewIexec(&rest.Config{}, cfg, ui, k8sMock, execMock) + + err := iex.Do() + if err == nil { + t.Fatal("expected error, got nil") + } + if err.Error() != "fetchAllPods failed: error fetching pods" { + t.Errorf("unexpected error message: %v", err) + } +} diff --git a/pkg/iexec/iface.go b/pkg/iexec/iface.go new file mode 100644 index 0000000..35c9e0b --- /dev/null +++ b/pkg/iexec/iface.go @@ -0,0 +1,22 @@ +package iexec + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/rest" +) + +// TerminalUI is responsible for selecting a pod or container from a list. +type TerminalUI interface { + SelectPod(pods []corev1.Pod, config Config) (corev1.Pod, error) + SelectContainer(containers []corev1.Container, config Config) (corev1.Container, error) +} + +type K8sClient interface { + FetchAllPods(namespace, labelSelector string) (*corev1.PodList, error) + MatchPods(pods *corev1.PodList, config Config) (*corev1.PodList, error) + MatchContainers(pod corev1.Pod, config Config) ([]corev1.Container, error) +} + +type ExecRunner interface { + RunExec(restConfig *rest.Config, pod corev1.Pod, container corev1.Container, cmd []string) error +} diff --git a/pkg/iexec/kubernetes.go b/pkg/iexec/k8s_client_impl.go similarity index 58% rename from pkg/iexec/kubernetes.go rename to pkg/iexec/k8s_client_impl.go index 5eb3a30..7012982 100644 --- a/pkg/iexec/kubernetes.go +++ b/pkg/iexec/k8s_client_impl.go @@ -3,45 +3,38 @@ package iexec import ( "context" "fmt" + log "github.com/sirupsen/logrus" "sort" "strings" - "github.com/pkg/errors" - - log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) -// get all pods from kubernetes API. -func getAllPods(client kubernetes.Interface, namespace, selector string) (*corev1.PodList, error) { - pods, err := client.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{FieldSelector: "status.phase=Running", LabelSelector: selector}) - if err != nil { - return pods, errors.Wrap(err, "unable to get pods") - } - - if len(pods.Items) == 0 { - return pods, errors.New("no running pods found") - } +type RealK8sClient struct { + client kubernetes.Interface +} - log.WithFields(log.Fields{ - "pods": len(pods.Items), - "namespace": namespace, - }).Debug("total pods discovered...") +func NewRealK8sClient(client kubernetes.Interface) *RealK8sClient { + return &RealK8sClient{client: client} +} - return pods, nil +func (r *RealK8sClient) FetchAllPods(namespace, labelSelector string) (*corev1.PodList, error) { + return r.client.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{ + LabelSelector: labelSelector, + }) } -func (r *Iexec) matchPods(pods *corev1.PodList) (corev1.PodList, error) { +func (r *RealK8sClient) MatchPods(pods *corev1.PodList, config Config) (*corev1.PodList, error) { var result corev1.PodList log.WithFields(log.Fields{ - "SearchFilter": r.config.PodFilter, + "SearchFilter": config.PodFilter, }).Infof("Get all pods for podFilter...") for i, pod := range pods.Items { - if strings.Contains(pod.GetName(), r.config.PodFilter) { + if strings.Contains(pod.GetName(), config.PodFilter) { result.Items = append(result.Items, pod) log.WithFields(log.Fields{ "PodName": pod.GetName(), @@ -51,15 +44,15 @@ func (r *Iexec) matchPods(pods *corev1.PodList) (corev1.PodList, error) { } if len(result.Items) == 0 { - err := fmt.Errorf("no pods found for filter: %s", r.config.PodFilter) + err := fmt.Errorf("no pods found for filter: %s", config.PodFilter) - return result, err + return &result, err } - return result, nil + return &result, nil } -func matchContainers(pod corev1.Pod, config Config) ([]corev1.Container, error) { +func (r *RealK8sClient) MatchContainers(pod corev1.Pod, config Config) ([]corev1.Container, error) { if config.ContainerFilter == "" { return pod.Spec.Containers, nil } diff --git a/pkg/iexec/terminal_ui_prompt.go b/pkg/iexec/terminal_ui_prompt.go new file mode 100644 index 0000000..099a190 --- /dev/null +++ b/pkg/iexec/terminal_ui_prompt.go @@ -0,0 +1,86 @@ +package iexec + +import ( + "os" + + "github.com/manifoldco/promptui" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" +) + +// PromptUITerminal is the real interactive UI using promptui +type PromptUITerminal struct { + // Add fields if needed (e.g., templates) +} + +// NewPromptUITerminal constructor +func NewPromptUITerminal() *PromptUITerminal { + return &PromptUITerminal{} +} + +func (p *PromptUITerminal) SelectPod(pods []corev1.Pod, config Config) (corev1.Pod, error) { + // Single item => return immediately + if len(pods) == 1 { + return pods[0], nil + } + + // Multiple items => open /dev/tty + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return corev1.Pod{}, errors.Wrap(err, "failed to open /dev/tty for pod selection") + } + defer tty.Close() + + var templates *promptui.SelectTemplates + if config.Naked { + templates = podTemplateNaked + } else { + templates = podTemplate + } + + prompt := promptui.Select{ + Label: "Select Pod", + Items: pods, + Stdout: tty, // Render to the TTY + IsVimMode: config.VimMode, + Templates: templates, + } + + i, _, err := prompt.Run() + if err != nil { + return corev1.Pod{}, errors.Wrap(err, "promptui pod selection failed") + } + return pods[i], nil +} + +func (p *PromptUITerminal) SelectContainer(containers []corev1.Container, config Config) (corev1.Container, error) { + if len(containers) == 1 { + return containers[0], nil + } + + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return corev1.Container{}, errors.Wrap(err, "failed to open /dev/tty for container selection") + } + defer tty.Close() + + var templates *promptui.SelectTemplates + if config.Naked { + templates = containerTemplatesNaked + } else { + templates = containerTemplates + } + + prompt := promptui.Select{ + Label: "Select Container", + Items: containers, + Stdout: tty, + IsVimMode: config.VimMode, + Templates: templates, + } + i, _, err := prompt.Run() + if err != nil { + return corev1.Container{}, errors.Wrap(err, "promptui container selection failed") + } + return containers[i], nil +}