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 }