Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
5 changes: 5 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>` | deployments, statefulsets, replicasets|
Expand Down Expand Up @@ -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 |
Expand Down
10 changes: 10 additions & 0 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions internal/domain/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/domain/readonly_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
29 changes: 29 additions & 0 deletions internal/k8s/action_resolution_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
}
}
17 changes: 10 additions & 7 deletions internal/k8s/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion internal/k8s/kinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion internal/mock/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions internal/ui/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 2 additions & 0 deletions internal/ui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions internal/ui/external_shell.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading