From d65d472081532bcc7919bb72b62725a656bc9b4d Mon Sep 17 00:00:00 2001 From: Allen Chen Date: Wed, 12 Aug 2026 08:22:43 -0700 Subject: [PATCH 1/3] Add command to open instance ports --- .agents/skills/brev-cli/SKILL.md | 4 + .agents/skills/brev-cli/reference/commands.md | 26 ++ pkg/cmd/ports/open.go | 182 +++++++++++++ pkg/cmd/ports/open_test.go | 250 ++++++++++++++++++ pkg/cmd/ports/ports.go | 2 +- 5 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/ports/open.go create mode 100644 pkg/cmd/ports/open_test.go diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index a7c862f9..f1f1481d 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -174,6 +174,10 @@ brev port-forward my-instance -p 8080:8080 # List Brev-managed HTTP and network ports for an instance or external node brev ports ls my-instance brev ports ls my-node --json + +# Open a public port (TCP by default) +brev ports open my-instance 8080 +brev ports open my-node 53 --protocol udp --allow 203.0.113.10/32 ``` ### Listing Instances and Nodes diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index e1943e3e..b4efe229 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -511,6 +511,32 @@ brev ports ls my-node brev ports ls my-instance --json ``` +#### Open a port + +Open a raw TCP, UDP, or SSH port on a managed instance or registered compute +node. `add` is an alias for `open`. + +```bash +brev ports open [flags] +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--protocol` | Port protocol: `tcp` (default), `udp`, or `ssh` | +| `--allow` | Source CIDR allowed to connect; repeat to add more than one | +| `--json` | Output the opened port as JSON | + +Omit `--allow` to allow connections from any source. + +**Examples:** +```bash +brev ports open my-instance 8080 +brev ports open my-node 53 --protocol udp +brev ports open my-instance 8080 --allow 203.0.113.10/32 +brev ports add my-node 2222 --protocol ssh --json +``` + ## Organization Commands ### brev org ls diff --git a/pkg/cmd/ports/open.go b/pkg/cmd/ports/open.go new file mode 100644 index 00000000..afa13eea --- /dev/null +++ b/pkg/cmd/ports/open.go @@ -0,0 +1,182 @@ +package ports + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "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" +) + +// NewCmdOpenPort creates the `brev ports open` command. +func NewCmdOpenPort(portStore Store) *cobra.Command { + var protocol string + var allowedSources []string + var jsonOutput bool + + cmd := &cobra.Command{ + Annotations: map[string]string{"access": ""}, + Use: "open ", + Aliases: []string{"add"}, + DisableFlagsInUseLine: true, + Short: "Open a public port on an instance or external node", + Example: ` + brev ports open my-instance 8080 + brev ports open my-node 53 --protocol udp + brev ports open my-instance 8080 --allow 203.0.113.10/32`, + Args: cmderrors.TransformToValidationError(cobra.ExactArgs(2)), + RunE: func(cmd *cobra.Command, args []string) error { + portNumber, err := parsePortNumber(args[1]) + if err != nil { + return err + } + portProtocol, err := parseProtocol(protocol) + if err != nil { + return err + } + allowedSources, err = normalizeAllowedSources(allowedSources) + if err != nil { + return err + } + if err := Open(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], portNumber, portProtocol, allowedSources, jsonOutput); err != nil { + return breverrors.WrapAndTrace(err) + } + return nil + }, + } + + cmd.Flags().StringVar(&protocol, "protocol", "tcp", "port protocol (tcp, udp, or ssh)") + cmd.Flags().StringArrayVar(&allowedSources, "allow", nil, "source CIDR allowed to connect (repeatable; omit to allow all)") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output the opened port as JSON") + _ = cmd.RegisterFlagCompletionFunc("protocol", cobra.FixedCompletions( + []string{"tcp", "udp", "ssh"}, + cobra.ShellCompDirectiveNoFileComp, + )) + + return cmd +} + +// Open resolves a managed instance or registered compute node and opens a port. +func Open( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + protocol devplanev1.PortProtocol, + allowedSources []string, + jsonOutput bool, +) error { + target, err := cmdutil.ResolveWorkspaceOrNode(portStore, nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + var openedPort *devplanev1.Port + if target.Workspace != nil { + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenPort(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceOpenPortRequest{ + EnvironmentId: target.Workspace.ID, + Protocol: protocol, + PortNumber: portNumber, + AllowedSources: allowedSources, + })) + if err != nil { + return fmt.Errorf("open port on instance %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } else if target.Node != nil { + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenPort(ctx, connect.NewRequest(&devplanev1.OpenPortRequest{ + ExternalNodeId: target.Node.GetExternalNodeId(), + Protocol: protocol, + PortNumber: portNumber, + AllowedSources: allowedSources, + })) + if err != nil { + return fmt.Errorf("open port on external node %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } + + if openedPort == nil { + return fmt.Errorf("open port on %q: API returned no port", nameOrID) + } + return writeOpenResult(out, nameOrID, openedPort, jsonOutput) +} + +func parsePortNumber(value string) (int32, error) { + portNumber, err := strconv.ParseInt(value, 10, 32) + if err != nil || portNumber < 1 || portNumber > 65535 { + return 0, fmt.Errorf("invalid port %q: must be a number between 1 and 65535", value) + } + return int32(portNumber), nil +} + +func parseProtocol(value string) (devplanev1.PortProtocol, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "tcp": + return devplanev1.PortProtocol_PORT_PROTOCOL_TCP, nil + case "udp": + return devplanev1.PortProtocol_PORT_PROTOCOL_UDP, nil + case "ssh": + return devplanev1.PortProtocol_PORT_PROTOCOL_SSH, nil + default: + return devplanev1.PortProtocol_PORT_PROTOCOL_UNSPECIFIED, + fmt.Errorf("invalid protocol %q: must be tcp, udp, or ssh", value) + } +} + +func normalizeAllowedSources(values []string) ([]string, error) { + if len(values) == 0 { + return nil, nil + } + + normalized := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("allowed source cannot be empty") + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + return normalized, nil +} + +func writeOpenResult(out io.Writer, nameOrID string, port *devplanev1.Port, jsonOutput bool) error { + portInfo := toPortInfos([]*devplanev1.Port{port})[0] + if jsonOutput { + encoded, err := json.MarshalIndent(portInfo, "", " ") + if err != nil { + return breverrors.WrapAndTrace(err) + } + _, err = fmt.Fprintln(out, string(encoded)) + return breverrors.WrapAndTrace(err) + } + + _, err := fmt.Fprintf(out, "Opened %s port %d on %s.\n", portInfo.Protocol, port.GetServerPort(), nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return displayTables(out, nameOrID, []PortInfo{portInfo}) +} diff --git a/pkg/cmd/ports/open_test.go b/pkg/cmd/ports/open_test.go new file mode 100644 index 00000000..ae4f734e --- /dev/null +++ b/pkg/cmd/ports/open_test.go @@ -0,0 +1,250 @@ +package ports + +import ( + "bytes" + "context" + "net/http" + "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" +) + +func newTestServer(t *testing.T, handler http.Handler) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + t.Setenv("BREV_PUBLIC_API_URL", server.URL) +} + +type fakeOpenEnvironmentService struct { + devplanev1connect.UnimplementedEnvironmentServiceHandler + t *testing.T + wantReq *devplanev1.EnvironmentServiceOpenPortRequest + port *devplanev1.Port +} + +func (s *fakeOpenEnvironmentService) OpenPort( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceOpenPortRequest], +) (*connect.Response[devplanev1.EnvironmentServiceOpenPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantReq.GetEnvironmentId(), req.Msg.GetEnvironmentId()) + assert.Equal(s.t, s.wantReq.GetPortNumber(), req.Msg.GetPortNumber()) + assert.Equal(s.t, s.wantReq.GetProtocol(), req.Msg.GetProtocol()) + assert.Equal(s.t, s.wantReq.GetAllowedSources(), req.Msg.GetAllowedSources()) + return connect.NewResponse(&devplanev1.EnvironmentServiceOpenPortResponse{Port: s.port}), nil +} + +type fakeOpenNodeService struct { + devplanev1connect.UnimplementedExternalNodeServiceHandler + t *testing.T + node *devplanev1.ExternalNode + wantReq *devplanev1.OpenPortRequest + port *devplanev1.Port +} + +func (s *fakeOpenNodeService) ListNodes( + _ context.Context, + _ *connect.Request[devplanev1.ListNodesRequest], +) (*connect.Response[devplanev1.ListNodesResponse], error) { + return connect.NewResponse(&devplanev1.ListNodesResponse{Items: []*devplanev1.ExternalNode{s.node}}), nil +} + +func (s *fakeOpenNodeService) OpenPort( + _ context.Context, + req *connect.Request[devplanev1.OpenPortRequest], +) (*connect.Response[devplanev1.OpenPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantReq.GetExternalNodeId(), req.Msg.GetExternalNodeId()) + assert.Equal(s.t, s.wantReq.GetPortNumber(), req.Msg.GetPortNumber()) + assert.Equal(s.t, s.wantReq.GetProtocol(), req.Msg.GetProtocol()) + assert.Equal(s.t, s.wantReq.GetAllowedSources(), req.Msg.GetAllowedSources()) + return connect.NewResponse(&devplanev1.OpenPortResponse{Port: s.port}), nil +} + +func TestOpenEnvironment(t *testing.T) { + service := &fakeOpenEnvironmentService{ + t: t, + wantReq: &devplanev1.EnvironmentServiceOpenPortRequest{ + EnvironmentId: "env123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_TCP, + PortNumber: 8080, + AllowedSources: []string{"203.0.113.10/32"}, + }, + port: &devplanev1.Port{ + PortId: "port123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_TCP, + PortNumber: 19001, + ServerPort: 8080, + AllowedSources: []string{"203.0.113.10/32"}, + }, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + + 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 := Open( + context.Background(), + &out, + store, + "my-instance", + 8080, + devplanev1.PortProtocol_PORT_PROTOCOL_TCP, + []string{"203.0.113.10/32"}, + false, + ) + + require.NoError(t, err) + assert.Contains(t, out.String(), "Opened TCP port 8080 on my-instance.") + assert.Contains(t, out.String(), "19001") + assert.Contains(t, out.String(), "203.0.113.10/32") +} + +func TestOpenExternalNodeByIDJSON(t *testing.T) { + hostname := "global.prd.ga.run.brev.nvidia.com" + service := &fakeOpenNodeService{ + t: t, + node: &devplanev1.ExternalNode{ExternalNodeId: "unode123", Name: "my-node"}, + wantReq: &devplanev1.OpenPortRequest{ + ExternalNodeId: "unode123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_UDP, + PortNumber: 53, + }, + port: &devplanev1.Port{ + PortId: "port53", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_UDP, + PortNumber: 19053, + ServerPort: 53, + Hostname: &hostname, + }, + } + _, handler := devplanev1connect.NewExternalNodeServiceHandler(service) + newTestServer(t, handler) + + store := &fakeStore{ + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := Open( + context.Background(), + &out, + store, + "unode123", + 53, + devplanev1.PortProtocol_PORT_PROTOCOL_UDP, + nil, + true, + ) + + require.NoError(t, err) + assert.JSONEq(t, `{ + "port_id": "port53", + "kind": "tcp_udp", + "endpoint": "global.prd.ga.run.brev.nvidia.com:19053", + "public_port": 19053, + "destination_port": 53, + "protocol": "UDP", + "allowed_sources": [], + "authorized_emails": [], + "allow_public_unauthenticated": false, + "type": "unspecified" + }`, out.String()) +} + +func TestNewCmdOpenPortParsesFlags(t *testing.T) { + service := &fakeOpenEnvironmentService{ + t: t, + wantReq: &devplanev1.EnvironmentServiceOpenPortRequest{ + EnvironmentId: "env123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_SSH, + PortNumber: 2222, + AllowedSources: []string{"10.0.0.0/8", "192.0.2.0/24"}, + }, + port: &devplanev1.Port{ + PortId: "port2222", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_SSH, + PortNumber: 19222, + ServerPort: 2222, + }, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + + store := &fakeStore{ + workspaces: []entity.Workspace{{ID: "env123", Name: "my-instance", CreatedByUserID: "user1"}}, + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + cmd := NewCmdPorts(store) + cmd.SetArgs([]string{ + "open", "my-instance", "2222", "--protocol", "SSH", + "--allow", "10.0.0.0/8", "--allow", "192.0.2.0/24", + "--allow", "10.0.0.0/8", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, out.String(), "Opened SSH port 2222 on my-instance.") +} + +func TestParsePortNumber(t *testing.T) { + tests := []struct { + value string + want int32 + err bool + }{ + {value: "1", want: 1}, + {value: "65535", want: 65535}, + {value: "0", err: true}, + {value: "65536", err: true}, + {value: "http", err: true}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + got, err := parsePortNumber(tt.value) + if tt.err { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseProtocol(t *testing.T) { + tests := []struct { + input string + want devplanev1.PortProtocol + }{ + {input: "tcp", want: devplanev1.PortProtocol_PORT_PROTOCOL_TCP}, + {input: "UDP", want: devplanev1.PortProtocol_PORT_PROTOCOL_UDP}, + {input: " ssh ", want: devplanev1.PortProtocol_PORT_PROTOCOL_SSH}, + } + for _, tt := range tests { + got, err := parseProtocol(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + + _, err := parseProtocol("http") + assert.EqualError(t, err, `invalid protocol "http": must be tcp, udp, or ssh`) +} diff --git a/pkg/cmd/ports/ports.go b/pkg/cmd/ports/ports.go index 6ae613d5..7b963241 100644 --- a/pkg/cmd/ports/ports.go +++ b/pkg/cmd/ports/ports.go @@ -53,7 +53,7 @@ func NewCmdPorts(portStore Store) *cobra.Command { brev ports ls my-instance brev ports ls my-node --json`, } - cmd.AddCommand(NewCmdPortsLs(portStore)) + cmd.AddCommand(NewCmdPortsLs(portStore), NewCmdOpenPort(portStore)) return cmd } From 9490c22644c888396750247b96223fcd05b8bae2 Mon Sep 17 00:00:00 2001 From: Allen Chen Date: Fri, 14 Aug 2026 11:44:20 -0700 Subject: [PATCH 2/3] Add HTTP application port support --- .agents/skills/brev-cli/SKILL.md | 2 + .agents/skills/brev-cli/reference/commands.md | 18 +- pkg/cmd/ports/open.go | 255 +++++++++++++++--- pkg/cmd/ports/open_test.go | 206 +++++++++++++- 4 files changed, 437 insertions(+), 44 deletions(-) diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index f1f1481d..8b087adc 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -178,6 +178,8 @@ brev ports ls my-node --json # Open a public port (TCP by default) brev ports open my-instance 8080 brev ports open my-node 53 --protocol udp --allow 203.0.113.10/32 +brev ports open my-instance 3000 --protocol http --public +brev ports open my-instance 8888 --protocol http --authorize me@example.com ``` ### Listing Instances and Nodes diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index b4efe229..5ec873d3 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -513,8 +513,8 @@ brev ports ls my-instance --json #### Open a port -Open a raw TCP, UDP, or SSH port on a managed instance or registered compute -node. `add` is an alias for `open`. +Open a raw TCP, UDP, or SSH port, or create an HTTP application endpoint, on a +managed instance or registered compute node. `add` is an alias for `open`. ```bash brev ports open [flags] @@ -523,11 +523,17 @@ brev ports open [flags] **Flags:** | Flag | Description | |------|-------------| -| `--protocol` | Port protocol: `tcp` (default), `udp`, or `ssh` | -| `--allow` | Source CIDR allowed to connect; repeat to add more than one | +| `--protocol` | Port protocol: `tcp` (default), `udp`, `ssh`, `http`, or `https` | +| `--allow` | Source CIDR for TCP, UDP, or SSH; repeat to add more than one | +| `--authorize` | Email authorized for an HTTP endpoint; repeat to add more than one | +| `--hostname` | HTTP endpoint hostname prefix; defaults to the destination port | +| `--public` | Disable authentication for an HTTP endpoint | | `--json` | Output the opened port as JSON | -Omit `--allow` to allow connections from any source. +Omit `--allow` to allow raw-port connections from any source. HTTP endpoints +default to authorizing the current user's email; use `--public` to make one +available without authentication. `--protocol http` connects the public HTTPS +endpoint to a plain-HTTP service, while `https` expects TLS on the destination. **Examples:** ```bash @@ -535,6 +541,8 @@ brev ports open my-instance 8080 brev ports open my-node 53 --protocol udp brev ports open my-instance 8080 --allow 203.0.113.10/32 brev ports add my-node 2222 --protocol ssh --json +brev ports open my-instance 3000 --protocol http --public +brev ports open my-instance 8888 --protocol http --authorize me@example.com ``` ## Organization Commands diff --git a/pkg/cmd/ports/open.go b/pkg/cmd/ports/open.go index afa13eea..9f216906 100644 --- a/pkg/cmd/ports/open.go +++ b/pkg/cmd/ports/open.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "regexp" "strconv" "strings" @@ -21,9 +22,7 @@ import ( // NewCmdOpenPort creates the `brev ports open` command. func NewCmdOpenPort(portStore Store) *cobra.Command { - var protocol string - var allowedSources []string - var jsonOutput bool + var opts openOptions cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, @@ -31,42 +30,185 @@ func NewCmdOpenPort(portStore Store) *cobra.Command { Aliases: []string{"add"}, DisableFlagsInUseLine: true, Short: "Open a public port on an instance or external node", - Example: ` - brev ports open my-instance 8080 - brev ports open my-node 53 --protocol udp - brev ports open my-instance 8080 --allow 203.0.113.10/32`, + Example: "\n brev ports open my-instance 8080" + + "\n brev ports open my-node 53 --protocol udp" + + "\n brev ports open my-instance 8080 --allow 203.0.113.10/32" + + "\n brev ports open my-instance 3000 --protocol http --public", Args: cmderrors.TransformToValidationError(cobra.ExactArgs(2)), RunE: func(cmd *cobra.Command, args []string) error { - portNumber, err := parsePortNumber(args[1]) - if err != nil { - return err - } - portProtocol, err := parseProtocol(protocol) - if err != nil { - return err - } - allowedSources, err = normalizeAllowedSources(allowedSources) - if err != nil { - return err - } - if err := Open(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], portNumber, portProtocol, allowedSources, jsonOutput); err != nil { - return breverrors.WrapAndTrace(err) - } - return nil + return runOpenCommand(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], args[1], opts) }, } - cmd.Flags().StringVar(&protocol, "protocol", "tcp", "port protocol (tcp, udp, or ssh)") - cmd.Flags().StringArrayVar(&allowedSources, "allow", nil, "source CIDR allowed to connect (repeatable; omit to allow all)") - cmd.Flags().BoolVar(&jsonOutput, "json", false, "output the opened port as JSON") + cmd.Flags().StringVar(&opts.protocol, "protocol", "tcp", "port protocol (tcp, udp, ssh, http, or https)") + cmd.Flags().StringArrayVar(&opts.allowedSources, "allow", nil, "source CIDR allowed to connect (repeatable; omit to allow all)") + cmd.Flags().StringArrayVar(&opts.authorizedEmails, "authorize", nil, "email authorized for an HTTP port (repeatable; defaults to you)") + cmd.Flags().StringVar(&opts.customHostname, "hostname", "", "hostname prefix for an HTTP port (defaults to the destination port)") + cmd.Flags().BoolVar(&opts.allowPublicUnauthenticated, "public", false, "disable authentication for an HTTP port") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "output the opened port as JSON") _ = cmd.RegisterFlagCompletionFunc("protocol", cobra.FixedCompletions( - []string{"tcp", "udp", "ssh"}, + []string{"tcp", "udp", "ssh", "http", "https"}, cobra.ShellCompDirectiveNoFileComp, )) return cmd } +type openOptions struct { + protocol string + allowedSources []string + authorizedEmails []string + customHostname string + allowPublicUnauthenticated bool + jsonOutput bool +} + +func runOpenCommand( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portValue string, + opts openOptions, +) error { + portNumber, err := parsePortNumber(portValue) + if err != nil { + return err + } + if isHTTPProtocol(opts.protocol) { + return runOpenHTTPCommand(ctx, out, portStore, nameOrID, portNumber, opts) + } + return runOpenNetworkCommand(ctx, out, portStore, nameOrID, portNumber, opts) +} + +func runOpenHTTPCommand( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + opts openOptions, +) error { + httpProtocol, err := parseHTTPProtocol(opts.protocol) + if err != nil { + return err + } + if len(opts.allowedSources) > 0 { + return breverrors.NewValidationError("--allow is only supported for tcp, udp, and ssh ports") + } + authorizedEmails, err := normalizeAuthorizedEmails(opts.authorizedEmails) + if err != nil { + return err + } + if opts.allowPublicUnauthenticated && len(authorizedEmails) > 0 { + return breverrors.NewValidationError("--public and --authorize cannot be used together") + } + if err := validateHTTPHostname(opts.customHostname); err != nil { + return err + } + return breverrors.WrapAndTrace(OpenHTTP( + ctx, out, portStore, nameOrID, portNumber, httpProtocol, opts.customHostname, + authorizedEmails, opts.allowPublicUnauthenticated, opts.jsonOutput, + )) +} + +func runOpenNetworkCommand( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + opts openOptions, +) error { + if opts.customHostname != "" || len(opts.authorizedEmails) > 0 || opts.allowPublicUnauthenticated { + return breverrors.NewValidationError("--hostname, --authorize, and --public are only supported for http and https ports") + } + portProtocol, err := parseProtocol(opts.protocol) + if err != nil { + return err + } + allowedSources, err := normalizeAllowedSources(opts.allowedSources) + if err != nil { + return err + } + return breverrors.WrapAndTrace(Open( + ctx, out, portStore, nameOrID, portNumber, portProtocol, allowedSources, opts.jsonOutput, + )) +} + +// OpenHTTP resolves a managed instance or registered compute node and creates +// an authenticated or public HTTP application endpoint. +func OpenHTTP( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + httpProtocol devplanev1.HttpPortProtocol, + customHostname string, + authorizedEmails []string, + allowPublicUnauthenticated bool, + jsonOutput bool, +) error { + target, err := cmdutil.ResolveWorkspaceOrNodeWithContext(ctx, portStore, nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + if !allowPublicUnauthenticated && len(authorizedEmails) == 0 { + user, err := portStore.GetCurrentUser() + if err != nil { + return fmt.Errorf("get current user for HTTP port authorization: %w", err) + } + if user == nil || strings.TrimSpace(user.Email) == "" { + return breverrors.NewValidationError("could not determine your email; use --authorize or --public") + } + authorizedEmails = []string{strings.TrimSpace(user.Email)} + } + + var openedPort *devplanev1.Port + if target.Workspace != nil { + hostname := buildHTTPHostname(customHostname, portNumber, target.Workspace.ID) + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenHTTPPort(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceOpenHTTPPortRequest{ + EnvironmentId: target.Workspace.ID, + PortNumber: portNumber, + CustomHostname: hostname, + HttpProtocol: httpProtocol, + AuthorizedEmails: authorizedEmails, + AllowPublicUnauthenticated: allowPublicUnauthenticated, + })) + if err != nil { + return fmt.Errorf("open HTTP port on instance %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } else if target.Node != nil { + hostname := buildHTTPHostname(customHostname, portNumber, target.Node.GetExternalNodeId()) + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenHTTPPort(ctx, connect.NewRequest(&devplanev1.OpenHTTPPortRequest{ + ExternalNodeId: target.Node.GetExternalNodeId(), + PortNumber: portNumber, + CustomHostname: hostname, + HttpProtocol: httpProtocol, + AuthorizedEmails: authorizedEmails, + AllowPublicUnauthenticated: allowPublicUnauthenticated, + })) + if err != nil { + return fmt.Errorf("open HTTP port on external node %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } + + if openedPort == nil { + return fmt.Errorf("open HTTP port on %q: API returned no port", nameOrID) + } + return writeOpenResult(out, nameOrID, openedPort, jsonOutput) +} + // Open resolves a managed instance or registered compute node and opens a port. func Open( ctx context.Context, @@ -78,7 +220,7 @@ func Open( allowedSources []string, jsonOutput bool, ) error { - target, err := cmdutil.ResolveWorkspaceOrNode(portStore, nameOrID) + target, err := cmdutil.ResolveWorkspaceOrNodeWithContext(ctx, portStore, nameOrID) if err != nil { return breverrors.WrapAndTrace(err) } @@ -138,11 +280,36 @@ func parseProtocol(value string) (devplanev1.PortProtocol, error) { return devplanev1.PortProtocol_PORT_PROTOCOL_SSH, nil default: return devplanev1.PortProtocol_PORT_PROTOCOL_UNSPECIFIED, - fmt.Errorf("invalid protocol %q: must be tcp, udp, or ssh", value) + fmt.Errorf("invalid protocol %q: must be tcp, udp, ssh, http, or https", value) + } +} + +func isHTTPProtocol(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + return value == "http" || value == "https" +} + +func parseHTTPProtocol(value string) (devplanev1.HttpPortProtocol, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "http": + return devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, nil + case "https": + return devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, nil + default: + return devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_UNSPECIFIED, + fmt.Errorf("invalid HTTP protocol %q: must be http or https", value) } } func normalizeAllowedSources(values []string) ([]string, error) { + return normalizeUniqueValues(values, "allowed source") +} + +func normalizeAuthorizedEmails(values []string) ([]string, error) { + return normalizeUniqueValues(values, "authorized email") +} + +func normalizeUniqueValues(values []string, label string) ([]string, error) { if len(values) == 0 { return nil, nil } @@ -152,7 +319,7 @@ func normalizeAllowedSources(values []string) ([]string, error) { for _, value := range values { value = strings.TrimSpace(value) if value == "" { - return nil, fmt.Errorf("allowed source cannot be empty") + return nil, fmt.Errorf("%s cannot be empty", label) } if _, ok := seen[value]; ok { continue @@ -163,6 +330,34 @@ func normalizeAllowedSources(values []string) ([]string, error) { return normalized, nil } +var httpHostnamePattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +func validateHTTPHostname(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + if len(value) > 63 { + return breverrors.NewValidationError("hostname must be 63 characters or fewer") + } + if !httpHostnamePattern.MatchString(value) { + return breverrors.NewValidationError("hostname must contain only lowercase letters, digits, and hyphens, and must start and end with a letter or digit") + } + return nil +} + +func buildHTTPHostname(value string, portNumber int32, targetID string) string { + hostname := strings.TrimSpace(value) + if hostname == "" { + hostname = strconv.Itoa(int(portNumber)) + } + suffix := "-" + strings.ToLower(strings.TrimSpace(targetID)) + if targetID == "" || strings.HasSuffix(hostname, suffix) { + return hostname + } + return hostname + suffix +} + func writeOpenResult(out io.Writer, nameOrID string, port *devplanev1.Port, jsonOutput bool) error { portInfo := toPortInfos([]*devplanev1.Port{port})[0] if jsonOutput { diff --git a/pkg/cmd/ports/open_test.go b/pkg/cmd/ports/open_test.go index ae4f734e..fe5d1dfb 100644 --- a/pkg/cmd/ports/open_test.go +++ b/pkg/cmd/ports/open_test.go @@ -25,9 +25,38 @@ func newTestServer(t *testing.T, handler http.Handler) { type fakeOpenEnvironmentService struct { devplanev1connect.UnimplementedEnvironmentServiceHandler - t *testing.T - wantReq *devplanev1.EnvironmentServiceOpenPortRequest - port *devplanev1.Port + t *testing.T + wantReq *devplanev1.EnvironmentServiceOpenPortRequest + wantHTTPReq *devplanev1.EnvironmentServiceOpenHTTPPortRequest + port *devplanev1.Port + httpPort *devplanev1.Port +} + +type httpOpenRequest interface { + GetPortNumber() int32 + GetCustomHostname() string + GetHttpProtocol() devplanev1.HttpPortProtocol + GetAuthorizedEmails() []string + GetAllowPublicUnauthenticated() bool +} + +func assertHTTPOpenRequest(t *testing.T, want, got httpOpenRequest) { + t.Helper() + assert.Equal(t, want.GetPortNumber(), got.GetPortNumber()) + assert.Equal(t, want.GetCustomHostname(), got.GetCustomHostname()) + assert.Equal(t, want.GetHttpProtocol(), got.GetHttpProtocol()) + assert.Equal(t, want.GetAuthorizedEmails(), got.GetAuthorizedEmails()) + assert.Equal(t, want.GetAllowPublicUnauthenticated(), got.GetAllowPublicUnauthenticated()) +} + +func (s *fakeOpenEnvironmentService) OpenHTTPPort( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceOpenHTTPPortRequest], +) (*connect.Response[devplanev1.EnvironmentServiceOpenHTTPPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantHTTPReq.GetEnvironmentId(), req.Msg.GetEnvironmentId()) + assertHTTPOpenRequest(s.t, s.wantHTTPReq, req.Msg) + return connect.NewResponse(&devplanev1.EnvironmentServiceOpenHTTPPortResponse{Port: s.httpPort}), nil } func (s *fakeOpenEnvironmentService) OpenPort( @@ -44,10 +73,22 @@ func (s *fakeOpenEnvironmentService) OpenPort( type fakeOpenNodeService struct { devplanev1connect.UnimplementedExternalNodeServiceHandler - t *testing.T - node *devplanev1.ExternalNode - wantReq *devplanev1.OpenPortRequest - port *devplanev1.Port + t *testing.T + node *devplanev1.ExternalNode + wantReq *devplanev1.OpenPortRequest + wantHTTPReq *devplanev1.OpenHTTPPortRequest + port *devplanev1.Port + httpPort *devplanev1.Port +} + +func (s *fakeOpenNodeService) OpenHTTPPort( + _ context.Context, + req *connect.Request[devplanev1.OpenHTTPPortRequest], +) (*connect.Response[devplanev1.OpenHTTPPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantHTTPReq.GetExternalNodeId(), req.Msg.GetExternalNodeId()) + assertHTTPOpenRequest(s.t, s.wantHTTPReq, req.Msg) + return connect.NewResponse(&devplanev1.OpenHTTPPortResponse{Port: s.httpPort}), nil } func (s *fakeOpenNodeService) ListNodes( @@ -154,7 +195,7 @@ func TestOpenExternalNodeByIDJSON(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{ "port_id": "port53", - "kind": "tcp_udp", + "kind": "network", "endpoint": "global.prd.ga.run.brev.nvidia.com:19053", "public_port": 19053, "destination_port": 53, @@ -166,6 +207,107 @@ func TestOpenExternalNodeByIDJSON(t *testing.T) { }`, out.String()) } +func TestOpenHTTPEnvironmentDefaultsToCurrentUser(t *testing.T) { + hostname := "3000-env123.apps.run.brev.nvidia.com" + service := &fakeOpenEnvironmentService{ + t: t, + wantHTTPReq: &devplanev1.EnvironmentServiceOpenHTTPPortRequest{ + EnvironmentId: "env123", + PortNumber: 3000, + CustomHostname: "3000-env123", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + AuthorizedEmails: []string{"me@example.com"}, + }, + httpPort: &devplanev1.Port{ + PortId: "http3000", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + PortNumber: 443, + ServerPort: 3000, + Hostname: &hostname, + AuthorizedEmails: []string{"me@example.com"}, + }, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + store := &fakeStore{ + workspaces: []entity.Workspace{{ID: "env123", Name: "my-instance", CreatedByUserID: "user1"}}, + user: &entity.User{ID: "user1", Email: "me@example.com"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := OpenHTTP( + context.Background(), + &out, + store, + "my-instance", + 3000, + devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + "", + nil, + false, + false, + ) + + require.NoError(t, err) + assert.Contains(t, out.String(), "Opened HTTP port 3000 on my-instance.") + assert.Contains(t, out.String(), "https://3000-env123.apps.run.brev.nvidia.com") + assert.Contains(t, out.String(), "me@example.com") +} + +func TestOpenHTTPExternalNodePublicJSON(t *testing.T) { + hostname := "demo-unode123.apps.run.brev.nvidia.com" + public := true + service := &fakeOpenNodeService{ + t: t, + node: &devplanev1.ExternalNode{ExternalNodeId: "unode123", Name: "my-node"}, + wantHTTPReq: &devplanev1.OpenHTTPPortRequest{ + ExternalNodeId: "unode123", + PortNumber: 8443, + CustomHostname: "demo-unode123", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, + AllowPublicUnauthenticated: true, + }, + httpPort: &devplanev1.Port{ + PortId: "http8443", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, + PortNumber: 443, + ServerPort: 8443, + Hostname: &hostname, + AllowPublicUnauthenticated: &public, + }, + } + _, handler := devplanev1connect.NewExternalNodeServiceHandler(service) + newTestServer(t, handler) + store := &fakeStore{ + user: &entity.User{ID: "user1", Email: "me@example.com"}, + org: &entity.Organization{ID: "org1"}, + } + cmd := NewCmdPorts(store) + cmd.SetArgs([]string{ + "open", "my-node", "8443", "--protocol", "HTTPS", + "--hostname", "demo", "--public", "--json", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + assert.JSONEq(t, `{ + "port_id": "http8443", + "kind": "http", + "endpoint": "https://demo-unode123.apps.run.brev.nvidia.com", + "public_port": 443, + "destination_port": 8443, + "protocol": "HTTPS", + "allowed_sources": [], + "authorized_emails": [], + "allow_public_unauthenticated": true, + "type": "unspecified" + }`, out.String()) +} + func TestNewCmdOpenPortParsesFlags(t *testing.T) { service := &fakeOpenEnvironmentService{ t: t, @@ -246,5 +388,51 @@ func TestParseProtocol(t *testing.T) { } _, err := parseProtocol("http") - assert.EqualError(t, err, `invalid protocol "http": must be tcp, udp, or ssh`) + assert.EqualError(t, err, `invalid protocol "http": must be tcp, udp, ssh, http, or https`) +} + +func TestOpenHTTPFlagValidation(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "public with authorized email", + args: []string{"open", "my-instance", "8080", "--protocol", "http", "--public", "--authorize", "me@example.com"}, + want: "--public and --authorize cannot be used together", + }, + { + name: "IP allow-list on HTTP", + args: []string{"open", "my-instance", "8080", "--protocol", "http", "--allow", "10.0.0.0/8"}, + want: "--allow is only supported for tcp, udp, and ssh ports", + }, + { + name: "HTTP flag on TCP", + args: []string{"open", "my-instance", "8080", "--public"}, + want: "--hostname, --authorize, and --public are only supported for http and https ports", + }, + { + name: "invalid hostname", + args: []string{"open", "my-instance", "8080", "--protocol", "http", "--hostname", "Not Valid"}, + want: "hostname must contain only lowercase letters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewCmdPorts(&fakeStore{}) + cmd.SetArgs(tt.args) + + err := cmd.Execute() + + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func TestBuildHTTPHostname(t *testing.T) { + assert.Equal(t, "8080-env123", buildHTTPHostname("", 8080, "ENV123")) + assert.Equal(t, "demo-env123", buildHTTPHostname(" demo ", 8080, "ENV123")) + assert.Equal(t, "demo-env123", buildHTTPHostname("demo-env123", 8080, "ENV123")) } From c6f8fcc8e70694d23afc7d94b6b4121ec32f1e92 Mon Sep 17 00:00:00 2001 From: Allen Chen Date: Wed, 19 Aug 2026 09:51:19 -0700 Subject: [PATCH 3/3] Align port creation command --- .agents/skills/brev-cli/SKILL.md | 10 ++++---- .agents/skills/brev-cli/reference/commands.md | 23 ++++++++++--------- pkg/cmd/ports/open.go | 22 +++++++++--------- pkg/cmd/ports/open_test.go | 22 ++++++++---------- pkg/cmd/ports/ports.go | 2 +- pkg/cmd/ports/ports_test.go | 18 +++++++++------ 6 files changed, 50 insertions(+), 47 deletions(-) diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index 8b087adc..9a57e2f5 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -175,11 +175,11 @@ brev port-forward my-instance -p 8080:8080 brev ports ls my-instance brev ports ls my-node --json -# Open a public port (TCP by default) -brev ports open my-instance 8080 -brev ports open my-node 53 --protocol udp --allow 203.0.113.10/32 -brev ports open my-instance 3000 --protocol http --public -brev ports open my-instance 8888 --protocol http --authorize me@example.com +# Create a public port (TCP by default) +brev ports create my-instance 8080 +brev ports create my-node 53 --protocol udp --allow 203.0.113.10/32 +brev ports create my-instance 3000 --protocol http --public +brev ports create my-instance 8888 --protocol http --authorize me@example.com ``` ### Listing Instances and Nodes diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index 5ec873d3..f2ce66dc 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -511,13 +511,14 @@ brev ports ls my-node brev ports ls my-instance --json ``` -#### Open a port +#### Create a port -Open a raw TCP, UDP, or SSH port, or create an HTTP application endpoint, on a -managed instance or registered compute node. `add` is an alias for `open`. +Create a raw TCP, UDP, or SSH port, or an HTTP application endpoint, on a +managed instance or registered compute node. `open` and `add` are aliases for +`create`. ```bash -brev ports open [flags] +brev ports create [flags] ``` **Flags:** @@ -528,7 +529,7 @@ brev ports open [flags] | `--authorize` | Email authorized for an HTTP endpoint; repeat to add more than one | | `--hostname` | HTTP endpoint hostname prefix; defaults to the destination port | | `--public` | Disable authentication for an HTTP endpoint | -| `--json` | Output the opened port as JSON | +| `--json` | Output the created port as JSON | Omit `--allow` to allow raw-port connections from any source. HTTP endpoints default to authorizing the current user's email; use `--public` to make one @@ -537,12 +538,12 @@ endpoint to a plain-HTTP service, while `https` expects TLS on the destination. **Examples:** ```bash -brev ports open my-instance 8080 -brev ports open my-node 53 --protocol udp -brev ports open my-instance 8080 --allow 203.0.113.10/32 -brev ports add my-node 2222 --protocol ssh --json -brev ports open my-instance 3000 --protocol http --public -brev ports open my-instance 8888 --protocol http --authorize me@example.com +brev ports create my-instance 8080 +brev ports create my-node 53 --protocol udp +brev ports create my-instance 8080 --allow 203.0.113.10/32 +brev ports create my-node 2222 --protocol ssh --json +brev ports create my-instance 3000 --protocol http --public +brev ports create my-instance 8888 --protocol http --authorize me@example.com ``` ## Organization Commands diff --git a/pkg/cmd/ports/open.go b/pkg/cmd/ports/open.go index 9f216906..bf453a47 100644 --- a/pkg/cmd/ports/open.go +++ b/pkg/cmd/ports/open.go @@ -20,20 +20,20 @@ import ( breverrors "github.com/brevdev/brev-cli/pkg/errors" ) -// NewCmdOpenPort creates the `brev ports open` command. -func NewCmdOpenPort(portStore Store) *cobra.Command { +// NewCmdCreatePort creates the `brev ports create` command. +func NewCmdCreatePort(portStore Store) *cobra.Command { var opts openOptions cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, - Use: "open ", - Aliases: []string{"add"}, + Use: "create ", + Aliases: []string{"open", "add"}, DisableFlagsInUseLine: true, - Short: "Open a public port on an instance or external node", - Example: "\n brev ports open my-instance 8080" + - "\n brev ports open my-node 53 --protocol udp" + - "\n brev ports open my-instance 8080 --allow 203.0.113.10/32" + - "\n brev ports open my-instance 3000 --protocol http --public", + Short: "Create a public port on an instance or external node", + Example: "\n brev ports create my-instance 8080" + + "\n brev ports create my-node 53 --protocol udp" + + "\n brev ports create my-instance 8080 --allow 203.0.113.10/32" + + "\n brev ports create my-instance 3000 --protocol http --public", Args: cmderrors.TransformToValidationError(cobra.ExactArgs(2)), RunE: func(cmd *cobra.Command, args []string) error { return runOpenCommand(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], args[1], opts) @@ -45,7 +45,7 @@ func NewCmdOpenPort(portStore Store) *cobra.Command { cmd.Flags().StringArrayVar(&opts.authorizedEmails, "authorize", nil, "email authorized for an HTTP port (repeatable; defaults to you)") cmd.Flags().StringVar(&opts.customHostname, "hostname", "", "hostname prefix for an HTTP port (defaults to the destination port)") cmd.Flags().BoolVar(&opts.allowPublicUnauthenticated, "public", false, "disable authentication for an HTTP port") - cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "output the opened port as JSON") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "output the created port as JSON") _ = cmd.RegisterFlagCompletionFunc("protocol", cobra.FixedCompletions( []string{"tcp", "udp", "ssh", "http", "https"}, cobra.ShellCompDirectiveNoFileComp, @@ -369,7 +369,7 @@ func writeOpenResult(out io.Writer, nameOrID string, port *devplanev1.Port, json return breverrors.WrapAndTrace(err) } - _, err := fmt.Fprintf(out, "Opened %s port %d on %s.\n", portInfo.Protocol, port.GetServerPort(), nameOrID) + _, err := fmt.Fprintf(out, "Created %s port %d on %s.\n", portInfo.Protocol, port.GetServerPort(), nameOrID) if err != nil { return breverrors.WrapAndTrace(err) } diff --git a/pkg/cmd/ports/open_test.go b/pkg/cmd/ports/open_test.go index fe5d1dfb..524a2241 100644 --- a/pkg/cmd/ports/open_test.go +++ b/pkg/cmd/ports/open_test.go @@ -149,7 +149,7 @@ func TestOpenEnvironment(t *testing.T) { ) require.NoError(t, err) - assert.Contains(t, out.String(), "Opened TCP port 8080 on my-instance.") + assert.Contains(t, out.String(), "Created TCP port 8080 on my-instance.") assert.Contains(t, out.String(), "19001") assert.Contains(t, out.String(), "203.0.113.10/32") } @@ -195,7 +195,6 @@ func TestOpenExternalNodeByIDJSON(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{ "port_id": "port53", - "kind": "network", "endpoint": "global.prd.ga.run.brev.nvidia.com:19053", "public_port": 19053, "destination_port": 53, @@ -250,7 +249,7 @@ func TestOpenHTTPEnvironmentDefaultsToCurrentUser(t *testing.T) { ) require.NoError(t, err) - assert.Contains(t, out.String(), "Opened HTTP port 3000 on my-instance.") + assert.Contains(t, out.String(), "Created HTTP port 3000 on my-instance.") assert.Contains(t, out.String(), "https://3000-env123.apps.run.brev.nvidia.com") assert.Contains(t, out.String(), "me@example.com") } @@ -285,7 +284,7 @@ func TestOpenHTTPExternalNodePublicJSON(t *testing.T) { } cmd := NewCmdPorts(store) cmd.SetArgs([]string{ - "open", "my-node", "8443", "--protocol", "HTTPS", + "create", "my-node", "8443", "--protocol", "HTTPS", "--hostname", "demo", "--public", "--json", }) var out bytes.Buffer @@ -296,7 +295,6 @@ func TestOpenHTTPExternalNodePublicJSON(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{ "port_id": "http8443", - "kind": "http", "endpoint": "https://demo-unode123.apps.run.brev.nvidia.com", "public_port": 443, "destination_port": 8443, @@ -308,7 +306,7 @@ func TestOpenHTTPExternalNodePublicJSON(t *testing.T) { }`, out.String()) } -func TestNewCmdOpenPortParsesFlags(t *testing.T) { +func TestNewCmdCreatePortParsesFlags(t *testing.T) { service := &fakeOpenEnvironmentService{ t: t, wantReq: &devplanev1.EnvironmentServiceOpenPortRequest{ @@ -334,7 +332,7 @@ func TestNewCmdOpenPortParsesFlags(t *testing.T) { } cmd := NewCmdPorts(store) cmd.SetArgs([]string{ - "open", "my-instance", "2222", "--protocol", "SSH", + "create", "my-instance", "2222", "--protocol", "SSH", "--allow", "10.0.0.0/8", "--allow", "192.0.2.0/24", "--allow", "10.0.0.0/8", }) @@ -344,7 +342,7 @@ func TestNewCmdOpenPortParsesFlags(t *testing.T) { err := cmd.Execute() require.NoError(t, err) - assert.Contains(t, out.String(), "Opened SSH port 2222 on my-instance.") + assert.Contains(t, out.String(), "Created SSH port 2222 on my-instance.") } func TestParsePortNumber(t *testing.T) { @@ -399,22 +397,22 @@ func TestOpenHTTPFlagValidation(t *testing.T) { }{ { name: "public with authorized email", - args: []string{"open", "my-instance", "8080", "--protocol", "http", "--public", "--authorize", "me@example.com"}, + args: []string{"create", "my-instance", "8080", "--protocol", "http", "--public", "--authorize", "me@example.com"}, want: "--public and --authorize cannot be used together", }, { name: "IP allow-list on HTTP", - args: []string{"open", "my-instance", "8080", "--protocol", "http", "--allow", "10.0.0.0/8"}, + args: []string{"create", "my-instance", "8080", "--protocol", "http", "--allow", "10.0.0.0/8"}, want: "--allow is only supported for tcp, udp, and ssh ports", }, { name: "HTTP flag on TCP", - args: []string{"open", "my-instance", "8080", "--public"}, + args: []string{"create", "my-instance", "8080", "--public"}, want: "--hostname, --authorize, and --public are only supported for http and https ports", }, { name: "invalid hostname", - args: []string{"open", "my-instance", "8080", "--protocol", "http", "--hostname", "Not Valid"}, + args: []string{"create", "my-instance", "8080", "--protocol", "http", "--hostname", "Not Valid"}, want: "hostname must contain only lowercase letters", }, } diff --git a/pkg/cmd/ports/ports.go b/pkg/cmd/ports/ports.go index 7b963241..4546a880 100644 --- a/pkg/cmd/ports/ports.go +++ b/pkg/cmd/ports/ports.go @@ -53,7 +53,7 @@ func NewCmdPorts(portStore Store) *cobra.Command { brev ports ls my-instance brev ports ls my-node --json`, } - cmd.AddCommand(NewCmdPortsLs(portStore), NewCmdOpenPort(portStore)) + cmd.AddCommand(NewCmdPortsLs(portStore), NewCmdCreatePort(portStore)) return cmd } diff --git a/pkg/cmd/ports/ports_test.go b/pkg/cmd/ports/ports_test.go index ac973144..e672c506 100644 --- a/pkg/cmd/ports/ports_test.go +++ b/pkg/cmd/ports/ports_test.go @@ -71,16 +71,20 @@ func (s *fakeNodeService) ListNodes( return connect.NewResponse(&devplanev1.ListNodesResponse{Items: s.nodes}), nil } -func TestPortsCommandUsesListSubcommand(t *testing.T) { +func TestPortsCommandUsesSubcommands(t *testing.T) { cmd := NewCmdPorts(&fakeStore{}) - commands := cmd.Commands() - require.Len(t, commands, 1) - assert.Equal(t, "ls", commands[0].Name()) - assert.Equal(t, "ls ", commands[0].Use) - assert.Contains(t, commands[0].Annotations, "access") + listCmd, _, err := cmd.Find([]string{"ls"}) + require.NoError(t, err) + assert.Equal(t, "ls ", listCmd.Use) + assert.Contains(t, listCmd.Annotations, "access") assert.Nil(t, cmd.Flags().Lookup("json")) - assert.NotNil(t, commands[0].Flags().Lookup("json")) + assert.NotNil(t, listCmd.Flags().Lookup("json")) + + createCmd, _, err := cmd.Find([]string{"create"}) + require.NoError(t, err) + assert.Equal(t, "create ", createCmd.Use) + assert.ElementsMatch(t, []string{"open", "add"}, createCmd.Aliases) } func TestRunEnvironmentJSON(t *testing.T) {