#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2016 Dimension Data # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later # # Authors: # - Adam Friedman from __future__ import (absolute_import, division, print_function) __metaclass__ = type DOCUMENTATION = ''' --- module: dimensiondata_vlan short_description: Manage a VLAN in a Cloud Control network domain extends_documentation_fragment: - community.general.dimensiondata - community.general.dimensiondata_wait - community.general.attributes description: - Manage VLANs in Cloud Control network domains. author: 'Adam Friedman (@tintoy)' attributes: check_mode: support: none diff_mode: support: none options: name: description: - The name of the target VLAN. type: str required: true description: description: - A description of the VLAN. type: str default: '' network_domain: description: - The Id or name of the target network domain. required: true type: str private_ipv4_base_address: description: - The base address for the VLAN's IPv4 network (e.g. 192.168.1.0). type: str default: '' private_ipv4_prefix_size: description: - The size of the IPv4 address space, e.g 24. - Required, if C(private_ipv4_base_address) is specified. type: int default: 0 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 type: str allow_expand: description: - Permit expansion of the target VLAN's network if the module parameters specify a larger network than the VLAN currently possesses. - 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). type: bool default: false ''' EXAMPLES = ''' - name: Add or update VLAN community.general.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: true - name: Read / get VLAN details community.general.dimensiondata_vlan: region: na location: NA5 network_domain: test_network name: my_vlan1 state: readonly wait: true - name: Delete a VLAN community.general.dimensiondata_vlan: region: na location: NA5 network_domain: test_network name: my_vlan_1 state: absent wait: true ''' RETURN = ''' vlan: description: Dictionary describing the VLAN. returned: On success when I(state) is 'present' type: complex contains: id: description: VLAN ID. type: str sample: "aaaaa000-a000-4050-a215-2808934ccccc" name: description: VLAN name. type: str sample: "My VLAN" description: description: VLAN description. type: str sample: "My VLAN description" location: description: Datacenter location. type: str sample: NA3 private_ipv4_base_address: description: The base address for the VLAN's private IPV4 network. type: str 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: str sample: 192.168.23.1 private_ipv6_base_address: description: The base address for the VLAN's IPV6 network. type: str 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: str sample: 2402:9900:111:1195:0:0:0:1 status: description: VLAN status. type: str sample: NORMAL ''' from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.general.plugins.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()