Skip to content

Commit 0de7f3b

Browse files
committed
add port forward and all work
1 parent 4d90f8b commit 0de7f3b

12 files changed

Lines changed: 874 additions & 22 deletions

File tree

pkg/cmd/copy/copy_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package copy
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseCopyArguments_Upload(t *testing.T) {
8+
ws, remotePath, localPath, isUpload, err := parseCopyArguments("./local.txt", "my-node:/tmp/dest")
9+
if err != nil {
10+
t.Fatalf("unexpected error: %v", err)
11+
}
12+
if ws != "my-node" {
13+
t.Errorf("expected workspace my-node, got %s", ws)
14+
}
15+
if remotePath != "/tmp/dest" {
16+
t.Errorf("expected remotePath /tmp/dest, got %s", remotePath)
17+
}
18+
if localPath != "./local.txt" {
19+
t.Errorf("expected localPath ./local.txt, got %s", localPath)
20+
}
21+
if !isUpload {
22+
t.Error("expected isUpload=true")
23+
}
24+
}
25+
26+
func TestParseCopyArguments_Download(t *testing.T) {
27+
ws, remotePath, localPath, isUpload, err := parseCopyArguments("my-node:/tmp/file", "./local.txt")
28+
if err != nil {
29+
t.Fatalf("unexpected error: %v", err)
30+
}
31+
if ws != "my-node" {
32+
t.Errorf("expected workspace my-node, got %s", ws)
33+
}
34+
if remotePath != "/tmp/file" {
35+
t.Errorf("expected remotePath /tmp/file, got %s", remotePath)
36+
}
37+
if localPath != "./local.txt" {
38+
t.Errorf("expected localPath ./local.txt, got %s", localPath)
39+
}
40+
if isUpload {
41+
t.Error("expected isUpload=false")
42+
}
43+
}
44+
45+
func TestParseCopyArguments_BothLocal(t *testing.T) {
46+
_, _, _, _, err := parseCopyArguments("./a", "./b")
47+
if err == nil {
48+
t.Fatal("expected error when both paths are local")
49+
}
50+
}
51+
52+
func TestParseCopyArguments_BothRemote(t *testing.T) {
53+
_, _, _, _, err := parseCopyArguments("ws1:/a", "ws2:/b")
54+
if err == nil {
55+
t.Fatal("expected error when both paths are remote")
56+
}
57+
}
58+
59+
func TestParseWorkspacePath_Local(t *testing.T) {
60+
ws, fp, err := parseWorkspacePath("/tmp/local/file")
61+
if err != nil {
62+
t.Fatalf("unexpected error: %v", err)
63+
}
64+
if ws != "" {
65+
t.Errorf("expected empty workspace, got %s", ws)
66+
}
67+
if fp != "/tmp/local/file" {
68+
t.Errorf("expected /tmp/local/file, got %s", fp)
69+
}
70+
}
71+
72+
func TestParseWorkspacePath_Remote(t *testing.T) {
73+
ws, fp, err := parseWorkspacePath("my-instance:/remote/path")
74+
if err != nil {
75+
t.Fatalf("unexpected error: %v", err)
76+
}
77+
if ws != "my-instance" {
78+
t.Errorf("expected my-instance, got %s", ws)
79+
}
80+
if fp != "/remote/path" {
81+
t.Errorf("expected /remote/path, got %s", fp)
82+
}
83+
}
84+
85+
func TestParseWorkspacePath_InvalidMultipleColons(t *testing.T) {
86+
_, _, err := parseWorkspacePath("ws:path:extra")
87+
if err == nil {
88+
t.Fatal("expected error for multiple colons")
89+
}
90+
}

pkg/cmd/open/open_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,43 @@
11
package open
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestIsEditorType(t *testing.T) {
8+
valid := []string{"code", "cursor", "windsurf", "terminal", "tmux"}
9+
for _, v := range valid {
10+
if !isEditorType(v) {
11+
t.Errorf("expected %q to be valid editor type", v)
12+
}
13+
}
14+
15+
invalid := []string{"vim", "emacs", "vscode", "Code", "", "ssh"}
16+
for _, v := range invalid {
17+
if isEditorType(v) {
18+
t.Errorf("expected %q to NOT be valid editor type", v)
19+
}
20+
}
21+
}
22+
23+
func TestGetEditorName(t *testing.T) {
24+
tests := []struct {
25+
input string
26+
want string
27+
}{
28+
{"code", "VSCode"},
29+
{"cursor", "Cursor"},
30+
{"windsurf", "Windsurf"},
31+
{"terminal", "Terminal"},
32+
{"tmux", "tmux"},
33+
{"unknown", "VSCode"},
34+
}
35+
for _, tt := range tests {
36+
t.Run(tt.input, func(t *testing.T) {
37+
got := getEditorName(tt.input)
38+
if got != tt.want {
39+
t.Errorf("getEditorName(%q) = %q, want %q", tt.input, got, tt.want)
40+
}
41+
})
42+
}
43+
}

