Skip to content

Commit fe70029

Browse files
authored
Merge pull request #1347 from rackerlabs/ironic-inspection-hook
feat(ironic): Add update_baremetal_port ironic inspection hook
2 parents 72537b4 + bb4bd7a commit fe70029

File tree

12 files changed

+783
-7
lines changed

12 files changed

+783
-7
lines changed

components/ironic/values.yaml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@ conf:
8888
loader_file_paths: "snponly.efi:/usr/lib/ipxe/snponly.efi"
8989
inspector:
9090
extra_kernel_params: ipa-collect-lldp=1
91-
# Agent inspection hooks - ports hook removed to prevent port manipulation during agent inspection
92-
# Default hooks include: ramdisk-error,validate-interfaces,ports,architecture
93-
# We override to exclude 'ports' from the default hooks
94-
default_hooks: "ramdisk-error,validate-interfaces,architecture"
95-
hooks: "$default_hooks,pci-devices,parse-lldp,local-link-connection,resource-class"
91+
# Agent inspection hooks run after inspecting in-band using the IPA image:
92+
hooks: "ramdisk-error,validate-interfaces,architecture,pci-devices,validate-interfaces,parse-lldp,resource-class,update-baremetal-port"
93+
redfish:
94+
# Redfish inspection hooks run after inspecting out-of-band using the BMC:
95+
inspection_hooks: "validate-interfaces,ports,port-bios-name,architecture,pci-devices"
96+
add_ports: "all"
9697
# enable sensors and metrics for redfish metrics - https://docs.openstack.org/ironic/latest/admin/drivers/redfish/metrics.html
9798
sensor_data:
9899
send_sensor_data: true

