diff --git a/.changelog/2760.txt b/.changelog/2760.txt new file mode 100644 index 0000000000..f43b34180b --- /dev/null +++ b/.changelog/2760.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +cloudflare_api_shield_operation +``` diff --git a/docs/resources/api_shield_operation.md b/docs/resources/api_shield_operation.md new file mode 100644 index 0000000000..d8e435357b --- /dev/null +++ b/docs/resources/api_shield_operation.md @@ -0,0 +1,36 @@ +--- +page_title: "cloudflare_api_shield_operation Resource - Cloudflare" +subcategory: "" +description: |- + Provides a resource to manage an operation in API Shield Endpoint Management. +--- + +# cloudflare_api_shield_operation (Resource) + +Provides a resource to manage an operation in API Shield Endpoint Management. + +## Example Usage + +```terraform +resource "cloudflare_api_shield_operation" "example" { + zone_id = "0da42c8d2132a9ddaf714f9e7c920711" + method = "GET" + host = "api.example.com" + endpoint = "/path" +} +``` + +## Schema + +### Required + +- `endpoint` (String) The endpoint which can contain path parameter templates in curly braces, each will be replaced from left to right with `{varN}`, starting with `{var1}`. This will then be [Cloudflare-normalized](https://developers.cloudflare.com/rules/normalization/how-it-works/). **Modifying this attribute will force creation of a new resource.** +- `host` (String) RFC3986-compliant host. **Modifying this attribute will force creation of a new resource.** +- `method` (String) The HTTP method used to access the endpoint. **Modifying this attribute will force creation of a new resource.** +- `zone_id` (String) The zone identifier to target for the resource. **Modifying this attribute will force creation of a new resource.** + +### Read-Only + +- `id` (String) The ID of this resource. + + diff --git a/examples/resources/cloudflare_api_shield_operation/resource.tf b/examples/resources/cloudflare_api_shield_operation/resource.tf new file mode 100644 index 0000000000..bb29cb0ae0 --- /dev/null +++ b/examples/resources/cloudflare_api_shield_operation/resource.tf @@ -0,0 +1,6 @@ +resource "cloudflare_api_shield_operation" "example" { + zone_id = "0da42c8d2132a9ddaf714f9e7c920711" + method = "GET" + host = "api.example.com" + endpoint = "/path" +} diff --git a/internal/sdkv2provider/provider.go b/internal/sdkv2provider/provider.go index fc9657f637..04a6afd0c9 100644 --- a/internal/sdkv2provider/provider.go +++ b/internal/sdkv2provider/provider.go @@ -188,6 +188,7 @@ func New(version string) func() *schema.Provider { "cloudflare_account": resourceCloudflareAccount(), "cloudflare_address_map": resourceCloudflareAddressMap(), "cloudflare_api_shield": resourceCloudflareAPIShield(), + "cloudflare_api_shield_operation": resourceCloudflareAPIShieldOperation(), "cloudflare_api_token": resourceCloudflareApiToken(), "cloudflare_argo": resourceCloudflareArgo(), "cloudflare_authenticated_origin_pulls_certificate": resourceCloudflareAuthenticatedOriginPullsCertificate(), diff --git a/internal/sdkv2provider/resource_cloudflare_api_shield_operation.go b/internal/sdkv2provider/resource_cloudflare_api_shield_operation.go new file mode 100644 index 0000000000..c91eac158a --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_api_shield_operation.go @@ -0,0 +1,108 @@ +package sdkv2provider + +import ( + "context" + "fmt" + "github.com/pkg/errors" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudflareAPIShieldOperation() *schema.Resource { + return &schema.Resource{ + Schema: resourceCloudflareAPIShieldOperationSchema(), + CreateContext: resourceCloudflareAPIShieldOperationCreate, + ReadContext: resourceCloudflareAPIShieldOperationRead, + DeleteContext: resourceCloudflareAPIShieldOperationDelete, + Importer: &schema.ResourceImporter{ + StateContext: nil, + }, + Description: heredoc.Doc(` + Provides a resource to manage an operation in API Shield Endpoint Management. + `), + } +} + +func resourceCloudflareAPIShieldOperationCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + zoneID := d.Get(consts.ZoneIDSchemaKey).(string) + + ops, err := client.CreateAPIShieldOperations( + ctx, + cloudflare.ZoneIdentifier(zoneID), + cloudflare.CreateAPIShieldOperationsParams{ + Operations: []cloudflare.APIShieldBasicOperation{ + { + Method: d.Get("method").(string), + Host: d.Get("host").(string), + Endpoint: d.Get("endpoint").(string), + }, + }, + }, + ) + + if err != nil { + return diag.FromErr(errors.Wrap(err, "failed to create API Shield Operation")) + } + + if length := len(ops); length != 1 { + return diag.FromErr(fmt.Errorf("expected output to have 1 entry but got: %d", length)) + } + + d.SetId(ops[0].ID) + return resourceCloudflareAPIShieldOperationRead(ctx, d, meta) +} + +func resourceCloudflareAPIShieldOperationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + zoneID := d.Get(consts.ZoneIDSchemaKey).(string) + + op, err := client.GetAPIShieldOperation( + ctx, + cloudflare.ZoneIdentifier(zoneID), + cloudflare.GetAPIShieldOperationParams{ + OperationID: d.Id(), + }, + ) + + if err != nil { + return diag.FromErr(fmt.Errorf("failed to fetch API Shield Operation: %w", err)) + } + + if err := d.Set("method", op.Method); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("host", op.Host); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("endpoint", op.Endpoint); err != nil { + return diag.FromErr(err) + } + + d.SetId(op.ID) + return nil +} + +func resourceCloudflareAPIShieldOperationDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + zoneID := d.Get(consts.ZoneIDSchemaKey).(string) + + err := client.DeleteAPIShieldOperation( + ctx, + cloudflare.ZoneIdentifier(zoneID), + cloudflare.DeleteAPIShieldOperationParams{ + OperationID: d.Id(), + }, + ) + if err != nil { + return diag.FromErr(fmt.Errorf("failed to fetch API Shield Operation: %w", err)) + } + + return nil +} diff --git a/internal/sdkv2provider/resource_cloudflare_api_shield_operation_test.go b/internal/sdkv2provider/resource_cloudflare_api_shield_operation_test.go new file mode 100644 index 0000000000..5ff339386a --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_api_shield_operation_test.go @@ -0,0 +1,122 @@ +package sdkv2provider + +import ( + "context" + "errors" + "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 TestAccCloudflareAPIShieldOperation_Create(t *testing.T) { + // Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the API token + // endpoint does not yet support the API tokens without an explicit scope. + if os.Getenv("CLOUDFLARE_API_TOKEN") != "" { + t.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd := generateRandomResourceName() + resourceID := "cloudflare_api_shield_operation." + rnd + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + domain := os.Getenv("CLOUDFLARE_DOMAIN") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckAPIShieldOperationDelete, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAPIShieldOperation(rnd, zoneID, cloudflare.APIShieldBasicOperation{Method: "GET", Host: domain, Endpoint: "/example/path"}), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID, consts.ZoneIDSchemaKey, zoneID), + resource.TestCheckResourceAttr(resourceID, "method", "GET"), + resource.TestCheckResourceAttr(resourceID, "host", domain), + resource.TestCheckResourceAttr(resourceID, "endpoint", "/example/path"), + ), + }, + }, + }) +} + +func TestAccCloudflareAPIShieldOperation_ForceNew(t *testing.T) { + // Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the API token + // endpoint does not yet support the API tokens without an explicit scope. + if os.Getenv("CLOUDFLARE_API_TOKEN") != "" { + t.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd := generateRandomResourceName() + resourceID := "cloudflare_api_shield_operation." + rnd + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + domain := os.Getenv("CLOUDFLARE_DOMAIN") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckAPIShieldOperationDelete, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAPIShieldOperation(rnd, zoneID, cloudflare.APIShieldBasicOperation{Method: "GET", Host: domain, Endpoint: "/example/path"}), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID, consts.ZoneIDSchemaKey, zoneID), + resource.TestCheckResourceAttr(resourceID, "method", "GET"), + resource.TestCheckResourceAttr(resourceID, "host", domain), + resource.TestCheckResourceAttr(resourceID, "endpoint", "/example/path"), + ), + }, + { + Config: testAccCloudflareAPIShieldOperation(rnd, zoneID, cloudflare.APIShieldBasicOperation{Method: "POST", Host: domain, Endpoint: "/example/path"}), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID, consts.ZoneIDSchemaKey, zoneID), + resource.TestCheckResourceAttr(resourceID, "method", "POST"), // check that we've 'updated' the value + resource.TestCheckResourceAttr(resourceID, "host", domain), + resource.TestCheckResourceAttr(resourceID, "endpoint", "/example/path"), + ), + }, + }, + }) +} + +func testAccCheckAPIShieldOperationDelete(s *terraform.State) error { + client := testAccProvider.Meta().(*cloudflare.API) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudflare_api_shield_operation" { + continue + } + + _, err := client.GetAPIShieldOperation( + context.Background(), + cloudflare.ZoneIdentifier(rs.Primary.Attributes[consts.ZoneIDSchemaKey]), + cloudflare.GetAPIShieldOperationParams{ + OperationID: rs.Primary.Attributes["id"], + }, + ) + if err == nil { + return fmt.Errorf("operation still exists") + } + + var notFoundError *cloudflare.NotFoundError + if !errors.As(err, ¬FoundError) { + return fmt.Errorf("expected not found error but got: %w", err) + } + } + + return nil +} + +func testAccCloudflareAPIShieldOperation(resourceName, zone string, op cloudflare.APIShieldBasicOperation) string { + return fmt.Sprintf(` + resource "cloudflare_api_shield_operation" "%[1]s" { + zone_id = "%[2]s" + method = "%[3]s" + host = "%[4]s" + endpoint = "%[5]s" + } +`, resourceName, zone, op.Method, op.Host, op.Endpoint) +} diff --git a/internal/sdkv2provider/schema_cloudflare_api_shield_operation.go b/internal/sdkv2provider/schema_cloudflare_api_shield_operation.go new file mode 100644 index 0000000000..c7768ce5bc --- /dev/null +++ b/internal/sdkv2provider/schema_cloudflare_api_shield_operation.go @@ -0,0 +1,37 @@ +package sdkv2provider + +import ( + "github.com/MakeNowJust/heredoc/v2" + + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudflareAPIShieldOperationSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + consts.ZoneIDSchemaKey: { + Description: consts.ZoneIDSchemaDescription, + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "method": { + Description: "The HTTP method used to access the endpoint", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "host": { + Description: "RFC3986-compliant host", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "endpoint": { + Description: heredoc.Doc("The endpoint which can contain path parameter templates in curly braces, each will be replaced from left to right with `{varN}`, starting with `{var1}`. This will then be [Cloudflare-normalized](https://developers.cloudflare.com/rules/normalization/how-it-works/)"), + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + } +}