github.com/anfernee/terraform@v0.6.16-0.20160430000239-06e5085a92f2/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 its presence.
    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  	// If we don't have a zone ID we're doing an import. Parse it from the ID.
   250  	if _, ok := d.GetOk("zone_id"); !ok {
   251  		parts := strings.Split(d.Id(), "_")
   252  		d.Set("zone_id", parts[0])
   253  		d.Set("name", parts[1])
   254  		d.Set("type", parts[2])
   255  		if len(parts) > 3 {
   256  			d.Set("set_identifier", parts[3])
   257  		}
   258  
   259  		d.Set("weight", -1)
   260  	}
   261  
   262  	record, err := findRecord(d, meta)
   263  	if err != nil {
   264  		switch err {
   265  		case r53NoHostedZoneFound, r53NoRecordsFound:
   266  			log.Printf("[DEBUG] %s for: %s, removing from state file", err, d.Id())
   267  			d.SetId("")
   268  			return nil
   269  		default:
   270  			return err
   271  		}
   272  	}
   273  
   274  	err = d.Set("records", flattenResourceRecords(record.ResourceRecords))
   275  	if err != nil {
   276  		return fmt.Errorf("[DEBUG] Error setting records for: %s, error: %#v", d.Id(), err)
   277  	}
   278  
   279  	if alias := record.AliasTarget; alias != nil {
   280  		if _, ok := d.GetOk("alias"); !ok {
   281  			d.Set("alias", []interface{}{
   282  				map[string]interface{}{
   283  					"zone_id": *alias.HostedZoneId,
   284  					"name":    *alias.DNSName,
   285  					"evaluate_target_health": *alias.EvaluateTargetHealth,
   286  				},
   287  			})
   288  		}
   289  	}
   290  
   291  	d.Set("ttl", record.TTL)
   292  	// Only set the weight if it's non-nil, otherwise we end up with a 0 weight
   293  	// which has actual contextual meaning with Route 53 records
   294  	//   See http://docs.aws.amazon.com/fr_fr/Route53/latest/APIReference/API_ChangeResourceRecordSets_Examples.html
   295  	if record.Weight != nil {
   296  		d.Set("weight", record.Weight)
   297  	}
   298  	d.Set("set_identifier", record.SetIdentifier)
   299  	d.Set("failover", record.Failover)
   300  	d.Set("health_check_id", record.HealthCheckId)
   301  
   302  	return nil
   303  }
   304  
   305  // findRecord takes a ResourceData struct for aws_resource_route53_record. It
   306  // uses the referenced zone_id to query Route53 and find information on it's
   307  // records.
   308  //
   309  // If records are found, it returns the matching
   310  // route53.ResourceRecordSet and nil for the error.
   311  //
   312  // If no hosted zone is found, it returns a nil recordset and r53NoHostedZoneFound
   313  // error.
   314  //
   315  // If no matching recordset is found, it returns nil and a r53NoRecordsFound
   316  // error
   317  //
   318  // If there are other errors, it returns nil a nil recordset and passes on the
   319  // error.
   320  func findRecord(d *schema.ResourceData, meta interface{}) (*route53.ResourceRecordSet, error) {
   321  	conn := meta.(*AWSClient).r53conn
   322  	// Scan for a
   323  	zone := cleanZoneID(d.Get("zone_id").(string))
   324  
   325  	// get expanded name
   326  	zoneRecord, err := conn.GetHostedZone(&route53.GetHostedZoneInput{Id: aws.String(zone)})
   327  	if err != nil {
   328  		if r53err, ok := err.(awserr.Error); ok && r53err.Code() == "NoSuchHostedZone" {
   329  			return nil, r53NoHostedZoneFound
   330  		}
   331  		return nil, err
   332  	}
   333  	en := expandRecordName(d.Get("name").(string), *zoneRecord.HostedZone.Name)
   334  	log.Printf("[DEBUG] Expanded record name: %s", en)
   335  	d.Set("fqdn", en)
   336  
   337  	lopts := &route53.ListResourceRecordSetsInput{
   338  		HostedZoneId:    aws.String(cleanZoneID(zone)),
   339  		StartRecordName: aws.String(en),
   340  		StartRecordType: aws.String(d.Get("type").(string)),
   341  	}
   342  
   343  	log.Printf("[DEBUG] List resource records sets for zone: %s, opts: %s",
   344  		zone, lopts)
   345  	resp, err := conn.ListResourceRecordSets(lopts)
   346  	if err != nil {
   347  		return nil, err
   348  	}
   349  
   350  	for _, record := range resp.ResourceRecordSets {
   351  		name := cleanRecordName(*record.Name)
   352  		if FQDN(strings.ToLower(name)) != FQDN(strings.ToLower(*lopts.StartRecordName)) {
   353  			continue
   354  		}
   355  		if strings.ToUpper(*record.Type) != strings.ToUpper(*lopts.StartRecordType) {
   356  			continue
   357  		}
   358  
   359  		if record.SetIdentifier != nil && *record.SetIdentifier != d.Get("set_identifier") {
   360  			continue
   361  		}
   362  		// The only safe return where a record is found
   363  		return record, nil
   364  	}
   365  	return nil, r53NoRecordsFound
   366  }
   367  
   368  func resourceAwsRoute53RecordDelete(d *schema.ResourceData, meta interface{}) error {
   369  	conn := meta.(*AWSClient).r53conn
   370  	// Get the records
   371  	rec, err := findRecord(d, meta)
   372  	if err != nil {
   373  		switch err {
   374  		case r53NoHostedZoneFound, r53NoRecordsFound:
   375  			log.Printf("[DEBUG] %s for: %s, removing from state file", err, d.Id())
   376  			d.SetId("")
   377  			return nil
   378  		default:
   379  			return err
   380  		}
   381  	}
   382  
   383  	// Change batch for deleting
   384  	changeBatch := &route53.ChangeBatch{
   385  		Comment: aws.String("Deleted by Terraform"),
   386  		Changes: []*route53.Change{
   387  			&route53.Change{
   388  				Action:            aws.String("DELETE"),
   389  				ResourceRecordSet: rec,
   390  			},
   391  		},
   392  	}
   393  
   394  	zone := cleanZoneID(d.Get("zone_id").(string))
   395  
   396  	req := &route53.ChangeResourceRecordSetsInput{
   397  		HostedZoneId: aws.String(cleanZoneID(zone)),
   398  		ChangeBatch:  changeBatch,
   399  	}
   400  
   401  	wait := resource.StateChangeConf{
   402  		Pending:    []string{"rejected"},
   403  		Target:     []string{"accepted"},
   404  		Timeout:    5 * time.Minute,
   405  		MinTimeout: 1 * time.Second,
   406  		Refresh: func() (interface{}, string, error) {
   407  			_, err := conn.ChangeResourceRecordSets(req)
   408  			if err != nil {
   409  				if r53err, ok := err.(awserr.Error); ok {
   410  					if r53err.Code() == "PriorRequestNotComplete" {
   411  						// There is some pending operation, so just retry
   412  						// in a bit.
   413  						return 42, "rejected", nil
   414  					}
   415  
   416  					if r53err.Code() == "InvalidChangeBatch" {
   417  						// This means that the record is already gone.
   418  						return 42, "accepted", nil
   419  					}
   420  				}
   421  
   422  				return 42, "failure", err
   423  			}
   424  
   425  			return 42, "accepted", nil
   426  		},
   427  	}
   428  
   429  	if _, err := wait.WaitForState(); err != nil {
   430  		return err
   431  	}
   432  
   433  	return nil
   434  }
   435  
   436  func resourceAwsRoute53RecordBuildSet(d *schema.ResourceData, zoneName string) (*route53.ResourceRecordSet, error) {
   437  	// get expanded name
   438  	en := expandRecordName(d.Get("name").(string), zoneName)
   439  
   440  	// Create the RecordSet request with the fully expanded name, e.g.
   441  	// sub.domain.com. Route 53 requires a fully qualified domain name, but does
   442  	// not require the trailing ".", which it will itself, so we don't call FQDN
   443  	// here.
   444  	rec := &route53.ResourceRecordSet{
   445  		Name: aws.String(en),
   446  		Type: aws.String(d.Get("type").(string)),
   447  	}
   448  
   449  	if v, ok := d.GetOk("ttl"); ok {
   450  		rec.TTL = aws.Int64(int64(v.(int)))
   451  	}
   452  
   453  	// Resource records
   454  	if v, ok := d.GetOk("records"); ok {
   455  		recs := v.(*schema.Set).List()
   456  		rec.ResourceRecords = expandResourceRecords(recs, d.Get("type").(string))
   457  	}
   458  
   459  	// Alias record
   460  	if v, ok := d.GetOk("alias"); ok {
   461  		aliases := v.(*schema.Set).List()
   462  		if len(aliases) > 1 {
   463  			return nil, fmt.Errorf("You can only define a single alias target per record")
   464  		}
   465  		alias := aliases[0].(map[string]interface{})
   466  		rec.AliasTarget = &route53.AliasTarget{
   467  			DNSName:              aws.String(alias["name"].(string)),
   468  			EvaluateTargetHealth: aws.Bool(alias["evaluate_target_health"].(bool)),
   469  			HostedZoneId:         aws.String(alias["zone_id"].(string)),
   470  		}
   471  		log.Printf("[DEBUG] Creating alias: %#v", alias)
   472  	} else {
   473  		if _, ok := d.GetOk("ttl"); !ok {
   474  			return nil, fmt.Errorf(`provider.aws: aws_route53_record: %s: "ttl": required field is not set`, d.Get("name").(string))
   475  		}
   476  
   477  		if _, ok := d.GetOk("records"); !ok {
   478  			return nil, fmt.Errorf(`provider.aws: aws_route53_record: %s: "records": required field is not set`, d.Get("name").(string))
   479  		}
   480  	}
   481  
   482  	if v, ok := d.GetOk("failover"); ok {
   483  		if _, ok := d.GetOk("set_identifier"); !ok {
   484  			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))
   485  		}
   486  		rec.Failover = aws.String(v.(string))
   487  	}
   488  
   489  	if v, ok := d.GetOk("health_check_id"); ok {
   490  		rec.HealthCheckId = aws.String(v.(string))
   491  	}
   492  
   493  	if v, ok := d.GetOk("set_identifier"); ok {
   494  		rec.SetIdentifier = aws.String(v.(string))
   495  	}
   496  
   497  	w := d.Get("weight").(int)
   498  	if w > -1 {
   499  		if _, ok := d.GetOk("set_identifier"); !ok {
   500  			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))
   501  		}
   502  		rec.Weight = aws.Int64(int64(w))
   503  	}
   504  
   505  	return rec, nil
   506  }
   507  
   508  func FQDN(name string) string {
   509  	n := len(name)
   510  	if n == 0 || name[n-1] == '.' {
   511  		return name
   512  	} else {
   513  		return name + "."
   514  	}
   515  }
   516  
   517  // Route 53 stores the "*" wildcard indicator as ASCII 42 and returns the
   518  // octal equivalent, "\\052". Here we look for that, and convert back to "*"
   519  // as needed.
   520  func cleanRecordName(name string) string {
   521  	str := name
   522  	if strings.HasPrefix(name, "\\052") {
   523  		str = strings.Replace(name, "\\052", "*", 1)
   524  		log.Printf("[DEBUG] Replacing octal \\052 for * in: %s", name)
   525  	}
   526  	return str
   527  }
   528  
   529  // Check if the current record name contains the zone suffix.
   530  // If it does not, add the zone name to form a fully qualified name
   531  // and keep AWS happy.
   532  func expandRecordName(name, zone string) string {
   533  	rn := strings.ToLower(strings.TrimSuffix(name, "."))
   534  	zone = strings.TrimSuffix(zone, ".")
   535  	if !strings.HasSuffix(rn, zone) {
   536  		rn = strings.Join([]string{name, zone}, ".")
   537  	}
   538  	return rn
   539  }
   540  
   541  func resourceAwsRoute53AliasRecordHash(v interface{}) int {
   542  	var buf bytes.Buffer
   543  	m := v.(map[string]interface{})
   544  	buf.WriteString(fmt.Sprintf("%s-", m["name"].(string)))
   545  	buf.WriteString(fmt.Sprintf("%s-", m["zone_id"].(string)))
   546  	buf.WriteString(fmt.Sprintf("%t-", m["evaluate_target_health"].(bool)))
   547  
   548  	return hashcode.String(buf.String())
   549  }