diff --git a/changelogs/fragments/baremetal_port_group_module.yaml b/changelogs/fragments/baremetal_port_group_module.yaml new file mode 100644 index 00000000..d1c5115e --- /dev/null +++ b/changelogs/fragments/baremetal_port_group_module.yaml @@ -0,0 +1,5 @@ +--- +minor_changes: + - Added the new ``openstack.cloud.baremetal_port_group`` module to manage + Bare Metal port groups (create, update, and delete), including CI role + coverage and unit tests. diff --git a/ci/roles/baremetal_port_group/defaults/main.yml b/ci/roles/baremetal_port_group/defaults/main.yml new file mode 100644 index 00000000..499f3ad7 --- /dev/null +++ b/ci/roles/baremetal_port_group/defaults/main.yml @@ -0,0 +1,12 @@ +expected_fields: + - address + - created_at + - extra + - id + - links + - mode + - name + - node_id + - properties + - standalone_ports_supported + - updated_at diff --git a/ci/roles/baremetal_port_group/tasks/main.yml b/ci/roles/baremetal_port_group/tasks/main.yml new file mode 100644 index 00000000..c720102a --- /dev/null +++ b/ci/roles/baremetal_port_group/tasks/main.yml @@ -0,0 +1,100 @@ +--- +# TODO: Actually run this role in CI. Atm we do not have DevStack's ironic plugin enabled. +- name: Create baremetal node + openstack.cloud.baremetal_node: + cloud: "{{ cloud }}" + driver_info: + ipmi_address: "1.2.3.4" + ipmi_username: "admin" + ipmi_password: "secret" + name: ansible_baremetal_node + nics: + - mac: "aa:bb:cc:aa:bb:cc" + state: present + register: node + +- name: Create baremetal port group + openstack.cloud.baremetal_port_group: + cloud: "{{ cloud }}" + state: present + name: ansible_baremetal_port_group + node: ansible_baremetal_node + address: fa:16:3e:aa:aa:ab + mode: active-backup + standalone_ports_supported: true + extra: + test: created + properties: + miimon: '100' + register: port_group + +- debug: var=port_group + +- name: Assert return values of baremetal_port_group module + assert: + that: + # allow new fields to be introduced but prevent fields from being removed + - expected_fields|difference(port_group.port_group.keys())|length == 0 + - port_group.port_group.name == "ansible_baremetal_port_group" + - port_group.port_group.node_id == node.node.id + +- name: Update baremetal port group + openstack.cloud.baremetal_port_group: + cloud: "{{ cloud }}" + state: present + id: "{{ port_group.port_group.id }}" + mode: 802.3ad + standalone_ports_supported: false + extra: + test: updated + register: updated_port_group + +- name: Assert return values of updated baremetal port group + assert: + that: + - updated_port_group is changed + - updated_port_group.port_group.id == port_group.port_group.id + - updated_port_group.port_group.mode == "802.3ad" + - not updated_port_group.port_group.standalone_ports_supported + - updated_port_group.port_group.extra.test == "updated" + +- name: Update baremetal port group again + openstack.cloud.baremetal_port_group: + cloud: "{{ cloud }}" + state: present + id: "{{ port_group.port_group.id }}" + mode: 802.3ad + standalone_ports_supported: false + extra: + test: updated + register: updated_port_group + +- name: Assert idempotency for baremetal port group module + assert: + that: + - updated_port_group is not changed + - updated_port_group.port_group.id == port_group.port_group.id + +- name: Delete baremetal port group + openstack.cloud.baremetal_port_group: + cloud: "{{ cloud }}" + state: absent + id: "{{ port_group.port_group.id }}" + +- name: Delete baremetal port group again + openstack.cloud.baremetal_port_group: + cloud: "{{ cloud }}" + state: absent + id: "{{ port_group.port_group.id }}" + register: deleted_port_group + +- name: Assert idempotency for deleted baremetal port group + assert: + that: + - deleted_port_group is not changed + +- name: Delete baremetal node + openstack.cloud.baremetal_node: + cloud: "{{ cloud }}" + name: ansible_baremetal_node + state: absent diff --git a/meta/runtime.yml b/meta/runtime.yml index 0e1d208e..2eb1ffbe 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -10,6 +10,7 @@ action_groups: - baremetal_node_action - baremetal_node_info - baremetal_port + - baremetal_port_group - baremetal_port_info - catalog_service - catalog_service_info diff --git a/plugins/modules/baremetal_port_group.py b/plugins/modules/baremetal_port_group.py new file mode 100644 index 00000000..9b1e6100 --- /dev/null +++ b/plugins/modules/baremetal_port_group.py @@ -0,0 +1,257 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2026 OpenStack Ansible SIG +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +module: baremetal_port_group +short_description: Create/Delete Bare Metal port group resources from OpenStack +author: OpenStack Ansible SIG +description: + - Create, update and remove Bare Metal port groups from OpenStack. +options: + id: + description: + - ID of the port group. + - Will be auto-generated if not specified. + type: str + aliases: ['uuid'] + name: + description: + - Name of the port group. + type: str + node: + description: + - ID or Name of the node this resource belongs to. + - Required when creating a new port group. + type: str + address: + description: + - Physical hardware address of this port group, typically the hardware + MAC address. + type: str + extra: + description: + - A set of one or more arbitrary metadata key and value pairs. + type: dict + standalone_ports_supported: + description: + - Whether the port group supports ports that are not members of this + port group. + type: bool + mode: + description: + - The port group mode. + type: str + properties: + description: + - Key/value properties for the port group. + type: dict + state: + description: + - Indicates desired state of the resource. + choices: ['present', 'absent'] + default: present + type: str +extends_documentation_fragment: +- openstack.cloud.openstack +''' + +EXAMPLES = r''' +- name: Create Bare Metal port group + openstack.cloud.baremetal_port_group: + cloud: devstack + state: present + name: bond0 + node: bm-0 + address: fa:16:3e:aa:aa:aa + mode: '802.3ad' + standalone_ports_supported: true + register: result + +- name: Update Bare Metal port group + openstack.cloud.baremetal_port_group: + cloud: devstack + state: present + id: 1a85ebca-22bf-42eb-ad9e-f640789b8098 + mode: 'active-backup' + properties: + miimon: '100' + register: result + +- name: Delete Bare Metal port group + openstack.cloud.baremetal_port_group: + cloud: devstack + state: absent + id: 1a85ebca-22bf-42eb-ad9e-f640789b8098 + register: result +''' + +RETURN = r''' +port_group: + description: A port group dictionary, subset of the dictionary keys listed + below may be returned, depending on your cloud provider. + returned: success + type: dict + contains: + address: + description: Physical hardware address of the port group. + returned: success + type: str + created_at: + description: Bare Metal port group created at timestamp. + returned: success + type: str + extra: + description: A set of one or more arbitrary metadata key and value + pairs. + returned: success + type: dict + id: + description: The UUID for the Bare Metal port group resource. + returned: success + type: str + links: + description: A list of relative links, including the self and + bookmark links. + returned: success + type: list + mode: + description: The port group mode. + returned: success + type: str + name: + description: Bare Metal port group name. + returned: success + type: str + node_id: + description: UUID of the Bare Metal node this resource belongs to. + returned: success + type: str + properties: + description: Key/value properties for this port group. + returned: success + type: dict + standalone_ports_supported: + description: Whether standalone ports are supported. + returned: success + type: bool + updated_at: + description: Bare Metal port group updated at timestamp. + returned: success + type: str +''' + +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import ( + OpenStackModule +) + + +class BaremetalPortGroupModule(OpenStackModule): + argument_spec = dict( + id=dict(aliases=['uuid']), + name=dict(), + node=dict(), + address=dict(), + extra=dict(type='dict'), + standalone_ports_supported=dict(type='bool'), + mode=dict(), + properties=dict(type='dict'), + state=dict(default='present', choices=['present', 'absent']), + ) + + module_kwargs = dict( + required_one_of=[ + ('id', 'name'), + ], + supports_check_mode=True, + ) + + def _find_port_group(self): + id_or_name = self.params['id'] if self.params['id'] else self.params['name'] + if not id_or_name: + return None + + try: + return self.conn.baremetal.find_port_group(id_or_name) + except self.sdk.exceptions.ResourceNotFound: + return None + + def _build_create_attrs(self): + attrs = {} + + for key in ['id', 'name', 'address', 'extra', + 'standalone_ports_supported', 'mode', 'properties']: + if self.params[key] is not None: + attrs[key] = self.params[key] + + node_name_or_id = self.params['node'] + if not node_name_or_id: + self.fail_json(msg="Parameter 'node' is required when creating a new port group") + + node = self.conn.baremetal.find_node(node_name_or_id, ignore_missing=False) + attrs['node_id'] = node['id'] + return attrs + + def _build_update_attrs(self, port_group): + attrs = {} + + for key in ['name', 'address', 'extra', + 'standalone_ports_supported', 'mode', 'properties']: + if self.params[key] is not None and self.params[key] != port_group.get(key): + attrs[key] = self.params[key] + + return attrs + + def _will_change(self, port_group, state): + if state == 'absent': + return bool(port_group) + + if not port_group: + return True + + return bool(self._build_update_attrs(port_group)) + + def run(self): + state = self.params['state'] + port_group = self._find_port_group() + + if self.ansible.check_mode: + if state == 'present' and not port_group: + self._build_create_attrs() + self.exit_json(changed=self._will_change(port_group, state)) + + if state == 'present': + if not port_group: + port_group = self.conn.baremetal.create_port_group( + **self._build_create_attrs()) + self.exit_json( + changed=True, + port_group=port_group.to_dict(computed=False)) + + update_attrs = self._build_update_attrs(port_group) + changed = bool(update_attrs) + + if changed: + port_group = self.conn.baremetal.update_port_group( + port_group['id'], **update_attrs) + + self.exit_json( + changed=changed, + port_group=port_group.to_dict(computed=False)) + + if not port_group: + self.exit_json(changed=False) + + self.conn.baremetal.delete_port_group(port_group['id']) + self.exit_json(changed=True) + + +def main(): + module = BaremetalPortGroupModule() + module() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/modules/cloud/openstack/test_baremetal_port_group.py b/tests/unit/modules/cloud/openstack/test_baremetal_port_group.py new file mode 100644 index 00000000..4f689552 --- /dev/null +++ b/tests/unit/modules/cloud/openstack/test_baremetal_port_group.py @@ -0,0 +1,385 @@ +import importlib.util +import json +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import patch + +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes + + +def _load_module_under_test(): + module_path = Path(__file__).resolve().parents[5] / 'plugins/modules/baremetal_port_group.py' + spec = importlib.util.spec_from_file_location('baremetal_port_group', str(module_path)) + if spec is None or spec.loader is None: + raise ImportError('Cannot load baremetal_port_group module for tests') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +baremetal_port_group = _load_module_under_test() + + +def set_module_args(args): + if '_ansible_remote_tmp' not in args: + args['_ansible_remote_tmp'] = '/tmp' + if '_ansible_keep_remote_files' not in args: + args['_ansible_keep_remote_files'] = False + + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(Exception): + pass + + +class AnsibleFailJson(Exception): + pass + + +def exit_json(*args, **kwargs): + if 'changed' not in kwargs: + kwargs['changed'] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): + kwargs['failed'] = True + raise AnsibleFailJson(kwargs) + + +class ModuleTestCase(unittest.TestCase): + mock_module = None + mock_sleep = None + + def setUp(self): + self.mock_module = patch.multiple( + basic.AnsibleModule, + exit_json=exit_json, + fail_json=fail_json, + ) + self.mock_module.start() + self.mock_sleep = patch('time.sleep') + self.mock_sleep.start() + set_module_args({}) + self.addCleanup(self.mock_module.stop) + self.addCleanup(self.mock_sleep.stop) + + +class FakePortGroup(dict[str, object]): + + def to_dict(self, computed=False): + return dict(self) + + +class FakeSDK(object): + class exceptions: + class OpenStackCloudException(Exception): + pass + + class ResourceNotFound(Exception): + pass + + +class TestBaremetalPortGroup(ModuleTestCase): + module = baremetal_port_group + + def setUp(self): + super(TestBaremetalPortGroup, self).setUp() + self.module = baremetal_port_group + + def _run_module(self, module_args, baremetal): + set_module_args(module_args) + conn = mock.Mock() + conn.baremetal = baremetal + with mock.patch.object( + baremetal_port_group.BaremetalPortGroupModule, + 'openstack_cloud_from_module', + return_value=(FakeSDK(), conn), + ): + self.module.main() + + def _new_baremetal(self): + baremetal = mock.Mock() + baremetal.find_port_group.return_value = None + baremetal.find_node.return_value = {'id': 'node-1'} + return baremetal + + def test_create_port_group(self): + baremetal = self._new_baremetal() + baremetal.create_port_group.return_value = FakePortGroup( + id='pg-1', + name='bond0', + node_id='node-1', + address='fa:16:3e:aa:aa:aa', + mode='active-backup', + extra={}, + properties={}, + standalone_ports_supported=True, + links=[], + created_at='2026-01-01T00:00:00+00:00', + updated_at=None, + ) + + with self.assertRaises(AnsibleExitJson) as ex: + self._run_module( + { + 'id': None, + 'name': 'bond0', + 'node': 'node-name', + 'address': 'fa:16:3e:aa:aa:aa', + 'extra': {}, + 'standalone_ports_supported': True, + 'mode': 'active-backup', + 'properties': {}, + 'state': 'present', + }, + baremetal, + ) + + result = ex.exception.args[0] + self.assertTrue(result['changed']) + self.assertEqual('pg-1', result['port_group']['id']) + baremetal.find_node.assert_called_once_with('node-name', ignore_missing=False) + baremetal.create_port_group.assert_called_once_with( + name='bond0', + node_id='node-1', + address='fa:16:3e:aa:aa:aa', + extra={}, + standalone_ports_supported=True, + mode='active-backup', + properties={}, + ) + + def test_create_port_group_without_node_fails(self): + baremetal = self._new_baremetal() + + with self.assertRaises(AnsibleFailJson) as ex: + self._run_module( + { + 'id': None, + 'name': 'bond0', + 'node': None, + 'address': None, + 'extra': None, + 'standalone_ports_supported': None, + 'mode': None, + 'properties': None, + 'state': 'present', + }, + baremetal, + ) + + self.assertIn("Parameter 'node' is required", ex.exception.args[0]['msg']) + baremetal.create_port_group.assert_not_called() + + def test_update_port_group_when_values_changed(self): + baremetal = self._new_baremetal() + baremetal.find_port_group.return_value = FakePortGroup( + id='pg-1', + name='bond0', + node_id='node-1', + mode='active-backup', + address=None, + extra={}, + properties={}, + standalone_ports_supported=True, + links=[], + created_at='2026-01-01T00:00:00+00:00', + updated_at=None, + ) + baremetal.update_port_group.return_value = FakePortGroup( + id='pg-1', + name='bond0', + node_id='node-1', + mode='802.3ad', + address=None, + extra={}, + properties={}, + standalone_ports_supported=True, + links=[], + created_at='2026-01-01T00:00:00+00:00', + updated_at='2026-01-02T00:00:00+00:00', + ) + + with self.assertRaises(AnsibleExitJson) as ex: + self._run_module( + { + 'id': 'pg-1', + 'name': None, + 'node': None, + 'address': None, + 'extra': None, + 'standalone_ports_supported': None, + 'mode': '802.3ad', + 'properties': None, + 'state': 'present', + }, + baremetal, + ) + + result = ex.exception.args[0] + self.assertTrue(result['changed']) + self.assertEqual('802.3ad', result['port_group']['mode']) + baremetal.update_port_group.assert_called_once_with('pg-1', mode='802.3ad') + + def test_present_noop_when_already_matching(self): + baremetal = self._new_baremetal() + baremetal.find_port_group.return_value = FakePortGroup( + id='pg-1', + name='bond0', + node_id='node-1', + mode='active-backup', + address='fa:16:3e:aa:aa:aa', + extra={'a': 'b'}, + properties={'miimon': '100'}, + standalone_ports_supported=False, + links=[], + created_at='2026-01-01T00:00:00+00:00', + updated_at=None, + ) + + with self.assertRaises(AnsibleExitJson) as ex: + self._run_module( + { + 'id': 'pg-1', + 'name': 'bond0', + 'node': None, + 'address': 'fa:16:3e:aa:aa:aa', + 'extra': {'a': 'b'}, + 'standalone_ports_supported': False, + 'mode': 'active-backup', + 'properties': {'miimon': '100'}, + 'state': 'present', + }, + baremetal, + ) + + result = ex.exception.args[0] + self.assertFalse(result['changed']) + baremetal.update_port_group.assert_not_called() + + def test_delete_existing_port_group(self): + baremetal = self._new_baremetal() + baremetal.find_port_group.return_value = FakePortGroup(id='pg-1', name='bond0') + + with self.assertRaises(AnsibleExitJson) as ex: + self._run_module( + { + 'id': 'pg-1', + 'name': None, + 'node': None, + 'address': None, + 'extra': None, + 'standalone_ports_supported': None, + 'mode': None, + 'properties': None, + 'state': 'absent', + }, + baremetal, + ) + + result = ex.exception.args[0] + self.assertTrue(result['changed']) + baremetal.delete_port_group.assert_called_once_with('pg-1') + + def test_delete_missing_port_group_is_noop(self): + baremetal = self._new_baremetal() + baremetal.find_port_group.return_value = None + + with self.assertRaises(AnsibleExitJson) as ex: + self._run_module( + { + 'id': 'pg-1', + 'name': None, + 'node': None, + 'address': None, + 'extra': None, + 'standalone_ports_supported': None, + 'mode': None, + 'properties': None, + 'state': 'absent', + }, + baremetal, + ) + + result = ex.exception.args[0] + self.assertFalse(result['changed']) + baremetal.delete_port_group.assert_not_called() + + def test_check_mode_create_marks_changed(self): + baremetal = self._new_baremetal() + baremetal.find_port_group.return_value = None + + with self.assertRaises(AnsibleExitJson) as ex: + self._run_module( + { + '_ansible_check_mode': True, + 'id': None, + 'name': 'bond0', + 'node': 'node-name', + 'address': None, + 'extra': None, + 'standalone_ports_supported': None, + 'mode': None, + 'properties': None, + 'state': 'present', + }, + baremetal, + ) + + result = ex.exception.args[0] + self.assertTrue(result['changed']) + baremetal.create_port_group.assert_not_called() + baremetal.find_node.assert_called_once_with('node-name', ignore_missing=False) + + def test_check_mode_create_without_node_fails(self): + baremetal = self._new_baremetal() + baremetal.find_port_group.return_value = None + + with self.assertRaises(AnsibleFailJson) as ex: + self._run_module( + { + '_ansible_check_mode': True, + 'id': None, + 'name': 'bond0', + 'node': None, + 'address': None, + 'extra': None, + 'standalone_ports_supported': None, + 'mode': None, + 'properties': None, + 'state': 'present', + }, + baremetal, + ) + + self.assertIn("Parameter 'node' is required", ex.exception.args[0]['msg']) + baremetal.create_port_group.assert_not_called() + baremetal.find_node.assert_not_called() + + def test_find_port_group_resource_not_found_returns_none(self): + baremetal = self._new_baremetal() + baremetal.find_port_group.side_effect = FakeSDK.exceptions.ResourceNotFound() + + with self.assertRaises(AnsibleExitJson) as ex: + self._run_module( + { + 'id': 'pg-1', + 'name': None, + 'node': None, + 'address': None, + 'extra': None, + 'standalone_ports_supported': None, + 'mode': None, + 'properties': None, + 'state': 'absent', + }, + baremetal, + ) + + result = ex.exception.args[0] + self.assertFalse(result['changed'])