diff --git a/lib/ansible/modules/network/f5/bigip_remote_syslog.py b/lib/ansible/modules/network/f5/bigip_remote_syslog.py index 151b29da5ab..f0de9a5db76 100644 --- a/lib/ansible/modules/network/f5/bigip_remote_syslog.py +++ b/lib/ansible/modules/network/f5/bigip_remote_syslog.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- # -# Copyright (c) 2017 F5 Networks Inc. +# 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 @@ -25,17 +25,25 @@ options: - Specifies the IP address, or hostname, for the remote system to which the system sends log messages. required: True + name: + description: + - Specifies the name of the syslog object. + - This option is required when multiple C(remote_host) with the same IP + or hostname are present on the device. + - If C(name) is not provided C(remote_host) is used by default. + version_added: 2.8 remote_port: description: - Specifies the port that the system uses to send messages to the - remote logging server. When creating a remote syslog, if this parameter - is not specified, the default value C(514) is used. + remote logging server. + - When creating a remote syslog, if this parameter is not specified, the + default value C(514) is used. local_ip: description: - Specifies the local IP address of the system that is logging. To - provide no local IP, specify the value C(none). When creating a - remote syslog, if this parameter is not specified, the default value - C(none) is used. + provide no local IP, specify the value C(none). + - When creating a remote syslog, if this parameter is not specified, the + default value C(none) is used. state: description: - When C(present), guarantees that the remote syslog exists with the provided @@ -48,26 +56,29 @@ options: extends_documentation_fragment: f5 author: - Tim Rupp (@caphrim007) + - Wojciech Wypior (@wojtek0806) ''' EXAMPLES = r''' - name: Add a remote syslog server to log to bigip_remote_syslog: remote_host: 10.10.10.10 - password: secret - server: lb.mydomain.com - user: admin - validate_certs: no + provider: + password: secret + server: lb.mydomain.com + user: admin + validate_certs: no delegate_to: localhost - name: Add a remote syslog server on a non-standard port to log to bigip_remote_syslog: remote_host: 10.10.10.10 remote_port: 1234 - password: secret - server: lb.mydomain.com - user: admin - validate_certs: no + provider: + password: secret + server: lb.mydomain.com + user: admin + validate_certs: no delegate_to: localhost ''' @@ -84,93 +95,84 @@ local_ip: sample: 10.10.10.10 ''' -import re - from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six import iteritems try: - from library.module_utils.network.f5.bigip import HAS_F5SDK - from library.module_utils.network.f5.bigip import F5Client + from library.module_utils.network.f5.bigip import F5RestClient from library.module_utils.network.f5.common import F5ModuleError from library.module_utils.network.f5.common import AnsibleF5Parameters from library.module_utils.network.f5.common import cleanup_tokens from library.module_utils.network.f5.common import f5_argument_spec + from library.module_utils.network.f5.common import exit_json + from library.module_utils.network.f5.common import fail_json + from library.module_utils.network.f5.common import compare_dictionary + from library.module_utils.network.f5.common import is_valid_hostname + from library.module_utils.network.f5.common import fq_name from library.module_utils.network.f5.ipaddress import is_valid_ip - try: - from library.module_utils.network.f5.common import iControlUnexpectedHTTPError - except ImportError: - HAS_F5SDK = False except ImportError: - from ansible.module_utils.network.f5.bigip import HAS_F5SDK - from ansible.module_utils.network.f5.bigip import F5Client + from ansible.module_utils.network.f5.bigip import F5RestClient from ansible.module_utils.network.f5.common import F5ModuleError from ansible.module_utils.network.f5.common import AnsibleF5Parameters from ansible.module_utils.network.f5.common import cleanup_tokens from ansible.module_utils.network.f5.common import f5_argument_spec + from ansible.module_utils.network.f5.common import exit_json + from ansible.module_utils.network.f5.common import fail_json + from ansible.module_utils.network.f5.common import compare_dictionary + from ansible.module_utils.network.f5.common import is_valid_hostname + from ansible.module_utils.network.f5.common import fq_name from ansible.module_utils.network.f5.ipaddress import is_valid_ip - try: - from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError - except ImportError: - HAS_F5SDK = False class Parameters(AnsibleF5Parameters): + api_map = { + 'remotePort': 'remote_port', + 'localIp': 'local_ip', + 'host': 'remote_host', + } + updatables = [ - 'remote_port', 'local_ip', 'remoteServers' + 'remote_port', + 'local_ip', + 'remote_host', + 'name', ] returnables = [ - 'remote_port', 'local_ip' + 'remote_port', + 'local_ip', + 'remote_host', + 'name', + 'remoteServers', ] api_attributes = [ - 'remoteServers' + 'remotePort', + 'localIp', + 'host', + 'name', + 'remoteServers', ] - def to_return(self): - result = {} - for returnable in self.returnables: - result[returnable] = getattr(self, returnable) - result = self._filter_params(result) - return result +class ApiParameters(Parameters): + pass + + +class ModuleParameters(Parameters): @property def remote_host(self): if is_valid_ip(self._values['remote_host']): return self._values['remote_host'] - elif self.is_valid_hostname(self._values['remote_host']): + elif is_valid_hostname(self._values['remote_host']): return str(self._values['remote_host']) raise F5ModuleError( "The provided 'remote_host' is not a valid IP or hostname" ) - def is_valid_hostname(self, host): - """Reasonable attempt at validating a hostname - - Compiled from various paragraphs outlined here - https://tools.ietf.org/html/rfc3696#section-2 - https://tools.ietf.org/html/rfc1123 - - Notably, - * Host software MUST handle host names of up to 63 characters and - SHOULD handle host names of up to 255 characters. - * The "LDH rule", after the characters that it permits. (letters, digits, hyphen) - * If the hyphen is used, it is not permitted to appear at - either the beginning or end of a label - - :param host: - :return: - """ - if len(host) > 255: - return False - host = host.rstrip(".") - allowed = re.compile(r'(?!-)[A-Z0-9-]{1,63}(? 1: + raise F5ModuleError( + "Multiple occurrences of hostname: {0} detected, please specify 'name' parameter". format(self.want.remote_host) + ) + + # A absent syslog does not appear in the list of existing syslogs + if self.want.state == 'absent': + if self.want.name not in self.syslogs: + return False + + # At this point we know the existing syslog is not absent, so we need + # to change it in some way. + # + # First, if we see that the syslog is in the current list of syslogs, + # we are going to update it + changes = dict(self.changes.api_params()) + if self.want.name in self.syslogs: + self.syslogs[self.want.name].update(changes) + else: + # else, we are going to add it to the list of syslogs + self.syslogs[self.want.name] = changes + + # Since the name attribute is not a parameter tracked in the Parameter + # classes, we will add the name to the list of attributes so that when + # we update the API, it creates the correct vector + self.syslogs[self.want.name].update({'name': self.want.name}) + + # Finally, the absent state forces us to remove the syslog from the + # list. + if self.want.state == 'absent': + del self.syslogs[self.want.name] + + # All of the syslogs must be re-assembled into a list of dictionaries + # so that when we PATCH the API endpoint, the syslogs list is filled + # correctly. + # + # There are **not** individual API endpoints for the individual syslogs. + # Instead, the endpoint includes a list of syslogs that is part of the + # system config + result = [v for k, v in iteritems(self.syslogs)] + + self.changes = Changes(params=dict(remoteServers=result)) + self.changes.update(self.want._values) self.update_on_device() return True - def exists(self): - self.have = self.read_current_from_device() - if self.have.remoteServers is None: - return False - - for server in self.have.remoteServers: - if server['host'] == self.want.remote_host: - return True - def update_on_device(self): params = self.changes.api_params() - result = self.client.api.tm.sys.syslog.load() - result.modify(**params) + params = dict( + remoteServers=params.get('remoteServers') + ) + uri = "https://{0}:{1}/mgmt/tm/sys/syslog/".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.patch(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) def read_current_from_device(self): - resource = self.client.api.tm.sys.syslog.load() - attrs = resource.attrs - result = Parameters(params=attrs) + uri = "https://{0}:{1}/mgmt/tm/sys/syslog/".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.get(uri) + try: + response = resp.json() + 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) + result = response.get('remoteServers', []) return result - def absent(self): - if self.exists(): - return self.remove() - return False - - def remove(self): - if self.module.check_mode: - return True - self.remove_from_device() - if self.exists(): - raise F5ModuleError("Failed to delete the remote syslog.") - return True - - def remove_from_device(self): - self._update_changed_options() - params = self.changes.api_params() - result = self.client.api.tm.sys.syslog.load() - result.modify(**params) - class ArgumentSpec(object): def __init__(self): @@ -440,6 +441,7 @@ class ArgumentSpec(object): ), remote_port=dict(), local_ip=dict(), + name=dict(), state=dict( default='present', choices=['absent', 'present'] @@ -457,18 +459,17 @@ def main(): argument_spec=spec.argument_spec, supports_check_mode=spec.supports_check_mode ) - if not HAS_F5SDK: - module.fail_json(msg="The python f5-sdk module is required") + + client = F5RestClient(**module.params) try: - client = F5Client(**module.params) mm = ModuleManager(module=module, client=client) results = mm.exec_module() cleanup_tokens(client) - module.exit_json(**results) + exit_json(module, results, client) except F5ModuleError as ex: cleanup_tokens(client) - module.fail_json(msg=str(ex)) + fail_json(module, ex, client) if __name__ == '__main__': diff --git a/test/units/modules/network/f5/fixtures/load_tm_sys_syslog.json b/test/units/modules/network/f5/fixtures/load_tm_sys_syslog_1.json similarity index 95% rename from test/units/modules/network/f5/fixtures/load_tm_sys_syslog.json rename to test/units/modules/network/f5/fixtures/load_tm_sys_syslog_1.json index f9f887ed549..47d7628207f 100644 --- a/test/units/modules/network/f5/fixtures/load_tm_sys_syslog.json +++ b/test/units/modules/network/f5/fixtures/load_tm_sys_syslog_1.json @@ -29,7 +29,7 @@ "remotePort": 514 }, { - "name": "/Common/remotesyslog1", + "name": "/Common/remotesyslog2", "host": "20.20.20.20", "localIp": "1.1.1.1", "remotePort": 8000 diff --git a/test/units/modules/network/f5/fixtures/load_tm_sys_syslog_2.json b/test/units/modules/network/f5/fixtures/load_tm_sys_syslog_2.json new file mode 100644 index 00000000000..d1eb8929d55 --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_tm_sys_syslog_2.json @@ -0,0 +1,38 @@ +{ + "kind": "tm:sys:syslog:syslogstate", + "selfLink": "https://localhost/mgmt/tm/sys/syslog?ver=13.0.0", + "authPrivFrom": "notice", + "authPrivTo": "emerg", + "clusteredHostSlot": "enabled", + "clusteredMessageSlot": "disabled", + "consoleLog": "enabled", + "cronFrom": "warning", + "cronTo": "emerg", + "daemonFrom": "notice", + "daemonTo": "emerg", + "isoDate": "disabled", + "kernFrom": "debug", + "kernTo": "emerg", + "local6From": "notice", + "local6To": "emerg", + "mailFrom": "notice", + "mailTo": "emerg", + "messagesFrom": "notice", + "messagesTo": "warning", + "userLogFrom": "notice", + "userLogTo": "emerg", + "remoteServers": [ + { + "name": "/Common/remotesyslog1", + "host": "10.10.10.10", + "localIp": "none", + "remotePort": 514 + }, + { + "name": "/Common/remotesyslog2", + "host": "10.10.10.10", + "localIp": "1.1.1.1", + "remotePort": 8000 + } + ] +} diff --git a/test/units/modules/network/f5/test_bigip_remote_syslog.py b/test/units/modules/network/f5/test_bigip_remote_syslog.py index 9f64773228e..ad52519391a 100644 --- a/test/units/modules/network/f5/test_bigip_remote_syslog.py +++ b/test/units/modules/network/f5/test_bigip_remote_syslog.py @@ -9,41 +9,43 @@ __metaclass__ = type import os import json import sys +import pytest from nose.plugins.skip import SkipTest if sys.version_info < (2, 7): raise SkipTest("F5 Ansible modules require Python >= 2.7") -from units.compat import unittest -from units.compat.mock import Mock -from units.compat.mock import patch from ansible.module_utils.basic import AnsibleModule try: - from library.modules.bigip_remote_syslog import Parameters + from library.modules.bigip_remote_syslog import ApiParameters + from library.modules.bigip_remote_syslog import ModuleParameters from library.modules.bigip_remote_syslog import ModuleManager from library.modules.bigip_remote_syslog import ArgumentSpec - from library.modules.bigip_remote_syslog import HAS_F5SDK - from library.modules.bigip_remote_syslog import HAS_NETADDR - from library.module_utils.network.f5.common import F5ModuleError - from library.module_utils.network.f5.common import iControlUnexpectedHTTPError - from test.unit.modules.utils import set_module_args -except ImportError: - try: - from ansible.modules.network.f5.bigip_remote_syslog import Parameters - from ansible.modules.network.f5.bigip_remote_syslog import ModuleManager - from ansible.modules.network.f5.bigip_remote_syslog import ArgumentSpec - from ansible.modules.network.f5.bigip_remote_syslog import HAS_F5SDK - from ansible.modules.network.f5.bigip_remote_syslog import HAS_NETADDR - from ansible.module_utils.network.f5.common import F5ModuleError - from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError - from units.modules.utils import set_module_args - except ImportError: - raise SkipTest("F5 Ansible modules require the f5-sdk Python library") - from ansible.modules.network.f5.bigip_remote_syslog import HAS_NETADDR - if not HAS_NETADDR: - raise SkipTest("F5 Ansible modules require the netaddr Python library") + from library.module_utils.network.f5.common import F5ModuleError + + # In Ansible 2.8, Ansible changed import paths. + from test.units.compat import unittest + from test.units.compat.mock import Mock + from test.units.compat.mock import patch + + from test.units.modules.utils import set_module_args +except ImportError: + from ansible.modules.network.f5.bigip_remote_syslog import ApiParameters + from ansible.modules.network.f5.bigip_remote_syslog import ModuleParameters + from ansible.modules.network.f5.bigip_remote_syslog import ModuleManager + from ansible.modules.network.f5.bigip_remote_syslog import ArgumentSpec + + from ansible.module_utils.network.f5.common import F5ModuleError + + # Ansible 2.8 imports + from units.compat import unittest + from units.compat.mock import Mock + from units.compat.mock import patch + + from units.modules.utils import set_module_args + fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') fixture_data = {} @@ -75,26 +77,11 @@ class TestParameters(unittest.TestCase): local_ip='1.1.1.1' ) - p = Parameters(params=args) + p = ModuleParameters(params=args) assert p.remote_host == '10.10.10.10' assert p.remote_port == 514 assert p.local_ip == '1.1.1.1' - def test_api_parameters(self): - args = dict( - remoteServers=[ - dict( - name='/Common/remotesyslog1', - host='10.10.10.10', - localIp='none', - remotePort=514 - ) - ] - ) - - p = Parameters(params=args) - assert len(p.remoteServers) == 1 - class TestManager(unittest.TestCase): @@ -103,12 +90,15 @@ class TestManager(unittest.TestCase): def test_create_remote_syslog(self, *args): set_module_args(dict( - remote_host='10.10.10.10', + remote_host='1.1.1.1', server='localhost', password='password', user='admin' )) + fixture = load_fixture('load_tm_sys_syslog_1.json') + current = fixture['remoteServers'] + module = AnsibleModule( argument_spec=self.spec.argument_spec, supports_check_mode=self.spec.supports_check_mode @@ -118,6 +108,7 @@ class TestManager(unittest.TestCase): mm = ModuleManager(module=module) mm.exists = Mock(side_effect=[False, True]) mm.update_on_device = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) results = mm.exec_module() @@ -125,13 +116,16 @@ class TestManager(unittest.TestCase): def test_create_remote_syslog_idempotent(self, *args): set_module_args(dict( + name='remotesyslog1', remote_host='10.10.10.10', server='localhost', password='password', user='admin' )) - current = Parameters(params=load_fixture('load_tm_sys_syslog.json')) + fixture = load_fixture('load_tm_sys_syslog_1.json') + current = fixture['remoteServers'] + module = AnsibleModule( argument_spec=self.spec.argument_spec, supports_check_mode=self.spec.supports_check_mode @@ -155,7 +149,9 @@ class TestManager(unittest.TestCase): user='admin' )) - current = Parameters(params=load_fixture('load_tm_sys_syslog.json')) + fixture = load_fixture('load_tm_sys_syslog_1.json') + current = fixture['remoteServers'] + module = AnsibleModule( argument_spec=self.spec.argument_spec, supports_check_mode=self.spec.supports_check_mode @@ -181,7 +177,9 @@ class TestManager(unittest.TestCase): user='admin' )) - current = Parameters(params=load_fixture('load_tm_sys_syslog.json')) + fixture = load_fixture('load_tm_sys_syslog_1.json') + current = fixture['remoteServers'] + module = AnsibleModule( argument_spec=self.spec.argument_spec, supports_check_mode=self.spec.supports_check_mode @@ -197,3 +195,31 @@ class TestManager(unittest.TestCase): assert results['changed'] is True assert results['local_ip'] == '2.2.2.2' + + def test_update_no_name_dupe_host(self, *args): + set_module_args(dict( + remote_host='10.10.10.10', + local_ip='2.2.2.2', + server='localhost', + password='password', + user='admin' + )) + + fixture = load_fixture('load_tm_sys_syslog_2.json') + current = fixture['remoteServers'] + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode + ) + + # Override methods in the specific type of manager + mm = ModuleManager(module=module) + mm.exists = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) + mm.update_on_device = Mock(return_value=True) + + with pytest.raises(F5ModuleError) as ex: + mm.exec_module() + + assert "Multiple occurrences of hostname" in str(ex)