Skip to content

Commit f8407ae

Browse files
committed
Add an interactive context/namespace/pod picker
Running k8tc without --pod now opens a picker that drills from context to namespace to pod (and container, when a pod has more than one), instead of requiring the pod name on the command line. Each rung is a filterable list: type to narrow, arrows to move, Esc to back up or clear the filter, Ctrl+C to quit (q is an ordinary filter character). The dialog is a centered modal whose height fits the items. The context rung is skipped when there is only one context (or --context is given), and preselects the current context. The namespace rung is skipped when -n is given. When `kubectl get namespaces` is refused, the picker falls back to a free-text namespace field prefilled with the chosen context's own namespace. A new kube.Discovery type runs the read-only `kubectl get`/`config` lookups (contexts, namespaces, pods+containers), with the kube context passed per call so the picker can switch contexts as the user drills. A --context flag is also threaded through kube.Client so browsing and transfers target the same context.
1 parent 680489f commit f8407ae

10 files changed

Lines changed: 1356 additions & 16 deletions

File tree

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,27 @@ Produces a single static binary.
3333
## Usage
3434

3535
```sh
36-
k8tc --pod <name> [flags]
36+
k8tc [--pod <name>] [flags]
3737
```
3838

39+
Run `k8tc` with **no `--pod`** and it opens an interactive picker that drills
40+
from context to namespace to pod: pick a context (skipped if you only have one),
41+
then a namespace, then a pod (and a container, when a pod has more than one).
42+
Type to filter the current list, ``/`` to move, `Enter` to descend/select,
43+
`Esc` to clear the filter or back up a level (and to exit at the top), `Ctrl+C`
44+
to quit. `q` is *not* a quit key here — it's an ordinary filter character. Pass
45+
`--pod` (with the usual flags) to skip the picker entirely; pass `--context`
46+
to skip the context step, and `-n` to start straight on that namespace's pods.
47+
48+
Listing namespaces needs cluster-wide permission; on a restricted cluster where
49+
`kubectl get namespaces` is refused, the picker falls back to a free-text
50+
namespace field, prefilled with the namespace your kubeconfig sets for the
51+
chosen context (if any).
52+
3953
| Flag | Meaning |
4054
|------------------------|----------------------------------------------------------------|
41-
| `--pod` | Pod name (required). |
55+
| `--pod` | Pod name. Omit to choose interactively. |
56+
| `--context` | Kube context (`kubectl --context`); default is the current one. |
4257
| `--namespace`, `-n` | Namespace (passed to `kubectl -n`). |
4358
| `--container`, `-c` | Container name (`kubectl exec -c`); omit for the default. |
4459
| `--remote-path` | Initial remote directory (default `/`). |

cmd/k8tc/main.go

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,25 @@ import (
1313
tea "github.com/charmbracelet/bubbletea"
1414
"github.com/cosmocode/k8tc/internal/kube"
1515
"github.com/cosmocode/k8tc/internal/local"
16+
"github.com/cosmocode/k8tc/internal/picker"
1617
"github.com/cosmocode/k8tc/internal/remote"
1718
"github.com/cosmocode/k8tc/internal/transfer"
1819
"github.com/cosmocode/k8tc/internal/ui"
1920
)
2021

