diff --git a/docs/resources/service_waf_configuration.md b/docs/resources/service_waf_configuration.md index 7a1afe5fb..b3385e49d 100644 --- a/docs/resources/service_waf_configuration.md +++ b/docs/resources/service_waf_configuration.md @@ -581,6 +581,7 @@ $ terraform state rm fastly_service_waf_configuration.waf ### Optional +- **activate** (Boolean) Conditionally prevents a new firewall version from being activated. The apply step will continue to create a new draft version but will not activate it if this is set to `false`. Default `true` - **allowed_http_versions** (String) Allowed HTTP versions - **allowed_methods** (String) A space-separated list of HTTP method names - **allowed_request_content_type** (String) Allowed request content types @@ -613,6 +614,12 @@ $ terraform state rm fastly_service_waf_configuration.waf - **warning_anomaly_score** (Number) Score value to add for warning anomalies - **xss_score_threshold** (Number) XSS attack threshold +### Read-Only + +- **active** (Boolean) Whether a specific firewall version is currently deployed +- **cloned_version** (Number) The latest cloned firewall version by the provider +- **number** (Number) The WAF firewall version + ### Nested Schema for `rule` diff --git a/fastly/block_fastly_waf_configuration_v1_active_rule_test.go b/fastly/block_fastly_waf_configuration_v1_active_rule_test.go index 29d13deac..826307068 100644 --- a/fastly/block_fastly_waf_configuration_v1_active_rule_test.go +++ b/fastly/block_fastly_waf_configuration_v1_active_rule_test.go @@ -94,7 +94,7 @@ func TestAccFastlyServiceWAFVersionV1AddUpdateDeleteRules(t *testing.T) { Revision: 2, }, } - wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20) + wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20, true) rulesTF1 := testAccCheckFastlyServiceWAFVersionV1ComposeWAFRules(rules1) wafVer1 := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput, rulesTF1, "") diff --git a/fastly/block_fastly_waf_configuration_v1_exclusion_test.go b/fastly/block_fastly_waf_configuration_v1_exclusion_test.go index 5932dabb6..c535d30ef 100644 --- a/fastly/block_fastly_waf_configuration_v1_exclusion_test.go +++ b/fastly/block_fastly_waf_configuration_v1_exclusion_test.go @@ -132,7 +132,7 @@ func TestAccFastlyServiceWAFVersionV1Validation(t *testing.T) { for _, c := range cases { - wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20) + wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20, true) exclusionsTF1 := testAccCheckFastlyServiceWAFVersionV1ComposeWAFRuleExclusions(c.exclusions) wafVer1 := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput, "", exclusionsTF1) @@ -238,7 +238,7 @@ func TestAccFastlyServiceWAFVersionV1AddUpdateDeleteExclusions(t *testing.T) { }, } - wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20) + wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20, true) rulesTF := testAccCheckFastlyServiceWAFVersionV1ComposeWAFRules(rules) exclusionsTF1 := testAccCheckFastlyServiceWAFVersionV1ComposeWAFRuleExclusions(exclusions1) exclusionsTF2 := testAccCheckFastlyServiceWAFVersionV1ComposeWAFRuleExclusions(exclusions2) diff --git a/fastly/resource_fastly_service_waf_configuration.go b/fastly/resource_fastly_service_waf_configuration.go index 83dbc6795..d8bcba33a 100644 --- a/fastly/resource_fastly_service_waf_configuration.go +++ b/fastly/resource_fastly_service_waf_configuration.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" - "github.com/hashicorp/go-cty/cty" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "log" "sort" gofastly "github.com/fastly/go-fastly/v6/fastly" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) @@ -23,7 +24,20 @@ func resourceServiceWAFConfigurationV1() *schema.Resource { Importer: &schema.ResourceImporter{ StateContext: resourceServiceWAFConfigurationV1Import, }, - CustomizeDiff: validateWAFConfigurationResource, + CustomizeDiff: customdiff.All( + validateWAFConfigurationResource, + customdiff.ComputedIf("cloned_version", func(_ context.Context, d *schema.ResourceDiff, _ interface{}) bool { + // If anything other than "activate" has changed, the current version will be + // cloned in resourceServiceWAFConfigurationV1Update so set it as recomputed. + for _, changedKey := range d.GetChangedKeysPrefix("") { + if changedKey == "activate" { + continue + } + return true + } + return false + }), + ), Schema: map[string]*schema.Schema{ "waf_id": { Type: schema.TypeString, @@ -31,6 +45,22 @@ func resourceServiceWAFConfigurationV1() *schema.Resource { ForceNew: true, Description: "The ID of the Web Application Firewall that the configuration belongs to", }, + "activate": { + Type: schema.TypeBool, + Description: "Conditionally prevents a new firewall version from being activated. The apply step will continue to create a new draft version but will not activate it if this is set to `false`. Default `true`", + Default: true, + Optional: true, + }, + "active": { + Type: schema.TypeBool, + Description: "Whether a specific firewall version is currently deployed", + Computed: true, + }, + "cloned_version": { + Type: schema.TypeInt, + Computed: true, + Description: "The latest cloned firewall version by the provider", + }, "allowed_http_versions": { Type: schema.TypeString, Optional: true, @@ -130,6 +160,11 @@ func resourceServiceWAFConfigurationV1() *schema.Resource { Computed: true, Description: "The maximum number of arguments allowed", }, + "number": { + Type: schema.TypeInt, + Computed: true, + Description: "The WAF firewall version", + }, "notice_anomaly_score": { Type: schema.TypeInt, Optional: true, @@ -225,61 +260,98 @@ func resourceServiceWAFConfigurationV1Create(ctx context.Context, d *schema.Reso func resourceServiceWAFConfigurationV1Update(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { conn := meta.(*FastlyClient).conn - latestVersion, err := getLatestVersion(d, meta) - if err != nil { - return diag.FromErr(err) + // If any attributes other than Computed (unconfigurable) or "activate" have changed, clone a new firewall version. + // Otherwise, don't clone but activate a draft version that was previously created with "activate = false". + var needsChange bool + for k, v := range resourceServiceWAFConfigurationV1().Schema { + if (v.Computed && !v.Optional) || k == "activate" { + continue + } + if d.HasChange(k) { + needsChange = true + break + } } wafID := d.Get("waf_id").(string) log.Printf("[INFO] updating configuration for WAF: %s", wafID) - if latestVersion.Locked { - latestVersion, err = conn.CloneWAFVersion(&gofastly.CloneWAFVersionInput{ - WAFID: wafID, - WAFVersionNumber: latestVersion.Number, - }) + + var latestVersion *gofastly.WAFVersion + var err error + if needsChange { + latestVersion, err = getLatestVersion(d, meta) if err != nil { return diag.FromErr(err) } - } - input := buildUpdateInput(d, latestVersion.ID, latestVersion.Number) - if input.HasChanges() { - latestVersion, err = conn.UpdateWAFVersion(input) + if latestVersion.Locked { + latestVersion, err = conn.CloneWAFVersion(&gofastly.CloneWAFVersionInput{ + WAFID: wafID, + WAFVersionNumber: latestVersion.Number, + }) + if err != nil { + return diag.FromErr(err) + } + } + err = d.Set("cloned_version", latestVersion.Number) if err != nil { return diag.FromErr(err) } - } - if d.HasChange("rule") { - if err := updateRules(d, meta, wafID, latestVersion.Number); err != nil { + input := buildUpdateInput(d, latestVersion.ID, latestVersion.Number) + if input.HasChanges() { + latestVersion, err = conn.UpdateWAFVersion(input) + if err != nil { + return diag.FromErr(err) + } + } + + if d.HasChange("rule") { + if err := updateRules(d, meta, wafID, latestVersion.Number); err != nil { + return diag.FromErr(err) + } + } + + if d.HasChange("rule_exclusion") { + if err := updateWAFRuleExclusions(d, meta, wafID, latestVersion.Number); err != nil { + return diag.FromErr(err) + } + } + } else { + // Retrieve draft version + versionToRead := d.Get("cloned_version").(int) + latestVersion, err = conn.GetWAFVersion(&gofastly.GetWAFVersionInput{ + WAFID: wafID, + WAFVersionNumber: versionToRead, + }) + if err != nil { return diag.FromErr(err) } } - if d.HasChange("rule_exclusion") { - if err := updateWAFRuleExclusions(d, meta, wafID, latestVersion.Number); err != nil { + shouldActivate := d.Get("activate").(bool) + versionNotYetActivated := !needsChange && (!latestVersion.Locked && !latestVersion.Active) + if shouldActivate && (needsChange || versionNotYetActivated) { + err := conn.DeployWAFVersion(&gofastly.DeployWAFVersionInput{ + WAFID: wafID, + WAFVersionNumber: d.Get("cloned_version").(int), + }) + if err != nil { return diag.FromErr(err) } - } - err = conn.DeployWAFVersion(&gofastly.DeployWAFVersionInput{ - WAFID: wafID, - WAFVersionNumber: latestVersion.Number, - }) - if err != nil { - return diag.FromErr(err) + statusCheck := &WAFDeploymentChecker{ + Timeout: d.Timeout(schema.TimeoutCreate), + Delay: WAFStatusCheckDelay, + MinTimeout: WAFStatusCheckMinTimeout, + Check: DefaultWAFDeploymentChecker(conn), + } + err = statusCheck.waitForDeployment(ctx, wafID, latestVersion) + if err != nil { + return diag.FromErr(err) + } } - statusCheck := &WAFDeploymentChecker{ - Timeout: d.Timeout(schema.TimeoutCreate), - Delay: WAFStatusCheckDelay, - MinTimeout: WAFStatusCheckMinTimeout, - Check: DefaultWAFDeploymentChecker(conn), - } - err = statusCheck.waitForDeployment(ctx, wafID, latestVersion) - if err != nil { - return diag.FromErr(err) - } return resourceServiceWAFConfigurationV1Read(ctx, d, meta) } @@ -302,6 +374,31 @@ func resourceServiceWAFConfigurationV1Read(_ context.Context, d *schema.Resource return diag.FromErr(err) } + if d.Get("cloned_version").(int) == 0 { + err := d.Set("cloned_version", latestVersion.Number) + if err != nil { + return diag.FromErr(err) + } + } + + if d.Get("activate") == false { + conn := meta.(*FastlyClient).conn + wafID := d.Get("waf_id").(string) + versionToRead := d.Get("cloned_version").(int) + latestVersion, err = conn.GetWAFVersion(&gofastly.GetWAFVersionInput{ + WAFID: wafID, + WAFVersionNumber: versionToRead, + }) + if err != nil { + return diag.FromErr(err) + } + } else { + err := d.Set("cloned_version", latestVersion.Number) + if err != nil { + return diag.FromErr(err) + } + } + log.Printf("[INFO] retrieving WAF version number: %d", latestVersion.Number) refreshWAFConfig(d, latestVersion) @@ -480,6 +577,7 @@ func buildUpdateInput(d *schema.ResourceData, id string, number int) *gofastly.U } func refreshWAFConfig(d *schema.ResourceData, version *gofastly.WAFVersion) { + d.Set("active", version.Active) d.Set("allowed_http_versions", version.AllowedHTTPVersions) d.Set("allowed_methods", version.AllowedMethods) d.Set("allowed_request_content_type", version.AllowedRequestContentType) @@ -496,6 +594,7 @@ func refreshWAFConfig(d *schema.ResourceData, version *gofastly.WAFVersion) { d.Set("lfi_score_threshold", version.LFIScoreThreshold) d.Set("max_file_size", version.MaxFileSize) d.Set("max_num_args", version.MaxNumArgs) + d.Set("number", version.Number) d.Set("notice_anomaly_score", version.NoticeAnomalyScore) d.Set("paranoia_level", version.ParanoiaLevel) d.Set("php_injection_score_threshold", version.PHPInjectionScoreThreshold) @@ -520,10 +619,19 @@ func determineLatestVersion(versions []*gofastly.WAFVersion) (*gofastly.WAFVersi return versions[i].Number > versions[j].Number }) - return versions[0], nil + // Find the current active firewall version + // or, pick the most recent version if there's no active version yet + latest := versions[0] + for _, v := range versions { + if v.Active { + latest = v + break + } + } + + return latest, nil } func validateWAFConfigurationResource(_ context.Context, d *schema.ResourceDiff, _ interface{}) error { - err := validateWAFRuleExclusion(d) - return err + return validateWAFRuleExclusion(d) } diff --git a/fastly/resource_fastly_service_waf_configuration_test.go b/fastly/resource_fastly_service_waf_configuration_test.go index a68bcb0bf..93a14d4c6 100644 --- a/fastly/resource_fastly_service_waf_configuration_test.go +++ b/fastly/resource_fastly_service_waf_configuration_test.go @@ -39,12 +39,13 @@ func TestAccFastlyServiceWAFVersionV1DetermineVersion(t *testing.T) { Errored: false, }, { + // active version should be selected remote: []*gofastly.WAFVersion{ {Number: 3}, - {Number: 2}, + {Number: 2, Active: true}, {Number: 1}, }, - local: 3, + local: 2, Errored: false, }, } @@ -66,7 +67,7 @@ func TestAccFastlyServiceWAFVersionV1DetermineVersion(t *testing.T) { func TestAccFastlyServiceWAFVersionV1Add(t *testing.T) { var service gofastly.ServiceDetail name := fmt.Sprintf("tf-test-%s", acctest.RandString(10)) - wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20) + wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20, true) wafVer := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput, "", "") resource.ParallelTest(t, resource.TestCase{ @@ -88,7 +89,7 @@ func TestAccFastlyServiceWAFVersionV1Add(t *testing.T) { func TestAccFastlyServiceWAFVersionV1AddExistingService(t *testing.T) { var service gofastly.ServiceDetail name := fmt.Sprintf("tf-test-%s", acctest.RandString(10)) - wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20) + wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20, true) wafVer := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput, "", "") resource.ParallelTest(t, resource.TestCase{ @@ -117,12 +118,16 @@ func TestAccFastlyServiceWAFVersionV1Update(t *testing.T) { var service gofastly.ServiceDetail name := fmt.Sprintf("tf-test-%s", acctest.RandString(10)) - wafVerInput1 := testAccFastlyServiceWAFVersionV1BuildConfig(20) + wafVerInput1 := testAccFastlyServiceWAFVersionV1BuildConfig(20, true) wafVer1 := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput1, "", "") - wafVerInput2 := testAccFastlyServiceWAFVersionV1BuildConfig(22) + wafVerInput2 := testAccFastlyServiceWAFVersionV1BuildConfig(22, false) wafVer2 := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput2, "", "") + wafVerInput3 := testAccFastlyServiceWAFVersionV1BuildConfig(22, true) + wafVer3 := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput3, "", "") + + resourceName := "fastly_service_waf_configuration.waf" resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProviderFactories: testAccProviders, @@ -140,6 +145,17 @@ func TestAccFastlyServiceWAFVersionV1Update(t *testing.T) { Check: resource.ComposeTestCheckFunc( testAccCheckServiceV1Exists(serviceRef, &service), testAccCheckFastlyServiceWAFVersionV1CheckAttributes(&service, wafVerInput2, 2), + resource.TestCheckResourceAttr(resourceName, "active", "false"), + resource.TestCheckResourceAttr(resourceName, "number", "2"), + ), + }, + { + Config: testAccFastlyServiceWAFVersionV1(name, wafVer3), + Check: resource.ComposeTestCheckFunc( + testAccCheckServiceV1Exists(serviceRef, &service), + testAccCheckFastlyServiceWAFVersionV1CheckAttributes(&service, wafVerInput3, 2), + resource.TestCheckResourceAttr(resourceName, "active", "true"), + resource.TestCheckResourceAttr(resourceName, "number", "2"), ), }, }, @@ -149,7 +165,7 @@ func TestAccFastlyServiceWAFVersionV1Update(t *testing.T) { func TestAccFastlyServiceWAFVersionV1Delete(t *testing.T) { var service gofastly.ServiceDetail name := fmt.Sprintf("tf-test-%s", acctest.RandString(10)) - wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20) + wafVerInput := testAccFastlyServiceWAFVersionV1BuildConfig(20, true) wafVer := testAccFastlyServiceWAFVersionV1ComposeConfiguration(wafVerInput, "", "") resource.ParallelTest(t, resource.TestCase{ @@ -249,8 +265,9 @@ func TestAccFastlyServiceWAFVersionV1Import(t *testing.T) { ImportState: true, ImportStateVerify: true, - // Rule Exclusion should be ignored until it is in GA. - ImportStateVerifyIgnore: []string{"rule_exclusion"}, + // - The "activate" attribute is not stored on the Fastly API and must be ignored. + // - Rule Exclusion should be ignored until it is in GA. + ImportStateVerifyIgnore: []string{"activate", "rule_exclusion"}, }, { Config: wafSvcCfg, @@ -263,6 +280,8 @@ func TestAccFastlyServiceWAFVersionV1Import(t *testing.T) { func testAccCheckFastlyServiceWAFVersionV1CheckAttributes(service *gofastly.ServiceDetail, local map[string]interface{}, latestVersion int) resource.TestCheckFunc { return func(s *terraform.State) error { + // The "activate" attribute is not stored on the Fastly API and must be ignored. + delete(local, "activate") conn := testAccProvider.Meta().(*FastlyClient).conn wafResp, err := conn.ListWAFs(&gofastly.ListWAFsInput{ FilterService: service.ID, @@ -460,8 +479,9 @@ resource "fastly_service_v1" "foo" { `, name, domainName, backendName, extraHCL) } -func testAccFastlyServiceWAFVersionV1BuildConfig(threshold int) map[string]interface{} { +func testAccFastlyServiceWAFVersionV1BuildConfig(threshold int, activate bool) map[string]interface{} { return map[string]interface{}{ + "activate": activate, "allowed_http_versions": "HTTP/1.0 HTTP/1.1", "allowed_methods": "GET HEAD POST", "allowed_request_content_type": "application/x-www-form-urlencoded|multipart/form-data|text/xml|application/xml",