diff --git a/builtin/providers/fastly/resource_fastly_service_v1.go b/builtin/providers/fastly/resource_fastly_service_v1.go index 9ce389b40c52..6630c7c772f7 100644 --- a/builtin/providers/fastly/resource_fastly_service_v1.go +++ b/builtin/providers/fastly/resource_fastly_service_v1.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "strings" "time" "github.com/hashicorp/terraform/helper/schema" @@ -156,6 +157,110 @@ func resourceServiceV1() *schema.Resource { Type: schema.TypeBool, Optional: true, }, + + "header": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + // required fields + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "A name to refer to this Header object", + }, + "action": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "One of set, append, delete, regex, or regex_repeat", + ValidateFunc: func(v interface{}, k string) (ws []string, es []error) { + var found bool + for _, t := range []string{"set", "append", "delete", "regex", "regex_repeat"} { + if v.(string) == t { + found = true + } + } + if !found { + es = append(es, fmt.Errorf( + "Fastly Header action is case sensitive and must be one of 'set', 'append', 'delete', 'regex', or 'regex_repeat'; found: %s", v.(string))) + } + return + }, + }, + "type": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Type to manipulate: request, fetch, cache, response", + ValidateFunc: func(v interface{}, k string) (ws []string, es []error) { + var found bool + for _, t := range []string{"request", "fetch", "cache", "response"} { + if v.(string) == t { + found = true + } + } + if !found { + es = append(es, fmt.Errorf( + "Fastly Header type is case sensitive and must be one of 'request', 'fetch', 'cache', or 'response'; found: %s", v.(string))) + } + return + }, + }, + "destination": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Header this affects", + }, + // Optional fields, defaults where they exist + "ignore_if_set": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Don't add the header if it is already. (Only applies to 'set' action.). Default `false`", + }, + "source": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Variable to be used as a source for the header content (Does not apply to 'delete' action.)", + }, + "regex": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Regular expression to use (Only applies to 'regex' and 'regex_repeat' actions.)", + }, + "substitution": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Value to substitute in place of regular expression. (Only applies to 'regex' and 'regex_repeat'.)", + }, + "priority": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + Default: 100, + Description: "Lower priorities execute first. (Default: 100.)", + }, + // These fields represent Fastly options that Terraform does not + // currently support + "request_condition": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Description: "Optional name of a RequestCondition to apply.", + }, + "cache_condition": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Description: "Optional name of a CacheCondition to apply.", + }, + "response_condition": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Description: "Optional name of a ResponseCondition to apply.", + }, + }, + }, + }, }, } } @@ -194,7 +299,7 @@ func resourceServiceV1Update(d *schema.ResourceData, meta interface{}) error { // DefaultTTL, a new Version must be created first, and updates posted to that // Version. Loop these attributes and determine if we need to create a new version first var needsChange bool - for _, v := range []string{"domain", "backend", "default_host", "default_ttl"} { + for _, v := range []string{"domain", "backend", "default_host", "default_ttl", "header"} { if d.HasChange(v) { needsChange = true } @@ -369,6 +474,60 @@ func resourceServiceV1Update(d *schema.ResourceData, meta interface{}) error { } } + if d.HasChange("header") { + // Note: we don't utilize the PUT endpoint to update a Header, we simply + // destroy it and create a new one. This is how Terraform works with nested + // sub resources, we only get the full diff not a partial set item diff. + // Because this is done on a new version of the configuration, this is + // considered safe + oh, nh := d.GetChange("header") + if oh == nil { + oh = new(schema.Set) + } + if nh == nil { + nh = new(schema.Set) + } + + ohs := oh.(*schema.Set) + nhs := nh.(*schema.Set) + + remove := ohs.Difference(nhs).List() + add := nhs.Difference(ohs).List() + + // Delete removed headers + for _, dRaw := range remove { + df := dRaw.(map[string]interface{}) + opts := gofastly.DeleteHeaderInput{ + Service: d.Id(), + Version: latestVersion, + Name: df["name"].(string), + } + + log.Printf("[DEBUG] Fastly Header Removal opts: %#v", opts) + err := conn.DeleteHeader(&opts) + if err != nil { + return err + } + } + + // POST new Headers + for _, dRaw := range add { + opts, err := buildHeader(dRaw.(map[string]interface{})) + if err != nil { + log.Printf("[DEBUG] Error building Header: %s", err) + return err + } + opts.Service = d.Id() + opts.Version = latestVersion + + log.Printf("[DEBUG] Fastly Header Addition opts: %#v", opts) + _, err = conn.CreateHeader(opts) + if err != nil { + return err + } + } + } + // validate version log.Printf("[DEBUG] Validating Fastly Service (%s), Version (%s)", d.Id(), latestVersion) valid, msg, err := conn.ValidateVersion(&gofastly.ValidateVersionInput{ @@ -447,6 +606,7 @@ func resourceServiceV1Read(d *schema.ResourceData, meta interface{}) error { // TODO: update go-fastly to support an ActiveVersion struct, which contains // domain and backend info in the response. Here we do 2 additional queries // to find out that info + log.Printf("[DEBUG] Refreshing Domains for (%s)", d.Id()) domainList, err := conn.ListDomains(&gofastly.ListDomainsInput{ Service: d.Id(), Version: s.ActiveVersion.Number, @@ -464,6 +624,7 @@ func resourceServiceV1Read(d *schema.ResourceData, meta interface{}) error { } // Refresh Backends + log.Printf("[DEBUG] Refreshing Backends for (%s)", d.Id()) backendList, err := conn.ListBackends(&gofastly.ListBackendsInput{ Service: d.Id(), Version: s.ActiveVersion.Number, @@ -478,6 +639,24 @@ func resourceServiceV1Read(d *schema.ResourceData, meta interface{}) error { if err := d.Set("backend", bl); err != nil { log.Printf("[WARN] Error setting Backends for (%s): %s", d.Id(), err) } + + // refresh headers + log.Printf("[DEBUG] Refreshing Headers for (%s)", d.Id()) + headerList, err := conn.ListHeaders(&gofastly.ListHeadersInput{ + Service: d.Id(), + Version: s.ActiveVersion.Number, + }) + + if err != nil { + return fmt.Errorf("[ERR] Error looking up Headers for (%s), version (%s): %s", d.Id(), s.ActiveVersion.Number, err) + } + + hl := flattenHeaders(headerList) + + if err := d.Set("header", hl); err != nil { + log.Printf("[WARN] Error setting Headers for (%s): %s", d.Id(), err) + } + } else { log.Printf("[DEBUG] Active Version for Service (%s) is empty, no state to refresh", d.Id()) } @@ -590,7 +769,7 @@ func findService(id string, meta interface{}) (*gofastly.Service, error) { l, err := conn.ListServices(&gofastly.ListServicesInput{}) if err != nil { - return nil, fmt.Errorf("[WARN] Error listing servcies when deleting Fastly Service (%s): %s", id, err) + return nil, fmt.Errorf("[WARN] Error listing services when deleting Fastly Service (%s): %s", id, err) } for _, s := range l { @@ -602,3 +781,77 @@ func findService(id string, meta interface{}) (*gofastly.Service, error) { return nil, fastlyNoServiceFoundErr } + +func flattenHeaders(headerList []*gofastly.Header) []map[string]interface{} { + var hl []map[string]interface{} + for _, h := range headerList { + // Convert Header to a map for saving to state. + nh := map[string]interface{}{ + "name": h.Name, + "action": h.Action, + "ignore_if_set": h.IgnoreIfSet, + "type": h.Type, + "destination": h.Destination, + "source": h.Source, + "regex": h.Regex, + "substitution": h.Substitution, + "priority": int(h.Priority), + "request_condition": h.RequestCondition, + "cache_condition": h.CacheCondition, + "response_condition": h.ResponseCondition, + } + + for k, v := range nh { + if v == "" { + delete(nh, k) + } + } + + hl = append(hl, nh) + } + return hl +} + +func buildHeader(headerMap interface{}) (*gofastly.CreateHeaderInput, error) { + df := headerMap.(map[string]interface{}) + opts := gofastly.CreateHeaderInput{ + Name: df["name"].(string), + IgnoreIfSet: df["ignore_if_set"].(bool), + Destination: df["destination"].(string), + Priority: uint(df["priority"].(int)), + Source: df["source"].(string), + Regex: df["regex"].(string), + Substitution: df["substitution"].(string), + RequestCondition: df["request_condition"].(string), + CacheCondition: df["cache_condition"].(string), + ResponseCondition: df["response_condition"].(string), + } + + act := strings.ToLower(df["action"].(string)) + switch act { + case "set": + opts.Action = gofastly.HeaderActionSet + case "append": + opts.Action = gofastly.HeaderActionAppend + case "delete": + opts.Action = gofastly.HeaderActionDelete + case "regex": + opts.Action = gofastly.HeaderActionRegex + case "regex_repeat": + opts.Action = gofastly.HeaderActionRegexRepeat + } + + ty := strings.ToLower(df["type"].(string)) + switch ty { + case "request": + opts.Type = gofastly.HeaderTypeRequest + case "fetch": + opts.Type = gofastly.HeaderTypeFetch + case "cache": + opts.Type = gofastly.HeaderTypeCache + case "response": + opts.Type = gofastly.HeaderTypeResponse + } + + return &opts, nil +} diff --git a/builtin/providers/fastly/resource_fastly_service_v1_headers_test.go b/builtin/providers/fastly/resource_fastly_service_v1_headers_test.go new file mode 100644 index 000000000000..306de61f458f --- /dev/null +++ b/builtin/providers/fastly/resource_fastly_service_v1_headers_test.go @@ -0,0 +1,233 @@ +package fastly + +import ( + "fmt" + "reflect" + "sort" + "testing" + + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + gofastly "github.com/sethvargo/go-fastly" +) + +func TestFastlyServiceV1_BuildHeaders(t *testing.T) { + cases := []struct { + remote *gofastly.CreateHeaderInput + local map[string]interface{} + }{ + { + remote: &gofastly.CreateHeaderInput{ + Name: "someheadder", + Action: gofastly.HeaderActionDelete, + IgnoreIfSet: true, + Type: gofastly.HeaderTypeCache, + Destination: "http.aws-id", + Priority: uint(100), + }, + local: map[string]interface{}{ + "name": "someheadder", + "action": "delete", + "ignore_if_set": true, + "destination": "http.aws-id", + "priority": 100, + "source": "", + "regex": "", + "substitution": "", + "request_condition": "", + "cache_condition": "", + "response_condition": "", + "type": "cache", + }, + }, + { + remote: &gofastly.CreateHeaderInput{ + Name: "someheadder", + Action: gofastly.HeaderActionSet, + Type: gofastly.HeaderTypeCache, + Destination: "http.aws-id", + Priority: uint(100), + Source: "http.server-name", + }, + local: map[string]interface{}{ + "name": "someheadder", + "action": "set", + "ignore_if_set": false, + "destination": "http.aws-id", + "priority": 100, + "source": "http.server-name", + "regex": "", + "substitution": "", + "request_condition": "", + "cache_condition": "", + "response_condition": "", + "type": "cache", + }, + }, + } + + for _, c := range cases { + out, _ := buildHeader(c.local) + if !reflect.DeepEqual(out, c.remote) { + t.Fatalf("Error matching:\nexpected: %#v\ngot: %#v", c.remote, out) + } + } +} + +func TestAccFastlyServiceV1_headers_basic(t *testing.T) { + var service gofastly.ServiceDetail + name := fmt.Sprintf("tf-test-%s", acctest.RandString(10)) + domainName1 := fmt.Sprintf("%s.notadomain.com", acctest.RandString(10)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckServiceV1Destroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccServiceV1HeadersConfig(name, domainName1), + Check: resource.ComposeTestCheckFunc( + testAccCheckServiceV1Exists("fastly_service_v1.foo", &service), + testAccCheckFastlyServiceV1HeaderAttributes(&service, name, []string{"http.x-amz-request-id", "http.Server"}, nil), + resource.TestCheckResourceAttr( + "fastly_service_v1.foo", "name", name), + resource.TestCheckResourceAttr( + "fastly_service_v1.foo", "header.#", "2"), + ), + }, + + resource.TestStep{ + Config: testAccServiceV1HeadersConfig_update(name, domainName1), + Check: resource.ComposeTestCheckFunc( + testAccCheckServiceV1Exists("fastly_service_v1.foo", &service), + testAccCheckFastlyServiceV1HeaderAttributes(&service, name, []string{"http.x-amz-request-id", "http.Server"}, []string{"http.server-name"}), + resource.TestCheckResourceAttr( + "fastly_service_v1.foo", "name", name), + resource.TestCheckResourceAttr( + "fastly_service_v1.foo", "header.#", "3"), + resource.TestCheckResourceAttr( + "fastly_service_v1.foo", "header.1147514417.source", "server.identity"), + ), + }, + }, + }) +} + +func testAccCheckFastlyServiceV1HeaderAttributes(service *gofastly.ServiceDetail, name string, headersDeleted, headersAdded []string) resource.TestCheckFunc { + return func(s *terraform.State) error { + + if service.Name != name { + return fmt.Errorf("Bad name, expected (%s), got (%s)", name, service.Name) + } + + conn := testAccProvider.Meta().(*FastlyClient).conn + headersList, err := conn.ListHeaders(&gofastly.ListHeadersInput{ + Service: service.ID, + Version: service.ActiveVersion.Number, + }) + + if err != nil { + return fmt.Errorf("[ERR] Error looking up Headers for (%s), version (%s): %s", service.Name, service.ActiveVersion.Number, err) + } + + var deleted []string + var added []string + for _, h := range headersList { + if h.Action == gofastly.HeaderActionDelete { + deleted = append(deleted, h.Destination) + } + if h.Action == gofastly.HeaderActionSet { + added = append(added, h.Destination) + } + } + + sort.Strings(headersAdded) + sort.Strings(headersDeleted) + sort.Strings(deleted) + sort.Strings(added) + + if !reflect.DeepEqual(headersDeleted, deleted) { + return fmt.Errorf("Deleted Headers did not match.\n\tExpected: (%#v)\n\tGot: (%#v)", headersDeleted, deleted) + } + if !reflect.DeepEqual(headersAdded, added) { + return fmt.Errorf("Added Headers did not match.\n\tExpected: (%#v)\n\tGot: (%#v)", headersAdded, added) + } + + return nil + } +} + +func testAccServiceV1HeadersConfig(name, domain string) string { + return fmt.Sprintf(` +resource "fastly_service_v1" "foo" { + name = "%s" + + domain { + name = "%s" + comment = "tf-testing-domain" + } + + backend { + address = "aws.amazon.com" + name = "amazon docs" + } + + header { + destination = "http.x-amz-request-id" + type = "cache" + action = "delete" + name = "remove x-amz-request-id" + } + + header { + destination = "http.Server" + type = "cache" + action = "delete" + name = "remove s3 server" + } + + force_destroy = true +}`, name, domain) +} + +func testAccServiceV1HeadersConfig_update(name, domain string) string { + return fmt.Sprintf(` +resource "fastly_service_v1" "foo" { + name = "%s" + + domain { + name = "%s" + comment = "tf-testing-domain" + } + + backend { + address = "aws.amazon.com" + name = "amazon docs" + } + + header { + destination = "http.x-amz-request-id" + type = "cache" + action = "delete" + name = "remove x-amz-request-id" + } + + header { + destination = "http.Server" + type = "cache" + action = "delete" + name = "DESTROY S3" + } + + header { + destination = "http.server-name" + type = "request" + action = "set" + source = "server.identity" + name = "Add server name" + } + + force_destroy = true +}`, name, domain) +} diff --git a/website/source/docs/providers/fastly/r/service_v1.html.markdown b/website/source/docs/providers/fastly/r/service_v1.html.markdown index 8e84234f0616..15b0e8adc2aa 100644 --- a/website/source/docs/providers/fastly/r/service_v1.html.markdown +++ b/website/source/docs/providers/fastly/r/service_v1.html.markdown @@ -40,7 +40,7 @@ resource "fastly_service_v1" "demo" { ``` -Basic usage with an Amazon S3 Website: +Basic usage with an Amazon S3 Website, and removes the `x-amz-request-id` header: ``` resource "fastly_service_v1" "demo" { @@ -57,6 +57,13 @@ resource "fastly_service_v1" "demo" { port = 80 } + header { + destination = "http.x-amz-request-id" + type = "cache" + action = "delete" + name = "remove x-amz-request-id" + } + default_host = "${aws_s3_bucket.website.name}.s3-website-us-west-2.amazonaws.com" force_destroy = true @@ -76,7 +83,7 @@ resource "aws_s3_bucket" "website" { **Note:** For an AWS S3 Bucket, the Backend address is `.s3-website-.amazonaws.com`. The `default_host` attribute should be set to `.s3-website-.amazonaws.com`. See the -Fastly documentation on [Amazon S3][fastly-s3] +Fastly documentation on [Amazon S3][fastly-s3]. ## Argument Reference @@ -87,6 +94,8 @@ The following arguments are supported: Service. Defined below. * `backend` - (Required) A set of Backends to service requests from your Domains. Defined below. +* `header` - (Optional) A set of Headers to manipulate for each request. Defined +below. * `default_host` - (Optional) The default hostname * `default_ttl` - (Optional) The default Time-to-live (TTL) for requests * `force_destroy` - (Optional) Services that are active cannot be destroyed. In @@ -117,6 +126,25 @@ Default `200` * `weight` - (Optional) How long to wait for the first bytes in milliseconds. Default `100` +The `Header` block supports adding, removing, or modifying Request and Response +headers. See Fastly's documentation on +[Adding or modifying headers on HTTP requests and responses](https://docs.fastly.com/guides/basic-configuration/adding-or-modifying-headers-on-http-requests-and-responses#field-description-table) for more detailed information on any +of the properties below. + +* `name` - (Required) A unique name to refer to this header attribute +* `action` - (Required) The Header manipulation action to take; must be one of +`set`, `append`, `delete`, `regex`, or `regex_repeat` +* `type` - (Required) The Request type to apply the selected Action on +* `destination` - (Required) The name of the header that is going to be affected +by the Action +* `ignore_if_set` - (Optional) Do not add the header if it is already present. +(Only applies to `set` action.). Default `false` +* `source` - (Optional) Variable to be used as a source for the header content +(Does not apply to `delete` action.) +* `regex` - (Optional) Regular expression to use (Only applies to `regex` and `regex_repeat` actions.) +* `substitution` - (Optional) Value to substitute in place of regular expression. (Only applies to `regex` and `regex_repeat`.) +* `priority` - (Optional) Lower priorities execute first. (Default: `100`.) + ## Attributes Reference The following attributes are exported: @@ -126,6 +154,7 @@ The following attributes are exported: * `active_version` - The currently active version of your Fastly Service * `domain` – Set of Domains. See above for details * `backend` – Set of Backends. See above for details +* `header` – Set of Headers. See above for details * `default_host` – Default host specified * `default_ttl` - Default TTL * `force_destroy` - Force the destruction of the Service on delete @@ -133,4 +162,3 @@ The following attributes are exported: [fastly-s3]: https://docs.fastly.com/guides/integrations/amazon-s3 [fastly-cname]: https://docs.fastly.com/guides/basic-setup/adding-cname-records -