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