Skip to content

Commit e9e2e73

Browse files
committed
feat(understack-workflows): add enroll-dcx-netdev command
Onboard baremetal appliances into Ironic from Rackspace DCX inventory data alone, when there is no BMC access and no NIC MACs. The new `enroll-dcx-netdev <device_number> --name-prefix Thing` command fetches a device's switch ports and location from the DCX API, then reuses `netdev_reconciler.enroll()` to create (idempotently) a netdev Ironic node `<prefix>-<device_number>` with a baremetal port per data port. Per port it sets local_link_connection (placeholder switch_id, switch_info from the DCX switch hostname, port_id Ethernet1/<n>), category=network, a deterministic locally-administered MAC (stable across re-runs), and a physical_network derived from the switch pair. The node's extra carries external_cmdb_id, rack, and position. resource_class defaults to the lowercased name prefix. DCX auth token is read from DCX_TOKEN; OS_CLOUD selects the Ironic target. --dry-run builds and logs the payload without touching Ironic.
1 parent 8afc48d commit e9e2e73

7 files changed

Lines changed: 572 additions & 0 deletions

File tree

‎python/understack-workflows/pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dependencies = [
2323
"pydantic>=2,<3",
2424
"pynautobot>=3,<4",
2525
"python-ironicclient>=6,<7",
26+
"requests>=2,<3",
2627
"sushy>=5.3.0,<6",
2728
"kubernetes==33.1.0",
2829
"netapp-ontap==9.17.1.0",
@@ -32,6 +33,7 @@ dependencies = [
3233
[project.scripts]
3334
bmc-kube-password = "understack_workflows.main.bmc_display_password:main"
3435
bmc-password = "understack_workflows.main.print_bmc_password:main"
36+
enroll-dcx-netdev = "understack_workflows.main.enroll_dcx_netdev:main"
3537
enroll-fw = "understack_workflows.main.enroll_fw:main"
3638
enroll-netdev = "understack_workflows.main.enroll_netdev:main"
3739
enroll-server = "understack_workflows.main.enroll_server:main"
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import pytest
2+
3+
from understack_workflows.dcx import enroll as dcx_enroll
4+
from understack_workflows.dcx.client import DcxClient
5+
6+
DEVICE_NUMBER = 1383170
7+
8+
# Shaped like GET /devices/{id}/switch_ports: one DRACNet (management) port and
9+
# two Public (25G data) ports on a switch pair.
10+
SWITCH_PORTS = [
11+
{
12+
"switch_port": {
13+
"name": "fa0/10",
14+
"vlans": [],
15+
"native_vlan": None,
16+
"interface_type": "DRACNet",
17+
"port_number": 10,
18+
"switch_name": "f8-41-1d.iad3",
19+
}
20+
},
21+
{
22+
"switch_port": {
23+
"name": "fa0/1",
24+
"vlans": [3800],
25+
"native_vlan": 3800,
26+
"interface_type": "Public",
27+
"port_number": 1,
28+
"switch_name": "f8-41-2.iad3",
29+
}
30+
},
31+
{
32+
"switch_port": {
33+
"name": "fa0/16",
34+
"vlans": [],
35+
"native_vlan": 1,
36+
"interface_type": "Public",
37+
"port_number": 16,
38+
"switch_name": "f8-42-2.iad3",
39+
}
40+
},
41+
]
42+
43+
# Shaped like GET /devices/locations_and_ports?device_numbers={id}
44+
LOCATION = {
45+
"device_number": str(DEVICE_NUMBER),
46+
"container": "F8-41",
47+
"starting_space": "19.0",
48+
"data_center": "IAD3",
49+
}
50+
51+
52+
def build():
53+
return dcx_enroll.build_enroll_kwargs(
54+
# argparse hands us the device number as a string; enroll should coerce
55+
# external_cmdb_id back to an int.
56+
device_number=str(DEVICE_NUMBER),
57+
switch_ports=SWITCH_PORTS,
58+
location=LOCATION,
59+
name_prefix="Appliance",
60+
)
61+
62+
63+
def test_node_name_and_cmdb_and_resource_class():
64+
kwargs = build()
65+
assert kwargs["name"] == "Appliance-1383170"
66+
assert kwargs["external_cmdb_id"] == DEVICE_NUMBER
67+
assert kwargs["resource_class"] == "appliance"
68+
69+
70+
def test_only_public_ports_are_enrolled():
71+
kwargs = build()
72+
interfaces = {port["intf"] for port in kwargs["ports"]}
73+
# DRACNet (Ethernet1/10) is excluded; only the two Public ports remain.
74+
assert interfaces == {"Ethernet1/1", "Ethernet1/16"}
75+
assert len(kwargs["ports"]) == 2
76+
77+
78+
def test_port_fields():
79+
kwargs = build()
80+
by_switch = {port["switch"]: port for port in kwargs["ports"]}
81+
82+
# switch_info is the fully-qualified switch name.
83+
port = by_switch["f8-41-2.iad3.rackspace.net"]
84+
assert port["label"] == "fa0/1"
85+
assert port["intf"] == "Ethernet1/1"
86+
87+
port = by_switch["f8-42-2.iad3.rackspace.net"]
88+
assert port["label"] == "fa0/16"
89+
assert port["intf"] == "Ethernet1/16"
90+
91+
92+
def test_physical_network_is_derived():
93+
kwargs = build()
94+
# The data switches are a -2 pair, so the suffix is kept in the group name.
95+
assert kwargs["physical_network"] == "f8-41-2/f8-42-2-network"
96+
97+
98+
@pytest.mark.parametrize(
99+
("switch_names", "expected"),
100+
[
101+
(["f8-40-1.iad3", "f8-40-2.iad3"], "f8-40-network"),
102+
(["f8-41-1.iad3", "f8-42-1.iad3"], "f8-41/f8-42-network"),
103+
(["f8-41-2.iad3", "f8-42-2.iad3"], "f8-41-2/f8-42-2-network"),
104+
(["f8-41-3.iad3", "f8-42-3.iad3"], "f8-41-3/f8-42-3-network"),
105+
],
106+
)
107+
def test_derive_physnet(switch_names, expected):
108+
assert dcx_enroll.derive_physnet(switch_names) == expected
109+
110+
111+
def test_physical_network_override():
112+
kwargs = dcx_enroll.build_enroll_kwargs(
113+
device_number=DEVICE_NUMBER,
114+
switch_ports=SWITCH_PORTS,
115+
location=LOCATION,
116+
name_prefix="Appliance",
117+
physical_network="custom-network",
118+
)
119+
assert kwargs["physical_network"] == "custom-network"
120+
121+
122+
def test_resource_class_override():
123+
kwargs = dcx_enroll.build_enroll_kwargs(
124+
device_number=DEVICE_NUMBER,
125+
switch_ports=SWITCH_PORTS,
126+
location=LOCATION,
127+
name_prefix="Appliance",
128+
resource_class="special",
129+
)
130+
assert kwargs["resource_class"] == "special"
131+
132+
133+
def test_extra_carries_rack_and_position():
134+
kwargs = build()
135+
assert kwargs["extra"] == {
136+
"external_cmdb_id": DEVICE_NUMBER,
137+
"rack": "F8-41",
138+
"position": 19,
139+
}
140+
141+
142+
def test_no_public_ports_raises():
143+
with pytest.raises(ValueError, match="no Public switch ports"):
144+
dcx_enroll.build_enroll_kwargs(
145+
device_number=DEVICE_NUMBER,
146+
switch_ports=[SWITCH_PORTS[0]],
147+
location=LOCATION,
148+
name_prefix="Appliance",
149+
)
150+
151+
152+
def test_deterministic_mac_is_stable_and_locally_administered():
153+
mac1 = dcx_enroll.deterministic_mac(DEVICE_NUMBER, "f8-41-2.iad3", 1)
154+
mac2 = dcx_enroll.deterministic_mac(DEVICE_NUMBER, "f8-41-2.iad3", 1)
155+
assert mac1 == mac2
156+
157+
first_octet = int(mac1.split(":")[0], 16)
158+
assert first_octet & 0x01 == 0 # unicast
159+
assert first_octet & 0x02 == 0x02 # locally administered
160+
161+
162+
def test_deterministic_mac_is_unique_per_port():
163+
mac1 = dcx_enroll.deterministic_mac(DEVICE_NUMBER, "f8-41-2.iad3", 1)
164+
mac2 = dcx_enroll.deterministic_mac(DEVICE_NUMBER, "f8-42-2.iad3", 16)
165+
assert mac1 != mac2
166+
167+
168+
def test_macs_reproducible_across_builds():
169+
assert build()["ports"] == build()["ports"]
170+
171+
172+
def test_rack_of():
173+
assert dcx_enroll.rack_of("f8-41-2.iad3") == "f8-41"
174+
175+
176+
def test_rack_of_bad_name_raises():
177+
with pytest.raises(ValueError, match="Unexpected switch name"):
178+
dcx_enroll.rack_of("nodashes")
179+
180+
181+
def test_dcx_client_switch_ports(requests_mock):
182+
client = DcxClient(auth_token="secret", api_url="https://dcx.example")
183+
requests_mock.get(
184+
"https://dcx.example/devices/1383170/switch_ports",
185+
json=SWITCH_PORTS,
186+
)
187+
assert client.switch_ports(1383170) == SWITCH_PORTS
188+
assert requests_mock.last_request.headers["X-Auth-Token"] == "secret"
189+
190+
191+
def test_dcx_client_location(requests_mock):
192+
client = DcxClient(auth_token="secret", api_url="https://dcx.example")
193+
requests_mock.get(
194+
"https://dcx.example/devices/locations_and_ports",
195+
json=[LOCATION],
196+
)
197+
assert client.location(1383170) == LOCATION
198+
assert requests_mock.last_request.qs["device_numbers"] == ["1383170"]
199+
200+
201+
def test_dcx_client_location_empty_raises(requests_mock):
202+
client = DcxClient(auth_token="secret", api_url="https://dcx.example")
203+
requests_mock.get(
204+
"https://dcx.example/devices/locations_and_ports",
205+
json=[],
206+
)
207+
with pytest.raises(ValueError, match="no location"):
208+
client.location(1383170)
209+
210+
211+
class FakeDcxClient(DcxClient):
212+
def __init__(self):
213+
pass
214+
215+
def switch_ports(self, device_number):
216+
return SWITCH_PORTS
217+
218+
def location(self, device_number):
219+
return LOCATION
220+
221+
222+
def test_enroll_from_dcx_calls_reconciler(monkeypatch):
223+
captured = {}
224+
225+
def fake_enroll(**kwargs):
226+
captured.update(kwargs)
227+
228+
monkeypatch.setattr(dcx_enroll.netdev_reconciler, "enroll", fake_enroll)
229+
230+
result = dcx_enroll.enroll_from_dcx(
231+
client=FakeDcxClient(),
232+
device_number=DEVICE_NUMBER,
233+
name_prefix="Appliance",
234+
)
235+
236+
assert captured == result
237+
assert captured["name"] == "Appliance-1383170"
238+
assert captured["physical_network"] == "f8-41-2/f8-42-2-network"
239+
assert len(captured["ports"]) == 2
240+
241+
242+
def test_enroll_from_dcx_dry_run_does_not_enroll(monkeypatch):
243+
def fail_enroll(**kwargs):
244+
raise AssertionError("enroll must not be called on a dry run")
245+
246+
monkeypatch.setattr(dcx_enroll.netdev_reconciler, "enroll", fail_enroll)
247+
248+
result = dcx_enroll.enroll_from_dcx(
249+
client=FakeDcxClient(),
250+
device_number=DEVICE_NUMBER,
251+
name_prefix="Appliance",
252+
dry_run=True,
253+
)
254+
255+
assert result["name"] == "Appliance-1383170"

‎python/understack-workflows/understack_workflows/dcx/__init__.py‎

Whitespace-only changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import logging
2+
from functools import cached_property
3+
4+
import requests
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class DcxClient:
10+
def __init__(self, auth_token: str, api_url: str) -> None:
11+
"""Simple read-only client for the DCX inventory API.
12+
13+
The auth token is sent as the X-Auth-Token header and is never logged.
14+
"""
15+
self.token = auth_token
16+
self.api_url = api_url.rstrip("/")
17+
18+
@cached_property
19+
def client(self) -> requests.Session:
20+
session = requests.Session()
21+
session.headers = {"X-Auth-Token": self.token}
22+
return session
23+
24+
def switch_ports(self, device_number: int | str) -> list[dict]:
25+
"""Return the raw switch_port entries for a device.
26+
27+
The response is a list of ``{"switch_port": {...}}`` objects.
28+
"""
29+
url = f"{self.api_url}/devices/{device_number}/switch_ports"
30+
logger.info("Fetching DCX switch_ports for device %s", device_number)
31+
response = self.client.get(url)
32+
response.raise_for_status()
33+
return response.json()
34+
35+
def location(self, device_number: int | str) -> dict:
36+
"""Return the location record for a device (rack/space/data center).
37+
38+
The response is a list; we return the single matching entry.
39+
"""
40+
url = f"{self.api_url}/devices/locations_and_ports"
41+
logger.info("Fetching DCX location for device %s", device_number)
42+
response = self.client.get(url, params={"device_numbers": device_number})
43+
response.raise_for_status()
44+
records = response.json()
45+
if not records:
46+
raise ValueError(f"DCX returned no location for device {device_number}")
47+
return records[0]

0 commit comments

Comments
 (0)