1# This code is part of Ansible, but is an independent component.
2# This particular file snippet, and this file snippet only, is BSD licensed.
3# Modules you write using this snippet, which is embedded dynamically by Ansible
4# still belong to the author of the module, and may assign their own license
5# to the complete work.
6#
7# (c) 2017 Fortinet, Inc
8# All rights reserved.
9#
10# Redistribution and use in source and binary forms, with or without modification,
11# are permitted provided that the following conditions are met:
12#
13#    * Redistributions of source code must retain the above copyright
14#      notice, this list of conditions and the following disclaimer.
15#    * Redistributions in binary form must reproduce the above copyright notice,
16#      this list of conditions and the following disclaimer in the documentation
17#      and/or other materials provided with the distribution.
18#
19# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
22# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
24# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
26# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
27# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28#
29
30from __future__ import absolute_import, division, print_function
31
32__metaclass__ = type
33
34
35from ansible.module_utils.network.fortianalyzer.common import FAZ_RC
36from ansible.module_utils.network.fortianalyzer.common import FAZBaseException
37from ansible.module_utils.network.fortianalyzer.common import FAZCommon
38from ansible.module_utils.network.fortianalyzer.common import scrub_dict
39from ansible.module_utils.network.fortianalyzer.common import FAZMethods
40
41
42# ACTIVE BUG WITH OUR DEBUG IMPORT CALL - BECAUSE IT'S UNDER MODULE_UTILITIES
43# WHEN module_common.recursive_finder() runs under the module loader, it looks for this namespace debug import
44# and because it's not there, it always fails, regardless of it being under a try/catch here.
45# we're going to move it to a different namespace.
46# # check for debug lib
47# try:
48#     from ansible.module_utils.network.fortianalyzer.fortianalyzer_debug import debug_dump
49#     HAS_FAZ_DEBUG = True
50# except:
51#     HAS_FAZ_DEBUG = False
52
53
54# BEGIN HANDLER CLASSES
55class FortiAnalyzerHandler(object):
56    def __init__(self, conn, module):
57        self._conn = conn
58        self._module = module
59        self._tools = FAZCommon
60        self._uses_workspace = None
61        self._uses_adoms = None
62        self._locked_adom_list = list()
63        self._lock_info = None
64
65        self.workspace_check()
66        if self._uses_workspace:
67            self.get_lock_info(adom=self._module.paramgram["adom"])
68
69    def process_request(self, url, datagram, method):
70        """
71        Formats and Runs the API Request via Connection Plugin. Streamlined for use from Modules.
72
73        :param url: Connection URL to access
74        :type url: string
75        :param datagram: The prepared payload for the API Request in dictionary format
76        :type datagram: dict
77        :param method: The preferred API Request method (GET, ADD, POST, etc....)
78        :type method: basestring
79
80        :return: Dictionary containing results of the API Request via Connection Plugin.
81        :rtype: dict
82        """
83        try:
84            adom = self._module.paramgram["adom"]
85            if self.uses_workspace and adom not in self._locked_adom_list and method != FAZMethods.GET:
86                self.lock_adom(adom=adom)
87        except BaseException as err:
88            raise FAZBaseException(err)
89
90        data = self._tools.format_request(method, url, **datagram)
91        response = self._conn.send_request(method, data)
92
93        try:
94            adom = self._module.paramgram["adom"]
95            if self.uses_workspace and adom in self._locked_adom_list \
96                    and response[0] == 0 and method != FAZMethods.GET:
97                self.commit_changes(adom=adom)
98        except BaseException as err:
99            raise FAZBaseException(err)
100
101        # if HAS_FAZ_DEBUG:
102        #     try:
103        #         debug_dump(response, datagram, self._module.paramgram, url, method)
104        #     except BaseException:
105        #         pass
106
107        return response
108
109    def workspace_check(self):
110        """
111       Checks FortiAnalyzer for the use of Workspace mode.
112       """
113        url = "/cli/global/system/global"
114        data = {"fields": ["workspace-mode", "adom-status"]}
115        resp_obj = self.process_request(url, data, FAZMethods.GET)
116        try:
117            if resp_obj[1]["workspace-mode"] in ["workflow", "normal"]:
118                self.uses_workspace = True
119            elif resp_obj[1]["workspace-mode"] == "disabled":
120                self.uses_workspace = False
121        except KeyError:
122            self.uses_workspace = False
123        except BaseException as err:
124            raise FAZBaseException(msg="Couldn't determine workspace-mode in the plugin. Error: " + str(err))
125        try:
126            if resp_obj[1]["adom-status"] in [1, "enable"]:
127                self.uses_adoms = True
128            else:
129                self.uses_adoms = False
130        except KeyError:
131            self.uses_adoms = False
132        except BaseException as err:
133            raise FAZBaseException(msg="Couldn't determine adom-status in the plugin. Error: " + str(err))
134
135    def run_unlock(self):
136        """
137        Checks for ADOM status, if locked, it will unlock
138        """
139        for adom_locked in self._locked_adom_list:
140            self.unlock_adom(adom_locked)
141
142    def lock_adom(self, adom=None):
143        """
144        Locks an ADOM for changes
145        """
146        if not adom or adom == "root":
147            url = "/dvmdb/adom/root/workspace/lock"
148        else:
149            if adom.lower() == "global":
150                url = "/dvmdb/global/workspace/lock/"
151            else:
152                url = "/dvmdb/adom/{adom}/workspace/lock/".format(adom=adom)
153        datagram = {}
154        data = self._tools.format_request(FAZMethods.EXEC, url, **datagram)
155        resp_obj = self._conn.send_request(FAZMethods.EXEC, data)
156        code = resp_obj[0]
157        if code == 0 and resp_obj[1]["status"]["message"].lower() == "ok":
158            self.add_adom_to_lock_list(adom)
159        else:
160            lockinfo = self.get_lock_info(adom=adom)
161            self._module.fail_json(msg=("An error occurred trying to lock the adom. Error: "
162                                        + str(resp_obj) + ", LOCK INFO: " + str(lockinfo)))
163        return resp_obj
164
165    def unlock_adom(self, adom=None):
166        """
167        Unlocks an ADOM after changes
168        """
169        if not adom or adom == "root":
170            url = "/dvmdb/adom/root/workspace/unlock"
171        else:
172            if adom.lower() == "global":
173                url = "/dvmdb/global/workspace/unlock/"
174            else:
175                url = "/dvmdb/adom/{adom}/workspace/unlock/".format(adom=adom)
176        datagram = {}
177        data = self._tools.format_request(FAZMethods.EXEC, url, **datagram)
178        resp_obj = self._conn.send_request(FAZMethods.EXEC, data)
179        code = resp_obj[0]
180        if code == 0 and resp_obj[1]["status"]["message"].lower() == "ok":
181            self.remove_adom_from_lock_list(adom)
182        else:
183            self._module.fail_json(msg=("An error occurred trying to unlock the adom. Error: " + str(resp_obj)))
184        return resp_obj
185
186    def get_lock_info(self, adom=None):
187        """
188        Gets ADOM lock info so it can be displayed with the error messages. Or if determined to be locked by ansible
189        for some reason, then unlock it.
190        """
191        if not adom or adom == "root":
192            url = "/dvmdb/adom/root/workspace/lockinfo"
193        else:
194            if adom.lower() == "global":
195                url = "/dvmdb/global/workspace/lockinfo/"
196            else:
197                url = "/dvmdb/adom/{adom}/workspace/lockinfo/".format(adom=adom)
198        datagram = {}
199        data = self._tools.format_request(FAZMethods.GET, url, **datagram)
200        resp_obj = self._conn.send_request(FAZMethods.GET, data)
201        code = resp_obj[0]
202        if code != 0:
203            self._module.fail_json(msg=("An error occurred trying to get the ADOM Lock Info. Error: " + str(resp_obj)))
204        elif code == 0:
205            self._lock_info = resp_obj[1]
206        return resp_obj
207
208    def commit_changes(self, adom=None, aux=False):
209        """
210        Commits changes to an ADOM
211        """
212        if not adom or adom == "root":
213            url = "/dvmdb/adom/root/workspace/commit"
214        else:
215            if aux:
216                url = "/pm/config/adom/{adom}/workspace/commit".format(adom=adom)
217            else:
218                if adom.lower() == "global":
219                    url = "/dvmdb/global/workspace/commit/"
220                else:
221                    url = "/dvmdb/adom/{adom}/workspace/commit".format(adom=adom)
222        datagram = {}
223        data = self._tools.format_request(FAZMethods.EXEC, url, **datagram)
224        resp_obj = self._conn.send_request(FAZMethods.EXEC, data)
225        code = resp_obj[0]
226        if code != 0:
227            self._module.fail_json(msg=("An error occurred trying to commit changes to the adom. Error: "
228                                        + str(resp_obj)))
229
230    def govern_response(self, module, results, msg=None, good_codes=None,
231                        stop_on_fail=None, stop_on_success=None, skipped=None,
232                        changed=None, unreachable=None, failed=None, success=None, changed_if_success=None,
233                        ansible_facts=None):
234        """
235        This function will attempt to apply default values to canned responses from FortiAnalyzer we know of.
236        This saves time, and turns the response in the module into a "one-liner", while still giving us...
237        the flexibility to directly use return_response in modules if we have too. This function saves repeated code.
238
239        :param module: The Ansible Module CLASS object, used to run fail/exit json
240        :type module: object
241        :param msg: An overridable custom message from the module that called this.
242        :type msg: string
243        :param results: A dictionary object containing an API call results
244        :type results: dict
245        :param good_codes: A list of exit codes considered successful from FortiAnalyzer
246        :type good_codes: list
247        :param stop_on_fail: If true, stops playbook run when return code is NOT IN good codes (default: true)
248        :type stop_on_fail: boolean
249        :param stop_on_success: If true, stops playbook run when return code is IN good codes (default: false)
250        :type stop_on_success: boolean
251        :param changed: If True, tells Ansible that object was changed (default: false)
252        :type skipped: boolean
253        :param skipped: If True, tells Ansible that object was skipped (default: false)
254        :type skipped: boolean
255        :param unreachable: If True, tells Ansible that object was unreachable (default: false)
256        :type unreachable: boolean
257        :param failed: If True, tells Ansible that execution was a failure. Overrides good_codes. (default: false)
258        :type unreachable: boolean
259        :param success: If True, tells Ansible that execution was a success. Overrides good_codes. (default: false)
260        :type unreachable: boolean
261        :param changed_if_success: If True, defaults to changed if successful if you specify or not"
262        :type changed_if_success: boolean
263        :param ansible_facts: A prepared dictionary of ansible facts from the execution.
264        :type ansible_facts: dict
265        """
266        if module is None and results is None:
267            raise FAZBaseException("govern_response() was called without a module and/or results tuple! Fix!")
268        # Get the Return code from results
269        try:
270            rc = results[0]
271        except BaseException:
272            raise FAZBaseException("govern_response() was called without the return code at results[0]")
273
274        # init a few items
275        rc_data = None
276
277        # Get the default values for the said return code.
278        try:
279            rc_codes = FAZ_RC.get('faz_return_codes')
280            rc_data = rc_codes.get(rc)
281        except BaseException:
282            pass
283
284        if not rc_data:
285            rc_data = {}
286        # ONLY add to overrides if not none -- This is very important that the keys aren't added at this stage
287        # if they are empty. And there aren't that many, so let's just do a few if then statements.
288        if good_codes is not None:
289            rc_data["good_codes"] = good_codes
290        if stop_on_fail is not None:
291            rc_data["stop_on_fail"] = stop_on_fail
292        if stop_on_success is not None:
293            rc_data["stop_on_success"] = stop_on_success
294        if skipped is not None:
295            rc_data["skipped"] = skipped
296        if changed is not None:
297            rc_data["changed"] = changed
298        if unreachable is not None:
299            rc_data["unreachable"] = unreachable
300        if failed is not None:
301            rc_data["failed"] = failed
302        if success is not None:
303            rc_data["success"] = success
304        if changed_if_success is not None:
305            rc_data["changed_if_success"] = changed_if_success
306        if results is not None:
307            rc_data["results"] = results
308        if msg is not None:
309            rc_data["msg"] = msg
310        if ansible_facts is None:
311            rc_data["ansible_facts"] = {}
312        else:
313            rc_data["ansible_facts"] = ansible_facts
314
315        return self.return_response(module=module,
316                                    results=results,
317                                    msg=rc_data.get("msg", "NULL"),
318                                    good_codes=rc_data.get("good_codes", (0,)),
319                                    stop_on_fail=rc_data.get("stop_on_fail", True),
320                                    stop_on_success=rc_data.get("stop_on_success", False),
321                                    skipped=rc_data.get("skipped", False),
322                                    changed=rc_data.get("changed", False),
323                                    changed_if_success=rc_data.get("changed_if_success", False),
324                                    unreachable=rc_data.get("unreachable", False),
325                                    failed=rc_data.get("failed", False),
326                                    success=rc_data.get("success", False),
327                                    ansible_facts=rc_data.get("ansible_facts", dict()))
328
329    def return_response(self, module, results, msg="NULL", good_codes=(0,),
330                        stop_on_fail=True, stop_on_success=False, skipped=False,
331                        changed=False, unreachable=False, failed=False, success=False, changed_if_success=True,
332                        ansible_facts=()):
333        """
334        This function controls the logout and error reporting after an method or function runs. The exit_json for
335        ansible comes from logic within this function. If this function returns just the msg, it means to continue
336        execution on the playbook. It is called from the ansible module, or from the self.govern_response function.
337
338        :param module: The Ansible Module CLASS object, used to run fail/exit json
339        :type module: object
340        :param msg: An overridable custom message from the module that called this.
341        :type msg: string
342        :param results: A dictionary object containing an API call results
343        :type results: dict
344        :param good_codes: A list of exit codes considered successful from FortiAnalyzer
345        :type good_codes: list
346        :param stop_on_fail: If true, stops playbook run when return code is NOT IN good codes (default: true)
347        :type stop_on_fail: boolean
348        :param stop_on_success: If true, stops playbook run when return code is IN good codes (default: false)
349        :type stop_on_success: boolean
350        :param changed: If True, tells Ansible that object was changed (default: false)
351        :type skipped: boolean
352        :param skipped: If True, tells Ansible that object was skipped (default: false)
353        :type skipped: boolean
354        :param unreachable: If True, tells Ansible that object was unreachable (default: false)
355        :type unreachable: boolean
356        :param failed: If True, tells Ansible that execution was a failure. Overrides good_codes. (default: false)
357        :type unreachable: boolean
358        :param success: If True, tells Ansible that execution was a success. Overrides good_codes. (default: false)
359        :type unreachable: boolean
360        :param changed_if_success: If True, defaults to changed if successful if you specify or not"
361        :type changed_if_success: boolean
362        :param ansible_facts: A prepared dictionary of ansible facts from the execution.
363        :type ansible_facts: dict
364
365        :return: A string object that contains an error message
366        :rtype: str
367        """
368
369        # VALIDATION ERROR
370        if (len(results) == 0) or (failed and success) or (changed and unreachable):
371            module.exit_json(msg="Handle_response was called with no results, or conflicting failed/success or "
372                                 "changed/unreachable parameters. Fix the exit code on module. "
373                                 "Generic Failure", failed=True)
374
375        # IDENTIFY SUCCESS/FAIL IF NOT DEFINED
376        if not failed and not success:
377            if len(results) > 0:
378                if results[0] not in good_codes:
379                    failed = True
380                elif results[0] in good_codes:
381                    success = True
382
383        if len(results) > 0:
384            # IF NO MESSAGE WAS SUPPLIED, GET IT FROM THE RESULTS, IF THAT DOESN'T WORK, THEN WRITE AN ERROR MESSAGE
385            if msg == "NULL":
386                try:
387                    msg = results[1]['status']['message']
388                except BaseException:
389                    msg = "No status message returned at results[1][status][message], " \
390                          "and none supplied to msg parameter for handle_response."
391
392            if failed:
393                # BECAUSE SKIPPED/FAILED WILL OFTEN OCCUR ON CODES THAT DON'T GET INCLUDED, THEY ARE CONSIDERED FAILURES
394                # HOWEVER, THEY ARE MUTUALLY EXCLUSIVE, SO IF IT IS MARKED SKIPPED OR UNREACHABLE BY THE MODULE LOGIC
395                # THEN REMOVE THE FAILED FLAG SO IT DOESN'T OVERRIDE THE DESIRED STATUS OF SKIPPED OR UNREACHABLE.
396                if failed and skipped:
397                    failed = False
398                if failed and unreachable:
399                    failed = False
400                if stop_on_fail:
401                    if self._uses_workspace:
402                        try:
403                            self.run_unlock()
404                        except BaseException as err:
405                            raise FAZBaseException(msg=("Couldn't unlock ADOM! Error: " + str(err)))
406                    module.exit_json(msg=msg, failed=failed, changed=changed, unreachable=unreachable, skipped=skipped,
407                                     results=results[1], ansible_facts=ansible_facts, rc=results[0],
408                                     invocation={"module_args": ansible_facts["ansible_params"]})
409            elif success:
410                if changed_if_success:
411                    changed = True
412                    success = False
413                if stop_on_success:
414                    if self._uses_workspace:
415                        try:
416                            self.run_unlock()
417                        except BaseException as err:
418                            raise FAZBaseException(msg=("Couldn't unlock ADOM! Error: " + str(err)))
419                    module.exit_json(msg=msg, success=success, changed=changed, unreachable=unreachable,
420                                     skipped=skipped, results=results[1], ansible_facts=ansible_facts, rc=results[0],
421                                     invocation={"module_args": ansible_facts["ansible_params"]})
422        return msg
423
424    @staticmethod
425    def construct_ansible_facts(response, ansible_params, paramgram, *args, **kwargs):
426        """
427        Constructs a dictionary to return to ansible facts, containing various information about the execution.
428
429        :param response: Contains the response from the FortiAnalyzer.
430        :type response: dict
431        :param ansible_params: Contains the parameters Ansible was called with.
432        :type ansible_params: dict
433        :param paramgram: Contains the paramgram passed to the modules' local modify function.
434        :type paramgram: dict
435        :param args: Free-form arguments that could be added.
436        :param kwargs: Free-form keyword arguments that could be added.
437
438        :return: A dictionary containing lots of information to append to Ansible Facts.
439        :rtype: dict
440        """
441
442        facts = {
443            "response": response,
444            "ansible_params": scrub_dict(ansible_params),
445            "paramgram": scrub_dict(paramgram),
446        }
447
448        if args:
449            facts["custom_args"] = args
450        if kwargs:
451            facts.update(kwargs)
452
453        return facts
454
455    @property
456    def uses_workspace(self):
457        return self._uses_workspace
458
459    @uses_workspace.setter
460    def uses_workspace(self, val):
461        self._uses_workspace = val
462
463    @property
464    def uses_adoms(self):
465        return self._uses_adoms
466
467    @uses_adoms.setter
468    def uses_adoms(self, val):
469        self._uses_adoms = val
470
471    def add_adom_to_lock_list(self, adom):
472        if adom not in self._locked_adom_list:
473            self._locked_adom_list.append(adom)
474
475    def remove_adom_from_lock_list(self, adom):
476        if adom in self._locked_adom_list:
477            self._locked_adom_list.remove(adom)
478