From 0eb0d962861d356ef7e0d612834c3d549aeaea0d Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Thu, 21 Sep 2017 06:48:51 -0700 Subject: [PATCH] Adds module which allows users to manage partitions on a BIG-IP (#30577) --- .../modules/network/f5/bigip_partition.py | 392 ++++++++++++++++++ .../defaults/main.yaml | 3 - .../bigip_configsync_action/tasks/main.yaml | 16 - .../bigip_configsync_action/tasks/setup.yaml | 13 - .../tasks/teardown.yaml | 21 - .../tasks/test-device-to-group.yaml | 23 - .../tasks/test-pull-recent-device.yaml | 48 --- .../f5/fixtures/load_tm_auth_partition.json | 9 + .../network/f5/test_bigip_partition.py | 220 ++++++++++ 9 files changed, 621 insertions(+), 124 deletions(-) create mode 100644 lib/ansible/modules/network/f5/bigip_partition.py delete mode 100644 test/integration/targets/bigip_configsync_action/defaults/main.yaml delete mode 100644 test/integration/targets/bigip_configsync_action/tasks/main.yaml delete mode 100644 test/integration/targets/bigip_configsync_action/tasks/setup.yaml delete mode 100644 test/integration/targets/bigip_configsync_action/tasks/teardown.yaml delete mode 100644 test/integration/targets/bigip_configsync_action/tasks/test-device-to-group.yaml delete mode 100644 test/integration/targets/bigip_configsync_action/tasks/test-pull-recent-device.yaml create mode 100644 test/units/modules/network/f5/fixtures/load_tm_auth_partition.json create mode 100644 test/units/modules/network/f5/test_bigip_partition.py diff --git a/lib/ansible/modules/network/f5/bigip_partition.py b/lib/ansible/modules/network/f5/bigip_partition.py new file mode 100644 index 0000000000..988f30c511 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_partition.py @@ -0,0 +1,392 @@ +#!/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.1' +} + +DOCUMENTATION = ''' +--- +module: bigip_partition +short_description: Manage BIG-IP partitions. +description: + - Manage BIG-IP partitions. +version_added: "2.5" +options: + description: + description: + - The description to attach to the Partition. + route_domain: + description: + - The default Route Domain to assign to the Partition. If no route domain + is specified, then the default route domain for the system (typically + zero) will be used only when creating a new partition. + state: + description: + - Whether the partition should exist or not. + default: present + choices: + - present + - absent +notes: + - Requires the f5-sdk Python package on the host. This is as easy as pip + install f5-sdk. + - Requires BIG-IP software version >= 12 +requirements: + - f5-sdk >= 2.2.3 +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = ''' +- name: Create partition "foo" using the default route domain + bigip_partition: + name: "foo" + password: "secret" + server: "lb.mydomain.com" + user: "admin" + delegate_to: localhost + +- name: Create partition "bar" using a custom route domain + bigip_partition: + name: "bar" + route_domain: 3 + password: "secret" + server: "lb.mydomain.com" + user: "admin" + delegate_to: localhost + +- name: Change route domain of partition "foo" + bigip_partition: + name: "foo" + route_domain: 8 + password: "secret" + server: "lb.mydomain.com" + user: "admin" + delegate_to: localhost + +- name: Set a description for partition "foo" + bigip_partition: + name: "foo" + description: "Tenant CompanyA" + password: "secret" + server: "lb.mydomain.com" + user: "admin" + delegate_to: localhost + +- name: Delete the "foo" partition + bigip_partition: + name: "foo" + password: "secret" + server: "lb.mydomain.com" + user: "admin" + state: "absent" + delegate_to: localhost +''' + +RETURN = ''' +route_domain: + description: Name of the route domain associated with the partition. + returned: changed and success + type: int + sample: 0 +description: + description: The description of the partition. + returned: changed and success + type: string + sample: "Example partition" +''' + +from ansible.module_utils.f5_utils import AnsibleF5Client +from ansible.module_utils.f5_utils import AnsibleF5Parameters +from ansible.module_utils.f5_utils import F5ModuleError +from ansible.module_utils.f5_utils import iteritems +from ansible.module_utils.f5_utils import defaultdict + +try: + from ansible.module_utils.f5_utils import HAS_F5SDK + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError +except ImportError: + HAS_F5SDK = False + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'defaultRouteDomain': 'route_domain', + } + + api_attributes = [ + 'description', 'defaultRouteDomain' + ] + + returnables = [ + 'description', 'route_domain' + ] + + updatables = [ + 'description', 'route_domain' + ] + + def __init__(self, params=None): + self._values = defaultdict(lambda: None) + self._values['__warnings'] = [] + if params: + self.update(params=params) + + def update(self, params=None): + if params: + for k, v in iteritems(params): + if self.api_map is not None and k in self.api_map: + map_key = self.api_map[k] + else: + map_key = k + + # Handle weird API parameters like `dns.proxy.__iter__` by + # using a map provided by the module developer + class_attr = getattr(type(self), map_key, None) + if isinstance(class_attr, property): + # There is a mapped value for the api_map key + if class_attr.fset is None: + # If the mapped value does not have + # an associated setter + self._values[map_key] = v + else: + # The mapped value has a setter + setattr(self, map_key, v) + else: + # If the mapped value is not a @property + self._values[map_key] = v + + 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 + + def api_params(self): + result = {} + for api_attribute in self.api_attributes: + if self.api_map is not None and 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 + + @property + def partition(self): + # Cannot create a partition in a partition, so nullify this + return None + + @property + def route_domain(self): + if self._values['route_domain'] is None: + return None + return int(self._values['route_domain']) + + +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: + result = self.__default(param) + return result + + def __default(self, param): + attr1 = getattr(self.want, param) + try: + attr2 = getattr(self.have, param) + if attr1 != attr2: + return attr1 + except AttributeError: + return attr1 + + +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): + 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: + changed[k] = change + 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 == "present": + 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 present(self): + if self.exists(): + return self.update() + else: + return self.create() + + def create(self): + if self.client.check_mode: + return True + self.create_on_device() + if not self.exists(): + raise F5ModuleError("Failed to create the partition.") + return True + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.client.check_mode: + return True + self.update_on_device() + return True + + def absent(self): + if self.exists(): + return self.remove() + return False + + def remove(self): + if self.client.check_mode: + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the partition.") + return True + + def read_current_from_device(self): + resource = self.client.api.tm.auth.partitions.partition.load( + name=self.want.name + ) + result = resource.attrs + return Parameters(result) + + def exists(self): + result = self.client.api.tm.auth.partitions.partition.exists( + name=self.want.name + ) + return result + + def update_on_device(self): + params = self.want.api_params() + result = self.client.api.tm.auth.partitions.partition.load( + name=self.want.name + ) + result.modify(**params) + + def create_on_device(self): + params = self.want.api_params() + self.client.api.tm.auth.partitions.partition.create( + name=self.want.name, + **params + ) + + def remove_from_device(self): + result = self.client.api.tm.auth.partitions.partition.load( + name=self.want.name + ) + if result: + result.delete() + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + self.argument_spec = dict( + name=dict(required=True), + description=dict(), + route_domain=dict(type='int'), + ) + self.f5_product_name = 'bigip' + + +def main(): + try: + spec = ArgumentSpec() + + client = AnsibleF5Client( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + f5_product_name=spec.f5_product_name + ) + + if not HAS_F5SDK: + raise F5ModuleError("The python f5-sdk module is required") + + 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/integration/targets/bigip_configsync_action/defaults/main.yaml b/test/integration/targets/bigip_configsync_action/defaults/main.yaml deleted file mode 100644 index 06f1a50d56..0000000000 --- a/test/integration/targets/bigip_configsync_action/defaults/main.yaml +++ /dev/null @@ -1,3 +0,0 @@ ---- - -device_group: "sdbt_sync_failover_dev_group" diff --git a/test/integration/targets/bigip_configsync_action/tasks/main.yaml b/test/integration/targets/bigip_configsync_action/tasks/main.yaml deleted file mode 100644 index c43cee2fd2..0000000000 --- a/test/integration/targets/bigip_configsync_action/tasks/main.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- - -# In this task list, the 0th item is the Active unit and the 1st item is the -# standby unit. - -- include: setup.yaml - when: ansible_play_batch[0] == inventory_hostname - -- include: test-device-to-group.yaml - when: ansible_play_batch[0] == inventory_hostname - -- include: test-pull-recent-device.yaml - when: ansible_play_batch[1] == inventory_hostname - -- include: teardown.yaml - when: ansible_play_batch[0] == inventory_hostname diff --git a/test/integration/targets/bigip_configsync_action/tasks/setup.yaml b/test/integration/targets/bigip_configsync_action/tasks/setup.yaml deleted file mode 100644 index 5278e10da0..0000000000 --- a/test/integration/targets/bigip_configsync_action/tasks/setup.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- - -- name: Create pool - bigip_pool: - lb_method: "round-robin" - name: "cs1.pool" - state: "present" - register: result - -- name: Assert Create pool - assert: - that: - - result|changed diff --git a/test/integration/targets/bigip_configsync_action/tasks/teardown.yaml b/test/integration/targets/bigip_configsync_action/tasks/teardown.yaml deleted file mode 100644 index d0672c1430..0000000000 --- a/test/integration/targets/bigip_configsync_action/tasks/teardown.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- - -- name: Delete pool - First device - bigip_pool: - name: "{{ item }}" - state: "absent" - with_items: - - "cs1.pool" - - "cs2.pool" - register: result - -- name: Assert Delete pool - assert: - that: - - result|changed - -- name: Sync configuration from device to group - bigip_configsync_actions: - device_group: "{{ device_group }}" - sync_device_to_group: yes - register: result diff --git a/test/integration/targets/bigip_configsync_action/tasks/test-device-to-group.yaml b/test/integration/targets/bigip_configsync_action/tasks/test-device-to-group.yaml deleted file mode 100644 index 8860af69e5..0000000000 --- a/test/integration/targets/bigip_configsync_action/tasks/test-device-to-group.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- - -- name: Sync configuration from device to group - bigip_configsync_actions: - device_group: "{{ device_group }}" - sync_device_to_group: yes - register: result - -- name: Sync configuration from device to group - assert: - that: - - result|changed - -- name: Sync configuration from device to group - Idempotent check - bigip_configsync_actions: - device_group: "{{ device_group }}" - sync_device_to_group: yes - register: result - -- name: Sync configuration from device to group - Idempotent check - assert: - that: - - not result|changed diff --git a/test/integration/targets/bigip_configsync_action/tasks/test-pull-recent-device.yaml b/test/integration/targets/bigip_configsync_action/tasks/test-pull-recent-device.yaml deleted file mode 100644 index f077322a50..0000000000 --- a/test/integration/targets/bigip_configsync_action/tasks/test-pull-recent-device.yaml +++ /dev/null @@ -1,48 +0,0 @@ ---- - -- name: Create another pool - First device - bigip_pool: - server: "{{ hostvars['bigip1']['ansible_host'] }}" - lb_method: "round_robin" - name: "cs2.pool" - state: "present" - register: result - -- name: Assert Create another pool - First device - assert: - that: - - result|changed - -- name: Sync configuration from most recent - Second device - bigip_configsync_actions: - device_group: "{{ device_group }}" - sync_most_recent_to_device: yes - register: result - -- name: Assert Sync configuration from most recent - Second device - assert: - that: - - result|changed - -- name: Sync configuration from most recent - Second device - Idempotent check - bigip_configsync_actions: - device_group: "{{ device_group }}" - sync_most_recent_to_device: yes - register: result - -- name: Assert Sync configuration from most recent - Second device - Idempotent check - assert: - that: - - not result|changed - -- name: Create another pool again - Second device - ensure it was created in previous sync - bigip_pool: - lb_method: "round_robin" - name: "cs2.pool" - state: "present" - register: result - -- name: Assert Create another pool again - Second device - ensure it was deleted in previous sync - assert: - that: - - not result|changed diff --git a/test/units/modules/network/f5/fixtures/load_tm_auth_partition.json b/test/units/modules/network/f5/fixtures/load_tm_auth_partition.json new file mode 100644 index 0000000000..386c30f7e2 --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_tm_auth_partition.json @@ -0,0 +1,9 @@ +{ + "kind": "tm:auth:partition:partitionstate", + "name": "foo", + "fullPath": "foo", + "generation": 212, + "selfLink": "https://localhost/mgmt/tm/auth/partition/foo?ver=13.0.0", + "defaultRouteDomain": 0, + "description": "my description" +} diff --git a/test/units/modules/network/f5/test_bigip_partition.py b/test/units/modules/network/f5/test_bigip_partition.py new file mode 100644 index 0000000000..0049d74cdb --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_partition.py @@ -0,0 +1,220 @@ +# -*- 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 Liccense 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 os +import json +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 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_partition import Parameters + from library.bigip_partition import ModuleManager + from library.bigip_partition import ArgumentSpec +except ImportError: + try: + from ansible.modules.network.f5.bigip_partition import Parameters + from ansible.modules.network.f5.bigip_partition import ModuleManager + from ansible.modules.network.f5.bigip_partition import ArgumentSpec + 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 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( + name='foo', + description='my description', + route_domain=0 + ) + + p = Parameters(args) + assert p.name == 'foo' + assert p.description == 'my description' + assert p.route_domain == 0 + + def test_module_parameters_string_domain(self): + args = dict( + name='foo', + route_domain='0' + ) + + p = Parameters(args) + assert p.name == 'foo' + assert p.route_domain == 0 + + def test_api_parameters(self): + args = dict( + name='foo', + description='my description', + defaultRouteDomain=1 + ) + + p = Parameters(args) + assert p.name == 'foo' + assert p.description == 'my description' + assert p.route_domain == 1 + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestManagerEcho(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + + def test_create_partition(self, *args): + set_module_args(dict( + name='foo', + description='my description', + server='localhost', + password='password', + 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 + ) + + # Override methods in the specific type of manager + mm = ModuleManager(client) + 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_create_partition_idempotent(self, *args): + set_module_args(dict( + name='foo', + description='my description', + server='localhost', + password='password', + user='admin' + )) + + current = Parameters(load_fixture('load_tm_auth_partition.json')) + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + # Override methods in the specific type of manager + mm = ModuleManager(client) + mm.exists = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) + + results = mm.exec_module() + + assert results['changed'] is False + + def test_update_description(self, *args): + set_module_args(dict( + name='foo', + description='another description', + server='localhost', + password='password', + user='admin' + )) + + current = Parameters(load_fixture('load_tm_auth_partition.json')) + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + # Override methods in the specific type of manager + mm = ModuleManager(client) + mm.exists = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) + mm.update_on_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['description'] == 'another description' + + def test_update_route_domain(self, *args): + set_module_args(dict( + name='foo', + route_domain=1, + server='localhost', + password='password', + user='admin' + )) + + current = Parameters(load_fixture('load_tm_auth_partition.json')) + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + # Override methods in the specific type of manager + mm = ModuleManager(client) + mm.exists = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) + mm.update_on_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['route_domain'] == 1