#!/usr/bin/python -tt # 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 . DOCUMENTATION = ''' --- module: rax short_description: create / delete an instance in Rackspace Public Cloud description: - creates / deletes a Rackspace Public Cloud instance and optionally waits for it to be 'running'. version_added: "1.2" options: api_key: description: - Rackspace API key (overrides I(credentials)) aliases: - password auth_endpoint: description: - The URI of the authentication service default: https://identity.api.rackspacecloud.com/v2.0/ version_added: 1.5 credentials: description: - File to find the Rackspace credentials in (ignored if I(api_key) and I(username) are provided) default: null aliases: - creds_file env: description: - Environment as configured in ~/.pyrax.cfg, see U(https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md#pyrax-configuration) version_added: 1.5 identity_type: description: - Authentication machanism to use, such as rackspace or keystone default: rackspace version_added: 1.5 region: description: - Region to create an instance in default: DFW tenant_id: description: - The tenant ID used for authentication version_added: 1.5 tenant_name: description: - The tenant name used for authentication version_added: 1.5 username: description: - Rackspace username (overrides I(credentials)) verify_ssl: description: - Whether or not to require SSL validation of API endpoints version_added: 1.5 auto_increment: description: - Whether or not to increment a single number with the name of the created servers. Only applicable when used with the I(group) attribute or meta key. default: yes version_added: 1.5 count: description: - number of instances to launch default: 1 version_added: 1.4 count_offset: description: - number count to start at default: 1 version_added: 1.4 disk_config: description: - Disk partitioning strategy choices: ['auto', 'manual'] version_added: '1.4' default: auto exact_count: description: - Explicitly ensure an exact count of instances, used with state=active/present default: no version_added: 1.4 files: description: - Files to insert into the instance. remotefilename:localcontent default: null flavor: description: - flavor to use for the instance default: null group: description: - host group to assign to server, is also used for idempotent operations to ensure a specific number of instances version_added: 1.4 image: description: - image to use for the instance. Can be an C(id), C(human_id) or C(name) default: null instance_ids: description: - list of instance ids, currently only used when state='absent' to remove instances version_added: 1.4 key_name: description: - key pair to use on the instance default: null aliases: ['keypair'] meta: description: - A hash of metadata to associate with the instance default: null name: description: - Name to give the instance default: null networks: description: - The network to attach to the instances. If specified, you must include ALL networks including the public and private interfaces. Can be C(id) or C(label). default: ['public', 'private'] version_added: 1.4 state: description: - Indicate desired state of the resource choices: ['present', 'absent'] default: present wait: description: - wait for the instance to be in state 'running' before returning default: "no" choices: [ "yes", "no" ] wait_timeout: description: - how long before wait gives up, in seconds default: 300 requirements: [ "pyrax" ] author: Jesse Keating, Matt Martz notes: - The following environment variables can be used, C(RAX_USERNAME), C(RAX_API_KEY), C(RAX_CREDS_FILE), C(RAX_CREDENTIALS), C(RAX_REGION). - C(RAX_CREDENTIALS) and C(RAX_CREDS_FILE) points to a credentials file appropriate for pyrax. See U(https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md#authenticating) - C(RAX_USERNAME) and C(RAX_API_KEY) obviate the use of a credentials file - C(RAX_REGION) defines a Rackspace Public Cloud region (DFW, ORD, LON, ...) ''' EXAMPLES = ''' - name: Build a Cloud Server gather_facts: False tasks: - name: Server build request local_action: module: rax credentials: ~/.raxpub name: rax-test1 flavor: 5 image: b11d9567-e412-4255-96b9-bd63ab23bcfe files: /root/.ssh/authorized_keys: /home/localuser/.ssh/id_rsa.pub /root/test.txt: /home/localuser/test.txt wait: yes state: present networks: - private - public register: rax - name: Build an exact count of cloud servers with incremented names hosts: local gather_facts: False tasks: - name: Server build requests local_action: module: rax credentials: ~/.raxpub name: test%03d.example.org flavor: performance1-1 image: ubuntu-1204-lts-precise-pangolin state: present count: 10 count_offset: 10 exact_count: yes group: test wait: yes register: rax ''' import sys import time import os import re from uuid import UUID from types import NoneType try: import pyrax except ImportError: print("failed=True msg='pyrax is required for this module'") sys.exit(1) ACTIVE_STATUSES = ('ACTIVE', 'BUILD', 'HARD_REBOOT', 'MIGRATING', 'PASSWORD', 'REBOOT', 'REBUILD', 'RESCUE', 'RESIZE', 'REVERT_RESIZE') FINAL_STATUSES = ('ACTIVE', 'ERROR') NON_CALLABLES = (basestring, bool, dict, int, list, NoneType) PUBLIC_NET_ID = "00000000-0000-0000-0000-000000000000" SERVICE_NET_ID = "11111111-1111-1111-1111-111111111111" def rax_slugify(value): return 'rax_%s' % (re.sub('[^\w-]', '_', value).lower().lstrip('_')) def pyrax_object_to_dict(obj): instance = {} for key in dir(obj): value = getattr(obj, key) if (isinstance(value, NON_CALLABLES) and not key.startswith('_')): key = rax_slugify(key) instance[key] = value for attr in ['id', 'accessIPv4', 'name', 'status']: instance[attr] = instance.get(rax_slugify(attr)) return instance def create(module, names, flavor, image, meta, key_name, files, wait, wait_timeout, disk_config, group, nics): cs = pyrax.cloudservers changed = False # Handle the file contents for rpath in files.keys(): lpath = os.path.expanduser(files[rpath]) try: fileobj = open(lpath, 'r') files[rpath] = fileobj.read() except Exception, e: module.fail_json(msg='Failed to load %s' % lpath) try: servers = [] for name in names: servers.append(cs.servers.create(name=name, image=image, flavor=flavor, meta=meta, key_name=key_name, files=files, nics=nics, disk_config=disk_config)) except Exception, e: module.fail_json(msg='%s' % e.message) else: changed = True if wait: end_time = time.time() + wait_timeout infinite = wait_timeout == 0 while infinite or time.time() < end_time: for server in servers: try: server.get() except: server.status == 'ERROR' if not filter(lambda s: s.status not in FINAL_STATUSES, servers): break time.sleep(5) success = [] error = [] timeout = [] for server in servers: try: server.get() except: server.status == 'ERROR' instance = pyrax_object_to_dict(server) if server.status == 'ACTIVE' or not wait: success.append(instance) elif server.status == 'ERROR': error.append(instance) elif wait: timeout.append(instance) results = { 'changed': changed, 'action': 'create', 'instances': success + error + timeout, 'success': success, 'error': error, 'timeout': timeout, 'instance_ids': { 'instances': [i['id'] for i in success + error + timeout], 'success': [i['id'] for i in success], 'error': [i['id'] for i in error], 'timeout': [i['id'] for i in timeout] } } if timeout: results['msg'] = 'Timeout waiting for all servers to build' elif error: results['msg'] = 'Failed to build all servers' if 'msg' in results: module.fail_json(**results) else: module.exit_json(**results) def delete(module, instance_ids, wait, wait_timeout): cs = pyrax.cloudservers changed = False instances = {} servers = [] for instance_id in instance_ids: servers.append(cs.servers.get(instance_id)) for server in servers: try: server.delete() except Exception, e: module.fail_json(msg=e.message) else: changed = True instance = pyrax_object_to_dict(server) instances[instance['id']] = instance # If requested, wait for server deletion if wait: end_time = time.time() + wait_timeout infinite = wait_timeout == 0 while infinite or time.time() < end_time: for server in servers: instance_id = server.id try: server.get() except: instances[instance_id]['status'] = 'DELETED' if not filter(lambda s: s['status'] not in ('', 'DELETED', 'ERROR'), instances.values()): break time.sleep(5) timeout = filter(lambda s: s['status'] not in ('', 'DELETED', 'ERROR'), instances.values()) error = filter(lambda s: s['status'] in ('ERROR'), instances.values()) success = filter(lambda s: s['status'] in ('', 'DELETED'), instances.values()) results = { 'changed': changed, 'action': 'delete', 'instances': success + error + timeout, 'success': success, 'error': error, 'timeout': timeout, 'instance_ids': { 'instances': [i['id'] for i in success + error + timeout], 'success': [i['id'] for i in success], 'error': [i['id'] for i in error], 'timeout': [i['id'] for i in timeout] } } if timeout: results['msg'] = 'Timeout waiting for all servers to delete' elif error: results['msg'] = 'Failed to delete all servers' if 'msg' in results: module.fail_json(**results) else: module.exit_json(**results) def cloudservers(module, state, name, flavor, image, meta, key_name, files, wait, wait_timeout, disk_config, count, group, instance_ids, exact_count, networks, count_offset, auto_increment): cs = pyrax.cloudservers cnw = pyrax.cloud_networks servers = [] # Add the group meta key if group and 'group' not in meta: meta['group'] = group elif 'group' in meta and group is None: group = meta['group'] # When using state=absent with group, the absent block won't match the # names properly. Use the exact_count functionality to decrease the count # to the desired level was_absent = False if group is not None and state == 'absent': exact_count = True state = 'present' was_absent = True # Check if the provided image is a UUID and if not, search for an # appropriate image using human_id and name if image: try: UUID(image) except ValueError: try: image = cs.images.find(human_id=image) except(cs.exceptions.NotFound, cs.exceptions.NoUniqueMatch): try: image = cs.images.find(name=image) except (cs.exceptions.NotFound, cs.exceptions.NoUniqueMatch): module.fail_json(msg='No matching image found (%s)' % image) image = pyrax.utils.get_id(image) # Check if the provided network is a UUID and if not, search for an # appropriate network using label nics = [] if networks: for network in networks: try: UUID(network) except ValueError: if network.lower() == 'public': nics.extend(cnw.get_server_networks(PUBLIC_NET_ID)) elif network.lower() == 'private': nics.extend(cnw.get_server_networks(SERVICE_NET_ID)) else: try: network_obj = cnw.find_network_by_label(network) except (pyrax.exceptions.NetworkNotFound, pyrax.exceptions.NetworkLabelNotUnique): module.fail_json(msg='No matching network found (%s)' % network) else: nics.extend(cnw.get_server_networks(network_obj)) else: nics.extend(cnw.get_server_networks(network)) # act on the state if state == 'present': for arg, value in dict(name=name, flavor=flavor, image=image).iteritems(): if not value: module.fail_json(msg='%s is required for the "rax" module' % arg) # Idempotent ensurance of a specific count of servers if exact_count is not False: # See if we can find servers that match our options if group is None: module.fail_json(msg='"group" must be provided when using ' '"exact_count"') else: if auto_increment: numbers = set() try: name % 0 except TypeError, e: if e.message.startswith('not all'): name = '%s%%d' % name else: module.fail_json(msg=e.message) pattern = re.sub(r'%\d*[sd]', r'(\d+)', name) for server in cs.servers.list(): if server.metadata.get('group') == group: servers.append(server) match = re.search(pattern, server.name) if match: number = int(match.group(1)) numbers.add(number) number_range = xrange(count_offset, count_offset + count) available_numbers = list(set(number_range) .difference(numbers)) else: for server in cs.servers.list(): if server.metadata.get('group') == group: servers.append(server) # If state was absent but the count was changed, # assume we only wanted to remove that number of instances if was_absent: diff = len(servers) - count if diff < 0: count = 0 else: count = diff if len(servers) > count: state = 'absent' del servers[:count] instance_ids = [] for server in servers: instance_ids.append(server.id) delete(module, instance_ids, wait, wait_timeout) elif len(servers) < count: if auto_increment: names = [] name_slice = count - len(servers) numbers_to_use = available_numbers[:name_slice] for number in numbers_to_use: names.append(name % number) else: names = [name] * (count - len(servers)) else: module.exit_json(changed=False, action=None, instances=[], success=[], error=[], timeout=[], instance_ids={'instances': [], 'success': [], 'error': [], 'timeout': []}) else: if group is not None: if auto_increment: numbers = set() try: name % 0 except TypeError, e: if e.message.startswith('not all'): name = '%s%%d' % name else: module.fail_json(msg=e.message) pattern = re.sub(r'%\d*[sd]', r'(\d+)', name) for server in cs.servers.list(): if server.metadata.get('group') == group: servers.append(server) match = re.search(pattern, server.name) if match: number = int(match.group(1)) numbers.add(number) number_range = xrange(count_offset, count_offset + count + len(numbers)) available_numbers = list(set(number_range) .difference(numbers)) names = [] numbers_to_use = available_numbers[:count] for number in numbers_to_use: names.append(name % number) else: names = [name] * count else: search_opts = { 'name': '^%s$' % name, 'image': image, 'flavor': flavor } servers = [] for server in cs.servers.list(search_opts=search_opts): if server.metadata != meta: continue servers.append(server) if len(servers) >= count: instances = [] for server in servers: instances.append(pyrax_object_to_dict(server)) instance_ids = [i['id'] for i in instances] module.exit_json(changed=False, action=None, instances=instances, success=[], error=[], timeout=[], instance_ids={'instances': instance_ids, 'success': [], 'error': [], 'timeout': []}) names = [name] * (count - len(servers)) create(module, names, flavor, image, meta, key_name, files, wait, wait_timeout, disk_config, group, nics) elif state == 'absent': if instance_ids is None: for arg, value in dict(name=name, flavor=flavor, image=image).iteritems(): if not value: module.fail_json(msg='%s is required for the "rax" ' 'module' % arg) search_opts = { 'name': '^%s$' % name, 'image': image, 'flavor': flavor } for server in cs.servers.list(search_opts=search_opts): if meta != server.metadata: continue servers.append(server) instance_ids = [] for server in servers: if len(instance_ids) < count: instance_ids.append(server.id) else: break if not instance_ids: module.exit_json(changed=False, action=None, instances=[], success=[], error=[], timeout=[], instance_ids={'instances': [], 'success': [], 'error': [], 'timeout': []}) delete(module, instance_ids, wait, wait_timeout) def main(): argument_spec = rax_argument_spec() argument_spec.update( dict( auto_increment=dict(default=True, type='bool'), count=dict(default=1, type='int'), count_offset=dict(default=1, type='int'), disk_config=dict(choices=['auto', 'manual']), exact_count=dict(default=False, type='bool'), files=dict(type='dict', default={}), flavor=dict(), group=dict(), image=dict(), instance_ids=dict(type='list'), key_name=dict(aliases=['keypair']), meta=dict(type='dict', default={}), name=dict(), networks=dict(type='list', default=['public', 'private']), service=dict(), state=dict(default='present', choices=['present', 'absent']), wait=dict(default=False, type='bool'), wait_timeout=dict(default=300), ) ) module = AnsibleModule( argument_spec=argument_spec, required_together=rax_required_together(), ) service = module.params.get('service') if service is not None: module.fail_json(msg='The "service" attribute has been deprecated, ' 'please remove "service: cloudservers" from your ' 'playbook pertaining to the "rax" module') auto_increment = module.params.get('auto_increment') count = module.params.get('count') count_offset = module.params.get('count_offset') disk_config = module.params.get('disk_config') if disk_config: disk_config = disk_config.upper() exact_count = module.params.get('exact_count', False) files = module.params.get('files') flavor = module.params.get('flavor') group = module.params.get('group') image = module.params.get('image') instance_ids = module.params.get('instance_ids') key_name = module.params.get('key_name') meta = module.params.get('meta') name = module.params.get('name') networks = module.params.get('networks') state = module.params.get('state') wait = module.params.get('wait') wait_timeout = int(module.params.get('wait_timeout')) setup_rax_module(module, pyrax) cloudservers(module, state, name, flavor, image, meta, key_name, files, wait, wait_timeout, disk_config, count, group, instance_ids, exact_count, networks, count_offset, auto_increment) # import module snippets from ansible.module_utils.basic import * from ansible.module_utils.rax import * ### invoke the module main()