2122
func main() {
2223
var (
23-
pod string
24-
namespace string
25-
container string
26-
remotePath string
27-
localPath string
28-
preserve bool
24+
kubeContext string
25+
pod string
26+
namespace string
27+
container string
28+
remotePath string
29+
localPath string
30+
preserve bool
2931
)
3032

31-
flag.StringVar(&pod, "pod", "", "pod name (required)")
33+
flag.StringVar(&kubeContext, "context", "", "kube context (kubectl --context); default current")
34+
flag.StringVar(&pod, "pod", "", "pod name (omit to pick interactively)")
3235
flag.StringVar(&namespace, "namespace", "", "namespace (kubectl -n)")
3336
flag.StringVar(&namespace, "n", "", "namespace (shorthand)")
3437
flag.StringVar(&container, "container", "", "container name (kubectl exec -c)")
@@ -40,25 +43,35 @@ func main() {
4043
"only effective against a privileged extract target")
4144
flag.Parse()
4245

43-
if pod == "" {
44-
fmt.Fprintln(os.Stderr, "error: --pod is required")
45-
flag.Usage()
46-
os.Exit(2)
47-
}
48-
4946
// Fail fast if kubectl is missing rather than surfacing it on first list.
5047
if _, err := exec.LookPath("kubectl"); err != nil {
5148
fmt.Fprintln(os.Stderr, "error: kubectl not found on PATH")
5249
os.Exit(1)
5350
}
5451

52+
// With no --pod, open the interactive picker to choose the
53+
// namespace/pod/container. It runs as its own program and hands back the
54+
// target; --namespace, if given, seeds it past the namespace step. Quitting
55+
// the picker without a choice exits cleanly.
56+
if pod == "" {
57+
sel, ok, err := runPicker(kubeContext, namespace)
58+
if err != nil {
59+
fmt.Fprintln(os.Stderr, "error:", err)
60+
os.Exit(1)
61+
}
62+
if !ok {
63+
os.Exit(0)
64+
}
65+
kubeContext, namespace, pod, container = sel.Context, sel.Namespace, sel.Pod, sel.Container
66+
}
67+
5568
if abs, err := filepath.Abs(localPath); err == nil {
5669
localPath = abs
5770
}
5871

5972
// One kube.Client (pod/container bound for the session) backs both the
6073
// remote directory lister and the transfer manager.
61-
client := &kube.Client{Namespace: namespace, Pod: pod, Container: container}
74+
client := &kube.Client{Context: kubeContext, Namespace: namespace, Pod: pod, Container: container}
6275
remoteLister := &remote.Lister{Client: client}
6376
xfer := &transfer.Manager{Client: client, PreserveOwnership: preserve}
6477

@@ -74,3 +87,17 @@ func main() {
7487
os.Exit(1)
7588
}
7689
}
90+
91+
// runPicker runs the interactive target chooser and returns the selection. ok is
92+
// false when the user quit without choosing; err is non-nil only on a Bubble Tea
93+
// runtime failure. kubeContext (from --context) scopes discovery; seedNamespace
94+
// (from -n) starts the picker on that namespace's pods.
95+
func runPicker(kubeContext, seedNamespace string) (picker.Selection, bool, error) {
96+
p := tea.NewProgram(picker.New(&kube.Discovery{}, kubeContext, seedNamespace), tea.WithAltScreen())
97+
res, err := p.Run()
98+
if err != nil {
99+
return picker.Selection{}, false, err
100+
}
101+
sel, ok := res.(picker.Model).Result()
102+
return sel, ok, nil
103+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require (
2727
github.com/muesli/cancelreader v0.2.2 // indirect
2828
github.com/muesli/termenv v0.16.0 // indirect
2929
github.com/rivo/uniseg v0.4.7 // indirect
30+
github.com/sahilm/fuzzy v0.1.1 // indirect
3031
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3132
golang.org/x/sys v0.38.0 // indirect
3233
golang.org/x/text v0.31.0 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
22
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
33
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
44
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5+
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
6+
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
57
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
68
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
79
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -14,6 +16,8 @@ github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dA
1416
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
1517
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
1618
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
19+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
20+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
1721
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
1822
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
1923
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
@@ -24,6 +28,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
2428
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2529
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2630
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
31+
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
32+
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
2733
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
2834
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
2935
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -44,6 +50,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
4450
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
4551
github.com/rmhubbert/bubbletea-overlay v0.6.7 h1:czTGRD7ZOVHLeOipnef3Lw7UQw0Ms66Z6aAjJIoZFOo=
4652
github.com/rmhubbert/bubbletea-overlay v0.6.7/go.mod h1:tR94NxVER/vn6BWMaHcwDMjUghsCvJb2uC3iDkYslww=
53+
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
54+
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
4755
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
4856
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
4957
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=

internal/kube/discover.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package kube
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os/exec"
8+
"strings"
9+
)
10+
11+
// PodInfo names one pod and the containers declared in its spec, in spec order.
12+
// It is what the picker needs to offer a pod (and, when there is more than one,
13+
// a container) to browse.
14+
type PodInfo struct {
15+
Name string
16+
Containers []string
17+
}
18+
19+
// Discovery runs read-only `kubectl get`/`kubectl config` commands to enumerate
20+
// what a session could target. It is the discovery counterpart to Client: where
21+
// Client is bound to one pod and execs inside it, Discovery is unbound and only
22+
// reads cluster/kubeconfig metadata, so it lives as its own type rather than on
23+
// Client.
24+
type Discovery struct {
25+
// Bin overrides the kubectl executable. Empty means "kubectl" on PATH.
26+
Bin string
27+
}
28+
29+
func (d *Discovery) bin() string {
30+
if d.Bin != "" {
31+
return d.Bin
32+
}
33+
return "kubectl"
34+
}
35+
36+
// cmd builds `kubectl [--context X] <args...>` bound to ctx, so a slow listing
37+
// is killed when the (Go) context is canceled. The kube context is per-call
38+
// (not bound on Discovery) because the picker switches contexts as the user
39+
// drills, so each listing names the context it wants. Unlike Client.Exec these
40+
// are top-level kubectl subcommands, not `exec` into a pod.
41+
func (d *Discovery) cmd(ctx context.Context, kubeCtx string, args ...string) *exec.Cmd {
42+
if kubeCtx != "" {
43+
args = append([]string{"--context", kubeCtx}, args...)
44+
}
45+
return exec.CommandContext(ctx, d.bin(), args...)
46+
}
47+
48+
// namespacesArgs / podsJSONPath / podsArgs are split out from the methods so the
49+
// exact kubectl invocation can be asserted in tests without shelling out, the
50+
// way TestClientExecArgs checks Exec.
51+
func contextsArgs() []string { return []string{"config", "get-contexts", "-o", "name"} }
52+
func currentContextArgs() []string { return []string{"config", "current-context"} }
53+
func namespacesArgs() []string { return []string{"get", "namespaces", "-o", "name"} }
54+
55+
// podsJSONPath emits one line per pod: "<name>\t<c1> <c2> …". It is a raw string
56+
// so the \t and \n reach kubectl's jsonpath parser literally (it, not Go,
57+
// expands them).
58+
const podsJSONPath = `{range .items[*]}{.metadata.name}{"\t"}{range .spec.containers[*]}{.name}{" "}{end}{"\n"}{end}`
59+
60+
func podsArgs(namespace string) []string {
61+
args := []string{"get", "pods"}
62+
if namespace != "" {
63+
args = append(args, "-n", namespace)
64+
}
65+
return append(args, "-o", "jsonpath="+podsJSONPath)
66+
}
67+
68+
// Contexts lists the context names in the user's kubeconfig (`kubectl config
69+
// get-contexts -o name`). It reads only local kubeconfig — no cluster calls —
70+
// so the picker can offer a context to choose before touching any cluster.
71+
func (d *Discovery) Contexts(ctx context.Context) ([]string, error) {
72+
out, err := d.run(ctx, "", contextsArgs()...)
73+
if err != nil {
74+
return nil, err
75+
}
76+
return parseNames(out, ""), nil
77+
}
78+
79+
// CurrentContext returns kubeconfig's current context name (`kubectl config
80+
// current-context`), so the picker can preselect it. It errors if no current
81+
// context is set, which the picker treats as "no preselection".
82+
func (d *Discovery) CurrentContext(ctx context.Context) (string, error) {
83+
out, err := d.run(ctx, "", currentContextArgs()...)
84+
if err != nil {
85+
return "", err
86+
}
87+
return strings.TrimSpace(out), nil
88+
}
89+
90+
// Namespaces lists kubeCtx's namespaces (empty kubeCtx means the current
91+
// context). It runs `kubectl get namespaces -o name`, which needs cluster-wide
92+
// list permission; on a restricted cluster it returns the kubectl error, which
93+
// the picker treats as "fall back to typing".
94+
func (d *Discovery) Namespaces(ctx context.Context, kubeCtx string) ([]string, error) {
95+
out, err := d.run(ctx, kubeCtx, namespacesArgs()...)
96+
if err != nil {
97+
return nil, err
98+
}
99+
return parseNames(out, "namespace/"), nil
100+
}
101+
102+
// Pods lists the pods in kubeCtx's namespace and the containers each declares.
103+
func (d *Discovery) Pods(ctx context.Context, kubeCtx, namespace string) ([]PodInfo, error) {
104+
out, err := d.run(ctx, kubeCtx, podsArgs(namespace)...)
105+
if err != nil {
106+
return nil, err
107+
}
108+
return parsePods(out), nil
109+
}
110+
111+
// KubeconfigNamespaces returns the namespace configured for kubeCtx (or the
112+
// current context, if kubeCtx is empty) as a one-element list — or empty if that
113+
// context sets none. It reads only local kubeconfig (no cluster calls), so it
114+
// works even where Namespaces is forbidden, which is exactly when the picker
115+
// uses it to prefill the typed-namespace fallback. Only this context's namespace
116+
// is offered: namespaces from other contexts belong to other clusters and would
117+
// be irrelevant (or misleading) suggestions here.
118+
func (d *Discovery) KubeconfigNamespaces(ctx context.Context, kubeCtx string) ([]string, error) {
119+
// `--minify` reduces the view to kubeCtx (or the current context), so this
120+
// jsonpath yields just that context's namespace.
121+
out, err := d.run(ctx, kubeCtx, "config", "view", "--minify", "-o", "jsonpath={..namespace}")
122+
if err != nil {
123+
return nil, err
124+
}
125+
return strings.Fields(out), nil
126+
}
127+
128+
// run executes a kubectl command in kubeCtx (empty means the current context),
129+
// returning stdout or a trimmed-stderr error, mirroring remote.Lister.run.
130+
// Canceling ctx kills the process.
131+
func (d *Discovery) run(ctx context.Context, kubeCtx string, args ...string) (string, error) {
132+
cmd := d.cmd(ctx, kubeCtx, args...)
133+
var out, errb bytes.Buffer
134+
cmd.Stdout = &out
135+
cmd.Stderr = &errb
136+
if err := cmd.Run(); err != nil {
137+
if msg := strings.TrimSpace(errb.String()); msg != "" {
138+
return "", fmt.Errorf("%s", msg)
139+
}
140+
return "", err
141+
}
142+
return out.String(), nil
143+
}
144+
145+
// parseNames splits `-o name` output ("<kind>/<name>" per line) into bare names,
146+
// stripping prefix and skipping blanks.
147+
func parseNames(out, prefix string) []string {
148+
var names []string
149+
for _, line := range strings.Split(out, "\n") {
150+
line = strings.TrimSpace(line)
151+
if line == "" {
152+
continue
153+
}
154+
names = append(names, strings.TrimPrefix(line, prefix))
155+
}
156+
return names
157+
}
158+
159+
// parsePods turns the podsJSONPath output into PodInfo. Each line is
160+
// "<name>\t<space-separated containers>"; a line with no tab (a pod with no
161+
// listed containers) yields a PodInfo with no containers.
162+
func parsePods(out string) []PodInfo {
163+
var pods []PodInfo
164+
for _, line := range strings.Split(out, "\n") {
165+
line = strings.TrimRight(line, "\r")
166+
name, rest, _ := strings.Cut(line, "\t")
167+
name = strings.TrimSpace(name)
168+
if name == "" {
169+
continue
170+
}
171+
var containers []string
172+
if cs := strings.Fields(rest); len(cs) > 0 {
173+
containers = cs
174+
}
175+
pods = append(pods, PodInfo{Name: name, Containers: containers})
176+
}
177+
return pods
178+
}

0 commit comments

Comments
 (0)