diff --git a/README.md b/README.md index 31213a8..3acda5c 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ ClusterRoleBindings · Nodes · Namespaces · Events · CRDs · Custom Resources | `y` YAML | the live object | | `l` logs | **follows** (`-f`), newest at the bottom, scroll back 500 lines | | `s` shell | a real interactive exec session - raw TTY, resize-aware, in-panel | +| `S` shell (terminal) | quits k10s into the same shell, in your own terminal | | `p` port-forward | real SPDY forward, start/stop from the pane | | `m` top | pod/node metrics, per container, with requests vs limits | | `e` edit · `r` restart · `c` scale | rollout restart, scale, `$EDITOR` | diff --git a/docs/keybindings.md b/docs/keybindings.md index 02c74fc..96c8dd7 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -109,6 +109,7 @@ nothing greyed out. | `y` | YAML | all except events | | `l` | Logs (follows live) | pods, workloads, jobs | | `s` | Shell — real interactive exec | pods | +| `S` | Shell (terminal) — **quits k10s** into it | pods | | `p` | Port Forward — real, toggles | pods, services | | `r` | Rollout Restart | deployments, statefulsets, daemonsets | | `c` | Scale — opens `:scale ` | deployments, statefulsets, replicasets| @@ -153,6 +154,10 @@ keys win unless the plugin sets `override: true`; see [plugins.md](plugins.md). The shell renders inside the main panel, so the rest of k10s stays visible. +`S` on the table is the other way in: it **quits k10s** and runs the same +shell in the terminal you started k10s from. Exit the shell to get your +prompt back. Inside the in-panel shell `S` is just a key for the pod. + ## Command box | Key | Action | diff --git a/docs/ui.md b/docs/ui.md index 3073fc6..7155a0c 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -156,6 +156,14 @@ Workloads resolve to one of their pods, the same way logs do. Backends without a shell (the offline demo) say so in the status bar rather than failing. +`S` is for when you want the real terminal instead: its scrollback, +selection, fonts and colours. It **quits k10s** and runs the same shell, on +the same pod and container `s` would pick, in the terminal you started k10s +from. Exiting the shell returns you to your prompt, and k10s exits with the +shell's exit code, like `kubectl exec -it`. Inside the in-panel shell `S` is +just a key for the pod, so detach first. Like `s`, it is not offered in +`--readonly`, and on the demo it says there is no shell instead of quitting. + ### Contexts `:ctx` lists kubeconfig contexts in the main panel, numbered like a @@ -275,6 +283,8 @@ Notable behavior: - **Logs** (`l`) follows the stream live, like `kubectl logs -f`. - **Shell** (`s`) opens a real interactive exec session **inside the main panel** — see below. +- **Shell (terminal)** (`S`) **quits k10s** and opens the same shell in the + terminal you started it from — see below. - **Port Forward** (`p`) starts a real forward on an ephemeral local port and toasts the address; pressing it again stops that forward, or cancels it while it is still connecting. diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 5adbb7c..9a61e38 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -70,6 +70,9 @@ const ( AYAML = "yaml" ALogs = "logs" AShell = "shell" + // AShellOut quits k10s and runs the pod shell in the terminal k10s was + // started from. + AShellOut = "shellout" APortFwd = "portfwd" ARestart = "restart" AEdit = "edit" @@ -154,9 +157,10 @@ type Source interface { Drain(name string) error Apply(kind, ns, name, yaml string) error - // Shell returns a process the UI can hand the terminal to via - // tea.ExecProcess (nil, nil if the backend has no real shell — the UI - // then falls back to a toast). + // Shell returns a process that runs the shell on a real terminal. S + // quits k10s and main.go runs it in the terminal k10s was started from + // (nil, nil if the backend has no real shell — the UI then falls back to + // a toast). Shell(kind, ns, name string) (tea.ExecCommand, error) // ShellSession opens an interactive shell the UI can render *inside* a // panel rather than by handing over the whole terminal. Returns diff --git a/internal/domain/readonly_test.go b/internal/domain/readonly_test.go index e861ef8..f8a5e9a 100644 --- a/internal/domain/readonly_test.go +++ b/internal/domain/readonly_test.go @@ -6,7 +6,7 @@ import "testing" // the ones that change the cluster, and the ones that open a session into it. func TestReadOnlyAllowsOnlyReadingActions(t *testing.T) { reads := map[string]bool{ADescribe: true, AYAML: true, ALogs: true, ATop: true} - all := []string{ADescribe, AYAML, ALogs, AShell, APortFwd, ARestart, AEdit, AScale, ATop, ACordon, ADrain, ADelete} + all := []string{ADescribe, AYAML, ALogs, AShell, AShellOut, APortFwd, ARestart, AEdit, AScale, ATop, ACordon, ADrain, ADelete} for _, id := range all { if got := ReadOnlyAllows(id); got != reads[id] { t.Errorf("ReadOnlyAllows(%q) = %v, want %v", id, got, reads[id]) diff --git a/internal/k8s/action_resolution_test.go b/internal/k8s/action_resolution_test.go index 76caca2..43c9f5d 100644 --- a/internal/k8s/action_resolution_test.go +++ b/internal/k8s/action_resolution_test.go @@ -1,11 +1,14 @@ package k8s import ( + "errors" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "testing" + + "github.com/p10node/k10s/internal/domain" ) func TestColdActionResolution(t *testing.T) { @@ -45,3 +48,29 @@ func TestColdActionResolution(t *testing.T) { }) } } + +// S runs Shell in the real terminal, so it has to land on the pod and +// container the in-panel shell picks: a workload resolves to one of its pods. +func TestShellResolvesTheSamePodAsShellSession(t *testing.T) { + p := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "web-pod", Namespace: "a", Labels: map[string]string{"app": "web"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app"}}}, Status: corev1.PodStatus{Phase: corev1.PodRunning}} + d := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace: "a"}, Spec: appsv1.DeploymentSpec{Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "web"}}}} + s := newTestStore(t, p, d) + + for _, tc := range []struct{ kind, name string }{{"pods", "web-pod"}, {"deployments", "web"}} { + cmd, err := s.Shell(tc.kind, "a", tc.name) + if err != nil { + t.Fatalf("Shell(%s/%s): %v", tc.kind, tc.name, err) + } + e, ok := cmd.(*execCommand) + if !ok { + t.Fatalf("Shell(%s/%s) returned %T", tc.kind, tc.name, cmd) + } + if e.ns != "a" || e.pod != "web-pod" || e.container != "app" { + t.Errorf("Shell(%s/%s) targets %s/%s container %s, want a/web-pod container app", tc.kind, tc.name, e.ns, e.pod, e.container) + } + } + + if _, err := s.Shell("configmaps", "a", "settings"); !errors.Is(err, domain.ErrNoShell) { + t.Errorf("Shell on a configmap: err = %v, want ErrNoShell", err) + } +} diff --git a/internal/k8s/exec.go b/internal/k8s/exec.go index 48dc173..aec7def 100644 --- a/internal/k8s/exec.go +++ b/internal/k8s/exec.go @@ -13,6 +13,8 @@ import ( "k8s.io/client-go/tools/remotecommand" kterm "k8s.io/kubectl/pkg/util/term" "k8s.io/streaming/pkg/httpstream" + + "github.com/p10node/k10s/internal/domain" ) // newExecutor builds the executor every exec path uses: WebSocket first, @@ -119,15 +121,16 @@ func (a sizeQueueAdapter) Next() *remotecommand.TerminalSize { return &remotecommand.TerminalSize{Width: s.Width, Height: s.Height} } -// Shell returns an interactive exec session into the pod's first container, -// handed to the UI via tea.ExecProcess. +// Shell returns an interactive exec session for the real terminal, into the +// same pod and container ShellSession picks: a pod is itself, a workload +// resolves to one of its pods. func (s *Store) Shell(kind, ns, name string) (tea.ExecCommand, error) { - if kind != "pods" { - return nil, fmt.Errorf("shell is only available for pods") - } - container, err := s.podContainer(ns, name) + pod, container, err := s.logTarget(kind, ns, name) if err != nil { + if err == domain.ErrNoLogs { + return nil, domain.ErrNoShell + } return nil, err } - return &execCommand{store: s, ns: effectiveNS(ns), pod: name, container: container, shellCmd: shellCmd}, nil + return &execCommand{store: s, ns: effectiveNS(ns), pod: pod, container: container, shellCmd: shellCmd}, nil } diff --git a/internal/k8s/kinds.go b/internal/k8s/kinds.go index e6100df..36fc416 100644 --- a/internal/k8s/kinds.go +++ b/internal/k8s/kinds.go @@ -2,7 +2,7 @@ package k8s import "github.com/p10node/k10s/internal/domain" -var podActions = []string{domain.ADescribe, domain.AYAML, domain.ALogs, domain.AShell, domain.APortFwd, domain.ATop, domain.AEdit, domain.ADelete} +var podActions = []string{domain.ADescribe, domain.AYAML, domain.ALogs, domain.AShell, domain.AShellOut, domain.APortFwd, domain.ATop, domain.AEdit, domain.ADelete} var wlActions = []string{domain.ADescribe, domain.AYAML, domain.ALogs, domain.ARestart, domain.AScale, domain.AEdit, domain.ADelete} var basicActions = []string{domain.ADescribe, domain.AYAML, domain.AEdit, domain.ADelete} var nodeActions = []string{domain.ADescribe, domain.AYAML, domain.ATop, domain.ACordon, domain.ADrain, domain.AEdit} diff --git a/internal/mock/data.go b/internal/mock/data.go index 6f35a7d..4be335a 100644 --- a/internal/mock/data.go +++ b/internal/mock/data.go @@ -34,7 +34,7 @@ type resourceDef struct { Extra []nsRow // rows living in a non-default namespace } -var podActions = []string{domain.ADescribe, domain.AYAML, domain.ALogs, domain.AShell, domain.APortFwd, domain.ATop, domain.AEdit, domain.ADelete} +var podActions = []string{domain.ADescribe, domain.AYAML, domain.ALogs, domain.AShell, domain.AShellOut, domain.APortFwd, domain.ATop, domain.AEdit, domain.ADelete} var wlActions = []string{domain.ADescribe, domain.AYAML, domain.ALogs, domain.ARestart, domain.AScale, domain.AEdit, domain.ADelete} var basicActions = []string{domain.ADescribe, domain.AYAML, domain.AEdit, domain.ADelete} var nodeActions = []string{domain.ADescribe, domain.AYAML, domain.ATop, domain.ACordon, domain.ADrain, domain.AEdit} diff --git a/internal/ui/actions.go b/internal/ui/actions.go index 7d8489f..4431b7b 100644 --- a/internal/ui/actions.go +++ b/internal/ui/actions.go @@ -23,6 +23,8 @@ var Actions = []Action{ {domain.AYAML, "y", "YAML", "󰈮", false}, {domain.ALogs, "l", "Logs", "󰑍", false}, {domain.AShell, "s", "Shell", "", false}, + // "S" quits k10s into the same shell in the real terminal. + {domain.AShellOut, "S", "Shell (terminal)", "", false}, // "p" for port-forward: "f" is now the find/search key. {domain.APortFwd, "p", "Port Forward", "󰛳", false}, {domain.ARestart, "r", "Rollout Restart", "󰑐", false}, diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 47780de..5210d77 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -285,6 +285,8 @@ func Help() string { d describe · y yaml · l logs (follow, when live) s shell — a real interactive shell rendered inside the main panel; ctrl+] detaches. The rest of k10s stays visible while you are in it. + S shell in the terminal — quits k10s and opens the same pod shell in + the terminal k10s was started from; exit it to get your prompt back. p port-forward (real) · r rollout restart · c scale (prompts for n) e edit (opens $EDITOR on live YAML) · m top (metrics-server) o cordon/uncordon · u drain (nodes only) diff --git a/internal/ui/external_shell.go b/internal/ui/external_shell.go new file mode 100644 index 0000000..23ed5c1 --- /dev/null +++ b/internal/ui/external_shell.go @@ -0,0 +1,75 @@ +package ui + +import ( + "errors" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/p10node/k10s/internal/domain" +) + +// S: quit k10s and open the pod shell in the terminal k10s was started from, +// with that terminal's scrollback, selection, fonts and colours. +// +// This is not tea.ExecProcess, which hands the terminal over and comes back +// into k10s afterwards. The point of S is to leave, so the UI only resolves +// the command, records it and quits; main.go runs it once the alt screen is +// gone, the same way /update's relaunch works. + +// externalShellMsg carries the command Source.Shell resolved, so that call +// (an API round trip) stays off the UI goroutine. +type externalShellMsg struct { + session, view int + target string + cmd tea.ExecCommand + err error +} + +// startExternalShell resolves the shell for S. short is the kind's short name +// ("po"), for the line main.go prints before handing over. +func (m *Model) startExternalShell(src domain.Source, short, kind, ns, name string) tea.Cmd { + m.backToTable() + session, view, release := m.connGen, m.viewGen, m.leaseSource() + m.startBusy("shell " + short + "/" + name) + + shown := ns + if shown == "" { + shown = "default" + } + target := short + "/" + name + " (namespace " + shown + ")" + + return func() tea.Msg { + defer release() + cmd, err := src.Shell(kind, ns, name) + return externalShellMsg{session: session, view: view, target: target, cmd: cmd, err: err} + } +} + +// handleExternalShell quits k10s once the shell is ready, or stays and says +// why there isn't one. +// +// Nothing on the tea.Quit path closes the source, and that matters: the +// command builds its exec request from the store only when main.go runs it. +func (m *Model) handleExternalShell(msg externalShellMsg) tea.Cmd { + if msg.session != m.connGen || msg.view != m.viewGen { + return nil // the user moved on; don't quit under them + } + m.busy = false + if msg.err != nil && !errors.Is(msg.err, domain.ErrNoShell) { + m.toast = "✗ " + msg.err.Error() + return nil + } + if msg.cmd == nil { + m.toast = "no interactive shell here (offline demo, or not a pod)" + return nil + } + m.exitShell = msg.cmd + m.exitShellTarget = msg.target + return tea.Quit +} + +// ExitShell is the shell S asked for, and the resource it belongs to. main.go +// runs it after the program exits; nil when k10s quit any other way. +func (m *Model) ExitShell() (tea.ExecCommand, string) { + return m.exitShell, m.exitShellTarget +} diff --git a/internal/ui/external_shell_test.go b/internal/ui/external_shell_test.go new file mode 100644 index 0000000..02ba3d1 --- /dev/null +++ b/internal/ui/external_shell_test.go @@ -0,0 +1,182 @@ +package ui + +import ( + "errors" + "io" + "strings" + "sync" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/p10node/k10s/internal/domain" + "github.com/p10node/k10s/internal/mock" +) + +// fakeExec stands in for the k8s exec command S hands to main.go. +type fakeExec struct{} + +func (fakeExec) SetStdin(io.Reader) {} +func (fakeExec) SetStdout(io.Writer) {} +func (fakeExec) SetStderr(io.Writer) {} +func (fakeExec) Run() error { return nil } + +// execSource wraps the demo backend with a Shell that resolves, and records +// whether anything closed it. +type execSource struct { + domain.Source + cmd tea.ExecCommand + err error + + mu sync.Mutex + closed bool +} + +func (s *execSource) Shell(kind, ns, name string) (tea.ExecCommand, error) { return s.cmd, s.err } +func (s *execSource) Close() { + s.mu.Lock() + s.closed = true + s.mu.Unlock() +} +func (s *execSource) isClosed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} + +func shellOutAction(t *testing.T) Action { + t.Helper() + for _, a := range Actions { + if a.ID == domain.AShellOut { + return a + } + } + t.Fatal("no S action in the action table") + return Action{} +} + +// pressShellOut fires S and feeds the resolved shell back, returning what +// Update answered with. +func pressShellOut(t *testing.T, m *Model) tea.Cmd { + t.Helper() + cmd := m.fireAction(shellOutAction(t)) + if cmd == nil { + t.Fatalf("S returned no command; toast = %q", m.toast) + } + _, next := m.Update(cmd()) + return next +} + +func isQuit(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + _, ok := cmd().(tea.QuitMsg) + return ok +} + +func TestShellOutQuitsAndHandsTheShellToMain(t *testing.T) { + src := &execSource{Source: mock.New(""), cmd: fakeExec{}} + m := newTestModel(t, src) + dismissOnboarding(m) + name := m.curName() + + if !isQuit(pressShellOut(t, m)) { + t.Fatal("S should quit k10s once the shell is ready") + } + cmd, target := m.ExitShell() + if cmd == nil { + t.Fatal("ExitShell() = nil after S, main.go would have nothing to run") + } + if want := "po/" + name; !strings.Contains(target, want) { + t.Errorf("target = %q, want it to name %s", target, want) + } + if m.busy { + t.Error("the loading indicator is still on after the shell resolved") + } +} + +// The exec command builds its request from the store when main.go runs it, +// after the program has exited, so quitting must not close the source. +func TestShellOutKeepsTheSourceOpen(t *testing.T) { + src := &execSource{Source: mock.New(""), cmd: fakeExec{}} + m := newTestModel(t, src) + dismissOnboarding(m) + + pressShellOut(t, m) + if m.sourceOwner != nil && m.sourceOwner.retired { + t.Error("S retired the source it is about to exec through") + } + if src.isClosed() { + t.Error("S closed the source it is about to exec through") + } +} + +func TestShellOutWithoutAShellStaysInK10s(t *testing.T) { + m := newTestModel(t, mock.New("")) // the demo backend has no real shell + dismissOnboarding(m) + + if isQuit(pressShellOut(t, m)) { + t.Fatal("S quit k10s with no shell to run") + } + if cmd, _ := m.ExitShell(); cmd != nil { + t.Error("ExitShell() is set without a shell") + } + if !strings.Contains(m.toast, "no interactive shell") { + t.Errorf("toast = %q, want the no-shell notice", m.toast) + } +} + +func TestShellOutReportsAFailedLookup(t *testing.T) { + src := &execSource{Source: mock.New(""), err: errors.New("no running pods found for deployments/web")} + m := newTestModel(t, src) + dismissOnboarding(m) + + if isQuit(pressShellOut(t, m)) { + t.Fatal("S quit k10s after the lookup failed") + } + if !strings.Contains(m.toast, "no running pods") { + t.Errorf("toast = %q, want the lookup error", m.toast) + } +} + +// A shell that resolves after the user has already moved on must not quit +// k10s under them. +func TestShellOutIgnoresAStaleResult(t *testing.T) { + src := &execSource{Source: mock.New(""), cmd: fakeExec{}} + m := newTestModel(t, src) + dismissOnboarding(m) + + cmd := m.fireAction(shellOutAction(t)) + m.backToTable() // what opening another view does + if _, next := m.Update(cmd()); isQuit(next) { + t.Fatal("a stale shell result quit k10s") + } + if c, _ := m.ExitShell(); c != nil { + t.Error("a stale shell result was kept") + } +} + +func TestShellOutIsOnShiftS(t *testing.T) { + src := &execSource{Source: mock.New(""), cmd: fakeExec{}} + m := newTestModel(t, src) + dismissOnboarding(m) + + cmd := m.handleKey(key("S")) + if cmd == nil { + t.Fatalf("S did nothing on a pod; toast = %q", m.toast) + } + if _, next := m.Update(cmd()); !isQuit(next) { + t.Error("S on a pod should quit into the shell") + } +} + +func TestActionsPaneListsShellOutForPods(t *testing.T) { + m := newTestModel(t, mock.New("")) + dismissOnboarding(m) + + pane := stripANSI(strings.Join(m.viewActions(40, 30).Lines, "\n")) + if !strings.Contains(pane, "Shell (terminal)") { + t.Errorf("Actions pane lacks the S action:\n%s", pane) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index ed46b2f..0555632 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -285,6 +285,11 @@ type Model struct { relaunch bool sourceOwner *sourceLease viewGen int + + // Set when S resolved a shell: k10s quits and main.go runs it in the + // terminal (see ExitShell). + exitShell tea.ExecCommand + exitShellTarget string } // New builds the UI model against src (either a real k8s.Store or the @@ -1072,6 +1077,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toast = "shell into " + msg.name + " · " + detachKey + " to detach" return m, waitShellOut(m.shellGen, msg.sess.Output()) + case externalShellMsg: + return m, m.handleExternalShell(msg) + case shellOutMsg: if msg.gen != m.shellGen || m.shellTerm == nil { return m, nil // a stale session's output @@ -2125,6 +2133,8 @@ func (m *Model) fireAction(a Action) tea.Cmd { } case domain.AShell: return m.startShellSession(kind, ns, name) + case domain.AShellOut: + return m.startExternalShell(src, r.Short, kind, ns, name) case domain.APortFwd: return m.startPortForward(kind, ns, name) case domain.AEdit: diff --git a/internal/ui/readonly_test.go b/internal/ui/readonly_test.go index fadd550..e39907f 100644 --- a/internal/ui/readonly_test.go +++ b/internal/ui/readonly_test.go @@ -32,7 +32,7 @@ func TestReadOnlyActionsPaneListsOnlyReads(t *testing.T) { } func TestReadOnlyRefusesWriteKeysBeforeAnyDialog(t *testing.T) { - for _, k := range []string{"D", "e", "s", "p"} { + for _, k := range []string{"D", "e", "s", "S", "p"} { m := newReadOnlyModel(t) m.handleKey(key(k)) if m.confirm != nil { diff --git a/main.go b/main.go index dfddbc4..f8a451a 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "strings" @@ -9,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" zone "github.com/lrstanley/bubblezone" + utilexec "k8s.io/client-go/util/exec" "github.com/p10node/k10s/internal/config" "github.com/p10node/k10s/internal/domain" @@ -133,6 +135,16 @@ func main() { os.Exit(1) } + // Set when the user pressed S. Run tears the alt screen down, so the pod + // shell gets the plain terminal k10s was started from, and k10s exits + // with the shell's exit code. + if c, target := m.ExitShell(); c != nil { + if code := runExitShell(c, target); code != 0 { + os.Exit(code) + } + return + } + // Set when the user accepted the restart offer after /update installed a // new binary. zone.Close has already run via defer order, and the alt // screen is torn down by Run, so the exec lands in a clean terminal. @@ -148,6 +160,29 @@ func main() { } } +// runExitShell runs the shell S asked for on this process's terminal and +// returns the code to exit with: the remote shell's own, or 1 when the +// session failed. +func runExitShell(c tea.ExecCommand, target string) int { + c.SetStdin(os.Stdin) + c.SetStdout(os.Stdout) + c.SetStderr(os.Stderr) + fmt.Fprintf(os.Stderr, "k10s: shell into %s — exit the shell to get your prompt back\n", target) + + err := c.Run() + if err == nil { + return 0 + } + // remotecommand reports a non-zero exit of the remote shell as a + // CodeExitError. That is the shell's result, not a k10s failure. + var exit utilexec.ExitError + if errors.As(err, &exit) { + return exit.ExitStatus() + } + fmt.Fprintln(os.Stderr, "k10s:", err) + return 1 +} + // selfUpdate is `k10s update`: the same install the TUI's /update performs, // with a progress line instead of a modal. // diff --git a/main_test.go b/main_test.go index 6de1532..efb74ef 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "io" "net/http" "net/http/httptest" "path/filepath" @@ -10,6 +11,7 @@ import ( "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + utilexec "k8s.io/client-go/util/exec" "github.com/p10node/k10s/internal/update" ) @@ -94,3 +96,31 @@ func TestSelfUpdateStepsAsideForAPackageManager(t *testing.T) { t.Errorf("selfUpdate error = %q, want %q", err, want) } } + +// runCmd is an exec command whose Run returns err. +type runCmd struct{ err error } + +func (runCmd) SetStdin(io.Reader) {} +func (runCmd) SetStdout(io.Writer) {} +func (runCmd) SetStderr(io.Writer) {} +func (c runCmd) Run() error { return c.err } + +// After S, k10s exits with the pod shell's own exit code, the way +// `kubectl exec -it` does, and with 1 only when the session itself failed. +func TestRunExitShellExitsWithTheShellsCode(t *testing.T) { + for _, tc := range []struct { + name string + err error + want int + }{ + {"shell exited cleanly", nil, 0}, + {"shell exited 3", utilexec.CodeExitError{Err: errors.New("command terminated with exit code 3"), Code: 3}, 3}, + {"session failed", errors.New("unable to upgrade connection"), 1}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := runExitShell(runCmd{tc.err}, "po/web-1 (namespace default)"); got != tc.want { + t.Errorf("exit code = %d, want %d", got, tc.want) + } + }) + } +}