1#!/usr/bin/python
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
9ANSIBLE_METADATA = {'metadata_version': '1.1',
10                    'status': ['stableinterface'],
11                    'supported_by': 'core'}
12
13
14DOCUMENTATION = '''
15---
16module: ec2_vol
17short_description: create and attach a volume, return volume id and device map
18description:
19    - creates an EBS volume and optionally attaches it to an instance.
20      If both an instance ID and a device name is given and the instance has a device at the device name, then no volume is created and no attachment is made.
21      This module has a dependency on python-boto.
22version_added: "1.1"
23options:
24  instance:
25    description:
26      - instance ID if you wish to attach the volume. Since 1.9 you can set to None to detach.
27  name:
28    description:
29      - volume Name tag if you wish to attach an existing volume (requires instance)
30    version_added: "1.6"
31  id:
32    description:
33      - volume id if you wish to attach an existing volume (requires instance) or remove an existing volume
34    version_added: "1.6"
35  volume_size:
36    description:
37      - size of volume (in GiB) to create.
38  volume_type:
39    description:
40      - Type of EBS volume; standard (magnetic), gp2 (SSD), io1 (Provisioned IOPS), st1 (Throughput Optimized HDD), sc1 (Cold HDD).
41        "Standard" is the old EBS default and continues to remain the Ansible default for backwards compatibility.
42    default: standard
43    version_added: "1.9"
44  iops:
45    description:
46      - the provisioned IOPs you want to associate with this volume (integer).
47    default: 100
48    version_added: "1.3"
49  encrypted:
50    description:
51      - Enable encryption at rest for this volume.
52    default: 'no'
53    type: bool
54    version_added: "1.8"
55  kms_key_id:
56    description:
57      - Specify the id of the KMS key to use.
58    version_added: "2.3"
59  device_name:
60    description:
61      - device id to override device mapping. Assumes /dev/sdf for Linux/UNIX and /dev/xvdf for Windows.
62  delete_on_termination:
63    description:
64      - When set to "yes", the volume will be deleted upon instance termination.
65    type: bool
66    default: 'no'
67    version_added: "2.1"
68  zone:
69    description:
70      - zone in which to create the volume, if unset uses the zone the instance is in (if set)
71    aliases: ['aws_zone', 'ec2_zone']
72  snapshot:
73    description:
74      - snapshot ID on which to base the volume
75    version_added: "1.5"
76  validate_certs:
77    description:
78      - When set to "no", SSL certificates will not be validated for boto versions >= 2.6.0.
79    type: bool
80    default: 'yes'
81    version_added: "1.5"
82  state:
83    description:
84      - whether to ensure the volume is present or absent, or to list existing volumes (The C(list) option was added in version 1.8).
85    default: present
86    choices: ['absent', 'present', 'list']
87    version_added: "1.6"
88  tags:
89    description:
90      - tag:value pairs to add to the volume after creation
91    default: {}
92    version_added: "2.3"
93author: "Lester Wade (@lwade)"
94extends_documentation_fragment:
95    - aws
96    - ec2
97'''
98
99EXAMPLES = '''
100# Simple attachment action
101- ec2_vol:
102    instance: XXXXXX
103    volume_size: 5
104    device_name: sdd
105
106# Example using custom iops params
107- ec2_vol:
108    instance: XXXXXX
109    volume_size: 5
110    iops: 100
111    device_name: sdd
112
113# Example using snapshot id
114- ec2_vol:
115    instance: XXXXXX
116    snapshot: "{{ snapshot }}"
117
118# Playbook example combined with instance launch
119- ec2:
120    keypair: "{{ keypair }}"
121    image: "{{ image }}"
122    wait: yes
123    count: 3
124  register: ec2
125- ec2_vol:
126    instance: "{{ item.id }}"
127    volume_size: 5
128  loop: "{{ ec2.instances }}"
129  register: ec2_vol
130
131# Example: Launch an instance and then add a volume if not already attached
132#   * Volume will be created with the given name if not already created.
133#   * Nothing will happen if the volume is already attached.
134#   * Requires Ansible 2.0
135
136- ec2:
137    keypair: "{{ keypair }}"
138    image: "{{ image }}"
139    zone: YYYYYY
140    id: my_instance
141    wait: yes
142    count: 1
143  register: ec2
144
145- ec2_vol:
146    instance: "{{ item.id }}"
147    name: my_existing_volume_Name_tag
148    device_name: /dev/xvdf
149  loop: "{{ ec2.instances }}"
150  register: ec2_vol
151
152# Remove a volume
153- ec2_vol:
154    id: vol-XXXXXXXX
155    state: absent
156
157# Detach a volume (since 1.9)
158- ec2_vol:
159    id: vol-XXXXXXXX
160    instance: None
161
162# List volumes for an instance
163- ec2_vol:
164    instance: i-XXXXXX
165    state: list
166
167# Create new volume using SSD storage
168- ec2_vol:
169    instance: XXXXXX
170    volume_size: 50
171    volume_type: gp2
172    device_name: /dev/xvdf
173
174# Attach an existing volume to instance. The volume will be deleted upon instance termination.
175- ec2_vol:
176    instance: XXXXXX
177    id: XXXXXX
178    device_name: /dev/sdf
179    delete_on_termination: yes
180'''
181
182RETURN = '''
183device:
184    description: device name of attached volume
185    returned: when success
186    type: str
187    sample: "/def/sdf"
188volume_id:
189    description: the id of volume
190    returned: when success
191    type: str
192    sample: "vol-35b333d9"
193volume_type:
194    description: the volume type
195    returned: when success
196    type: str
197    sample: "standard"
198volume:
199    description: a dictionary containing detailed attributes of the volume
200    returned: when success
201    type: str
202    sample: {
203        "attachment_set": {
204            "attach_time": "2015-10-23T00:22:29.000Z",
205            "deleteOnTermination": "false",
206            "device": "/dev/sdf",
207            "instance_id": "i-8356263c",
208            "status": "attached"
209        },
210        "create_time": "2015-10-21T14:36:08.870Z",
211        "encrypted": false,
212        "id": "vol-35b333d9",
213        "iops": null,
214        "size": 1,
215        "snapshot_id": "",
216        "status": "in-use",
217        "tags": {
218            "env": "dev"
219        },
220        "type": "standard",
221        "zone": "us-east-1b"
222    }
223'''
224
225import time
226
227from distutils.version import LooseVersion
228
229try:
230    import boto
231    import boto.ec2
232    import boto.exception
233    from boto.exception import BotoServerError
234    from boto.ec2.blockdevicemapping import BlockDeviceType, BlockDeviceMapping
235except ImportError:
236    pass  # Taken care of by ec2.HAS_BOTO
237
238from ansible.module_utils.basic import AnsibleModule
239from ansible.module_utils.ec2 import (HAS_BOTO, AnsibleAWSError, connect_to_aws, ec2_argument_spec,
240                                      get_aws_connection_info)
241
242
243def get_volume(module, ec2):
244    name = module.params.get('name')
245    id = module.params.get('id')
246    zone = module.params.get('zone')
247    filters = {}
248    volume_ids = None
249
250    # If no name or id supplied, just try volume creation based on module parameters
251    if id is None and name is None:
252        return None
253
254    if zone:
255        filters['availability_zone'] = zone
256    if name:
257        filters = {'tag:Name': name}
258    if id:
259        volume_ids = [id]
260    try:
261        vols = ec2.get_all_volumes(volume_ids=volume_ids, filters=filters)
262    except boto.exception.BotoServerError as e:
263        module.fail_json(msg="%s: %s" % (e.error_code, e.error_message))
264
265    if not vols:
266        if id:
267            msg = "Could not find the volume with id: %s" % id
268            if name:
269                msg += (" and name: %s" % name)
270            module.fail_json(msg=msg)
271        else:
272            return None
273
274    if len(vols) > 1:
275        module.fail_json(msg="Found more than one volume in zone (if specified) with name: %s" % name)
276    return vols[0]
277
278
279def get_volumes(module, ec2):
280
281    instance = module.params.get('instance')
282
283    try:
284        if not instance:
285            vols = ec2.get_all_volumes()
286        else:
287            vols = ec2.get_all_volumes(filters={'attachment.instance-id': instance})
288    except boto.exception.BotoServerError as e:
289        module.fail_json(msg="%s: %s" % (e.error_code, e.error_message))
290    return vols
291
292
293def delete_volume(module, ec2):
294    volume_id = module.params['id']
295    try:
296        ec2.delete_volume(volume_id)
297        module.exit_json(changed=True)
298    except boto.exception.EC2ResponseError as ec2_error:
299        if ec2_error.code == 'InvalidVolume.NotFound':
300            module.exit_json(changed=False)
301        module.fail_json(msg=ec2_error.message)
302
303
304def boto_supports_volume_encryption():
305    """
306    Check if Boto library supports encryption of EBS volumes (added in 2.29.0)
307
308    Returns:
309        True if boto library has the named param as an argument on the request_spot_instances method, else False
310    """
311    return hasattr(boto, 'Version') and LooseVersion(boto.Version) >= LooseVersion('2.29.0')
312
313
314def boto_supports_kms_key_id():
315    """
316    Check if Boto library supports kms_key_ids (added in 2.39.0)
317
318    Returns:
319        True if version is equal to or higher then the version needed, else False
320    """
321    return hasattr(boto, 'Version') and LooseVersion(boto.Version) >= LooseVersion('2.39.0')
322
323
324def create_volume(module, ec2, zone):
325    changed = False
326    name = module.params.get('name')
327    iops = module.params.get('iops')
328    encrypted = module.params.get('encrypted')
329    kms_key_id = module.params.get('kms_key_id')
330    volume_size = module.params.get('volume_size')
331    volume_type = module.params.get('volume_type')
332    snapshot = module.params.get('snapshot')
333    tags = module.params.get('tags')
334    # If custom iops is defined we use volume_type "io1" rather than the default of "standard"
335    if iops:
336        volume_type = 'io1'
337
338    volume = get_volume(module, ec2)
339    if volume is None:
340        try:
341            if boto_supports_volume_encryption():
342                if kms_key_id is not None:
343                    volume = ec2.create_volume(volume_size, zone, snapshot, volume_type, iops, encrypted, kms_key_id)
344                else:
345                    volume = ec2.create_volume(volume_size, zone, snapshot, volume_type, iops, encrypted)
346                changed = True
347            else:
348                volume = ec2.create_volume(volume_size, zone, snapshot, volume_type, iops)
349                changed = True
350
351            while volume.status != 'available':
352                time.sleep(3)
353                volume.update()
354
355            if name:
356                tags["Name"] = name
357            if tags:
358                ec2.create_tags([volume.id], tags)
359        except boto.exception.BotoServerError as e:
360            module.fail_json(msg="%s: %s" % (e.error_code, e.error_message))
361
362    return volume, changed
363
364
365def attach_volume(module, ec2, volume, instance):
366
367    device_name = module.params.get('device_name')
368    delete_on_termination = module.params.get('delete_on_termination')
369    changed = False
370
371    # If device_name isn't set, make a choice based on best practices here:
372    # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/block-device-mapping-concepts.html
373
374    # In future this needs to be more dynamic but combining block device mapping best practices
375    # (bounds for devices, as above) with instance.block_device_mapping data would be tricky. For me ;)
376
377    # Use password data attribute to tell whether the instance is Windows or Linux
378    if device_name is None:
379        try:
380            if not ec2.get_password_data(instance.id):
381                device_name = '/dev/sdf'
382            else:
383                device_name = '/dev/xvdf'
384        except boto.exception.BotoServerError as e:
385            module.fail_json(msg="%s: %s" % (e.error_code, e.error_message))
386
387    if volume.attachment_state() is not None:
388        adata = volume.attach_data
389        if adata.instance_id != instance.id:
390            module.fail_json(msg="Volume %s is already attached to another instance: %s"
391                             % (volume.id, adata.instance_id))
392        else:
393            # Volume is already attached to right instance
394            changed = modify_dot_attribute(module, ec2, instance, device_name)
395    else:
396        try:
397            volume.attach(instance.id, device_name)
398            while volume.attachment_state() != 'attached':
399                time.sleep(3)
400                volume.update()
401            changed = True
402        except boto.exception.BotoServerError as e:
403            module.fail_json(msg="%s: %s" % (e.error_code, e.error_message))
404
405        modify_dot_attribute(module, ec2, instance, device_name)
406
407    return volume, changed
408
409
410def modify_dot_attribute(module, ec2, instance, device_name):
411    """ Modify delete_on_termination attribute """
412
413    delete_on_termination = module.params.get('delete_on_termination')
414    changed = False
415
416    try:
417        instance.update()
418        dot = instance.block_device_mapping[device_name].delete_on_termination
419    except boto.exception.BotoServerError as e:
420        module.fail_json(msg="%s: %s" % (e.error_code, e.error_message))
421
422    if delete_on_termination != dot:
423        try:
424            bdt = BlockDeviceType(delete_on_termination=delete_on_termination)
425            bdm = BlockDeviceMapping()
426            bdm[device_name] = bdt
427
428            ec2.modify_instance_attribute(instance_id=instance.id, attribute='blockDeviceMapping', value=bdm)
429
430            while instance.block_device_mapping[device_name].delete_on_termination != delete_on_termination:
431                time.sleep(3)
432                instance.update()
433            changed = True
434        except boto.exception.BotoServerError as e:
435            module.fail_json(msg="%s: %s" % (e.error_code, e.error_message))
436
437    return changed
438
439
440def detach_volume(module, ec2, volume):
441
442    changed = False
443
444    if volume.attachment_state() is not None:
445        adata = volume.attach_data
446        volume.detach()
447        while volume.attachment_state() is not None:
448            time.sleep(3)
449            volume.update()
450        changed = True
451
452    return volume, changed
453
454
455def get_volume_info(volume, state):
456
457    # If we're just listing volumes then do nothing, else get the latest update for the volume
458    if state != 'list':
459        volume.update()
460
461    volume_info = {}
462    attachment = volume.attach_data
463
464    volume_info = {
465        'create_time': volume.create_time,
466        'encrypted': volume.encrypted,
467        'id': volume.id,
468        'iops': volume.iops,
469        'size': volume.size,
470        'snapshot_id': volume.snapshot_id,
471        'status': volume.status,
472        'type': volume.type,
473        'zone': volume.zone,
474        'attachment_set': {
475            'attach_time': attachment.attach_time,
476            'device': attachment.device,
477            'instance_id': attachment.instance_id,
478            'status': attachment.status
479        },
480        'tags': volume.tags
481    }
482    if hasattr(attachment, 'deleteOnTermination'):
483        volume_info['attachment_set']['deleteOnTermination'] = attachment.deleteOnTermination
484
485    return volume_info
486
487
488def main():
489    argument_spec = ec2_argument_spec()
490    argument_spec.update(dict(
491        instance=dict(),
492        id=dict(),
493        name=dict(),
494        volume_size=dict(),
495        volume_type=dict(choices=['standard', 'gp2', 'io1', 'st1', 'sc1'], default='standard'),
496        iops=dict(),
497        encrypted=dict(type='bool', default=False),
498        kms_key_id=dict(),
499        device_name=dict(),
500        delete_on_termination=dict(type='bool', default=False),
501        zone=dict(aliases=['availability_zone', 'aws_zone', 'ec2_zone']),
502        snapshot=dict(),
503        state=dict(choices=['absent', 'present', 'list'], default='present'),
504        tags=dict(type='dict', default={})
505    )
506    )
507    module = AnsibleModule(argument_spec=argument_spec)
508
509    if not HAS_BOTO:
510        module.fail_json(msg='boto required for this module')
511
512    id = module.params.get('id')
513    name = module.params.get('name')
514    instance = module.params.get('instance')
515    volume_size = module.params.get('volume_size')
516    encrypted = module.params.get('encrypted')
517    kms_key_id = module.params.get('kms_key_id')
518    device_name = module.params.get('device_name')
519    zone = module.params.get('zone')
520    snapshot = module.params.get('snapshot')
521    state = module.params.get('state')
522    tags = module.params.get('tags')
523
524    # Ensure we have the zone or can get the zone
525    if instance is None and zone is None and state == 'present':
526        module.fail_json(msg="You must specify either instance or zone")
527
528    # Set volume detach flag
529    if instance == 'None' or instance == '':
530        instance = None
531        detach_vol_flag = True
532    else:
533        detach_vol_flag = False
534
535    # Set changed flag
536    changed = False
537
538    region, ec2_url, aws_connect_params = get_aws_connection_info(module)
539
540    if region:
541        try:
542            ec2 = connect_to_aws(boto.ec2, region, **aws_connect_params)
543        except (boto.exception.NoAuthHandlerFound, AnsibleAWSError) as e:
544            module.fail_json(msg=str(e))
545    else:
546        module.fail_json(msg="region must be specified")
547
548    if state == 'list':
549        returned_volumes = []
550        vols = get_volumes(module, ec2)
551
552        for v in vols:
553            attachment = v.attach_data
554
555            returned_volumes.append(get_volume_info(v, state))
556
557        module.exit_json(changed=False, volumes=returned_volumes)
558
559    if encrypted and not boto_supports_volume_encryption():
560        module.fail_json(msg="You must use boto >= v2.29.0 to use encrypted volumes")
561
562    if kms_key_id is not None and not boto_supports_kms_key_id():
563        module.fail_json(msg="You must use boto >= v2.39.0 to use kms_key_id")
564
565    # Here we need to get the zone info for the instance. This covers situation where
566    # instance is specified but zone isn't.
567    # Useful for playbooks chaining instance launch with volume create + attach and where the
568    # zone doesn't matter to the user.
569    inst = None
570    if instance:
571        try:
572            reservation = ec2.get_all_instances(instance_ids=instance)
573        except BotoServerError as e:
574            module.fail_json(msg=e.message)
575        inst = reservation[0].instances[0]
576        zone = inst.placement
577
578        # Check if there is a volume already mounted there.
579        if device_name:
580            if device_name in inst.block_device_mapping:
581                module.exit_json(msg="Volume mapping for %s already exists on instance %s" % (device_name, instance),
582                                 volume_id=inst.block_device_mapping[device_name].volume_id,
583                                 device=device_name,
584                                 changed=False)
585
586    # Delaying the checks until after the instance check allows us to get volume ids for existing volumes
587    # without needing to pass an unused volume_size
588    if not volume_size and not (id or name or snapshot):
589        module.fail_json(msg="You must specify volume_size or identify an existing volume by id, name, or snapshot")
590
591    if volume_size and id:
592        module.fail_json(msg="Cannot specify volume_size together with id")
593
594    if state == 'present':
595        volume, changed = create_volume(module, ec2, zone)
596        if detach_vol_flag:
597            volume, changed = detach_volume(module, ec2, volume)
598        elif inst is not None:
599            volume, changed = attach_volume(module, ec2, volume, inst)
600
601        # Add device, volume_id and volume_type parameters separately to maintain backward compatibility
602        volume_info = get_volume_info(volume, state)
603
604        # deleteOnTermination is not correctly reflected on attachment
605        if module.params.get('delete_on_termination'):
606            for attempt in range(0, 8):
607                if volume_info['attachment_set'].get('deleteOnTermination') == 'true':
608                    break
609                time.sleep(5)
610                volume = ec2.get_all_volumes(volume_ids=volume.id)[0]
611                volume_info = get_volume_info(volume, state)
612        module.exit_json(changed=changed, volume=volume_info, device=volume_info['attachment_set']['device'],
613                         volume_id=volume_info['id'], volume_type=volume_info['type'])
614    elif state == 'absent':
615        delete_volume(module, ec2)
616
617
618if __name__ == '__main__':
619    main()
620