Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adds cloudflare_teams_route and acceptance tests #1572

Merged
merged 2 commits into from
Apr 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/1572.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:new-resource
cloudflare_teams_route
```
1 change: 1 addition & 0 deletions cloudflare/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ func Provider() *schema.Provider {
"cloudflare_teams_account": resourceCloudflareTeamsAccount(),
"cloudflare_teams_list": resourceCloudflareTeamsList(),
"cloudflare_teams_location": resourceCloudflareTeamsLocation(),
"cloudflare_teams_route": resourceCloudflareTeamsRoute(),
"cloudflare_teams_rule": resourceCloudflareTeamsRule(),
"cloudflare_teams_proxy_endpoint": resourceCloudflareTeamsProxyEndpoint(),
"cloudflare_waf_group": resourceCloudflareWAFGroup(),
Expand Down
134 changes: 134 additions & 0 deletions cloudflare/resource_cloudflare_teams_route.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package cloudflare

import (
"context"
"errors"
"fmt"
"strings"

"github.com/cloudflare/cloudflare-go"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func resourceCloudflareTeamsRoute() *schema.Resource {
return &schema.Resource{
Schema: resourceCloudflareTeamsRouteSchema(),
Create: resourceCloudflareTeamsRouteCreate,
Read: resourceCloudflareTeamsRouteRead,
Update: resourceCloudflareTeamsRouteUpdate,
Delete: resourceCloudflareTeamsRouteDelete,
Importer: &schema.ResourceImporter{
State: resourceCloudflareTeamsRouteImport,
},
}
}

func resourceCloudflareTeamsRouteRead(d *schema.ResourceData, meta interface{}) error {
client := meta.(*cloudflare.API)

tunnelRoute, err := client.GetTunnelRouteForIP(context.Background(), cloudflare.TunnelRoutesForIPParams{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of this to read the resource seems fragile. If you are managing two routes, say 10.0.0.0/8 and 10.10.0.0/24 via terraform and 10.10.0.0/24 gets manually removed on accident ... terraform will scan resources and actually find the route for 10.0.0.0/8 for the 10.10.0.0/24 resource. Was it not feasible to use the List Tunnel Routes API passing in the tunnel_id parameter to specify an exact tunnel?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, perhaps this should verify the network returned is an exact match for what was requested to prevent this?

Copy link
Contributor

@tjstansell tjstansell Apr 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess more than one network could route to the same tunnel, but you could pass in network_subnet=10.10.0.0/24 and network_superset=10.10.0.0/24 to get the exact route. And probably include is_deleted=false too ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look at doing that instead. It's a lot of workaround for an endpoint that doesn't follow the normal pattern of the V4 API.

AccountID: d.Get("account_id").(string),
Network: d.Get("network").(string),
})
if err != nil {
// FIXME(2022-04-21): Until the API returns a valid v4 compatible envelope, we need to
// check if the error message is related to problems unmarshalling the response _or_
// an expected not found error.
var notFoundError *cloudflare.NotFoundError
if strings.Contains(err.Error(), "error unmarshalling the JSON response error body") || errors.As(err, &notFoundError) {
d.SetId("")
return nil
}

return fmt.Errorf("error reading Tunnel Route for Network %q: %w", d.Id(), err)
}

d.Set("tunnel_id", tunnelRoute.TunnelID)
d.Set("network", tunnelRoute.Network)

if len(tunnelRoute.Comment) > 0 {
d.Set("comment", tunnelRoute.Comment)
}

return nil
}

func resourceCloudflareTeamsRouteCreate(d *schema.ResourceData, meta interface{}) error {
client := meta.(*cloudflare.API)

resource := cloudflare.TunnelRoutesCreateParams{
AccountID: d.Get("account_id").(string),
TunnelID: d.Get("tunnel_id").(string),
Network: d.Get("network").(string),
}

if comment, ok := d.Get("comment").(string); ok {
resource.Comment = comment
}

newTunnelRoute, err := client.CreateTunnelRoute(context.Background(), resource)
if err != nil {
return fmt.Errorf("error creating Tunnel Route for Network %q: %w", d.Get("network").(string), err)
}

d.SetId(newTunnelRoute.Network)

return resourceCloudflareTeamsRouteRead(d, meta)
}

func resourceCloudflareTeamsRouteUpdate(d *schema.ResourceData, meta interface{}) error {
client := meta.(*cloudflare.API)

resource := cloudflare.TunnelRoutesUpdateParams{
AccountID: d.Get("account_id").(string),
TunnelID: d.Get("tunnel_id").(string),
Network: d.Get("network").(string),
}

if comment, ok := d.Get("comment").(string); ok {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have a comment in a resource and then simply delete the line with the intent to remove the comment, will it remove it? Perhaps the comment parameter should have a default of "" so it's always set to something since the API seems to always return an empty string as the comment field.

resource.Comment = comment
}

_, err := client.UpdateTunnelRoute(context.Background(), resource)
if err != nil {
return fmt.Errorf("error updating Tunnel Route for Network %q: %w", d.Get("network").(string), err)
}

return resourceCloudflareTeamsRouteRead(d, meta)
}

func resourceCloudflareTeamsRouteDelete(d *schema.ResourceData, meta interface{}) error {
client := meta.(*cloudflare.API)

err := client.DeleteTunnelRoute(context.Background(), cloudflare.TunnelRoutesDeleteParams{
AccountID: d.Get("account_id").(string),
Network: d.Get("network").(string),
})
if err != nil {
return fmt.Errorf("error deleting Tunnel Route for Network %q: %w", d.Get("network").(string), err)
}

return nil
}

func resourceCloudflareTeamsRouteImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) {
attributes := strings.SplitN(d.Id(), "/", 3)

if len(attributes) != 2 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import doesn't seem like it'll work as it splits into 3 parts but looks for only 2 here. Similarly, since the read function only uses the account_id and network to find the route, shouldn't import just require those two fields? tunnel_id here isn't actually used, as the read call overrides it from the api response.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I originally implemented it with a different API call where tunnel_id was required. I'll send a follow-up PR in a few moments.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doh. i hadn't noticed you'd responded ... and just submitted a pr myself ... feel free to kill mine if you've got one.

return nil, fmt.Errorf(`invalid id (%q) specified, should be in format "accountID/tunnelID/network"`, d.Id())
}

accountID, tunnelID, network := attributes[0], attributes[1], attributes[2]

d.SetId(network)
d.Set("account_id", accountID)
d.Set("tunnel_id", tunnelID)
d.Set("network", network)

err := resourceCloudflareTeamsRouteRead(d, meta)
if err != nil {
return nil, err
}

return []*schema.ResourceData{d}, nil
}
109 changes: 109 additions & 0 deletions cloudflare/resource_cloudflare_teams_route_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cloudflare

import (
"context"
"errors"
"fmt"
"os"
"testing"

"github.com/cloudflare/cloudflare-go"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
)

func TestAccCloudflareTeamsRouteExists(t *testing.T) {
rnd := generateRandomResourceName()
name := fmt.Sprintf("cloudflare_teams_route.%s", rnd)
accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID")

var TunnelRoute cloudflare.TunnelRoute

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheckAccount(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testAccCloudflareTeamsRouteSimple(rnd, rnd, accountID, "10.0.0.20/32"),
Check: resource.ComposeTestCheckFunc(
testAccCheckCloudflareTeamsRouteExists(name, &TunnelRoute),
resource.TestCheckResourceAttr(name, "account_id", accountID),
resource.TestCheckResourceAttrSet(name, "tunnel_id"),
resource.TestCheckResourceAttr(name, "network", "10.0.0.20/32"),
resource.TestCheckResourceAttr(name, "comment", rnd),
),
},
},
})
}

func testAccCheckCloudflareTeamsRouteExists(name string, route *cloudflare.TunnelRoute) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[name]
if !ok {
return fmt.Errorf("Not found: %s", name)
}

if rs.Primary.ID == "" {
return errors.New("No Teams route is set")
}

client := testAccProvider.Meta().(*cloudflare.API)
foundTunnelRoute, err := client.GetTunnelRouteForIP(context.Background(), cloudflare.TunnelRoutesForIPParams{
Network: rs.Primary.ID,
})

if err != nil {
return err
}

*route = foundTunnelRoute

return nil
}
}

func TestAccCloudflareTeamsRouteUpdateComment(t *testing.T) {
rnd := generateRandomResourceName()
name := fmt.Sprintf("cloudflare_teams_route.%s", rnd)
accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID")

var TunnelRoute cloudflare.TunnelRoute

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheckAccount(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testAccCloudflareTeamsRouteSimple(rnd, rnd, accountID, "10.0.0.10/32"),
Check: resource.ComposeTestCheckFunc(
testAccCheckCloudflareTeamsRouteExists(name, &TunnelRoute),
resource.TestCheckResourceAttr(name, "comment", rnd),
),
},
{
Config: testAccCloudflareTeamsRouteSimple(rnd, rnd+"-updated", accountID, "10.0.0.10/32"),
Check: resource.ComposeTestCheckFunc(
testAccCheckCloudflareTeamsRouteExists(name, &TunnelRoute),
resource.TestCheckResourceAttr(name, "comment", rnd+"-updated"),
),
},
},
})
}

func testAccCloudflareTeamsRouteSimple(ID, comment, accountID, network string) string {
return fmt.Sprintf(`
resource "cloudflare_argo_tunnel" "%[1]s" {
account_id = "%[3]s"
name = "%[1]s"
secret = "AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg="
}

