summaryrefslogtreecommitdiff
path: root/cloud/amazon/route53.py
blob: 4f08a4f6543fb4c86f61e880997a6a465cb769f3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
#!/usr/bin/python
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.

DOCUMENTATION = '''
---
module: route53
version_added: "1.3"
short_description: add or delete entries in Amazons Route53 DNS service
description:
     - Creates and deletes DNS records in Amazons Route53 service
options:
  command:
    description:
      - Specifies the action to take.
    required: true
    choices: [ 'get', 'create', 'delete' ]
  zone:
    description:
      - The DNS zone to modify
    required: true
  hosted_zone_id:
    description:
      - The Hosted Zone ID of the DNS zone to modify
    required: false
    version_added: "2.0"
    default: null
  record:
    description:
      - The full DNS record to create or delete
    required: true
  ttl:
    description:
      - The TTL to give the new record
    required: false
    default: 3600 (one hour)
  type:
    description:
      - The type of DNS record to create
    required: true
    choices: [ 'A', 'CNAME', 'MX', 'AAAA', 'TXT', 'PTR', 'SRV', 'SPF', 'NS', 'SOA' ]
  alias:
    description:
      - Indicates if this is an alias record.
    required: false
    version_added: "1.9"
    default: False
    choices: [ 'True', 'False' ]
  alias_hosted_zone_id:
    description:
      - The hosted zone identifier.
    required: false
    version_added: "1.9"
    default: null
  alias_evaluate_target_health:
    description:
      - Whether or not to evaluate an alias target health. Useful for aliases to Elastic Load Balancers.
    required: false
    version_added: "2.1"
    default: false
  value:
    description:
      - The new value when creating a DNS record.  Multiple comma-spaced values are allowed for non-alias records.  When deleting a record all values for the record must be specified or Route53 will not delete it.
    required: false
    default: null
  overwrite:
    description:
      - Whether an existing record should be overwritten on create if values do not match
    required: false
    default: null
  retry_interval:
    description:
      - In the case that route53 is still servicing a prior request, this module will wait and try again after this many seconds. If you have many domain names, the default of 500 seconds may be too long.
    required: false
    default: 500
  private_zone:
    description:
      - If set to true, the private zone matching the requested name within the domain will be used if there are both public and private zones. The default is to use the public zone.
    required: false
    default: false
    version_added: "1.9"
  identifier:
    description:
      - Have to be specified for Weighted, latency-based and failover resource record sets only. An identifier
        that differentiates among multiple resource record sets that have the
        same combination of DNS name and type.
    required: false
    default: null
    version_added: "2.0"
  weight:
    description:
      - Weighted resource record sets only. Among resource record sets that
        have the same combination of DNS name and type, a value that
        determines what portion of traffic for the current resource record set
        is routed to the associated location.
    required: false
    default: null
    version_added: "2.0"
  region:
    description:
      - Latency-based resource record sets only Among resource record sets
        that have the same combination of DNS name and type, a value that
        determines which region this should be associated with for the
        latency-based routing
    required: false
    default: null
    version_added: "2.0"
  health_check:
    description:
      - Health check to associate with this record
    required: false
    default: null
    version_added: "2.0"
  failover:
    description:
      - Failover resource record sets only. Whether this is the primary or
        secondary resource record set. Allowed values are PRIMARY and SECONDARY
    required: false
    default: null
    version_added: "2.0"
  vpc_id:
    description:
      - "When used in conjunction with private_zone: true, this will only modify records in the private hosted zone attached to this VPC."
      - This allows you to have multiple private hosted zones, all with the same name, attached to different VPCs.
    required: false
    default: null
    version_added: "2.0"
  wait:
    description:
      - Wait until the changes have been replicated to all Amazon Route 53 DNS servers.
    required: false
    default: no
    version_added: "2.1"
  wait_timeout:
    description:
      - How long to wait for the changes to be replicated, in seconds.
    required: false
    default: 300
    version_added: "2.1"
author:
  - "Bruce Pennypacker (@bpennypacker)"
  - "Mike Buzzetti <mike.buzzetti@gmail.com>"
extends_documentation_fragment: aws
'''