python/ironic-understack/ironic_understack/conf.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,22 @@ def setup_conf():
1010
"device_types_dir",
1111
help="directory storing Device Type description YAML files",
1212
default="/var/lib/understack/device-types",
13-
)
13+
),
14+
cfg.DictOpt(
15+
"switch_name_vlan_group_mapping",
16+
help="Dictionary of switch hostname suffix to vlan group name",
17+
default={
18+
"1": "network",
19+
"2": "network",
20+
"3": "network",
21+
"4": "network",
22+
"1f": "storage",
23+
"2f": "storage",
24+
"3f": "storage-appliance",
25+
"4f": "storage-appliance",
26+
"1d": "bmc",
27+
},
28+
),
1429
]
1530
cfg.CONF.register_group(grp)
1631
cfg.CONF.register_opts(opts, group=grp)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class InspectedPort:
6+
"""Represents a parsed entry from Ironic inspection (inventory) data."""
7+
8+
mac_address: str
9+
name: str
10+
switch_system_name: str
11+
switch_port_id: str
12+
switch_chassis_id: str
13+
14+
@property
15+
def local_link_connection(self) -> dict:
16+
return {
17+
"port_id": self.switch_port_id,
18+
"switch_id": self.switch_chassis_id,
19+
"switch_info": self.switch_system_name,
20+
}
21+
22+
@property
23+
def parsed_name(self) -> dict[str, str]:
24+
parts = self.switch_system_name.split(".", maxsplit=1)
25+
if len(parts) != 2:
26+
raise ValueError(
27+
"Failed to parse switch hostname - expecting name.dc in %s", self
28+
)
29+
switch_name, data_center_name = parts
30+
31+
parts = switch_name.rsplit("-", maxsplit=1)
32+
if len(parts) != 2:
33+
raise ValueError(
34+
f"Unknown switch name format: {switch_name} - this hook requires "
35+
f"that switch names follow the convention <cabinet-name>-<suffix>"
36+
)
37+
38+
rack_name, switch_suffix = parts
39+
40+
return {
41+
"rack_name": rack_name,
42+
"switch_suffix": switch_suffix,
43+
"data_center_name": data_center_name,
44+
}
45+
46+
@property
47+
def rack_name(self) -> str:
48+
return self.parsed_name["rack_name"]
49+
50+
@property
51+
def switch_suffix(self) -> str:
52+
return self.parsed_name["switch_suffix"]
53+
54+
@property
55+
def data_center_name(self) -> str:
56+
return self.parsed_name["data_center_name"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import ironic.objects
2+
3+
4+
def ironic_ports_for_node(context, node_id: str) -> list:
5+
return ironic.objects.Port.list_by_node_id(context, node_id)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from ironic.drivers.modules.inspector.hooks import base
2+
from oslo_log import log as logging
3+
4+
from ironic_understack.ironic_wrapper import ironic_ports_for_node
5+
6+
LOG = logging.getLogger(__name__)
7+
8+
9+
class PortBiosNameHook(base.InspectionHook):
10+
"""Set port.extra.bios_name and pxe_enabled fields from redfish data."""
11+
12+
# "ports" creates baremetal ports for each physical NIC, be sure to run this
13+
# first because we will only be updating ports that already exist:
14+
dependencies = ["ports"]
15+
16+
def __call__(self, task, inventory, plugin_data):
17+
"""Populate the baremetal_port.extra.bios_name attribute."""
18+
inspected_interfaces = inventory.get("interfaces")
19+
if not inspected_interfaces:
20+
LOG.error("No interfaces in inventory for node %s", task.node.uuid)
21+
return
22+
23+
interface_names = {
24+
i["mac_address"].upper(): i["name"] for i in inspected_interfaces
25+
}
26+
27+
pxe_interface = _pxe_interface_name(inspected_interfaces)
28+
29+
for baremetal_port in ironic_ports_for_node(task.context, task.node.id):
30+
mac = baremetal_port.address.upper()
31+
required_bios_name = interface_names.get(mac)
32+
extra = baremetal_port.extra
33+
current_bios_name = extra.get("bios_name")
34+
35+
if current_bios_name != required_bios_name:
36+
LOG.info(
37+
"Port %(mac)s updating bios_name from %(old)s to %(new)s",
38+
{"mac": mac, "old": current_bios_name, "new": required_bios_name},
39+
)
40+
41+
if required_bios_name:
42+
extra["bios_name"] = required_bios_name
43+
else:
44+
extra.pop("bios_name", None)
45+
46+
baremetal_port.extra = extra
47+
baremetal_port.save()
48+
49+
required_pxe = required_bios_name == pxe_interface
50+
if baremetal_port.pxe_enabled != required_pxe:
51+
LOG.info("Port %s changed pxe_enabled to %s", mac, required_pxe)
52+
baremetal_port.pxe_enabled = required_pxe
53+
baremetal_port.save()
54+
55+
56+
def _pxe_interface_name(inspected_interfaces: list[dict]) -> str:
57+
"""Use a heuristic to determine our default interface for PXE."""
58+
names = sorted(i["name"] for i in inspected_interfaces)
59+
for prefix in ["NIC.Integrated", "NIC.Slot"]:
60+
for name in names:
61+
if name.startswith(prefix):
62+
return name
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import logging
2+
3+
from oslo_utils import uuidutils
4+
5+
from ironic_understack.port_bios_name_hook import PortBiosNameHook
6+
7+
_INVENTORY = {
8+
"memory": {"physical_mb": 98304},
9+
"interfaces": [
10+
{"mac_address": "11:11:11:11:11:11", "name": "NIC.Integrated.1-1"},
11+
{"mac_address": "22:22:22:22:22:22", "name": "NIC.Integrated.1-2"},
12+
],
13+
}
14+
15+
16+
def test_adding_bios_name(mocker, caplog):
17+
caplog.set_level(logging.DEBUG)
18+
19+
node_uuid = uuidutils.generate_uuid()
20+
mock_context = mocker.Mock()
21+
mock_node = mocker.Mock(id=1234)
22+
mock_task = mocker.Mock(node=mock_node, context=mock_context)
23+
mock_port = mocker.Mock(
24+
uuid=uuidutils.generate_uuid(),
25+
node_id=node_uuid,
26+
address="11:11:11:11:11:11",
27+
extra={},
28+
)
29+
30+
mocker.patch(
31+
"ironic_understack.port_bios_name_hook.ironic_ports_for_node",
32+
return_value=[mock_port],
33+
)
34+
35+
PortBiosNameHook().__call__(mock_task, _INVENTORY, {})
36+
37+
assert mock_port.extra == {"bios_name": "NIC.Integrated.1-1"}
38+
mock_port.save.assert_called()
39+
40+
41+
def test_removing_bios_name(mocker, caplog):
42+
caplog.set_level(logging.DEBUG)
43+
44+
node_uuid = uuidutils.generate_uuid()
45+
mock_context = mocker.Mock()
46+
mock_node = mocker.Mock(id=1234)
47+
mock_task = mocker.Mock(node=mock_node, context=mock_context)
48+
mock_port = mocker.Mock(
49+
uuid=uuidutils.generate_uuid(),
50+
node_id=node_uuid,
51+
address="33:33:33:33:33:33",
52+
extra={"bios_name": "old_name_no_longer_valid"},
53+
)
54+
55+
mocker.patch(
56+
"ironic_understack.port_bios_name_hook.ironic_ports_for_node",
57+
return_value=[mock_port],
58+
)
59+
60+
PortBiosNameHook().__call__(mock_task, _INVENTORY, {})
61+
62+
assert "bios_name" not in mock_port.extra
63+
mock_port.save.assert_called()
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import logging
2+
3+
import ironic.objects
4+
from oslo_utils import uuidutils
5+
6+
import ironic_understack
7+
from ironic_understack.update_baremetal_port import UpdateBaremetalPortsHook
8+
9+
# load some metaprgramming normally taken care of during Ironic initialization:
10+
ironic.objects.register_all()
11+
12+
_INVENTORY = {}
13+
_PLUGIN_DATA = {
14+
"all_interfaces": {
15+
"ex1": {
16+
"name": "ex1",
17+
"mac_address": "11:11:11:11:11:11",
18+
},
19+
"ex2": {
20+
"name": "ex2",
21+
"mac_address": "22:22:22:22:22:22",
22+
},
23+
"ex3": {
24+
"name": "ex3",
25+
"mac_address": "33:33:33:33:33:33",
26+
},
27+
"ex4": {
28+
"name": "ex4",
29+
"mac_address": "44:44:44:44:44:44",
30+
},
31+
},
32+
"parsed_lldp": {
33+
"ex1": {
34+
"switch_chassis_id": "88:5a:92:ec:54:59",
35+
"switch_port_id": "Ethernet1/18",
36+
"switch_system_name": "f20-3-1.iad3",
37+
},
38+
"ex2": {
39+
"switch_chassis_id": "88:5a:92:ec:54:59",
40+
"switch_port_id": "Ethernet1/18",
41+
"switch_system_name": "f20-3-2.iad3",
42+
},
43+
"ex3": {
44+
"switch_chassis_id": "88:5a:92:ec:54:59",
45+
"switch_port_id": "Ethernet1/18",
46+
"switch_system_name": "f20-3-1f.iad3",
47+
},
48+
"ex4": {
49+
"switch_chassis_id": "88:5a:92:ec:54:59",
50+
"switch_port_id": "Ethernet1/18",
51+
"switch_system_name": "f20-3-2f.iad3",
52+
},
53+
},
54+
}
55+
56+
MAPPING = {
57+
"1": "network",
58+
"2": "network",
59+
"1f": "storage",
60+
"2f": "storage",
61+
"-1d": "bmc",
62+
}
63+
64+
65+
def test_with_valid_data(mocker, caplog):
66+
caplog.set_level(logging.DEBUG)
67+
68+
node_uuid = uuidutils.generate_uuid()
69+
mock_traits = mocker.Mock()
70+
mock_context = mocker.Mock()
71+
mock_node = mocker.Mock(id=1234, traits=mock_traits)
72+
mock_task = mocker.Mock(node=mock_node, context=mock_context)
73+
mock_port = mocker.Mock(
74+
uuid=uuidutils.generate_uuid(),
75+
node_id=node_uuid,
76+
address="11:11:11:11:11:11",
77+
local_link_connection={},
78+
physical_network="original_value",
79+
)
80+
81+
mocker.patch(
82+
"ironic_understack.update_baremetal_port.ironic_ports_for_node",
83+
return_value=[mock_port],
84+
)
85+
mocker.patch(
86+
"ironic_understack.update_baremetal_port.CONF.ironic_understack.switch_name_vlan_group_mapping",
87+
MAPPING,
88+
)
89+
mocker.patch("ironic_understack.update_baremetal_port.objects.TraitList.create")
90+
91+
mock_traits.get_trait_names.return_value = ["CUSTOM_BMC_SWITCH", "bar"]
92+
93+
UpdateBaremetalPortsHook().__call__(mock_task, _INVENTORY, _PLUGIN_DATA)
94+
95+
assert mock_port.local_link_connection == {
96+
"port_id": "Ethernet1/18",
97+
"switch_id": "88:5a:92:ec:54:59",
98+
"switch_info": "f20-3-1.iad3",
99+
}
100+
assert mock_port.physical_network == "f20-3-network"
101+
mock_port.save.assert_called()
102+
103+
mock_traits.get_trait_names.assert_called_once()
104+
mock_traits.destroy.assert_called_once_with("CUSTOM_BMC_SWITCH")
105+
ironic_understack.update_baremetal_port.objects.TraitList.create.assert_called_once_with(
106+
mock_context, 1234, ["CUSTOM_NETWORK_SWITCH", "CUSTOM_STORAGE_SWITCH"]
107+
)
108+
mock_node.save.assert_called_once()

0 commit comments

Comments
 (0)