diff --git a/lib/ansible/modules/cloud/dimensiondata/dimensiondata_vlan.py b/lib/ansible/modules/cloud/dimensiondata/dimensiondata_vlan.py new file mode 100644 index 0000000000..0a7957e24a --- /dev/null +++ b/lib/ansible/modules/cloud/dimensiondata/dimensiondata_vlan.py @@ -0,0 +1,571 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016 Dimension Data +# +# This module 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. +# +# This software 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 this software. If not, see . +# +# Authors: +# - Adam Friedman +# + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.1' +} + +DOCUMENTATION = ''' +--- +module: dimensiondata_vlan +short_description: Manage a VLAN in a Cloud Control network domain. +extends_documentation_fragment: + - dimensiondata + - dimensiondata_wait +description: + - Manage VLANs in Cloud Control network domains. +version_added: "2.5" +author: 'Adam Friedman (@tintoy)' +options: + name: + description: + - The name of the target VLAN. + - Required if C(state) is C(present). + required: false + description: + description: + - A description of the VLAN. + required: false + default: null + network_domain: + description: + - The Id or name of the target network domain. + required: true + private_ipv4_base_address: + description: + - The base address for the VLAN's IPv4 network (e.g. 192.168.1.0). + required: false + private_ipv4_prefix_size: + description: + - The size of the IPv4 address space, e.g 24. + - Required, if C(private_ipv4_base_address) is specified. + required: false + state: + description: + - The desired state for the target VLAN. + - C(readonly) ensures that the state is only ever read, not modified (the module will fail if the resource does not exist). + choices: [present, absent, readonly] + default: present + allow_expand: + description: + - Permit expansion of the target VLAN's network if the module parameters specify a larger network than the VLAN currently posesses? + - If C(False), the module will fail under these conditions. + - This is intended to prevent accidental expansion of a VLAN's network (since this operation is not reversible). + required: false + default: False +''' + +EXAMPLES = ''' +# Add or update VLAN +- dimensiondata_vlan: + region: na + location: NA5 + network_domain: test_network + name: my_vlan1 + description: A test VLAN + private_ipv4_base_address: 192.168.23.0 + private_ipv4_prefix_size: 24 + state: present + wait: yes +# Read / get VLAN details +- dimensiondata_vlan: + region: na + location: NA5 + network_domain: test_network + name: my_vlan1 + state: readonly + wait: yes +# Delete a VLAN +- dimensiondata_vlan: + region: na + location: NA5 + network_domain: test_network + name: my_vlan_1 + state: absent + wait: yes +''' + +RETURN = ''' +vlan: + description: Dictionary describing the VLAN. + returned: On success when I(state) is 'present' + type: complex + contains: + id: + description: VLAN ID. + type: string + sample: "aaaaa000-a000-4050-a215-2808934ccccc" + name: + description: VLAN name. + type: string + sample: "My VLAN" + description: + description: VLAN description. + type: string + sample: "My VLAN description" + location: + description: Datacenter location. + type: string + sample: NA3 + private_ipv4_base_address: + description: The base address for the VLAN's private IPV4 network. + type: string + sample: 192.168.23.0 + private_ipv4_prefix_size: + description: The prefix size for the VLAN's private IPV4 network. + type: int + sample: 24 + private_ipv4_gateway_address: + description: The gateway address for the VLAN's private IPV4 network. + type: string + sample: 192.168.23.1 + private_ipv6_base_address: + description: The base address for the VLAN's IPV6 network. + type: string + sample: 2402:9900:111:1195:0:0:0:0 + private_ipv6_prefix_size: + description: The prefix size for the VLAN's IPV6 network. + type: int + sample: 64 + private_ipv6_gateway_address: + description: The gateway address for the VLAN's IPV6 network. + type: string + sample: 2402:9900:111:1195:0:0:0:1 + status: + description: VLAN status. + type: string + sample: NORMAL +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.dimensiondata import DimensionDataModule, UnknownNetworkError + +try: + from libcloud.common.dimensiondata import DimensionDataVlan, DimensionDataAPIException + + HAS_LIBCLOUD = True + +except ImportError: + DimensionDataVlan = None + + HAS_LIBCLOUD = False + + +class DimensionDataVlanModule(DimensionDataModule): + """ + The dimensiondata_vlan module for Ansible. + """ + + def __init__(self): + """ + Create a new Dimension Data VLAN module. + """ + + super(DimensionDataVlanModule, self).__init__( + module=AnsibleModule( + argument_spec=DimensionDataModule.argument_spec_with_wait( + name=dict(required=True, type='str'), + description=dict(default='', type='str'), + network_domain=dict(required=True, type='str'), + private_ipv4_base_address=dict(default='', type='str'), + private_ipv4_prefix_size=dict(default=0, type='int'), + allow_expand=dict(required=False, default=False, type='bool'), + state=dict(default='present', choices=['present', 'absent', 'readonly']) + ), + required_together=DimensionDataModule.required_together() + ) + ) + + self.name = self.module.params['name'] + self.description = self.module.params['description'] + self.network_domain_selector = self.module.params['network_domain'] + self.private_ipv4_base_address = self.module.params['private_ipv4_base_address'] + self.private_ipv4_prefix_size = self.module.params['private_ipv4_prefix_size'] + self.state = self.module.params['state'] + self.allow_expand = self.module.params['allow_expand'] + + if self.wait and self.state != 'present': + self.module.fail_json( + msg='The wait parameter is only supported when state is "present".' + ) + + def state_present(self): + """ + Ensure that the target VLAN is present. + """ + + network_domain = self._get_network_domain() + + vlan = self._get_vlan(network_domain) + if not vlan: + if self.module.check_mode: + self.module.exit_json( + msg='VLAN "{0}" is absent from network domain "{1}" (should be present).'.format( + self.name, self.network_domain_selector + ), + changed=True + ) + + vlan = self._create_vlan(network_domain) + self.module.exit_json( + msg='Created VLAN "{0}" in network domain "{1}".'.format( + self.name, self.network_domain_selector + ), + vlan=vlan_to_dict(vlan), + changed=True + ) + else: + diff = VlanDiff(vlan, self.module.params) + if not diff.has_changes(): + self.module.exit_json( + msg='VLAN "{0}" is present in network domain "{1}" (no changes detected).'.format( + self.name, self.network_domain_selector + ), + vlan=vlan_to_dict(vlan), + changed=False + ) + + return + + try: + diff.ensure_legal_change() + except InvalidVlanChangeError as invalid_vlan_change: + self.module.fail_json( + msg='Unable to update VLAN "{0}" in network domain "{1}": {2}'.format( + self.name, self.network_domain_selector, invalid_vlan_change + ) + ) + + if diff.needs_expand() and not self.allow_expand: + self.module.fail_json( + msg='The configured private IPv4 network size ({0}-bit prefix) for '.format( + self.private_ipv4_prefix_size + ) + 'the VLAN differs from its current network size ({0}-bit prefix) '.format( + vlan.private_ipv4_range_size + ) + 'and needs to be expanded. Use allow_expand=true if this is what you want.' + ) + + if self.module.check_mode: + self.module.exit_json( + msg='VLAN "{0}" is present in network domain "{1}" (changes detected).'.format( + self.name, self.network_domain_selector + ), + vlan=vlan_to_dict(vlan), + changed=True + ) + + if diff.needs_edit(): + vlan.name = self.name + vlan.description = self.description + + self.driver.ex_update_vlan(vlan) + + if diff.needs_expand(): + vlan.private_ipv4_range_size = self.private_ipv4_prefix_size + self.driver.ex_expand_vlan(vlan) + + self.module.exit_json( + msg='Updated VLAN "{0}" in network domain "{1}".'.format( + self.name, self.network_domain_selector + ), + vlan=vlan_to_dict(vlan), + changed=True + ) + + def state_readonly(self): + """ + Read the target VLAN's state. + """ + + network_domain = self._get_network_domain() + + vlan = self._get_vlan(network_domain) + if vlan: + self.module.exit_json( + vlan=vlan_to_dict(vlan), + changed=False + ) + else: + self.module.fail_json( + msg='VLAN "{0}" does not exist in network domain "{1}".'.format( + self.name, self.network_domain_selector + ) + ) + + def state_absent(self): + """ + Ensure that the target VLAN is not present. + """ + + network_domain = self._get_network_domain() + + vlan = self._get_vlan(network_domain) + if not vlan: + self.module.exit_json( + msg='VLAN "{0}" is absent from network domain "{1}".'.format( + self.name, self.network_domain_selector + ), + changed=False + ) + + return + + if self.module.check_mode: + self.module.exit_json( + msg='VLAN "{0}" is present in network domain "{1}" (should be absent).'.format( + self.name, self.network_domain_selector + ), + vlan=vlan_to_dict(vlan), + changed=True + ) + + self._delete_vlan(vlan) + + self.module.exit_json( + msg='Deleted VLAN "{0}" from network domain "{1}".'.format( + self.name, self.network_domain_selector + ), + changed=True + ) + + def _get_vlan(self, network_domain): + """ + Retrieve the target VLAN details from CloudControl. + + :param network_domain: The target network domain. + :return: The VLAN, or None if the target VLAN was not found. + :rtype: DimensionDataVlan + """ + + vlans = self.driver.ex_list_vlans( + location=self.location, + network_domain=network_domain + ) + matching_vlans = [vlan for vlan in vlans if vlan.name == self.name] + if matching_vlans: + return matching_vlans[0] + + return None + + def _create_vlan(self, network_domain): + vlan = self.driver.ex_create_vlan( + network_domain, + self.name, + self.private_ipv4_base_address, + self.description, + self.private_ipv4_prefix_size + ) + + if self.wait: + vlan = self._wait_for_vlan_state(vlan.id, 'NORMAL') + + return vlan + + def _delete_vlan(self, vlan): + try: + self.driver.ex_delete_vlan(vlan) + + # Not currently supported for deletes due to a bug in libcloud (module will error out if "wait" is specified when "state" is not "present"). + if self.wait: + self._wait_for_vlan_state(vlan, 'NOT_FOUND') + + except DimensionDataAPIException as api_exception: + self.module.fail_json( + msg='Failed to delete VLAN "{0}" due to unexpected error from the CloudControl API: {1}'.format( + vlan.id, api_exception.msg + ) + ) + + def _wait_for_vlan_state(self, vlan, state_to_wait_for): + network_domain = self._get_network_domain() + + wait_poll_interval = self.module.params['wait_poll_interval'] + wait_time = self.module.params['wait_time'] + + # Bizarre bug in libcloud when checking status after delete; socket.error is too generic to catch in this context so for now we don't even try. + + try: + return self.driver.connection.wait_for_state( + state_to_wait_for, + self.driver.ex_get_vlan, + wait_poll_interval, + wait_time, + vlan + ) + + except DimensionDataAPIException as api_exception: + if api_exception.code != 'RESOURCE_NOT_FOUND': + raise + + return DimensionDataVlan( + id=vlan.id, + status='NOT_FOUND', + name='', + description='', + private_ipv4_range_address='', + private_ipv4_range_size=0, + ipv4_gateway='', + ipv6_range_address='', + ipv6_range_size=0, + ipv6_gateway='', + location=self.location, + network_domain=network_domain + ) + + def _get_network_domain(self): + """ + Retrieve the target network domain from the Cloud Control API. + + :return: The network domain. + """ + + try: + return self.get_network_domain( + self.network_domain_selector, self.location + ) + except UnknownNetworkError: + self.module.fail_json( + msg='Cannot find network domain "{0}" in datacenter "{1}".'.format( + self.network_domain_selector, self.location + ) + ) + + return None + + +class InvalidVlanChangeError(Exception): + """ + Error raised when an illegal change to VLAN state is attempted. + """ + + pass + + +class VlanDiff(object): + """ + Represents differences between VLAN information (from CloudControl) and module parameters. + """ + + def __init__(self, vlan, module_params): + """ + + :param vlan: The VLAN information from CloudControl. + :type vlan: DimensionDataVlan + :param module_params: The module parameters. + :type module_params: dict + """ + + self.vlan = vlan + self.module_params = module_params + + self.name_changed = module_params['name'] != vlan.name + self.description_changed = module_params['description'] != vlan.description + self.private_ipv4_base_address_changed = module_params['private_ipv4_base_address'] != vlan.private_ipv4_range_address + self.private_ipv4_prefix_size_changed = module_params['private_ipv4_prefix_size'] != vlan.private_ipv4_range_size + + # Is configured prefix size greater than or less than the actual prefix size? + private_ipv4_prefix_size_difference = module_params['private_ipv4_prefix_size'] - vlan.private_ipv4_range_size + self.private_ipv4_prefix_size_increased = private_ipv4_prefix_size_difference > 0 + self.private_ipv4_prefix_size_decreased = private_ipv4_prefix_size_difference < 0 + + def has_changes(self): + """ + Does the VlanDiff represent any changes between the VLAN and module configuration? + + :return: True, if there are change changes; otherwise, False. + """ + + return self.needs_edit() or self.needs_expand() + + def ensure_legal_change(self): + """ + Ensure the change (if any) represented by the VlanDiff represents a legal change to VLAN state. + + - private_ipv4_base_address cannot be changed + - private_ipv4_prefix_size must be greater than or equal to the VLAN's existing private_ipv4_range_size + + :raise InvalidVlanChangeError: The VlanDiff does not represent a legal change to VLAN state. + """ + + # Cannot change base address for private IPv4 network. + if self.private_ipv4_base_address_changed: + raise InvalidVlanChangeError('Cannot change the private IPV4 base address for an existing VLAN.') + + # Cannot shrink private IPv4 network (by increasing prefix size). + if self.private_ipv4_prefix_size_increased: + raise InvalidVlanChangeError('Cannot shrink the private IPV4 network for an existing VLAN (only expand is supported).') + + def needs_edit(self): + """ + Is an Edit operation required to resolve the differences between the VLAN information and the module parameters? + + :return: True, if an Edit operation is required; otherwise, False. + """ + + return self.name_changed or self.description_changed + + def needs_expand(self): + """ + Is an Expand operation required to resolve the differences between the VLAN information and the module parameters? + + The VLAN's network is expanded by reducing the size of its network prefix. + + :return: True, if an Expand operation is required; otherwise, False. + """ + + return self.private_ipv4_prefix_size_decreased + + +def vlan_to_dict(vlan): + return { + 'id': vlan.id, + 'name': vlan.name, + 'description': vlan.description, + 'location': vlan.location.id, + 'private_ipv4_base_address': vlan.private_ipv4_range_address, + 'private_ipv4_prefix_size': vlan.private_ipv4_range_size, + 'private_ipv4_gateway_address': vlan.ipv4_gateway, + 'ipv6_base_address': vlan.ipv6_range_address, + 'ipv6_prefix_size': vlan.ipv6_range_size, + 'ipv6_gateway_address': vlan.ipv6_gateway, + 'status': vlan.status + } + + +def main(): + module = DimensionDataVlanModule() + + if module.state == 'present': + module.state_present() + elif module.state == 'readonly': + module.state_readonly() + elif module.state == 'absent': + module.state_absent() + + +if __name__ == '__main__': + main()