Skip to content

Commit 144ac07

Browse files
authored
feat: add direct port-forward diagnostic command (#99)
* Add port-forward command skeleton * Add port-forward mapping parser * Add single-pod selection for port-forward * Wire port-forward command to kube port-forward * Connect port-forward command to session targeting * Document port-forward diagnostic command * Simplify port-forward pod targeting
1 parent a8e1479 commit 144ac07

6 files changed

Lines changed: 293 additions & 1 deletion

File tree

docs/command-reference.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- `okdev logs [session] [--container <name> | --all] [--tail N] [--since 5m] [--follow] [--previous]`
3232
- `okdev ssh [session] [--setup-key] [--user root] [--cmd "..."] [--no-tmux] [--forward-agent|--no-forward-agent]`
3333
- `okdev ports`
34+
- `okdev port-forward [session] <local:remote>... [--pod <name> | --role <role>] [--ready-only]`
3435
- `okdev sync [--mode up|down|bi] [--foreground] [--reset] [--dry-run]`
3536
- `okdev prune [--ttl-hours 72] [--all-namespaces] [--all-users] [--include-pvc] [--dry-run]`
3637
- `okdev migrate [--template <name>] [--set key=value] [--dry-run] [--yes]`
@@ -202,6 +203,16 @@
202203
- No-op when managed forwards are already healthy and config is unchanged.
203204
- `--dry-run`: previews the SSH alias and port-forward actions without updating SSH config or starting/stopping managed forwards.
204205

206+
### `okdev port-forward [session] <local:remote>...`
207+
208+
- Runs direct foreground Kubernetes port-forwarding to one selected session pod.
209+
- Uses the current session target pod by default.
210+
- `--pod` selects one explicit pod by name.
211+
- `--role` selects one pod by workload role. Ambiguity is an error.
212+
- `--ready-only`: restricts selection to already-running pods.
213+
- Only `LOCAL:REMOTE` mappings are supported (kubectl-style `:REMOTE` or bare `PORT` are rejected).
214+
- The command stays attached until interrupted.
215+
205216
### `okdev sync [--mode up|down|bi] [--foreground] [--reset] [--dry-run]`
206217

207218
- Advanced command. Starts detached background sync by default; use `--foreground` for sync debugging, or explicit one-way sync (`up`/`down`).

internal/cli/command_ctor_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ func TestCommandConstructorsExposeExpectedMetadata(t *testing.T) {
4040
if cmd := newExecJobsCmd(&Options{}); cmd.Use != "exec-jobs [session]" || cmd.Flags().Lookup("pod") == nil || cmd.Flags().Lookup("role") == nil || cmd.Flags().Lookup("label") == nil || cmd.Flags().Lookup("exclude") == nil || cmd.Flags().Lookup("container") == nil || cmd.Flags().Lookup("fanout") == nil {
4141
t.Fatalf("unexpected exec-jobs command shape")
4242
}
43+
if cmd := newPortForwardCmd(&Options{}); cmd.Use != "port-forward [session] <local:remote>..." || cmd.Short == "" || cmd.Flags().Lookup("pod") == nil || cmd.Flags().Lookup("role") == nil || cmd.Flags().Lookup("ready-only") == nil {
44+
t.Fatalf("unexpected port-forward command shape")
45+
}
4346
if cmd := newPortsCmd(&Options{}); cmd.Use != "ports" || cmd.Short == "" || cmd.Flags().Lookup("dry-run") == nil {
4447
t.Fatalf("unexpected ports command shape")
4548
}

internal/cli/port_forward.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/acmore/okdev/internal/kube"
11+
"github.com/acmore/okdev/internal/workload"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
func newPortForwardCmd(opts *Options) *cobra.Command {
16+
var podName string
17+
var role string
18+
var readyOnly bool
19+
20+
cmd := &cobra.Command{
21+
Use: "port-forward [session] <local:remote>...",
22+
Short: "Run a direct foreground pod port-forward",
23+
Args: func(cmd *cobra.Command, args []string) error {
24+
if strings.TrimSpace(podName) != "" && strings.TrimSpace(role) != "" {
25+
return fmt.Errorf("--pod and --role are mutually exclusive")
26+
}
27+
_, mappingArgs := splitPortForwardArgs(args)
28+
_, err := parsePortForwardMappings(mappingArgs)
29+
return err
30+
},
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
sessionArgs, mappingArgs := splitPortForwardArgs(args)
33+
applySessionArg(opts, sessionArgs)
34+
cc, err := resolveCommandContext(opts, resolveSessionName)
35+
if err != nil {
36+
return err
37+
}
38+
if err := ensureExistingSessionOwnership(cc.opts, cc.kube, cc.namespace, cc.sessionName); err != nil {
39+
return err
40+
}
41+
mappings, err := parsePortForwardMappings(mappingArgs)
42+
if err != nil {
43+
return err
44+
}
45+
46+
target, err := resolvePortForwardTarget(cmd.Context(), cc, podName, role, readyOnly)
47+
if err != nil {
48+
return err
49+
}
50+
return runPortForward(cmd.Context(), cc.kube, cc.namespace, target, mappings, cmd.OutOrStdout())
51+
},
52+
}
53+
cmd.Flags().StringVar(&podName, "pod", "", "Target a specific pod by name")
54+
cmd.Flags().StringVar(&role, "role", "", "Target a pod by workload role")
55+
cmd.Flags().BoolVar(&readyOnly, "ready-only", false, "Use only already-running pods")
56+
return cmd
57+
}
58+
59+
func resolvePortForwardTarget(ctx context.Context, cc *commandContext, podName, role string, readyOnly bool) (workload.TargetRef, error) {
60+
var podNames []string
61+
switch {
62+
case strings.TrimSpace(podName) != "":
63+
podNames = []string{podName}
64+
case strings.TrimSpace(role) == "":
65+
pinned, err := resolveTargetRef(ctx, cc.opts, cc.cfg, cc.namespace, cc.sessionName, cc.kube)
66+
if err != nil {
67+
return workload.TargetRef{}, err
68+
}
69+
podNames = []string{pinned.PodName}
70+
}
71+
pods, err := selectSessionPods(ctx, cc, podNames, role, nil, nil, readyOnly)
72+
if err != nil {
73+
return workload.TargetRef{}, err
74+
}
75+
pod, err := selectSinglePortForwardPod(pods)
76+
if err != nil {
77+
return workload.TargetRef{}, err
78+
}
79+
return workload.TargetRef{PodName: pod.Name}, nil
80+
}
81+
82+
func parsePortForwardMappings(args []string) ([]string, error) {
83+
if len(args) == 0 {
84+
return nil, fmt.Errorf("requires at least one LOCAL:REMOTE mapping")
85+
}
86+
out := make([]string, 0, len(args))
87+
for _, arg := range args {
88+
parts := strings.Split(arg, ":")
89+
if len(parts) != 2 {
90+
return nil, fmt.Errorf("invalid port mapping %q, expected LOCAL:REMOTE", arg)
91+
}
92+
local, err := strconv.Atoi(parts[0])
93+
if err != nil || local <= 0 {
94+
return nil, fmt.Errorf("invalid local port in mapping %q", arg)
95+
}
96+
remote, err := strconv.Atoi(parts[1])
97+
if err != nil || remote <= 0 {
98+
return nil, fmt.Errorf("invalid remote port in mapping %q", arg)
99+
}
100+
out = append(out, fmt.Sprintf("%d:%d", local, remote))
101+
}
102+
return out, nil
103+
}
104+
105+
func selectSinglePortForwardPod(pods []kube.PodSummary) (kube.PodSummary, error) {
106+
if len(pods) != 1 {
107+
return kube.PodSummary{}, fmt.Errorf("port-forward requires exactly one pod, got %d", len(pods))
108+
}
109+
return pods[0], nil
110+
}
111+
112+
// splitPortForwardArgs separates an optional leading session name from the
113+
// LOCAL:REMOTE mapping args. The first arg is only treated as a session name
114+
// when it lacks a colon AND at least one more arg follows; otherwise it is
115+
// left in the mapping list so the parser surfaces a clear error.
116+
func splitPortForwardArgs(args []string) ([]string, []string) {
117+
if len(args) >= 2 && !strings.Contains(args[0], ":") {
118+
return args[:1], args[1:]
119+
}
120+
return nil, args
121+
}
122+
123+
type portForwardRunner interface {
124+
PortForward(context.Context, string, string, []string, io.Writer, io.Writer) error
125+
}
126+
127+
func runPortForward(ctx context.Context, client portForwardRunner, namespace string, target workload.TargetRef, forwards []string, out io.Writer) error {
128+
fmt.Fprintf(out, "Forwarding to pod=%s %s\n", target.PodName, strings.Join(forwards, ", "))
129+
return client.PortForward(ctx, namespace, target.PodName, forwards, out, io.Discard)
130+
}

internal/cli/port_forward_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"io"
8+
"reflect"
9+
"strings"
10+
"testing"
11+
12+
"github.com/acmore/okdev/internal/kube"
13+
"github.com/acmore/okdev/internal/workload"
14+
)
15+
16+
func TestParsePortForwardMappings(t *testing.T) {
17+
got, err := parsePortForwardMappings([]string{"8080:8080", "9000:9001"})
18+
if err != nil {
19+
t.Fatalf("parsePortForwardMappings: %v", err)
20+
}
21+
want := []string{"8080:8080", "9000:9001"}
22+
if !reflect.DeepEqual(want, got) {
23+
t.Fatalf("unexpected mappings: got=%v want=%v", got, want)
24+
}
25+
}
26+
27+
func TestParsePortForwardMappingsRejectsInvalidValues(t *testing.T) {
28+
for _, tc := range [][]string{
29+
nil,
30+
{},
31+
{"8080"},
32+
{"abc:8080"},
33+
{"8080:def"},
34+
{"0:8080"},
35+
{"8080:0"},
36+
{"8080:8080:8080"},
37+
} {
38+
if _, err := parsePortForwardMappings(tc); err == nil {
39+
t.Fatalf("expected error for %v", tc)
40+
}
41+
}
42+
}
43+
44+
func TestSelectSinglePortForwardPodRejectsAmbiguousMatch(t *testing.T) {
45+
pods := []kube.PodSummary{
46+
{Name: "master-0", Phase: "Running"},
47+
{Name: "master-1", Phase: "Running"},
48+
}
49+
_, err := selectSinglePortForwardPod(pods)
50+
if err == nil || !strings.Contains(err.Error(), "exactly one pod") {
51+
t.Fatalf("expected ambiguity error, got %v", err)
52+
}
53+
}
54+
55+
func TestSelectSinglePortForwardPodRejectsEmpty(t *testing.T) {
56+
_, err := selectSinglePortForwardPod(nil)
57+
if err == nil || !strings.Contains(err.Error(), "exactly one pod") {
58+
t.Fatalf("expected empty error, got %v", err)
59+
}
60+
}
61+
62+
func TestSelectSinglePortForwardPodReturnsSingle(t *testing.T) {
63+
pods := []kube.PodSummary{{Name: "worker-0", Phase: "Running"}}
64+
got, err := selectSinglePortForwardPod(pods)
65+
if err != nil {
66+
t.Fatalf("selectSinglePortForwardPod: %v", err)
67+
}
68+
if got.Name != "worker-0" {
69+
t.Fatalf("expected worker-0, got %q", got.Name)
70+
}
71+
}
72+
73+
func TestSplitPortForwardArgs(t *testing.T) {
74+
for _, tc := range []struct {
75+
name string
76+
args []string
77+
wantSession []string
78+
wantMappings []string
79+
}{
80+
{"empty", nil, nil, nil},
81+
{"mapping only", []string{"8080:8080"}, nil, []string{"8080:8080"}},
82+
{"session and mapping", []string{"my-sess", "8080:8080"}, []string{"my-sess"}, []string{"8080:8080"}},
83+
{"single non-mapping stays in mappings", []string{"8080"}, nil, []string{"8080"}},
84+
{"two mappings", []string{"8080:8080", "9000:9000"}, nil, []string{"8080:8080", "9000:9000"}},
85+
} {
86+
t.Run(tc.name, func(t *testing.T) {
87+
gotSession, gotMappings := splitPortForwardArgs(tc.args)
88+
if !reflect.DeepEqual(tc.wantSession, gotSession) {
89+
t.Fatalf("session: got=%v want=%v", gotSession, tc.wantSession)
90+
}
91+
if !reflect.DeepEqual(tc.wantMappings, gotMappings) {
92+
t.Fatalf("mappings: got=%v want=%v", gotMappings, tc.wantMappings)
93+
}
94+
})
95+
}
96+
}
97+
98+
type fakePortForwardClient struct {
99+
forwardedNamespace string
100+
forwardedPod string
101+
forwardedMappings []string
102+
}
103+
104+
func (f *fakePortForwardClient) PortForward(ctx context.Context, namespace, pod string, forwards []string, stdout io.Writer, stderr io.Writer) error {
105+
f.forwardedNamespace = namespace
106+
f.forwardedPod = pod
107+
f.forwardedMappings = append([]string(nil), forwards...)
108+
return context.Canceled
109+
}
110+
111+
func TestRunPortForwardUsesDirectKubePortForward(t *testing.T) {
112+
client := &fakePortForwardClient{}
113+
target := workload.TargetRef{PodName: "okdev-sess-master-0"}
114+
var out bytes.Buffer
115+
err := runPortForward(context.Background(), client, "demo", target, []string{"8080:8080"}, &out)
116+
if !errors.Is(err, context.Canceled) {
117+
t.Fatalf("expected context-canceled passthrough, got %v", err)
118+
}
119+
if client.forwardedNamespace != "demo" || client.forwardedPod != "okdev-sess-master-0" {
120+
t.Fatalf("unexpected forwarded target: ns=%q pod=%q", client.forwardedNamespace, client.forwardedPod)
121+
}
122+
wantMappings := []string{"8080:8080"}
123+
if !reflect.DeepEqual(wantMappings, client.forwardedMappings) {
124+
t.Fatalf("unexpected mappings: got=%v want=%v", client.forwardedMappings, wantMappings)
125+
}
126+
if !strings.Contains(out.String(), "Forwarding to pod=okdev-sess-master-0") {
127+
t.Fatalf("expected startup message, got %q", out.String())
128+
}
129+
}
130+
131+
func TestPortForwardCommandRejectsPodAndRoleTogether(t *testing.T) {
132+
cmd := newPortForwardCmd(&Options{})
133+
cmd.SetArgs([]string{"--pod", "a", "--role", "worker", "8080:8080"})
134+
err := cmd.Execute()
135+
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
136+
t.Fatalf("expected mutual exclusion error, got %v", err)
137+
}
138+
}
139+
140+
func TestPortForwardCommandRejectsMissingMappings(t *testing.T) {
141+
cmd := newPortForwardCmd(&Options{})
142+
cmd.SetArgs(nil)
143+
err := cmd.Execute()
144+
if err == nil || !strings.Contains(err.Error(), "requires at least one") {
145+
t.Fatalf("expected missing mapping error, got %v", err)
146+
}
147+
}

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func newRootCmdWithOptions() (*cobra.Command, *Options) {
5757
cmd.AddCommand(newAgentCmd(opts))
5858
cmd.AddCommand(newExecCmd(opts))
5959
cmd.AddCommand(newExecJobsCmd(opts))
60+
cmd.AddCommand(newPortForwardCmd(opts))
6061
cmd.AddCommand(newCpCmd(opts))
6162
cmd.AddCommand(newLogsCmd(opts))
6263
cmd.AddCommand(newSSHCmd(opts))

internal/cli/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func TestNewRootCmdRegistersExpectedCommandsAndFlags(t *testing.T) {
1515
}
1616
for _, name := range []string{
1717
"version", "init", "validate", "up", "down", "status", "list", "use",
18-
"target", "agent", "exec", "logs", "ssh", "ssh-proxy", "ports", "sync", "prune", "migrate", "completion",
18+
"target", "agent", "exec", "logs", "ssh", "ssh-proxy", "ports", "port-forward", "sync", "prune", "migrate", "completion",
1919
} {
2020
if _, _, err := cmd.Find([]string{name}); err != nil {
2121
t.Fatalf("expected subcommand %q: %v", name, err)

0 commit comments

Comments
 (0)