resource "cloudflare_teams_route" "%[1]s" {
account_id = "%[3]s"
tunnel_id = cloudflare_argo_tunnel.%[1]s.id
network = "%[4]s"
comment = "%[2]s"
}`, ID, comment, accountID, network)
}
27 changes: 27 additions & 0 deletions cloudflare/schema_cloudflare_teams_route.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cloudflare

import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func resourceCloudflareTeamsRouteSchema() map[string]*schema.Schema {
return map[string]*schema.Schema{
"account_id": {
Type: schema.TypeString,
Required: true,
},
"tunnel_id": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
},
"network": {
Type: schema.TypeString,
Required: true,
},
"comment": {
Type: schema.TypeString,
Optional: true,
},
}
}
3 changes: 3 additions & 0 deletions website/cloudflare.erb
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@
<li<%= sidebar_current("docs-cloudflare-teams-location") %>>
<a href="/docs/providers/cloudflare/r/teams_location.html">cloudflare_teams_location</a>
</li>
<li<%= sidebar_current("docs-cloudflare-teams-route") %>>
<a href="/docs/providers/cloudflare/r/teams_route.html">cloudflare_teams_route</a>
</li>
<li<%= sidebar_current("docs-cloudflare-teams-rule") %>>
<a href="/docs/providers/cloudflare/r/teams_rule.html">cloudflare_teams_rule</a>
</li>
Expand Down
39 changes: 39 additions & 0 deletions website/docs/r/teams_route.html.markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
layout: "cloudflare"
page_title: "Cloudflare: cloudflare_teams_route"
sidebar_content: "docs-cloudflare-teams-route"
description: |-
Provides a resource which manages Cloudflare Tunnel Routes for Zero Trust
---

# cloudflare_teams_route

Provides a resource, that manages Cloudflare tunnel routes for Zero Trust. Tunnel
routes are used to direct IP traffic through Cloudflare Tunnels.

## Example Usage

```hcl
resource "cloudflare_teams_route" "example"
account_id = "c4a7362d577a6c3019a474fd6f485821"
tunnel_id = "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415"
network = "192.0.2.24/32"
comment = "New tunnel route for documentation"
```

## Argument Reference

The following arguments are supported:

* `account_id` - (Required) The ID of the account where the tunnel route is being created
* `tunnel_id` - (Required) The ID of the tunnel that will service the tunnel route
* `network` - (Required) The IPv4 or IPv6 network that should use this tunnel route, in CIDR notation
* `comment` - (Optional) Description of the tunnel route

## Import

An existing tunnel route can be imported using the account ID, tunnel ID, and network CIDR.

```
$ terraform import cloudflare_teams_route c4a7362d577a6c3019a474fd6f485821/f70ff985-a4ef-4643-bbbc-4a0ed4fc8415/192.0.2.24/32
```