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