1# -*- coding: utf-8 -*- 2# 3# Copyright (c) 2016 Dimension Data 4# 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6# 7# Authors: 8# - Aimon Bustardo <aimon.bustardo@dimensiondata.com> 9# - Mark Maglana <mmaglana@gmail.com> 10# - Adam Friedman <tintoy@tintoy.io> 11# 12# Common functionality to be used by various module components 13 14from __future__ import (absolute_import, division, print_function) 15__metaclass__ = type 16 17import os 18import re 19import traceback 20 21from ansible.module_utils.basic import AnsibleModule, missing_required_lib 22from ansible.module_utils.six.moves import configparser 23from os.path import expanduser 24from uuid import UUID 25 26LIBCLOUD_IMP_ERR = None 27try: 28 from libcloud.common.dimensiondata import API_ENDPOINTS, DimensionDataAPIException, DimensionDataStatus 29 from libcloud.compute.base import Node, NodeLocation 30 from libcloud.compute.providers import get_driver 31 from libcloud.compute.types import Provider 32 33 import libcloud.security 34 35 HAS_LIBCLOUD = True 36except ImportError: 37 LIBCLOUD_IMP_ERR = traceback.format_exc() 38 HAS_LIBCLOUD = False 39 40# MCP 2.x version patten for location (datacenter) names. 41# 42# Note that this is not a totally reliable way of determining MCP version. 43# Unfortunately, libcloud's NodeLocation currently makes no provision for extended properties. 44# At some point we may therefore want to either enhance libcloud or enable overriding mcp_version 45# by specifying it in the module parameters. 46MCP_2_LOCATION_NAME_PATTERN = re.compile(r".*MCP\s?2.*") 47 48 49class DimensionDataModule(object): 50 """ 51 The base class containing common functionality used by Dimension Data modules for Ansible. 52 """ 53 54 def __init__(self, module): 55 """ 56 Create a new DimensionDataModule. 57 58 Will fail if Apache libcloud is not present. 59 60 :param module: The underlying Ansible module. 61 :type module: AnsibleModule 62 """ 63 64 self.module = module 65 66 if not HAS_LIBCLOUD: 67 self.module.fail_json(msg=missing_required_lib('libcloud'), exception=LIBCLOUD_IMP_ERR) 68 69 # Credentials are common to all Dimension Data modules. 70 credentials = self.get_credentials() 71 self.user_id = credentials['user_id'] 72 self.key = credentials['key'] 73 74 # Region and location are common to all Dimension Data modules. 75 region = self.module.params['region'] 76 self.region = 'dd-{0}'.format(region) 77 self.location = self.module.params['location'] 78 79 libcloud.security.VERIFY_SSL_CERT = self.module.params['validate_certs'] 80 81 self.driver = get_driver(Provider.DIMENSIONDATA)( 82 self.user_id, 83 self.key, 84 region=self.region 85 ) 86 87 # Determine the MCP API version (this depends on the target datacenter). 88 self.mcp_version = self.get_mcp_version(self.location) 89 90 # Optional "wait-for-completion" arguments 91 if 'wait' in self.module.params: 92 self.wait = self.module.params['wait'] 93 self.wait_time = self.module.params['wait_time'] 94 self.wait_poll_interval = self.module.params['wait_poll_interval'] 95 else: 96 self.wait = False 97 self.wait_time = 0 98 self.wait_poll_interval = 0 99 100 def get_credentials(self): 101 """ 102 Get user_id and key from module configuration, environment, or dotfile. 103 Order of priority is module, environment, dotfile. 104 105 To set in environment: 106 107 export MCP_USER='myusername' 108 export MCP_PASSWORD='mypassword' 109 110 To set in dot file place a file at ~/.dimensiondata with 111 the following contents: 112 113 [dimensiondatacloud] 114 MCP_USER: myusername 115 MCP_PASSWORD: mypassword 116 """ 117 118 if not HAS_LIBCLOUD: 119 self.module.fail_json(msg='libcloud is required for this module.') 120 121 user_id = None 122 key = None 123 124 # First, try the module configuration 125 if 'mcp_user' in self.module.params: 126 if 'mcp_password' not in self.module.params: 127 self.module.fail_json( 128 msg='"mcp_user" parameter was specified, but not "mcp_password" (either both must be specified, or neither).' 129 ) 130 131 user_id = self.module.params['mcp_user'] 132 key = self.module.params['mcp_password'] 133 134 # Fall back to environment 135 if not user_id or not key: 136 user_id = os.environ.get('MCP_USER', None) 137 key = os.environ.get('MCP_PASSWORD', None) 138 139 # Finally, try dotfile (~/.dimensiondata) 140 if not user_id or not key: 141 home = expanduser('~') 142 config = configparser.RawConfigParser() 143 config.read("%s/.dimensiondata" % home) 144 145 try: 146 user_id = config.get("dimensiondatacloud", "MCP_USER") 147 key = config.get("dimensiondatacloud", "MCP_PASSWORD") 148 except (configparser.NoSectionError, configparser.NoOptionError): 149 pass 150 151 # One or more credentials not found. Function can't recover from this 152 # so it has to raise an error instead of fail silently. 153 if not user_id: 154 raise MissingCredentialsError("Dimension Data user id not found") 155 elif not key: 156 raise MissingCredentialsError("Dimension Data key not found") 157 158 # Both found, return data 159 return dict(user_id=user_id, key=key) 160 161 def get_mcp_version(self, location): 162 """ 163 Get the MCP version for the specified location. 164 """ 165 166 location = self.driver.ex_get_location_by_id(location) 167 if MCP_2_LOCATION_NAME_PATTERN.match(location.name): 168 return '2.0' 169 170 return '1.0' 171 172 def get_network_domain(self, locator, location): 173 """ 174 Retrieve a network domain by its name or Id. 175 """ 176 177 if is_uuid(locator): 178 network_domain = self.driver.ex_get_network_domain(locator) 179 else: 180 matching_network_domains = [ 181 network_domain for network_domain in self.driver.ex_list_network_domains(location=location) 182 if network_domain.name == locator 183 ] 184 185 if matching_network_domains: 186 network_domain = matching_network_domains[0] 187 else: 188 network_domain = None 189 190 if network_domain: 191 return network_domain 192 193 raise UnknownNetworkError("Network '%s' could not be found" % locator) 194 195 def get_vlan(self, locator, location, network_domain): 196 """ 197 Get a VLAN object by its name or id 198 """ 199 if is_uuid(locator): 200 vlan = self.driver.ex_get_vlan(locator) 201 else: 202 matching_vlans = [ 203 vlan for vlan in self.driver.ex_list_vlans(location, network_domain) 204 if vlan.name == locator 205 ] 206 207 if matching_vlans: 208 vlan = matching_vlans[0] 209 else: 210 vlan = None 211 212 if vlan: 213 return vlan 214 215 raise UnknownVLANError("VLAN '%s' could not be found" % locator) 216 217 @staticmethod 218 def argument_spec(**additional_argument_spec): 219 """ 220 Build an argument specification for a Dimension Data module. 221 :param additional_argument_spec: An optional dictionary representing the specification for additional module arguments (if any). 222 :return: A dict containing the argument specification. 223 """ 224 225 spec = dict( 226 region=dict(type='str', default='na'), 227 mcp_user=dict(type='str', required=False), 228 mcp_password=dict(type='str', required=False, no_log=True), 229 location=dict(type='str', required=True), 230 validate_certs=dict(type='bool', required=False, default=True) 231 ) 232 233 if additional_argument_spec: 234 spec.update(additional_argument_spec) 235 236 return spec 237 238 @staticmethod 239 def argument_spec_with_wait(**additional_argument_spec): 240 """ 241 Build an argument specification for a Dimension Data module that includes "wait for completion" arguments. 242 :param additional_argument_spec: An optional dictionary representing the specification for additional module arguments (if any). 243 :return: A dict containing the argument specification. 244 """ 245 246 spec = DimensionDataModule.argument_spec( 247 wait=dict(type='bool', required=False, default=False), 248 wait_time=dict(type='int', required=False, default=600), 249 wait_poll_interval=dict(type='int', required=False, default=2) 250 ) 251 252 if additional_argument_spec: 253 spec.update(additional_argument_spec) 254 255 return spec 256 257 @staticmethod 258 def required_together(*additional_required_together): 259 """ 260 Get the basic argument specification for Dimension Data modules indicating which arguments are must be specified together. 261 :param additional_required_together: An optional list representing the specification for additional module arguments that must be specified together. 262 :return: An array containing the argument specifications. 263 """ 264 265 required_together = [ 266 ['mcp_user', 'mcp_password'] 267 ] 268 269 if additional_required_together: 270 required_together.extend(additional_required_together) 271 272 return required_together 273 274 275class LibcloudNotFound(Exception): 276 """ 277 Exception raised when Apache libcloud cannot be found. 278 """ 279 280 pass 281 282 283class MissingCredentialsError(Exception): 284 """ 285 Exception raised when credentials for Dimension Data CloudControl cannot be found. 286 """ 287 288 pass 289 290 291class UnknownNetworkError(Exception): 292 """ 293 Exception raised when a network or network domain cannot be found. 294 """ 295 296 pass 297 298 299class UnknownVLANError(Exception): 300 """ 301 Exception raised when a VLAN cannot be found. 302 """ 303 304 pass 305 306 307def get_dd_regions(): 308 """ 309 Get the list of available regions whose vendor is Dimension Data. 310 """ 311 312 # Get endpoints 313 all_regions = API_ENDPOINTS.keys() 314 315 # Only Dimension Data endpoints (no prefix) 316 regions = [region[3:] for region in all_regions if region.startswith('dd-')] 317 318 return regions 319 320 321def is_uuid(u, version=4): 322 """ 323 Test if valid v4 UUID 324 """ 325 try: 326 uuid_obj = UUID(u, version=version) 327 328 return str(uuid_obj) == u 329 except ValueError: 330 return False 331