diff --git a/.changelog/4077.txt b/.changelog/4077.txt new file mode 100644 index 0000000000..b88894c190 --- /dev/null +++ b/.changelog/4077.txt @@ -0,0 +1,7 @@ +```release-note:new-datasource +cloudflare_infrastructure_access_targets +``` + +```release-note:new-resource +cloudflare_infrastructure_access_target +``` diff --git a/docs/data-sources/infrastructure_access_targets.md b/docs/data-sources/infrastructure_access_targets.md new file mode 100644 index 0000000000..1e235ac608 --- /dev/null +++ b/docs/data-sources/infrastructure_access_targets.md @@ -0,0 +1,74 @@ +--- +page_title: "cloudflare_infrastructure_access_targets Data Source - Cloudflare" +subcategory: "" +description: |- + Use this data source to retrieve all Infrastructure Access Targets. +--- + +# cloudflare_infrastructure_access_targets (Data Source) + +Use this data source to retrieve all Infrastructure Access Targets. + + + +## Schema + +### Required + +- `account_id` (String) The account identifier to target for the resource. + +### Optional + +- `created_after` (String) A date and time after a target was created to filter on. +- `hostname` (String) The name of the app type. +- `hostname_contains` (String) The name of the app type. +- `ipv4` (String) The name of the app type. +- `ipv6` (String) The name of the app type. +- `modified_after` (String) A date and time after a target was modified to filter on. +- `virtual_network_id` (String) The name of the app type. + +### Read-Only + +- `targets` (Attributes List) (see [below for nested schema](#nestedatt--targets)) + + +### Nested Schema for `targets` + +Required: + +- `ip` (Attributes) The IPv4/IPv6 address that identifies where to reach a target. (see [below for nested schema](#nestedatt--targets--ip)) + +Read-Only: + +- `account_id` (String) The account identifier to target for the resource. +- `created_at` (String) The date and time at which the target was created. +- `hostname` (String) A non-unique field that refers to a target. +- `id` (String) The identifier of this resource. This is target's unique identifier. +- `modified_at` (String) The date and time at which the target was last modified. + + +### Nested Schema for `targets.ip` + +Optional: + +- `ipv4` (Attributes) The target's IPv4 address. (see [below for nested schema](#nestedatt--targets--ip--ipv4)) +- `ipv6` (Attributes) The target's IPv6 address. (see [below for nested schema](#nestedatt--targets--ip--ipv6)) + + +### Nested Schema for `targets.ip.ipv4` + +Required: + +- `ip_addr` (String) The IP address of the target. +- `virtual_network_id` (String) The private virtual network identifier for the target. + + + +### Nested Schema for `targets.ip.ipv6` + +Required: + +- `ip_addr` (String) The IP address of the target. +- `virtual_network_id` (String) The private virtual network identifier for the target. + + diff --git a/docs/resources/infrastructure_access_target.md b/docs/resources/infrastructure_access_target.md new file mode 100644 index 0000000000..3c51b6b291 --- /dev/null +++ b/docs/resources/infrastructure_access_target.md @@ -0,0 +1,53 @@ +--- +page_title: "cloudflare_infrastructure_access_target Resource - Cloudflare" +subcategory: "" +description: |- + The Infrastructure Access Target https://developers.cloudflare.com/cloudflare-one/insights/risk-score/ resource allows you to configure Cloudflare Risk Behaviors for an account. +--- + +# cloudflare_infrastructure_access_target (Resource) + +The [Infrastructure Access Target](https://developers.cloudflare.com/cloudflare-one/insights/risk-score/) resource allows you to configure Cloudflare Risk Behaviors for an account. + + + +## Schema + +### Required + +- `account_id` (String) The account identifier to target for the resource. +- `hostname` (String) A non-unique field that refers to a target. +- `ip` (Attributes) The IPv4/IPv6 address that identifies where to reach a target. (see [below for nested schema](#nestedatt--ip)) + +### Read-Only + +- `created_at` (String) The date and time at which the target was created. +- `id` (String) The identifier of this resource. +- `modified_at` (String) The date and time at which the target was last modified. + + +### Nested Schema for `ip` + +Optional: + +- `ipv4` (Attributes) The target's IPv4 address. (see [below for nested schema](#nestedatt--ip--ipv4)) +- `ipv6` (Attributes) The target's IPv6 address. (see [below for nested schema](#nestedatt--ip--ipv6)) + + +### Nested Schema for `ip.ipv4` + +Required: + +- `ip_addr` (String) The IP address of the target. +- `virtual_network_id` (String) The private virtual network identifier for the target. + + + +### Nested Schema for `ip.ipv6` + +Required: + +- `ip_addr` (String) The IP address of the target. +- `virtual_network_id` (String) The private virtual network identifier for the target. + + diff --git a/internal/framework/provider/provider.go b/internal/framework/provider/provider.go index 6226c8a90e..e833efc078 100644 --- a/internal/framework/provider/provider.go +++ b/internal/framework/provider/provider.go @@ -26,6 +26,7 @@ import ( "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/service/gateway_app_types" "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/service/gateway_categories" "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/service/hyperdrive_config" + "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/service/infrastructure_access_target" "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/service/list_item" "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/service/origin_ca_certificate" "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/service/r2_bucket" @@ -381,6 +382,7 @@ func (p *CloudflareProvider) Resources(ctx context.Context) []func() resource.Re workers_for_platforms_dispatch_namespace_deprecated.NewResource, workers_for_platforms_dispatch_namespace.NewResource, zero_trust_risk_score_integration.NewResource, + infrastructure_access_target.NewResource, } } @@ -393,6 +395,7 @@ func (p *CloudflareProvider) DataSources(ctx context.Context) []func() datasourc gateway_categories.NewDataSource, gateway_app_types.NewDataSource, dcv_delegation.NewDataSource, + infrastructure_access_target.NewDataSource, } } diff --git a/internal/framework/service/infrastructure_access_target/data_source.go b/internal/framework/service/infrastructure_access_target/data_source.go new file mode 100644 index 0000000000..3f10cf84ff --- /dev/null +++ b/internal/framework/service/infrastructure_access_target/data_source.go @@ -0,0 +1,90 @@ +package infrastructure_access_target + +import ( + "context" + "fmt" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/muxclient" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &InfrastructureAccessTargetDataSource{} + +func NewDataSource() datasource.DataSource { + return &InfrastructureAccessTargetDataSource{} +} + +type InfrastructureAccessTargetDataSource struct { + client *muxclient.Client +} + +func (d *InfrastructureAccessTargetDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_infrastructure_access_targets" +} + +func (d *InfrastructureAccessTargetDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*muxclient.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected resource configure type", + fmt.Sprintf("Expected *muxclient.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *InfrastructureAccessTargetDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data *InfrastructureAccessTargetsModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + accountId := data.AccountID.ValueString() + if accountId == "" { + resp.Diagnostics.AddError("failed to update infrastructure access target", "account id cannot be an empty string") + return + } + params := cloudflare.InfrastructureAccessTargetListParams{ + Hostname: data.Hostname.ValueString(), + HostnameContains: data.HostnameContains.ValueString(), + IPV4: data.IPV4.ValueString(), + IPV6: data.IPV6.ValueString(), + CreatedAfter: data.CreatedAfter.ValueString(), + ModifedAfter: data.ModifiedAfter.ValueString(), + VirtualNetworkId: data.VirtualNetworkId.ValueString(), + } + + allTargets, _, err := d.client.V1.ListInfrastructureAccessTargets(ctx, cloudflare.AccountIdentifier(accountId), params) + if err != nil { + resp.Diagnostics.AddError("failed to fetch Infrastructure Access Targets: %w", err.Error()) + return + } + if len(allTargets) == 0 { + resp.Diagnostics.AddError("failed to fetch Infrastructure Access Targets", "no Infrastructure Access Targets matching given query parameters") + } + + var targets []InfrastructureAccessTargetModel + for _, target := range allTargets { + targets = append(targets, InfrastructureAccessTargetModel{ + AccountID: types.StringValue(accountId), + Hostname: types.StringValue(target.Hostname), + ID: types.StringValue(target.ID), + IP: convertIPInfoToBaseTypeObject(target.IP), + CreatedAt: types.StringValue(target.CreatedAt), + ModifiedAt: types.StringValue(target.ModifiedAt), + }) + } + + data.Targets = targets + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/framework/service/infrastructure_access_target/data_source_test.go b/internal/framework/service/infrastructure_access_target/data_source_test.go new file mode 100644 index 0000000000..878363bf81 --- /dev/null +++ b/internal/framework/service/infrastructure_access_target/data_source_test.go @@ -0,0 +1,54 @@ +package infrastructure_access_target_test + +import ( + "fmt" + "os" + "testing" + + "github.com/cloudflare/terraform-provider-cloudflare/internal/acctest" + "github.com/cloudflare/terraform-provider-cloudflare/internal/utils" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccCloudflareInfrastructureAccessTarget_DataSource(t *testing.T) { + rnd1 := utils.GenerateRandomResourceName() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testCloudflareInfrastructureTargetsMatchNoIpv6(rnd1), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.cloudflare_infrastructure_access_targets."+rnd1, "targets.#", "1"), + resource.TestCheckNoResourceAttr("data.cloudflare_infrastructure_access_targets."+rnd1, "ip.ipv6"), + + resource.TestCheckResourceAttr("cloudflare_infrastructure_access_target."+rnd1, "hostname", rnd1), + resource.TestCheckResourceAttr("cloudflare_infrastructure_access_target."+rnd1, "ip.ipv4.ip_addr", "250.26.29.250"), + resource.TestCheckResourceAttr("cloudflare_infrastructure_access_target."+rnd1, "ip.ipv4.virtual_network_id", "b9c90134-52de-4903-81e8-004a3a06b435"), + ), + }, + }, + }) +} + +func testCloudflareInfrastructureTargetsMatchNoIpv6(hostname string) string { + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + return fmt.Sprintf(` +resource "cloudflare_infrastructure_access_target" "%[2]s" { + account_id = "%[1]s" + hostname = "%[2]s" + ip = { + ipv4 = { + ip_addr = "250.26.29.250", + virtual_network_id = "b9c90134-52de-4903-81e8-004a3a06b435" + } + } +} + +data "cloudflare_infrastructure_access_targets" "%[2]s" { + depends_on = [cloudflare_infrastructure_access_target.%[2]s] + account_id = "%[1]s" +} +`, accountID, hostname) +} diff --git a/internal/framework/service/infrastructure_access_target/model.go b/internal/framework/service/infrastructure_access_target/model.go new file mode 100644 index 0000000000..f17f3964e6 --- /dev/null +++ b/internal/framework/service/infrastructure_access_target/model.go @@ -0,0 +1,36 @@ +package infrastructure_access_target + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type InfrastructureAccessTargetModel struct { + AccountID types.String `tfsdk:"account_id"` + Hostname types.String `tfsdk:"hostname"` + ID types.String `tfsdk:"id"` + IP types.Object `tfsdk:"ip"` + CreatedAt types.String `tfsdk:"created_at"` + ModifiedAt types.String `tfsdk:"modified_at"` +} + +type InfrastructureAccessTargetIPInfoModel struct { + IPV4 types.Object `tfsdk:"ipv4"` + IPV6 types.Object `tfsdk:"ipv6"` +} + +type InfrastructureAccessTargetIPDetailsModel struct { + IPAddr types.String `tfsdk:"ip_addr"` + VirtualNetworkId types.String `tfsdk:"virtual_network_id"` +} + +type InfrastructureAccessTargetsModel struct { + AccountID types.String `tfsdk:"account_id"` + Hostname types.String `tfsdk:"hostname"` + HostnameContains types.String `tfsdk:"hostname_contains"` + IPV4 types.String `tfsdk:"ipv4"` + IPV6 types.String `tfsdk:"ipv6"` + VirtualNetworkId types.String `tfsdk:"virtual_network_id"` + CreatedAfter types.String `tfsdk:"created_after"` + ModifiedAfter types.String `tfsdk:"modified_after"` + Targets []InfrastructureAccessTargetModel `tfsdk:"targets"` +} diff --git a/internal/framework/service/infrastructure_access_target/resource.go b/internal/framework/service/infrastructure_access_target/resource.go new file mode 100644 index 0000000000..721726f794 --- /dev/null +++ b/internal/framework/service/infrastructure_access_target/resource.go @@ -0,0 +1,351 @@ +package infrastructure_access_target + +import ( + "context" + "errors" + "fmt" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/flatteners" + "github.com/cloudflare/terraform-provider-cloudflare/internal/framework/muxclient" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + tftypes "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &InfrastructureAccessTargetResource{} + +func NewResource() resource.Resource { + return &InfrastructureAccessTargetResource{} +} + +// InfrastructureAccessTargetResource defines the resource implementation. +type InfrastructureAccessTargetResource struct { + client *muxclient.Client +} + +func (r *InfrastructureAccessTargetResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_infrastructure_access_target" +} + +func (r *InfrastructureAccessTargetResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*muxclient.Client) + if !ok { + resp.Diagnostics.AddError( + "unexpected resource configure type", + fmt.Sprintf("Expected *muxclient.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *InfrastructureAccessTargetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *InfrastructureAccessTargetModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + accountId := data.AccountID.ValueString() + if accountId == "" { + resp.Diagnostics.AddError("failed to create infrastructure access target", "account id cannot be an empty string") + return + } + ipInfo, err := buildCreateIPInfoFromDetails(ctx, data.IP, resp) + if err != nil { + resp.Diagnostics.AddError("failed to create infrastructure access target", "account id cannot be an empty string") + return + } + createTargetParams := cloudflare.CreateInfrastructureAccessTargetParams{ + InfrastructureAccessTargetParams: cloudflare.InfrastructureAccessTargetParams{ + Hostname: data.Hostname.ValueString(), + IP: ipInfo, + }, + } + + tflog.Debug(ctx, fmt.Sprintf("Creating Cloudflare Infrastructure Access Target from struct %+v", createTargetParams)) + target, err := r.client.V1.CreateInfrastructureAccessTarget(ctx, cloudflare.AccountIdentifier(accountId), createTargetParams) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error creating Infrastructure Access Target for account %q", accountId), err.Error()) + return + } + + data = buildTargetModelFromResponse(data.AccountID, target) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *InfrastructureAccessTargetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *InfrastructureAccessTargetModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("Retrieving Cloudflare Infrastructure Access Target with ID %s", data.ID)) + target, err := r.client.V1.GetInfrastructureAccessTarget(ctx, cloudflare.AccountIdentifier(data.AccountID.ValueString()), data.ID.ValueString()) + if err != nil { + var notFoundError *cloudflare.NotFoundError + if errors.As(err, ¬FoundError) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("error finding Infrastructure Access Target with ID %s", data.ID), err.Error()) + return + } + + data = buildTargetModelFromResponse(data.AccountID, target) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *InfrastructureAccessTargetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data *InfrastructureAccessTargetModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + accountId := data.AccountID.ValueString() + if accountId == "" { + resp.Diagnostics.AddError("failed to update infrastructure access target", "account id cannot be an empty string") + return + } + ipInfo, err := buildUpdateIPInfoFromDetails(ctx, data.IP, resp) + if err != nil { + resp.Diagnostics.AddError("failed to create infrastructure access target", "account id cannot be an empty string") + return + } + updatedTargetParams := cloudflare.UpdateInfrastructureAccessTargetParams{ + ID: data.ID.ValueString(), + ModifyParams: cloudflare.InfrastructureAccessTargetParams{ + Hostname: data.Hostname.ValueString(), + IP: ipInfo, + }, + } + + tflog.Debug(ctx, fmt.Sprintf("Updating Cloudflare Infrastructure Access Target from struct: %+v", updatedTargetParams)) + updatedTarget, err := r.client.V1.UpdateInfrastructureAccessTarget(ctx, cloudflare.AccountIdentifier(accountId), updatedTargetParams) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error updating Infrastructure Access Target with ID %s for account %q", data.ID, accountId), err.Error()) + return + } + + data = buildTargetModelFromResponse(data.AccountID, updatedTarget) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *InfrastructureAccessTargetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *InfrastructureAccessTargetModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("Deleting Cloudflare Infrastructure Access Target with ID: %s", data.ID)) + err := r.client.V1.DeleteInfrastructureAccessTarget(ctx, cloudflare.AccountIdentifier(data.AccountID.ValueString()), data.ID.ValueString()) + var notFoundError *cloudflare.NotFoundError + if errors.As(err, ¬FoundError) { + // Return early without error if target is already deleted + return + } + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error deleting Infrastructure Access Target with ID %s for account %q", data.ID, data.AccountID.ValueString()), err.Error()) + return + } +} + +func buildCreateIPInfoFromDetails(ctx context.Context, ipInfoModel basetypes.ObjectValue, resp *resource.CreateResponse) (cloudflare.InfrastructureAccessTargetIPInfo, error) { + if ipInfoModel.IsNull() || ipInfoModel.IsUnknown() { + return cloudflare.InfrastructureAccessTargetIPInfo{}, fmt.Errorf("failed: ip info model is empty") + } + var ipInfo *InfrastructureAccessTargetIPInfoModel + resp.Diagnostics.Append(ipInfoModel.As(ctx, &ipInfo, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + + if (ipInfo.IPV4.IsNull() || ipInfo.IPV4.IsUnknown()) && (ipInfo.IPV6.IsNull() || ipInfo.IPV6.IsUnknown()) { + return cloudflare.InfrastructureAccessTargetIPInfo{}, fmt.Errorf("error creating target resource: one of ipv4 or ipv6 must be configured") + } + + if !(ipInfo.IPV4.IsNull() || ipInfo.IPV4.IsUnknown()) && !(ipInfo.IPV6.IsNull() || ipInfo.IPV6.IsUnknown()) { + var ipv4Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV4.As(ctx, &ipv4Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + var ipv6Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV6.As(ctx, &ipv6Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + return buildIPInfoFromAttributes(ipv4Details.IPAddr.ValueString(), ipv6Details.IPAddr.ValueString(), ipv4Details.VirtualNetworkId.ValueString(), ipv6Details.VirtualNetworkId.ValueString()), nil + } else if !(ipInfo.IPV4.IsNull() || ipInfo.IPV4.IsUnknown()) { + var ipv4Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV4.As(ctx, &ipv4Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + return buildIPV4InfoFromAttributes(ipv4Details.IPAddr.ValueString(), ipv4Details.VirtualNetworkId.ValueString()), nil + } else { + var ipv6Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV6.As(ctx, &ipv6Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + return buildIPV6InfoFromAttributes(ipv6Details.IPAddr.ValueString(), ipv6Details.VirtualNetworkId.ValueString()), nil + } +} + +func buildUpdateIPInfoFromDetails(ctx context.Context, ipInfoModel basetypes.ObjectValue, resp *resource.UpdateResponse) (cloudflare.InfrastructureAccessTargetIPInfo, error) { + if ipInfoModel.IsNull() || ipInfoModel.IsUnknown() { + return cloudflare.InfrastructureAccessTargetIPInfo{}, fmt.Errorf("failed: ip info model is empty") + } + var ipInfo *InfrastructureAccessTargetIPInfoModel + resp.Diagnostics.Append(ipInfoModel.As(ctx, &ipInfo, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + + if (ipInfo.IPV4.IsNull() || ipInfo.IPV4.IsUnknown()) && (ipInfo.IPV6.IsNull() || ipInfo.IPV6.IsUnknown()) { + return cloudflare.InfrastructureAccessTargetIPInfo{}, fmt.Errorf("error creating target resource: one of ipv4 or ipv6 must be configured") + } + + if !(ipInfo.IPV4.IsNull() || ipInfo.IPV4.IsUnknown()) && !(ipInfo.IPV6.IsNull() || ipInfo.IPV6.IsUnknown()) { + var ipv4Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV4.As(ctx, &ipv4Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + var ipv6Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV6.As(ctx, &ipv6Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + return buildIPInfoFromAttributes(ipv4Details.IPAddr.ValueString(), ipv6Details.IPAddr.ValueString(), ipv4Details.VirtualNetworkId.ValueString(), ipv6Details.VirtualNetworkId.ValueString()), nil + } else if !(ipInfo.IPV4.IsNull() || ipInfo.IPV4.IsUnknown()) { + var ipv4Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV4.As(ctx, &ipv4Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + return buildIPV4InfoFromAttributes(ipv4Details.IPAddr.ValueString(), ipv4Details.VirtualNetworkId.ValueString()), nil + } else { + var ipv6Details *InfrastructureAccessTargetIPDetailsModel + resp.Diagnostics.Append(ipInfo.IPV6.As(ctx, &ipv6Details, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true})...) + return buildIPV6InfoFromAttributes(ipv6Details.IPAddr.ValueString(), ipv6Details.VirtualNetworkId.ValueString()), nil + } +} + +func buildIPInfoFromAttributes(ipv4Addr string, ipv6Addr string, ipv4VirtualNetworkId string, ipv6VirtualNetworkId string) cloudflare.InfrastructureAccessTargetIPInfo { + return cloudflare.InfrastructureAccessTargetIPInfo{ + IPV4: &cloudflare.InfrastructureAccessTargetIPDetails{ + IPAddr: ipv4Addr, + VirtualNetworkId: ipv4VirtualNetworkId, + }, + IPV6: &cloudflare.InfrastructureAccessTargetIPDetails{ + IPAddr: ipv6Addr, + VirtualNetworkId: ipv6VirtualNetworkId, + }, + } +} + +func buildIPV4InfoFromAttributes(ipAddr string, virtualNetworkId string) cloudflare.InfrastructureAccessTargetIPInfo { + return cloudflare.InfrastructureAccessTargetIPInfo{ + IPV4: &cloudflare.InfrastructureAccessTargetIPDetails{ + IPAddr: ipAddr, + VirtualNetworkId: virtualNetworkId, + }, + } +} + +func buildIPV6InfoFromAttributes(ipAddr string, virtualNetworkId string) cloudflare.InfrastructureAccessTargetIPInfo { + return cloudflare.InfrastructureAccessTargetIPInfo{ + IPV6: &cloudflare.InfrastructureAccessTargetIPDetails{ + IPAddr: ipAddr, + VirtualNetworkId: virtualNetworkId, + }, + } +} + +func buildTargetModelFromResponse(accountID tftypes.String, target cloudflare.InfrastructureAccessTarget) *InfrastructureAccessTargetModel { + built := InfrastructureAccessTargetModel{ + AccountID: accountID, + Hostname: flatteners.String(target.Hostname), + ID: flatteners.String(target.ID), + IP: convertIPInfoToBaseTypeObject(target.IP), + CreatedAt: flatteners.String(target.CreatedAt), + ModifiedAt: flatteners.String(target.ModifiedAt), + } + return &built +} + +func convertIPInfoToBaseTypeObject(ipInfo cloudflare.InfrastructureAccessTargetIPInfo) basetypes.ObjectValue { + if ipInfo.IPV4 != nil && ipInfo.IPV6 != nil { + ipv4Object := buildObjectFromIpDetails(ipInfo.IPV4.IPAddr, ipInfo.IPV4.VirtualNetworkId) + ipv6Object := buildObjectFromIpDetails(ipInfo.IPV6.IPAddr, ipInfo.IPV6.VirtualNetworkId) + parentObjectMap := map[string]attr.Value{ + "ipv4": ipv4Object, + "ipv6": ipv6Object, + } + parentObjectValue, _ := tftypes.ObjectValue(map[string]attr.Type{ + "ipv4": tftypes.ObjectType{ + AttrTypes: map[string]attr.Type{ + "ip_addr": tftypes.StringType, + "virtual_network_id": tftypes.StringType, + }, + }, + "ipv6": tftypes.ObjectType{ + AttrTypes: map[string]attr.Type{ + "ip_addr": tftypes.StringType, + "virtual_network_id": tftypes.StringType, + }, + }, + }, parentObjectMap) + return parentObjectValue + } else if ipInfo.IPV4 != nil { + ipv4Object := buildObjectFromIpDetails(ipInfo.IPV4.IPAddr, ipInfo.IPV4.VirtualNetworkId) + return buildObjectFromIpInfoV4(ipv4Object) + } else { + ipv6Object := buildObjectFromIpDetails(ipInfo.IPV6.IPAddr, ipInfo.IPV6.VirtualNetworkId) + return buildObjectFromIpInfoV6(ipv6Object) + } +} + +func buildObjectFromIpDetails(ipAddr string, virtualNetworkId string) basetypes.ObjectValue { + ipDetailsAttributes := map[string]attr.Value{ + "ip_addr": flatteners.String(ipAddr), + "virtual_network_id": flatteners.String(virtualNetworkId), + } + ipDetailsObjectType, _ := tftypes.ObjectValue(map[string]attr.Type{ + "ip_addr": tftypes.StringType, + "virtual_network_id": tftypes.StringType, + }, ipDetailsAttributes) + + return ipDetailsObjectType +} + +func buildObjectFromIpInfoV4(baseObjectMap basetypes.ObjectValue) basetypes.ObjectValue { + parentObjectMap := map[string]attr.Value{ + "ipv4": baseObjectMap, + "ipv6": basetypes.NewObjectNull(map[string]attr.Type{ + "ip_addr": tftypes.StringType, + "virtual_network_id": tftypes.StringType, + }), + } + return buildIPInfoObjectValue(parentObjectMap) +} + +func buildObjectFromIpInfoV6(baseObjectMap basetypes.ObjectValue) basetypes.ObjectValue { + parentObjectMap := map[string]attr.Value{ + "ipv4": basetypes.NewObjectNull(map[string]attr.Type{ + "ip_addr": tftypes.StringType, + "virtual_network_id": tftypes.StringType, + }), + "ipv6": baseObjectMap, + } + return buildIPInfoObjectValue(parentObjectMap) +} + +func buildIPInfoObjectValue(objectMap map[string]attr.Value) basetypes.ObjectValue { + ipInfoObjectValue, _ := tftypes.ObjectValue(map[string]attr.Type{ + "ipv4": tftypes.ObjectType{ + AttrTypes: map[string]attr.Type{ + "ip_addr": tftypes.StringType, + "virtual_network_id": tftypes.StringType, + }, + }, + "ipv6": tftypes.ObjectType{ + AttrTypes: map[string]attr.Type{ + "ip_addr": tftypes.StringType, + "virtual_network_id": tftypes.StringType, + }, + }, + }, objectMap) + return ipInfoObjectValue +} diff --git a/internal/framework/service/infrastructure_access_target/resource_test.go b/internal/framework/service/infrastructure_access_target/resource_test.go new file mode 100644 index 0000000000..9904d366f9 --- /dev/null +++ b/internal/framework/service/infrastructure_access_target/resource_test.go @@ -0,0 +1,110 @@ +package infrastructure_access_target_test + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/acctest" + "github.com/cloudflare/terraform-provider-cloudflare/internal/utils" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestMain(m *testing.M) { + resource.TestMain(m) +} + +func init() { + resource.AddTestSweepers("cloudflare_infrastructure_access_target", &resource.Sweeper{ + Name: "cloudflare_infrastructure_access_target", + F: func(region string) error { + client, err := acctest.SharedV1Client() + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + + if err != nil { + return fmt.Errorf("error establishing client: %w", err) + } + + ctx := context.Background() + targets, _, err := client.ListInfrastructureAccessTargets(ctx, cloudflare.AccountIdentifier(accountID), cloudflare.InfrastructureAccessTargetListParams{}) + if err != nil { + return fmt.Errorf("failed to fetch rulesets: %w", err) + } + + for _, target := range targets { + err := client.DeleteInfrastructureAccessTarget(ctx, cloudflare.AccountIdentifier(accountID), target.ID) + if err != nil { + return fmt.Errorf("failed to delete ruleset %q: %w", target.ID, err) + } + } + + return nil + }, + }) +} + +func TestAccCloudflareInfrastructureAccessTarget_Basic(t *testing.T) { + accID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + rnd := utils.GenerateRandomResourceName() + resourceName := fmt.Sprintf("cloudflare_infrastructure_access_target.%s", rnd) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Create resource configuration + Config: testAccCloudflareInfrastructureAccessTargetCreate(accID, rnd), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "hostname", rnd), + resource.TestCheckResourceAttr(resourceName, "ip.ipv4.ip_addr", "250.26.29.250"), + resource.TestCheckNoResourceAttr(resourceName, "ip.ipv6"), + ), + }, + { + // Update resource configuration + Config: testAccCloudflareInfrastructureAccessTargetUpdate(accID, rnd), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "hostname", rnd+"-updated"), + resource.TestCheckResourceAttr(resourceName, "ip.ipv4.ip_addr", "250.26.29.250"), + resource.TestCheckResourceAttr(resourceName, "ip.ipv6.ip_addr", "64c0:64e8:f0b4:8dbf:7104:72b0:ec8f:f5e0"), + resource.TestCheckResourceAttr(resourceName, "ip.ipv6.virtual_network_id", "01920a8c-dc14-7bb2-b67b-14c858494a54"), + ), + }, + }, + }) +} + +func testAccCloudflareInfrastructureAccessTargetCreate(accID, hostname string) string { + return fmt.Sprintf(` +resource "cloudflare_infrastructure_access_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 testAccCloudflareInfrastructureAccessTargetUpdate(accID, hostname string) string { + return fmt.Sprintf(` +resource "cloudflare_infrastructure_access_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) +} diff --git a/internal/framework/service/infrastructure_access_target/schema.go b/internal/framework/service/infrastructure_access_target/schema.go new file mode 100644 index 0000000000..6a281a4609 --- /dev/null +++ b/internal/framework/service/infrastructure_access_target/schema.go @@ -0,0 +1,192 @@ +package infrastructure_access_target + +import ( + "context" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-framework/datasource" + dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +func (r *InfrastructureAccessTargetResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: heredoc.Doc(` + The [Infrastructure Access Target](https://developers.cloudflare.com/cloudflare-one/insights/risk-score/) resource allows you to configure Cloudflare Risk Behaviors for an account. + `), + Attributes: map[string]schema.Attribute{ + consts.AccountIDSchemaKey: schema.StringAttribute{ + MarkdownDescription: consts.AccountIDSchemaDescription, + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + consts.IDSchemaKey: schema.StringAttribute{ + Computed: true, + MarkdownDescription: consts.IDSchemaDescription, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "hostname": schema.StringAttribute{ + MarkdownDescription: "A non-unique field that refers to a target.", + Required: true, + }, + "ip": schema.SingleNestedAttribute{ + MarkdownDescription: "The IPv4/IPv6 address that identifies where to reach a target.", + Required: true, + Attributes: map[string]schema.Attribute{ + "ipv4": schema.SingleNestedAttribute{ + MarkdownDescription: "The target's IPv4 address.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "ip_addr": schema.StringAttribute{ + MarkdownDescription: "The IP address of the target.", + Required: true, + }, + "virtual_network_id": schema.StringAttribute{ + MarkdownDescription: "The private virtual network identifier for the target.", + Required: true, + }, + }, + }, + "ipv6": schema.SingleNestedAttribute{ + MarkdownDescription: "The target's IPv6 address.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "ip_addr": schema.StringAttribute{ + MarkdownDescription: "The IP address of the target.", + Required: true, + }, + "virtual_network_id": schema.StringAttribute{ + MarkdownDescription: "The private virtual network identifier for the target.", + Required: true, + }, + }, + }, + }, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The date and time at which the target was created.", + // Set value to read-only. + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "modified_at": schema.StringAttribute{ + MarkdownDescription: "The date and time at which the target was last modified.", + // Set value to read-only. + Computed: true, + }, + }, + } +} + +func (d *InfrastructureAccessTargetDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Use this data source to retrieve all Infrastructure Access Targets.", + Attributes: map[string]dschema.Attribute{ + consts.AccountIDSchemaKey: dschema.StringAttribute{ + MarkdownDescription: consts.AccountIDSchemaDescription, + Required: true, + }, + "hostname": dschema.StringAttribute{ + Optional: true, + Description: "The name of the app type.", + }, + "hostname_contains": dschema.StringAttribute{ + Optional: true, + Description: "The name of the app type.", + }, + "ipv4": dschema.StringAttribute{ + Optional: true, + Description: "The name of the app type.", + }, + "ipv6": dschema.StringAttribute{ + Optional: true, + Description: "The name of the app type.", + }, + "virtual_network_id": dschema.StringAttribute{ + Optional: true, + Description: "The name of the app type.", + }, + "created_after": dschema.StringAttribute{ + Optional: true, + Description: "A date and time after a target was created to filter on.", + }, + "modified_after": dschema.StringAttribute{ + Optional: true, + Description: "A date and time after a target was modified to filter on.", + }, + // Schema for data source is separate from resource so attributes + // are re written here but modified to be computer aka read-only. + "targets": dschema.ListNestedAttribute{ + Computed: true, + NestedObject: dschema.NestedAttributeObject{ + Attributes: map[string]dschema.Attribute{ + consts.AccountIDSchemaKey: dschema.StringAttribute{ + MarkdownDescription: consts.AccountIDSchemaDescription, + Computed: true, + }, + consts.IDSchemaKey: schema.StringAttribute{ + MarkdownDescription: consts.IDSchemaDescription + " This is target's unique identifier.", + Computed: true, + }, + "hostname": dschema.StringAttribute{ + MarkdownDescription: "A non-unique field that refers to a target.", + Computed: true, + }, + "ip": schema.SingleNestedAttribute{ + MarkdownDescription: "The IPv4/IPv6 address that identifies where to reach a target.", + Required: true, + Attributes: map[string]schema.Attribute{ + "ipv4": schema.SingleNestedAttribute{ + MarkdownDescription: "The target's IPv4 address.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "ip_addr": schema.StringAttribute{ + MarkdownDescription: "The IP address of the target.", + Required: true, + }, + "virtual_network_id": schema.StringAttribute{ + MarkdownDescription: "The private virtual network identifier for the target.", + Required: true, + }, + }, + }, + "ipv6": schema.SingleNestedAttribute{ + MarkdownDescription: "The target's IPv6 address.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "ip_addr": schema.StringAttribute{ + MarkdownDescription: "The IP address of the target.", + Required: true, + }, + "virtual_network_id": schema.StringAttribute{ + MarkdownDescription: "The private virtual network identifier for the target.", + Required: true, + }, + }, + }, + }, + }, + "created_at": dschema.StringAttribute{ + MarkdownDescription: "The date and time at which the target was created.", + Computed: true, + }, + "modified_at": dschema.StringAttribute{ + MarkdownDescription: "The date and time at which the target was last modified.", + Computed: true, + }, + }, + }, + }, + }, + } +}