package fastly import ( "errors" "fmt" "log" "strings" "time" "github.com/hashicorp/terraform/helper/schema" gofastly "github.com/sethvargo/go-fastly" ) var fastlyNoServiceFoundErr = errors.New("No matching Fastly Service found") func resourceServiceV1() *schema.Resource { return &schema.Resource{ Create: resourceServiceV1Create, Read: resourceServiceV1Read, Update: resourceServiceV1Update, Delete: resourceServiceV1Delete, Schema: map[string]*schema.Schema{ "name": &schema.Schema{ Type: schema.TypeString, Required: true, Description: "Unique name for this Service", }, // Active Version represents the currently activated version in Fastly. In // Terraform, we abstract this number away from the users and manage // creating and activating. It's used internally, but also exported for // users to see. "active_version": &schema.Schema{ Type: schema.TypeString, Computed: true, }, "domain": &schema.Schema{ Type: schema.TypeSet, Required: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "name": &schema.Schema{ Type: schema.TypeString, Required: true, Description: "The domain that this Service will respond to", }, "comment": &schema.Schema{ Type: schema.TypeString, Optional: true, }, }, }, }, "default_ttl": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 3600, Description: "The default Time-to-live (TTL) for the version", }, "default_host": &schema.Schema{ Type: schema.TypeString, Optional: true, Computed: true, Description: "The default hostname for the version", }, "backend": &schema.Schema{ Type: schema.TypeSet, Required: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ // required fields "name": &schema.Schema{ Type: schema.TypeString, Required: true, Description: "A name for this Backend", }, "address": &schema.Schema{ Type: schema.TypeString, Required: true, Description: "An IPv4, hostname, or IPv6 address for the Backend", }, // Optional fields, defaults where they exist "auto_loadbalance": &schema.Schema{ Type: schema.TypeBool, Optional: true, Default: true, Description: "Should this Backend be load balanced", }, "between_bytes_timeout": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 10000, Description: "How long to wait between bytes in milliseconds", }, "connect_timeout": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 1000, Description: "How long to wait for a timeout in milliseconds", }, "error_threshold": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 0, Description: "Number of errors to allow before the Backend is marked as down", }, "first_byte_timeout": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 15000, Description: "How long to wait for the first bytes in milliseconds", }, "max_conn": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 200, Description: "Maximum number of connections for this Backend", }, "port": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 80, Description: "The port number Backend responds on. Default 80", }, "ssl_check_cert": &schema.Schema{ Type: schema.TypeBool, Optional: true, Default: true, Description: "Be strict on checking SSL certs", }, // UseSSL is something we want to support in the future, but // requires SSL setup we don't yet have // TODO: Provide all SSL fields from https://docs.fastly.com/api/config#backend // "use_ssl": &schema.Schema{ // Type: schema.TypeBool, // Optional: true, // Default: false, // Description: "Whether or not to use SSL to reach the Backend", // }, "weight": &schema.Schema{ Type: schema.TypeInt, Optional: true, Default: 100, Description: "How long to wait for the first bytes in milliseconds", }, }, }, }, "force_destroy": &schema.Schema{ 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.", }, }, }, }, }, } } func resourceServiceV1Create(d *schema.ResourceData, meta interface{}) error { conn := meta.(*FastlyClient).conn service, err := conn.CreateService(&gofastly.CreateServiceInput{ Name: d.Get("name").(string), Comment: "Managed by Terraform", }) if err != nil { return err } d.SetId(service.ID) return resourceServiceV1Update(d, meta) } func resourceServiceV1Update(d *schema.ResourceData, meta interface{}) error { conn := meta.(*FastlyClient).conn // Update Name. No new verions is required for this if d.HasChange("name") { _, err := conn.UpdateService(&gofastly.UpdateServiceInput{ ID: d.Id(), Name: d.Get("name").(string), }) if err != nil { return err } } // Once activated, Versions are locked and become immutable. This is true for // versions that are no longer active. For Domains, Backends, DefaultHost and // 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", "header"} { if d.HasChange(v) { needsChange = true } } if needsChange { latestVersion := d.Get("active_version").(string) if latestVersion == "" { // If the service was just created, there is an empty Version 1 available // that is unlocked and can be updated latestVersion = "1" } else { // Clone the latest version, giving us an unlocked version we can modify log.Printf("[DEBUG] Creating clone of version (%s) for updates", latestVersion) newVersion, err := conn.CloneVersion(&gofastly.CloneVersionInput{ Service: d.Id(), Version: latestVersion, }) if err != nil { return err } // The new version number is named "Number", but it's actually a string latestVersion = newVersion.Number // New versions are not immediately found in the API, or are not // immediately mutable, so we need to sleep a few and let Fastly ready // itself. Typically, 7 seconds is enough log.Printf("[DEBUG] Sleeping 7 seconds to allow Fastly Version to be available") time.Sleep(7 * time.Second) } // update general settings if d.HasChange("default_host") || d.HasChange("default_ttl") { opts := gofastly.UpdateSettingsInput{ Service: d.Id(), Version: latestVersion, // default_ttl has the same default value of 3600 that is provided by // the Fastly API, so it's safe to include here DefaultTTL: uint(d.Get("default_ttl").(int)), } if attr, ok := d.GetOk("default_host"); ok { opts.DefaultHost = attr.(string) } log.Printf("[DEBUG] Update Settings opts: %#v", opts) _, err := conn.UpdateSettings(&opts) if err != nil { return err } } // Find differences in domains if d.HasChange("domain") { // Note: we don't utilize the PUT endpoint to update a Domain, 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 od, nd := d.GetChange("domain") if od == nil { od = new(schema.Set) } if nd == nil { nd = new(schema.Set) } ods := od.(*schema.Set) nds := nd.(*schema.Set) remove := ods.Difference(nds).List() add := nds.Difference(ods).List() // Delete removed domains for _, dRaw := range remove { df := dRaw.(map[string]interface{}) opts := gofastly.DeleteDomainInput{ Service: d.Id(), Version: latestVersion, Name: df["name"].(string), } log.Printf("[DEBUG] Fastly Domain Removal opts: %#v", opts) err := conn.DeleteDomain(&opts) if err != nil { return err } } // POST new Domains for _, dRaw := range add { df := dRaw.(map[string]interface{}) opts := gofastly.CreateDomainInput{ Service: d.Id(), Version: latestVersion, Name: df["name"].(string), } if v, ok := df["comment"]; ok { opts.Comment = v.(string) } log.Printf("[DEBUG] Fastly Domain Addition opts: %#v", opts) _, err := conn.CreateDomain(&opts) if err != nil { return err } } } // find difference in backends if d.HasChange("backend") { // POST new Backends // Note: we don't utilize the PUT endpoint to update a Backend, 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 ob, nb := d.GetChange("backend") if ob == nil { ob = new(schema.Set) } if nb == nil { nb = new(schema.Set) } obs := ob.(*schema.Set) nbs := nb.(*schema.Set) removeBackends := obs.Difference(nbs).List() addBackends := nbs.Difference(obs).List() // DELETE old Backends for _, bRaw := range removeBackends { bf := bRaw.(map[string]interface{}) opts := gofastly.DeleteBackendInput{ Service: d.Id(), Version: latestVersion, Name: bf["name"].(string), } log.Printf("[DEBUG] Fastly Backend Removal opts: %#v", opts) err := conn.DeleteBackend(&opts) if err != nil { return err } } for _, dRaw := range addBackends { df := dRaw.(map[string]interface{}) opts := gofastly.CreateBackendInput{ Service: d.Id(), Version: latestVersion, Name: df["name"].(string), Address: df["address"].(string), AutoLoadbalance: df["auto_loadbalance"].(bool), SSLCheckCert: df["ssl_check_cert"].(bool), Port: uint(df["port"].(int)), BetweenBytesTimeout: uint(df["between_bytes_timeout"].(int)), ConnectTimeout: uint(df["connect_timeout"].(int)), ErrorThreshold: uint(df["error_threshold"].(int)), FirstByteTimeout: uint(df["first_byte_timeout"].(int)), MaxConn: uint(df["max_conn"].(int)), Weight: uint(df["weight"].(int)), } log.Printf("[DEBUG] Create Backend Opts: %#v", opts) _, err := conn.CreateBackend(&opts) if err != nil { return err } } } 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{ Service: d.Id(), Version: latestVersion, }) if err != nil { return fmt.Errorf("[ERR] Error checking validation: %s", err) } if !valid { return fmt.Errorf("[ERR] Invalid configuration for Fastly Service (%s): %s", d.Id(), msg) } log.Printf("[DEBUG] Activating Fastly Service (%s), Version (%s)", d.Id(), latestVersion) _, err = conn.ActivateVersion(&gofastly.ActivateVersionInput{ Service: d.Id(), Version: latestVersion, }) if err != nil { return fmt.Errorf("[ERR] Error activating version (%s): %s", latestVersion, err) } // Only if the version is valid and activated do we set the active_version. // This prevents us from getting stuck in cloning an invalid version d.Set("active_version", latestVersion) } return resourceServiceV1Read(d, meta) } func resourceServiceV1Read(d *schema.ResourceData, meta interface{}) error { conn := meta.(*FastlyClient).conn // Find the Service. Discard the service because we need the ServiceDetails, // not just a Service record _, err := findService(d.Id(), meta) if err != nil { switch err { case fastlyNoServiceFoundErr: log.Printf("[WARN] %s for ID (%s)", err, d.Id()) d.SetId("") return nil default: return err } } s, err := conn.GetServiceDetails(&gofastly.GetServiceInput{ ID: d.Id(), }) if err != nil { return err } d.Set("name", s.Name) d.Set("active_version", s.ActiveVersion.Number) // If CreateService succeeds, but initial updates to the Service fail, we'll // have an empty ActiveService version (no version is active, so we can't // query for information on it) if s.ActiveVersion.Number != "" { settingsOpts := gofastly.GetSettingsInput{ Service: d.Id(), Version: s.ActiveVersion.Number, } if settings, err := conn.GetSettings(&settingsOpts); err == nil { d.Set("default_host", settings.DefaultHost) d.Set("default_ttl", settings.DefaultTTL) } else { return fmt.Errorf("[ERR] Error looking up Version settings for (%s), version (%s): %s", d.Id(), s.ActiveVersion.Number, err) } // 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, }) if err != nil { return fmt.Errorf("[ERR] Error looking up Domains for (%s), version (%s): %s", d.Id(), s.ActiveVersion.Number, err) } // Refresh Domains dl := flattenDomains(domainList) if err := d.Set("domain", dl); err != nil { log.Printf("[WARN] Error setting Domains for (%s): %s", d.Id(), err) } // Refresh Backends log.Printf("[DEBUG] Refreshing Backends for (%s)", d.Id()) backendList, err := conn.ListBackends(&gofastly.ListBackendsInput{ Service: d.Id(), Version: s.ActiveVersion.Number, }) if err != nil { return fmt.Errorf("[ERR] Error looking up Backends for (%s), version (%s): %s", d.Id(), s.ActiveVersion.Number, err) } bl := flattenBackends(backendList) 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()) } return nil } func resourceServiceV1Delete(d *schema.ResourceData, meta interface{}) error { conn := meta.(*FastlyClient).conn // Fastly will fail to delete any service with an Active Version. // If `force_destroy` is given, we deactivate the active version and then send // the DELETE call if d.Get("force_destroy").(bool) { s, err := conn.GetServiceDetails(&gofastly.GetServiceInput{ ID: d.Id(), }) if err != nil { return err } if s.ActiveVersion.Number != "" { _, err := conn.DeactivateVersion(&gofastly.DeactivateVersionInput{ Service: d.Id(), Version: s.ActiveVersion.Number, }) if err != nil { return err } } } err := conn.DeleteService(&gofastly.DeleteServiceInput{ ID: d.Id(), }) if err != nil { return err } _, err = findService(d.Id(), meta) if err != nil { switch err { // we expect no records to be found here case fastlyNoServiceFoundErr: d.SetId("") return nil default: return err } } // findService above returned something and nil error, but shouldn't have return fmt.Errorf("[WARN] Tried deleting Service (%s), but was still found", d.Id()) } func flattenDomains(list []*gofastly.Domain) []map[string]interface{} { dl := make([]map[string]interface{}, 0, len(list)) for _, d := range list { dl = append(dl, map[string]interface{}{ "name": d.Name, "comment": d.Comment, }) } return dl } func flattenBackends(backendList []*gofastly.Backend) []map[string]interface{} { var bl []map[string]interface{} for _, b := range backendList { // Convert Backend to a map for saving to state. nb := map[string]interface{}{ "name": b.Name, "address": b.Address, "auto_loadbalance": b.AutoLoadbalance, "between_bytes_timeout": int(b.BetweenBytesTimeout), "connect_timeout": int(b.ConnectTimeout), "error_threshold": int(b.ErrorThreshold), "first_byte_timeout": int(b.FirstByteTimeout), "max_conn": int(b.MaxConn), "port": int(b.Port), "ssl_check_cert": b.SSLCheckCert, "weight": int(b.Weight), } bl = append(bl, nb) } return bl } // findService finds a Fastly Service via the ListServices endpoint, returning // the Service if found. // // Fastly API does not include any "deleted_at" type parameter to indicate // that a Service has been deleted. GET requests to a deleted Service will // return 200 OK and have the full output of the Service for an unknown time // (days, in my testing). In order to determine if a Service is deleted, we // need to hit /service and loop the returned Services, searching for the one // in question. This endpoint only returns active or "alive" services. If the // Service is not included, then it's "gone" // // Returns a fastlyNoServiceFoundErr error if the Service is not found in the // ListServices response. func findService(id string, meta interface{}) (*gofastly.Service, error) { conn := meta.(*FastlyClient).conn l, err := conn.ListServices(&gofastly.ListServicesInput{}) if err != nil { return nil, fmt.Errorf("[WARN] Error listing services when deleting Fastly Service (%s): %s", id, err) } for _, s := range l { if s.ID == id { log.Printf("[DEBUG] Found Service (%s)", id) return s, nil } } 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 }