diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index 151f4250c..fb4112749 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ [project.scripts] bmc-kube-password = "understack_workflows.main.bmc_display_password:main" bmc-password = "understack_workflows.main.print_bmc_password:main" +create-port-group = "understack_workflows.main.create_port_group:main" enroll-fw = "understack_workflows.main.enroll_fw:main" enroll-netdev = "understack_workflows.main.enroll_netdev:main" enroll-server = "understack_workflows.main.enroll_server:main" diff --git a/python/understack-workflows/tests/test_create_port_group.py b/python/understack-workflows/tests/test_create_port_group.py new file mode 100644 index 000000000..b2f907a8b --- /dev/null +++ b/python/understack-workflows/tests/test_create_port_group.py @@ -0,0 +1,257 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from understack_workflows.main import create_port_group + +NODE_UUID = "c5df377d-8cb0-44d7-b4c0-bdfa7d228bf0" +NODE_NAME = "Dell-5BFZMD4" + + +def _make_port(uuid, address, llc=None, name=None): + return SimpleNamespace( + id=uuid, + address=address, + local_link_connection=llc or {}, + name=name, + ) + + +# Ports based on the real device data - only two have local_link_connection +# with port_id populated. +PORTS = [ + _make_port( + "cc0a4c29-0796-455c-b806-070ed124e839", + "c8:4b:d6:f3:c8:80", + llc={}, + name="Dell-5BFZMD4:NIC.Embedded.1-1-1", + ), + _make_port( + "3024e6cd-82b1-42f5-9962-7686de039a50", + "b4:83:51:24:e4:43", + llc={}, + name="Dell-5BFZMD4:NIC.Integrated.1-2-1", + ), + _make_port( + "ba699365-4763-4b6f-bf5a-fb7276dcb5a5", + "b4:83:51:24:e4:44", + llc={}, + name="Dell-5BFZMD4:NIC.Integrated.1-3-1", + ), + _make_port( + "015b6cf7-ad9e-4a70-9113-76e8c8454f42", + "b4:83:51:24:e4:45", + llc={}, + name="Dell-5BFZMD4:NIC.Integrated.1-4-1", + ), + _make_port( + "7aedc667-adaf-4cb0-9912-5dceddd2a375", + "b4:83:51:24:58:b3", + llc={ + "port_id": "Ethernet1/9", + "switch_id": "ec:19:2e:c9:77:37", + "switch_info": "g16-45-2.iad3.rackspace.net", + }, + name="Dell-5BFZMD4:NIC.Slot.1-2-1", + ), + _make_port( + "0de07302-2edc-4b92-9aaa-e504097784cc", + "b4:83:51:24:58:b2", + llc={}, + name="Dell-5BFZMD4:NIC.Slot.1-1-1", + ), + _make_port( + "dd0bc58b-3be5-4d6c-8f97-8003aded65e4", + "c8:4b:d6:f3:c8:81", + llc={}, + name="Dell-5BFZMD4:NIC.Embedded.2-1-1", + ), + _make_port( + "dac434df-7b24-455e-8570-ba6e1bbea244", + "b4:83:51:24:e4:42", + llc={ + "port_id": "Ethernet1/9", + "switch_id": "ec:19:2e:c9:85:97", + "switch_info": "g16-45-1.iad3.rackspace.net", + }, + name="Dell-5BFZMD4:NIC.Integrated.1-1-1", + ), +] + + +def _node(*, provision_state="manageable", name=NODE_NAME): + return SimpleNamespace( + id=NODE_UUID, + name=name, + provision_state=provision_state, + ) + + +def _mock_conn(mocker, *, node=None, ports=None, port_groups=None): + conn = MagicMock() + conn.baremetal.get_node.return_value = node + conn.baremetal.ports.return_value = ports or [] + conn.baremetal.port_groups.return_value = port_groups or [] + conn.baremetal.create_port_group.return_value = SimpleNamespace(id="new-pg-uuid") + mocker.patch( + "understack_workflows.main.create_port_group.get_openstack_client", + return_value=conn, + ) + return conn + + +# --- dry-run reports correct port group name -------------------------------- + + +def test_dry_run_reports_expected_port_group_name(mocker, caplog): + """Given the real port data, dry-run should report the correct pg name. + + The primary port is selected by sorting eligible ports on + (switch_info, port_id, address). With the two eligible ports: + - g16-45-1... Ethernet1/9 (dac434df) -> sorts first + - g16-45-2... Ethernet1/9 (7aedc667) + + port_channel suffix from "Ethernet1/9" -> "09" + Expected name: Dell-5BFZMD4-port-channel109 + """ + _mock_conn(mocker, node=_node(), ports=PORTS) + + import logging + + with caplog.at_level(logging.INFO): + create_port_group.create_port_group(NODE_UUID, dry_run=True) + + assert "Dell-5BFZMD4-port-channel109" in caplog.text + assert "[dry-run]" in caplog.text + + +def test_dry_run_does_not_create_port_group(mocker, caplog): + conn = _mock_conn(mocker, node=_node(), ports=PORTS) + + import logging + + with caplog.at_level(logging.INFO): + create_port_group.create_port_group(NODE_UUID, dry_run=True) + + conn.baremetal.create_port_group.assert_not_called() + conn.baremetal.update_port.assert_not_called() + + +# --- normal execution creates port group ------------------------------------ + + +def test_creates_port_group_with_correct_name(mocker): + conn = _mock_conn(mocker, node=_node(), ports=PORTS) + + create_port_group.create_port_group(NODE_UUID) + + conn.baremetal.create_port_group.assert_called_once() + call_kwargs = conn.baremetal.create_port_group.call_args.kwargs + assert call_kwargs["name"] == "Dell-5BFZMD4-port-channel109" + assert call_kwargs["mode"] == "802.3ad" + assert call_kwargs["node_id"] == NODE_UUID + + +def test_creates_port_group_with_primary_mac(mocker): + """The MAC should come from the primary (first sorted) eligible port.""" + conn = _mock_conn(mocker, node=_node(), ports=PORTS) + + create_port_group.create_port_group(NODE_UUID) + + call_kwargs = conn.baremetal.create_port_group.call_args.kwargs + # Primary is dac434df with MAC b4:83:51:24:e4:42 + assert call_kwargs["address"] == "b4:83:51:24:e4:42" + + +def test_assigns_eligible_ports_to_port_group(mocker): + conn = _mock_conn(mocker, node=_node(), ports=PORTS) + + create_port_group.create_port_group(NODE_UUID) + + # Two eligible ports should be assigned + assert conn.baremetal.update_port.call_count == 2 + updated_port_ids = { + call.args[0] for call in conn.baremetal.update_port.call_args_list + } + assert updated_port_ids == { + "7aedc667-adaf-4cb0-9912-5dceddd2a375", + "dac434df-7b24-455e-8570-ba6e1bbea244", + } + + +# --- error conditions ------------------------------------------------------- + + +def test_exits_when_node_not_found(mocker): + _mock_conn(mocker, node=None) + + with pytest.raises(SystemExit): + create_port_group.create_port_group(NODE_UUID) + + +def test_exits_when_node_in_disallowed_state(mocker): + _mock_conn(mocker, node=_node(provision_state="active")) + + with pytest.raises(SystemExit): + create_port_group.create_port_group(NODE_UUID) + + +def test_exits_when_port_group_already_exists(mocker): + existing_pg = SimpleNamespace(id="existing-pg") + _mock_conn(mocker, node=_node(), ports=PORTS, port_groups=[existing_pg]) + + with pytest.raises(SystemExit): + create_port_group.create_port_group(NODE_UUID) + + +def test_exits_when_no_eligible_ports(mocker): + # All ports have empty local_link_connection + ports_no_llc = [ + _make_port("aaa", "00:11:22:33:44:55", llc={}), + _make_port("bbb", "00:11:22:33:44:66", llc={}), + ] + _mock_conn(mocker, node=_node(), ports=ports_no_llc) + + with pytest.raises(SystemExit): + create_port_group.create_port_group(NODE_UUID) + + +# --- parse_port_channel ----------------------------------------------------- + + +def test_parse_port_channel_extracts_numeric_suffix(): + assert create_port_group.parse_port_channel("Ethernet1/9") == "09" + assert create_port_group.parse_port_channel("Ethernet1/15") == "15" + assert create_port_group.parse_port_channel("Ethernet1/1") == "01" + + +def test_parse_port_channel_exits_on_non_numeric(mocker): + with pytest.raises(SystemExit): + create_port_group.parse_port_channel("Ethernet1/foo") + + +# --- name vs uuid warning --------------------------------------------------- + + +def test_warns_when_name_provided_instead_of_uuid(mocker, caplog): + _mock_conn(mocker, node=_node(), ports=PORTS) + + import logging + + with caplog.at_level(logging.WARNING): + create_port_group.create_port_group("Dell-5BFZMD4") + + assert "not a UUID" in caplog.text + assert "preferred" in caplog.text + + +def test_no_warning_when_uuid_provided(mocker, caplog): + _mock_conn(mocker, node=_node(), ports=PORTS) + + import logging + + with caplog.at_level(logging.WARNING): + create_port_group.create_port_group(NODE_UUID) + + assert "not a UUID" not in caplog.text diff --git a/python/understack-workflows/understack_workflows/main/create_port_group.py b/python/understack-workflows/understack_workflows/main/create_port_group.py new file mode 100644 index 000000000..323870214 --- /dev/null +++ b/python/understack-workflows/understack_workflows/main/create_port_group.py @@ -0,0 +1,178 @@ +"""Create an Ironic port group for a baremetal node. + +Accepts a node identifier (UUID or name) and creates a bonded port group +from the node's ports that have local_link_connection data, assigning +all eligible ports as members. +""" + +import argparse +import logging +import os +import sys +import uuid as _uuid + +from understack_workflows import helpers +from understack_workflows.openstack.client import get_openstack_client + +logger = logging.getLogger(__name__) + +ALLOWED_STATES = {"enroll", "inspecting", "inspect wait", "manageable"} + + +def parse_port_channel(port_id: str) -> str: + """Derive a zero-padded port-channel suffix from a port_id string. + + Expects the numeric portion after the last '/' in the port_id. + """ + tail = port_id.rsplit("/", 1)[-1] + if not tail.isdigit(): + logger.error( + "Cannot derive numeric port-channel suffix from port_id='%s'", + port_id, + ) + sys.exit(1) + return f"{int(tail):02d}" + + +def create_port_group(node_id: str, dry_run: bool = False) -> None: + """Create a port group for the given node (UUID or name). + + When dry_run is True, validates the node and reports what port group + name would be created without making any changes. + """ + os_cloud = os.getenv("OS_CLOUD", "understack") + conn = get_openstack_client(cloud=os_cloud) + + try: + _uuid.UUID(node_id) + except ValueError: + logger.warning( + "Node identifier '%s' is not a UUID. Using a UUID is preferred " + "as node names may be reassigned or changed.", + node_id, + ) + + node = conn.baremetal.get_node(node_id) + if node is None: + logger.error("Node '%s' not found", node_id) + sys.exit(1) + + state = (node.provision_state or "").lower() + if state not in ALLOWED_STATES: + logger.error( + "Node %s is in state '%s', allowed: %s", + node.id, + node.provision_state, + sorted(ALLOWED_STATES), + ) + sys.exit(1) + + existing_pgs = list(conn.baremetal.port_groups(node=node.id)) + if existing_pgs: + logger.error("Port group already exists for node %s", node.id) + sys.exit(1) + + ports = list(conn.baremetal.ports(node=node.id, details=True)) + eligible = [] + for port in ports: + llc = getattr(port, "local_link_connection", None) or {} + if llc and llc.get("port_id"): + eligible.append(port) + + if not eligible: + logger.error( + "No ports with local_link_connection.port_id found for node %s", + node.id, + ) + sys.exit(1) + + def sort_key(port): + llc = port.local_link_connection or {} + return ( + llc.get("switch_info") or "", + llc.get("port_id") or "", + port.address or "", + ) + + primary = sorted(eligible, key=sort_key)[0] + llc = primary.local_link_connection or {} + port_channel = parse_port_channel(llc["port_id"]) + + mac = (primary.address or "").strip() + if not mac: + logger.error("Primary port %s is missing MAC address", primary.id) + sys.exit(1) + + node_name = node.name or node.id + pg_name = f"{node_name}-port-channel1{port_channel}" + + if dry_run: + logger.info( + "[dry-run] Would create port group '%s' for node %s", + pg_name, + node.id, + ) + logger.info("[dry-run] MAC: %s | Eligible ports: %d", mac, len(eligible)) + for port in eligible: + port_llc = port.local_link_connection or {} + logger.info( + "[dry-run] port %s switch=%s port_id=%s", + port.id, + port_llc.get("switch_info", ""), + port_llc.get("port_id", ""), + ) + return + + logger.info( + "Creating port group '%s' for node %s with MAC %s", + pg_name, + node.id, + mac, + ) + + pg = conn.baremetal.create_port_group( + node_id=node.id, + name=pg_name, + address=mac, + mode="802.3ad", + properties={ + "miimon": "100", + "xmit_hash_policy": "layer2+3", + "lacp_rate": "normal", + }, + is_standalone_ports_supported=True, + ) + + for port in eligible: + logger.info("Assigning port %s -> port group %s", port.id, pg.id) + conn.baremetal.update_port(port.id, port_group_id=pg.id) + + logger.info("Created port group %s with %d member port(s)", pg.id, len(eligible)) + + +def argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=os.path.basename(__file__), + description="Create an Ironic port group for a baremetal node", + ) + parser.add_argument( + "node", + help="Node UUID or name", + ) + parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Report the port group name that would be created without making changes", + ) + return parser + + +def main() -> None: + helpers.setup_logger() + args = argument_parser().parse_args() + create_port_group(node_id=args.node, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/workflows/argo-events/workflowtemplates/server-create-port-group.yaml b/workflows/argo-events/workflowtemplates/server-create-port-group.yaml new file mode 100644 index 000000000..2adff4d46 --- /dev/null +++ b/workflows/argo-events/workflowtemplates/server-create-port-group.yaml @@ -0,0 +1,55 @@ +--- +apiVersion: argoproj.io/v1alpha1 +metadata: + name: server-create-port-group + annotations: + workflows.argoproj.io/title: Create Baremetal Port Group for a node/server + workflows.argoproj.io/description: | + Creates a bonded (802.3ad) port group from a node's ports that have + local_link_connection data, and assigns all eligible ports as members. + + Parameters: + + - `node`: required node UUID or name + + To test this workflow you can run it with the following: + + ``` + argo -n argo-events submit --from workflowtemplate/server-create-port-group \ + -p node= + ``` + + Defined in `workflows/argo-events/workflowtemplates/server-create-port-group.yaml` +kind: WorkflowTemplate +spec: + entrypoint: server-create-port-group + serviceAccountName: workflow + arguments: + parameters: + - name: node + description: Node UUID or name to create a port group for. + volumes: + - name: baremetal-manage + secret: + secretName: baremetal-manage + items: + - key: clouds.yaml + path: clouds.yaml + templates: + - name: server-create-port-group + inputs: + parameters: + - name: node + container: + image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest + command: + - create-port-group + args: + - "{{inputs.parameters.node}}" + volumeMounts: + - mountPath: /etc/openstack + name: baremetal-manage + readOnly: true + env: + - name: OS_CLOUD + value: understack