Add baremetal_port_group module

Add support for managing Ironic baremetal port groups.

Include CI role coverage and unit tests for create, update, delete, and check mode behavior.

Add a reno fragment describing the new module.

Tests-Run: python -m pytest tests/unit/modules/cloud/openstack/test_baremetal_port_group.py
Change-Id: I98564fcb5b81a1dd7be1fbf5ffca364483296655
This commit is contained in:
Grzegorz Koper
2026-02-10 14:44:25 +01:00
parent 1dc367b566
commit 1a654a9c38
6 changed files with 760 additions and 0 deletions

View File

@@ -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.

View File

@@ -0,0 +1,12 @@
expected_fields:
- address
- created_at
- extra
- id
- links
- mode
- name
- node_id
- properties
- standalone_ports_supported
- updated_at

View File

@@ -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

View File

@@ -10,6 +10,7 @@ action_groups:
- baremetal_node_action - baremetal_node_action
- baremetal_node_info - baremetal_node_info
- baremetal_port - baremetal_port
- baremetal_port_group
- baremetal_port_info - baremetal_port_info
- catalog_service - catalog_service
- catalog_service_info - catalog_service_info

View File

@@ -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()

View File

@@ -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'])