From eec6b87eeab37b74f689aa21a8b97d5f7cffa522 Mon Sep 17 00:00:00 2001 From: Nuno Diegues Date: Fri, 26 Nov 2021 12:37:54 +0000 Subject: [PATCH] TUN-5361: Commands for managing virtual networks --- cmd/cloudflared/tunnel/cmd.go | 2 + .../tunnel/subcommand_context_vnets.go | 40 +++ cmd/cloudflared/tunnel/subcommands.go | 2 +- cmd/cloudflared/tunnel/teamnet_subcommands.go | 2 +- cmd/cloudflared/tunnel/vnets_subcommands.go | 285 ++++++++++++++++++ teamnet/filter.go | 9 + tunnelstore/client.go | 14 +- tunnelstore/client_teamnet.go | 6 - tunnelstore/client_vnets.go | 89 ++++++ vnet/api.go | 46 +++ vnet/api_test.go | 79 +++++ vnet/filter.go | 99 ++++++ 12 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 cmd/cloudflared/tunnel/subcommand_context_vnets.go create mode 100644 cmd/cloudflared/tunnel/vnets_subcommands.go create mode 100644 tunnelstore/client_vnets.go create mode 100644 vnet/api.go create mode 100644 vnet/api_test.go create mode 100644 vnet/filter.go diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index 88b79ffe..d3c4aabe 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -102,6 +102,8 @@ func Commands() []*cli.Command { buildLoginSubcommand(false), buildCreateCommand(), buildRouteCommand(), + // TODO TUN-5477 this should not be hidden + buildVirtualNetworkSubcommand(true), buildRunCommand(), buildListCommand(), buildInfoCommand(), diff --git a/cmd/cloudflared/tunnel/subcommand_context_vnets.go b/cmd/cloudflared/tunnel/subcommand_context_vnets.go new file mode 100644 index 00000000..27bdc71a --- /dev/null +++ b/cmd/cloudflared/tunnel/subcommand_context_vnets.go @@ -0,0 +1,40 @@ +package tunnel + +import ( + "github.com/google/uuid" + "github.com/pkg/errors" + + "github.com/cloudflare/cloudflared/vnet" +) + +func (sc *subcommandContext) addVirtualNetwork(newVnet vnet.NewVirtualNetwork) (vnet.VirtualNetwork, error) { + client, err := sc.client() + if err != nil { + return vnet.VirtualNetwork{}, errors.Wrap(err, noClientMsg) + } + return client.CreateVirtualNetwork(newVnet) +} + +func (sc *subcommandContext) listVirtualNetworks(filter *vnet.Filter) ([]*vnet.VirtualNetwork, error) { + client, err := sc.client() + if err != nil { + return nil, errors.Wrap(err, noClientMsg) + } + return client.ListVirtualNetworks(filter) +} + +func (sc *subcommandContext) deleteVirtualNetwork(vnetId uuid.UUID) error { + client, err := sc.client() + if err != nil { + return errors.Wrap(err, noClientMsg) + } + return client.DeleteVirtualNetwork(vnetId) +} + +func (sc *subcommandContext) updateVirtualNetwork(vnetId uuid.UUID, updates vnet.UpdateVirtualNetwork) error { + client, err := sc.client() + if err != nil { + return errors.Wrap(err, noClientMsg) + } + return client.UpdateVirtualNetwork(vnetId, updates) +} diff --git a/cmd/cloudflared/tunnel/subcommands.go b/cmd/cloudflared/tunnel/subcommands.go index 7f415872..ff18298e 100644 --- a/cmd/cloudflared/tunnel/subcommands.go +++ b/cmd/cloudflared/tunnel/subcommands.go @@ -329,7 +329,7 @@ func listCommand(c *cli.Context) error { if len(tunnels) > 0 { formatAndPrintTunnelList(tunnels, c.Bool("show-recently-disconnected")) } else { - fmt.Println("You have no tunnels, use 'cloudflared tunnel create' to define a new tunnel") + fmt.Println("No tunnels were found for the given filter flags. You can use 'cloudflared tunnel create' to create a tunnel.") } return nil diff --git a/cmd/cloudflared/tunnel/teamnet_subcommands.go b/cmd/cloudflared/tunnel/teamnet_subcommands.go index d87c9892..9862fec9 100644 --- a/cmd/cloudflared/tunnel/teamnet_subcommands.go +++ b/cmd/cloudflared/tunnel/teamnet_subcommands.go @@ -98,7 +98,7 @@ func showRoutesCommand(c *cli.Context) error { if len(routes) > 0 { formatAndPrintRouteList(routes) } else { - fmt.Println("You have no routes, use 'cloudflared tunnel route ip add' to add a route") + fmt.Println("No routes were found for the given filter flags. You can use 'cloudflared tunnel route ip add' to add a route.") } return nil diff --git a/cmd/cloudflared/tunnel/vnets_subcommands.go b/cmd/cloudflared/tunnel/vnets_subcommands.go new file mode 100644 index 00000000..939b4af6 --- /dev/null +++ b/cmd/cloudflared/tunnel/vnets_subcommands.go @@ -0,0 +1,285 @@ +package tunnel + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" + + "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil" + "github.com/cloudflare/cloudflared/cmd/cloudflared/updater" + "github.com/cloudflare/cloudflared/vnet" +) + +var ( + makeDefaultFlag = &cli.BoolFlag{ + Name: "default", + Aliases: []string{"d"}, + Usage: "The virtual network becomes the default one for the account. This means that all operations that " + + "omit a virtual network will now implicitly be using this virtual network (i.e., the default one) such " + + "as new IP routes that are created. When this flag is not set, the virtual network will not become the " + + "default one in the account.", + } + newNameFlag = &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "The new name for the virtual network.", + } + newCommentFlag = &cli.StringFlag{ + Name: "comment", + Aliases: []string{"c"}, + Usage: "A new comment describing the purpose of the virtual network.", + } +) + +func buildVirtualNetworkSubcommand(hidden bool) *cli.Command { + return &cli.Command{ + Name: "network", + Usage: "Configure and query virtual networks to manage private IP routes with overlapping IPs.", + UsageText: "cloudflared tunnel [--config FILEPATH] network COMMAND [arguments...]", + Description: `cloudflared allows to manage IP routes that expose origins in your private network space via their IP directly +to clients outside (e.g. using WARP client) --- those are configurable via "cloudflared tunnel route ip" commands. +By default, all those IP routes live in the same virtual network. Managing virtual networks (e.g. by creating a +new one) becomes relevant when you have different private networks that have overlapping IPs. E.g.: if you have +a private network A running Tunnel 1, and private network B running Tunnel 2, it is possible that both Tunnels +expose the same IP space (say 10.0.0.0/8); to handle that, you have to add each IP Route (one that points to +Tunnel 1 and another that points to Tunnel 2) in different Virtual Networks. That way, if your clients are on +Virtual Network X, they will see Tunnel 1 (via Route A) and not see Tunnel 2 (since its Route B is associated +to another Virtual Network Y).`, + Hidden: hidden, + Subcommands: []*cli.Command{ + { + Name: "add", + Action: cliutil.ConfiguredAction(addVirtualNetworkCommand), + Usage: "Add a new virtual network to which IP routes can be attached", + UsageText: "cloudflared tunnel [--config FILEPATH] network add [flags] NAME [\"comment\"]", + Description: `Adds a new virtual network. You can then attach IP routes to this virtual network with "cloudflared tunnel route ip" +commands. By doing so, such route(s) become segregated from route(s) in another virtual networks. Note that all +routes exist within some virtual network. If you do not specify any, then the system pre-creates a default virtual +network to which all routes belong. That is fine if you do not have overlapping IPs within different physical +private networks in your infrastructure exposed via Cloudflare Tunnel. Note: if a virtual network is added as +the new default, then the previous existing default virtual network will be automatically modified to no longer +be the current default.`, + Flags: []cli.Flag{makeDefaultFlag}, + Hidden: hidden, + }, + { + Name: "list", + Action: cliutil.ConfiguredAction(listVirtualNetworksCommand), + Usage: "Lists the virtual networks", + UsageText: "cloudflared tunnel [--config FILEPATH] network list [flags]", + Description: "Lists the virtual networks based on the given filter flags.", + Flags: listVirtualNetworksFlags(), + Hidden: hidden, + }, + { + Name: "delete", + Action: cliutil.ConfiguredAction(deleteVirtualNetworkCommand), + Usage: "Delete a virtual network", + UsageText: "cloudflared tunnel [--config FILEPATH] network delete VIRTUAL_NETWORK", + Description: `Deletes the virtual network (given its ID or name). This is only possible if that virtual network is unused. +A virtual network may be used by IP routes or by WARP devices.`, + Hidden: hidden, + }, + { + Name: "update", + Action: cliutil.ConfiguredAction(updateVirtualNetworkCommand), + Usage: "Update a virtual network", + UsageText: "cloudflared tunnel [--config FILEPATH] network update [flags] VIRTUAL_NETWORK", + Description: `Updates the virtual network (given its ID or name). If this virtual network is updated to become the new +default, then the previously existing default virtual network will also be modified to no longer be the default. +You cannot update a virtual network to not be the default anymore directly. Instead, you should create a new +default or update an existing one to become the default.`, + Flags: []cli.Flag{newNameFlag, newCommentFlag, makeDefaultFlag}, + Hidden: hidden, + }, + }, + } +} + +func listVirtualNetworksFlags() []cli.Flag { + flags := make([]cli.Flag, 0) + flags = append(flags, vnet.FilterFlags...) + flags = append(flags, outputFormatFlag) + return flags +} + +func addVirtualNetworkCommand(c *cli.Context) error { + sc, err := newSubcommandContext(c) + if err != nil { + return err + } + if c.NArg() < 1 { + return errors.New("You must supply at least 1 argument, the name of the virtual network you wish to add.") + } + + warningChecker := updater.StartWarningCheck(c) + defer warningChecker.LogWarningIfAny(sc.log) + + args := c.Args() + + name := args.Get(0) + + comment := "" + if c.NArg() >= 2 { + comment = args.Get(1) + } + + newVnet := vnet.NewVirtualNetwork{ + Name: name, + Comment: comment, + IsDefault: c.Bool(makeDefaultFlag.Name), + } + createdVnet, err := sc.addVirtualNetwork(newVnet) + + if err != nil { + return errors.Wrap(err, "Could not add virtual network") + } + + extraMsg := "" + if createdVnet.IsDefault { + extraMsg = " (as the new default for this account) " + } + fmt.Printf( + "Successfully added virtual 'network' %s with ID: %s%s\n"+ + "You can now add IP routes attached to this virtual network. See `cloudflared tunnel route ip add -help`\n", + name, createdVnet.ID, extraMsg, + ) + return nil +} + +func listVirtualNetworksCommand(c *cli.Context) error { + sc, err := newSubcommandContext(c) + if err != nil { + return err + } + + warningChecker := updater.StartWarningCheck(c) + defer warningChecker.LogWarningIfAny(sc.log) + + filter, err := vnet.NewFromCLI(c) + if err != nil { + return errors.Wrap(err, "invalid flags for filtering virtual networks") + } + + vnets, err := sc.listVirtualNetworks(filter) + if err != nil { + return err + } + + if outputFormat := c.String(outputFormatFlag.Name); outputFormat != "" { + return renderOutput(outputFormat, vnets) + } + + if len(vnets) > 0 { + formatAndPrintVnetsList(vnets) + } else { + fmt.Println("No virtual networks were found for the given filter flags. You can use 'cloudflared tunnel network add' to add a virtual network.") + } + + return nil +} + +func deleteVirtualNetworkCommand(c *cli.Context) error { + sc, err := newSubcommandContext(c) + if err != nil { + return err + } + if c.NArg() != 1 { + return errors.New("You must supply exactly one argument, either the ID or name of the virtual network to delete") + } + + input := c.Args().Get(0) + vnetId, err := getVnetId(sc, input) + if err != nil { + return err + } + + if err := sc.deleteVirtualNetwork(vnetId); err != nil { + return errors.Wrap(err, "API error") + } + fmt.Printf("Successfully deleted virtual network '%s'\n", input) + return nil +} + +func updateVirtualNetworkCommand(c *cli.Context) error { + sc, err := newSubcommandContext(c) + if err != nil { + return err + } + if c.NArg() != 1 { + return errors.New(" You must supply exactly one argument, either the ID or (current) name of the virtual network to update") + } + + input := c.Args().Get(0) + vnetId, err := getVnetId(sc, input) + if err != nil { + return err + } + + updates := vnet.UpdateVirtualNetwork{} + + if c.IsSet(newNameFlag.Name) { + newName := c.String(newNameFlag.Name) + updates.Name = &newName + } + if c.IsSet(newCommentFlag.Name) { + newComment := c.String(newCommentFlag.Name) + updates.Comment = &newComment + } + if c.IsSet(makeDefaultFlag.Name) { + isDefault := c.Bool(makeDefaultFlag.Name) + updates.IsDefault = &isDefault + } + + if err := sc.updateVirtualNetwork(vnetId, updates); err != nil { + return errors.Wrap(err, "API error") + } + fmt.Printf("Successfully updated virtual network '%s'\n", input) + return nil +} + +func getVnetId(sc *subcommandContext, input string) (uuid.UUID, error) { + val, err := uuid.Parse(input) + if err == nil { + return val, nil + } + + filter := vnet.NewFilter() + filter.WithDeleted(false) + filter.ByName(input) + + vnets, err := sc.listVirtualNetworks(filter) + if err != nil { + return uuid.Nil, err + } + + if len(vnets) != 1 { + return uuid.Nil, fmt.Errorf("there should only be 1 non-deleted virtual network named %s", input) + } + + return vnets[0].ID, nil +} + +func formatAndPrintVnetsList(vnets []*vnet.VirtualNetwork) { + const ( + minWidth = 0 + tabWidth = 8 + padding = 1 + padChar = ' ' + flags = 0 + ) + + writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags) + defer writer.Flush() + + _, _ = fmt.Fprintln(writer, "ID\tNAME\tIS DEFAULT\tCOMMENT\tCREATED\tDELETED\t") + + for _, virtualNetwork := range vnets { + formattedStr := virtualNetwork.TableString() + _, _ = fmt.Fprintln(writer, formattedStr) + } +} diff --git a/teamnet/filter.go b/teamnet/filter.go index 6cd8946b..a664834a 100644 --- a/teamnet/filter.go +++ b/teamnet/filter.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/url" + "strconv" "time" "github.com/google/uuid" @@ -86,6 +87,10 @@ func NewFromCLI(c *cli.Context) (*Filter, error) { f.tunnelID(u) } + if maxFetch := c.Int("max-fetch-size"); maxFetch > 0 { + f.MaxFetchSize(uint(maxFetch)) + } + return f, nil } @@ -133,6 +138,10 @@ func (f *Filter) tunnelID(id uuid.UUID) { f.queryParams.Set("tunnel_id", id.String()) } +func (f *Filter) MaxFetchSize(max uint) { + f.queryParams.Set("per_page", strconv.Itoa(int(max))) +} + func (f Filter) Encode() string { return f.queryParams.Encode() } diff --git a/tunnelstore/client.go b/tunnelstore/client.go index 764cfd3b..22aa0c59 100644 --- a/tunnelstore/client.go +++ b/tunnelstore/client.go @@ -15,10 +15,10 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" "github.com/rs/zerolog" - "golang.org/x/net/http2" "github.com/cloudflare/cloudflared/teamnet" + "github.com/cloudflare/cloudflared/vnet" ) const ( @@ -238,6 +238,12 @@ type Client interface { AddRoute(newRoute teamnet.NewRoute) (teamnet.Route, error) DeleteRoute(network net.IPNet) error GetByIP(ip net.IP) (teamnet.DetailedRoute, error) + + // Virtual Networks endpoints + CreateVirtualNetwork(newVnet vnet.NewVirtualNetwork) (vnet.VirtualNetwork, error) + ListVirtualNetworks(filter *vnet.Filter) ([]*vnet.VirtualNetwork, error) + DeleteVirtualNetwork(id uuid.UUID) error + UpdateVirtualNetwork(id uuid.UUID, updates vnet.UpdateVirtualNetwork) error } type RESTClient struct { @@ -252,6 +258,7 @@ type baseEndpoints struct { accountLevel url.URL zoneLevel url.URL accountRoutes url.URL + accountVnets url.URL } var _ Client = (*RESTClient)(nil) @@ -268,6 +275,10 @@ func NewRESTClient(baseURL, accountTag, zoneTag, authToken, userAgent string, lo if err != nil { return nil, errors.Wrap(err, "failed to create route account-level endpoint") } + accountVnetsEndpoint, err := url.Parse(fmt.Sprintf("%s/accounts/%s/virtual_networks", baseURL, accountTag)) + if err != nil { + return nil, errors.Wrap(err, "failed to create virtual network account-level endpoint") + } zoneLevelEndpoint, err := url.Parse(fmt.Sprintf("%s/zones/%s/tunnels", baseURL, zoneTag)) if err != nil { return nil, errors.Wrap(err, "failed to create account level endpoint") @@ -282,6 +293,7 @@ func NewRESTClient(baseURL, accountTag, zoneTag, authToken, userAgent string, lo accountLevel: *accountLevelEndpoint, zoneLevel: *zoneLevelEndpoint, accountRoutes: *accountRoutesEndpoint, + accountVnets: *accountVnetsEndpoint, }, authToken: authToken, userAgent: userAgent, diff --git a/tunnelstore/client_teamnet.go b/tunnelstore/client_teamnet.go index 5e8addec..fe43f82a 100644 --- a/tunnelstore/client_teamnet.go +++ b/tunnelstore/client_teamnet.go @@ -81,12 +81,6 @@ func (r *RESTClient) GetByIP(ip net.IP) (teamnet.DetailedRoute, error) { return teamnet.DetailedRoute{}, r.statusCodeToError("get route by IP", resp) } -func parseListRoutes(body io.ReadCloser) ([]*teamnet.Route, error) { - var routes []*teamnet.Route - err := parseResponse(body, &routes) - return routes, err -} - func parseListDetailedRoutes(body io.ReadCloser) ([]*teamnet.DetailedRoute, error) { var routes []*teamnet.DetailedRoute err := parseResponse(body, &routes) diff --git a/tunnelstore/client_vnets.go b/tunnelstore/client_vnets.go new file mode 100644 index 00000000..ab83c617 --- /dev/null +++ b/tunnelstore/client_vnets.go @@ -0,0 +1,89 @@ +package tunnelstore + +import ( + "io" + "net/http" + "net/url" + "path" + + "github.com/google/uuid" + "github.com/pkg/errors" + + "github.com/cloudflare/cloudflared/vnet" +) + +func (r *RESTClient) CreateVirtualNetwork(newVnet vnet.NewVirtualNetwork) (vnet.VirtualNetwork, error) { + resp, err := r.sendRequest("POST", r.baseEndpoints.accountVnets, newVnet) + if err != nil { + return vnet.VirtualNetwork{}, errors.Wrap(err, "REST request failed") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return parseVnet(resp.Body) + } + + return vnet.VirtualNetwork{}, r.statusCodeToError("add virtual network", resp) +} + +func (r *RESTClient) ListVirtualNetworks(filter *vnet.Filter) ([]*vnet.VirtualNetwork, error) { + endpoint := r.baseEndpoints.accountVnets + endpoint.RawQuery = filter.Encode() + resp, err := r.sendRequest("GET", endpoint, nil) + if err != nil { + return nil, errors.Wrap(err, "REST request failed") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return parseListVnets(resp.Body) + } + + return nil, r.statusCodeToError("list virtual networks", resp) +} + +func (r *RESTClient) DeleteVirtualNetwork(id uuid.UUID) error { + endpoint := r.baseEndpoints.accountVnets + endpoint.Path = path.Join(endpoint.Path, url.PathEscape(id.String())) + resp, err := r.sendRequest("DELETE", endpoint, nil) + if err != nil { + return errors.Wrap(err, "REST request failed") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + _, err := parseVnet(resp.Body) + return err + } + + return r.statusCodeToError("delete virtual network", resp) +} + +func (r *RESTClient) UpdateVirtualNetwork(id uuid.UUID, updates vnet.UpdateVirtualNetwork) error { + endpoint := r.baseEndpoints.accountVnets + endpoint.Path = path.Join(endpoint.Path, url.PathEscape(id.String())) + resp, err := r.sendRequest("PATCH", endpoint, updates) + if err != nil { + return errors.Wrap(err, "REST request failed") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + _, err := parseVnet(resp.Body) + return err + } + + return r.statusCodeToError("update virtual network", resp) +} + +func parseListVnets(body io.ReadCloser) ([]*vnet.VirtualNetwork, error) { + var vnets []*vnet.VirtualNetwork + err := parseResponse(body, &vnets) + return vnets, err +} + +func parseVnet(body io.ReadCloser) (vnet.VirtualNetwork, error) { + var vnet vnet.VirtualNetwork + err := parseResponse(body, &vnet) + return vnet, err +} diff --git a/vnet/api.go b/vnet/api.go new file mode 100644 index 00000000..3ce38d4f --- /dev/null +++ b/vnet/api.go @@ -0,0 +1,46 @@ +package vnet + +import ( + "fmt" + "strconv" + "time" + + "github.com/google/uuid" +) + +type NewVirtualNetwork struct { + Name string `json:"name"` + Comment string `json:"comment"` + IsDefault bool `json:"is_default"` +} + +type VirtualNetwork struct { + ID uuid.UUID `json:"id"` + Comment string `json:"comment"` + Name string `json:"name"` + IsDefault bool `json:"is_default_network"` + CreatedAt time.Time `json:"created_at"` + DeletedAt time.Time `json:"deleted_at"` +} + +type UpdateVirtualNetwork struct { + Name *string `json:"name,omitempty"` + Comment *string `json:"comment,omitempty"` + IsDefault *bool `json:"is_default_network,omitempty"` +} + +func (virtualNetwork VirtualNetwork) TableString() string { + deletedColumn := "-" + if !virtualNetwork.DeletedAt.IsZero() { + deletedColumn = virtualNetwork.DeletedAt.Format(time.RFC3339) + } + return fmt.Sprintf( + "%s\t%s\t%s\t%s\t%s\t%s\t", + virtualNetwork.ID, + virtualNetwork.Name, + strconv.FormatBool(virtualNetwork.IsDefault), + virtualNetwork.Comment, + virtualNetwork.CreatedAt.Format(time.RFC3339), + deletedColumn, + ) +} diff --git a/vnet/api_test.go b/vnet/api_test.go new file mode 100644 index 00000000..76e35715 --- /dev/null +++ b/vnet/api_test.go @@ -0,0 +1,79 @@ +package vnet + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestVirtualNetworkJsonRoundtrip(t *testing.T) { + data := `{ + "id":"74fce949-351b-4752-b261-81a56cfd3130", + "comment":"New York DC1", + "name":"us-east-1", + "is_default_network":true, + "created_at":"2021-11-26T14:40:02.600673Z", + "deleted_at":"2021-12-01T10:23:13.102645Z" + }` + var v VirtualNetwork + err := json.Unmarshal([]byte(data), &v) + + require.NoError(t, err) + require.Equal(t, uuid.MustParse("74fce949-351b-4752-b261-81a56cfd3130"), v.ID) + require.Equal(t, "us-east-1", v.Name) + require.Equal(t, "New York DC1", v.Comment) + require.Equal(t, true, v.IsDefault) + + bytes, err := json.Marshal(v) + require.NoError(t, err) + obtainedJson := string(bytes) + data = strings.Replace(data, "\t", "", -1) + data = strings.Replace(data, "\n", "", -1) + require.Equal(t, data, obtainedJson) +} + +func TestMarshalNewVnet(t *testing.T) { + newVnet := NewVirtualNetwork{ + Name: "eu-west-1", + Comment: "London office", + IsDefault: true, + } + + serialized, err := json.Marshal(newVnet) + require.NoError(t, err) + require.True(t, strings.Contains(string(serialized), newVnet.Name)) +} + +func TestMarshalUpdateVnet(t *testing.T) { + newName := "bulgaria-1" + updates := UpdateVirtualNetwork{ + Name: &newName, + } + + // Test where receiver is struct + serialized, err := json.Marshal(updates) + require.NoError(t, err) + require.True(t, strings.Contains(string(serialized), newName)) +} + +func TestVnetTableString(t *testing.T) { + virtualNet := VirtualNetwork{ + ID: uuid.New(), + Name: "us-east-1", + Comment: "New York DC1", + IsDefault: true, + CreatedAt: time.Now(), + DeletedAt: time.Time{}, + } + + row := virtualNet.TableString() + require.True(t, strings.HasPrefix(row, virtualNet.ID.String())) + require.True(t, strings.Contains(row, virtualNet.Name)) + require.True(t, strings.Contains(row, virtualNet.Comment)) + require.True(t, strings.Contains(row, "true")) + require.True(t, strings.HasSuffix(row, "-\t")) +} diff --git a/vnet/filter.go b/vnet/filter.go new file mode 100644 index 00000000..5cd7d82e --- /dev/null +++ b/vnet/filter.go @@ -0,0 +1,99 @@ +package vnet + +import ( + "net/url" + "strconv" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" +) + +var ( + filterId = cli.StringFlag{ + Name: "id", + Usage: "List virtual networks with the given `ID`", + } + filterName = cli.StringFlag{ + Name: "name", + Usage: "List virtual networks with the given `NAME`", + } + filterDefault = cli.BoolFlag{ + Name: "is-default", + Usage: "If true, lists the virtual network that is the default one. If false, lists all non-default virtual networks for the account. If absent, all are included in the results regardless of their default status.", + } + filterDeleted = cli.BoolFlag{ + Name: "show-deleted", + Usage: "If false (default), only show non-deleted virtual networks. If true, only show deleted virtual networks.", + } + FilterFlags = []cli.Flag{ + &filterId, + &filterName, + &filterDefault, + &filterDeleted, + } +) + +// Filter which virtual networks get queried. +type Filter struct { + queryParams url.Values +} + +func NewFilter() *Filter { + return &Filter{ + queryParams: url.Values{}, + } +} + +func (f *Filter) ById(vnetId uuid.UUID) { + f.queryParams.Set("id", vnetId.String()) +} + +func (f *Filter) ByName(name string) { + f.queryParams.Set("name", name) +} + +func (f *Filter) ByDefaultStatus(isDefault bool) { + f.queryParams.Set("is_default", strconv.FormatBool(isDefault)) +} + +func (f *Filter) WithDeleted(isDeleted bool) { + f.queryParams.Set("is_deleted", strconv.FormatBool(isDeleted)) +} + +func (f *Filter) MaxFetchSize(max uint) { + f.queryParams.Set("per_page", strconv.Itoa(int(max))) +} + +func (f Filter) Encode() string { + return f.queryParams.Encode() +} + +// NewFromCLI parses CLI flags to discover which filters should get applied to list virtual networks. +func NewFromCLI(c *cli.Context) (*Filter, error) { + f := NewFilter() + + if id := c.String("id"); id != "" { + vnetId, err := uuid.Parse(id) + if err != nil { + return nil, errors.Wrapf(err, "%s is not a valid virtual network ID", id) + } + f.ById(vnetId) + } + + if name := c.String("name"); name != "" { + f.ByName(name) + } + + if c.IsSet("is-default") { + f.ByDefaultStatus(c.Bool("is-default")) + } + + f.WithDeleted(c.Bool("show-deleted")) + + if maxFetch := c.Int("max-fetch-size"); maxFetch > 0 { + f.MaxFetchSize(uint(maxFetch)) + } + + return f, nil +}