Adds the bigip_command module to Ansible (#24850)
This patch adds the bigip_command module to Ansible to support arbitrary tmsh command to a F5 BIG-IP.
This commit is contained in:
parent
45e4b8f97a
commit
4fc40304d5
2 changed files with 484 additions and 0 deletions
375
lib/ansible/modules/network/f5/bigip_command.py
Normal file
375
lib/ansible/modules/network/f5/bigip_command.py
Normal file
|
@ -0,0 +1,375 @@
|
||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright 2016 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 License 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/>.
|
||||||
|
|
||||||
|
ANSIBLE_METADATA = {
|
||||||
|
'status': ['preview'],
|
||||||
|
'supported_by': 'community',
|
||||||
|
'metadata_version': '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCUMENTATION = '''
|
||||||
|
---
|
||||||
|
module: bigip_command
|
||||||
|
short_description: Run arbitrary command on F5 devices.
|
||||||
|
description:
|
||||||
|
- Sends an arbitrary command to an BIG-IP node and returns the results
|
||||||
|
read from the device. This module includes an argument that will cause
|
||||||
|
the module to wait for a specific condition before returning or timing
|
||||||
|
out if the condition is not met.
|
||||||
|
version_added: "2.4"
|
||||||
|
options:
|
||||||
|
commands:
|
||||||
|
description:
|
||||||
|
- The commands to send to the remote BIG-IP device over the
|
||||||
|
configured provider. The resulting output from the command
|
||||||
|
is returned. If the I(wait_for) argument is provided, the
|
||||||
|
module is not returned until the condition is satisfied or
|
||||||
|
the number of retries as expired.
|
||||||
|
- The I(commands) argument also accepts an alternative form
|
||||||
|
that allows for complex values that specify the command
|
||||||
|
to run and the output format to return. This can be done
|
||||||
|
on a command by command basis. The complex argument supports
|
||||||
|
the keywords C(command) and C(output) where C(command) is the
|
||||||
|
command to run and C(output) is 'text' or 'one-line'.
|
||||||
|
required: True
|
||||||
|
wait_for:
|
||||||
|
description:
|
||||||
|
- Specifies what to evaluate from the output of the command
|
||||||
|
and what conditionals to apply. This argument will cause
|
||||||
|
the task to wait for a particular conditional to be true
|
||||||
|
before moving forward. If the conditional is not true
|
||||||
|
by the configured retries, the task fails. See examples.
|
||||||
|
aliases: ['waitfor']
|
||||||
|
match:
|
||||||
|
description:
|
||||||
|
- The I(match) argument is used in conjunction with the
|
||||||
|
I(wait_for) argument to specify the match policy. Valid
|
||||||
|
values are C(all) or C(any). If the value is set to C(all)
|
||||||
|
then all conditionals in the I(wait_for) must be satisfied. If
|
||||||
|
the value is set to C(any) then only one of the values must be
|
||||||
|
satisfied.
|
||||||
|
default: all
|
||||||
|
retries:
|
||||||
|
description:
|
||||||
|
- Specifies the number of retries a command should by tried
|
||||||
|
before it is considered failed. The command is run on the
|
||||||
|
target device every retry and evaluated against the I(wait_for)
|
||||||
|
conditionals.
|
||||||
|
default: 10
|
||||||
|
interval:
|
||||||
|
description:
|
||||||
|
- Configures the interval in seconds to wait between retries
|
||||||
|
of the command. If the command does not pass the specified
|
||||||
|
conditional, the interval indicates how to long to wait before
|
||||||
|
trying the command again.
|
||||||
|
default: 1
|
||||||
|
notes:
|
||||||
|
- Requires the f5-sdk Python package on the host. This is as easy as pip
|
||||||
|
install f5-sdk.
|
||||||
|
- Requires Ansible >= 2.3.
|
||||||
|
requirements:
|
||||||
|
- f5-sdk >= 2.2.3
|
||||||
|
extends_documentation_fragment: f5
|
||||||
|
author:
|
||||||
|
- Tim Rupp (@caphrim007)
|
||||||
|
'''
|
||||||
|
|
||||||
|
EXAMPLES = '''
|
||||||
|
- name: run show version on remote devices
|
||||||
|
bigip_command:
|
||||||
|
commands: show sys version
|
||||||
|
server: "lb.mydomain.com"
|
||||||
|
password: "secret"
|
||||||
|
user: "admin"
|
||||||
|
validate_certs: "no"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: run show version and check to see if output contains BIG-IP
|
||||||
|
bigip_command:
|
||||||
|
commands: show sys version
|
||||||
|
wait_for: result[0] contains BIG-IP
|
||||||
|
server: "lb.mydomain.com"
|
||||||
|
password: "secret"
|
||||||
|
user: "admin"
|
||||||
|
validate_certs: "no"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: run multiple commands on remote nodes
|
||||||
|
bigip_command:
|
||||||
|
commands:
|
||||||
|
- show sys version
|
||||||
|
- list ltm virtual
|
||||||
|
server: "lb.mydomain.com"
|
||||||
|
password: "secret"
|
||||||
|
user: "admin"
|
||||||
|
validate_certs: "no"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: run multiple commands and evaluate the output
|
||||||
|
bigip_command:
|
||||||
|
commands:
|
||||||
|
- show sys version
|
||||||
|
- list ltm virtual
|
||||||
|
wait_for:
|
||||||
|
- result[0] contains BIG-IP
|
||||||
|
- result[1] contains my-vs
|
||||||
|
server: "lb.mydomain.com"
|
||||||
|
password: "secret"
|
||||||
|
user: "admin"
|
||||||
|
validate_certs: "no"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: tmsh prefixes will automatically be handled
|
||||||
|
bigip_command:
|
||||||
|
commands:
|
||||||
|
- show sys version
|
||||||
|
- tmsh list ltm virtual
|
||||||
|
server: "lb.mydomain.com"
|
||||||
|
password: "secret"
|
||||||
|
user: "admin"
|
||||||
|
validate_certs: "no"
|
||||||
|
delegate_to: localhost
|
||||||
|
'''
|
||||||
|
|
||||||
|
RETURN = '''
|
||||||
|
stdout:
|
||||||
|
description: The set of responses from the commands
|
||||||
|
returned: always
|
||||||
|
type: list
|
||||||
|
sample: ['...', '...']
|
||||||
|
|
||||||
|
stdout_lines:
|
||||||
|
description: The value of stdout split into a list
|
||||||
|
returned: always
|
||||||
|
type: list
|
||||||
|
sample: [['...', '...'], ['...'], ['...']]
|
||||||
|
|
||||||
|
failed_conditions:
|
||||||
|
description: The list of conditionals that have failed
|
||||||
|
returned: failed
|
||||||
|
type: list
|
||||||
|
sample: ['...', '...']
|
||||||
|
'''
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
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 iControlUnexpectedHTTPError
|
||||||
|
from ansible.module_utils.netcli import FailedConditionsError
|
||||||
|
from ansible.module_utils.six import string_types
|
||||||
|
from ansible.module_utils.netcli import Conditional
|
||||||
|
from ansible.module_utils.network_common import ComplexList
|
||||||
|
from ansible.module_utils.network_common import to_list
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
|
||||||
|
class Parameters(AnsibleF5Parameters):
|
||||||
|
returnables = ['stdout', 'stdout_lines', 'warnings']
|
||||||
|
|
||||||
|
def to_return(self):
|
||||||
|
result = {}
|
||||||
|
for returnable in self.returnables:
|
||||||
|
result[returnable] = getattr(self, returnable)
|
||||||
|
result = self._filter_params(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commands(self):
|
||||||
|
commands = deque(self._values['commands'])
|
||||||
|
commands.appendleft(
|
||||||
|
'tmsh modify cli preference pager disabled'
|
||||||
|
)
|
||||||
|
commands = map(self._ensure_tmsh_prefix, list(commands))
|
||||||
|
return list(commands)
|
||||||
|
|
||||||
|
def _ensure_tmsh_prefix(self, cmd):
|
||||||
|
cmd = cmd.strip()
|
||||||
|
if cmd[0:5] != 'tmsh ':
|
||||||
|
cmd = 'tmsh ' + cmd.strip()
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleManager(object):
|
||||||
|
def __init__(self, client):
|
||||||
|
self.client = client
|
||||||
|
self.want = Parameters(self.client.module.params)
|
||||||
|
self.changes = Parameters()
|
||||||
|
|
||||||
|
def _to_lines(self, stdout):
|
||||||
|
lines = list()
|
||||||
|
for item in stdout:
|
||||||
|
if isinstance(item, string_types):
|
||||||
|
item = str(item).split('\n')
|
||||||
|
lines.append(item)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _is_valid_mode(self, cmd):
|
||||||
|
valid_configs = [
|
||||||
|
'tmsh list', 'tmsh show',
|
||||||
|
'tmsh modify cli preference pager disabled'
|
||||||
|
]
|
||||||
|
if any(cmd.startswith(x) for x in valid_configs):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def exec_module(self):
|
||||||
|
result = dict()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.execute()
|
||||||
|
except iControlUnexpectedHTTPError as e:
|
||||||
|
raise F5ModuleError(str(e))
|
||||||
|
|
||||||
|
result.update(**self.changes.to_return())
|
||||||
|
result.update(dict(changed=True))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
warnings = list()
|
||||||
|
|
||||||
|
commands = self.parse_commands(warnings)
|
||||||
|
|
||||||
|
wait_for = self.want.wait_for or list()
|
||||||
|
retries = self.want.retries
|
||||||
|
|
||||||
|
conditionals = [Conditional(c) for c in wait_for]
|
||||||
|
|
||||||
|
if self.client.check_mode:
|
||||||
|
return
|
||||||
|
|
||||||
|
while retries > 0:
|
||||||
|
responses = self.execute_on_device(commands)
|
||||||
|
|
||||||
|
for item in list(conditionals):
|
||||||
|
if item(responses):
|
||||||
|
if self.want.match == 'any':
|
||||||
|
return item
|
||||||
|
conditionals.remove(item)
|
||||||
|
|
||||||
|
if not conditionals:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(self.want.interval)
|
||||||
|
retries -= 1
|
||||||
|
else:
|
||||||
|
failed_conditions = [item.raw for item in conditionals]
|
||||||
|
errmsg = 'One or more conditional statements have not been satisfied'
|
||||||
|
raise FailedConditionsError(errmsg, failed_conditions)
|
||||||
|
|
||||||
|
self.changes = Parameters({
|
||||||
|
'stdout': responses,
|
||||||
|
'stdout_lines': self._to_lines(responses),
|
||||||
|
'warnings': warnings
|
||||||
|
})
|
||||||
|
|
||||||
|
def parse_commands(self, warnings):
|
||||||
|
results = []
|
||||||
|
commands = list(deque(set(self.want.commands)))
|
||||||
|
spec = dict(
|
||||||
|
command=dict(key=True),
|
||||||
|
output=dict(
|
||||||
|
default='text',
|
||||||
|
choices=['text', 'one-line']
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
transform = ComplexList(spec, self.client.module)
|
||||||
|
commands = transform(commands)
|
||||||
|
|
||||||
|
for index, item in enumerate(commands):
|
||||||
|
if not self._is_valid_mode(item['command']):
|
||||||
|
warnings.append(
|
||||||
|
'Using "write" commands is not idempotent. You should use '
|
||||||
|
'a module that is specifically made for that. If such a '
|
||||||
|
'module does not exist, then please file a bug. The command '
|
||||||
|
'in question is "%s..."' % item['command'][0:40]
|
||||||
|
)
|
||||||
|
if item['output'] == 'one-line' and 'one-line' not in item['command']:
|
||||||
|
item['command'] += ' one-line'
|
||||||
|
elif item['output'] == 'text' and 'one-line' in item['command']:
|
||||||
|
item['command'] = item['command'].replace('one-line', '')
|
||||||
|
results.append(item)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def execute_on_device(self, commands):
|
||||||
|
responses = []
|
||||||
|
for item in to_list(commands):
|
||||||
|
output = self.client.api.tm.util.bash.exec_cmd(
|
||||||
|
'run',
|
||||||
|
utilCmdArgs='-c "{0}"'.format(item['command'])
|
||||||
|
)
|
||||||
|
if hasattr(output, 'commandResult'):
|
||||||
|
responses.append(str(output.commandResult))
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentSpec(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.supports_check_mode = True
|
||||||
|
self.argument_spec = dict(
|
||||||
|
commands=dict(
|
||||||
|
type='list',
|
||||||
|
required=True
|
||||||
|
),
|
||||||
|
wait_for=dict(
|
||||||
|
type='list',
|
||||||
|
aliases=['waitfor']
|
||||||
|
),
|
||||||
|
match=dict(
|
||||||
|
default='all',
|
||||||
|
choices=['any', 'all']
|
||||||
|
),
|
||||||
|
retries=dict(
|
||||||
|
default=10,
|
||||||
|
type='int'
|
||||||
|
),
|
||||||
|
interval=dict(
|
||||||
|
default=1,
|
||||||
|
type='int'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.f5_product_name = 'bigip'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not HAS_F5SDK:
|
||||||
|
raise F5ModuleError("The python f5-sdk module is required")
|
||||||
|
|
||||||
|
spec = ArgumentSpec()
|
||||||
|
|
||||||
|
client = AnsibleF5Client(
|
||||||
|
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 (FailedConditionsError, AttributeError) as e:
|
||||||
|
client.module.fail_json(msg=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
109
test/units/modules/network/f5/test_bigip_command.py
Normal file
109
test/units/modules/network/f5/test_bigip_command.py
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
# -*- 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 License 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 sys
|
||||||
|
|
||||||
|
if sys.version_info < (2, 7):
|
||||||
|
from nose.plugins.skip import SkipTest
|
||||||
|
raise SkipTest("F5 Ansible modules require Python >= 2.7")
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
|
from library.bigip_command import Parameters
|
||||||
|
from library.bigip_command import ModuleManager
|
||||||
|
from library.bigip_command import ArgumentSpec
|
||||||
|
except ImportError:
|
||||||
|
from ansible.modules.network.f5.bigip_command import Parameters
|
||||||
|
from ansible.modules.network.f5.bigip_command import ModuleManager
|
||||||
|
from ansible.modules.network.f5.bigip_command import ArgumentSpec
|
||||||
|
|
||||||
|
fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures')
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
with open(path) as f:
|
||||||
|
data = f.read()
|
||||||
|
try:
|
||||||
|
data = json.loads(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class TestParameters(unittest.TestCase):
|
||||||
|
def test_module_parameters(self):
|
||||||
|
args = dict(
|
||||||
|
commands=[
|
||||||
|
"tmsh show sys version"
|
||||||
|
],
|
||||||
|
server='localhost',
|
||||||
|
user='admin',
|
||||||
|
password='password'
|
||||||
|
)
|
||||||
|
p = Parameters(args)
|
||||||
|
assert len(p.commands) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@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_run_single_command(self, *args):
|
||||||
|
set_module_args(dict(
|
||||||
|
commands=[
|
||||||
|
"tmsh show sys version"
|
||||||
|
],
|
||||||
|
server='localhost',
|
||||||
|
user='admin',
|
||||||
|
password='password'
|
||||||
|
))
|
||||||
|
|
||||||
|
client = AnsibleF5Client(
|
||||||
|
argument_spec=self.spec.argument_spec,
|
||||||
|
supports_check_mode=self.spec.supports_check_mode,
|
||||||
|
f5_product_name=self.spec.f5_product_name
|
||||||
|
)
|
||||||
|
mm = ModuleManager(client)
|
||||||
|
|
||||||
|
# Override methods to force specific logic in the module to happen
|
||||||
|
mm.execute_on_device = Mock(return_value='foo')
|
||||||
|
|
||||||
|
results = mm.exec_module()
|
||||||
|
|
||||||
|
assert results['changed'] is True
|
Loading…
Add table
Reference in a new issue