pkg/cmd/portforward/portforward.go

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"os/exec"
77
"os/signal"
88
"path/filepath"
9+
"strconv"
910
"strings"
1011

12+
nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
13+
1114
"github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
1215
"github.com/brevdev/brev-cli/pkg/cmd/completions"
1316
"github.com/brevdev/brev-cli/pkg/cmd/refresh"
@@ -20,7 +23,7 @@ import (
2023

2124
var (
2225
sshLinkLong = "Port forward your Brev machine's port to your local port"
23-
sshLinkExample = "brev port-forward <ws_name> -p local_port:remote_port"
26+
sshLinkExample = "brev port-forward <name> -p local_port:remote_port"
2427
)
2528

2629
type PortforwardStore interface {
@@ -53,7 +56,7 @@ func NewCmdPortForwardSSH(pfStore PortforwardStore, t *terminal.Terminal) *cobra
5356
return nil
5457
},
5558
}
56-
cmd.Flags().StringVarP(&port, "port", "p", "", "port forward flag describe me better")
59+
cmd.Flags().StringVarP(&port, "port", "p", "", "port forward string, local_port:remote_port")
5760
cmd.Flags().BoolVar(&useHost, "host", false, "Use the -host version of the instance")
5861
err := cmd.RegisterFlagCompletionFunc("port", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
5962
return nil, cobra.ShellCompDirectiveNoSpace
@@ -66,30 +69,47 @@ func NewCmdPortForwardSSH(pfStore PortforwardStore, t *terminal.Terminal) *cobra
6669
return cmd
6770
}
6871

