diff --git a/lib/ansible/modules/network/f5/bigip_smtp.py b/lib/ansible/modules/network/f5/bigip_smtp.py new file mode 100644 index 0000000000..9b90476b0a --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_smtp.py @@ -0,0 +1,530 @@ +#!/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_smtp +short_description: Manages SMTP settings on the BIG-IP +description: + - Allows configuring of the BIG-IP to send mail via an SMTP server by + configuring the parameters of an SMTP server. +version_added: 2.6 +options: + name: + description: + - Specifies the name of the SMTP server configuration. + required: True + partition: + description: + - Device partition to manage resources on. + default: Common + smtp_server: + description: + - SMTP server host name in the format of a fully qualified domain name. + - This value is required when create a new SMTP configuration. + smtp_server_port: + description: + - Specifies the SMTP port number. + - When creating a new SMTP configuration, the default is C(25) when + C(encryption) is C(none) or C(tls). The default is C(465) when C(ssl) + is selected. + local_host_name: + description: + - Host name used in SMTP headers in the format of a fully qualified + domain name. This setting does not refer to the BIG-IP system's hostname. + from_address: + description: + - Email address that the email is being sent from. This is the "Reply-to" + address that the recipient sees. + encryption: + description: + - Specifies whether the SMTP server requires an encrypted connection in + order to send mail. + choices: + - none + - ssl + - tls + authentication: + description: + - Credentials can be set on an SMTP server's configuration even if that + authentication is not used (think staging configs or emergency changes). + This parameter acts as a switch to make the specified C(smtp_server_username) + and C(smtp_server_password) parameters active or not. + - When C(yes), the authentication parameters will be active. + - When C(no), the authentication parameters will be inactive. + type: bool + smtp_server_username: + description: + - User name that the SMTP server requires when validating a user. + smtp_server_password: + description: + - Password that the SMTP server requires when validating a user. + state: + description: + - When C(present), ensures that the SMTP configuration exists. + - When C(absent), ensures that the SMTP configuration does not exist. + required: False + default: present + choices: + - present + - absent + update_password: + description: + - Passwords are stored encrypted, so the module cannot know if the supplied + C(smtp_server_password) is the same or different than the existing password. + This parameter controls the updating of the C(smtp_server_password) + credential. + - When C(always), will always update the password. + - When C(on_create), will only set the password for newly created SMTP server + configurations. + default: always + choices: + - always + - on_create +extends_documentation_fragment: f5 +notes: + - Requires the netaddr Python package on the host. This is as easy as + C(pip install netaddr). +requirements: + - netaddr +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = r''' +- name: Create a base SMTP server configuration + bigip_smtp: + name: my-smtp + smtp_server: 1.1.1.1 + smtp_server_username: mail-admin + smtp_server_password: mail-secret + local_host_name: smtp.mydomain.com + from_address: no-reply@mydomain.com + password: secret + server: lb.mydomain.com + state: present + user: admin + delegate_to: localhost +''' + +RETURN = r''' +smtp_server: + description: The new C(smtp_server) value of the SMTP configuration. + returned: changed + type: string + sample: mail.mydomain.com +smtp_server_port: + description: The new C(smtp_server_port) value of the SMTP configuration. + returned: changed + type: int + sample: 25 +local_host_name: + description: The new C(local_host_name) value of the SMTP configuration. + returned: changed + type: string + sample: smtp.mydomain.com +from_address: + description: The new C(from_address) value of the SMTP configuration. + returned: changed + type: string + sample: no-reply@mydomain.com +encryption: + description: The new C(encryption) value of the SMTP configuration. + returned: changed + type: string + sample: tls +authentication: + description: Whether the authentication parameters are active or not. + returned: changed + type: bool + sample: yes +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback + +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.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 is_valid_hostname + from library.module_utils.network.f5.common import f5_argument_spec + 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.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 is_valid_hostname + from ansible.module_utils.network.f5.common import f5_argument_spec + try: + from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError + except ImportError: + HAS_F5SDK = False + +try: + import netaddr + HAS_NETADDR = True +except ImportError: + HAS_NETADDR = False + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'username': 'smtp_server_username', + 'passwordEncrypted': 'smtp_server_password', + 'localHostName': 'local_host_name', + 'smtpServerHostName': 'smtp_server', + 'smtpServerPort': 'smtp_server_port', + 'encryptedConnection': 'encryption', + 'authenticationEnabled': 'authentication_enabled', + 'authenticationDisabled': 'authentication_disabled', + 'fromAddress': 'from_address' + } + + api_attributes = [ + 'username', 'passwordEncrypted', 'localHostName', 'smtpServerHostName', + 'smtpServerPort', 'encryptedConnection', 'authenticationEnabled', + 'authenticationDisabled', 'fromAddress' + ] + + returnables = [ + 'smtp_server_username', 'smtp_server_password', 'local_host_name', + 'smtp_server', 'smtp_server_port', 'encryption', 'authentication', + 'from_address' + ] + + updatables = [ + 'smtp_server_username', 'smtp_server_password', 'local_host_name', + 'smtp_server', 'smtp_server_port', 'encryption', 'authentication', + 'from_address' + ] + + +class ApiParameters(Parameters): + pass + + +class ModuleParameters(Parameters): + @property + def local_host_name(self): + if self._values['local_host_name'] is None: + return None + try: + # Check for valid IPv4 or IPv6 entries + netaddr.IPNetwork(self._values['local_host_name']) + return self._values['local_host_name'] + except netaddr.core.AddrFormatError: + # else fallback to checking reasonably well formatted hostnames + if is_valid_hostname(self._values['local_host_name']): + return str(self._values['local_host_name']) + raise F5ModuleError( + "The provided 'local_host_name' value {0} is not a valid IP or hostname".format( + str(self._values['local_host_name']) + ) + ) + + @property + def authentication_enabled(self): + if self._values['authentication'] is None: + return None + if self._values['authentication']: + return True + + @property + def authentication_disabled(self): + if self._values['authentication'] is None: + return None + if not self._values['authentication']: + return True + + @property + def smtp_server_port(self): + if self._values['smtp_server_port'] is None: + return None + return int(self._values['smtp_server_port']) + + +class Changes(Parameters): + 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 + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + @property + def smtp_server_password(self): + return None + + @property + def smtp_server_username(self): + return None + + +class Difference(object): + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result + except AttributeError: + return self.__default(param) + + def __default(self, param): + attr1 = getattr(self.want, param) + try: + attr2 = getattr(self.have, param) + if attr1 != attr2: + return attr1 + except AttributeError: + return attr1 + + @property + def smtp_server_password(self): + if self.want.update_password == 'on_create': + return None + return self.want.smtp_server_password + + @property + def authentication(self): + if self.want.authentication_enabled: + if self.want.authentication_enabled != self.have.authentication_enabled: + return dict( + authentication_enabled=self.want.authentication_enabled + ) + if self.want.authentication_disabled: + if self.want.authentication_disabled != self.have.authentication_disabled: + return dict( + authentication_disable=self.want.authentication_disabled + ) + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = kwargs.get('client', None) + self.want = ModuleParameters(params=self.module.params) + self.have = ApiParameters() + self.changes = UsableChanges() + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = Parameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + if isinstance(change, dict): + changed.update(change) + else: + changed[k] = change + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def exec_module(self): + changed = False + result = dict() + state = self.want.state + + try: + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + except iControlUnexpectedHTTPError as e: + raise F5ModuleError(str(e)) + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.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 present(self): + if self.exists(): + return self.update() + else: + return self.create() + + def exists(self): + result = self.client.api.tm.sys.smtp_servers.smtp_server.exists( + name=self.want.name, + partition=self.want.partition + ) + return result + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.module.check_mode: + return True + self.update_on_device() + return True + + def remove(self): + if self.module.check_mode: + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: + return True + self.create_on_device() + return True + + def create_on_device(self): + params = self.want.api_params() + self.client.api.tm.sys.smtp_servers.smtp_server.create( + name=self.want.name, + partition=self.want.partition, + **params + ) + + def update_on_device(self): + params = self.want.api_params() + resource = self.client.api.tm.sys.smtp_servers.smtp_server.load( + name=self.want.name, + partition=self.want.partition + ) + resource.modify(**params) + + def absent(self): + if self.exists(): + return self.remove() + return False + + def remove_from_device(self): + resource = self.client.api.tm.sys.smtp_servers.smtp_server.load( + name=self.want.name, + partition=self.want.partition + ) + if resource: + resource.delete() + + def read_current_from_device(self): + resource = self.client.api.tm.sys.smtp_servers.smtp_server.load( + name=self.want.name, + partition=self.want.partition + ) + result = resource.attrs + return ApiParameters(params=result) + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + name=dict(required=True), + smtp_server=dict(), + smtp_server_port=dict(type='int'), + smtp_server_username=dict(no_log=True), + smtp_server_password=dict(no_log=True), + local_host_name=dict(), + encryption=dict(choices=['none', 'ssl', 'tls']), + update_password=dict( + default='always', + choices=['always', 'on_create'] + ), + from_address=dict(), + authentication=dict(type='bool'), + partition=dict( + default='Common', + fallback=(env_fallback, ['F5_PARTITION']) + ), + state=dict( + default='present', + choices=['present', 'absent'] + ) + ) + self.argument_spec = {} + self.argument_spec.update(f5_argument_spec) + self.argument_spec.update(argument_spec) + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + 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") + if not HAS_NETADDR: + module.fail_json(msg="The python netaddr module is required") + + try: + client = F5Client(**module.params) + mm = ModuleManager(module=module, client=client) + results = mm.exec_module() + cleanup_tokens(client) + module.exit_json(**results) + except F5ModuleError as ex: + cleanup_tokens(client) + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/test_bigip_smtp.py b/test/units/modules/network/f5/test_bigip_smtp.py new file mode 100644 index 0000000000..d24882cffa --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_smtp.py @@ -0,0 +1,152 @@ +# -*- 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 + +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 Mock +from ansible.compat.tests.mock import patch +from ansible.module_utils.basic import AnsibleModule + +try: + from library.modules.bigip_smtp import ApiParameters + from library.modules.bigip_smtp import ModuleParameters + from library.modules.bigip_smtp import ModuleManager + from library.modules.bigip_smtp import ArgumentSpec + 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_smtp import ApiParameters + from ansible.modules.network.f5.bigip_smtp import ModuleParameters + from ansible.modules.network.f5.bigip_smtp import ModuleManager + from ansible.modules.network.f5.bigip_smtp import ArgumentSpec + 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") + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +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( + name='foo', + smtp_server='1.1.1.1', + smtp_server_port='25', + smtp_server_username='admin', + smtp_server_password='password', + local_host_name='smtp.mydomain.com', + encryption='tls', + update_password='always', + from_address='no-reply@mydomain.com', + authentication=True, + ) + + p = ModuleParameters(params=args) + assert p.name == 'foo' + assert p.smtp_server == '1.1.1.1' + assert p.smtp_server_port == 25 + assert p.smtp_server_username == 'admin' + assert p.smtp_server_password == 'password' + assert p.local_host_name == 'smtp.mydomain.com' + assert p.encryption == 'tls' + assert p.update_password == 'always' + assert p.from_address == 'no-reply@mydomain.com' + assert p.authentication_disabled is None + assert p.authentication_enabled is True + + def test_api_parameters(self): + p = ApiParameters(params=load_fixture('load_sys_smtp_server.json')) + assert p.name == 'foo' + assert p.smtp_server == 'mail.foo.bar' + assert p.smtp_server_port == 465 + assert p.smtp_server_username == 'admin' + assert p.smtp_server_password == '$M$Ch$this-is-encrypted==' + assert p.local_host_name == 'mail-host.foo.bar' + assert p.encryption == 'ssl' + assert p.from_address == 'no-reply@foo.bar' + assert p.authentication_disabled is None + assert p.authentication_enabled is True + + +@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_create_monitor(self, *args): + set_module_args(dict( + name='foo', + smtp_server='1.1.1.1', + smtp_server_port='25', + smtp_server_username='admin', + smtp_server_password='password', + local_host_name='smtp.mydomain.com', + encryption='tls', + update_password='always', + from_address='no-reply@mydomain.com', + authentication=True, + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + 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(side_effect=[False, True]) + mm.create_on_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['encryption'] == 'tls' + assert results['smtp_server'] == '1.1.1.1' + assert results['smtp_server_port'] == 25 + assert results['local_host_name'] == 'smtp.mydomain.com' + assert results['authentication'] is True + assert results['from_address'] == 'no-reply@mydomain.com' + assert 'smtp_server_username' not in results + assert 'smtp_server_password' not in results