1# -*- coding: utf-8 -*-
2
3# This code is part of Ansible, but is an independent component.
4# This particular file snippet, and this file snippet only, is licensed under the
5# Modified BSD License. Modules you write using this snippet, which is embedded
6# dynamically by Ansible, still belong to the author of the module, and may assign
7# their own license to the complete work.
8#
9# Copyright (c), Entrust Datacard Corporation, 2019
10# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
11
12# Redistribution and use in source and binary forms, with or without modification,
13# are permitted provided that the following conditions are met:
14#    1. Redistributions of source code must retain the above copyright notice,
15#       this list of conditions and the following disclaimer.
16#    2. Redistributions in binary form must reproduce the above copyright notice,
17#       this list of conditions and the following disclaimer in the documentation
18#       and/or other materials provided with the distribution.
19#
20# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
23# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
24# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
25# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
28# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30from __future__ import absolute_import, division, print_function
31
32__metaclass__ = type
33
34import json
35import os
36import re
37import time
38import traceback
39
40from ansible.module_utils._text import to_text, to_native
41from ansible.module_utils.basic import missing_required_lib
42from ansible.module_utils.six.moves.urllib.parse import urlencode
43from ansible.module_utils.six.moves.urllib.error import HTTPError
44from ansible.module_utils.urls import Request
45
46YAML_IMP_ERR = None
47try:
48    import yaml
49except ImportError:
50    YAML_FOUND = False
51    YAML_IMP_ERR = traceback.format_exc()
52else:
53    YAML_FOUND = True
54
55valid_file_format = re.compile(r".*(\.)(yml|yaml|json)$")
56
57
58def ecs_client_argument_spec():
59    return dict(
60        entrust_api_user=dict(type='str', required=True),
61        entrust_api_key=dict(type='str', required=True, no_log=True),
62        entrust_api_client_cert_path=dict(type='path', required=True),
63        entrust_api_client_cert_key_path=dict(type='path', required=True, no_log=True),
64        entrust_api_specification_path=dict(type='path', default='https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml'),
65    )
66
67
68class SessionConfigurationException(Exception):
69    """ Raised if we cannot configure a session with the API """
70
71    pass
72
73
74class RestOperationException(Exception):
75    """ Encapsulate a REST API error """
76
77    def __init__(self, error):
78        self.status = to_native(error.get("status", None))
79        self.errors = [to_native(err.get("message")) for err in error.get("errors", {})]
80        self.message = to_native(" ".join(self.errors))
81
82
83def generate_docstring(operation_spec):
84    """Generate a docstring for an operation defined in operation_spec (swagger)"""
85    # Description of the operation
86    docs = operation_spec.get("description", "No Description")
87    docs += "\n\n"
88
89    # Parameters of the operation
90    parameters = operation_spec.get("parameters", [])
91    if len(parameters) != 0:
92        docs += "\tArguments:\n\n"
93    for parameter in parameters:
94        docs += "{0} ({1}:{2}): {3}\n".format(
95            parameter.get("name"),
96            parameter.get("type", "No Type"),
97            "Required" if parameter.get("required", False) else "Not Required",
98            parameter.get("description"),
99        )
100
101    return docs
102
103
104def bind(instance, method, operation_spec):
105    def binding_scope_fn(*args, **kwargs):
106        return method(instance, *args, **kwargs)
107
108    # Make sure we don't confuse users; add the proper name and documentation to the function.
109    # Users can use !help(<function>) to get help on the function from interactive python or pdb
110    operation_name = operation_spec.get("operationId").split("Using")[0]
111    binding_scope_fn.__name__ = str(operation_name)
112    binding_scope_fn.__doc__ = generate_docstring(operation_spec)
113
114    return binding_scope_fn
115
116
117class RestOperation(object):
118    def __init__(self, session, uri, method, parameters=None):
119        self.session = session
120        self.method = method
121        if parameters is None:
122            self.parameters = {}
123        else:
124            self.parameters = parameters
125        self.url = "{scheme}://{host}{base_path}{uri}".format(scheme="https", host=session._spec.get("host"), base_path=session._spec.get("basePath"), uri=uri)
126
127    def restmethod(self, *args, **kwargs):
128        """Do the hard work of making the request here"""
129
130        # gather named path parameters and do substitution on the URL
131        if self.parameters:
132            path_parameters = {}
133            body_parameters = {}
134            query_parameters = {}
135            for x in self.parameters:
136                expected_location = x.get("in")
137                key_name = x.get("name", None)
138                key_value = kwargs.get(key_name, None)
139                if expected_location == "path" and key_name and key_value:
140                    path_parameters.update({key_name: key_value})
141                elif expected_location == "body" and key_name and key_value:
142                    body_parameters.update({key_name: key_value})
143                elif expected_location == "query" and key_name and key_value:
144                    query_parameters.update({key_name: key_value})
145
146            if len(body_parameters.keys()) >= 1:
147                body_parameters = body_parameters.get(list(body_parameters.keys())[0])
148            else:
149                body_parameters = None
150        else:
151            path_parameters = {}
152            query_parameters = {}
153            body_parameters = None
154
155        # This will fail if we have not set path parameters with a KeyError
156        url = self.url.format(**path_parameters)
157        if query_parameters:
158            # modify the URL to add path parameters
159            url = url + "?" + urlencode(query_parameters)
160
161        try:
162            if body_parameters:
163                body_parameters_json = json.dumps(body_parameters)
164                response = self.session.request.open(method=self.method, url=url, data=body_parameters_json)
165            else:
166                response = self.session.request.open(method=self.method, url=url)
167            request_error = False
168        except HTTPError as e:
169            # An HTTPError has the same methods available as a valid response from request.open
170            response = e
171            request_error = True
172
173        # Return the result if JSON and success ({} for empty responses)
174        # Raise an exception if there was a failure.
175        try:
176            result_code = response.getcode()
177            result = json.loads(response.read())
178        except ValueError:
179            result = {}
180
181        if result or result == {}:
182            if result_code and result_code < 400:
183                return result
184            else:
185                raise RestOperationException(result)
186
187        # Raise a generic RestOperationException if this fails
188        raise RestOperationException({"status": result_code, "errors": [{"message": "REST Operation Failed"}]})
189
190
191class Resource(object):
192    """ Implement basic CRUD operations against a path. """
193
194    def __init__(self, session):
195        self.session = session
196        self.parameters = {}
197
198        for url in session._spec.get("paths").keys():
199            methods = session._spec.get("paths").get(url)
200            for method in methods.keys():
201                operation_spec = methods.get(method)
202                operation_name = operation_spec.get("operationId", None)
203                parameters = operation_spec.get("parameters")
204
205                if not operation_name:
206                    if method.lower() == "post":
207                        operation_name = "Create"
208                    elif method.lower() == "get":
209                        operation_name = "Get"
210                    elif method.lower() == "put":
211                        operation_name = "Update"
212                    elif method.lower() == "delete":
213                        operation_name = "Delete"
214                    elif method.lower() == "patch":
215                        operation_name = "Patch"
216                    else:
217                        raise SessionConfigurationException(to_native("Invalid REST method type {0}".format(method)))
218
219                    # Get the non-parameter parts of the URL and append to the operation name
220                    # e.g  /application/version -> GetApplicationVersion
221                    # e.g. /application/{id}    -> GetApplication
222                    # This may lead to duplicates, which we must prevent.
223                    operation_name += re.sub(r"{(.*)}", "", url).replace("/", " ").title().replace(" ", "")
224                    operation_spec["operationId"] = operation_name
225
226                op = RestOperation(session, url, method, parameters)
227                setattr(self, operation_name, bind(self, op.restmethod, operation_spec))
228
229
230# Session to encapsulate the connection parameters of the module_utils Request object, the api spec, etc
231class ECSSession(object):
232    def __init__(self, name, **kwargs):
233        """
234        Initialize our session
235        """
236
237        self._set_config(name, **kwargs)
238
239    def client(self):
240        resource = Resource(self)
241        return resource
242
243    def _set_config(self, name, **kwargs):
244        headers = {
245            "Content-Type": "application/json",
246            "Connection": "keep-alive",
247        }
248        self.request = Request(headers=headers, timeout=60)
249
250        configurators = [self._read_config_vars]
251        for configurator in configurators:
252            self._config = configurator(name, **kwargs)
253            if self._config:
254                break
255        if self._config is None:
256            raise SessionConfigurationException(to_native("No Configuration Found."))
257
258        # set up auth if passed
259        entrust_api_user = self.get_config("entrust_api_user")
260        entrust_api_key = self.get_config("entrust_api_key")
261        if entrust_api_user and entrust_api_key:
262            self.request.url_username = entrust_api_user
263            self.request.url_password = entrust_api_key
264        else:
265            raise SessionConfigurationException(to_native("User and key must be provided."))
266
267        # set up client certificate if passed (support all-in one or cert + key)
268        entrust_api_cert = self.get_config("entrust_api_cert")
269        entrust_api_cert_key = self.get_config("entrust_api_cert_key")
270        if entrust_api_cert:
271            self.request.client_cert = entrust_api_cert
272            if entrust_api_cert_key:
273                self.request.client_key = entrust_api_cert_key
274        else:
275            raise SessionConfigurationException(to_native("Client certificate for authentication to the API must be provided."))
276
277        # set up the spec
278        entrust_api_specification_path = self.get_config("entrust_api_specification_path")
279
280        if not entrust_api_specification_path.startswith("http") and not os.path.isfile(entrust_api_specification_path):
281            raise SessionConfigurationException(to_native("OpenAPI specification was not found at location {0}.".format(entrust_api_specification_path)))
282        if not valid_file_format.match(entrust_api_specification_path):
283            raise SessionConfigurationException(to_native("OpenAPI specification filename must end in .json, .yml or .yaml"))
284
285        self.verify = True
286
287        if entrust_api_specification_path.startswith("http"):
288            try:
289                http_response = Request().open(method="GET", url=entrust_api_specification_path)
290                http_response_contents = http_response.read()
291                if entrust_api_specification_path.endswith(".json"):
292                    self._spec = json.load(http_response_contents)
293                elif entrust_api_specification_path.endswith(".yml") or entrust_api_specification_path.endswith(".yaml"):
294                    self._spec = yaml.safe_load(http_response_contents)
295            except HTTPError as e:
296                raise SessionConfigurationException(to_native("Error downloading specification from address '{0}', received error code '{1}'".format(
297                    entrust_api_specification_path, e.getcode())))
298        else:
299            with open(entrust_api_specification_path) as f:
300                if ".json" in entrust_api_specification_path:
301                    self._spec = json.load(f)
302                elif ".yml" in entrust_api_specification_path or ".yaml" in entrust_api_specification_path:
303                    self._spec = yaml.safe_load(f)
304
305    def get_config(self, item):
306        return self._config.get(item, None)
307
308    def _read_config_vars(self, name, **kwargs):
309        """ Read configuration from variables passed to the module. """
310        config = {}
311
312        entrust_api_specification_path = kwargs.get("entrust_api_specification_path")
313        if not entrust_api_specification_path or (not entrust_api_specification_path.startswith("http") and not os.path.isfile(entrust_api_specification_path)):
314            raise SessionConfigurationException(
315                to_native(
316                    "Parameter provided for entrust_api_specification_path of value '{0}' was not a valid file path or HTTPS address.".format(
317                        entrust_api_specification_path
318                    )
319                )
320            )
321
322        for required_file in ["entrust_api_cert", "entrust_api_cert_key"]:
323            file_path = kwargs.get(required_file)
324            if not file_path or not os.path.isfile(file_path):
325                raise SessionConfigurationException(
326                    to_native("Parameter provided for {0} of value '{1}' was not a valid file path.".format(required_file, file_path))
327                )
328
329        for required_var in ["entrust_api_user", "entrust_api_key"]:
330            if not kwargs.get(required_var):
331                raise SessionConfigurationException(to_native("Parameter provided for {0} was missing.".format(required_var)))
332
333        config["entrust_api_cert"] = kwargs.get("entrust_api_cert")
334        config["entrust_api_cert_key"] = kwargs.get("entrust_api_cert_key")
335        config["entrust_api_specification_path"] = kwargs.get("entrust_api_specification_path")
336        config["entrust_api_user"] = kwargs.get("entrust_api_user")
337        config["entrust_api_key"] = kwargs.get("entrust_api_key")
338
339        return config
340
341
342def ECSClient(entrust_api_user=None, entrust_api_key=None, entrust_api_cert=None, entrust_api_cert_key=None, entrust_api_specification_path=None):
343    """Create an ECS client"""
344
345    if not YAML_FOUND:
346        raise SessionConfigurationException(missing_required_lib("PyYAML"), exception=YAML_IMP_ERR)
347
348    if entrust_api_specification_path is None:
349        entrust_api_specification_path = "https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml"
350
351    # Not functionally necessary with current uses of this module_util, but better to be explicit for future use cases
352    entrust_api_user = to_text(entrust_api_user)
353    entrust_api_key = to_text(entrust_api_key)
354    entrust_api_cert_key = to_text(entrust_api_cert_key)
355    entrust_api_specification_path = to_text(entrust_api_specification_path)
356
357    return ECSSession(
358        "ecs",
359        entrust_api_user=entrust_api_user,
360        entrust_api_key=entrust_api_key,
361        entrust_api_cert=entrust_api_cert,
362        entrust_api_cert_key=entrust_api_cert_key,
363        entrust_api_specification_path=entrust_api_specification_path,
364    ).client()
365