From f94d337ef6f764ec51885f6523468642db80ad82 Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Wed, 18 Oct 2017 10:05:43 -0700 Subject: [PATCH] Adds new module allowing you to wait for a bigip (#31846) Module allows you to wait for a bigip device to be "ready" for configuration. This module will wait for things like the device coming online as well as the REST API and MCPD being ready. If all of the above is not online and ready, then no configuration will be able to be made. --- lib/ansible/modules/network/f5/bigip_wait.py | 419 ++++++++++++++++++ .../modules/network/f5/test_bigip_wait.py | 140 ++++++ 2 files changed, 559 insertions(+) create mode 100644 lib/ansible/modules/network/f5/bigip_wait.py create mode 100644 test/units/modules/network/f5/test_bigip_wait.py diff --git a/lib/ansible/modules/network/f5/bigip_wait.py b/lib/ansible/modules/network/f5/bigip_wait.py new file mode 100644 index 00000000000..8708ec27a61 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_wait.py @@ -0,0 +1,419 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: bigip_wait +short_description: Wait for a BIG-IP condition before continuing +description: + - You can wait for BIG-IP to be "ready". By "ready", we mean that BIG-IP is ready + to accept configuration. + - This module can take into account situations where the device is in the middle + of rebooting due to a configuration change. +version_added: "2.5" +options: + timeout: + description: + - Maximum number of seconds to wait for. + - When used without other conditions it is equivalent of just sleeping. + - The default timeout is deliberately set to 2 hours because no individual + REST API. + default: 7200 + delay: + description: + - Number of seconds to wait before starting to poll. + default: 0 + sleep: + default: 1 + description: + - Number of seconds to sleep between checks, before 2.3 this was hardcoded to 1 second. + msg: + description: + - This overrides the normal error message from a failure to meet the required conditions. +notes: + - Requires the f5-sdk Python package on the host. This is as easy as pip + install f5-sdk. +requirements: + - f5-sdk >= 2.2.3 +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = r''' +- name: Wait for BIG-IP to be ready to take configuration + bigip_wait: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost + +- name: Wait a maximum of 300 seconds for BIG-IP to be ready to take configuration + bigip_wait: + timeout: 300 + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost + +- name: Wait for BIG-IP to be ready, don't start checking for 10 seconds + bigip_wait: + delay: 10 + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost +''' + +RETURN = r''' +# only common fields returned +''' + +import datetime +import signal +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.f5_utils import AnsibleF5Client +from ansible.module_utils.f5_utils import AnsibleF5Parameters +from ansible.module_utils.f5_utils import HAS_F5SDK +from ansible.module_utils.f5_utils import F5ModuleError +from ansible.module_utils.f5_utils import F5_COMMON_ARGS +from ansible.module_utils.six import iteritems +from collections import defaultdict + +try: + from f5.bigip import ManagementRoot as BigIpMgmt + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError +except ImportError: + HAS_F5SDK = False + + +def hard_timeout(client, want, start): + elapsed = datetime.datetime.utcnow() - start + client.module.fail_json( + want.msg or "Timeout when waiting for BIG-IP", elapsed=elapsed.seconds + ) + + +class AnsibleF5ClientStub(AnsibleF5Client): + """Interim class to disconnect Params from connection + + This module is an interim class that was made to separate the Ansible Module + Parameters from the connection to BIG-IP. + + Since this module needs to be able to control the connection process, the default + class is not appropriate. Therefore, we overload it and re-define out the + connection related work to a separate method. + + This class should serve as a reason to break apart this work itself into separate + classes in module_utils. There will be on-going work to do this and, when done, + the result will replace this work here. + + """ + def __init__(self, argument_spec=None, supports_check_mode=False, + mutually_exclusive=None, required_together=None, + required_if=None, required_one_of=None, add_file_common_args=False, + f5_product_name='bigip'): + self.f5_product_name = f5_product_name + + merged_arg_spec = dict() + merged_arg_spec.update(F5_COMMON_ARGS) + if argument_spec: + merged_arg_spec.update(argument_spec) + self.arg_spec = merged_arg_spec + + mutually_exclusive_params = [] + if mutually_exclusive: + mutually_exclusive_params += mutually_exclusive + + required_together_params = [] + if required_together: + required_together_params += required_together + + self.module = AnsibleModule( + argument_spec=merged_arg_spec, + supports_check_mode=supports_check_mode, + mutually_exclusive=mutually_exclusive_params, + required_together=required_together_params, + required_if=required_if, + required_one_of=required_one_of, + add_file_common_args=add_file_common_args + ) + + self.check_mode = self.module.check_mode + self._connect_params = self._get_connect_params() + + def connect(self): + try: + if 'transport' not in self.module.params or self.module.params['transport'] != 'cli': + self.api = self._get_mgmt_root( + self.f5_product_name, **self._connect_params + ) + return True + except Exception: + return False + + def _get_mgmt_root(self, type, **kwargs): + if type == 'bigip': + result = BigIpMgmt( + kwargs['server'], + kwargs['user'], + kwargs['password'], + port=kwargs['server_port'], + timeout=1, + token='tmos' + ) + return result + + +class Parameters(AnsibleF5Parameters): + returnables = [ + 'elapsed' + ] + + def __init__(self, params=None): + self._values = defaultdict(lambda: None) + if params: + self.update(params=params) + self._values['__warnings'] = [] + + def update(self, params=None): + if params: + for k, v in iteritems(params): + if self.api_map is not None and k in self.api_map: + map_key = self.api_map[k] + else: + map_key = k + + # Handle weird API parameters like `dns.proxy.__iter__` by + # using a map provided by the module developer + class_attr = getattr(type(self), map_key, None) + if isinstance(class_attr, property): + # There is a mapped value for the api_map key + if class_attr.fset is None: + # If the mapped value does not have an associated setter + self._values[map_key] = v + else: + # The mapped value has a setter + setattr(self, map_key, v) + else: + # If the mapped value is not a @property + self._values[map_key] = v + + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + pass + return result + + @property + def delay(self): + if self._values['delay'] is None: + return None + return int(self._values['delay']) + + @property + def timeout(self): + if self._values['timeout'] is None: + return None + return int(self._values['timeout']) + + @property + def sleep(self): + if self._values['sleep'] is None: + return None + return int(self._values['sleep']) + + +class Changes(Parameters): + pass + + +class ModuleManager(object): + def __init__(self, client): + self.client = client + self.have = None + self.want = Parameters(self.client.module.params) + self.changes = Parameters() + + def exec_module(self): + result = dict() + + try: + changed = self.execute() + except iControlUnexpectedHTTPError as e: + raise F5ModuleError(str(e)) + + changes = self.changes.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + return result + + def _announce_deprecations(self, result): + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def execute(self): + signal.signal( + signal.SIGALRM, + lambda sig, frame: hard_timeout(self.client, self.want, start) + ) + + # setup handler before scheduling signal, to eliminate a race + signal.alarm(int(self.want.timeout)) + + start = datetime.datetime.utcnow() + if self.want.delay: + time.sleep(float(self.want.delay)) + end = start + datetime.timedelta(seconds=int(self.want.timeout)) + while datetime.datetime.utcnow() < end: + time.sleep(int(self.want.sleep)) + try: + # The first test verifies that the REST API is available; this is done + # by repeatedly trying to login to it. + connected = self._connect_to_device() + if not connected: + continue + + if self._device_is_rebooting(): + # Wait for the reboot to happen and then start from the beginning + # of the waiting. + continue + + if self._is_mprov_running_on_device(): + self._wait_for_module_provisioning() + break + except Exception: + # The types of exception's we're handling here are "REST API is not + # ready" exceptions. + # + # For example, + # + # Typically caused by device starting up: + # + # icontrol.exceptions.iControlUnexpectedHTTPError: 404 Unexpected Error: + # Not Found for uri: https://localhost:10443/mgmt/tm/sys/ + # icontrol.exceptions.iControlUnexpectedHTTPError: 503 Unexpected Error: + # Service Temporarily Unavailable for uri: https://localhost:10443/mgmt/tm/sys/ + # + # + # Typically caused by a device being down + # + # requests.exceptions.SSLError: HTTPSConnectionPool(host='localhost', port=10443): + # Max retries exceeded with url: /mgmt/tm/sys/ (Caused by SSLError( + # SSLError("bad handshake: SysCallError(-1, 'Unexpected EOF')",),)) + # + # + # Typically caused by device still booting + # + # raise SSLError(e, request=request)\nrequests.exceptions.SSLError: + # HTTPSConnectionPool(host='localhost', port=10443): Max retries + # exceeded with url: /mgmt/shared/authn/login (Caused by + # SSLError(SSLError(\"bad handshake: SysCallError(-1, 'Unexpected EOF')\",),)), + continue + else: + elapsed = datetime.datetime.utcnow() - start + self.client.module.fail_json( + msg=self.want.msg or "Timeout when waiting for BIG-IP", elapsed=elapsed.seconds + ) + elapsed = datetime.datetime.utcnow() - start + self.changes.update({'elapsed': elapsed.seconds}) + return False + + def _connect_to_device(self): + result = self.client.connect() + return result + + def _device_is_rebooting(self): + output = self.client.api.tm.util.bash.exec_cmd( + 'run', + utilCmdArgs='-c "runlevel"' + ) + try: + if '6' in output.commandResult: + return True + except AttributeError: + return False + + def _wait_for_module_provisioning(self): + # To prevent things from running forever, the hack is to check + # for mprov's status twice. If mprov is finished, then in most + # cases (not ASM) the provisioning is probably ready. + nops = 0 + # Sleep a little to let provisioning settle and begin properly + time.sleep(5) + while nops < 4: + try: + if not self._is_mprov_running_on_device(): + nops += 1 + else: + nops = 0 + except Exception: + # This can be caused by restjavad restarting. + pass + time.sleep(10) + + def _is_mprov_running_on_device(self): + output = self.client.api.tm.util.bash.exec_cmd( + 'run', + utilCmdArgs='-c "ps aux | grep \'[m]prov\'"' + ) + if hasattr(output, 'commandResult'): + return True + return False + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + self.argument_spec = dict( + timeout=dict(default=7200, type='int'), + delay=dict(default=0, type='int'), + sleep=dict(default=1, type='int'), + msg=dict() + ) + self.f5_product_name = 'bigip' + + +def main(): + if not HAS_F5SDK: + raise F5ModuleError("The python f5-sdk module is required") + + spec = ArgumentSpec() + + client = AnsibleF5ClientStub( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + f5_product_name=spec.f5_product_name, + ) + + try: + mm = ModuleManager(client) + results = mm.exec_module() + client.module.exit_json(**results) + except F5ModuleError as e: + client.module.fail_json(msg=str(e)) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/test_bigip_wait.py b/test/units/modules/network/f5/test_bigip_wait.py new file mode 100644 index 00000000000..4bac6731579 --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_wait.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2017 F5 Networks Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public Liccense for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import json +import pytest +import sys + +from nose.plugins.skip import SkipTest +if sys.version_info < (2, 7): + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch, Mock +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes +from ansible.module_utils.f5_utils import AnsibleF5Client +from ansible.module_utils.f5_utils import F5ModuleError + +try: + from library.bigip_wait import Parameters + from library.bigip_wait import ModuleManager + from library.bigip_wait import ArgumentSpec + from library.bigip_wait import AnsibleF5ClientStub + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError +except ImportError: + try: + from ansible.modules.network.f5.bigip_wait import Parameters + from ansible.modules.network.f5.bigip_wait import ModuleManager + from ansible.modules.network.f5.bigip_wait import ArgumentSpec + from ansible.modules.network.f5.bigip_wait import AnsibleF5ClientStub + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError + except ImportError: + raise SkipTest("F5 Ansible modules require the f5-sdk Python library") + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + args = dict( + delay=3, + timeout=500, + sleep=10, + msg='We timed out during waiting for BIG-IP :-(' + ) + + p = Parameters(args) + assert p.delay == 3 + assert p.timeout == 500 + assert p.sleep == 10 + assert p.msg == 'We timed out during waiting for BIG-IP :-(' + + def test_module_string_parameters(self): + args = dict( + delay='3', + timeout='500', + sleep='10', + msg='We timed out during waiting for BIG-IP :-(' + ) + + p = Parameters(args) + assert p.delay == 3 + assert p.timeout == 500 + assert p.sleep == 10 + assert p.msg == 'We timed out during waiting for BIG-IP :-(' + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestManager(unittest.TestCase): + def setUp(self): + self.spec = ArgumentSpec() + + def test_wait_already_available(self, *args): + set_module_args(dict( + password='passsword', + server='localhost', + user='admin' + )) + + client = AnsibleF5ClientStub( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + # Override methods to force specific logic in the module to happen + mm = ModuleManager(client) + mm._connect_to_device = Mock(return_value=True) + mm._device_is_rebooting = Mock(return_value=False) + mm._is_mprov_running_on_device = Mock(return_value=False) + + results = mm.exec_module() + + assert results['changed'] is False + assert results['elapsed'] == 1