Repository URL to install this package:
|
Version:
6.0.0 ▾
|
#!/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
DOCUMENTATION = r'''
---
module: bigip_command
short_description: Run TMSH and BASH commands on F5 devices
description:
- Sends a TMSH or BASH command to a 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.
- This module is B(not) idempotent, nor will it ever be. It is intended as
a stop-gap measure to satisfy automation requirements until such a time as
a real module has been developed to configure in the way you need.
- If you are using this module, we recommend also filing an issue
to have a B(real) module created for your needs.
version_added: "1.0.0"
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 has expired.
- Only C(tmsh) commands are supported. If you are piping or adding additional
logic that is outside of C(tmsh) (such as grep'ing, awk'ing or other shell
related logic that are not C(tmsh)), this behavior is not supported.
required: True
type: raw
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 the examples.
type: list
elements: str
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.
type: str
choices:
- any
- all
default: all
retries:
description:
- Specifies the number of retries a command should be tried
before it is considered failed. The command is run on the
target device every retry and evaluated against the I(wait_for)
conditionals.
type: int
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.
type: int
default: 1
warn:
description:
- Whether the module should raise warnings related to command idempotency
or not.
- Note that the F5 Ansible developers specifically leave this on to make you
aware that your usage of this module may be better served by official F5
Ansible modules. This module should always be used as a last resort.
default: True
type: bool
chdir:
description:
- Change into this directory before running the command.
type: str
notes:
- When running this module in an HA environment via SSH connection and using a role other than C(admin)
or C(root), you may see a C(Change Pending) status, even if you did not make any changes.
This is being tracked with ID429869.
- When using the bigip_command module with the REST API, there are a number of places regex is used
internally to escape characters such as quotation marks. If your TMSH command contains regex characters itself,
such as datagroup wildcards C(*), then a large amount of escape characters may be needed.
extends_documentation_fragment: f5networks.f5_modules.f5_rest_cli
author:
- Tim Rupp (@caphrim007)
- Wojciech Wypior (@wojtek0806)
'''
EXAMPLES = r'''
- name: run show version on remote devices
bigip_command:
commands: show sys version
provider:
server: lb.mydomain.com
password: secret
user: admin
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
provider:
server: lb.mydomain.com
password: secret
user: admin
register: result
delegate_to: localhost
- name: run multiple commands on remote nodes
bigip_command:
commands:
- show sys version
- list ltm virtual
provider:
server: lb.mydomain.com
password: secret
user: admin
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
provider:
server: lb.mydomain.com
password: secret
user: admin
register: result
delegate_to: localhost
- name: tmsh prefixes will automatically be handled
bigip_command:
commands:
- show sys version
- tmsh list ltm virtual
provider:
server: lb.mydomain.com
password: secret
user: admin
delegate_to: localhost
- name: Delete all LTM nodes in Partition1, assuming no dependencies exist
bigip_command:
commands:
- delete ltm node all
chdir: Partition1
provider:
server: lb.mydomain.com
password: secret
user: admin
delegate_to: localhost
- name: Command that contains wildcard character to be passed to tmsh
bigip_command:
commands:
- modify ltm data-group internal dg_string records add { "my test\\\\\\\*string" { data "value" }}
provider:
server: lb.mydomain.com
password: secret
user: admin
delegate_to: localhost
'''
RETURN = r'''
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: ['...', '...']
warn:
description: Whether or not to raise warnings about modification commands.
returned: changed
type: bool
sample: True
'''
import copy
import re
import shlex
import time
from datetime import datetime
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.six import string_types
from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.parsing import Conditional
from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
ComplexList, to_list
)
from collections import deque
from ..module_utils.bigip import F5RestClient
from ..module_utils.common import (
F5ModuleError, AnsibleF5Parameters, f5_argument_spec, is_cli
)
from ..module_utils.icontrol import tmos_version
from ..module_utils.teem import send_teem
try:
from ..module_utils.common import run_commands
HAS_CLI_TRANSPORT = True
except ImportError:
HAS_CLI_TRANSPORT = False
class NoChangeReporter(object):
stdout_re = [
# A general error when a resource already exists
re.compile(r"The requested.*already exists"),
# Returned when creating a duplicate cli alias
re.compile(r"Data Input Error: shared.*already exists"),
]
def find_no_change(self, responses):
"""Searches the response for something that looks like a change
This method borrows heavily from Ansible's ``_find_prompt`` method
defined in the ``lib/ansible/plugins/connection/network_cli.py::Connection``
class.
Arguments:
response (string): The output from the command.
Returns:
bool: True when change is detected. False otherwise.
"""
for response in responses:
for regex in self.stdout_re:
if regex.search(response):
return True
return False
class Parameters(AnsibleF5Parameters):
returnables = ['stdout', 'stdout_lines', 'warnings', 'executed_commands']
def to_return(self):
result = {}
try:
for returnable in self.returnables:
result[returnable] = getattr(self, returnable)
result = self._filter_params(result)
return result
except Exception:
return result
@property
def raw_commands(self):
if self._values['commands'] is None:
return []
if isinstance(self._values['commands'], string_types):
result = [self._values['commands']]
else:
result = self._values['commands']
return result
def cmd_has_pipe(self, cmd):
lex = shlex.shlex(cmd, posix=True)
lex.whitespace = '|'
lex.whitespace_split = True
return len(list(lex)) > 1
def convert_commands(self, commands):
result = []
for command in commands:
tmp = dict(
command='',
pipeline=''
)
command = command.replace("'", "\\'")
pipeline = command.split('|', 1) if self.cmd_has_pipe(command) else [command]
tmp['command'] = pipeline[0]
try:
tmp['pipeline'] = pipeline[1]
except IndexError:
pass
result.append(tmp)
return result
def convert_commands_cli(self, commands):
result = []
for command in commands:
tmp = dict(
command='',
pipeline=''
)
pipeline = command.split('|', 1) if self.cmd_has_pipe(command) else [command]
tmp['command'] = pipeline[0]
try:
tmp['pipeline'] = pipeline[1]
except IndexError:
pass
result.append(tmp)
return result
def merge_command_dict(self, command):
if command['pipeline'] != '':
escape_patterns = r'([$"])'
command['pipeline'] = re.sub(escape_patterns, r'\\\1', command['pipeline'])
command['command'] = '{0} | {1}'.format(command['command'], command['pipeline']).strip()
def merge_command_dict_cli(self, command):
if command['pipeline'] != '':
command['command'] = '{0} | {1}'.format(command['command'], command['pipeline']).strip()
@property
def rest_commands(self):
# ['list ltm virtual']
commands = self.normalized_commands
commands = self.convert_commands(commands)
if self.chdir:
# ['cd /Common; list ltm virtual']
for command in commands:
self.addon_chdir(command)
# ['tmsh -c "cd /Common; list ltm virtual"']
for command in commands:
self.addon_tmsh(command)
for command in commands:
self.merge_command_dict(command)
result = [x['command'] for x in commands]
return result
@property
def cli_commands(self):
# ['list ltm virtual']
commands = self.normalized_commands
commands = self.convert_commands_cli(commands)
if self.chdir:
# ['cd /Common; list ltm virtual']
for command in commands:
self.addon_chdir(command)
if not self.is_tmsh:
# ['tmsh -c "cd /Common; list ltm virtual"']
for command in commands:
self.addon_tmsh_cli(command)
for command in commands:
self.merge_command_dict_cli(command)
result = [x['command'] for x in commands]
return result
@property
def normalized_commands(self):
if self._values['normalized_commands'] is None:
return None
return deque(self._values['normalized_commands'])
@property
def chdir(self):
if self._values['chdir'] is None:
return None
if self._values['chdir'].startswith('/'):
return self._values['chdir']
return '/{0}'.format(self._values['chdir'])
@property
def user_commands(self):
commands = self.raw_commands
return map(self._ensure_tmsh_prefix, commands)
@property
def wait_for(self):
return self._values['wait_for'] or list()
def addon_tmsh(self, command):
escape_patterns = r'([$"])'
if command['command'].count('"') % 2 != 0:
raise Exception('Double quotes are unbalanced')
command['command'] = re.sub(escape_patterns, r'\\\\\\\1', command['command'])
command['command'] = 'tmsh -c \\\"{0}\\\"'.format(command['command'])
def addon_tmsh_cli(self, command):
if command['command'].count('"') % 2 != 0:
raise Exception('Double quotes are unbalanced')
command['command'] = 'tmsh -c "{0}"'.format(command['command'])
def addon_chdir(self, command):
command['command'] = "cd {0}; {1}".format(self.chdir, command['command'])
class BaseManager(object):
def __init__(self, *args, **kwargs):
self.module = kwargs.get('module', None)
self.client = F5RestClient(**self.module.params)
self.want = Parameters(params=self.module.params)
self.want.update({'module': self.module})
self.changes = Parameters(module=self.module)
self.valid_configs = [
'list', 'show', 'modify cli preference pager disabled'
]
self.changed_command_prefixes = ('modify', 'create', 'delete')
self.warnings = list()
def _to_lines(self, stdout):
lines = list()
for item in stdout:
if isinstance(item, string_types):
item = item.split('\n')
lines.append(item)
return lines
def _announce_warnings(self, result):
warnings = result.pop('warnings', [])
for warning in warnings:
self.module.warn(warning)
def notify_non_idempotent_commands(self, commands):
for index, item in enumerate(commands):
if any(item.startswith(x) for x in self.valid_configs):
return
else:
self.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 "{0}..."'.format(item[0:40])
)
@staticmethod
def normalize_commands(raw_commands):
if not raw_commands:
return None
result = []
for command in raw_commands:
command = command.strip()
if command[0:5] == 'tmsh ':
command = command[4:].strip()
result.append(command)
return result
def parse_commands(self):
results = []
commands = self._transform_to_complex_commands(self.commands)
for index, item in enumerate(commands):
# This needs to be removed so that the ComplexList used in to_commands
# will work correctly.
output = item.pop('output', None)
if output == 'one-line' and 'one-line' not in item['command']:
item['command'] += ' one-line'
elif output == 'text' and 'one-line' in item['command']:
item['command'] = item['command'].replace('one-line', '')
results.append(item)
return results
def execute(self):
if self.want.normalized_commands:
result = self.want.normalized_commands
else:
result = self.normalize_commands(self.want.raw_commands)
self.want.update({'normalized_commands': result})
if not result:
return False
self.notify_non_idempotent_commands(self.want.normalized_commands)
commands = self.parse_commands()
retries = self.want.retries
conditionals = [Conditional(c) for c in self.want.wait_for]
if self.module.check_mode:
return
while retries > 0:
responses = self._execute(commands)
self._check_known_errors(responses)
for item in list(conditionals):
if item(responses):
if self.want.match == 'any':
conditionals = list()
break
conditionals.remove(item)
if not conditionals:
break
time.sleep(self.want.interval)
retries -= 1
else:
failed_conditions = [item.raw for item in conditionals]
errmsg = 'The following wait_for conditional statements have not been satisfied.'
raise F5ModuleError(errmsg, failed_conditions)
stdout_lines = self._to_lines(responses)
changes = {
'stdout': responses,
'stdout_lines': stdout_lines,
'executed_commands': self.commands
}
if self.want.warn:
changes['warnings'] = self.warnings
self.changes = Parameters(params=changes, module=self.module)
return self.determine_change(responses)
def determine_change(self, responses):
changer = NoChangeReporter()
if changer.find_no_change(responses):
return False
if any(x for x in self.want.normalized_commands if x.startswith(self.changed_command_prefixes)):
return True
return False
def _check_known_errors(self, responses):
# A regex to match the error IDs used in the F5 v2 logging framework.
# pattern = r'^[0-9A-Fa-f]+:?\d+?:'
for resp in responses:
if 'usage: tmsh' in resp:
raise F5ModuleError(
"tmsh command printed its 'help' message instead of running your command. "
"This usually indicates unbalanced quotes."
)
def _transform_to_complex_commands(self, commands):
spec = dict(
command=dict(key=True),
output=dict(
default='text',
choices=['text', 'one-line']
),
)
transform = ComplexList(spec, self.module)
result = transform(commands)
return result
class V1Manager(BaseManager):
"""Supports CLI (SSH) communication with the remote device
"""
def _execute(self, commands):
if self.want.is_tmsh:
command = dict(
command="modify cli preference pager disabled"
)
else:
command = dict(
command="tmsh modify cli preference pager disabled"
)
self.execute_on_device(command)
return self.execute_on_device(commands)
@property
def commands(self):
return self.want.cli_commands
def is_tmsh(self):
try:
self.execute_on_device('tmsh -v')
except Exception as ex:
if 'Syntax Error:' in str(ex):
return True
raise
return False
def exec_module(self):
result = dict()
changed = self.execute()
result.update(**self.changes.to_return())
result.update(dict(changed=changed))
self._announce_warnings(result)
return result
def execute(self):
self.want.update({'is_tmsh': self.is_tmsh()})
return super(V1Manager, self).execute()
def execute_on_device(self, commands):
result = run_commands(self.module, commands)
return result
class V2Manager(BaseManager):
"""Supports REST communication with the remote device
"""
def _execute(self, commands):
return self.execute_on_device(commands)
@property
def commands(self):
return self.want.rest_commands
def exec_module(self):
start = datetime.now().isoformat()
version = tmos_version(self.client)
result = dict()
changed = self.execute()
result.update(**self.changes.to_return())
result.update(dict(changed=changed))
self._announce_warnings(result)
send_teem(start, self.client, self.module, version)
return result
def execute_on_device(self, commands):
responses = []
uri = "https://{0}:{1}/mgmt/tm/util/bash".format(
self.client.provider['server'],
self.client.provider['server_port'],
)
for item in to_list(commands):
try:
args = dict(
command='run',
utilCmdArgs='-c "{0}"'.format(item['command'])
)
resp = self.client.api.post(uri, json=args)
response = resp.json()
if 'commandResult' in response:
output = u'{0}'.format(response['commandResult'])
responses.append(output.strip())
except ValueError as ex:
raise F5ModuleError(str(ex))
if 'code' in response and response['code'] == 400:
if 'message' in response:
raise F5ModuleError(response['message'])
else:
raise F5ModuleError(resp.content)
return responses
class ModuleManager(object):
def __init__(self, *args, **kwargs):
self.kwargs = kwargs
self.module = kwargs.get('module', None)
def exec_module(self):
if is_cli(self.module) and HAS_CLI_TRANSPORT:
manager = self.get_manager('v1')
else:
manager = self.get_manager('v2')
result = manager.exec_module()
return result
def get_manager(self, type):
if type == 'v1':
return V1Manager(**self.kwargs)
elif type == 'v2':
return V2Manager(**self.kwargs)
class ArgumentSpec(object):
def __init__(self):
self.supports_check_mode = True
argument_spec = dict(
commands=dict(
type='raw',
required=True
),
wait_for=dict(
type='list',
elements='str',
aliases=['waitfor']
),
match=dict(
default='all',
choices=['any', 'all']
),
retries=dict(
default=10,
type='int'
),
interval=dict(
default=1,
type='int'
),
warn=dict(
type='bool',
default='yes'
),
chdir=dict()
)
# required to add CLI to choices and ssh_keyfile as per documentation
provider_update = dict(
transport=dict(
type='str',
default='rest',
choices=['cli', 'rest']
),
ssh_keyfile=dict(
type='path'
),
)
new_spec = copy.deepcopy(f5_argument_spec)
self.argument_spec = {}
self.argument_spec.update(new_spec)
self.argument_spec['provider']['options'].update(provider_update)
self.argument_spec.update(argument_spec)
def main():
spec = ArgumentSpec()
module = AnsibleModule(
argument_spec=spec.argument_spec,
supports_check_mode=spec.supports_check_mode
)
try:
mm = ModuleManager(module=module)
results = mm.exec_module()
module.exit_json(**results)
except F5ModuleError as ex:
module.fail_json(msg=str(ex))
if __name__ == '__main__':
main()