diff --git a/lib/ansible/modules/network/f5/bigip_virtual_address.py b/lib/ansible/modules/network/f5/bigip_virtual_address.py new file mode 100644 index 0000000000..48dd932e87 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_virtual_address.py @@ -0,0 +1,543 @@ +#!/usr/bin/python +# -*- 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 . + +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.0' +} + +DOCUMENTATION = ''' +--- +module: bigip_virtual_address +short_description: Manage LTM virtual addresses on a BIG-IP. +description: + - Manage LTM virtual addresses on a BIG-IP. +version_added: "2.4" +options: + address: + description: + - Virtual address. This value cannot be modified after it is set. + required: True + aliases: + - name + netmask: + description: + - Netmask of the provided virtual address. This value cannot be + modified after it is set. + default: 255.255.255.255 + connection_limit: + description: + - Specifies the number of concurrent connections that the system + allows on this virtual address. + arp_state: + description: + - Specifies whether the system accepts ARP requests. When (disabled), + specifies that the system does not accept ARP requests. Note that + both ARP and ICMP Echo must be disabled in order for forwarding + virtual servers using that virtual address to forward ICMP packets. + If (enabled), then the packets are dropped. + choices: + - enabled + - disabled + auto_delete: + description: + - Specifies whether the system automatically deletes the virtual + address with the deletion of the last associated virtual server. + When C(disabled), specifies that the system leaves the virtual + address even when all associated virtual servers have been deleted. + When creating the virtual address, the default value is C(enabled). + choices: + - enabled + - disabled + icmp_echo: + description: + - Specifies how the systems sends responses to (ICMP) echo requests + on a per-virtual address basis for enabling route advertisement. + When C(enabled), the BIG-IP system intercepts ICMP echo request + packets and responds to them directly. When C(disabled), the BIG-IP + system passes ICMP echo requests through to the backend servers. + When (selective), causes the BIG-IP system to internally enable or + disable responses based on virtual server state; C(when_any_available), + C(when_all_available, or C(always), regardless of the state of any + virtual servers. + choices: + - enabled + - disabled + - selective + state: + description: + - The virtual address state. If C(absent), an attempt to delete the + virtual address will be made. This will only succeed if this + virtual address is not in use by a virtual server. C(present) creates + the virtual address and enables it. If C(enabled), enable the virtual + address if it exists. If C(disabled), create the virtual address if + needed, and set state to C(disabled). + default: present + choices: + - present + - absent + - enabled + - disabled + advertise_route: + description: + - Specifies what routes of the virtual address the system advertises. + When C(when_any_available), advertises the route when any virtual + server is available. When C(when_all_available), advertises the + route when all virtual servers are available. When (always), always + advertises the route regardless of the virtual servers available. + choices: + - always + - when_all_available + - when_any_available + use_route_advertisement: + description: + - Specifies whether the system uses route advertisement for this + virtual address. When disabled, the system does not advertise + routes for this virtual address. + choices: + - yes + - no +notes: + - Requires the f5-sdk Python package on the host. This is as easy as pip + install f5-sdk. + - Requires the netaddr Python package on the host. This is as easy as pip + install netaddr. +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = ''' +- name: Add virtual address + bigip_virtual_address: + server: "lb.mydomain.net" + user: "admin" + password: "secret" + state: "present" + partition: "Common" + address: "10.10.10.10" + delegate_to: localhost + +- name: Enable route advertisement on the virtual address + bigip_virtual_address: + server: "lb.mydomain.net" + user: "admin" + password: "secret" + state: "present" + address: "10.10.10.10" + use_route_advertisement: yes + delegate_to: localhost +''' + +RETURN = ''' +use_route_advertisement: + description: The new setting for whether to use route advertising or not. + returned: changed + type: bool + sample: true +auto_delete: + description: New setting for auto deleting virtual address. + returned: changed + type: string + sample: enabled +icmp_echo: + description: New ICMP echo setting applied to virtual address. + returned: changed + type: string + sample: disabled +connection_limit: + description: The new connection limit of the virtual address. + returned: changed + type: int + sample: 1000 +netmask: + description: The netmask of the virtual address. + returned: created + type: int + sample: 2345 +arp_state: + description: The new way the virtual address handles ARP requests. + returned: changed + type: string + sample: disabled +address: + description: The address of the virtual address. + returned: created + type: int + sample: 2345 +state: + description: The new state of the virtual address. + returned: changed + type: string + sample: disabled +''' + +try: + import netaddr + HAS_NETADDR = True +except ImportError: + HAS_NETADDR = False + +from ansible.module_utils.basic import BOOLEANS_TRUE +from ansible.module_utils.basic import BOOLEANS_FALSE +from ansible.module_utils.f5_utils import ( + AnsibleF5Client, + AnsibleF5Parameters, + HAS_F5SDK, + F5ModuleError, + iControlUnexpectedHTTPError +) + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'routeAdvertisement': 'use_route_advertisement', + 'autoDelete': 'auto_delete', + 'icmpEcho': 'icmp_echo', + 'connectionLimit': 'connection_limit', + 'serverScope': 'advertise_route', + 'mask': 'netmask', + 'arp': 'arp_state' + } + + updatables = [ + 'use_route_advertisement', 'auto_delete', 'icmp_echo', 'connection_limit', + 'arp_state', 'enabled', 'advertise_route' + ] + + returnables = [ + 'use_route_advertisement', 'auto_delete', 'icmp_echo', 'connection_limit', + 'netmask', 'arp_state', 'address', 'state' + ] + + api_attributes = [ + 'routeAdvertisement', 'autoDelete', 'icmpEcho', 'connectionLimit', + 'advertiseRoute', 'arp', 'mask', 'enabled', 'serverScope' + ] + + @property + def advertise_route(self): + if self._values['advertise_route'] is None: + return None + elif self._values['advertise_route'] in ['any', 'when_any_available']: + return 'any' + elif self._values['advertise_route'] in ['all', 'when_all_available']: + return 'all' + elif self._values['advertise_route'] in ['none', 'always']: + return 'none' + + @property + def connection_limit(self): + if self._values['connection_limit'] is None: + return None + return int(self._values['connection_limit']) + + @property + def use_route_advertisement(self): + if self._values['use_route_advertisement'] is None: + return None + elif self._values['use_route_advertisement'] in BOOLEANS_TRUE: + return 'enabled' + elif self._values['use_route_advertisement'] == 'enabled': + return 'enabled' + else: + return 'disabled' + + @property + def enabled(self): + if self._values['state'] in ['enabled', 'present']: + return 'yes' + elif self._values['enabled'] in BOOLEANS_TRUE: + return 'yes' + elif self._values['state'] == 'disabled': + return 'no' + elif self._values['enabled'] in BOOLEANS_FALSE: + return 'no' + else: + return None + + @property + def address(self): + if self._values['address'] is None: + return None + try: + ip = netaddr.IPAddress(self._values['address']) + return str(ip) + except netaddr.core.AddrFormatError: + raise F5ModuleError( + "The provided 'address' is not a valid IP address" + ) + + @property + def netmask(self): + if self._values['netmask'] is None: + return None + try: + ip = netaddr.IPAddress(self._values['netmask']) + return str(ip) + except netaddr.core.AddrFormatError: + raise F5ModuleError( + "The provided 'netmask' is not a valid IP address" + ) + + @property + def auto_delete(self): + if self._values['auto_delete'] is None: + return None + elif self._values['auto_delete'] in BOOLEANS_TRUE: + return True + elif self._values['auto_delete'] == 'enabled': + return True + else: + return False + + @property + def state(self): + if self.enabled == 'yes' and self._values['state'] != 'present': + return 'enabled' + elif self.enabled == 'no': + return 'disabled' + else: + return self._values['state'] + + def to_return(self): + result = {} + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + return result + + def api_params(self): + result = {} + for api_attribute in self.api_attributes: + if api_attribute in self.api_map: + result[api_attribute] = getattr( + self, self.api_map[api_attribute]) + else: + result[api_attribute] = getattr(self, api_attribute) + result = self._filter_params(result) + return result + + +class ModuleManager(object): + def __init__(self, client): + self.client = client + self.have = None + self.want = Parameters(self.client.module.params) + self.changes = Parameters() + + 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 = Parameters(changed) + + def _update_changed_options(self): + changed = {} + for key in Parameters.updatables: + if getattr(self.want, key) is not None: + attr1 = getattr(self.want, key) + attr2 = getattr(self.have, key) + if attr1 != attr2: + changed[key] = attr1 + if changed: + self.changes = Parameters(changed) + return True + return False + + def exec_module(self): + changed = False + result = dict() + state = self.want.state + + try: + if state in ['present', 'enabled', 'disabled']: + changed = self.present() + elif state == "absent": + changed = self.absent() + except iControlUnexpectedHTTPError as e: + raise F5ModuleError(str(e)) + + changes = self.changes.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + return result + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def present(self): + if self.exists(): + return self.update() + else: + return self.create() + + def absent(self): + changed = False + if self.exists(): + changed = self.remove() + return changed + + def read_current_from_device(self): + resource = self.client.api.tm.ltm.virtual_address_s.virtual_address.load( + name=self.want.address, + partition=self.want.partition + ) + result = resource.attrs + return Parameters(result) + + def exists(self): + result = self.client.api.tm.ltm.virtual_address_s.virtual_address.exists( + name=self.want.address, + partition=self.want.partition + ) + return result + + def update(self): + self.have = self.read_current_from_device() + if self.want.netmask is not None: + if self.have.netmask != self.want.netmask: + raise F5ModuleError( + "The netmask cannot be changed. Delete and recreate" + "the virtual address if you need to do this." + ) + if self.want.address is not None: + if self.have.address != self.want.address: + raise F5ModuleError( + "The address cannot be changed. Delete and recreate" + "the virtual address if you need to do this." + ) + if not self.should_update(): + return False + if self.client.check_mode: + return True + self.update_on_device() + return True + + def update_on_device(self): + params = self.want.api_params() + resource = self.client.api.tm.ltm.virtual_address_s.virtual_address.load( + name=self.want.address, + partition=self.want.partition + ) + resource.modify(**params) + + def create(self): + self._set_changed_options() + if self.client.check_mode: + return True + self.create_on_device() + if self.exists(): + return True + else: + raise F5ModuleError("Failed to create the virtual address") + + def create_on_device(self): + params = self.want.api_params() + self.client.api.tm.ltm.virtual_address_s.virtual_address.create( + name=self.want.address, + partition=self.want.partition, + address=self.want.address, + **params + ) + + def remove(self): + if self.client.check_mode: + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the virtual address") + return True + + def remove_from_device(self): + resource = self.client.api.tm.ltm.virtual_address_s.virtual_address.load( + name=self.want.address, + partition=self.want.partition + ) + resource.delete() + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + self.argument_spec = dict( + state=dict( + default='present', + choices=['present', 'absent', 'disabled', 'enabled'] + ), + address=dict( + type='str', + required=True, + aliases=['name'] + ), + netmask=dict( + type='str', + default='255.255.255.255', + ), + connection_limit=dict( + type='int' + ), + arp_state=dict( + choices=['enabled', 'disabled'], + ), + auto_delete=dict( + choices=['enabled', 'disabled'], + ), + icmp_echo=dict( + choices=['enabled', 'disabled', 'selective'], + ), + advertise_route=dict( + choices=['always', 'when_all_available', 'when_any_available'], + ), + use_route_advertisement=dict( + type='bool' + ) + ) + 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 F5ModuleError as e: + client.module.fail_json(msg=str(e)) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/fixtures/load_ltm_virtual_address_default.json b/test/units/modules/network/f5/fixtures/load_ltm_virtual_address_default.json new file mode 100644 index 0000000000..a2b87012a1 --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_ltm_virtual_address_default.json @@ -0,0 +1,25 @@ +{ + "kind": "tm:ltm:virtual-address:virtual-addressstate", + "name": "1.1.1.1", + "partition": "Common", + "fullPath": "/Common/1.1.1.1", + "generation": 116, + "selfLink": "https://localhost/mgmt/tm/ltm/virtual-address/~Common~1.1.1.1?ver=12.1.2", + "address": "1.1.1.1", + "arp": "enabled", + "autoDelete": "true", + "connectionLimit": 0, + "enabled": "yes", + "floating": "enabled", + "icmpEcho": "enabled", + "inheritedTrafficGroup": "false", + "mask": "255.255.255.255", + "routeAdvertisement": "disabled", + "serverScope": "any", + "spanning": "disabled", + "trafficGroup": "/Common/traffic-group-1", + "trafficGroupReference": { + "link": "https://localhost/mgmt/tm/cm/traffic-group/~Common~traffic-group-1?ver=12.1.2" + }, + "unit": 1 +} diff --git a/test/units/modules/network/f5/test_bigip_virtual_address.py b/test/units/modules/network/f5/test_bigip_virtual_address.py new file mode 100644 index 0000000000..ea325f2af9 --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_virtual_address.py @@ -0,0 +1,251 @@ +# -*- 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 . + +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_virtual_address import Parameters + from library.bigip_virtual_address import ModuleManager + from library.bigip_virtual_address import ArgumentSpec +except ImportError: + from ansible.modules.network.f5.bigip_virtual_address import Parameters + from ansible.modules.network.f5.bigip_virtual_address import ModuleManager + from ansible.modules.network.f5.bigip_virtual_address import ArgumentSpec + +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( + state='present', + address='1.1.1.1', + netmask='2.2.2.2', + connection_limit='10', + arp_state='enabled', + auto_delete='enabled', + icmp_echo='enabled', + advertise_route='always', + use_route_advertisement='yes' + ) + p = Parameters(args) + assert p.state == 'present' + assert p.address == '1.1.1.1' + assert p.netmask == '2.2.2.2' + assert p.connection_limit == 10 + assert p.arp_state == 'enabled' + assert p.auto_delete is True + assert p.icmp_echo == 'enabled' + assert p.advertise_route == 'none' + assert p.use_route_advertisement == 'enabled' + + def test_api_parameters(self): + args = load_fixture('load_ltm_virtual_address_default.json') + p = Parameters(args) + assert p.name == '1.1.1.1' + assert p.address == '1.1.1.1' + assert p.arp_state == 'enabled' + assert p.auto_delete is True + assert p.connection_limit == 0 + assert p.state == 'enabled' + assert p.icmp_echo == 'enabled' + assert p.netmask == '255.255.255.255' + assert p.use_route_advertisement == 'disabled' + assert p.advertise_route == 'any' + + def test_module_parameters_advertise_route_all(self): + args = dict( + advertise_route='when_all_available' + ) + p = Parameters(args) + assert p.advertise_route == 'all' + + def test_module_parameters_advertise_route_any(self): + args = dict( + advertise_route='when_any_available' + ) + p = Parameters(args) + assert p.advertise_route == 'any' + + def test_module_parameters_icmp_echo_disabled(self): + args = dict( + icmp_echo='disabled' + ) + p = Parameters(args) + assert p.icmp_echo == 'disabled' + + def test_module_parameters_icmp_echo_selective(self): + args = dict( + icmp_echo='selective' + ) + p = Parameters(args) + assert p.icmp_echo == 'selective' + + def test_module_parameters_auto_delete_disabled(self): + args = dict( + auto_delete='disabled' + ) + p = Parameters(args) + assert p.auto_delete is False + + def test_module_parameters_arp_state_disabled(self): + args = dict( + arp_state='disabled' + ) + p = Parameters(args) + assert p.arp_state == 'disabled' + + def test_module_parameters_use_route_advert_disabled(self): + args = dict( + use_route_advertisement='no' + ) + p = Parameters(args) + assert p.use_route_advertisement == 'disabled' + + def test_module_parameters_state_present(self): + args = dict( + state='present' + ) + p = Parameters(args) + assert p.state == 'present' + assert p.enabled == 'yes' + + def test_module_parameters_state_absent(self): + args = dict( + state='absent' + ) + p = Parameters(args) + assert p.state == 'absent' + + def test_module_parameters_state_enabled(self): + args = dict( + state='enabled' + ) + p = Parameters(args) + assert p.state == 'enabled' + assert p.enabled == 'yes' + + def test_module_parameters_state_disabled(self): + args = dict( + state='disabled' + ) + p = Parameters(args) + assert p.state == 'disabled' + assert p.enabled == 'no' + + +@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_virtual_address(self, *args): + set_module_args(dict( + state='present', + address='1.1.1.1', + netmask='2.2.2.2', + connection_limit='10', + arp_state='enabled', + auto_delete='enabled', + icmp_echo='enabled', + advertise_route='always', + use_route_advertisement='yes', + password='admin', + server='localhost', + user='admin', + )) + + 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.exists = Mock(side_effect=[False, True]) + mm.create_on_device = Mock(return_value=True) + + results = mm.exec_module() + assert results['changed'] is True + + def test_delete_virtual_address(self, *args): + set_module_args(dict( + state='absent', + address='1.1.1.1', + password='admin', + server='localhost', + user='admin', + )) + + 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.exists = Mock(side_effect=[True, False]) + mm.remove_from_device = Mock(return_value=True) + + results = mm.exec_module() + assert results['changed'] is True