# FIXME: the command stuff should have a more state like configuration alias -- MPD

EXAMPLES = '''
# Add new.foo.com as an A record with 3 IPs and wait until the changes have been replicated
- route53:
      command: create
      zone: foo.com
      record: new.foo.com
      type: A
      ttl: 7200
      value: 1.1.1.1,2.2.2.2,3.3.3.3
      wait: yes

# Retrieve the details for new.foo.com
- route53:
      command: get
      zone: foo.com
      record: new.foo.com
      type: A
  register: rec

# Delete new.foo.com A record using the results from the get command
- route53:
      command: delete
      zone: foo.com
      record: "{{ rec.set.record }}"
      ttl: "{{ rec.set.ttl }}"
      type: "{{ rec.set.type }}"
      value: "{{ rec.set.value }}"

# Add an AAAA record.  Note that because there are colons in the value
# that the entire parameter list must be quoted:
- route53:
      command: "create"
      zone: "foo.com"
      record: "localhost.foo.com"
      type: "AAAA"
      ttl: "7200"
      value: "::1"

# Add a SRV record with multiple fields for a service on port 22222
# For more information on SRV records see:
# https://en.wikipedia.org/wiki/SRV_record
- route53:
      command: "create"
      "zone": "foo.com"
      "record": "_example-service._tcp.foo.com"
      "type": "SRV"
      "value": ["0 0 22222 host1.foo.com", "0 0 22222 host2.foo.com"]

# Add a TXT record. Note that TXT and SPF records must be surrounded
# by quotes when sent to Route 53:
- route53:
      command: "create"
      zone: "foo.com"
      record: "localhost.foo.com"
      type: "TXT"
      ttl: "7200"
      value: '"bar"'

# Add an alias record that points to an Amazon ELB:
- route53:
    command: create
    zone: foo.com
    record: elb.foo.com
    type: A
    value: "{{ elb_dns_name }}"
    alias: True
    alias_hosted_zone_id: "{{ elb_zone_id }}"

# Retrieve the details for elb.foo.com
- route53:
      command: get
      zone: foo.com
      record: elb.foo.com
      type: A
  register: rec

# Delete an alias record using the results from the get command
- route53:
      command: delete
      zone: foo.com
      record: "{{ rec.set.record }}"
      ttl: "{{ rec.set.ttl }}"
      type: "{{ rec.set.type }}"
      value: "{{ rec.set.value }}"
      alias: True
      alias_hosted_zone_id: "{{ rec.set.alias_hosted_zone_id }}"

# Add an alias record that points to an Amazon ELB and evaluates it health:
- route53:
    command: create
    zone: foo.com
    record: elb.foo.com
    type: A
    value: "{{ elb_dns_name }}"
    alias: True
    alias_hosted_zone_id: "{{ elb_zone_id }}"
    alias_evaluate_target_health: True

# Add an AAAA record with Hosted Zone ID.  Note that because there are colons in the value
# that the entire parameter list must be quoted:
- route53:
      command: "create"
      zone: "foo.com"
      hosted_zone_id: "Z2AABBCCDDEEFF"
      record: "localhost.foo.com"
      type: "AAAA"
      ttl: "7200"
      value: "::1"

# Add an AAAA record with Hosted Zone ID.  Note that because there are colons in the value
# that the entire parameter list must be quoted:
- route53:
      command: "create"
      zone: "foo.com"
      hosted_zone_id: "Z2AABBCCDDEEFF"
      record: "localhost.foo.com"
      type: "AAAA"
      ttl: "7200"
      value: "::1"

# Use a routing policy to distribute traffic:
- route53:
      command: "create"
      zone: "foo.com"
      record: "www.foo.com"
      type: "CNAME"
      value: "host1.foo.com"
      ttl: 30
      # Routing policy
      identifier: "host1@www"
      weight: 100
      health_check: "d994b780-3150-49fd-9205-356abdd42e75"

'''

