github.com/ricardclau/terraform@v0.6.17-0.20160519222547-283e3ae6b5a9/builtin/providers/aws/resource_aws_route53_record.go (about)

     1  package aws
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"log"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/hashicorp/terraform/helper/hashcode"
    12  	"github.com/hashicorp/terraform/helper/resource"
    13  	"github.com/hashicorp/terraform/helper/schema"
    14  
    15  	"github.com/aws/aws-sdk-go/aws"
    16  	"github.com/aws/aws-sdk-go/aws/awserr"
    17  	"github.com/aws/aws-sdk-go/service/route53"
    18  )
    19  
    20  var r53NoRecordsFound = errors.New("No matching Hosted Zone found")
    21  var r53NoHostedZoneFound = errors.New("No matching records found")
    22  
    23  func resourceAwsRoute53Record() *schema.Resource {
    24  	return &schema.Resource{
    25  		Create: resourceAwsRoute53RecordCreate,
    26  		Read:   resourceAwsRoute53RecordRead,
    27  		Update: resourceAwsRoute53RecordUpdate,
    28  		Delete: resourceAwsRoute53RecordDelete,
    29  
    30  		SchemaVersion: 1,
    31  		MigrateState:  resourceAwsRoute53RecordMigrateState,
    32  
    33  		Schema: map[string]*schema.Schema{
    34  			"name": &schema.Schema{
    35  				Type:     schema.TypeString,
    36  				Required: true,
    37  				ForceNew: true,
    38  				StateFunc: func(v interface{}) string {
    39  					value := strings.TrimSuffix(v.(string), ".")
    40  					return strings.ToLower(value)
    41  				},
    42  			},
    43  
    44  			"fqdn": &schema.Schema{
    45  				Type:     schema.TypeString,
    46  				Computed: true,
    47  			},
    48  
    49  			"type": &schema.Schema{
    50  				Type:     schema.TypeString,
    51  				Required: true,
    52  				ForceNew: true,
    53  			},
    54  
    55  			"zone_id": &schema.Schema{
    56  				Type:     schema.TypeString,
    57  				Required: true,
    58  				ForceNew: true,
    59  				ValidateFunc: func(v interface{}, k string) (ws []string, es []error) {
    60  					value := v.(string)
    61  					if value == "" {
    62  						es = append(es, fmt.Errorf("Cannot have empty zone_id"))
    63  					}
    64  					return
    65  				},
    66  			},
    67  
    68  			"ttl": &schema.Schema{
    69  				Type:          schema.TypeInt,
    70  				Optional:      true,
    71  				ConflictsWith: []string{"alias"},
    72  			},
    73  
    74  			// Weight uses a special sentinel value to indicate its presence.
    75  			// Because 0 is a valid value for Weight, we default to -1 so that any
    76  			// inclusion of a weight (zero or not) will be a usable value
    77  			"weight": &schema.Schema{
    78  				Type:     schema.TypeInt,
    79  				Optional: true,
    80  				Default:  -1,
    81  			},
    82  
    83  			"set_identifier": &schema.Schema{
    84  				Type:     schema.TypeString,
    85  				Optional: true,
    86  				ForceNew: true,
    87  			},
    88  
    89  			"alias": &schema.Schema{
    90  				Type:          schema.TypeSet,
    91  				Optional:      true,
    92  				ConflictsWith: []string{"records", "ttl"},
    93  				Elem: &schema.Resource{
    94  					Schema: map[string]*schema.Schema{
    95  						"zone_id": &schema.Schema{
    96  							Type:     schema.TypeString,
    97  							Required: true,
    98  						},
    99  
   100  						"name": &schema.Schema{
   101  							Type:     schema.TypeString,
   102  							Required: true,
   103  						},
   104  
   105  						"evaluate_target_health": &schema.Schema{
   106  							Type:     schema.TypeBool,
   107  							Required: true,
   108  						},
   109  					},
   110  				},
   111  				Set: resourceAwsRoute53AliasRecordHash,
   112  			},
   113  
   114  			"failover": &schema.Schema{ // PRIMARY | SECONDARY
   115  				Type:     schema.TypeString,
   116  				Optional: true,
   117  			},
   118  
   119  			"health_check_id": &schema.Schema{ // ID of health check
   120  				Type:     schema.TypeString,
   121  				Optional: true,
   122  			},
   123  
   124  			"records": &schema.Schema{
   125  				Type:          schema.TypeSet,
   126  				ConflictsWith: []string{"alias"},
   127  				Elem:          &schema.Schema{Type: schema.TypeString},
   128  				Optional:      true,
   129  				Set:           schema.HashString,
   130  			},
   131  		},
   132  	}
   133  }
   134  
   135  func resourceAwsRoute53RecordUpdate(d *schema.ResourceData, meta interface{}) error {
   136  	// Route 53 supports CREATE, DELETE, and UPSERT actions. We use UPSERT, and
   137  	// AWS dynamically determines if a record should be created or updated.
   138  	// Amazon Route 53 can update an existing resource record set only when all
   139  	// of the following values match: Name, Type
   140  	// (and SetIdentifier, which we don't use yet).
   141  	// See http://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeResourceRecordSets_Requests.html#change-rrsets-request-action
   142  	//
   143  	// Because we use UPSERT, for resouce update here we simply fall through to
   144  	// our resource create function.
   145  	return resourceAwsRoute53RecordCreate(d, meta)
   146  }
   147  
   148  func resourceAwsRoute53RecordCreate(d *schema.ResourceData, meta interface{}) error {
   149  	conn := meta.(*AWSClient).r53conn
   150  	zone := cleanZoneID(d.Get("zone_id").(string))
   151  
   152  	var err error
   153  	zoneRecord, err := conn.GetHostedZone(&route53.GetHostedZoneInput{Id: aws.String(zone)})
   154  	if err != nil {
   155  		return err
   156  	}
   157  	if zoneRecord.HostedZone == nil {
   158  		return fmt.Errorf("[WARN] No Route53 Zone found for id (%s)", zone)
   159  	}
   160  
   161  	// Build the record
   162  	rec, err := resourceAwsRoute53RecordBuildSet(d, *zoneRecord.HostedZone.Name)
   163  	if err != nil {
   164  		return err
   165  	}
   166  
   167  	// Create the new records. We abuse StateChangeConf for this to
   168  	// retry for us since Route53 sometimes returns errors about another
   169  	// operation happening at the same time.
   170  	changeBatch := &route53.ChangeBatch{
   171  		Comment: aws.String("Managed by Terraform"),
   172  		Changes: []*route53.Change{
   173  			&route53.Change{
   174  				Action:            aws.String("UPSERT"),
   175  				ResourceRecordSet: rec,
   176  			},
   177  		},
   178  	}
   179  
   180  	req := &route53.ChangeResourceRecordSetsInput{
   181  		HostedZoneId: aws.String(cleanZoneID(*zoneRecord.HostedZone.Id)),
   182  		ChangeBatch:  changeBatch,
   183  	}
   184  
   185  	log.Printf("[DEBUG] Creating resource records for zone: %s, name: %s\n\n%s",
   186  		zone, *rec.Name, req)
   187  
   188  	wait := resource.StateChangeConf{
   189  		Pending:    []string{"rejected"},
   190  		Target:     []string{"accepted"},
   191  		Timeout:    5 * time.Minute,
   192  		MinTimeout: 1 * time.Second,
   193  		Refresh: func() (interface{}, string, error) {
   194  			resp, err := conn.ChangeResourceRecordSets(req)
   195  			if err != nil {
   196  				if r53err, ok := err.(awserr.Error); ok {
   197  					if r53err.Code() == "PriorRequestNotComplete" {
   198  						// There is some pending operation, so just retry
   199  						// in a bit.
   200  						return nil, "rejected", nil
   201  					}
   202  				}
   203  
   204  				return nil, "failure", err
   205  			}
   206  
   207  			return resp, "accepted", nil
   208  		},
   209  	}
   210  
   211  	respRaw, err := wait.WaitForState()
   212  	if err != nil {
   213  		return err
   214  	}
   215  	changeInfo := respRaw.(*route53.ChangeResourceRecordSetsOutput).ChangeInfo
   216  
   217  	// Generate an ID
   218  	vars := []string{
   219  		zone,
   220  		strings.ToLower(d.Get("name").(string)),
   221  		d.Get("type").(string),
   222  	}
   223  	if v, ok := d.GetOk("set_identifier"); ok {
   224  		vars = append(vars, v.(string))
   225  	}
   226  
   227  	d.SetId(strings.Join(vars, "_"))
   228  
   229  	// Wait until we are done
   230  	wait = resource.StateChangeConf{
   231  		Delay:      30 * time.Second,
   232  		Pending:    []string{"PENDING"},
   233  		Target:     []string{"INSYNC"},
   234  		Timeout:    30 * time.Minute,
   235  		MinTimeout: 5 * time.Second,
   236  		Refresh: func() (result interface{}, state string, err error) {
   237  			changeRequest := &route53.GetChangeInput{
   238  				Id: aws.String(cleanChangeID(*changeInfo.Id)),
   239  			}
   240  			return resourceAwsGoRoute53Wait(conn, changeRequest)
   241  		},
   242  	}
   243  	_, err = wait.WaitForState()
   244  	if err != nil {
   245  		return err
   246  	}
   247  
   248  	return resourceAwsRoute53RecordRead(d, meta)
   249  }
   250  
   251  func resourceAwsRoute53RecordRead(d *schema.ResourceData, meta interface{}) error {
   252  	// If we don't have a zone ID we're doing an import. Parse it from the ID.
   253  	if _, ok := d.GetOk("zone_id"); !ok {
   254  		parts := strings.Split(d.Id(), "_")
   255  		d.Set("zone_id", parts[0])
   256  		d.Set("name", parts[1])
   257  		d.Set("type", parts[2])
   258  		if len(parts) > 3 {
   259  			d.Set("set_identifier", parts[3])
   260  		}
   261  
   262  		d.Set("weight", -1)
   263  	}
   264  
   265  	record, err := findRecord(d, meta)
   266  	if err != nil {
   267  		switch err {
   268  		case r53NoHostedZoneFound, r53NoRecordsFound:
   269  			log.Printf("[DEBUG] %s for: %s, removing from state file", err, d.Id())
   270  			d.SetId("")
   271  			return nil
   272  		default:
   273  			return err
   274  		}
   275  	}
   276  
   277  	err = d.Set("records", flattenResourceRecords(record.ResourceRecords))
   278  	if err != nil {
   279  		return fmt.Errorf("[DEBUG] Error setting records for: %s, error: %#v", d.Id(), err)
   280  	}
   281  
   282  	if alias := record.AliasTarget; alias != nil {
   283  		if _, ok := d.GetOk("alias"); !ok {
   284  			d.Set("alias", []interface{}{
   285  				map[string]interface{}{
   286  					"zone_id": *alias.HostedZoneId,
   287  					"name":    *alias.DNSName,
   288  					"evaluate_target_health": *alias.EvaluateTargetHealth,
   289  				},
   290  			})
   291  		}
   292  	}
   293  
   294  	d.Set("ttl", record.TTL)
   295  	// Only set the weight if it's non-nil, otherwise we end up with a 0 weight
   296  	// which has actual contextual meaning with Route 53 records
   297  	//   See http://docs.aws.amazon.com/fr_fr/Route53/latest/APIReference/API_ChangeResourceRecordSets_Examples.html
   298  	if record.Weight != nil {
   299  		d.Set("weight", record.Weight)
   300  	}
   301  	d.Set("set_identifier", record.SetIdentifier)
   302  	d.Set("failover", record.Failover)
   303  	d.Set("health_check_id", record.HealthCheckId)
   304  
   305  	return nil
   306  }
   307  
   308  // findRecord takes a ResourceData struct for aws_resource_route53_record. It
   309  // uses the referenced zone_id to query Route53 and find information on it's
   310  // records.
   311  //
   312  // If records are found, it returns the matching
   313  // route53.ResourceRecordSet and nil for the error.
   314  //
   315  // If no hosted zone is found, it returns a nil recordset and r53NoHostedZoneFound
   316  // error.
   317  //
   318  // If no matching recordset is found, it returns nil and a r53NoRecordsFound
   319  // error
   320  //
   321  // If there are other errors, it returns nil a nil recordset and passes on the
   322  // error.
   323  func findRecord(d *schema.ResourceData, meta interface{}) (*route53.ResourceRecordSet, error) {
   324  	conn := meta.(*AWSClient).r53conn
   325  	// Scan for a
   326  	zone := cleanZoneID(d.Get("zone_id").(string))
   327  
   328  	// get expanded name
   329  	zoneRecord, err := conn.GetHostedZone(&route53.GetHostedZoneInput{Id: aws.String(zone)})
   330  	if err != nil {
   331  		if r53err, ok := err.(awserr.Error); ok && r53err.Code() == "NoSuchHostedZone" {
   332  			return nil, r53NoHostedZoneFound
   333  		}
   334  		return nil, err
   335  	}
   336  
   337  	en := expandRecordName(d.Get("name").(string), *zoneRecord.HostedZone.Name)
   338  	log.Printf("[DEBUG] Expanded record name: %s", en)
   339  	d.Set("fqdn", en)
   340  
   341  	lopts := &route53.ListResourceRecordSetsInput{
   342  		HostedZoneId:    aws.String(cleanZoneID(zone)),
   343  		StartRecordName: aws.String(en),
   344  		StartRecordType: aws.String(d.Get("type").(string)),
   345  	}
   346  
   347  	log.Printf("[DEBUG] List resource records sets for zone: %s, opts: %s",
   348  		zone, lopts)
   349  	resp, err := conn.ListResourceRecordSets(lopts)
   350  	if err != nil {
   351  		return nil, err
   352  	}
   353  
   354  	for _, record := range resp.ResourceRecordSets {
   355  		name := cleanRecordName(*record.Name)
   356  		if FQDN(strings.ToLower(name)) != FQDN(strings.ToLower(*lopts.StartRecordName)) {
   357  			continue
   358  		}
   359  		if strings.ToUpper(*record.Type) != strings.ToUpper(*lopts.StartRecordType) {
   360  			continue
   361  		}
   362  
   363  		if record.SetIdentifier != nil && *record.SetIdentifier != d.Get("set_identifier") {
   364  			continue
   365  		}
   366  		// The only safe return where a record is found
   367  		return record, nil
   368  	}
   369  	return nil, r53NoRecordsFound
   370  }
   371  
   372  func resourceAwsRoute53RecordDelete(d *schema.ResourceData, meta interface{}) error {
   373  	conn := meta.(*AWSClient).r53conn
   374  	// Get the records
   375  	rec, err := findRecord(d, meta)
   376  	if err != nil {
   377  		switch err {
   378  		case r53NoHostedZoneFound, r53NoRecordsFound:
   379  			log.Printf("[DEBUG] %s for: %s, removing from state file", err, d.Id())
   380  			d.SetId("")
   381  			return nil
   382  		default:
   383  			return err
   384  		}
   385  	}
   386  
   387  	// Change batch for deleting
   388  	changeBatch := &route53.ChangeBatch{
   389  		Comment: aws.String("Deleted by Terraform"),
   390  		Changes: []*route53.Change{
   391  			&route53.Change{
   392  				Action:            aws.String("DELETE"),
   393  				ResourceRecordSet: rec,
   394  			},
   395  		},
   396  	}
   397  
   398  	zone := cleanZoneID(d.Get("zone_id").(string))
   399  
   400  	req := &route53.ChangeResourceRecordSetsInput{
   401  		HostedZoneId: aws.String(cleanZoneID(zone)),
   402  		ChangeBatch:  changeBatch,
   403  	}
   404  
   405  	wait := resource.StateChangeConf{
   406  		Pending:    []string{"rejected"},
   407  		Target:     []string{"accepted"},
   408  		Timeout:    5 * time.Minute,
   409  		MinTimeout: 1 * time.Second,
   410  		Refresh: func() (interface{}, string, error) {
   411  			_, err := conn.ChangeResourceRecordSets(req)
   412  			if err != nil {
   413  				if r53err, ok := err.(awserr.Error); ok {
   414  					if r53err.Code() == "PriorRequestNotComplete" {
   415  						// There is some pending operation, so just retry
   416  						// in a bit.
   417  						return 42, "rejected", nil
   418  					}
   419  
   420  					if r53err.Code() == "InvalidChangeBatch" {
   421  						// This means that the record is already gone.
   422  						return 42, "accepted", nil
   423  					}
   424  				}
   425  
   426  				return 42, "failure", err
   427  			}
   428  
   429  			return 42, "accepted", nil
   430  		},
   431  	}
   432  
   433  	if _, err := wait.WaitForState(); err != nil {
   434  		return err
   435  	}
   436  
   437  	return nil
   438  }
   439  
   440  func resourceAwsRoute53RecordBuildSet(d *schema.ResourceData, zoneName string) (*route53.ResourceRecordSet, error) {
   441  	// get expanded name
   442  	en := expandRecordName(d.Get("name").(string), zoneName)
   443  
   444  	// Create the RecordSet request with the fully expanded name, e.g.
   445  	// sub.domain.com. Route 53 requires a fully qualified domain name, but does
   446  	// not require the trailing ".", which it will itself, so we don't call FQDN
   447  	// here.
   448  	rec := &route53.ResourceRecordSet{
   449  		Name: aws.String(en),
   450  		Type: aws.String(d.Get("type").(string)),
   451  	}
   452  
   453  	if v, ok := d.GetOk("ttl"); ok {
   454  		rec.TTL = aws.Int64(int64(v.(int)))
   455  	}
   456  
   457  	// Resource records
   458  	if v, ok := d.GetOk("records"); ok {
   459  		recs := v.(*schema.Set).List()
   460  		rec.ResourceRecords = expandResourceRecords(recs, d.Get("type").(string))
   461  	}
   462  
   463  	// Alias record
   464  	if v, ok := d.GetOk("alias"); ok {
   465  		aliases := v.(*schema.Set).List()
   466  		if len(aliases) > 1 {
   467  			return nil, fmt.Errorf("You can only define a single alias target per record")
   468  		}
   469  		alias := aliases[0].(map[string]interface{})
   470  		rec.AliasTarget = &route53.AliasTarget{
   471  			DNSName:              aws.String(alias["name"].(string)),
   472  			EvaluateTargetHealth: aws.Bool(alias["evaluate_target_health"].(bool)),
   473  			HostedZoneId:         aws.String(alias["zone_id"].(string)),
   474  		}
   475  		log.Printf("[DEBUG] Creating alias: %#v", alias)
   476  	} else {
   477  		if _, ok := d.GetOk("ttl"); !ok {
   478  			return nil, fmt.Errorf(`provider.aws: aws_route53_record: %s: "ttl": required field is not set`, d.Get("name").(string))
   479  		}
   480  
   481  		if _, ok := d.GetOk("records"); !ok {
   482  			return nil, fmt.Errorf(`provider.aws: aws_route53_record: %s: "records": required field is not set`, d.Get("name").(string))
   483  		}
   484  	}
   485  
   486  	if v, ok := d.GetOk("failover"); ok {
   487  		if _, ok := d.GetOk("set_identifier"); !ok {
   488  			return nil, fmt.Errorf(`provider.aws: aws_route53_record: %s: "set_identifier": required field is not set when "failover" is set`, d.Get("name").(string))
   489  		}
   490  		rec.Failover = aws.String(v.(string))
   491  	}
   492  
   493  	if v, ok := d.GetOk("health_check_id"); ok {
   494  		rec.HealthCheckId = aws.String(v.(string))
   495  	}
   496  
   497  	if v, ok := d.GetOk("set_identifier"); ok {
   498  		rec.SetIdentifier = aws.String(v.(string))
   499  	}
   500  
   501  	w := d.Get("weight").(int)
   502  	if w > -1 {
   503  		if _, ok := d.GetOk("set_identifier"); !ok {
   504  			return nil, fmt.Errorf(`provider.aws: aws_route53_record: %s: "set_identifier": required field is not set when "weight" is set`, d.Get("name").(string))
   505  		}
   506  		rec.Weight = aws.Int64(int64(w))
   507  	}
   508  
   509  	return rec, nil
   510  }
   511  
   512  func FQDN(name string) string {
   513  	n := len(name)
   514  	if n == 0 || name[n-1] == '.' {
   515  		return name
   516  	} else {
   517  		return name + "."
   518  	}
   519  }
   520  
   521  // Route 53 stores the "*" wildcard indicator as ASCII 42 and returns the
   522  // octal equivalent, "\\052". Here we look for that, and convert back to "*"
   523  // as needed.
   524  func cleanRecordName(name string) string {
   525  	str := name
   526  	if strings.HasPrefix(name, "\\052") {
   527  		str = strings.Replace(name, "\\052", "*", 1)
   528  		log.Printf("[DEBUG] Replacing octal \\052 for * in: %s", name)
   529  	}
   530  	return str
   531  }
   532  
   533  // Check if the current record name contains the zone suffix.
   534  // If it does not, add the zone name to form a fully qualified name
   535  // and keep AWS happy.
   536  func expandRecordName(name, zone string) string {
   537  	rn := strings.ToLower(strings.TrimSuffix(name, "."))
   538  	zone = strings.TrimSuffix(zone, ".")
   539  	if !strings.HasSuffix(rn, zone) {
   540  		rn = strings.Join([]string{name, zone}, ".")
   541  	}
   542  	return rn
   543  }
   544  
   545  func resourceAwsRoute53AliasRecordHash(v interface{}) int {
   546  	var buf bytes.Buffer
   547  	m := v.(map[string]interface{})
   548  	buf.WriteString(fmt.Sprintf("%s-", m["name"].(string)))
   549  	buf.WriteString(fmt.Sprintf("%s-", m["zone_id"].(string)))
   550  	buf.WriteString(fmt.Sprintf("%t-", m["evaluate_target_health"].(bool)))
   551  
   552  	return hashcode.String(buf.String())
   553  }