diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index f054e452..5f29bcb5 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -170,6 +170,10 @@ brev copy my-instance:/remote/file ./local-path/ # Port forward brev port-forward my-instance -p 8080:8080 + +# List public HTTP and TCP/UDP ports for an instance or external node +brev ports my-instance +brev ports my-node --json ``` ### Listing Instances and Nodes diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index fe91cbbd..de7c3aa0 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -466,6 +466,29 @@ brev port-forward my-instance -p 8080:8080 brev port-forward my-instance -p 3000:3000 ``` +### brev ports +List public HTTP applications and TCP/UDP port mappings for a managed instance +or registered compute node. + +```bash +brev ports [flags] +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--json` | Output the port mappings as JSON | + +The table output includes endpoint, IP restrictions, public port, destination +port, and protocol. HTTP applications also include their authorization policy. + +**Examples:** +```bash +brev ports my-instance +brev ports my-node +brev ports my-instance --json +``` + ## Organization Commands ### brev org ls diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 49b5254d..831a2aa9 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -35,6 +35,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/open" "github.com/brevdev/brev-cli/pkg/cmd/org" "github.com/brevdev/brev-cli/pkg/cmd/portforward" + "github.com/brevdev/brev-cli/pkg/cmd/ports" "github.com/brevdev/brev-cli/pkg/cmd/profile" "github.com/brevdev/brev-cli/pkg/cmd/proxy" "github.com/brevdev/brev-cli/pkg/cmd/redeem" @@ -276,6 +277,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(invite.NewCmdInvite(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(redeem.NewCmdRedeem(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(portforward.NewCmdPortForwardSSH(loginCmdStore, t)) + cmd.AddCommand(ports.NewCmdPorts(loginCmdStore)) cmd.AddCommand(login.NewCmdLogin(t, noLoginCmdStore, loginAuth)) cmd.AddCommand(logout.NewCmdLogout(loginAuth, noLoginCmdStore)) cmd.AddCommand(tasks.NewCmdTasks(t, noLoginCmdStore)) diff --git a/pkg/cmd/ports/ports.go b/pkg/cmd/ports/ports.go new file mode 100644 index 00000000..643cb6df --- /dev/null +++ b/pkg/cmd/ports/ports.go @@ -0,0 +1,306 @@ +// Package ports displays the public port mappings for an instance or external node. +package ports + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "strconv" + "strings" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + + "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" + "github.com/brevdev/brev-cli/pkg/cmd/register" + cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" + "github.com/brevdev/brev-cli/pkg/config" + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +// Store contains the dependencies needed to resolve both managed instances and +// registered compute nodes. +type Store interface { + cmdutil.WorkspaceOrNodeResolver +} + +// PortInfo is the stable JSON representation of a port mapping. +type PortInfo struct { + PortID string `json:"port_id"` + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` + PublicPort int32 `json:"public_port"` + DestinationPort int32 `json:"destination_port"` + Protocol string `json:"protocol"` + AllowedSources []string `json:"allowed_sources"` + AuthorizedEmails []string `json:"authorized_emails"` + AllowPublicUnauthenticated bool `json:"allow_public_unauthenticated"` + Type string `json:"type"` +} + +// NewCmdPorts creates the `brev ports` command. +func NewCmdPorts(portStore Store) *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Annotations: map[string]string{"access": ""}, + Use: "ports ", + DisableFlagsInUseLine: true, + Short: "List public ports for an instance or external node", + Example: ` + brev ports my-instance + brev ports my-node --json`, + Args: cmderrors.TransformToValidationError(cobra.ExactArgs(1)), + RunE: func(cmd *cobra.Command, args []string) error { + if err := Run(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], jsonOutput); err != nil { + return breverrors.WrapAndTrace(err) + } + return nil + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") + return cmd +} + +// Run resolves a managed instance or registered compute node and displays its ports. +func Run(ctx context.Context, out io.Writer, portStore Store, nameOrID string, jsonOutput bool) error { + target, err := cmdutil.ResolveWorkspaceOrNode(portStore, nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + var apiPorts []*devplanev1.Port + if target.Workspace != nil { + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.GetNetworkInfo(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceGetNetworkInfoRequest{ + EnvironmentId: target.Workspace.ID, + })) + if err != nil { + return fmt.Errorf("get ports for instance %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil && resp.Msg.GetNetworkInfo() != nil { + apiPorts = resp.Msg.GetNetworkInfo().GetPorts() + } + } else if target.Node != nil { + apiPorts = target.Node.GetPorts() + } + portInfos := toPortInfos(apiPorts) + if jsonOutput { + return writeJSON(out, portInfos) + } + return displayTables(out, nameOrID, portInfos) +} + +func toPortInfos(apiPorts []*devplanev1.Port) []PortInfo { + portInfos := make([]PortInfo, 0, len(apiPorts)) + for _, port := range apiPorts { + if port == nil { + continue + } + isHTTP := port.GetHttpProtocol() != devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_UNSPECIFIED + kind := "tcp_udp" + if isHTTP { + kind = "http" + } + portInfos = append(portInfos, PortInfo{ + PortID: port.GetPortId(), + Kind: kind, + Endpoint: endpoint(port, isHTTP), + PublicPort: port.GetPortNumber(), + DestinationPort: port.GetServerPort(), + Protocol: protocolLabel(port, isHTTP), + AllowedSources: append([]string{}, port.GetAllowedSources()...), + AuthorizedEmails: append([]string{}, port.GetAuthorizedEmails()...), + AllowPublicUnauthenticated: port.GetAllowPublicUnauthenticated(), + Type: portTypeLabel(port.GetType()), + }) + } + return portInfos +} + +func endpoint(port *devplanev1.Port, isHTTP bool) string { + hostname := port.GetHostname() + if hostname == "" { + return "" + } + if isHTTP { + return "https://" + hostname + } + if port.GetPortNumber() == 0 { + return hostname + } + return net.JoinHostPort(hostname, strconv.Itoa(int(port.GetPortNumber()))) +} + +func protocolLabel(port *devplanev1.Port, isHTTP bool) string { + if isHTTP { + switch port.GetHttpProtocol() { + case devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP: + return "HTTP" + case devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS: + return "HTTPS" + default: + return "UNKNOWN" + } + } + + switch port.GetProtocol() { + case devplanev1.PortProtocol_PORT_PROTOCOL_SSH: + return "SSH" + case devplanev1.PortProtocol_PORT_PROTOCOL_TCP: + return "TCP" + case devplanev1.PortProtocol_PORT_PROTOCOL_UDP: + return "UDP" + default: + return "UNKNOWN" + } +} + +func portTypeLabel(portType devplanev1.PortType) string { + switch portType { + case devplanev1.PortType_PORT_TYPE_SYSTEM: + return "system" + case devplanev1.PortType_PORT_TYPE_USER: + return "user" + default: + return "unspecified" + } +} + +func writeJSON(out io.Writer, portInfos []PortInfo) error { + encoded, err := json.MarshalIndent(portInfos, "", " ") + if err != nil { + return breverrors.WrapAndTrace(err) + } + _, err = fmt.Fprintln(out, string(encoded)) + return breverrors.WrapAndTrace(err) +} + +func displayTables(out io.Writer, nameOrID string, portInfos []PortInfo) error { + if len(portInfos) == 0 { + _, err := fmt.Fprintf(out, "No ports are open on %s.\n", nameOrID) + return breverrors.WrapAndTrace(err) + } + + httpPorts := make([]PortInfo, 0, len(portInfos)) + networkPorts := make([]PortInfo, 0, len(portInfos)) + for _, port := range portInfos { + if port.Kind == "http" { + httpPorts = append(httpPorts, port) + } else { + networkPorts = append(networkPorts, port) + } + } + + if len(httpPorts) > 0 { + if _, err := fmt.Fprintln(out, "HTTP APPLICATIONS"); err != nil { + return breverrors.WrapAndTrace(err) + } + displayHTTPTable(out, httpPorts) + } + if len(httpPorts) > 0 && len(networkPorts) > 0 { + if _, err := fmt.Fprintln(out); err != nil { + return breverrors.WrapAndTrace(err) + } + } + if len(networkPorts) > 0 { + if _, err := fmt.Fprintln(out, "TCP/UDP PORTS"); err != nil { + return breverrors.WrapAndTrace(err) + } + displayNetworkTable(out, networkPorts) + } + return nil +} + +func displayHTTPTable(out io.Writer, portInfos []PortInfo) { + tw := newTable(out) + tw.AppendHeader(table.Row{"ENDPOINT", "AUTHORIZATION", "IP RESTRICTIONS", "PUBLIC", "DESTINATION", "PROTOCOL"}) + for _, port := range portInfos { + destinationPort := port.DestinationPort + if destinationPort == 0 { + destinationPort = port.PublicPort + } + tw.AppendRow(table.Row{ + valueOrDash(port.Endpoint), + authorizationLabel(port), + allowedSourcesLabel(port.AllowedSources), + portNumberLabel(port.PublicPort), + portNumberLabel(destinationPort), + port.Protocol, + }) + } + tw.Render() +} + +func displayNetworkTable(out io.Writer, portInfos []PortInfo) { + tw := newTable(out) + tw.AppendHeader(table.Row{"ENDPOINT", "IP RESTRICTIONS", "PUBLIC", "DESTINATION", "PROTOCOL"}) + for _, port := range portInfos { + tw.AppendRow(table.Row{ + valueOrDash(port.Endpoint), + allowedSourcesLabel(port.AllowedSources), + portNumberLabel(port.PublicPort), + portNumberLabel(port.DestinationPort), + port.Protocol, + }) + } + tw.Render() +} + +func newTable(out io.Writer) table.Writer { + tw := table.NewWriter() + tw.SetOutputMirror(out) + options := table.OptionsDefault + options.DrawBorder = false + options.SeparateColumns = false + options.SeparateRows = false + options.SeparateHeader = false + tw.Style().Options = options + return tw +} + +func authorizationLabel(port PortInfo) string { + if port.AllowPublicUnauthenticated { + return "Public" + } + if len(port.AuthorizedEmails) > 0 { + return strings.Join(port.AuthorizedEmails, ", ") + } + return "-" +} + +func allowedSourcesLabel(allowedSources []string) string { + if len(allowedSources) == 0 { + return "Anywhere" + } + allAnywhere := true + for _, source := range allowedSources { + if source != "0.0.0.0/0" { + allAnywhere = false + break + } + } + if allAnywhere { + return "Anywhere" + } + return strings.Join(allowedSources, ", ") +} + +func portNumberLabel(port int32) string { + if port == 0 { + return "-" + } + return strconv.Itoa(int(port)) +} + +func valueOrDash(value string) string { + if value == "" { + return "-" + } + return value +} diff --git a/pkg/cmd/ports/ports_test.go b/pkg/cmd/ports/ports_test.go new file mode 100644 index 00000000..180da3dd --- /dev/null +++ b/pkg/cmd/ports/ports_test.go @@ -0,0 +1,231 @@ +package ports + +import ( + "bytes" + "context" + "net/http/httptest" + "testing" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/brevdev/brev-cli/pkg/entity" +) + +type fakeStore struct { + workspaces []entity.Workspace + user *entity.User + org *entity.Organization +} + +func (s *fakeStore) GetAuthTokens() (*entity.AuthTokens, error) { + return nil, nil +} + +func (s *fakeStore) GetActiveOrganizationOrDefault() (*entity.Organization, error) { + return s.org, nil +} + +func (s *fakeStore) GetWorkspaceByNameOrID(_ string, _ string) ([]entity.Workspace, error) { + return s.workspaces, nil +} + +func (s *fakeStore) GetCurrentUser() (*entity.User, error) { + return s.user, nil +} + +func (s *fakeStore) GetAccessToken() (string, error) { + return "test-token", nil +} + +type fakeEnvironmentService struct { + devplanev1connect.UnimplementedEnvironmentServiceHandler + t *testing.T + expectedEnvID string + ports []*devplanev1.Port +} + +func (s *fakeEnvironmentService) GetNetworkInfo( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceGetNetworkInfoRequest], +) (*connect.Response[devplanev1.EnvironmentServiceGetNetworkInfoResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.expectedEnvID, req.Msg.GetEnvironmentId()) + return connect.NewResponse(&devplanev1.EnvironmentServiceGetNetworkInfoResponse{ + NetworkInfo: &devplanev1.EnvironmentNetworkInfo{Ports: s.ports}, + }), nil +} + +type fakeNodeService struct { + devplanev1connect.UnimplementedExternalNodeServiceHandler + nodes []*devplanev1.ExternalNode +} + +func (s *fakeNodeService) ListNodes( + _ context.Context, + _ *connect.Request[devplanev1.ListNodesRequest], +) (*connect.Response[devplanev1.ListNodesResponse], error) { + return connect.NewResponse(&devplanev1.ListNodesResponse{Items: s.nodes}), nil +} + +func TestRunEnvironmentJSON(t *testing.T) { + public := false + hostname := "jupyter-env123.apps.run.brev.nvidia.com" + service := &fakeEnvironmentService{ + t: t, + expectedEnvID: "env123", + ports: []*devplanev1.Port{ + { + PortId: "port-http", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + PortNumber: 443, + ServerPort: 8888, + Hostname: &hostname, + AuthorizedEmails: []string{"user@example.com"}, + AllowPublicUnauthenticated: &public, + Type: devplanev1.PortType_PORT_TYPE_SYSTEM, + }, + }, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + t.Setenv("BREV_PUBLIC_API_URL", server.URL) + + store := &fakeStore{ + workspaces: []entity.Workspace{{ID: "env123", Name: "my-instance", CreatedByUserID: "user1"}}, + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := Run(context.Background(), &out, store, "my-instance", true) + + require.NoError(t, err) + assert.JSONEq(t, `[ + { + "port_id": "port-http", + "kind": "http", + "endpoint": "https://jupyter-env123.apps.run.brev.nvidia.com", + "public_port": 443, + "destination_port": 8888, + "protocol": "HTTP", + "allowed_sources": [], + "authorized_emails": ["user@example.com"], + "allow_public_unauthenticated": false, + "type": "system" + } + ]`, out.String()) +} + +func TestRunExternalNodeByIDDisplaysTables(t *testing.T) { + httpHostname := "jupyter-node.apps.run.brev.nvidia.com" + tcpHostname := "global.prd.ga.run.brev.nvidia.com" + service := &fakeNodeService{nodes: []*devplanev1.ExternalNode{ + { + ExternalNodeId: "unode123", + Name: "my-node", + Ports: []*devplanev1.Port{ + { + PortId: "port-http", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + PortNumber: 443, + ServerPort: 8888, + Hostname: &httpHostname, + AuthorizedEmails: []string{"user@example.com"}, + }, + { + PortId: "port-tcp", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_TCP, + PortNumber: 18928, + ServerPort: 22, + Hostname: &tcpHostname, + AllowedSources: []string{"0.0.0.0/0"}, + }, + }, + }, + }} + _, handler := devplanev1connect.NewExternalNodeServiceHandler(service) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + t.Setenv("BREV_PUBLIC_API_URL", server.URL) + + store := &fakeStore{ + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := Run(context.Background(), &out, store, "unode123", false) + + require.NoError(t, err) + assert.Contains(t, out.String(), "HTTP APPLICATIONS") + assert.Contains(t, out.String(), "https://jupyter-node.apps.run.brev.nvidia.com") + assert.Contains(t, out.String(), "user@example.com") + assert.Contains(t, out.String(), "TCP/UDP PORTS") + assert.Contains(t, out.String(), "global.prd.ga.run.brev.nvidia.com:18928") + assert.Contains(t, out.String(), "Anywhere") + assert.Contains(t, out.String(), "22") + assert.Contains(t, out.String(), "TCP") +} + +func TestToPortInfosHandlesPublicHTTPAndRestrictedUDP(t *testing.T) { + public := true + httpHostname := "app.example.com" + udpHostname := "gateway.example.com" + + got := toPortInfos([]*devplanev1.Port{ + { + PortId: "http", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, + PortNumber: 443, + ServerPort: 8443, + Hostname: &httpHostname, + AllowPublicUnauthenticated: &public, + }, + { + PortId: "udp", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_UDP, + PortNumber: 5000, + ServerPort: 5001, + Hostname: &udpHostname, + AllowedSources: []string{"10.0.0.0/8"}, + Type: devplanev1.PortType_PORT_TYPE_USER, + }, + nil, + }) + + require.Len(t, got, 2) + assert.Equal(t, "http", got[0].Kind) + assert.Equal(t, "HTTPS", got[0].Protocol) + assert.Equal(t, "https://app.example.com", got[0].Endpoint) + assert.True(t, got[0].AllowPublicUnauthenticated) + assert.Equal(t, "tcp_udp", got[1].Kind) + assert.Equal(t, "UDP", got[1].Protocol) + assert.Equal(t, "gateway.example.com:5000", got[1].Endpoint) + assert.Equal(t, []string{"10.0.0.0/8"}, got[1].AllowedSources) + assert.Equal(t, "user", got[1].Type) +} + +func TestRunEmptyPortsJSONIsArray(t *testing.T) { + service := &fakeEnvironmentService{t: t, expectedEnvID: "env-empty"} + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + t.Setenv("BREV_PUBLIC_API_URL", server.URL) + + store := &fakeStore{ + workspaces: []entity.Workspace{{ID: "env-empty", Name: "empty", CreatedByUserID: "user1"}}, + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := Run(context.Background(), &out, store, "empty", true) + + require.NoError(t, err) + assert.JSONEq(t, `[]`, out.String()) +} diff --git a/pkg/cmd/util/externalnode.go b/pkg/cmd/util/externalnode.go index 0db4b3ab..8ee18338 100644 --- a/pkg/cmd/util/externalnode.go +++ b/pkg/cmd/util/externalnode.go @@ -124,7 +124,7 @@ func OpenPort(store ExternalNodeStore, nodeID string, portNumber int32, protocol return resp.Msg.GetPort(), nil } -// FindExternalNode searches for an external node by name in the user's active organization. +// FindExternalNode searches for an external node by name or ID in the user's active organization. // Returns (nil, nil) if no matching node is found. func FindExternalNode(store ExternalNodeStore, name string) (*nodev1.ExternalNode, error) { org, err := store.GetActiveOrganizationOrDefault() @@ -139,7 +139,7 @@ func FindExternalNode(store ExternalNodeStore, name string) (*nodev1.ExternalNod return nil, breverrors.WrapAndTrace(err) } for _, node := range resp.Msg.GetItems() { - if strings.EqualFold(node.GetName(), name) { + if strings.EqualFold(node.GetName(), name) || node.GetExternalNodeId() == name { return node, nil } }