MINIMUM_BOTO_VERSION = '2.28.0'
WAIT_RETRY_SLEEP = 5  # how many seconds to wait between propagation status polls


import time
import distutils.version

try:
    import boto
    import boto.ec2
    from boto import route53
    from boto.route53 import Route53Connection
    from boto.route53.record import Record, ResourceRecordSets
    from boto.route53.status import Status
    HAS_BOTO = True
except ImportError:
    HAS_BOTO = False


class TimeoutError(Exception):
    pass


def get_zone_by_name(conn, module, zone_name, want_private, zone_id, want_vpc_id):
    """Finds a zone by name or zone_id"""
    for zone in conn.get_zones():
        # only save this zone id if the private status of the zone matches
        # the private_zone_in boolean specified in the params
        private_zone = module.boolean(zone.config.get('PrivateZone', False))
        if private_zone == want_private and ((zone.name == zone_name and zone_id == None) or zone.id.replace('/hostedzone/', '') == zone_id):
            if want_vpc_id:
                # NOTE: These details aren't available in other boto methods, hence the necessary
                # extra API call
                zone_details = conn.get_hosted_zone(zone.id)['GetHostedZoneResponse']
                # this is to deal with this boto bug: https://github.com/boto/boto/pull/2882
                if isinstance(zone_details['VPCs'], dict):
                    if zone_details['VPCs']['VPC']['VPCId'] == want_vpc_id:
                        return zone
                else: # Forward compatibility for when boto fixes that bug
                    if want_vpc_id in [v['VPCId'] for v in zone_details['VPCs']]:
                        return zone
            else:
                return zone
    return None


def commit(changes, retry_interval, wait, wait_timeout):
    """Commit changes, but retry PriorRequestNotComplete errors."""
    result = None
    retry = 10
    while True:
        try:
            retry -= 1
            result = changes.commit()
            break
        except boto.route53.exception.DNSServerError as e:
            code = e.body.split("<Code>")[1]
            code = code.split("</Code>")[0]
            if code != 'PriorRequestNotComplete' or retry < 0:
                raise e
            time.sleep(float(retry_interval))

    if wait:
        timeout_time = time.time() + wait_timeout
        connection = changes.connection
        change = result['ChangeResourceRecordSetsResponse']['ChangeInfo']
        status = Status(connection, change)
        while status.status != 'INSYNC' and time.time() < timeout_time:
            time.sleep(WAIT_RETRY_SLEEP)
            status.update()
        if time.time() >= timeout_time:
            raise TimeoutError()
        return result

# Shamelessly copied over from https://git.io/vgmDG
IGNORE_CODE = 'Throttling'
MAX_RETRIES=5
def invoke_with_throttling_retries(function_ref, *argv):
    retries=0
    while True:
        try:
            retval=function_ref(*argv)
            return retval
        except boto.exception.BotoServerError as e:
            if e.code != IGNORE_CODE or retries==MAX_RETRIES:
                raise e
        time.sleep(5 * (2**retries))
        retries += 1

