package fastly import ( "errors" "fmt" "log" "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, }, }, } } 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"} { 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 } } } // 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 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 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) } } 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 servcies 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 }