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