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