diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index 4feb24a6..051768d3 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -181,6 +181,11 @@ 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 +# Update one mapping interactively or by exact ID +brev ports update my-instance --destination-port 8081 +brev ports update my-instance --id nport-abc123 --allow 203.0.113.10/32 +brev ports update my-instance --id nport-abc123 --public + # Close one port interactively, by exact ID, or close all ports brev ports close my-instance brev ports close my-instance --id nport-abc123 --approve diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index dbdcbc51..7e6feecf 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -544,6 +544,43 @@ brev ports open my-instance 3000 --protocol http --public brev ports open my-instance 8888 --protocol http --authorize me@example.com ``` +#### Update a port + +Update a mapping in place while preserving its `port_id` and public endpoint. +Omit `--id` to select a mapping interactively. `edit` is an alias for `update`. + +```bash +brev ports update [flags] +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--id` | Update the exact mapping with this `port_id`; omit to select interactively | +| `--destination-port` | Change the destination port (1-65535) | +| `--allow` | Replace source restrictions with this CIDR; repeat to add more than one | +| `--allow-anywhere` | Clear all source restrictions | +| `--protocol` | Change an HTTP mapping's origin protocol to `http` or `https` | +| `--authorize` | Replace an HTTP mapping's authorized emails; repeat to add more than one | +| `--public` | Allow unauthenticated public access to an HTTP mapping | +| `--json` | Output the updated mapping as JSON | + +`--allow` and `--allow-anywhere` cannot be combined. `--authorize` and +`--public` cannot be combined. HTTP access and protocol flags are rejected for +raw TCP, UDP, and SSH mappings. When one command updates multiple field groups, +the API applies them in destination, source, protocol, then access order; those +separate mutations are not transactional. + +**Examples:** +```bash +brev ports update my-instance --destination-port 8081 +brev ports update my-instance --id nport-abc123 --allow 203.0.113.10/32 +brev ports edit my-node --id nport-abc123 --allow-anywhere +brev ports update my-instance --id nport-abc123 --protocol https +brev ports update my-instance --id nport-abc123 --authorize me@example.com +brev ports update my-instance --id nport-abc123 --public --json +``` + #### Close ports Select and close one port interactively, close an exact mapping by its diff --git a/pkg/cmd/ports/ports.go b/pkg/cmd/ports/ports.go index e0a44a08..14c52624 100644 --- a/pkg/cmd/ports/ports.go +++ b/pkg/cmd/ports/ports.go @@ -70,6 +70,7 @@ func NewCmdPorts(portStore Store) *cobra.Command { cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") cmd.AddCommand(NewCmdOpenPort(portStore)) + cmd.AddCommand(NewCmdUpdatePort(portStore)) cmd.AddCommand(NewCmdClosePort(portStore)) return cmd } diff --git a/pkg/cmd/ports/update.go b/pkg/cmd/ports/update.go new file mode 100644 index 00000000..fa34d179 --- /dev/null +++ b/pkg/cmd/ports/update.go @@ -0,0 +1,482 @@ +package ports + +import ( + "context" + "encoding/json" + "fmt" + "io" + "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" + "github.com/brevdev/brev-cli/pkg/terminal" +) + +type updateOptions struct { + portID string + destinationPort string + allowedSources []string + allowAnywhere bool + protocol string + authorizedEmails []string + public bool + jsonOutput bool + + destinationPortSet bool + allowedSourcesSet bool + allowAnywhereSet bool + protocolSet bool + authorizedEmailsSet bool + publicSet bool +} + +type portUpdates struct { + destinationPort *int32 + allowedSources *[]string + httpProtocol *devplanev1.HttpPortProtocol + authorizedEmails *[]string + public bool +} + +type updatePrompter interface { + terminal.Selector +} + +// NewCmdUpdatePort creates the `brev ports update` command. +func NewCmdUpdatePort(portStore Store) *cobra.Command { + return newCmdUpdatePort(portStore, register.TerminalPrompter{}) +} + +func newCmdUpdatePort(portStore Store, prompter updatePrompter) *cobra.Command { + var opts updateOptions + + cmd := &cobra.Command{ + Annotations: map[string]string{"access": ""}, + Use: "update ", + Aliases: []string{"edit"}, + DisableFlagsInUseLine: true, + Short: "Update a public port on an instance or external node", + Example: ` + brev ports update my-instance --id nport-abc123 --destination-port 8081 + brev ports update my-instance --id nport-abc123 --allow 203.0.113.10/32 + brev ports update my-node --id nport-abc123 --allow-anywhere + brev ports update my-instance --id nport-abc123 --protocol https + brev ports update my-instance --id nport-abc123 --public`, + Args: cmderrors.TransformToValidationError(cobra.ExactArgs(1)), + RunE: func(cmd *cobra.Command, args []string) error { + opts.destinationPortSet = cmd.Flags().Changed("destination-port") + opts.allowedSourcesSet = cmd.Flags().Changed("allow") + opts.allowAnywhereSet = cmd.Flags().Changed("allow-anywhere") + opts.protocolSet = cmd.Flags().Changed("protocol") + opts.authorizedEmailsSet = cmd.Flags().Changed("authorize") + opts.publicSet = cmd.Flags().Changed("public") + + updates, err := buildPortUpdates(opts) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return breverrors.WrapAndTrace(runUpdate( + cmd.Context(), cmd.OutOrStdout(), portStore, prompter, args[0], opts.portID, updates, opts.jsonOutput, + )) + }, + } + + cmd.Flags().StringVar(&opts.portID, "id", "", "update the exact port mapping with this port_id (omit to select interactively)") + cmd.Flags().StringVar(&opts.destinationPort, "destination-port", "", "new destination port (1-65535)") + cmd.Flags().StringArrayVar(&opts.allowedSources, "allow", nil, "replace source restrictions with this CIDR (repeatable)") + cmd.Flags().BoolVar(&opts.allowAnywhere, "allow-anywhere", false, "clear all source restrictions") + cmd.Flags().StringVar(&opts.protocol, "protocol", "", "new origin protocol for an HTTP mapping (http or https)") + cmd.Flags().StringArrayVar(&opts.authorizedEmails, "authorize", nil, "replace HTTP access with this authorized email (repeatable)") + cmd.Flags().BoolVar(&opts.public, "public", false, "make an HTTP mapping publicly accessible without authentication") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "output the updated port as JSON") + _ = cmd.RegisterFlagCompletionFunc("protocol", cobra.FixedCompletions( + []string{"http", "https"}, + cobra.ShellCompDirectiveNoFileComp, + )) + + return cmd +} + +func buildPortUpdates(opts updateOptions) (portUpdates, error) { + var updates portUpdates + var err error + + updates.destinationPort, err = destinationPortUpdate(opts) + if err != nil { + return updates, err + } + updates.allowedSources, err = allowedSourcesUpdate(opts) + if err != nil { + return updates, err + } + updates.httpProtocol, err = httpProtocolUpdate(opts) + if err != nil { + return updates, err + } + updates.authorizedEmails, updates.public, err = httpAccessUpdate(opts) + if err != nil { + return updates, err + } + + if !updates.hasChanges() { + return updates, breverrors.NewValidationError("specify at least one update: --destination-port, --allow, --allow-anywhere, --protocol, --authorize, or --public") + } + return updates, nil +} + +func destinationPortUpdate(opts updateOptions) (*int32, error) { + if !opts.destinationPortSet { + return nil, nil + } + portNumber, err := parsePortNumber(opts.destinationPort) + if err != nil { + return nil, err + } + return &portNumber, nil +} + +func allowedSourcesUpdate(opts updateOptions) (*[]string, error) { + if opts.allowedSourcesSet && opts.allowAnywhereSet { + return nil, breverrors.NewValidationError("--allow and --allow-anywhere cannot be used together") + } + if opts.allowAnywhereSet && !opts.allowAnywhere { + return nil, breverrors.NewValidationError("--allow-anywhere=false does not update the port; omit the flag or use --allow") + } + if opts.allowedSourcesSet { + allowedSources, err := normalizeAllowedSources(opts.allowedSources) + if err != nil { + return nil, err + } + return &allowedSources, nil + } + if opts.allowAnywhere { + allowedSources := []string{} + return &allowedSources, nil + } + return nil, nil +} + +func httpProtocolUpdate(opts updateOptions) (*devplanev1.HttpPortProtocol, error) { + if !opts.protocolSet { + return nil, nil + } + httpProtocol, err := parseHTTPProtocol(opts.protocol) + if err != nil { + return nil, err + } + return &httpProtocol, nil +} + +func httpAccessUpdate(opts updateOptions) (*[]string, bool, error) { + if opts.authorizedEmailsSet && opts.publicSet { + return nil, false, breverrors.NewValidationError("--authorize and --public cannot be used together") + } + if opts.publicSet && !opts.public { + return nil, false, breverrors.NewValidationError("--public=false requires an authorization policy; use --authorize instead") + } + if opts.authorizedEmailsSet { + authorizedEmails, err := normalizeAuthorizedEmails(opts.authorizedEmails) + if err != nil { + return nil, false, err + } + if len(authorizedEmails) == 0 { + return nil, false, breverrors.NewValidationError("--authorize requires at least one email") + } + return &authorizedEmails, false, nil + } + if opts.public { + authorizedEmails := []string{} + return &authorizedEmails, true, nil + } + return nil, false, nil +} + +func (u portUpdates) hasChanges() bool { + return u.destinationPort != nil || u.allowedSources != nil || u.httpProtocol != nil || u.authorizedEmails != nil +} + +func runUpdate( + ctx context.Context, + out io.Writer, + portStore Store, + prompter updatePrompter, + nameOrID string, + portID string, + updates portUpdates, + jsonOutput bool, +) error { + target, apiPorts, err := resolveTargetPorts(ctx, portStore, nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + port, err := selectPortToUpdate(prompter, apiPorts, strings.TrimSpace(portID)) + if err != nil { + return breverrors.WrapAndTrace(err) + } + if !isHTTPPort(port) && (updates.httpProtocol != nil || updates.authorizedEmails != nil) { + return breverrors.NewValidationError("--protocol, --authorize, and --public can only update an HTTP mapping") + } + + updated, err := applyPortUpdates(ctx, portStore, target, port, updates) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return writeUpdateResult(out, nameOrID, updated, jsonOutput) +} + +func selectPortToUpdate( + prompter terminal.Selector, + apiPorts []*devplanev1.Port, + portID string, +) (*devplanev1.Port, error) { + ports := removablePorts(apiPorts) + if len(ports) == 0 { + return nil, fmt.Errorf("no updatable ports are open on this target") + } + if portID != "" { + for _, port := range ports { + if port.GetPortId() == portID { + return port, nil + } + } + return nil, fmt.Errorf("port_id %q is not open on this target", portID) + } + + labels := make([]string, len(ports)) + for i, port := range ports { + labels[i] = closeSelectionLabel(i, port) + } + chosen := prompter.Select("Select a port to update", labels) + for i, label := range labels { + if label == chosen { + return ports[i], nil + } + } + return nil, fmt.Errorf("selected item did not match any open port") +} + +func isHTTPPort(port *devplanev1.Port) bool { + return port.GetHttpProtocol() != devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_UNSPECIFIED +} + +func applyPortUpdates( + ctx context.Context, + portStore Store, + target *cmdutil.WorkspaceOrNode, + port *devplanev1.Port, + updates portUpdates, +) (*devplanev1.Port, error) { + updated := port + var err error + + if updates.destinationPort != nil { + updated, err = setPortTarget(ctx, portStore, target, port.GetPortId(), *updates.destinationPort) + if err != nil { + return nil, fmt.Errorf("update destination port: %w", err) + } + } + if updates.allowedSources != nil { + updated, err = setPortAllowedSources(ctx, portStore, target, port.GetPortId(), *updates.allowedSources) + if err != nil { + return nil, fmt.Errorf("update allowed sources: %w", err) + } + } + if updates.httpProtocol != nil { + updated, err = setHTTPPortProtocol(ctx, portStore, target, port.GetPortId(), *updates.httpProtocol) + if err != nil { + return nil, fmt.Errorf("update HTTP protocol: %w", err) + } + } + if updates.authorizedEmails != nil { + updated, err = setHTTPPortAccess(ctx, portStore, target, port.GetPortId(), *updates.authorizedEmails, updates.public) + if err != nil { + return nil, fmt.Errorf("update HTTP access: %w", err) + } + } + + if updated == nil { + return nil, fmt.Errorf("update port %q: API returned no port", port.GetPortId()) + } + return updated, nil +} + +//nolint:dupl // Environment and node RPCs intentionally have parallel request types. +func setPortTarget( + ctx context.Context, + portStore Store, + target *cmdutil.WorkspaceOrNode, + portID string, + destinationPort int32, +) (*devplanev1.Port, error) { + if target.Workspace != nil { + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetPortTarget(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetPortTargetRequest{ + PortId: portID, PortNumber: destinationPort, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set destination port: API returned no port") + } + return resp.Msg.GetPort(), nil + } + if target.Node != nil { + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetPortTarget(ctx, connect.NewRequest(&devplanev1.SetPortTargetRequest{ + PortId: portID, PortNumber: destinationPort, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set destination port: API returned no port") + } + return resp.Msg.GetPort(), nil + } + return nil, fmt.Errorf("resolved target has no instance or external node") +} + +func setPortAllowedSources( + ctx context.Context, + portStore Store, + target *cmdutil.WorkspaceOrNode, + portID string, + allowedSources []string, +) (*devplanev1.Port, error) { + if target.Workspace != nil { + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetPortAllowedSources(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetPortAllowedSourcesRequest{ + PortId: portID, + AllowedSources: &devplanev1.EnvironmentServiceSetPortAllowedSourcesRequestAllowedSources{ + CidrBlocks: allowedSources, + }, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set allowed sources: API returned no port") + } + return resp.Msg.GetPort(), nil + } + if target.Node != nil { + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetPortAllowedSources(ctx, connect.NewRequest(&devplanev1.SetPortAllowedSourcesRequest{ + PortId: portID, AllowedSources: allowedSources, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set allowed sources: API returned no port") + } + return resp.Msg.GetPort(), nil + } + return nil, fmt.Errorf("resolved target has no instance or external node") +} + +//nolint:dupl // Environment and node RPCs intentionally have parallel request types. +func setHTTPPortProtocol( + ctx context.Context, + portStore Store, + target *cmdutil.WorkspaceOrNode, + portID string, + protocol devplanev1.HttpPortProtocol, +) (*devplanev1.Port, error) { + if target.Workspace != nil { + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetHTTPPortProtocol(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetHTTPPortProtocolRequest{ + PortId: portID, HttpProtocol: protocol, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set HTTP protocol: API returned no port") + } + return resp.Msg.GetPort(), nil + } + if target.Node != nil { + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetHTTPPortProtocol(ctx, connect.NewRequest(&devplanev1.SetHTTPPortProtocolRequest{ + PortId: portID, HttpProtocol: protocol, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set HTTP protocol: API returned no port") + } + return resp.Msg.GetPort(), nil + } + return nil, fmt.Errorf("resolved target has no instance or external node") +} + +func setHTTPPortAccess( + ctx context.Context, + portStore Store, + target *cmdutil.WorkspaceOrNode, + portID string, + authorizedEmails []string, + public bool, +) (*devplanev1.Port, error) { + if target.Workspace != nil { + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetHTTPPortAccess(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetHTTPPortAccessRequest{ + PortId: portID, + AuthorizedEmails: &devplanev1.EnvironmentServiceSetHTTPPortAccessRequestAuthorizedEmails{ + Emails: authorizedEmails, + }, + AllowPublicUnauthenticated: public, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set HTTP access: API returned no port") + } + return resp.Msg.GetPort(), nil + } + if target.Node != nil { + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.SetHTTPPortAccess(ctx, connect.NewRequest(&devplanev1.SetHTTPPortAccessRequest{ + PortId: portID, + AuthorizedEmails: authorizedEmails, + AllowPublicUnauthenticated: public, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if resp == nil || resp.Msg == nil || resp.Msg.GetPort() == nil { + return nil, fmt.Errorf("set HTTP access: API returned no port") + } + return resp.Msg.GetPort(), nil + } + return nil, fmt.Errorf("resolved target has no instance or external node") +} + +func writeUpdateResult(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) + } + + if _, err := fmt.Fprintf(out, "Updated port %s on %s.\n", port.GetPortId(), nameOrID); err != nil { + return breverrors.WrapAndTrace(err) + } + return displayTables(out, nameOrID, []PortInfo{portInfo}) +} diff --git a/pkg/cmd/ports/update_test.go b/pkg/cmd/ports/update_test.go new file mode 100644 index 00000000..7db9e4fb --- /dev/null +++ b/pkg/cmd/ports/update_test.go @@ -0,0 +1,365 @@ +package ports + +import ( + "bytes" + "context" + "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 fakeUpdatePrompter struct { + selectIndex int + selectCalls int + items []string +} + +func (p *fakeUpdatePrompter) Select(_ string, items []string) string { + p.selectCalls++ + p.items = append([]string{}, items...) + if p.selectIndex < 0 || p.selectIndex >= len(items) { + return "" + } + return items[p.selectIndex] +} + +type fakeUpdateEnvironmentService struct { + devplanev1connect.UnimplementedEnvironmentServiceHandler + t *testing.T + expectedEnvID string + ports []*devplanev1.Port + responsePort *devplanev1.Port + targetReq *devplanev1.EnvironmentServiceSetPortTargetRequest + sourcesReq *devplanev1.EnvironmentServiceSetPortAllowedSourcesRequest + protocolReq *devplanev1.EnvironmentServiceSetHTTPPortProtocolRequest + accessReq *devplanev1.EnvironmentServiceSetHTTPPortAccessRequest +} + +func (s *fakeUpdateEnvironmentService) 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{ + Status: devplanev1.NetworkMemberStatus_NETWORK_MEMBER_STATUS_CONNECTED, + Ports: s.ports, + }, + }), nil +} + +func (s *fakeUpdateEnvironmentService) SetPortTarget( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceSetPortTargetRequest], +) (*connect.Response[devplanev1.EnvironmentServiceSetPortTargetResponse], error) { + s.targetReq = req.Msg + return connect.NewResponse(&devplanev1.EnvironmentServiceSetPortTargetResponse{Port: s.responsePort}), nil +} + +func (s *fakeUpdateEnvironmentService) SetPortAllowedSources( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceSetPortAllowedSourcesRequest], +) (*connect.Response[devplanev1.EnvironmentServiceSetPortAllowedSourcesResponse], error) { + s.sourcesReq = req.Msg + return connect.NewResponse(&devplanev1.EnvironmentServiceSetPortAllowedSourcesResponse{Port: s.responsePort}), nil +} + +func (s *fakeUpdateEnvironmentService) SetHTTPPortProtocol( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceSetHTTPPortProtocolRequest], +) (*connect.Response[devplanev1.EnvironmentServiceSetHTTPPortProtocolResponse], error) { + s.protocolReq = req.Msg + return connect.NewResponse(&devplanev1.EnvironmentServiceSetHTTPPortProtocolResponse{Port: s.responsePort}), nil +} + +func (s *fakeUpdateEnvironmentService) SetHTTPPortAccess( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceSetHTTPPortAccessRequest], +) (*connect.Response[devplanev1.EnvironmentServiceSetHTTPPortAccessResponse], error) { + s.accessReq = req.Msg + return connect.NewResponse(&devplanev1.EnvironmentServiceSetHTTPPortAccessResponse{Port: s.responsePort}), nil +} + +type fakeUpdateNodeService struct { + devplanev1connect.UnimplementedExternalNodeServiceHandler + node *devplanev1.ExternalNode + responsePort *devplanev1.Port + targetReq *devplanev1.SetPortTargetRequest + sourcesReq *devplanev1.SetPortAllowedSourcesRequest + protocolReq *devplanev1.SetHTTPPortProtocolRequest + accessReq *devplanev1.SetHTTPPortAccessRequest +} + +func (s *fakeUpdateNodeService) 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 *fakeUpdateNodeService) SetPortTarget( + _ context.Context, + req *connect.Request[devplanev1.SetPortTargetRequest], +) (*connect.Response[devplanev1.SetPortTargetResponse], error) { + s.targetReq = req.Msg + return connect.NewResponse(&devplanev1.SetPortTargetResponse{Port: s.responsePort}), nil +} + +func (s *fakeUpdateNodeService) SetPortAllowedSources( + _ context.Context, + req *connect.Request[devplanev1.SetPortAllowedSourcesRequest], +) (*connect.Response[devplanev1.SetPortAllowedSourcesResponse], error) { + s.sourcesReq = req.Msg + return connect.NewResponse(&devplanev1.SetPortAllowedSourcesResponse{Port: s.responsePort}), nil +} + +func (s *fakeUpdateNodeService) SetHTTPPortProtocol( + _ context.Context, + req *connect.Request[devplanev1.SetHTTPPortProtocolRequest], +) (*connect.Response[devplanev1.SetHTTPPortProtocolResponse], error) { + s.protocolReq = req.Msg + return connect.NewResponse(&devplanev1.SetHTTPPortProtocolResponse{Port: s.responsePort}), nil +} + +func (s *fakeUpdateNodeService) SetHTTPPortAccess( + _ context.Context, + req *connect.Request[devplanev1.SetHTTPPortAccessRequest], +) (*connect.Response[devplanev1.SetHTTPPortAccessResponse], error) { + s.accessReq = req.Msg + return connect.NewResponse(&devplanev1.SetHTTPPortAccessResponse{Port: s.responsePort}), nil +} + +func newUpdateEnvironmentStore() *fakeStore { + return &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"}, + } +} + +func testHTTPPort(destinationPort int32) *devplanev1.Port { + hostname := "demo.apps.run.brev.nvidia.com" + return &devplanev1.Port{ + PortId: "http-one", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + PortNumber: 443, + ServerPort: destinationPort, + Hostname: &hostname, + AuthorizedEmails: []string{"me@example.com"}, + } +} + +func TestUpdateEnvironmentDestinationAndAllowedSources(t *testing.T) { + updated := testTCPPort("nport-one", 41001) + updated.ServerPort = 9090 + updated.AllowedSources = []string{"203.0.113.10/32", "198.51.100.0/24"} + service := &fakeUpdateEnvironmentService{ + t: t, + expectedEnvID: "env123", + ports: []*devplanev1.Port{testTCPPort("nport-one", 41001)}, + responsePort: updated, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + cmd := newCmdUpdatePort(newUpdateEnvironmentStore(), &fakeUpdatePrompter{selectIndex: -1}) + cmd.SetArgs([]string{ + "my-instance", "--id", "nport-one", "--destination-port", "9090", + "--allow", "203.0.113.10/32", "--allow", "198.51.100.0/24", "--json", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + require.NotNil(t, service.targetReq) + assert.Equal(t, "nport-one", service.targetReq.GetPortId()) + assert.Equal(t, int32(9090), service.targetReq.GetPortNumber()) + require.NotNil(t, service.sourcesReq) + assert.Equal(t, []string{"203.0.113.10/32", "198.51.100.0/24"}, service.sourcesReq.GetAllowedSources().GetCidrBlocks()) + assert.JSONEq(t, `{ + "port_id":"nport-one", + "kind":"network", + "endpoint":"global.prd.ga.run.brev.nvidia.com:41001", + "public_port":41001, + "destination_port":9090, + "protocol":"TCP", + "allowed_sources":["203.0.113.10/32","198.51.100.0/24"], + "authorized_emails":[], + "allow_public_unauthenticated":false, + "type":"user" + }`, out.String()) +} + +func TestUpdateExternalNodeHTTPProtocolAndPublicAccess(t *testing.T) { + public := true + updated := testHTTPPort(8443) + updated.HttpProtocol = devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS + updated.AuthorizedEmails = nil + updated.AllowPublicUnauthenticated = &public + service := &fakeUpdateNodeService{ + node: &devplanev1.ExternalNode{ + ExternalNodeId: "unode123", + Name: "my-node", + Ports: []*devplanev1.Port{testHTTPPort(8443)}, + }, + responsePort: updated, + } + _, handler := devplanev1connect.NewExternalNodeServiceHandler(service) + newTestServer(t, handler) + store := &fakeStore{ + user: &entity.User{ID: "user1", Email: "me@example.com"}, + org: &entity.Organization{ID: "org1"}, + } + cmd := newCmdUpdatePort(store, &fakeUpdatePrompter{selectIndex: -1}) + cmd.SetArgs([]string{"my-node", "--id", "http-one", "--protocol", "https", "--public", "--json"}) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + require.NotNil(t, service.protocolReq) + assert.Equal(t, devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, service.protocolReq.GetHttpProtocol()) + require.NotNil(t, service.accessReq) + assert.Empty(t, service.accessReq.GetAuthorizedEmails()) + assert.True(t, service.accessReq.GetAllowPublicUnauthenticated()) + assert.Contains(t, out.String(), `"protocol": "HTTPS"`) + assert.Contains(t, out.String(), `"allow_public_unauthenticated": true`) +} + +func TestUpdateHTTPAuthorizedEmails(t *testing.T) { + updated := testHTTPPort(8080) + updated.AuthorizedEmails = []string{"one@example.com", "two@example.com"} + service := &fakeUpdateEnvironmentService{ + t: t, + expectedEnvID: "env123", + ports: []*devplanev1.Port{testHTTPPort(8080)}, + responsePort: updated, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + cmd := newCmdUpdatePort(newUpdateEnvironmentStore(), &fakeUpdatePrompter{selectIndex: -1}) + cmd.SetArgs([]string{ + "my-instance", "--id", "http-one", + "--authorize", "one@example.com", "--authorize", "two@example.com", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + require.NotNil(t, service.accessReq) + assert.Equal(t, []string{"one@example.com", "two@example.com"}, service.accessReq.GetAuthorizedEmails().GetEmails()) + assert.False(t, service.accessReq.GetAllowPublicUnauthenticated()) + assert.Contains(t, out.String(), "Updated port http-one on my-instance.") +} + +func TestUpdateInteractiveSelectionCanDisambiguateDuplicateDestinations(t *testing.T) { + updated := testTCPPort("nport-two", 52002) + updated.AllowedSources = []string{} + service := &fakeUpdateEnvironmentService{ + t: t, + expectedEnvID: "env123", + ports: []*devplanev1.Port{ + testTCPPort("nport-one", 41001), + testTCPPort("nport-two", 52002), + }, + responsePort: updated, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + prompter := &fakeUpdatePrompter{selectIndex: 1} + cmd := newCmdUpdatePort(newUpdateEnvironmentStore(), prompter) + cmd.SetArgs([]string{"my-instance", "--allow-anywhere"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, 1, prompter.selectCalls) + require.Len(t, prompter.items, 2) + assert.Contains(t, prompter.items[0], "public 41001 -> destination 8080") + assert.Contains(t, prompter.items[1], "public 52002 -> destination 8080") + require.NotNil(t, service.sourcesReq) + assert.Equal(t, "nport-two", service.sourcesReq.GetPortId()) + assert.Empty(t, service.sourcesReq.GetAllowedSources().GetCidrBlocks()) +} + +func TestUpdateRejectsUnknownID(t *testing.T) { + service := &fakeUpdateEnvironmentService{ + t: t, + expectedEnvID: "env123", + ports: []*devplanev1.Port{testTCPPort("nport-one", 41001)}, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + cmd := newCmdUpdatePort(newUpdateEnvironmentStore(), &fakeUpdatePrompter{selectIndex: -1}) + cmd.SetArgs([]string{"my-instance", "--id", "missing", "--destination-port", "9090"}) + + err := cmd.Execute() + + assert.ErrorContains(t, err, `port_id "missing" is not open on this target`) +} + +func TestUpdateRejectsHTTPFlagsForRawPort(t *testing.T) { + service := &fakeUpdateEnvironmentService{ + t: t, + expectedEnvID: "env123", + ports: []*devplanev1.Port{testTCPPort("nport-one", 41001)}, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + cmd := newCmdUpdatePort(newUpdateEnvironmentStore(), &fakeUpdatePrompter{selectIndex: -1}) + cmd.SetArgs([]string{"my-instance", "--id", "nport-one", "--public"}) + + err := cmd.Execute() + + assert.ErrorContains(t, err, "can only update an HTTP mapping") +} + +func TestBuildPortUpdatesValidation(t *testing.T) { + tests := []struct { + name string + opts updateOptions + want string + }{ + {name: "no updates", want: "specify at least one update"}, + { + name: "allow conflicts with anywhere", + opts: updateOptions{allowedSourcesSet: true, allowedSources: []string{"10.0.0.0/8"}, allowAnywhereSet: true, allowAnywhere: true}, + want: "--allow and --allow-anywhere cannot be used together", + }, + { + name: "authorize conflicts with public", + opts: updateOptions{authorizedEmailsSet: true, authorizedEmails: []string{"me@example.com"}, publicSet: true, public: true}, + want: "--authorize and --public cannot be used together", + }, + { + name: "invalid destination", + opts: updateOptions{destinationPortSet: true, destinationPort: "65536"}, + want: "must be a number between 1 and 65535", + }, + { + name: "invalid HTTP protocol", + opts: updateOptions{protocolSet: true, protocol: "tcp"}, + want: "must be http or https", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildPortUpdates(tt.opts) + assert.ErrorContains(t, err, tt.want) + }) + } +}