def main():
    argument_spec = ec2_argument_spec()
    argument_spec.update(dict(
            command                      = dict(choices=['get', 'create', 'delete'], required=True),
            zone                         = dict(required=True),
            hosted_zone_id               = dict(required=False, default=None),
            record                       = dict(required=True),
            ttl                          = dict(required=False, type='int', default=3600),
            type                         = dict(choices=['A', 'CNAME', 'MX', 'AAAA', 'TXT', 'PTR', 'SRV', 'SPF', 'NS', 'SOA'], required=True),
            alias                        = dict(required=False, type='bool'),
            alias_hosted_zone_id         = dict(required=False),
            alias_evaluate_target_health = dict(required=False, type='bool', default=False),
            value                        = dict(required=False),
            overwrite                    = dict(required=False, type='bool'),
            retry_interval               = dict(required=False, default=500),
            private_zone                 = dict(required=False, type='bool', default=False),
            identifier                   = dict(required=False, default=None),
            weight                       = dict(required=False, type='int'),
            region                       = dict(required=False),
            health_check                 = dict(required=False),
            failover                     = dict(required=False,choices=['PRIMARY','SECONDARY']),
            vpc_id                       = dict(required=False),
            wait                         = dict(required=False, type='bool', default=False),
            wait_timeout                 = dict(required=False, type='int', default=300),
        )
    )
    module = AnsibleModule(argument_spec=argument_spec)

    if not HAS_BOTO:
        module.fail_json(msg='boto required for this module')

    if distutils.version.StrictVersion(boto.__version__) < distutils.version.StrictVersion(MINIMUM_BOTO_VERSION):
        module.fail_json(msg='Found boto in version %s, but >= %s is required' % (boto.__version__, MINIMUM_BOTO_VERSION))

    command_in                      = module.params.get('command')
    zone_in                         = module.params.get('zone').lower()
    hosted_zone_id_in               = module.params.get('hosted_zone_id')
    ttl_in                          = module.params.get('ttl')
    record_in                       = module.params.get('record').lower()
    type_in                         = module.params.get('type')
    value_in                        = module.params.get('value')
    alias_in                        = module.params.get('alias')
    alias_hosted_zone_id_in         = module.params.get('alias_hosted_zone_id')
    alias_evaluate_target_health_in = module.params.get('alias_evaluate_target_health')
    retry_interval_in               = module.params.get('retry_interval')
    private_zone_in                 = module.params.get('private_zone')
    identifier_in                   = module.params.get('identifier')
    weight_in                       = module.params.get('weight')
    region_in                       = module.params.get('region')
    health_check_in                 = module.params.get('health_check')
    failover_in                     = module.params.get('failover')
    vpc_id_in                       = module.params.get('vpc_id')
    wait_in                         = module.params.get('wait')
    wait_timeout_in                 = module.params.get('wait_timeout')

    region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module)

    value_list = ()

    if isinstance(value_in, str):
        if value_in:
            value_list = sorted([s.strip() for s in value_in.split(',')])
    elif isinstance(value_in, list):
        value_list = sorted(value_in)

    if zone_in[-1:] != '.':
        zone_in += "."

    if record_in[-1:] != '.':
        record_in += "."

    if command_in == 'create' or command_in == 'delete':
        if not value_in:
            module.fail_json(msg = "parameter 'value' required for create/delete")
        elif alias_in:
            if len(value_list) != 1:
                module.fail_json(msg = "parameter 'value' must contain a single dns name for alias create/delete")
            elif not alias_hosted_zone_id_in:
                module.fail_json(msg = "parameter 'alias_hosted_zone_id' required for alias create/delete")
        elif ( weight_in!=None or region_in!=None or failover_in!=None ) and identifier_in==None:
            module.fail_json(msg= "If you specify failover, region or weight you must also specify identifier")

    if command_in == 'create':
        if ( weight_in!=None or region_in!=None or failover_in!=None ) and identifier_in==None:
          module.fail_json(msg= "If you specify failover, region or weight you must also specify identifier")
        elif  ( weight_in==None and region_in==None and failover_in==None ) and identifier_in!=None:
          module.fail_json(msg= "You have specified identifier which makes sense only if you specify one of: weight, region or failover.")



    if vpc_id_in and not private_zone_in:
        module.fail_json(msg="parameter 'private_zone' must be true when specifying parameter"
            " 'vpc_id'")


    # connect to the route53 endpoint
    try:
        conn = Route53Connection(**aws_connect_kwargs)
    except boto.exception.BotoServerError as e:
        module.fail_json(msg = e.error_message)

    # Find the named zone ID
    zone = get_zone_by_name(conn, module, zone_in, private_zone_in, hosted_zone_id_in, vpc_id_in)

    # Verify that the requested zone is already defined in Route53
    if zone is None:
        errmsg = "Zone %s does not exist in Route53" % zone_in
        module.fail_json(msg = errmsg)

    record = {}

    found_record = False
    wanted_rset = Record(name=record_in, type=type_in, ttl=ttl_in,
        identifier=identifier_in, weight=weight_in, region=region_in,
        health_check=health_check_in, failover=failover_in)
    for v in value_list:
        if alias_in:
            wanted_rset.set_alias(alias_hosted_zone_id_in, v, alias_evaluate_target_health_in)
        else:
            wanted_rset.add_value(v)

    sets = conn.get_all_rrsets(zone.id, name=record_in, type=type_in, identifier=identifier_in)
    for rset in sets:
        # Due to a bug in either AWS or Boto, "special" characters are returned as octals, preventing round
        # tripping of things like * and @.
        decoded_name = rset.name.replace(r'\052', '*')
        decoded_name = decoded_name.replace(r'\100', '@')
        #Need to save this changes in rset, because of comparing rset.to_xml() == wanted_rset.to_xml() in next block
        rset.name = decoded_name

        if identifier_in is not None:
            identifier_in = str(identifier_in)

        if rset.type == type_in and decoded_name.lower() == record_in.lower() and rset.identifier == identifier_in:
            found_record = True
            record['zone'] = zone_in
            record['type'] = rset.type
            record['record'] = decoded_name
            record['ttl'] = rset.ttl
            record['value'] = ','.join(sorted(rset.resource_records))
            record['values'] = sorted(rset.resource_records)
            if hosted_zone_id_in:
                record['hosted_zone_id'] = hosted_zone_id_in
            record['identifier'] = rset.identifier
            record['weight'] = rset.weight
            record['region'] = rset.region
            record['failover'] = rset.failover
            record['health_check'] = rset.health_check
            if hosted_zone_id_in:
                record['hosted_zone_id'] = hosted_zone_id_in
            if rset.alias_dns_name:
              record['alias'] = True
              record['value'] = rset.alias_dns_name
              record['values'] = [rset.alias_dns_name]
              record['alias_hosted_zone_id'] = rset.alias_hosted_zone_id
              record['alias_evaluate_target_health'] = rset.alias_evaluate_target_health
            else:
              record['alias'] = False
              record['value'] = ','.join(sorted(rset.resource_records))
              record['values'] = sorted(rset.resource_records)
            if command_in == 'create' and rset.to_xml() == wanted_rset.to_xml():
                module.exit_json(changed=False)
            break

    if command_in == 'get':
        if type_in == 'NS':
            ns = record['values']
        else:
            # Retrieve name servers associated to the zone.
            ns = conn.get_zone(zone_in).get_nameservers()

        module.exit_json(changed=False, set=record, nameservers=ns)

    if command_in == 'delete' and not found_record:
        module.exit_json(changed=False)

    changes = ResourceRecordSets(conn, zone.id)

    if command_in == 'create' or command_in == 'delete':
        if command_in == 'create' and found_record:
            if not module.params['overwrite']:
                module.fail_json(msg = "Record already exists with different value. Set 'overwrite' to replace it")
            command = 'UPSERT'
        else:
            command = command_in.upper()
        changes.add_change_record(command, wanted_rset)

    try:
        result = invoke_with_throttling_retries(commit, changes, retry_interval_in, wait_in, wait_timeout_in)
    except boto.route53.exception.DNSServerError as e:
        txt = e.body.split("<Message>")[1]
        txt = txt.split("</Message>")[0]
        if "but it already exists" in txt:
                module.exit_json(changed=False)
        else:
                module.fail_json(msg = txt)
    except TimeoutError:
        module.fail_json(msg='Timeout waiting for changes to replicate')

    module.exit_json(changed=True)

# import module snippets
from ansible.module_utils.basic import *
from ansible.module_utils.ec2 import *

main()