diff --git a/.changelog/4062.txt b/.changelog/4062.txt new file mode 100644 index 0000000000..0abf657ba8 --- /dev/null +++ b/.changelog/4062.txt @@ -0,0 +1,11 @@ +```release-note:enhancement +provider: add new data source and resource for infrastructure target +``` + +```release-note:enhancement +resource/cloudflare_zero_trust_infrastructure_target: add new resource infrastructure target +``` + +```release-note:enhancement +data_source/cloudflare_zero_trust_infrastructure_targets: add new data source to read infrastructure targets +``` diff --git a/internal/sdkv2provider/data_source_infrastructure_targets.go b/internal/sdkv2provider/data_source_infrastructure_targets.go new file mode 100644 index 0000000000..86d4eec447 --- /dev/null +++ b/internal/sdkv2provider/data_source_infrastructure_targets.go @@ -0,0 +1,105 @@ +package sdkv2provider + +import ( + "context" + "fmt" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceCloudflareZeroTrustInfrastructureTargets() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + consts.AccountIDSchemaKey: { + Description: consts.AccountIDSchemaDescription, + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "hostname": { + Type: schema.TypeString, + Optional: true, + Description: "The hostname of a target", + }, + "hostname_contains": { + Type: schema.TypeString, + Optional: true, + Description: "A partial match to the hostname of a target.", + }, + "ip_v4": { + Type: schema.TypeString, + Optional: true, + Description: "The IPv4 address of the target.", + }, + "ip_v6": { + Type: schema.TypeString, + Optional: true, + Description: "The IPv6 address of the target", + }, + "virtual_network_id": { + Type: schema.TypeString, + Optional: true, + Description: "The private virtual network identifier of the target.", + }, + "created_after": { + Type: schema.TypeString, + Optional: true, + Description: "The date and time at which the target was created.", + }, + "modified_after": { + Type: schema.TypeString, + Optional: true, + Description: "The date and time at which the target was modified.", + }, + }, + Description: "Use this datasource to lookup a tunnel in an account.", + ReadContext: dataSourceCloudflareInfrastructureTargetRead, + } +} + +func dataSourceCloudflareInfrastructureTargetRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + tflog.Debug(ctx, "Reading Targets") + client := meta.(*cloudflare.API) + accID := d.Get(consts.AccountIDSchemaKey).(string) + + hostname := d.Get("hostname").(string) + hostnameContains := d.Get("hostname_contains").(string) + ipv4 := d.Get("ip_v4").(string) + ipv6 := d.Get("ip_v6").(string) + vnetId := d.Get("virtual_network_id").(string) + createdAfter := d.Get("created_after").(string) + modifiedAfter := d.Get("created_after").(string) + checkSetNil := func(s string) *string { + if s == "" { + return nil + } else { + return &s + } + } + + params := cloudflare.TargetListParams{ + CreatedAfter: *checkSetNil(createdAfter), + Hostname: *checkSetNil(hostname), + HostnameContains: *checkSetNil(hostnameContains), + IPV4: *checkSetNil(ipv4), + IPV6: *checkSetNil(ipv6), + ModifedAfter: *checkSetNil(modifiedAfter), + VirtualNetworkId: *checkSetNil(vnetId), + } + + targets, _, err := client.ListInfrastructureTargets(ctx, cloudflare.AccountIdentifier(accID), params) + if err != nil { + return diag.FromErr(fmt.Errorf("failed to fetch Infrastructure Targets: %w", err)) + } + if len(targets) == 0 { + return diag.FromErr(fmt.Errorf("no Infrastructure Targets matching given query parameters")) + } + if err = d.Set("targets", targets); err != nil { + return diag.FromErr(fmt.Errorf("error setting Infrastructure Targets set: %w", err)) + } + return nil +} diff --git a/internal/sdkv2provider/data_source_infrastructure_targets_test.go b/internal/sdkv2provider/data_source_infrastructure_targets_test.go new file mode 100644 index 0000000000..a35692a81b --- /dev/null +++ b/internal/sdkv2provider/data_source_infrastructure_targets_test.go @@ -0,0 +1,114 @@ +package sdkv2provider + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccCloudflareTarget_MatchHostname(t *testing.T) { + rnd1 := generateRandomResourceName() + rnd2 := generateRandomResourceName() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testCloudflareInfrastructureTargetsMatchNoIpv6(rnd1), + Check: resource.ComposeTestCheckFunc( + // We should expect this data source to have 1 resource + resource.TestCheckResourceAttr("data.cloudflare_zero_trust_infrastructure_targets."+rnd1, "resources.#", "1"), + // Check that there is no ipv6 object in this resource + resource.TestCheckNoResourceAttr("data.cloudflare_zero_trust_infrastructure_targets."+rnd1, "ip.ipv6"), + // Check the existing attributes of this resource + resource.TestCheckTypeSetElemNestedAttrs("data.cloudflare_zero_trust_infrastructure_targets."+rnd1, "resources.*", map[string]string{ + "hostname": rnd1, + "ip.ipv4.ip_addr": "187.26.29.249", + "ip.ipv4.virtual_network_id": "c77b744e-acc8-428f-9257-6878c046ed55", + }), + ), + }, + { + Config: testCloudflareInfrastructureTargetsMatchAll(rnd1, rnd2), + Check: resource.ComposeTestCheckFunc( + // Expect this data source to have 2 resources + resource.TestCheckResourceAttr("data.cloudflare_zero_trust_infrastructure_targets.all", "resources.#", "2"), + // Check the attributes of the first resource + resource.TestCheckTypeSetElemNestedAttrs("data.cloudflare_zero_trust_infrastructure_targets.all", "resources.*", map[string]string{ + "hostname": rnd1, + "ip.ipv4.ip_addr": "187.26.29.249", + "ip.ipv4.virtual_network_id": "c77b744e-acc8-428f-9257-6878c046ed55", + }), + // Check the attributes of the second resource + resource.TestCheckTypeSetElemNestedAttrs("data.cloudflare_zero_trust_infrastructure_targets.all", "resources.*", map[string]string{ + "hostname": rnd2, + "ip.ipv4.ip_addr": "250.26.29.250", + "ip.ipv6.ip_addr": "64c0:64e8:f0b4:8dbf:7104:72b0:ec8f:f5e0", + "ip.ipv6.virtual_network_id": "01920a8c-dc14-7bb2-b67b-14c858494a54", + }), + ), + }, + }, + }) +} + +func testCloudflareInfrastructureTargetsMatchNoIpv6(hostname string) string { + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + return fmt.Sprintf(` +resource "cloudflare_zero_trust_infrastructure_target" "%[2]s" { + account_id = "%[1]s" + hostname = "%[2]s" + ip = { + ipv4 = { + ip_addr = "187.26.29.249", + virtual_network_id = "c77b744e-acc8-428f-9257-6878c046ed55" + } + } +} + +data "cloudflare_zero_trust_infrastructure_targets" "%[2]s" { + depends_on = [cloudflare_zero_trust_infrastructure_target.%[2]s] +} +`, accountID, hostname) +} + +func testCloudflareInfrastructureTargetsMatchAll(hostname1 string, hostname2 string) string { + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + return fmt.Sprintf(` +resource "cloudflare_zero_trust_infrastructure_target" "%[2]s" { + account_id = "%[1]s" + hostname = "%[2]s" + ip = { + ipv4 = { + ip_addr = "187.26.29.249", + virtual_network_id = "c77b744e-acc8-428f-9257-6878c046ed55" + } + } +} + +resource "cloudflare_zero_trust_infrastructure_target" "%[3]s" { + account_id = "%[1]s" + hostname = "%[3]s" + ip = { + ipv4 = { + ip_addr = "250.26.29.250", + virtual_network_id = "01920a8c-dc14-7bb2-b67b-14c858494a54" + }, + ipv6 = { + ip_addr = "64c0:64e8:f0b4:8dbf:7104:72b0:ec8f:f5e0", + virtual_network_id = "01920a8c-dc14-7bb2-b67b-14c858494a54" + } + } +} + +data "cloudflare_zero_trust_infrastructure_targets" "all" { + depends_on = [ + cloudflare_zero_trust_infrastructure_target.%[2]s, + cloudflare_zero_trust_infrastructure_target.%[3]s + ] +} +`, accountID, hostname1, hostname2) +} diff --git a/internal/sdkv2provider/provider.go b/internal/sdkv2provider/provider.go index 42d307c76c..7108e8d6e5 100644 --- a/internal/sdkv2provider/provider.go +++ b/internal/sdkv2provider/provider.go @@ -174,6 +174,7 @@ func New(version string) func() *schema.Provider { "cloudflare_accounts": dataSourceCloudflareAccounts(), "cloudflare_devices": dataSourceCloudflareDevices(), "cloudflare_device_posture_rules": dataSourceCloudflareDevicePostureRules(), + "cloudflare_zero_trust_infrastructure_targets": dataSourceCloudflareZeroTrustInfrastructureTargets(), "cloudflare_ip_ranges": dataSourceCloudflareIPRanges(), "cloudflare_list": dataSourceCloudflareList(), "cloudflare_lists": dataSourceCloudflareLists(), @@ -258,6 +259,7 @@ func New(version string) func() *schema.Provider { "cloudflare_healthcheck": resourceCloudflareHealthcheck(), "cloudflare_hostname_tls_setting": resourceCloudflareHostnameTLSSetting(), "cloudflare_hostname_tls_setting_ciphers": resourceCloudflareHostnameTLSSettingCiphers(), + "cloudflare_zero_trust_infrastructure_target": resourceCloudflareZeroTrustInfrastructureTarget(), "cloudflare_ipsec_tunnel": resourceCloudflareIPsecTunnel(), "cloudflare_magic_wan_ipsec_tunnel": resourceCloudflareMagicWANIPsecTunnel(), "cloudflare_keyless_certificate": resourceCloudflareKeylessCertificate(), diff --git a/internal/sdkv2provider/resource_cloudflare_infrastructure_target.go b/internal/sdkv2provider/resource_cloudflare_infrastructure_target.go new file mode 100644 index 0000000000..61a40afdf0 --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_infrastructure_target.go @@ -0,0 +1,168 @@ +package sdkv2provider + +import ( + "context" + "errors" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudflareZeroTrustInfrastructureTarget() *schema.Resource { + return &schema.Resource{ + Schema: resourceCloudflareInfrastructureTargetSchema(), + CreateContext: resourceCloudflareInfrastructureTargetCreate, + ReadContext: resourceCloudflareInfrastructureTargetRead, + UpdateContext: resourceCloudflareInfrastructureTargetUpdate, + DeleteContext: resourceCloudflareInfrastructureTargetDelete, + Description: heredoc.Doc(` + Provides a Cloudflare Infrastructure Target resource. + `), + } +} + +func resourceCloudflareInfrastructureTargetCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + identifier, err := initIdentifier(d) + if err != nil { + return diag.FromErr(err) + } + + ipInfo, err := parseIPInfo(d) + if err != nil { + return diag.FromErr(err) + } + createTargetParams := cloudflare.CreateInfrastructureTargetParams{ + InfrastructureTargetParams: cloudflare.InfrastructureTargetParams{ + Hostname: d.Get("hostname").(string), + IP: ipInfo, + }, + } + + tflog.Debug(ctx, fmt.Sprintf("Creating Cloudflare Infrastructure Target from struct %+v", createTargetParams)) + target, err := client.CreateInfrastructureTarget(ctx, identifier, createTargetParams) + if err != nil { + return diag.FromErr(fmt.Errorf("error creating Infrastructure Target for account %q: %w", d.Get(consts.AccountIDSchemaKey).(string), err)) + } + + d.SetId(target.ID) + d.Set("created_at", target.CreatedAt) + d.Set("modified_at", target.ModifiedAt) + + return resourceCloudflareInfrastructureTargetRead(ctx, d, meta) +} + +func resourceCloudflareInfrastructureTargetRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + identifier, err := initIdentifier(d) + if err != nil { + return diag.FromErr(err) + } + + tflog.Debug(ctx, fmt.Sprintf("Retrieving Cloudflare Infrastructure Target with ID %s", d.Id())) + target, err := client.GetInfrastructureTarget(ctx, identifier, d.Id()) + if err != nil { + var notFoundError *cloudflare.NotFoundError + if errors.As(err, ¬FoundError) { + tflog.Info(ctx, fmt.Sprintf("Infrastructure Target with ID %s does not exist", d.Id())) + d.SetId("") + return nil + } + return diag.FromErr(fmt.Errorf("error finding Infrastructure Target with ID %s: %w", d.Id(), err)) + } + + d.Set("hostname", target.Hostname) + d.Set("ip", target.IP) + d.Set("created_at", target.CreatedAt) + d.Set("modified_at", target.ModifiedAt) + return nil +} + +func resourceCloudflareInfrastructureTargetUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + accountID := d.Get(consts.AccountIDSchemaKey).(string) + identifier, err := initIdentifier(d) + if err != nil { + return diag.FromErr(err) + } + + ipInfo, err := parseIPInfo(d) + if err != nil { + return diag.FromErr(err) + } + updatedTargetParams := cloudflare.UpdateInfrastructureTargetParams{ + ID: d.Id(), + ModifyParams: cloudflare.InfrastructureTargetParams{ + Hostname: d.Get("hostname").(string), + IP: ipInfo, + }, + } + + tflog.Debug(ctx, fmt.Sprintf("Updating Cloudflare Infrastructure Target from struct: %+v", updatedTargetParams)) + updatedTarget, err := client.UpdateInfrastructureTarget(ctx, identifier, updatedTargetParams) + if err != nil { + return diag.FromErr(fmt.Errorf("error updating Infrastructure Target with ID %s for account %q: %w", d.Id(), accountID, err)) + } + + d.Set("modified_at", updatedTarget.ModifiedAt) + return resourceCloudflareInfrastructureTargetRead(ctx, d, meta) +} + +func resourceCloudflareInfrastructureTargetDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + identifier, err := initIdentifier(d) + if err != nil { + return diag.FromErr(err) + } + + tflog.Debug(ctx, fmt.Sprintf("Deleting Cloudflare Infrastructure Target with ID: %s", d.Id())) + err = client.DeleteInfrastructureTarget(ctx, identifier, d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error deleting Infrastructure Target with ID %s for account %q: %w", d.Id(), d.Get(consts.AccountIDSchemaKey).(string), err)) + } + + d.SetId("") + return nil +} + +func parseIPInfo(d *schema.ResourceData) (cloudflare.IPInfo, error) { + ip := d.Get("ip").(map[string]interface{}) + ip_v4, ip_v4_exists := ip["ipv4"] + ip_v6, ip_v6_exists := ip["ipv6"] + + if !ip_v4_exists && !ip_v6_exists { + return cloudflare.IPInfo{}, fmt.Errorf("error creating target resource: one of ipv4 or ipv6 must be configured") + } + + if ip_v4_exists && ip_v6_exists { + return cloudflare.IPInfo{ + IPV4: &cloudflare.IPDetails{ + IpAddr: ip_v4.(map[string]interface{})["ip_addr"].(string), + VirtualNetworkId: ip_v4.(map[string]interface{})["virtual_network_id"].(string), + }, + IPV6: &cloudflare.IPDetails{ + IpAddr: ip_v6.(map[string]interface{})["ip_addr"].(string), + VirtualNetworkId: ip_v6.(map[string]interface{})["virtual_network_id"].(string), + }, + }, nil + } else if ip_v4_exists { + return cloudflare.IPInfo{ + IPV4: &cloudflare.IPDetails{ + IpAddr: ip_v4.(map[string]interface{})["ip_addr"].(string), + VirtualNetworkId: ip_v4.(map[string]interface{})["virtual_network_id"].(string), + }, + }, nil + } else { + return cloudflare.IPInfo{ + IPV6: &cloudflare.IPDetails{ + IpAddr: ip_v6.(map[string]interface{})["ip_addr"].(string), + VirtualNetworkId: ip_v6.(map[string]interface{})["virtual_network_id"].(string), + }, + }, nil + } +} diff --git a/internal/sdkv2provider/resource_cloudflare_infrastructure_target_test.go b/internal/sdkv2provider/resource_cloudflare_infrastructure_target_test.go new file mode 100644 index 0000000000..c16b30d8ad --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_infrastructure_target_test.go @@ -0,0 +1,98 @@ +package sdkv2provider + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccCloudflareInfrastructureTargetCreateUpdate(t *testing.T) { + accID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + rnd := generateRandomResourceName() + name := fmt.Sprintf("cloudflare_zero_trust_infrastructure_target.%s", rnd) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareInfrastructureTargetDestroy, + Steps: []resource.TestStep{ + { + // Create resource configuration + Config: testAccCloudflareInfrastructureTargetCreate(accID, rnd), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "hostname", rnd), + resource.TestCheckResourceAttr(name, "ip.ipv4.ip_addr", "250.26.29.250"), + resource.TestCheckNoResourceAttr(name, "ip.ipv6"), + ), + }, + { + // Update resource configuration + Config: testAccCloudflareInfrastructureTargetUpdate(accID, rnd), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "hostname", rnd+"-updated"), + resource.TestCheckResourceAttr(name, "ip.ipv4.ip_addr", "250.26.29.250"), + resource.TestCheckResourceAttr(name, "ip.ipv6.ip_addr", "64c0:64e8:f0b4:8dbf:7104:72b0:ec8f:f5e0"), + resource.TestCheckResourceAttr(name, "ip.ipv6.virtual_network_id", "01920a8c-dc14-7bb2-b67b-14c858494a54"), + ), + }, + }, + }) +} + +func testAccCloudflareInfrastructureTargetCreate(accID, hostname string) string { + return fmt.Sprintf(` + resource "cloudflare_zero_trust_infrastructure_target" "%[2]s" { + account_id = "%[1]s" + hostname = "%[2]s" + ip = { + ipv4 = { + ip_addr = "250.26.29.250", + virtual_network_id = "01920a8c-dc14-7bb2-b67b-14c858494a54" + }, + }`, accID, hostname) +} + +func testAccCloudflareInfrastructureTargetUpdate(accID, hostname string) string { + return fmt.Sprintf(` + resource "cloudflare_zero_trust_infrastructure_target" "%[2]s" { + account_id = "%[1]s" + hostname = "%[2]s-updated" + ip = { + ipv4 = { + ip_addr = "250.26.29.250", + virtual_network_id = "01920a8c-dc14-7bb2-b67b-14c858494a54" + }, + ipv6 = { + ip_addr = "64c0:64e8:f0b4:8dbf:7104:72b0:ec8f:f5e0", + virtual_network_id = "01920a8c-dc14-7bb2-b67b-14c858494a54" + } + }`, accID, hostname) +} + +func testAccCheckCloudflareInfrastructureTargetDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudflare_zero_trust_infrastructure_target" { + continue + } + + accountID := rs.Primary.Attributes[consts.AccountIDSchemaKey] + targetID := rs.Primary.ID + client := testAccProvider.Meta().(*cloudflare.API) + target, err := client.GetInfrastructureTarget(context.Background(), cloudflare.AccountIdentifier(accountID), targetID) + + if err == nil { + return fmt.Errorf("infrastructure target with ID %s still exists", target.ID) + } + + } + + return nil +} diff --git a/internal/sdkv2provider/schema_cloudflare_infrastructure_target.go b/internal/sdkv2provider/schema_cloudflare_infrastructure_target.go new file mode 100644 index 0000000000..18827530d9 --- /dev/null +++ b/internal/sdkv2provider/schema_cloudflare_infrastructure_target.go @@ -0,0 +1,83 @@ +package sdkv2provider + +import ( + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudflareInfrastructureTargetSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + consts.AccountIDSchemaKey: { + Description: consts.AccountIDSchemaDescription, + Type: schema.TypeString, + Required: true, + Computed: true, + }, + "hostname": { + Type: schema.TypeString, + Description: "A non-unique field that refers to a target.", + Required: true, + }, + "ip": { + Type: schema.TypeList, + Required: true, + Description: "The IPv4/IPv6 address that identifies where to reach a target.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ipv4": { + Type: schema.TypeList, + Optional: true, + Description: "The target's IPv4 address.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ip_addr": { + Type: schema.TypeString, + Required: true, + Description: "The IP address of the target.", + }, + "virtual_network_id": { + Type: schema.TypeString, + Required: true, + Description: "The private virtual network identifier for the target.", + }, + }, + }, + }, + "ipv6": { + Type: schema.TypeList, + Optional: true, + Description: "The target's IPv6 address.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ip_addr": { + Type: schema.TypeString, + Required: true, + Description: "The IP address of the target.", + }, + "virtual_network_id": { + Type: schema.TypeString, + Required: true, + Description: "The private virtual network identifier for the target.", + }, + }, + }, + }, + }, + }, + }, + "created_at": { + Type: schema.TypeString, + Optional: true, + // Sets this value to read-only + Computed: true, + Description: "The date and time at which the target was created.", + }, + "modified_at": { + Type: schema.TypeString, + Optional: true, + // Sets this value to read-only + Computed: true, + Description: "The date and time at which the target was modified.", + }, + } +}