72+
// parsePortString validates and splits a "local:remote" port string.
73+
func parsePortString(portString string) (localPort, remotePort string, err error) {
74+
if !strings.Contains(portString, ":") {
75+
return "", "", breverrors.NewValidationError("port format invalid, use local_port:remote_port")
76+
}
77+
parts := strings.Split(portString, ":")
78+
if len(parts) != 2 {
79+
return "", "", breverrors.NewValidationError("port format invalid, use local_port:remote_port")
80+
}
81+
return parts[0], parts[1], nil
82+
}
83+
84+
// isPortAlreadyAllocatedError returns true if the error indicates the port is already open.
85+
func isPortAlreadyAllocatedError(err error) bool {
86+
return err != nil && strings.Contains(err.Error(), "already allocated")
87+
}
88+
6989
func RunPortforward(pfStore PortforwardStore, nameOrID string, portString string, useHost bool) error {
70-
var portSplit []string
71-
if strings.Contains(portString, ":") {
72-
portSplit = strings.Split(portString, ":")
73-
if len(portSplit) != 2 {
74-
return breverrors.NewValidationError("port format invalid, use local_port:remote_port")
75-
}
76-
} else {
77-
return breverrors.NewValidationError("port format invalid, use local_port:remote_port")
90+
localPort, remotePort, err := parsePortString(portString)
91+
if err != nil {
92+
return err
7893
}
7994

8095
res := refresh.RunRefreshAsync(pfStore)
8196

8297
sshName, err := ConvertNametoSSHName(pfStore, nameOrID, useHost)
8398
if err != nil {
84-
return breverrors.WrapAndTrace(err)
99+
// Workspace not found — try external nodes.
100+
node, nodeErr := util.FindExternalNode(pfStore, nameOrID)
101+
if nodeErr != nil || node == nil {
102+
return breverrors.WrapAndTrace(err) // return original workspace error
103+
}
104+
return portForwardExternalNode(pfStore, res, node, localPort, remotePort)
85105
}
86106

87107
err = res.Await()
88108
if err != nil {
89109
return breverrors.WrapAndTrace(err)
90110
}
91111

92-
_, err = RunSSHPortForward("-L", portSplit[0], portSplit[1], sshName)
112+
_, err = RunSSHPortForward("-L", localPort, remotePort, sshName)
93113
if err != nil {
94114
return breverrors.WrapAndTrace(err)
95115
}
@@ -108,6 +128,49 @@ func ConvertNametoSSHName(store PortforwardStore, workspaceNameOrID string, useH
108128
return sshName, nil
109129
}
110130

131+
func portForwardExternalNode(pfStore PortforwardStore, res *refresh.RefreshRes, node *nodev1.ExternalNode, localPort, remotePort string) error {
132+
info, err := util.ResolveExternalNodeSSH(pfStore, node)
133+
if err != nil {
134+
return breverrors.WrapAndTrace(err)
135+
}
136+
137+
// Parse the remote port so we can open it via the OpenPort RPC.
138+
remotePortNum, err := strconv.ParseInt(remotePort, 10, 32)
139+
if err != nil {
140+
return breverrors.WrapAndTrace(fmt.Errorf("invalid remote port %q: %w", remotePort, err))
141+
}
142+
143+
// Open the port on the netbird side so it's accessible.
144+
// This binding persists after the CLI exits — it won't be closed on Ctrl+C.
145+
fmt.Printf("Opening port %s on node %q...\n", remotePort, node.GetName())
146+
_, err = util.OpenPort(pfStore, node.GetExternalNodeId(), int32(remotePortNum), nodev1.PortProtocol_PORT_PROTOCOL_TCP)
147+
if err != nil {
148+
// Port already allocated is not a real error — it's already open.
149+
if isPortAlreadyAllocatedError(err) {
150+
fmt.Printf("Port %s is already open on the remote node.\n", remotePort)
151+
} else {
152+
return breverrors.WrapAndTrace(err)
153+
}
154+
} else {
155+
fmt.Printf("Port %s is now bound on the remote node. Note: this binding persists after this command exits.\n", remotePort)
156+
}
157+
158+
if err := res.Await(); err != nil {
159+
return breverrors.WrapAndTrace(err)
160+
}
161+
162+
// The SSH tunnel forwards local traffic through the SSH connection to the
163+
// actual port on the box. Ctrl+C stops the local forward but not the
164+
// remote port binding.
165+
alias := info.SSHAlias()
166+
fmt.Printf("Setting up local forward: localhost:%s -> %s:%s\n", localPort, alias, remotePort)
167+
_, err = RunSSHPortForward("-L", localPort, remotePort, alias)
168+
if err != nil {
169+
return breverrors.WrapAndTrace(err)
170+
}
171+
return nil
172+
}
173+
111174
func RunSSHPortForward(forwardType string, localPort string, remotePort string, sshName string) (*os.Process, error) {
112175
signals := make(chan os.Signal, 1)
113176
signal.Notify(signals, os.Interrupt)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,70 @@
11
package portforward
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
)
7+
8+
func TestParsePortString_Valid(t *testing.T) {
9+
local, remote, err := parsePortString("8080:3000")
10+
if err != nil {
11+
t.Fatalf("unexpected error: %v", err)
12+
}
13+
if local != "8080" {
14+
t.Errorf("expected local 8080, got %s", local)
15+
}
16+
if remote != "3000" {
17+
t.Errorf("expected remote 3000, got %s", remote)
18+
}
19+
}
20+
21+
func TestParsePortString_SamePort(t *testing.T) {
22+
local, remote, err := parsePortString("8080:8080")
23+
if err != nil {
24+
t.Fatalf("unexpected error: %v", err)
25+
}
26+
if local != "8080" || remote != "8080" {
27+
t.Errorf("expected 8080:8080, got %s:%s", local, remote)
28+
}
29+
}
30+
31+
func TestParsePortString_NoColon(t *testing.T) {
32+
_, _, err := parsePortString("8080")
33+
if err == nil {
34+
t.Fatal("expected error for missing colon")
35+
}
36+
}
37+
38+
func TestParsePortString_TooManyColons(t *testing.T) {
39+
_, _, err := parsePortString("8080:3000:443")
40+
if err == nil {
41+
t.Fatal("expected error for too many colons")
42+
}
43+
}
44+
45+
func TestParsePortString_Empty(t *testing.T) {
46+
_, _, err := parsePortString("")
47+
if err == nil {
48+
t.Fatal("expected error for empty string")
49+
}
50+
}
51+
52+
func TestIsPortAlreadyAllocatedError_True(t *testing.T) {
53+
err := fmt.Errorf("skybridge API error: 400, body: Port 8080 is already allocated for this client")
54+
if !isPortAlreadyAllocatedError(err) {
55+
t.Error("expected true for 'already allocated' error")
56+
}
57+
}
58+
59+
func TestIsPortAlreadyAllocatedError_False(t *testing.T) {
60+
err := fmt.Errorf("connection refused")
61+
if isPortAlreadyAllocatedError(err) {
62+
t.Error("expected false for unrelated error")
63+
}
64+
}
65+
66+
func TestIsPortAlreadyAllocatedError_Nil(t *testing.T) {
67+
if isPortAlreadyAllocatedError(nil) {
68+
t.Error("expected false for nil error")
69+
}
70+
}

pkg/cmd/refresh/refresh.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ func getExternalNodeSSHEntries(store RefreshStore) []ssh.ExternalNodeSSHEntry {
216216
entries = append(entries, ssh.ExternalNodeSSHEntry{
217217
Alias: ssh.SanitizeNodeName(node.GetName()),
218218
Hostname: sshPort.GetHostname(),
219-
Port: sshPort.GetPortNumber(),
219+
Port: sshPort.GetServerPort(),
220220
User: linuxUser,
221221
})
222222
}

0 commit comments

Comments
 (0)