1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

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.
This commit is contained in:
Tim Rupp 2017-10-18 10:05:43 -07:00 committed by John R Barker
parent 91e9aaca28
commit f94d337ef6
2 changed files with 559 additions and 0 deletions

View file

@ -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()

View file

@ -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 <http://www.gnu.org/licenses/>.
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