diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index 50ce1101..3b5f8b0c 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -174,6 +174,12 @@ brev port-forward my-instance -p 8080:8080 # List Skybridge-managed HTTP and network ports for an instance or external node brev ports my-instance brev ports 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 2111490b..4256d728 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -510,6 +510,40 @@ brev ports my-node brev ports my-instance --json ``` +#### Open 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`. + +```bash +brev ports open [flags] +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--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 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 +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 ### brev org ls diff --git a/pkg/cmd/ports/open.go b/pkg/cmd/ports/open.go new file mode 100644 index 00000000..9f216906 --- /dev/null +++ b/pkg/cmd/ports/open.go @@ -0,0 +1,377 @@ +package ports + +import ( + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "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 opts openOptions + + 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: "\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 { + return runOpenCommand(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], args[1], opts) + }, + } + + 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", "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, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + protocol devplanev1.PortProtocol, + allowedSources []string, + jsonOutput bool, +) error { + target, err := cmdutil.ResolveWorkspaceOrNodeWithContext(ctx, 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, 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 + } + + 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("%s cannot be empty", label) + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + 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 { + 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..fe5d1dfb --- /dev/null +++ b/pkg/cmd/ports/open_test.go @@ -0,0 +1,438 @@ +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 + 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( + _ 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 + 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( + _ 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": "network", + "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 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, + 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, 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")) +} diff --git a/pkg/cmd/ports/ports.go b/pkg/cmd/ports/ports.go index 868bc0b3..90b61150 100644 --- a/pkg/cmd/ports/ports.go +++ b/pkg/cmd/ports/ports.go @@ -69,6 +69,7 @@ func NewCmdPorts(portStore Store) *cobra.Command { } cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") + cmd.AddCommand(NewCmdOpenPort(portStore)) return cmd }