Skip to content

Commit c033a89

Browse files
committed
Add update_baremetal_port ironic inspection hook
1 parent 19a99da commit c033a89

File tree

6 files changed

+348
-0
lines changed

6 files changed

+348
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from ironic.objects import port as ironic_port
2+
from oslo_utils import uuidutils
3+
4+
from ironic_understack.update_baremetal_port import UpdateBaremetalPortsHook
5+
6+
_INTERFACE_1 = {
7+
"name": "example1",
8+
"mac_address": "11:11:11:11:11:11",
9+
"ipv4_address": "1.1.1.1",
10+
"lldp": [
11+
(0, ""),
12+
(1, "04885a92ec5459"),
13+
(2, "0545746865726e6574312f3138"),
14+
(3, "0078"),
15+
(5, "6632302d332d32662e69616433"),
16+
],
17+
}
18+
19+
_PLUGIN_DATA = {"all_interfaces": {"example1": _INTERFACE_1}}
20+
21+
_INVENTORY = {"interfaces": [_INTERFACE_1]}
22+
23+
24+
def test_with_valid_data(mocker):
25+
node_uuid = uuidutils.generate_uuid()
26+
mock_node = mocker.Mock()
27+
mock_task = mocker.Mock(node=mock_node)
28+
mock_port = mocker.Mock(
29+
uuid=uuidutils.generate_uuid(),
30+
node_id=node_uuid,
31+
address="11:11:11:11:11:11",
32+
local_link_connection={},
33+
physical_network="original_value",
34+
)
35+
mocker.patch(
36+
"ironic_understack.update_baremetal_port.objects.port.Port.get_by_address",
37+
return_value=mock_port,
38+
)
39+
40+
UpdateBaremetalPortsHook().__call__(mock_task, _INVENTORY, _PLUGIN_DATA)
41+
42+
assert mock_port.local_link_connection == {
43+
"port_id": "Ethernet1/18",
44+
"switch_id": "88:5a:92:ec:54:59",
45+
"switch_info": "f20-3-2f.iad3",
46+
}
47+
assert mock_port.physical_network == "f20-3-storage"
48+
49+
mock_port.save.assert_called()
50+
mock_node.remove_trait.assert_not_called()
51+
mock_node.add_trait.assert_called_once_with("CUSTOM_STORAGE_SWITCH")
52+
mock_node.save.assert_called_once()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pytest
2+
3+
from ironic_understack.vlan_group_name_convention import vlan_group_name
4+
5+
6+
def test_vlan_group_name_valid_switches():
7+
assert vlan_group_name("a1-1-1") == "a1-1-network"
8+
assert vlan_group_name("a1-2-1") == "a1-2-network"
9+
assert vlan_group_name("b12-1") == "b12-network"
10+
assert vlan_group_name("a2-12-1") == "a2-12-network"
11+
assert vlan_group_name("a2-12-2") == "a2-12-network"
12+
assert vlan_group_name("a2-12-1f") == "a2-12-storage"
13+
assert vlan_group_name("a2-12-2f") == "a2-12-storage"
14+
assert vlan_group_name("a2-12-3f") == "a2-12-storage-appliance"
15+
assert vlan_group_name("a2-12-4f") == "a2-12-storage-appliance"
16+
assert vlan_group_name("a2-12-1d") == "a2-12-bmc"
17+
18+
19+
def test_vlan_group_name_with_domain():
20+
assert vlan_group_name("a2-12-1.iad3.rackspace.net") == "a2-12-network"
21+
assert vlan_group_name("a2-12-1f.lon3.rackspace.net") == "a2-12-storage"
22+
23+
24+
def test_vlan_group_name_case_insensitive():
25+
assert vlan_group_name("A2-12-1F") == "a2-12-storage"
26+
assert vlan_group_name("A2-12-1") == "a2-12-network"
27+
28+
29+
def test_vlan_group_name_invalid_format():
30+
with pytest.raises(ValueError, match="Unknown switch name format"):
31+
vlan_group_name("invalid")
32+
33+
with pytest.raises(ValueError, match="Unknown switch name format"):
34+
vlan_group_name("")
35+
36+
37+
def test_vlan_group_name_unknown_suffix():
38+
with pytest.raises(ValueError, match="Unknown switch suffix"):
39+
vlan_group_name("a2-12-99")
40+
41+
with pytest.raises(ValueError, match="Unknown switch suffix"):
42+
vlan_group_name("a2-12-5f")
43+
44+
with pytest.raises(ValueError, match="Unknown switch suffix"):
45+
vlan_group_name("a2-12-xyz")
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import binascii
2+
from typing import Any
3+
4+
import netaddr
5+
import openstack
6+
from construct import core
7+
from ironic import objects
8+
from ironic.common import exception
9+
from ironic.drivers.modules.inspector import lldp_tlvs
10+
from ironic.drivers.modules.inspector.hooks import base
11+
from oslo_log import log as logging
12+
13+
import ironic_understack.vlan_group_name_convention
14+
15+
LOG = logging.getLogger(__name__)
16+
17+
LldpData = list[tuple[int, str]]
18+
19+
class UpdateBaremetalPortsHook(base.InspectionHook):
20+
"""Hook to update ports according to LLDP data."""
21+
22+
dependencies = ["validate-interfaces"]
23+
24+
def __call__(self, task, inventory, plugin_data):
25+
"""Update Ports' local_link_info and physnet based on LLDP data.
26+
27+
Process the LLDP packet fields for each NIC in the inventory.
28+
29+
Updates attributes of the baremetal port:
30+
31+
- local_link_info.port_id (e.g. "Ethernet1/1")
32+
- local_link_info.switch_id (e.g. "aa:bb:cc:dd:ee:ff")
33+
- local_link_info.switch_info (e.g. "a1-1-1.ord1")
34+
- physical_network (e.g. "a1-1-network")
35+
36+
Also adds or removes node "traits" based on the inventory data. We
37+
control the trait "CUSTOM_STORAGE_SWITCH".
38+
"""
39+
lldp_raw: dict[str, LldpData] = plugin_data.get("lldp_raw") or {}
40+
node_uuid: str = task.node.id
41+
interfaces: list[dict] = inventory["interfaces"]
42+
# The all_interfaces field in plugin_data is provided by the
43+
# validate-interfaces hook, so it is a dependency for this hook
44+
all_interfaces: dict[str, dict] = plugin_data["all_interfaces"]
45+
context = task.context
46+
vlan_groups: set[str] = set()
47+
48+
for iface in interfaces:
49+
if iface["name"] not in all_interfaces:
50+
# This interface was not "validated" so don't bother with it
51+
continue
52+
53+
mac_address = iface["mac_address"]
54+
port = objects.port.Port.get_by_address(context, mac_address)
55+
if not port:
56+
LOG.debug(
57+
"Skipping LLDP processing for interface %s of node "
58+
"%s: matching port not found in Ironic.",
59+
mac_address,
60+
node_uuid,
61+
)
62+
continue
63+
64+
lldp_data = lldp_raw.get(iface["name"]) or iface.get("lldp")
65+
if lldp_data is None:
66+
LOG.warning(
67+
"No LLDP data found for interface %s of node %s",
68+
mac_address,
69+
node_uuid,
70+
)
71+
continue
72+
73+
local_link_connection = _parse_lldp(lldp_data, node_uuid)
74+
vlan_group = vlan_group_name(local_link_connection)
75+
76+
_set_local_link_connection(port, node_uuid, local_link_connection)
77+
_update_port_physical_network(port, vlan_group)
78+
if vlan_group:
79+
vlan_groups.add(vlan_group)
80+
_update_node_traits(task, vlan_groups)
81+
82+
83+
def _set_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict):
84+
try:
85+
LOG.debug("Updating port %s for node %s", port.address, node_uuid)
86+
port.local_link_connection = local_link_connection
87+
port.save()
88+
except exception.IronicException as e:
89+
LOG.warning(
90+
"Failed to update port %(uuid)s for node %(node)s. Error: %(error)s",
91+
{"uuid": port.id, "node": node_uuid, "error": e},
92+
)
93+
94+
95+
def _parse_lldp(lldp_data: LldpData, node_id: str) -> dict[str, str]:
96+
"""Convert Ironic's "lldp_raw" format to local_link dict."""
97+
try:
98+
decoded = {}
99+
for tlv_type, tlv_value in lldp_data:
100+
if tlv_type not in decoded:
101+
decoded[tlv_type] = []
102+
decoded[tlv_type].append(bytearray(binascii.unhexlify(tlv_value)))
103+
104+
local_link = {
105+
"port_id": _extract_port_id(decoded),
106+
"switch_id": _extract_switch_id(decoded),
107+
"switch_info": _extract_hostname(decoded),
108+
}
109+
return { k: v for k, v in local_link.items() if v }
110+
except (binascii.Error, core.MappingError, netaddr.AddrFormatError) as e:
111+
LOG.warning("Failed to parse lldp_raw data for Node %s: %s", node_id, e)
112+
return {}
113+
114+
115+
def _extract_port_id(data: dict) -> str | None:
116+
for value in data.get(lldp_tlvs.LLDP_TLV_PORT_ID, []):
117+
parsed = lldp_tlvs.PortId.parse(value)
118+
if parsed.value: # pyright: ignore reportAttributeAccessIssue
119+
return parsed.value.value # pyright: ignore reportAttributeAccessIssue
120+
121+
122+
def _extract_switch_id(data: dict) -> str | None:
123+
for value in data.get(lldp_tlvs.LLDP_TLV_CHASSIS_ID, []):
124+
parsed = lldp_tlvs.ChassisId.parse(value)
125+
if "mac_address" in parsed.subtype: # pyright: ignore reportAttributeAccessIssue
126+
return str(parsed.value.value) # pyright: ignore reportAttributeAccessIssue
127+
128+
129+
def _extract_hostname(data: dict) -> str | None:
130+
for value in data.get(lldp_tlvs.LLDP_TLV_SYS_NAME, []):
131+
parsed = lldp_tlvs.SysName.parse(value)
132+
if parsed.value: # pyright: ignore reportAttributeAccessIssue
133+
return parsed.value # pyright: ignore reportAttributeAccessIssue
134+
135+
136+
def vlan_group_name(local_link_connection) -> str | None:
137+
switch_name = local_link_connection.get("switch_info")
138+
if not switch_name:
139+
return
140+
141+
return ironic_understack.vlan_group_name_convention.vlan_group_name(switch_name)
142+
143+
144+
def _update_port_physical_network(port, new_physical_network: str|None):
145+
old_physical_network = port.physical_network
146+
147+
if new_physical_network == old_physical_network:
148+
return
149+
150+
LOG.debug(
151+
"Updating port %s physical_network from %s to %s",
152+
port.id,
153+
old_physical_network,
154+
new_physical_network,
155+
)
156+
port.physical_network = new_physical_network
157+
port.save()
158+
159+
160+
def _update_node_traits(task, vlan_groups: set[str]):
161+
"""Add or remove traits to the node.
162+
163+
We manage one trait: "CUSTOM_STORAGE_SWITCH" which is added if the node has
164+
any ports connected to a storage fabric, othwise it is removed from the
165+
node.
166+
"""
167+
TRAIT_STORAGE_SWITCH = "CUSTOM_STORAGE_SWITCH"
168+
169+
storage_vlan_groups = {
170+
x for x in vlan_groups if x.endswith("-storage")
171+
}
172+
173+
if storage_vlan_groups:
174+
task.node.add_trait(TRAIT_STORAGE_SWITCH)
175+
else:
176+
try:
177+
task.node.remove_trait(TRAIT_STORAGE_SWITCH)
178+
except openstack.exceptions.NotFoundException:
179+
pass
180+
181+
task.node.save()
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
def vlan_group_name(switch_name: str) -> str:
2+
"""The VLAN GROUP name is a function of the switch name.
3+
4+
Switch hostname convention is site-dependent, but in Rackspace all
5+
top-of-rack switch names follow the convention: <cabinet-name>-<suffix>
6+
Example switch names include a2-12-1f and a2-12-1. (These are normally
7+
qualified with a site-specific domain name like a2-12-1.iad3.rackspace.net,
8+
but we are only considering the unqualified name, ignoring everything after
9+
the first dot).
10+
11+
It easy to parse the switch name into cabinet and suffix. Convert the
12+
switch-name-suffix to vlan-group-suffix using the following mapping:
13+
14+
1 → network
15+
2 → network
16+
1f → storage
17+
2f → storage
18+
3f → storage-appliance
19+
4f → storage-appliance
20+
1d → bmc
21+
22+
The VLAN GROUP name results from joining the cabinet name to the new suffix
23+
with a hyphen. The result is in lower case: <cabinet-name>-<vlan-group-suffix>
24+
25+
So for example, switch a2-12-1 is in VLAN GROUP a2-12-network.
26+
"""
27+
# Remove domain suffix if present (everything after first dot)
28+
switch_name = switch_name.split(".")[0].lower()
29+
30+
# Split into cabinet and suffix (last component after last hyphen)
31+
parts = switch_name.rsplit("-", 1)
32+
if len(parts) != 2:
33+
raise ValueError(f"Unknown switch name format: {switch_name}")
34+
35+
cabinet_name, suffix = parts
36+
37+
# Map suffix to VLAN group suffix
38+
suffix_mapping = {
39+
"1": "network",
40+
"2": "network",
41+
"3": "network",
42+
"4": "network",
43+
"1f": "storage",
44+
"2f": "storage",
45+
"3f": "storage-appliance",
46+
"4f": "storage-appliance",
47+
"1d": "bmc",
48+
}
49+
50+
vlan_suffix = suffix_mapping.get(suffix)
51+
if vlan_suffix is None:
52+
raise ValueError(f"Unknown switch suffix: {suffix}")
53+
54+
return f"{cabinet_name}-{vlan_suffix}"

python/ironic-understack/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ readme = "README.md"
1212
license = "MIT"
1313
dependencies = [
1414
"ironic>=29.0,<30",
15+
"pytest-mock>=3.15.1",
1516
"pyyaml~=6.0",
1617
"understack-flavor-matcher",
1718
]
1819

1920
[project.entry-points."ironic.inspection.hooks"]
2021
resource-class = "ironic_understack.resource_class:UndercloudResourceClassHook"
22+
update-baremetal-port = "ironic_understack.update_baremetal_port:UpdateBaremetalPortsHook"
2123

2224
[project.entry-points."ironic.hardware.interfaces.inspect"]
2325
redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackRedfishInspect"

python/ironic-understack/uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)