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 0000000000..8708ec27a6 --- /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 0000000000..4bac673157 --- /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