1#!/usr/local/bin/python3.8
2# Copyright: Ansible Project
3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
4
5from __future__ import absolute_import, division, print_function
6__metaclass__ = type
7
8
9DOCUMENTATION = '''
10---
11module: ec2_vpc_subnet
12version_added: 1.0.0
13short_description: Manage subnets in AWS virtual private clouds
14description:
15    - Manage subnets in AWS virtual private clouds.
16author:
17- Robert Estelle (@erydo)
18- Brad Davidson (@brandond)
19requirements: [ boto3 ]
20options:
21  az:
22    description:
23      - "The availability zone for the subnet."
24    type: str
25  cidr:
26    description:
27      - "The CIDR block for the subnet. E.g. 192.0.2.0/24."
28    type: str
29    required: true
30  ipv6_cidr:
31    description:
32      - "The IPv6 CIDR block for the subnet. The VPC must have a /56 block assigned and this value must be a valid IPv6 /64 that falls in the VPC range."
33      - "Required if I(assign_instances_ipv6=true)"
34    type: str
35  tags:
36    description:
37      - "A dict of tags to apply to the subnet. Any tags currently applied to the subnet and not present here will be removed."
38    aliases: [ 'resource_tags' ]
39    type: dict
40  state:
41    description:
42      - "Create or remove the subnet."
43    default: present
44    choices: [ 'present', 'absent' ]
45    type: str
46  vpc_id:
47    description:
48      - "VPC ID of the VPC in which to create or delete the subnet."
49    required: true
50    type: str
51  map_public:
52    description:
53      - "Specify C(yes) to indicate that instances launched into the subnet should be assigned public IP address by default."
54    type: bool
55    default: 'no'
56  assign_instances_ipv6:
57    description:
58      - "Specify C(yes) to indicate that instances launched into the subnet should be automatically assigned an IPv6 address."
59    type: bool
60    default: false
61  wait:
62    description:
63      - "When I(wait=true) and I(state=present), module will wait for subnet to be in available state before continuing."
64    type: bool
65    default: true
66  wait_timeout:
67    description:
68      - "Number of seconds to wait for subnet to become available I(wait=True)."
69    default: 300
70    type: int
71  purge_tags:
72    description:
73      - Whether or not to remove tags that do not appear in the I(tags) list.
74    type: bool
75    default: true
76extends_documentation_fragment:
77- amazon.aws.aws
78- amazon.aws.ec2
79
80'''
81
82EXAMPLES = '''
83# Note: These examples do not set authentication details, see the AWS Guide for details.
84
85- name: Create subnet for database servers
86  amazon.aws.ec2_vpc_subnet:
87    state: present
88    vpc_id: vpc-123456
89    cidr: 10.0.1.16/28
90    tags:
91      Name: Database Subnet
92  register: database_subnet
93
94- name: Remove subnet for database servers
95  amazon.aws.ec2_vpc_subnet:
96    state: absent
97    vpc_id: vpc-123456
98    cidr: 10.0.1.16/28
99
100- name: Create subnet with IPv6 block assigned
101  amazon.aws.ec2_vpc_subnet:
102    state: present
103    vpc_id: vpc-123456
104    cidr: 10.1.100.0/24
105    ipv6_cidr: 2001:db8:0:102::/64
106
107- name: Remove IPv6 block assigned to subnet
108  amazon.aws.ec2_vpc_subnet:
109    state: present
110    vpc_id: vpc-123456
111    cidr: 10.1.100.0/24
112    ipv6_cidr: ''
113'''
114
115RETURN = '''
116subnet:
117    description: Dictionary of subnet values
118    returned: I(state=present)
119    type: complex
120    contains:
121        id:
122            description: Subnet resource id
123            returned: I(state=present)
124            type: str
125            sample: subnet-b883b2c4
126        cidr_block:
127            description: The IPv4 CIDR of the Subnet
128            returned: I(state=present)
129            type: str
130            sample: "10.0.0.0/16"
131        ipv6_cidr_block:
132            description: The IPv6 CIDR block actively associated with the Subnet
133            returned: I(state=present)
134            type: str
135            sample: "2001:db8:0:102::/64"
136        availability_zone:
137            description: Availability zone of the Subnet
138            returned: I(state=present)
139            type: str
140            sample: us-east-1a
141        state:
142            description: state of the Subnet
143            returned: I(state=present)
144            type: str
145            sample: available
146        tags:
147            description: tags attached to the Subnet, includes name
148            returned: I(state=present)
149            type: dict
150            sample: {"Name": "My Subnet", "env": "staging"}
151        map_public_ip_on_launch:
152            description: whether public IP is auto-assigned to new instances
153            returned: I(state=present)
154            type: bool
155            sample: false
156        assign_ipv6_address_on_creation:
157            description: whether IPv6 address is auto-assigned to new instances
158            returned: I(state=present)
159            type: bool
160            sample: false
161        vpc_id:
162            description: the id of the VPC where this Subnet exists
163            returned: I(state=present)
164            type: str
165            sample: vpc-67236184
166        available_ip_address_count:
167            description: number of available IPv4 addresses
168            returned: I(state=present)
169            type: str
170            sample: 251
171        default_for_az:
172            description: indicates whether this is the default Subnet for this Availability Zone
173            returned: I(state=present)
174            type: bool
175            sample: false
176        ipv6_association_id:
177            description: The IPv6 association ID for the currently associated CIDR
178            returned: I(state=present)
179            type: str
180            sample: subnet-cidr-assoc-b85c74d2
181        ipv6_cidr_block_association_set:
182            description: An array of IPv6 cidr block association set information.
183            returned: I(state=present)
184            type: complex
185            contains:
186                association_id:
187                    description: The association ID
188                    returned: always
189                    type: str
190                ipv6_cidr_block:
191                    description: The IPv6 CIDR block that is associated with the subnet.
192                    returned: always
193                    type: str
194                ipv6_cidr_block_state:
195                    description: A hash/dict that contains a single item. The state of the cidr block association.
196                    returned: always
197                    type: dict
198                    contains:
199                        state:
200                            description: The CIDR block association state.
201                            returned: always
202                            type: str
203'''
204
205
206import time
207
208try:
209    import botocore
210except ImportError:
211    pass  # caught by AnsibleAWSModule
212
213from ansible.module_utils._text import to_text
214from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
215
216from ..module_utils.core import AnsibleAWSModule
217from ..module_utils.ec2 import AWSRetry
218from ..module_utils.ec2 import ansible_dict_to_boto3_filter_list
219from ..module_utils.ec2 import ansible_dict_to_boto3_tag_list
220from ..module_utils.ec2 import boto3_tag_list_to_ansible_dict
221from ..module_utils.ec2 import compare_aws_tags
222from ..module_utils.ec2 import describe_ec2_tags
223from ..module_utils.ec2 import ensure_ec2_tags
224from ..module_utils.waiters import get_waiter
225
226
227def get_subnet_info(subnet):
228    if 'Subnets' in subnet:
229        return [get_subnet_info(s) for s in subnet['Subnets']]
230    elif 'Subnet' in subnet:
231        subnet = camel_dict_to_snake_dict(subnet['Subnet'])
232    else:
233        subnet = camel_dict_to_snake_dict(subnet)
234
235    if 'tags' in subnet:
236        subnet['tags'] = boto3_tag_list_to_ansible_dict(subnet['tags'])
237    else:
238        subnet['tags'] = dict()
239
240    if 'subnet_id' in subnet:
241        subnet['id'] = subnet['subnet_id']
242        del subnet['subnet_id']
243
244    subnet['ipv6_cidr_block'] = ''
245    subnet['ipv6_association_id'] = ''
246    ipv6set = subnet.get('ipv6_cidr_block_association_set')
247    if ipv6set:
248        for item in ipv6set:
249            if item.get('ipv6_cidr_block_state', {}).get('state') in ('associated', 'associating'):
250                subnet['ipv6_cidr_block'] = item['ipv6_cidr_block']
251                subnet['ipv6_association_id'] = item['association_id']
252
253    return subnet
254
255
256def waiter_params(module, params, start_time):
257    if not module.botocore_at_least("1.7.0"):
258        remaining_wait_timeout = int(module.params['wait_timeout'] + start_time - time.time())
259        params['WaiterConfig'] = {'Delay': 5, 'MaxAttempts': remaining_wait_timeout // 5}
260    return params
261
262
263def handle_waiter(conn, module, waiter_name, params, start_time):
264    try:
265        get_waiter(conn, waiter_name).wait(
266            **waiter_params(module, params, start_time)
267        )
268    except botocore.exceptions.WaiterError as e:
269        module.fail_json_aws(e, "Failed to wait for updates to complete")
270    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
271        module.fail_json_aws(e, "An exception happened while trying to wait for updates")
272
273
274def create_subnet(conn, module, vpc_id, cidr, ipv6_cidr=None, az=None, start_time=None):
275    wait = module.params['wait']
276    wait_timeout = module.params['wait_timeout']
277
278    params = dict(VpcId=vpc_id,
279                  CidrBlock=cidr)
280
281    if ipv6_cidr:
282        params['Ipv6CidrBlock'] = ipv6_cidr
283
284    if az:
285        params['AvailabilityZone'] = az
286
287    try:
288        subnet = get_subnet_info(conn.create_subnet(aws_retry=True, **params))
289    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
290        module.fail_json_aws(e, msg="Couldn't create subnet")
291
292    # Sometimes AWS takes its time to create a subnet and so using
293    # new subnets's id to do things like create tags results in
294    # exception.
295    if wait and subnet.get('state') != 'available':
296        handle_waiter(conn, module, 'subnet_exists', {'SubnetIds': [subnet['id']]}, start_time)
297        handle_waiter(conn, module, 'subnet_available', {'SubnetIds': [subnet['id']]}, start_time)
298        subnet['state'] = 'available'
299
300    return subnet
301
302
303def ensure_tags(conn, module, subnet, tags, purge_tags, start_time):
304
305    changed = ensure_ec2_tags(
306        conn, module, subnet['id'],
307        resource_type='subnet',
308        purge_tags=purge_tags,
309        tags=tags,
310        retry_codes=['InvalidSubnetID.NotFound'])
311
312    if module.params['wait'] and not module.check_mode:
313        # Wait for tags to be updated
314        filters = [{'Name': 'tag:{0}'.format(k), 'Values': [v]} for k, v in tags.items()]
315        handle_waiter(conn, module, 'subnet_exists',
316                      {'SubnetIds': [subnet['id']], 'Filters': filters}, start_time)
317
318    return changed
319
320
321def ensure_map_public(conn, module, subnet, map_public, check_mode, start_time):
322    if check_mode:
323        return
324    try:
325        conn.modify_subnet_attribute(aws_retry=True, SubnetId=subnet['id'],
326                                     MapPublicIpOnLaunch={'Value': map_public})
327    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
328        module.fail_json_aws(e, msg="Couldn't modify subnet attribute")
329
330
331def ensure_assign_ipv6_on_create(conn, module, subnet, assign_instances_ipv6, check_mode, start_time):
332    if check_mode:
333        return
334    try:
335        conn.modify_subnet_attribute(aws_retry=True, SubnetId=subnet['id'],
336                                     AssignIpv6AddressOnCreation={'Value': assign_instances_ipv6})
337    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
338        module.fail_json_aws(e, msg="Couldn't modify subnet attribute")
339
340
341def disassociate_ipv6_cidr(conn, module, subnet, start_time):
342    if subnet.get('assign_ipv6_address_on_creation'):
343        ensure_assign_ipv6_on_create(conn, module, subnet, False, False, start_time)
344
345    try:
346        conn.disassociate_subnet_cidr_block(aws_retry=True, AssociationId=subnet['ipv6_association_id'])
347    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
348        module.fail_json_aws(e, msg="Couldn't disassociate ipv6 cidr block id {0} from subnet {1}"
349                             .format(subnet['ipv6_association_id'], subnet['id']))
350
351    # Wait for cidr block to be disassociated
352    if module.params['wait']:
353        filters = ansible_dict_to_boto3_filter_list(
354            {'ipv6-cidr-block-association.state': ['disassociated'],
355             'vpc-id': subnet['vpc_id']}
356        )
357        handle_waiter(conn, module, 'subnet_exists',
358                      {'SubnetIds': [subnet['id']], 'Filters': filters}, start_time)
359
360
361def ensure_ipv6_cidr_block(conn, module, subnet, ipv6_cidr, check_mode, start_time):
362    wait = module.params['wait']
363    changed = False
364
365    if subnet['ipv6_association_id'] and not ipv6_cidr:
366        if not check_mode:
367            disassociate_ipv6_cidr(conn, module, subnet, start_time)
368        changed = True
369
370    if ipv6_cidr:
371        filters = ansible_dict_to_boto3_filter_list({'ipv6-cidr-block-association.ipv6-cidr-block': ipv6_cidr,
372                                                     'vpc-id': subnet['vpc_id']})
373
374        try:
375            _subnets = conn.describe_subnets(aws_retry=True, Filters=filters)
376            check_subnets = get_subnet_info(_subnets)
377        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
378            module.fail_json_aws(e, msg="Couldn't get subnet info")
379
380        if check_subnets and check_subnets[0]['ipv6_cidr_block']:
381            module.fail_json(msg="The IPv6 CIDR '{0}' conflicts with another subnet".format(ipv6_cidr))
382
383        if subnet['ipv6_association_id']:
384            if not check_mode:
385                disassociate_ipv6_cidr(conn, module, subnet, start_time)
386            changed = True
387
388        try:
389            if not check_mode:
390                associate_resp = conn.associate_subnet_cidr_block(aws_retry=True, SubnetId=subnet['id'],
391                                                                  Ipv6CidrBlock=ipv6_cidr)
392            changed = True
393        except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
394            module.fail_json_aws(e, msg="Couldn't associate ipv6 cidr {0} to {1}".format(ipv6_cidr, subnet['id']))
395        else:
396            if not check_mode and wait:
397                filters = ansible_dict_to_boto3_filter_list(
398                    {'ipv6-cidr-block-association.state': ['associated'],
399                     'vpc-id': subnet['vpc_id']}
400                )
401                handle_waiter(conn, module, 'subnet_exists',
402                              {'SubnetIds': [subnet['id']], 'Filters': filters}, start_time)
403
404        if associate_resp.get('Ipv6CidrBlockAssociation', {}).get('AssociationId'):
405            subnet['ipv6_association_id'] = associate_resp['Ipv6CidrBlockAssociation']['AssociationId']
406            subnet['ipv6_cidr_block'] = associate_resp['Ipv6CidrBlockAssociation']['Ipv6CidrBlock']
407            if subnet['ipv6_cidr_block_association_set']:
408                subnet['ipv6_cidr_block_association_set'][0] = camel_dict_to_snake_dict(associate_resp['Ipv6CidrBlockAssociation'])
409            else:
410                subnet['ipv6_cidr_block_association_set'].append(camel_dict_to_snake_dict(associate_resp['Ipv6CidrBlockAssociation']))
411
412    return changed
413
414
415def get_matching_subnet(conn, module, vpc_id, cidr):
416    filters = ansible_dict_to_boto3_filter_list({'vpc-id': vpc_id, 'cidr-block': cidr})
417    try:
418        _subnets = conn.describe_subnets(aws_retry=True, Filters=filters)
419        subnets = get_subnet_info(_subnets)
420    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
421        module.fail_json_aws(e, msg="Couldn't get matching subnet")
422
423    if subnets:
424        return subnets[0]
425
426    return None
427
428
429def ensure_subnet_present(conn, module):
430    subnet = get_matching_subnet(conn, module, module.params['vpc_id'], module.params['cidr'])
431    changed = False
432
433    # Initialize start so max time does not exceed the specified wait_timeout for multiple operations
434    start_time = time.time()
435
436    if subnet is None:
437        if not module.check_mode:
438            subnet = create_subnet(conn, module, module.params['vpc_id'], module.params['cidr'],
439                                   ipv6_cidr=module.params['ipv6_cidr'], az=module.params['az'], start_time=start_time)
440        changed = True
441        # Subnet will be None when check_mode is true
442        if subnet is None:
443            return {
444                'changed': changed,
445                'subnet': {}
446            }
447    if module.params['wait']:
448        handle_waiter(conn, module, 'subnet_exists', {'SubnetIds': [subnet['id']]}, start_time)
449
450    if module.params['ipv6_cidr'] != subnet.get('ipv6_cidr_block'):
451        if ensure_ipv6_cidr_block(conn, module, subnet, module.params['ipv6_cidr'], module.check_mode, start_time):
452            changed = True
453
454    if module.params['map_public'] != subnet['map_public_ip_on_launch']:
455        ensure_map_public(conn, module, subnet, module.params['map_public'], module.check_mode, start_time)
456        changed = True
457
458    if module.params['assign_instances_ipv6'] != subnet.get('assign_ipv6_address_on_creation'):
459        ensure_assign_ipv6_on_create(conn, module, subnet, module.params['assign_instances_ipv6'], module.check_mode, start_time)
460        changed = True
461
462    if module.params['tags'] != subnet['tags']:
463        stringified_tags_dict = dict((to_text(k), to_text(v)) for k, v in module.params['tags'].items())
464        if ensure_tags(conn, module, subnet, stringified_tags_dict, module.params['purge_tags'], start_time):
465            changed = True
466
467    subnet = get_matching_subnet(conn, module, module.params['vpc_id'], module.params['cidr'])
468    if not module.check_mode and module.params['wait']:
469        # GET calls are not monotonic for map_public_ip_on_launch and assign_ipv6_address_on_creation
470        # so we only wait for those if necessary just before returning the subnet
471        subnet = ensure_final_subnet(conn, module, subnet, start_time)
472
473    return {
474        'changed': changed,
475        'subnet': subnet
476    }
477
478
479def ensure_final_subnet(conn, module, subnet, start_time):
480    for rewait in range(0, 30):
481        map_public_correct = False
482        assign_ipv6_correct = False
483
484        if module.params['map_public'] == subnet['map_public_ip_on_launch']:
485            map_public_correct = True
486        else:
487            if module.params['map_public']:
488                handle_waiter(conn, module, 'subnet_has_map_public', {'SubnetIds': [subnet['id']]}, start_time)
489            else:
490                handle_waiter(conn, module, 'subnet_no_map_public', {'SubnetIds': [subnet['id']]}, start_time)
491
492        if module.params['assign_instances_ipv6'] == subnet.get('assign_ipv6_address_on_creation'):
493            assign_ipv6_correct = True
494        else:
495            if module.params['assign_instances_ipv6']:
496                handle_waiter(conn, module, 'subnet_has_assign_ipv6', {'SubnetIds': [subnet['id']]}, start_time)
497            else:
498                handle_waiter(conn, module, 'subnet_no_assign_ipv6', {'SubnetIds': [subnet['id']]}, start_time)
499
500        if map_public_correct and assign_ipv6_correct:
501            break
502
503        time.sleep(5)
504        subnet = get_matching_subnet(conn, module, module.params['vpc_id'], module.params['cidr'])
505
506    return subnet
507
508
509def ensure_subnet_absent(conn, module):
510    subnet = get_matching_subnet(conn, module, module.params['vpc_id'], module.params['cidr'])
511    if subnet is None:
512        return {'changed': False}
513
514    try:
515        if not module.check_mode:
516            conn.delete_subnet(aws_retry=True, SubnetId=subnet['id'])
517            if module.params['wait']:
518                handle_waiter(conn, module, 'subnet_deleted', {'SubnetIds': [subnet['id']]}, time.time())
519        return {'changed': True}
520    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
521        module.fail_json_aws(e, msg="Couldn't delete subnet")
522
523
524def main():
525    argument_spec = dict(
526        az=dict(default=None, required=False),
527        cidr=dict(required=True),
528        ipv6_cidr=dict(default='', required=False),
529        state=dict(default='present', choices=['present', 'absent']),
530        tags=dict(default={}, required=False, type='dict', aliases=['resource_tags']),
531        vpc_id=dict(required=True),
532        map_public=dict(default=False, required=False, type='bool'),
533        assign_instances_ipv6=dict(default=False, required=False, type='bool'),
534        wait=dict(type='bool', default=True),
535        wait_timeout=dict(type='int', default=300, required=False),
536        purge_tags=dict(default=True, type='bool')
537    )
538
539    required_if = [('assign_instances_ipv6', True, ['ipv6_cidr'])]
540
541    module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True, required_if=required_if)
542
543    if module.params.get('assign_instances_ipv6') and not module.params.get('ipv6_cidr'):
544        module.fail_json(msg="assign_instances_ipv6 is True but ipv6_cidr is None or an empty string")
545
546    if not module.botocore_at_least("1.7.0"):
547        module.warn("botocore >= 1.7.0 is required to use wait_timeout for custom wait times")
548
549    retry_decorator = AWSRetry.jittered_backoff(retries=10)
550    connection = module.client('ec2', retry_decorator=retry_decorator)
551
552    state = module.params.get('state')
553
554    try:
555        if state == 'present':
556            result = ensure_subnet_present(connection, module)
557        elif state == 'absent':
558            result = ensure_subnet_absent(connection, module)
559    except botocore.exceptions.ClientError as e:
560        module.fail_json_aws(e)
561
562    module.exit_json(**result)
563
564
565if __name__ == '__main__':
566    main()
567