1# -*- coding: utf-8 -*-
2#
3# Copyright 2018 www.privaz.io Valletech AB
4#
5# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
6
7from __future__ import (absolute_import, division, print_function)
8__metaclass__ = type
9
10
11import time
12import ssl
13from os import environ
14from ansible.module_utils.six import string_types
15from ansible.module_utils.basic import AnsibleModule
16
17
18HAS_PYONE = True
19
20try:
21    from pyone import OneException
22    from pyone.server import OneServer
23except ImportError:
24    OneException = Exception
25    HAS_PYONE = False
26
27
28class OpenNebulaModule:
29    """
30    Base class for all OpenNebula Ansible Modules.
31    This is basically a wrapper of the common arguments, the pyone client and
32    some utility methods.
33    """
34
35    common_args = dict(
36        api_url=dict(type='str', aliases=['api_endpoint'], default=environ.get("ONE_URL")),
37        api_username=dict(type='str', default=environ.get("ONE_USERNAME")),
38        api_password=dict(type='str', no_log=True, aliases=['api_token'], default=environ.get("ONE_PASSWORD")),
39        validate_certs=dict(default=True, type='bool'),
40        wait_timeout=dict(type='int', default=300),
41    )
42
43    def __init__(self, argument_spec, supports_check_mode=False, mutually_exclusive=None, required_one_of=None, required_if=None):
44
45        module_args = OpenNebulaModule.common_args.copy()
46        module_args.update(argument_spec)
47
48        self.module = AnsibleModule(argument_spec=module_args,
49                                    supports_check_mode=supports_check_mode,
50                                    mutually_exclusive=mutually_exclusive,
51                                    required_one_of=required_one_of,
52                                    required_if=required_if)
53        self.result = dict(changed=False,
54                           original_message='',
55                           message='')
56        self.one = self.create_one_client()
57
58        self.resolved_parameters = self.resolve_parameters()
59
60    def create_one_client(self):
61        """
62        Creates an XMLPRC client to OpenNebula.
63
64        Returns: the new xmlrpc client.
65
66        """
67
68        # context required for not validating SSL, old python versions won't validate anyway.
69        if hasattr(ssl, '_create_unverified_context'):
70            no_ssl_validation_context = ssl._create_unverified_context()
71        else:
72            no_ssl_validation_context = None
73
74        # Check if the module can run
75        if not HAS_PYONE:
76            self.fail("pyone is required for this module")
77
78        if self.module.params.get("api_url"):
79            url = self.module.params.get("api_url")
80        else:
81            self.fail("Either api_url or the environment variable ONE_URL must be provided")
82
83        if self.module.params.get("api_username"):
84            username = self.module.params.get("api_username")
85        else:
86            self.fail("Either api_username or the environment vairable ONE_USERNAME must be provided")
87
88        if self.module.params.get("api_password"):
89            password = self.module.params.get("api_password")
90        else:
91            self.fail("Either api_password or the environment vairable ONE_PASSWORD must be provided")
92
93        session = "%s:%s" % (username, password)
94
95        if not self.module.params.get("validate_certs") and "PYTHONHTTPSVERIFY" not in environ:
96            return OneServer(url, session=session, context=no_ssl_validation_context)
97        else:
98            return OneServer(url, session)
99
100    def close_one_client(self):
101        """
102        Close the pyone session.
103        """
104        self.one.server_close()
105
106    def fail(self, msg):
107        """
108        Utility failure method, will ensure pyone is properly closed before failing.
109        Args:
110            msg: human readable failure reason.
111        """
112        if hasattr(self, 'one'):
113            self.close_one_client()
114        self.module.fail_json(msg=msg)
115
116    def exit(self):
117        """
118        Utility exit method, will ensure pyone is properly closed before exiting.
119
120        """
121        if hasattr(self, 'one'):
122            self.close_one_client()
123        self.module.exit_json(**self.result)
124
125    def resolve_parameters(self):
126        """
127        This method resolves parameters provided by a secondary ID to the primary ID.
128        For example if cluster_name is present, cluster_id will be introduced by performing
129        the required resolution
130
131        Returns: a copy of the parameters that includes the resolved parameters.
132
133        """
134
135        resolved_params = dict(self.module.params)
136
137        if 'cluster_name' in self.module.params:
138            clusters = self.one.clusterpool.info()
139            for cluster in clusters.CLUSTER:
140                if cluster.NAME == self.module.params.get('cluster_name'):
141                    resolved_params['cluster_id'] = cluster.ID
142
143        return resolved_params
144
145    def is_parameter(self, name):
146        """
147        Utility method to check if a parameter was provided or is resolved
148        Args:
149            name: the parameter to check
150        """
151        if name in self.resolved_parameters:
152            return self.get_parameter(name) is not None
153        else:
154            return False
155
156    def get_parameter(self, name):
157        """
158        Utility method for accessing parameters that includes resolved ID
159        parameters from provided Name parameters.
160        """
161        return self.resolved_parameters.get(name)
162
163    def get_host_by_name(self, name):
164        '''
165        Returns a host given its name.
166        Args:
167            name: the name of the host
168
169        Returns: the host object or None if the host is absent.
170
171        '''
172        hosts = self.one.hostpool.info()
173        for h in hosts.HOST:
174            if h.NAME == name:
175                return h
176        return None
177
178    def get_cluster_by_name(self, name):
179        """
180        Returns a cluster given its name.
181        Args:
182            name: the name of the cluster
183
184        Returns: the cluster object or None if the host is absent.
185        """
186
187        clusters = self.one.clusterpool.info()
188        for c in clusters.CLUSTER:
189            if c.NAME == name:
190                return c
191        return None
192
193    def get_template_by_name(self, name):
194        '''
195        Returns a template given its name.
196        Args:
197            name: the name of the template
198
199        Returns: the template object or None if the host is absent.
200
201        '''
202        templates = self.one.templatepool.info()
203        for t in templates.TEMPLATE:
204            if t.NAME == name:
205                return t
206        return None
207
208    def cast_template(self, template):
209        """
210        OpenNebula handles all template elements as strings
211        At some point there is a cast being performed on types provided by the user
212        This function mimics that transformation so that required template updates are detected properly
213        additionally an array will be converted to a comma separated list,
214        which works for labels and hopefully for something more.
215
216        Args:
217            template: the template to transform
218
219        Returns: the transformed template with data casts applied.
220        """
221
222        # TODO: check formally available data types in templates
223        # TODO: some arrays might be converted to space separated
224
225        for key in template:
226            value = template[key]
227            if isinstance(value, dict):
228                self.cast_template(template[key])
229            elif isinstance(value, list):
230                template[key] = ', '.join(value)
231            elif not isinstance(value, string_types):
232                template[key] = str(value)
233
234    def requires_template_update(self, current, desired):
235        """
236        This function will help decide if a template update is required or not
237        If a desired key is missing from the current dictionary an update is required
238        If the intersection of both dictionaries is not deep equal, an update is required
239        Args:
240            current: current template as a dictionary
241            desired: desired template as a dictionary
242
243        Returns: True if a template update is required
244        """
245
246        if not desired:
247            return False
248
249        self.cast_template(desired)
250        intersection = dict()
251        for dkey in desired.keys():
252            if dkey in current.keys():
253                intersection[dkey] = current[dkey]
254            else:
255                return True
256        return not (desired == intersection)
257
258    def wait_for_state(self, element_name, state, state_name, target_states,
259                       invalid_states=None, transition_states=None,
260                       wait_timeout=None):
261        """
262        Args:
263            element_name: the name of the object we are waiting for: HOST, VM, etc.
264            state: lambda that returns the current state, will be queried until target state is reached
265            state_name: lambda that returns the readable form of a given state
266            target_states: states expected to be reached
267            invalid_states: if any of this states is reached, fail
268            transition_states: when used, these are the valid states during the transition.
269            wait_timeout: timeout period in seconds. Defaults to the provided parameter.
270        """
271
272        if not wait_timeout:
273            wait_timeout = self.module.params.get("wait_timeout")
274
275        start_time = time.time()
276
277        while (time.time() - start_time) < wait_timeout:
278            current_state = state()
279
280            if current_state in invalid_states:
281                self.fail('invalid %s state %s' % (element_name, state_name(current_state)))
282
283            if transition_states:
284                if current_state not in transition_states:
285                    self.fail('invalid %s transition state %s' % (element_name, state_name(current_state)))
286
287            if current_state in target_states:
288                return True
289
290            time.sleep(self.one.server_retry_interval())
291
292        self.fail(msg="Wait timeout has expired!")
293
294    def run_module(self):
295        """
296        trigger the start of the execution of the module.
297        Returns:
298
299        """
300        try:
301            self.run(self.one, self.module, self.result)
302        except OneException as e:
303            self.fail(msg="OpenNebula Exception: %s" % e)
304
305    def run(self, one, module, result):
306        """
307        to be implemented by subclass with the actual module actions.
308        Args:
309            one: the OpenNebula XMLRPC client
310            module: the Ansible Module object
311            result: the Ansible result
312        """
313        raise NotImplementedError("Method requires implementation")
314