From 1a28a4817649d3e072cfc086d7e683e33ae0f4c9 Mon Sep 17 00:00:00 2001 From: Adam Friedman Date: Thu, 9 Feb 2017 22:30:31 +1100 Subject: [PATCH] Refactor dimensiondata_network module (#21043) * Refactor dimensiondata_network to use shared base class for common functionality. * Experiment: remove the assignments in the "except ImportError:" block that keep PyCharm happy. If this fixes the build, then I reckon there's a bug in the validate-modules script (https://github.com/ansible/ansible/blob/devel/test/sanity/validate-modules/validate-modules#L322). * Remove unused imports. * Changes based on feedback from @gundalow for ansible/ansible#21043. - Use no_log=True for mcp_password parameter. - Collapse module parameter definitions. * Use shared definitions and doc fragments for common module arguments (ansible/ansible#21043). * Make default network plan "ESSENTIALS", rather than "ADVANCED" (this is consistent with our other tooling). Tidy up module parameter documentation. * Simplify dimensiondata module documentation fragments (didn't know you could include multiple fragments). * Change 'verify_ssl_cert' module parameter to 'validate_certs'. --- lib/ansible/module_utils/dimensiondata.py | 625 ++++++++---------- .../dimensiondata/dimensiondata_network.py | 395 ++++++----- .../module_docs_fragments/dimensiondata.py | 58 ++ .../dimensiondata_wait.py | 45 ++ 4 files changed, 560 insertions(+), 563 deletions(-) create mode 100644 lib/ansible/utils/module_docs_fragments/dimensiondata.py create mode 100644 lib/ansible/utils/module_docs_fragments/dimensiondata_wait.py diff --git a/lib/ansible/module_utils/dimensiondata.py b/lib/ansible/module_utils/dimensiondata.py index 33d2fee2a6..694ecea946 100644 --- a/lib/ansible/module_utils/dimensiondata.py +++ b/lib/ansible/module_utils/dimensiondata.py @@ -20,119 +20,305 @@ # - Mark Maglana # - Adam Friedman # -# Common methods to be used by versious module components +# Common functionality to be used by versious module components + import os -from ansible.module_utils.six.moves.configparser import ConfigParser -from ansible.module_utils.pycompat24 import get_exception +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves import configparser from os.path import expanduser from uuid import UUID try: - from libcloud.common.dimensiondata import \ - API_ENDPOINTS, DimensionDataAPIException + from libcloud.common.dimensiondata import API_ENDPOINTS, DimensionDataAPIException, DimensionDataStatus + from libcloud.compute.base import Node, NodeLocation + from libcloud.compute.providers import get_driver + from libcloud.compute.types import Provider + + import libcloud.security + HAS_LIBCLOUD = True except ImportError: HAS_LIBCLOUD = False +# MCP 2.x version patten for location (datacenter) names. +# +# Note that this is not a totally reliable way of determining MCP version. +# Unfortunately, libcloud's NodeLocation currently makes no provision for extended properties. +# At some point we may therefore want to either enhance libcloud or enable overriding mcp_version +# by specifying it in the module parameters. +MCP_2_LOCATION_NAME_PATTERN = re.compile(r".*MCP\s?2.*") + + +class DimensionDataModule(object): + """ + The base class containing common functionality used by Dimension Data modules for Ansible. + """ + + def __init__(self, module): + """ + Create a new DimensionDataModule. + + Will fail if Apache libcloud is not present. + + :param module: The underlying Ansible module. + :type module: AnsibleModule + """ + + self.module = module + + if not HAS_LIBCLOUD: + self.module.fail_json(msg='libcloud is required for this module.') + + return + + # Credentials are common to all Dimension Data modules. + credentials = self.get_credentials() + self.user_id = credentials['user_id'] + self.key = credentials['key'] + + # Region and location are common to all Dimension Data modules. + region = self.module.params['region'] + self.region = 'dd-{}'.format(region) + self.location = self.module.params['location'] + + libcloud.security.VERIFY_SSL_CERT = self.module.params['validate_certs'] + + self.driver = get_driver(Provider.DIMENSIONDATA)( + self.user_id, + self.key, + region=self.region + ) + + # Determine the MCP API version (this depends on the target datacenter). + self.mcp_version = self.get_mcp_version(self.location) + + # Optional "wait-for-completion" arguments + if 'wait' in self.module.params: + self.wait = self.module.params['wait'] + self.wait_time = self.module.params['wait_time'] + self.wait_poll_interval = self.module.params['wait_poll_interval'] + else: + self.wait = False + self.wait_time = 0 + self.wait_poll_interval = 0 + + def get_credentials(self): + """ + Get user_id and key from module configuration, environment, or dotfile. + Order of priority is module, environment, dotfile. + + To set in environment: + + export MCP_USER='myusername' + export MCP_PASSWORD='mypassword' + + To set in dot file place a file at ~/.dimensiondata with + the following contents: + + [dimensiondatacloud] + MCP_USER: myusername + MCP_PASSWORD: mypassword + """ + + if not HAS_LIBCLOUD: + self.module.fail_json(msg='libcloud is required for this module.') + + return None + + user_id = None + key = None + + # First, try the module configuration + if 'mcp_user' in self.module.params: + if 'mcp_password' not in self.module.params: + self.module.fail_json( + msg='"mcp_user" parameter was specified, but not "mcp_password" (either both must be specified, or neither).' + ) + + return None + + user_id = self.module.params['mcp_user'] + key = self.module.params['mcp_password'] + + # Fall back to environment + if not user_id or not key: + user_id = os.environ.get('MCP_USER', None) + key = os.environ.get('MCP_PASSWORD', None) + + # Finally, try dotfile (~/.dimensiondata) + if not user_id or not key: + home = expanduser('~') + config = configparser.RawConfigParser() + config.read("%s/.dimensiondata" % home) + + try: + user_id = config.get("dimensiondatacloud", "MCP_USER") + key = config.get("dimensiondatacloud", "MCP_PASSWORD") + except (configparser.NoSectionError, configparser.NoOptionError): + pass + + # One or more credentials not found. Function can't recover from this + # so it has to raise an error instead of fail silently. + if not user_id: + raise MissingCredentialsError("Dimension Data user id not found") + elif not key: + raise MissingCredentialsError("Dimension Data key not found") + + # Both found, return data + return dict(user_id=user_id, key=key) + + def get_mcp_version(self, location): + """ + Get the MCP version for the specified location. + """ + + location = self.driver.ex_get_location_by_id(location) + if MCP_2_LOCATION_NAME_PATTERN.match(location.name): + return '2.0' + + return '1.0' + + def get_network_domain(self, locator, location): + """ + Retrieve a network domain by its name or Id. + """ + + if is_uuid(locator): + network_domain = self.driver.ex_get_network_domain(locator) + else: + matching_network_domains = [ + network_domain for network_domain in self.driver.ex_list_network_domains(location=location) + if network_domain.name == locator + ] + + if matching_network_domains: + network_domain = matching_network_domains[0] + else: + network_domain = None + + if network_domain: + return network_domain + + raise UnknownNetworkError("Network '%s' could not be found" % locator) + + def get_vlan(self, locator, location, network_domain): + """ + Get a VLAN object by its name or id + """ + if is_uuid(locator): + vlan = self.driver.ex_get_vlan(locator) + else: + matching_vlans = [ + vlan for vlan in self.driver.ex_list_vlans(location, network_domain) + if vlan.name == locator + ] + + if matching_vlans: + vlan = matching_vlans[0] + else: + vlan = None + + if vlan: + return vlan + + raise UnknownVLANError("VLAN '%s' could not be found" % locator) + + @staticmethod + def argument_spec(**additional_argument_spec): + """ + Build an argument specification for a Dimension Data module. + :param additional_argument_spec: An optional dictionary representing the specification for additional module arguments (if any). + :return: A dict containing the argument specification. + """ + + spec = dict( + region=dict(type='str', default='na'), + mcp_user=dict(type='str', required=False), + mcp_password=dict(type='str', required=False, no_log=True), + location=dict(type='str', required=True), + validate_certs=dict(type='bool', required=False, default=True) + ) + + if additional_argument_spec: + spec.update(additional_argument_spec) + + return spec + + @staticmethod + def argument_spec_with_wait(**additional_argument_spec): + """ + Build an argument specification for a Dimension Data module that includes "wait for completion" arguments. + :param additional_argument_spec: An optional dictionary representing the specification for additional module arguments (if any). + :return: A dict containing the argument specification. + """ + + spec = DimensionDataModule.argument_spec( + wait=dict(type='bool', required=False, default=False), + wait_time=dict(type='int', required=False, default=600), + wait_poll_interval=dict(type='int', required=False, default=2) + ) + + if additional_argument_spec: + spec.update(additional_argument_spec) + + return spec + + @staticmethod + def required_together(*additional_required_together): + """ + Get the basic argument specification for Dimension Data modules indicating which arguments are must be specified together. + :param additional_required_together: An optional list representing the specification for additional module arguments that must be specified together. + :return: An array containing the argument specifications. + """ + + required_together = [ + ['mcp_user', 'mcp_password'] + ] + + if additional_required_together: + required_together.extend(additional_required_together) + + return required_together -# Custom Exceptions class LibcloudNotFound(Exception): + """ + Exception raised when Apache libcloud cannot be found. + """ + pass class MissingCredentialsError(Exception): + """ + Exception raised when credentials for Dimension Data CloudControl cannot be found. + """ + pass class UnknownNetworkError(Exception): + """ + Exception raised when a network or network domain cannot be found. + """ + pass class UnknownVLANError(Exception): + """ + Exception raised when a VLAN cannot be found. + """ + pass -def check_libcloud_or_fail(): - """ - Checks if libcloud is installed and fails if not - """ - if not HAS_LIBCLOUD: - raise LibcloudNotFound("apache-libcloud is required.") - - -def get_credentials(module): - """ - Get user_id and key from module configuration, environment, or dotfile. - Order of priority is module, environment, dotfile. - - To set in environment: - - export MCP_USER='myusername' - export MCP_PASSWORD='mypassword' - - To set in dot file place a file at ~/.dimensiondata with - the following contents: - - [dimensiondatacloud] - MCP_USER: myusername - MCP_PASSWORD: mypassword - """ - - if not HAS_LIBCLOUD: - module.fail_json(msg='libcloud is required for this module.') - - return None - - user_id = None - key = None - - # First, try the module configuration - if 'mcp_user' in module.params: - if 'mcp_password' not in module.params: - module.fail_json( - '"mcp_user" parameter was specified, but not "mcp_password" ' + - '(either both must be specified, or neither).' - ) - - return None - - user_id = module.params['mcp_user'] - key = module.params['mcp_password'] - - # Fall back to environment - if not user_id or not key: - user_id = os.environ.get('MCP_USER', None) - key = os.environ.get('MCP_PASSWORD', None) - - # Finally, try dotfile (~/.dimensiondata) - if not user_id or not key: - home = expanduser('~') - config = ConfigParser.RawConfigParser() - config.read("%s/.dimensiondata" % home) - - try: - user_id = config.get("dimensiondatacloud", "MCP_USER") - key = config.get("dimensiondatacloud", "MCP_PASSWORD") - except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): - pass - - # One or more credentials not found. Function can't recover from this - # so it has to raise an error instead of fail silently. - if not user_id: - raise MissingCredentialsError("Dimension Data user id not found") - elif not key: - raise MissingCredentialsError("Dimension Data key not found") - - # Both found, return data - return dict(user_id=user_id, key=key) - - def get_dd_regions(): """ Get the list of available regions whose vendor is Dimension Data. """ - check_libcloud_or_fail() # Get endpoints all_regions = API_ENDPOINTS.keys() @@ -143,284 +329,13 @@ def get_dd_regions(): return regions -def get_network_domain_by_name(driver, name, location): - """ - Get a network domain object by its name - """ - networks = driver.ex_list_network_domains(location=location) - found_networks = [network for network in networks if network.name == name] - - if not found_networks: - raise UnknownNetworkError("Network '%s' could not be found" % name) - - return found_networks[0] - - -def get_network_domain(driver, locator, location): - """ - Get a network domain object by its name or id - """ - if is_uuid(locator): - net_id = locator - else: - name = locator - networks = driver.ex_list_network_domains(location=location) - found_networks = [network for network in networks if network.name == name] - - if not found_networks: - raise UnknownNetworkError("Network '%s' could not be found" % name) - - net_id = found_networks[0].id - - return driver.ex_get_network_domain(net_id) - - -def get_vlan(driver, locator, location, network_domain): - """ - Get a VLAN object by its name or id - """ - if is_uuid(locator): - vlan_id = locator - else: - vlans = driver.ex_list_vlans(location=location, - network_domain=network_domain) - found_vlans = [vlan for vlan in vlans if vlan.name == locator] - - if not found_vlans: - raise UnknownVLANError("VLAN '%s' could not be found" % locator) - - vlan_id = found_vlans[0].id - - return driver.ex_get_vlan(vlan_id) - - -def get_mcp_version(driver, location): - """ - Get a locations MCP version - """ - # Get location to determine if MCP 1.0 or 2.0 - location = driver.ex_get_location_by_id(location) - if 'MCP 2.0' in location.name: - return '2.0' - return '1.0' - - def is_uuid(u, version=4): """ Test if valid v4 UUID """ try: uuid_obj = UUID(u, version=version) + return str(uuid_obj) == u - except: + except ValueError: return False - - -def expand_ip_block(block): - """ - Expand public IP block to show all addresses - """ - addresses = [] - ip_r = block.base_ip.split('.') - last_quad = int(ip_r[3]) - address_root = "%s.%s.%s." % (ip_r[0], ip_r[1], ip_r[2]) - for i in range(int(block.size)): - addresses.append(address_root + str(last_quad + i)) - return addresses - - -def get_public_ip_block(module, driver, network_domain, block_id=False, - base_ip=False): - """ - Get public IP block details - """ - # Block ID given, try to use it. - if block_id is not False: - try: - block = driver.ex_get_public_ip_block(block_id) - except DimensionDataAPIException: - e = get_exception() - # 'UNEXPECTED_ERROR' should be removed once upstream bug is fixed. - # Currently any call to ex_get_public_ip_block where the block does - # not exist will return UNEXPECTED_ERROR rather than - # 'RESOURCE_NOT_FOUND'. - if e.code == "RESOURCE_NOT_FOUND" or e.code == 'UNEXPECTED_ERROR': - module.exit_json(changed=False, msg="Public IP Block does " - "not exist") - else: - module.fail_json(msg="Unexpected error while retrieving " - "block: %s" % e.code) - module.fail_json(msg="Error retreving Public IP Block " + - "'%s': %s" % (block.id, e.message)) - # Block ID not given, try to use base_ip. - else: - blocks = list_public_ip_blocks(module, driver, network_domain) - if blocks is not False: - block = next(block for block in blocks if block.base_ip == base_ip) - else: - module.exit_json(changed=False, msg="IP block starting with " - "'%s' does not exist." % base_ip) - return block - - -def list_nat_rules(module, driver, network_domain): - """ - Get list of NAT rules for domain - """ - try: - return driver.ex_list_nat_rules(network_domain) - except DimensionDataAPIException: - e = get_exception() - module.fail_json(msg="Failed to list NAT rules: %s" % e.message) - - -def list_public_ip_blocks(module, driver, network_domain): - """ - Get list of public IP blocks for a domain - """ - try: - blocks = driver.ex_list_public_ip_blocks(network_domain) - return blocks - except DimensionDataAPIException: - e = get_exception() - - module.fail_json(msg="Error retreving Public IP Blocks: %s" % e) - - -def get_block_allocation(module, cp_driver, lb_driver, network_domain, block): - """ - Get public IP block allocation details. Shows all ips in block and if - they are allocated. Example: - - {'id': 'eb8b16ca-3c91-45fb-b04b-5d7d387a9f4a', - 'addresses': [{'address': '162.2.100.100', - 'allocated': True - }, - {'address': '162.2.100.101', - 'allocated': False - } - ] - } - """ - nat_rules = list_nat_rules(module, cp_driver, network_domain) - balancers = list_balancers(module, lb_driver) - pub_ip_block = get_public_ip_block(module, cp_driver, network_domain, - block.id, False) - pub_ips = expand_ip_block(pub_ip_block) - block_detailed = {'id': block.id, 'addresses': []} - for ip in pub_ips: - allocated = False - - nat_match = [nat_rule for nat_rule in nat_rules - if nat_rule.external_ip == ip] - lb_match = [balancer for balancer in balancers - if balancer.ip == ip] - - if len(nat_match) > 0 or len(lb_match) > 0: - allocated = True - else: - allocated = False - - block_detailed['addresses'].append({'address': ip, - 'allocated': allocated}) - return block_detailed - - -def list_balancers(module, lb_driver): - try: - return lb_driver.list_balancers() - except DimensionDataAPIException: - e = get_exception() - - module.fail_json(msg="Failed to list Load Balancers: %s" % e.message) - - -def get_blocks_with_unallocated(module, cp_driver, lb_driver, network_domain): - """ - Gets ip blocks with one or more unallocated IPs. - ex: - {'unallocated_count': , - 'ip_blocks': [], - 'unallocated_addresses': [] - } - """ - total_unallocated_ips = 0 - all_blocks = list_public_ip_blocks(module, cp_driver, network_domain) - unalloc_blocks = [] - unalloc_addresses = [] - for block in all_blocks: - d_blocks = get_block_allocation(module, cp_driver, lb_driver, - network_domain, block) - i = 0 - for addr in d_blocks['addresses']: - if addr['allocated'] is False: - if i == 0: - unalloc_blocks.append(d_blocks) - unalloc_addresses.append(addr['address']) - total_unallocated_ips += 1 - i += 1 - return {'unallocated_count': total_unallocated_ips, - 'ip_blocks': unalloc_blocks, - 'unallocated_addresses': unalloc_addresses} - - -def get_unallocated_public_ips(module, cp_driver, lb_driver, network_domain, - reuse_free, count=0): - """ - Get and/or provision unallocated public IPs - """ - free_ips = [] - if reuse_free is True: - blocks_with_unallocated = get_blocks_with_unallocated(module, - cp_driver, - lb_driver, - network_domain) - free_ips = blocks_with_unallocated['unallocated_addresses'] - if len(free_ips) < count: - num_needed = count - len(free_ips) - for i in range(num_needed): - block = cp_driver.ex_add_public_ip_block_to_network_domain( - network_domain) - block_dict = get_block_allocation(module, cp_driver, lb_driver, - network_domain, block) - for addr in block_dict['addresses']: - free_ips.append(addr['address']) - if len(free_ips) >= count: - break - return {'changed': True, 'msg': 'Allocated public IP block(s)', - 'addresses': free_ips[:count]} - else: - return {'changed': False, 'msg': 'Found enough unallocated IPs' + - ' without provisioning.', 'addresses': free_ips} - - -def is_ipv4_addr(ip): - """ - Simple way to check if IPv4 address - """ - parts = ip.split('.') - try: - return len(parts) == 4 and all(0 <= int(part) < 256 for part in parts) - except: - return False - - -def get_node_by_name_and_ip(module, lb_driver, name, ip): - """ - Nodes do not have unique names, we need to match name and IP to be - sure we get the correct one - """ - nodes = lb_driver.ex_get_nodes() - found_nodes = [] - if not is_ipv4_addr(ip): - module.fail_json(msg="Node '%s' ip is not a valid IPv4 address" % ip) - - found_nodes = [node for node in nodes - if node.name == name and node.ip == ip] - if len(found_nodes) == 0: - return None - elif len(found_nodes) == 1: - return found_nodes[0] - else: - module.fail_json(msg="More than one node of name '%s' found." % name) diff --git a/lib/ansible/modules/cloud/dimensiondata/dimensiondata_network.py b/lib/ansible/modules/cloud/dimensiondata/dimensiondata_network.py index f0dda2638e..4a9e5142ef 100644 --- a/lib/ansible/modules/cloud/dimensiondata/dimensiondata_network.py +++ b/lib/ansible/modules/cloud/dimensiondata/dimensiondata_network.py @@ -22,42 +22,24 @@ # - Adam Friedman # -ANSIBLE_METADATA = {'status': ['preview'], - 'supported_by': 'community', - 'version': '1.0'} +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'community', + 'version': '1.0' +} DOCUMENTATION = ''' --- module: dimensiondata_network short_description: Create, update, and delete MCP 1.0 & 2.0 networks +extends_documentation_fragment: + - dimensiondata + - dimensiondata_wait description: - Create, update, and delete MCP 1.0 & 2.0 networks version_added: "2.3" author: 'Aimon Bustardo (@aimonb)' options: - region: - description: - - The target region. - - Valid regions are defined in Apache libcloud project [libcloud/common/dimensiondata.py] - - Regions are also listed in https://libcloud.readthedocs.io/en/latest/compute/drivers/dimensiondata.html - - Note that the default value "na" stands for "North America". - - The module prepends 'dd-' to the region. - default: na - mcp_user: - description: - - The username used to authenticate to the CloudControl API. - - If not specified, will fall back to MCP_USER from environment variable or ~/.dimensiondata. - required: false - mcp_password: - description: - - The password used to authenticate to the CloudControl API. - - If not specified, will fall back to MCP_PASSWORD from environment variable or ~/.dimensiondata. - - Required if mcp_user is specified. - required: false - location: - description: - - The target datacenter. - required: true name: description: - The name of the network domain to create. @@ -71,27 +53,7 @@ options: - The service plan, either “ESSENTIALS” or “ADVANCED”. - MCP 2.0 Only. choices: [ESSENTIALS, ADVANCED] - default: ADVANCED - verify_ssl_cert: - description: - - Check that SSL certificate is valid. - required: false - default: true - wait: - description: - - Should we wait for the task to complete before moving onto the next. - required: false - default: false - wait_time: - description: - - Only applicable if wait is true. This is the amount of time in seconds to wait - required: false - default: 600 - wait_poll_interval: - description: - - The amount to time inbetween polling for task completion - required: false - default: 2 + default: ESSENTIALS state: description: - Should the resource be present or absent. @@ -124,7 +86,7 @@ EXAMPLES = ''' RETURN = ''' network: description: Dictionary describing the network. - returned: On success when I(state) is 'present' + returned: On success when I(state=present). type: dictionary contains: id: @@ -159,181 +121,198 @@ network: from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.dimensiondata import get_credentials, DimensionDataAPIException, LibcloudNotFound +from ansible.module_utils.dimensiondata import DimensionDataModule, DimensionDataAPIException from ansible.module_utils.pycompat24 import get_exception try: - from libcloud.compute.types import Provider - from libcloud.compute.providers import get_driver from libcloud.compute.base import NodeLocation - import libcloud.security + HAS_LIBCLOUD = True except ImportError: HAS_LIBCLOUD = False -def network_obj_to_dict(network, version): - network_dict = dict(id=network.id, name=network.name, - description=network.description) - if isinstance(network.location, NodeLocation): - network_dict['location'] = network.location.id - else: - network_dict['location'] = network.location +class DimensionDataNetworkModule(DimensionDataModule): + """ + The dimensiondata_network module for Ansible. + """ - if version == '1.0': - network_dict['private_net'] = network.private_net - network_dict['multicast'] = network.multicast - network_dict['status'] = None - else: - network_dict['private_net'] = None - network_dict['multicast'] = None - network_dict['status'] = network.status - return network_dict + def __init__(self): + """ + Create a new Dimension Data network module. + """ - -def get_mcp_version(driver, location): - # Get location to determine if MCP 1.0 or 2.0 - location = driver.ex_get_location_by_id(location) - if 'MCP 2.0' in location.name: - return '2.0' - return '1.0' - - -def create_network(module, driver, mcp_version, location, - name, description): - - # Make sure service_plan argument is defined - if mcp_version == '2.0' and 'service_plan' not in module.params: - module.fail_json('service_plan required when creating network and ' + - 'location is MCP 2.0') - service_plan = module.params['service_plan'] - - # Create network - try: - if mcp_version == '1.0': - res = driver.ex_create_network(location, name, - description=description) - else: - res = driver.ex_create_network_domain(location, name, - service_plan, - description=description) - except DimensionDataAPIException: - e = get_exception() - - module.fail_json(msg="Failed to create new network: %s" % str(e)) - - if module.params['wait'] is True: - wait_for_network_state(module, driver, res.id, 'NORMAL') - msg = "Created network %s in %s" % (name, location) - network = network_obj_to_dict(res, mcp_version) - - module.exit_json(changed=True, msg=msg, network=network) - - -def delete_network(module, driver, matched_network, mcp_version): - try: - if mcp_version == '1.0': - res = driver.ex_delete_network(matched_network[0]) - else: - res = driver.ex_delete_network_domain(matched_network[0]) - if res is True: - module.exit_json(changed=True, - msg="Deleted network with id %s" % - matched_network[0].id) - - module.fail_json("Unexpected failure deleting network with " + - "id %s", matched_network[0].id) - except DimensionDataAPIException: - e = get_exception() - - module.fail_json(msg="Failed to delete network: %s" % str(e)) - - -def wait_for_network_state(module, driver, net_id, state_to_wait_for): - try: - return driver.connection.wait_for_state( - state_to_wait_for, driver.ex_get_network_domain, - module.params['wait_poll_interval'], - module.params['wait_time'], net_id + super(DimensionDataNetworkModule, self).__init__( + module=AnsibleModule( + argument_spec=DimensionDataModule.argument_spec_with_wait( + name=dict(type='str', required=True), + description=dict(type='str', required=False), + service_plan=dict(default='ESSENTIALS', choices=['ADVANCED', 'ESSENTIALS']), + state=dict(default='present', choices=['present', 'absent']) + ), + required_together=DimensionDataModule.required_together() + ) ) - except DimensionDataAPIException: - e = get_exception() - module.fail_json(msg='Network did not reach % state in time: %s' - % (state_to_wait_for, e.msg)) + self.name = self.module.params['name'] + self.description = self.module.params['description'] + self.service_plan = self.module.params['service_plan'] + self.state = self.module.params['state'] + + def state_present(self): + network = self._get_network() + + if network: + self.module.exit_json( + changed=False, + msg='Network already exists', + network=self._network_to_dict(network) + ) + + return + + network = self._create_network() + + self.module.exit_json( + changed=True, + msg='Created network "%s" in datacenter "%s".' % (self.name, self.location), + network=self._network_to_dict(network) + ) + + def state_absent(self): + network = self._get_network() + + if not network: + self.module.exit_json( + changed=False, + msg='Network "%s" does not exist' % self.name, + network=self._network_to_dict(network) + ) + + return + + self._delete_network(network) + + def _get_network(self): + if self.mcp_version == '1.0': + networks = self.driver.list_networks(location=self.location) + else: + networks = self.driver.ex_list_network_domains(location=self.location) + + matched_network = [network for network in networks if network.name == self.name] + if matched_network: + return matched_network[0] + + return None + + def _network_to_dict(self, network): + network_dict = dict( + id=network.id, + name=network.name, + description=network.description + ) + + if isinstance(network.location, NodeLocation): + network_dict['location'] = network.location.id + else: + network_dict['location'] = network.location + + if self.mcp_version == '1.0': + network_dict['private_net'] = network.private_net + network_dict['multicast'] = network.multicast + network_dict['status'] = None + else: + network_dict['private_net'] = None + network_dict['multicast'] = None + network_dict['status'] = network.status + + return network_dict + + def _create_network(self): + + # Make sure service_plan argument is defined + if self.mcp_version == '2.0' and 'service_plan' not in self.module.params: + self.module.fail_json( + msg='service_plan required when creating network and location is MCP 2.0' + ) + + return None + + # Create network + try: + if self.mcp_version == '1.0': + network = self.driver.ex_create_network( + self.location, + self.name, + description=self.description + ) + else: + network = self.driver.ex_create_network_domain( + self.location, + self.name, + self.module.params['service_plan'], + description=self.description + ) + except DimensionDataAPIException: + api_exception = get_exception() + + self.module.fail_json( + msg="Failed to create new network: %s" % str(api_exception) + ) + + return None + + if self.module.params['wait'] is True: + network = self._wait_for_network_state(network.id, 'NORMAL') + + return network + + def _delete_network(self, network): + try: + if self.mcp_version == '1.0': + deleted = self.driver.ex_delete_network(network) + else: + deleted = self.driver.ex_delete_network_domain(network) + + if deleted: + self.module.exit_json( + changed=True, + msg="Deleted network with id %s" % network.id + ) + + self.module.fail_json( + "Unexpected failure deleting network with id %s", network.id + ) + + except DimensionDataAPIException: + api_exception = get_exception() + + self.module.fail_json( + msg="Failed to delete network: %s" % str(api_exception) + ) + + def _wait_for_network_state(self, net_id, state_to_wait_for): + try: + return self.driver.connection.wait_for_state( + state_to_wait_for, + self.driver.ex_get_network_domain, + self.module.params['wait_poll_interval'], + self.module.params['wait_time'], + net_id + ) + except DimensionDataAPIException: + api_exception = get_exception() + + self.module.fail_json( + msg='Network did not reach % state in time: %s' % (state_to_wait_for, api_exception.msg) + ) def main(): - module = AnsibleModule( - argument_spec=dict( - region=dict(default='na'), - mcp_user=dict(required=False, type='str'), - mcp_password=dict(required=False, type='str'), - location=dict(required=True, type='str'), - name=dict(required=True, type='str'), - description=dict(required=False, type='str'), - service_plan=dict(default='ADVANCED', choices=['ADVANCED', - 'ESSENTIALS']), - state=dict(default='present', choices=['present', 'absent']), - wait=dict(required=False, default=False, type='bool'), - wait_time=dict(required=False, default=600, type='int'), - wait_poll_interval=dict(required=False, default=2, type='int'), - verify_ssl_cert=dict(required=False, default=True, type='bool') - ) - ) - - try: - credentials = get_credentials(module) - except LibcloudNotFound: - module.fail_json(msg='libcloud is required for this module.') - - if not credentials: - module.fail_json(msg="User credentials not found") - - # set short vars for readability - user_id = credentials['user_id'] - key = credentials['key'] - region = 'dd-%s' % module.params['region'] - location = module.params['location'] - name = module.params['name'] - description = module.params['description'] - verify_ssl_cert = module.params['verify_ssl_cert'] - state = module.params['state'] - - # Instantiate driver - libcloud.security.VERIFY_SSL_CERT = verify_ssl_cert - DimensionData = get_driver(Provider.DIMENSIONDATA) - driver = DimensionData(user_id, key, region=region) - - # Get MCP API Version - mcp_version = get_mcp_version(driver, location) - - # Get network list - if mcp_version == '1.0': - networks = driver.list_networks(location=location) - else: - networks = driver.ex_list_network_domains(location=location) - matched_network = [network for network in networks if network.name == name] - - # Ensure network state - if state == 'present': - # Network already exists - if matched_network: - module.exit_json( - changed=False, - msg="Network already exists", - network=network_obj_to_dict(matched_network[0], mcp_version) - ) - create_network(module, driver, mcp_version, location, name, - description) - elif state == 'absent': - # Destroy network - if matched_network: - delete_network(module, driver, matched_network, mcp_version) - else: - module.exit_json(changed=False, msg="Network does not exist") - else: - module.fail_json(msg="Requested state was " + - "'%s'. State must be 'absent' or 'present'" % state) + module = DimensionDataNetworkModule() + if module.state == 'present': + module.state_present() + elif module.state == 'absent': + module.state_absent() if __name__ == '__main__': main() diff --git a/lib/ansible/utils/module_docs_fragments/dimensiondata.py b/lib/ansible/utils/module_docs_fragments/dimensiondata.py new file mode 100644 index 0000000000..d477a83bf3 --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/dimensiondata.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016 Dimension Data +# +# This module 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. +# +# This software 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 this software. If not, see . +# +# Authors: +# - Adam Friedman + + +class ModuleDocFragment(object): + + # Dimension Data doc fragment + DOCUMENTATION = ''' + +options: + region: + description: + - The target region. + choices: + - Regions are defined in Apache libcloud project [libcloud/common/dimensiondata.py] + - They are also listed in U(https://libcloud.readthedocs.io/en/latest/compute/drivers/dimensiondata.html) + - Note that the default value "na" stands for "North America". + - The module prepends 'dd-' to the region choice. + default: na + mcp_user: + description: + - The username used to authenticate to the CloudControl API. + - If not specified, will fall back to C(MCP_USER) from environment variable or C(~/.dimensiondata). + required: false + mcp_password: + description: + - The password used to authenticate to the CloudControl API. + - If not specified, will fall back to C(MCP_PASSWORD) from environment variable or C(~/.dimensiondata). + - Required if I(mcp_user) is specified. + required: false + location: + description: + - The target datacenter. + required: true + validate_certs: + description: + - If C(false), SSL certificates will not be validated. + - This should only be used on private instances of the CloudControl API that use self-signed certificates. + required: false + default: true + ''' diff --git a/lib/ansible/utils/module_docs_fragments/dimensiondata_wait.py b/lib/ansible/utils/module_docs_fragments/dimensiondata_wait.py new file mode 100644 index 0000000000..a281758f5b --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/dimensiondata_wait.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016 Dimension Data +# +# This module 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. +# +# This software 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 this software. If not, see . +# +# Authors: +# - Adam Friedman + + +class ModuleDocFragment(object): + + # Dimension Data ("wait-for-completion" parameters) doc fragment + DOCUMENTATION = ''' + +options: + wait: + description: + - Should we wait for the task to complete before moving onto the next. + required: false + default: false + wait_time: + description: + - The maximum amount of time (in seconds) to wait for the task to complete. + - Only applicable if I(wait=true). + required: false + default: 600 + wait_poll_interval: + description: + - The amount of time (in seconds) to wait between checks for task completion. + - Only applicable if I(wait=true). + required: false + default: 2 + '''