diff --git a/lib/ansible/modules/cloud/vmware/vmware_dvswitch_pvlans.py b/lib/ansible/modules/cloud/vmware/vmware_dvswitch_pvlans.py new file mode 100644 index 0000000000..8794997c30 --- /dev/null +++ b/lib/ansible/modules/cloud/vmware/vmware_dvswitch_pvlans.py @@ -0,0 +1,533 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2018, Christian Kotte +# +# 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 = ''' +--- +module: vmware_dvswitch_pvlans +short_description: Manage Private VLAN configuration of a Distributed Switch +description: + - This module can be used to configure Private VLANs (PVLANs) on a Distributed Switch. +version_added: 2.8 +author: +- Christian Kotte (@ckotte) +notes: + - Tested on vSphere 6.5 and 6.7 +requirements: + - "python >= 2.6" + - PyVmomi +options: + switch: + description: + - The name of the Distributed Switch. + type: str + required: True + aliases: ['dvswitch'] + primary_pvlans: + description: + - A list of VLAN IDs that should be configured as Primary PVLANs. + - If C(primary_pvlans) isn't specified, all PVLANs will be deleted if present. + - Each member of the list requires primary_pvlan_id (int) set. + - The secondary promiscuous PVLAN will be created automatically. + - If C(secondary_pvlans) isn't specified, the primary PVLANs and each secondary promiscuous PVLAN will be created. + - Please see examples for more information. + type: list + default: [] + secondary_pvlans: + description: + - A list of VLAN IDs that should be configured as Secondary PVLANs. + - 'C(primary_pvlans) need to be specified to create any Secodary PVLAN.' + - If C(primary_pvlans) isn't specified, all PVLANs will be deleted if present. + - Each member of the list requires primary_pvlan_id (int), secondary_pvlan_id (int), and pvlan_type (str) to be set. + - The type of the secondary PVLAN can be isolated or community. The secondary promiscuous PVLAN will be created automatically. + - Please see examples for more information. + type: list + default: [] +extends_documentation_fragment: vmware.documentation +''' + +EXAMPLES = ''' +- name: Create PVLANs on a Distributed Switch + vmware_dvswitch_pvlans: + hostname: '{{ inventory_hostname }}' + username: '{{ vcenter_username }}' + password: '{{ vcenter_password }}' + switch: dvSwitch + primary_pvlans: + - primary_pvlan_id: 1 + - primary_pvlan_id: 4 + secondary_pvlans: + - primary_pvlan_id: 1 + secondary_pvlan_id: 2 + pvlan_type: isolated + - primary_pvlan_id: 1 + secondary_pvlan_id: 3 + pvlan_type: community + - primary_pvlan_id: 4 + secondary_pvlan_id: 5 + pvlan_type: community + delegate_to: localhost + +- name: Create primary PVLAN and secondary promiscuous PVLAN on a Distributed Switch + vmware_dvswitch_pvlans: + hostname: '{{ inventory_hostname }}' + username: '{{ vcenter_username }}' + password: '{{ vcenter_password }}' + switch: dvSwitch + primary_pvlans: + - primary_pvlan_id: 1 + delegate_to: localhost + +- name: Remove all PVLANs from a Distributed Switch + vmware_dvswitch_pvlans: + hostname: '{{ inventory_hostname }}' + username: '{{ vcenter_username }}' + password: '{{ vcenter_password }}' + switch: dvSwitch + primary_pvlans: [] + secondary_pvlans: [] + delegate_to: localhost +''' + +RETURN = """ +result: + description: information about performed operation + returned: always + type: string + sample: { + "changed": true, + "dvswitch": "dvSwitch", + "private_vlans": [ + { + "primary_pvlan_id": 1, + "pvlan_type": "promiscuous", + "secondary_pvlan_id": 1 + }, + { + "primary_pvlan_id": 1, + "pvlan_type": "isolated", + "secondary_pvlan_id": 2 + }, + { + "primary_pvlan_id": 1, + "pvlan_type": "community", + "secondary_pvlan_id": 3 + } + ], + "private_vlans_previous": [], + "result": "All private VLANs added" + } +""" + +try: + from pyVmomi import vim +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible.module_utils.vmware import ( + PyVmomi, TaskError, find_dvs_by_name, vmware_argument_spec, wait_for_task +) + + +class VMwareDvSwitchPvlans(PyVmomi): + """Class to manage Private VLANs on a Distributed Virtual Switch""" + + def __init__(self, module): + super(VMwareDvSwitchPvlans, self).__init__(module) + self.switch_name = self.module.params['switch'] + if self.module.params['primary_pvlans']: + self.primary_pvlans = self.module.params['primary_pvlans'] + if self.module.params['secondary_pvlans']: + self.secondary_pvlans = self.module.params['secondary_pvlans'] + else: + self.secondary_pvlans = None + self.do_pvlan_sanity_checks() + else: + self.primary_pvlans = None + self.secondary_pvlans = None + self.dvs = find_dvs_by_name(self.content, self.switch_name) + if self.dvs is None: + self.module.fail_json(msg="Failed to find DVS %s" % self.switch_name) + + def do_pvlan_sanity_checks(self): + """Do sanity checks for primary and secondary PVLANs""" + # Check if primary PVLANs are unique + for primary_vlan in self.primary_pvlans: + count = 0 + primary_pvlan_id = self.get_primary_pvlan_option(primary_vlan) + for primary_vlan_2 in self.primary_pvlans: + primary_pvlan_id_2 = self.get_primary_pvlan_option(primary_vlan_2) + if primary_pvlan_id == primary_pvlan_id_2: + count += 1 + if count > 1: + self.module.fail_json( + msg="The primary PVLAN ID '%s' must be unique!" % primary_pvlan_id + ) + if self.secondary_pvlans: + # Check if secondary PVLANs are unique + for secondary_pvlan in self.secondary_pvlans: + count = 0 + result = self.get_secondary_pvlan_options(secondary_pvlan) + for secondary_pvlan_2 in self.secondary_pvlans: + result_2 = self.get_secondary_pvlan_options(secondary_pvlan_2) + if result[0] == result_2[0]: + count += 1 + if count > 1: + self.module.fail_json( + msg="The secondary PVLAN ID '%s' must be unique!" % result[0] + ) + # Check if secondary PVLANs are already used as primary PVLANs + for primary_vlan in self.primary_pvlans: + primary_pvlan_id = self.get_primary_pvlan_option(primary_vlan) + for secondary_pvlan in self.secondary_pvlans: + result = self.get_secondary_pvlan_options(secondary_pvlan) + if primary_pvlan_id == result[0]: + self.module.fail_json( + msg="The secondary PVLAN ID '%s' is already used as a primary PVLAN!" % + result[0] + ) + # Check if a primary PVLAN is present for every secondary PVLANs + for secondary_pvlan in self.secondary_pvlans: + primary_pvlan_found = False + result = self.get_secondary_pvlan_options(secondary_pvlan) + for primary_vlan in self.primary_pvlans: + primary_pvlan_id = self.get_primary_pvlan_option(primary_vlan) + if result[1] == primary_pvlan_id: + primary_pvlan_found = True + break + if not primary_pvlan_found: + self.module.fail_json( + msg="The primary PVLAN ID '%s' isn't defined for the secondary PVLAN ID '%s'!" % + (result[1], result[0]) + ) + + def ensure(self): + """Manage Private VLANs""" + changed = False + results = dict(changed=changed) + results['dvswitch'] = self.switch_name + changed_list_add = [] + changed_list_remove = [] + + config_spec = vim.dvs.VmwareDistributedVirtualSwitch.ConfigSpec() + # Use the same version in the new spec; The version will be increased by one by the API automatically + config_spec.configVersion = self.dvs.config.configVersion + + # Check Private VLANs + results['private_vlans'] = None + if self.primary_pvlans: + desired_pvlan_list = [] + for primary_vlan in self.primary_pvlans: + primary_pvlan_id = self.get_primary_pvlan_option(primary_vlan) + temp_pvlan = dict() + temp_pvlan['primary_pvlan_id'] = primary_pvlan_id + temp_pvlan['secondary_pvlan_id'] = primary_pvlan_id + temp_pvlan['pvlan_type'] = 'promiscuous' + desired_pvlan_list.append(temp_pvlan) + if self.secondary_pvlans: + for secondary_pvlan in self.secondary_pvlans: + (secondary_pvlan_id, + secondary_vlan_primary_vlan_id, + pvlan_type) = self.get_secondary_pvlan_options(secondary_pvlan) + temp_pvlan = dict() + temp_pvlan['primary_pvlan_id'] = secondary_vlan_primary_vlan_id + temp_pvlan['secondary_pvlan_id'] = secondary_pvlan_id + temp_pvlan['pvlan_type'] = pvlan_type + desired_pvlan_list.append(temp_pvlan) + results['private_vlans'] = desired_pvlan_list + if self.dvs.config.pvlanConfig: + pvlan_spec_list = [] + # Check if desired PVLANs are configured + for primary_vlan in self.primary_pvlans: + primary_pvlan_id = self.get_primary_pvlan_option(primary_vlan) + promiscuous_found = other_found = False + for pvlan_object in self.dvs.config.pvlanConfig: + if pvlan_object.primaryVlanId == primary_pvlan_id and pvlan_object.pvlanType == 'promiscuous': + promiscuous_found = True + break + if not promiscuous_found: + changed = True + changed_list_add.append('promiscuous (%s, %s)' % (primary_pvlan_id, primary_pvlan_id)) + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='add', + primary_pvlan_id=primary_pvlan_id, + secondary_pvlan_id=primary_pvlan_id, + pvlan_type='promiscuous' + ) + ) + if self.secondary_pvlans: + for secondary_pvlan in self.secondary_pvlans: + (secondary_pvlan_id, + secondary_vlan_primary_vlan_id, + pvlan_type) = self.get_secondary_pvlan_options(secondary_pvlan) + if primary_pvlan_id == secondary_vlan_primary_vlan_id: + for pvlan_object_2 in self.dvs.config.pvlanConfig: + if (pvlan_object_2.primaryVlanId == secondary_vlan_primary_vlan_id + and pvlan_object_2.secondaryVlanId == secondary_pvlan_id + and pvlan_object_2.pvlanType == pvlan_type): + other_found = True + break + if not other_found: + changed = True + changed_list_add.append( + '%s (%s, %s)' % (pvlan_type, primary_pvlan_id, secondary_pvlan_id) + ) + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='add', + primary_pvlan_id=primary_pvlan_id, + secondary_pvlan_id=secondary_pvlan_id, + pvlan_type=pvlan_type + ) + ) + # Check if a PVLAN needs to be removed + for pvlan_object in self.dvs.config.pvlanConfig: + promiscuous_found = other_found = False + if (pvlan_object.primaryVlanId == pvlan_object.secondaryVlanId + and pvlan_object.pvlanType == 'promiscuous'): + for primary_vlan in self.primary_pvlans: + primary_pvlan_id = self.get_primary_pvlan_option(primary_vlan) + if pvlan_object.primaryVlanId == primary_pvlan_id and pvlan_object.pvlanType == 'promiscuous': + promiscuous_found = True + break + if not promiscuous_found: + changed = True + changed_list_remove.append( + 'promiscuous (%s, %s)' % (pvlan_object.primaryVlanId, pvlan_object.secondaryVlanId) + ) + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='remove', + primary_pvlan_id=pvlan_object.primaryVlanId, + secondary_pvlan_id=pvlan_object.secondaryVlanId, + pvlan_type='promiscuous' + ) + ) + elif self.secondary_pvlans: + for secondary_pvlan in self.secondary_pvlans: + (secondary_pvlan_id, + secondary_vlan_primary_vlan_id, + pvlan_type) = self.get_secondary_pvlan_options(secondary_pvlan) + if (pvlan_object.primaryVlanId == secondary_vlan_primary_vlan_id + and pvlan_object.secondaryVlanId == secondary_pvlan_id + and pvlan_object.pvlanType == pvlan_type): + other_found = True + break + if not other_found: + changed = True + changed_list_remove.append( + '%s (%s, %s)' % ( + pvlan_object.pvlanType, pvlan_object.primaryVlanId, pvlan_object.secondaryVlanId + ) + ) + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='remove', + primary_pvlan_id=pvlan_object.primaryVlanId, + secondary_pvlan_id=pvlan_object.secondaryVlanId, + pvlan_type=pvlan_object.pvlanType + ) + ) + else: + changed = True + changed_list_remove.append( + '%s (%s, %s)' % ( + pvlan_object.pvlanType, pvlan_object.primaryVlanId, pvlan_object.secondaryVlanId + ) + ) + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='remove', + primary_pvlan_id=pvlan_object.primaryVlanId, + secondary_pvlan_id=pvlan_object.secondaryVlanId, + pvlan_type=pvlan_object.pvlanType + ) + ) + else: + changed = True + changed_list_add.append('All private VLANs') + pvlan_spec_list = [] + for primary_vlan in self.primary_pvlans: + # the first secondary VLAN's type is always promiscuous + primary_pvlan_id = self.get_primary_pvlan_option(primary_vlan) + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='add', + primary_pvlan_id=primary_pvlan_id, + secondary_pvlan_id=primary_pvlan_id, + pvlan_type='promiscuous' + ) + ) + if self.secondary_pvlans: + for secondary_pvlan in self.secondary_pvlans: + (secondary_pvlan_id, + secondary_vlan_primary_vlan_id, + pvlan_type) = self.get_secondary_pvlan_options(secondary_pvlan) + if primary_pvlan_id == secondary_vlan_primary_vlan_id: + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='add', + primary_pvlan_id=primary_pvlan_id, + secondary_pvlan_id=secondary_pvlan_id, + pvlan_type=pvlan_type + ) + ) + else: + # Remove PVLAN configuration if present + if self.dvs.config.pvlanConfig: + changed = True + changed_list_remove.append('All private VLANs') + pvlan_spec_list = [] + for pvlan_object in self.dvs.config.pvlanConfig: + pvlan_spec_list.append( + self.create_pvlan_config_spec( + operation='remove', + primary_pvlan_id=pvlan_object.primaryVlanId, + secondary_pvlan_id=pvlan_object.secondaryVlanId, + pvlan_type=pvlan_object.pvlanType + ) + ) + + if changed: + message_add = message_remove = None + if changed_list_add: + message_add = self.build_change_message('add', changed_list_add) + if changed_list_remove: + message_remove = self.build_change_message('remove', changed_list_remove) + if message_add and message_remove: + message = message_add + '. ' + message_remove + '.' + elif message_add: + message = message_add + elif message_remove: + message = message_remove + current_pvlan_list = [] + for pvlan_object in self.dvs.config.pvlanConfig: + temp_pvlan = dict() + temp_pvlan['primary_pvlan_id'] = pvlan_object.primaryVlanId + temp_pvlan['secondary_pvlan_id'] = pvlan_object.secondaryVlanId + temp_pvlan['pvlan_type'] = pvlan_object.pvlanType + current_pvlan_list.append(temp_pvlan) + results['private_vlans_previous'] = current_pvlan_list + config_spec.pvlanConfigSpec = pvlan_spec_list + if not self.module.check_mode: + try: + task = self.dvs.ReconfigureDvs_Task(config_spec) + wait_for_task(task) + except TaskError as invalid_argument: + self.module.fail_json( + msg="Failed to update DVS : %s" % to_native(invalid_argument) + ) + else: + message = "PVLANs already configured properly" + results['changed'] = changed + results['result'] = message + + self.module.exit_json(**results) + + def get_primary_pvlan_option(self, primary_vlan): + """Get Primary PVLAN option""" + primary_pvlan_id = primary_vlan.get('primary_pvlan_id', None) + if primary_pvlan_id is None: + self.module.fail_json( + msg="Please specify primary_pvlan_id in primary_pvlans options as it's a required parameter" + ) + if primary_pvlan_id in (0, 4095): + self.module.fail_json(msg="The VLAN IDs of 0 and 4095 are reserved and cannot be used as a primary PVLAN.") + return primary_pvlan_id + + def get_secondary_pvlan_options(self, secondary_pvlan): + """Get Secondary PVLAN option""" + secondary_pvlan_id = secondary_pvlan.get('secondary_pvlan_id', None) + if secondary_pvlan_id is None: + self.module.fail_json( + msg="Please specify secondary_pvlan_id in secondary_pvlans options as it's a required parameter" + ) + primary_pvlan_id = secondary_pvlan.get('primary_pvlan_id', None) + if primary_pvlan_id is None: + self.module.fail_json( + msg="Please specify primary_pvlan_id in secondary_pvlans options as it's a required parameter" + ) + if secondary_pvlan_id in (0, 4095) or primary_pvlan_id in (0, 4095): + self.module.fail_json( + msg="The VLAN IDs of 0 and 4095 are reserved and cannot be used as a primary or secondary PVLAN." + ) + pvlan_type = secondary_pvlan.get('pvlan_type', None) + supported_pvlan_types = ['isolated', 'community'] + if pvlan_type is None: + self.module.fail_json(msg="Please specify pvlan_type in secondary_pvlans options as it's a required parameter") + elif pvlan_type not in supported_pvlan_types: + self.module.fail_json(msg="The specified PVLAN type '%s' isn't supported!" % pvlan_type) + return secondary_pvlan_id, primary_pvlan_id, pvlan_type + + @staticmethod + def create_pvlan_config_spec(operation, primary_pvlan_id, secondary_pvlan_id, pvlan_type): + """ + Create PVLAN config spec + operation: add, edit, or remove + Returns: PVLAN config spec + """ + pvlan_spec = vim.dvs.VmwareDistributedVirtualSwitch.PvlanConfigSpec() + pvlan_spec.operation = operation + pvlan_spec.pvlanEntry = vim.dvs.VmwareDistributedVirtualSwitch.PvlanMapEntry() + pvlan_spec.pvlanEntry.primaryVlanId = primary_pvlan_id + pvlan_spec.pvlanEntry.secondaryVlanId = secondary_pvlan_id + pvlan_spec.pvlanEntry.pvlanType = pvlan_type + return pvlan_spec + + def build_change_message(self, operation, changed_list): + """Build the changed message""" + if operation == 'add': + changed_operation = 'added' + elif operation == 'remove': + changed_operation = 'removed' + if self.module.check_mode: + changed_suffix = ' would be %s' % changed_operation + else: + changed_suffix = ' %s' % changed_operation + if len(changed_list) > 2: + message = ', '.join(changed_list[:-1]) + ', and ' + str(changed_list[-1]) + elif len(changed_list) == 2: + message = ' and '.join(changed_list) + elif len(changed_list) == 1: + message = changed_list[0] + message += changed_suffix + return message + + +def main(): + """Main""" + argument_spec = vmware_argument_spec() + argument_spec.update( + dict( + switch=dict(required=True, aliases=['dvswitch']), + primary_pvlans=dict(type='list', default=list(), required=False), + secondary_pvlans=dict(type='list', default=list(), required=False), + ) + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + vmware_dvswitch_pvlans = VMwareDvSwitchPvlans(module) + vmware_dvswitch_pvlans.ensure() + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/vmware_dvswitch_pvlans/aliases b/test/integration/targets/vmware_dvswitch_pvlans/aliases new file mode 100644 index 0000000000..845e8a6dad --- /dev/null +++ b/test/integration/targets/vmware_dvswitch_pvlans/aliases @@ -0,0 +1,2 @@ +cloud/vcenter +unsupported diff --git a/test/integration/targets/vmware_dvswitch_pvlans/tasks/main.yml b/test/integration/targets/vmware_dvswitch_pvlans/tasks/main.yml new file mode 100644 index 0000000000..8d2b80a26b --- /dev/null +++ b/test/integration/targets/vmware_dvswitch_pvlans/tasks/main.yml @@ -0,0 +1,132 @@ +# Test code for the vmware_dvswitch_pvlans module. +# Copyright: (c) 2018, Christian Kotte +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: store the vcenter container ip + set_fact: + vcsim: "{{ lookup('env', 'vcenter_host') }}" + +- debug: var=vcsim + +- name: Wait for Flask controller to come up online + wait_for: + host: "{{ vcsim }}" + port: 5000 + state: started + +- name: kill vcsim + uri: + url: http://{{ vcsim }}:5000/killall + +- name: start vcsim + uri: + url: http://{{ vcsim }}:5000/spawn?cluster=2 + register: vcsim_instance + +- name: Wait for vcsim server to come up online + wait_for: + host: "{{ vcsim }}" + port: 443 + state: started + +- name: get a list of Datacenter from vcsim + uri: + url: http://{{ vcsim }}:5000/govc_find?filter=DC + register: datacenters + +- debug: var=vcsim_instance +- debug: var=datacenters + +# Testcase 0001: Add Distributed vSwitch +- name: add distributed vSwitch + vmware_dvswitch: + validate_certs: False + hostname: "{{ vcsim }}" + username: "{{ vcsim_instance['json']['username'] }}" + password: "{{ vcsim_instance['json']['password'] }}" + datacenter_name: "{{ item | basename }}" + state: present + switch_name: dvswitch_0001 + mtu: 9000 + uplink_quantity: 2 + discovery_proto: lldp + discovery_operation: both + register: dvs_result_0001 + with_items: + - "{{ datacenters['json'] }}" + +- name: ensure distributed vswitch is present + assert: + that: + - "{{ dvs_result_0001.changed == true }}" + +- name: get a list of distributed vswitch from vcsim after adding + uri: + url: http://{{ vcsim }}:5000/govc_find?filter=DVS + register: new_dvs_0001 + +- debug: + msg: "{{ item | basename }}" + with_items: "{{ new_dvs_0001['json'] }}" + +- set_fact: new_dvs_name="{% for dvs in new_dvs_0001['json'] %} {{ True if (dvs | basename) == 'dvswitch_0001' else False }}{% endfor %}" + +- debug: var=new_dvs_name +- assert: + that: + - "{{ 'True' in new_dvs_name }}" + +- name: Configure PVLANs in check mode + vmware_dvswitch_pvlans: + hostname: "{{ vcsim }}" + username: "{{ vcsim_instance['json']['username'] }}" + password: "{{ vcsim_instance['json']['password'] }}" + switch: dvswitch_0001 + primary_pvlans: + - primary_pvlan_id: 1 + - primary_pvlan_id: 4 + secondary_pvlans: + - primary_pvlan_id: 1 + secondary_pvlan_id: 2 + pvlan_type: isolated + - primary_pvlan_id: 1 + secondary_pvlan_id: 3 + pvlan_type: community + - primary_pvlan_id: 4 + secondary_pvlan_id: 5 + pvlan_type: community + validate_certs: no + register: pvlans_result_check_mode + check_mode: yes + +- name: ensure pvlans were configured + assert: + that: + - pvlans_result_check_mode.changed + +- name: Configure PVLANs + vmware_dvswitch_pvlans: + hostname: "{{ vcsim }}" + username: "{{ vcsim_instance['json']['username'] }}" + password: "{{ vcsim_instance['json']['password'] }}" + switch: dvswitch_0001 + primary_pvlans: + - primary_pvlan_id: 1 + - primary_pvlan_id: 4 + secondary_pvlans: + - primary_pvlan_id: 1 + secondary_pvlan_id: 2 + pvlan_type: isolated + - primary_pvlan_id: 1 + secondary_pvlan_id: 3 + pvlan_type: community + - primary_pvlan_id: 4 + secondary_pvlan_id: 5 + pvlan_type: community + validate_certs: no + register: pvlans_result + +- name: ensure pvlans were configured + assert: + that: + - pvlans_result.changed