diff --git a/.zuul.yaml b/.zuul.yaml index 5a09a389..576682dc 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -48,6 +48,7 @@ designate: true neutron-dns: true neutron-trunk: true + neutron-segments: true zuul_copy_output: '{{ devstack_log_dir }}/test_output.log': 'logs' extensions_to_txt: diff --git a/ci/roles/network_segment/defaults/main.yml b/ci/roles/network_segment/defaults/main.yml new file mode 100644 index 00000000..334c02ff --- /dev/null +++ b/ci/roles/network_segment/defaults/main.yml @@ -0,0 +1,17 @@ +--- +expected_fields: + - description + - id + - name + - network_id + - network_type + - physical_network + - segmentation_id + +network_name: segment_network +segment_name: example_segment +network_type: vlan +segmentation_id: 999 +physical_network: public +initial_description: "example segment description" +updated_description: "updated segment description" diff --git a/ci/roles/network_segment/tasks/main.yml b/ci/roles/network_segment/tasks/main.yml new file mode 100644 index 00000000..96d8520e --- /dev/null +++ b/ci/roles/network_segment/tasks/main.yml @@ -0,0 +1,72 @@ +--- +- name: Create network {{ network_name }} + openstack.cloud.network: + cloud: "{{ cloud }}" + name: "{{ network_name }}" + state: present + +- name: Create segment {{ segment_name }} + openstack.cloud.network_segment: + cloud: "{{ cloud }}" + name: "{{ segment_name }}" + description: "{{ initial_description }}" + network: "{{ network_name }}" + network_type: "{{ network_type }}" + segmentation_id: "{{ segmentation_id }}" + physical_network: "{{ physical_network }}" + state: present + register: segment + +- name: Assert changed + assert: + that: segment is changed + +- name: Assert segment fields + assert: + that: item in segment.network_segment + loop: "{{ expected_fields }}" + +- name: Update segment {{ segment_name }} by name - no changes + openstack.cloud.network_segment: + cloud: "{{ cloud }}" + name: "{{ segment_name }}" + description: "{{ initial_description }}" + state: present + register: segment + +- name: Assert not changed + assert: + that: segment is not changed + +- name: Update segment {{ segment_name }} by all fields - changes + openstack.cloud.network_segment: + cloud: "{{ cloud }}" + name: "{{ segment_name }}" + description: "{{ updated_description }}" + network: "{{ network_name }}" + network_type: "{{ network_type }}" + segmentation_id: "{{ segmentation_id }}" + physical_network: "{{ physical_network }}" + state: present + register: segment + +- name: Assert changed + assert: + that: segment is changed + +- name: Delete segment {{ segment_name }} + openstack.cloud.network_segment: + cloud: "{{ cloud }}" + name: "{{ segment_name }}" + state: absent + register: segment + +- name: Assert changed + assert: + that: segment is changed + +- name: Delete network {{ network_name }} + openstack.cloud.network: + cloud: "{{ cloud }}" + name: "{{ network_name }}" + state: absent diff --git a/ci/roles/subnet/defaults/main.yml b/ci/roles/subnet/defaults/main.yml index c48397a2..d41c145d 100644 --- a/ci/roles/subnet/defaults/main.yml +++ b/ci/roles/subnet/defaults/main.yml @@ -25,3 +25,4 @@ expected_fields: - updated_at - use_default_subnet_pool subnet_name: shade_subnet +segment_name: example_segment diff --git a/ci/roles/subnet/tasks/main.yml b/ci/roles/subnet/tasks/main.yml index 5c48f32b..918f2fcb 100644 --- a/ci/roles/subnet/tasks/main.yml +++ b/ci/roles/subnet/tasks/main.yml @@ -17,10 +17,20 @@ name: "{{ network_name }}" state: present +- name: Create network segment {{ segment_name }} + openstack.cloud.network_segment: + cloud: "{{ cloud }}" + name: "{{ segment_name }}" + network: "{{ network_name }}" + network_type: "vxlan" + segmentation_id: 1000 + state: present + - name: Create subnet {{ subnet_name }} on network {{ network_name }} openstack.cloud.subnet: cloud: "{{ cloud }}" network_name: "{{ network_name }}" + network_segment: "{{ segment_name }}" name: "{{ subnet_name }}" state: present enable_dhcp: "{{ enable_subnet_dhcp }}" @@ -177,6 +187,13 @@ state: absent register: subnet +- name: Delete network segment {{ segment_name }} + openstack.cloud.network_segment: + cloud: "{{ cloud }}" + name: "{{ segment_name }}" + network: "{{ network_name }}" + state: absent + - name: Delete network {{ network_name }} openstack.cloud.network: cloud: "{{ cloud }}" diff --git a/ci/run-collection.yml b/ci/run-collection.yml index 6ced59e6..50e424f3 100644 --- a/ci/run-collection.yml +++ b/ci/run-collection.yml @@ -32,6 +32,7 @@ - { role: loadbalancer, tags: loadbalancer } - { role: logging, tags: logging } - { role: network, tags: network } + - { role: network_segment, tags: network_segment } - { role: neutron_rbac_policy, tags: neutron_rbac_policy } - { role: object, tags: object } - { role: object_container, tags: object_container } diff --git a/meta/runtime.yml b/meta/runtime.yml index 2eb1ffbe..9356b226 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -52,6 +52,7 @@ action_groups: - lb_pool - loadbalancer - network + - network_segment - networks_info - neutron_rbac_policies_info - neutron_rbac_policy diff --git a/plugins/modules/network_segment.py b/plugins/modules/network_segment.py new file mode 100644 index 00000000..97f09df4 --- /dev/null +++ b/plugins/modules/network_segment.py @@ -0,0 +1,183 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 British Broadcasting Corporation +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = ''' +--- +module: network_segment +short_description: Creates/removes network segments from OpenStack +author: OpenStack Ansible SIG +description: + - Add, update or remove network segments from OpenStack. +options: + name: + description: + - Name to be assigned to the segment. Although Neutron allows for + non-unique segment names, this module enforces segment name + uniqueness. + required: true + type: str + description: + description: + - Description of the segment + type: str + network: + description: + - Name or id of the network to which the segment should be attached + type: str + network_type: + description: + - The type of physical network that maps to this segment resource. + type: str + physical_network: + description: + - The physical network where this segment object is implemented. + type: str + segmentation_id: + description: + - An isolated segment on the physical network. The I(network_type) + attribute defines the segmentation model. For example, if the + I(network_type) value is vlan, this ID is a vlan identifier. If + the I(network_type) value is gre, this ID is a gre key. + type: int + state: + description: + - Indicate desired state of the resource. + choices: ['present', 'absent'] + default: present + type: str +extends_documentation_fragment: +- openstack.cloud.openstack +''' + +EXAMPLES = ''' +# Create a VLAN type network segment named 'segment1'. +- openstack.cloud.network_segment: + cloud: mycloud + name: segment1 + network: my_network + network_type: vlan + segmentation_id: 2000 + physical_network: my_physnet + state: present +''' + +RETURN = ''' +id: + description: Id of segment + returned: On success when segment exists. + type: str +network_segment: + description: Dictionary describing the network segment. + returned: On success when network segment exists. + type: dict + contains: + description: + description: Description + type: str + id: + description: Id + type: str + name: + description: Name + type: str + network_id: + description: Network Id + type: str + network_type: + description: Network type + type: str + physical_network: + description: Physical network + type: str + segmentation_id: + description: Segmentation Id + type: int +''' + +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule + + +class NetworkSegmentModule(OpenStackModule): + + argument_spec = dict( + name=dict(required=True), + description=dict(), + network=dict(), + network_type=dict(), + physical_network=dict(), + segmentation_id=dict(type='int'), + state=dict(default='present', choices=['absent', 'present']) + ) + + def run(self): + + state = self.params['state'] + name = self.params['name'] + network_name_or_id = self.params['network'] + + kwargs = {} + filters = {} + for arg in ('description', 'network_type', 'physical_network', 'segmentation_id'): + if self.params[arg] is not None: + kwargs[arg] = self.params[arg] + + for arg in ('network_type', 'physical_network'): + if self.params[arg] is not None: + filters[arg] = self.params[arg] + + if network_name_or_id: + network = self.conn.network.find_network(network_name_or_id, + ignore_missing=False, + **filters) + kwargs['network_id'] = network.id + filters['network_id'] = network.id + + segment = self.conn.network.find_segment(name, **filters) + + if state == 'present': + if not segment: + segment = self.conn.network.create_segment(name=name, **kwargs) + changed = True + else: + changed = False + update_kwargs = {} + + # As the name is required and all other attributes cannot be + # changed (and appear in filters above), we only need to handle + # updates to the description here. + for arg in ["description"]: + if ( + arg in kwargs + # ensure user wants something specific + and kwargs[arg] is not None + # and this is not what we have right now + and kwargs[arg] != segment[arg] + ): + update_kwargs[arg] = kwargs[arg] + + if update_kwargs: + segment = self.conn.network.update_segment( + segment.id, **update_kwargs + ) + changed = True + + segment = segment.to_dict(computed=False) + self.exit(changed=changed, network_segment=segment, id=segment['id']) + elif state == 'absent': + if not segment: + self.exit(changed=False) + else: + self.conn.network.delete_segment(segment['id']) + self.exit(changed=True) + + +def main(): + module = NetworkSegmentModule() + module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/subnet.py b/plugins/modules/subnet.py index 7c50aee3..36b4eae6 100644 --- a/plugins/modules/subnet.py +++ b/plugins/modules/subnet.py @@ -115,6 +115,10 @@ options: - Required when I(state) is 'present' aliases: ['network_name'] type: str + network_segment: + description: + - Name or id of the network segment to which the subnet should be associated + type: str project: description: - Project name or ID containing the subnet (name admin-only) @@ -294,6 +298,7 @@ class SubnetModule(OpenStackModule): argument_spec = dict( name=dict(required=True), network=dict(aliases=['network_name']), + network_segment=dict(), cidr=dict(), description=dict(), ip_version=dict(type='int', default=4, choices=[4, 6]), @@ -369,9 +374,11 @@ class SubnetModule(OpenStackModule): return [dict(start=pool_start, end=pool_end)] return None - def _build_params(self, network, project, subnet_pool): + def _build_params(self, network, segment, project, subnet_pool): params = {attr: self.params[attr] for attr in self.attr_params} params['network_id'] = network.id + if segment: + params['segment_id'] = segment.id if project: params['project_id'] = project.id if subnet_pool: @@ -416,6 +423,7 @@ class SubnetModule(OpenStackModule): def run(self): state = self.params['state'] network_name_or_id = self.params['network'] + network_segment_name_or_id = self.params['network_segment'] project_name_or_id = self.params['project'] subnet_pool_name_or_id = self.params['subnet_pool'] subnet_name = self.params['name'] @@ -444,6 +452,13 @@ class SubnetModule(OpenStackModule): **filters) filters['network_id'] = network.id + segment = None + if network_segment_name_or_id: + segment = self.conn.network.find_segment(network_segment_name_or_id, + ignore_missing=False, + **filters) + filters['segment_id'] = segment.id + subnet_pool = None if subnet_pool_name_or_id: subnet_pool = self.conn.network.find_subnet_pool( @@ -460,7 +475,7 @@ class SubnetModule(OpenStackModule): changed = False if state == 'present': - params = self._build_params(network, project, subnet_pool) + params = self._build_params(network, segment, project, subnet_pool) if subnet is None: subnet = self.conn.network.create_subnet(**params) changed = True