github.com/leeprovoost/terraform@v0.6.10-0.20160119085442-96f3f76118e7/builtin/providers/aws/resource_aws_route53_record.go (about)

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