netbox-inventory-toolkit 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. netbox_inventory_toolkit/__init__.py +8 -0
  2. netbox_inventory_toolkit/__main__.py +6 -0
  3. netbox_inventory_toolkit/cli.py +49 -0
  4. netbox_inventory_toolkit/collect/__init__.py +1 -0
  5. netbox_inventory_toolkit/collect/__main__.py +6 -0
  6. netbox_inventory_toolkit/collect/build.py +488 -0
  7. netbox_inventory_toolkit/collect/cli.py +236 -0
  8. netbox_inventory_toolkit/collect/config.py +69 -0
  9. netbox_inventory_toolkit/collect/context.py +80 -0
  10. netbox_inventory_toolkit/collect/hardware.py +312 -0
  11. netbox_inventory_toolkit/collect/identity.py +143 -0
  12. netbox_inventory_toolkit/collect/log.py +58 -0
  13. netbox_inventory_toolkit/collect/manual.py +117 -0
  14. netbox_inventory_toolkit/collect/pipeline.py +205 -0
  15. netbox_inventory_toolkit/collect/process.py +60 -0
  16. netbox_inventory_toolkit/collect/render.py +43 -0
  17. netbox_inventory_toolkit/collect/snmp_merge.py +813 -0
  18. netbox_inventory_toolkit/collect/sources/__init__.py +1 -0
  19. netbox_inventory_toolkit/collect/sources/base.py +57 -0
  20. netbox_inventory_toolkit/collect/sources/ipmi.py +320 -0
  21. netbox_inventory_toolkit/collect/sources/redfish.py +318 -0
  22. netbox_inventory_toolkit/collect/sources/snmp.py +394 -0
  23. netbox_inventory_toolkit/collect/sources/snmp_oids.py +141 -0
  24. netbox_inventory_toolkit/collect/sources/talos.py +321 -0
  25. netbox_inventory_toolkit/collect/sources/unifi.py +475 -0
  26. netbox_inventory_toolkit/inventory/__init__.py +1 -0
  27. netbox_inventory_toolkit/inventory/choices.py +614 -0
  28. netbox_inventory_toolkit/inventory/fields.py +710 -0
  29. netbox_inventory_toolkit/inventory/index.py +2011 -0
  30. netbox_inventory_toolkit/inventory/keys.py +102 -0
  31. netbox_inventory_toolkit/inventory/problems.py +76 -0
  32. netbox_inventory_toolkit/inventory/racks.py +350 -0
  33. netbox_inventory_toolkit/inventory/schema.py +38 -0
  34. netbox_inventory_toolkit/inventory/util.py +106 -0
  35. netbox_inventory_toolkit/lsp.py +375 -0
  36. netbox_inventory_toolkit/sync/__init__.py +1 -0
  37. netbox_inventory_toolkit/sync/__main__.py +6 -0
  38. netbox_inventory_toolkit/sync/api.py +57 -0
  39. netbox_inventory_toolkit/sync/catalog.py +366 -0
  40. netbox_inventory_toolkit/sync/cli.py +185 -0
  41. netbox_inventory_toolkit/sync/context.py +222 -0
  42. netbox_inventory_toolkit/sync/devices.py +405 -0
  43. netbox_inventory_toolkit/sync/dtl.py +311 -0
  44. netbox_inventory_toolkit/sync/images.py +206 -0
  45. netbox_inventory_toolkit/sync/inventory.py +298 -0
  46. netbox_inventory_toolkit/sync/meta.py +364 -0
  47. netbox_inventory_toolkit/sync/network.py +161 -0
  48. netbox_inventory_toolkit/sync/prune.py +230 -0
  49. netbox_inventory_toolkit/sync/reconciler.py +46 -0
  50. netbox_inventory_toolkit/sync/templates.py +89 -0
  51. netbox_inventory_toolkit/sync/validate.py +902 -0
  52. netbox_inventory_toolkit-0.1.6.dist-info/METADATA +186 -0
  53. netbox_inventory_toolkit-0.1.6.dist-info/RECORD +57 -0
  54. netbox_inventory_toolkit-0.1.6.dist-info/WHEEL +5 -0
  55. netbox_inventory_toolkit-0.1.6.dist-info/entry_points.txt +2 -0
  56. netbox_inventory_toolkit-0.1.6.dist-info/licenses/LICENSE +21 -0
  57. netbox_inventory_toolkit-0.1.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ """NetBox inventory toolkit."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("netbox-inventory-toolkit")
7
+ except PackageNotFoundError: # running from a source tree without installed metadata
8
+ __version__ = "0.1.6"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,49 @@
1
+ """Command line entry point for the NetBox inventory toolkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from . import __version__
8
+
9
+ USAGE = """\
10
+ usage: netbox-inventory-toolkit <command> [options]
11
+
12
+ commands:
13
+ collect collect live source data into the generated inventory layer
14
+ sync reconcile the inventory into NetBox
15
+ check validate the inventory without contacting NetBox
16
+ lsp run the inventory language server (stdio)
17
+ version print the package version
18
+ """
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ args = list(sys.argv[1:] if argv is None else argv)
23
+ if not args or args[0] in {"-h", "--help", "help"}:
24
+ print(USAGE, end="")
25
+ return 0
26
+ if args[0] in {"--version", "version"}:
27
+ print(f"netbox-inventory-toolkit {__version__}")
28
+ return 0
29
+
30
+ command, rest = args[0], args[1:]
31
+ if command == "collect":
32
+ from .collect.cli import main as collect_main
33
+
34
+ return collect_main(rest)
35
+ if command in {"sync", "check"}:
36
+ from .sync.cli import main as sync_main
37
+
38
+ return sync_main([*rest, "--check"] if command == "check" else rest)
39
+ if command == "lsp":
40
+ from .lsp import main as lsp_main
41
+
42
+ return lsp_main(rest)
43
+
44
+ print(f"error: unknown command {command!r}\n\n{USAGE}", file=sys.stderr, end="")
45
+ return 2
46
+
47
+
48
+ if __name__ == "__main__":
49
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Inventory collection pipeline."""
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,488 @@
1
+ """Build canonical generated device entries from source observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from netbox_inventory_toolkit.inventory.util import normalize_mac
9
+
10
+ from .config import compute_roles, default_tag, require_site, talos_platform, talos_roles
11
+ from .hardware import (
12
+ add_bmc_components,
13
+ add_module_component,
14
+ clean_model,
15
+ disk_bay_type,
16
+ disk_manufacturer,
17
+ infer_memory_manufacturer,
18
+ memory_class,
19
+ normalize_manufacturer,
20
+ register_module_type,
21
+ )
22
+ from .log import get_logger
23
+ from .sources.talos import (
24
+ cpu_cores,
25
+ memory_gib,
26
+ node_bond_members,
27
+ node_disks,
28
+ node_link,
29
+ node_link_macs,
30
+ talos_table,
31
+ )
32
+
33
+ log = get_logger(__name__)
34
+
35
+
36
+ def _manual_interface_type(device: dict[str, Any] | None, name: str) -> str:
37
+ for interface in (device or {}).get("interfaces") or []:
38
+ if str(interface.get("name") or "") == name and interface.get("type"):
39
+ return str(interface["type"])
40
+ return ""
41
+
42
+
43
+ def build_servers(
44
+ clients: list[dict],
45
+ manual: dict[str, Any],
46
+ switch_name: str = "",
47
+ *,
48
+ module_types: dict[str, dict[str, Any]] | None = None,
49
+ bmc_by_name: dict[str, dict[str, Any]] | None = None,
50
+ ) -> list[dict]:
51
+ clients_by_ip = {
52
+ str(client.get("ip")): client
53
+ for client in clients
54
+ if client.get("is_wired") and client.get("ip")
55
+ }
56
+ clients_by_mac = {
57
+ normalize_mac(client["mac"]): client
58
+ for client in clients
59
+ if client.get("is_wired") and client.get("mac")
60
+ }
61
+ site_slug = require_site(manual)
62
+ tag = default_tag(manual)
63
+ servers: list[dict] = []
64
+ catalog = module_types if module_types is not None else {}
65
+ bmc_data = bmc_by_name or {}
66
+
67
+ for device in manual["devices"]:
68
+ if device.get("talos_node") or str(device.get("role") or "") not in compute_roles(manual):
69
+ continue
70
+ ip = str(device.get("unifi_ip") or "")
71
+ claimed_mac = str(device.get("unifi_mac") or "")
72
+ client = clients_by_ip.get(ip) if ip else None
73
+ if client is None and claimed_mac:
74
+ client = clients_by_mac.get(normalize_mac(claimed_mac))
75
+ if not ip and not client:
76
+ continue
77
+ if not ip and client:
78
+ ip = str(client.get("ip") or "")
79
+ if not ip:
80
+ continue
81
+ name = str(device.get("name") or "")
82
+ uplinks = device.get("uplinks") or []
83
+ port = (client or {}).get("sw_port")
84
+ mac = str((client or {}).get("mac") or "")
85
+ unifi_name = str((client or {}).get("name") or (client or {}).get("hostname") or "")
86
+ status = device.get("status") or ("active" if client else "offline")
87
+ device_type = str(device.get("device_type") or "")
88
+
89
+ # The management address belongs on the physical NIC that carries the
90
+ # traffic to the switch, not on a virtual "mgmt" interface.
91
+ if uplinks:
92
+ primary_iface = str(uplinks[0].get("interface") or "Port 1")
93
+ else:
94
+ primary_iface = "Port 1"
95
+
96
+ interfaces: list[dict] = []
97
+ interface_sources = ["manual", "unifi"] if client else ["manual"]
98
+ if uplinks:
99
+ for uplink in uplinks:
100
+ iface_name = str(uplink.get("interface") or "Port 1")
101
+ entry: dict[str, Any] = {
102
+ "name": iface_name,
103
+ "type": "1000base-t",
104
+ "enabled": True,
105
+ "description": f"Uplink to {uplink.get('switch')} Port {uplink.get('port')}",
106
+ "sources": interface_sources,
107
+ }
108
+ if iface_name == primary_iface:
109
+ if mac:
110
+ entry["mac_address"] = mac
111
+ entry["ip_addresses"] = [
112
+ {"address": f"{ip}/24", "dns_name": name, "primary": True}
113
+ ]
114
+ interfaces.append(entry)
115
+ else:
116
+ interfaces.append(
117
+ {
118
+ "name": "Port 1",
119
+ "type": "1000base-t",
120
+ "enabled": True,
121
+ "description": "Server network uplink",
122
+ "mac_address": mac or None,
123
+ "ip_addresses": [{"address": f"{ip}/24", "dns_name": name, "primary": True}],
124
+ "sources": interface_sources,
125
+ }
126
+ )
127
+
128
+ description = f"{name} at {ip}"
129
+ if unifi_name:
130
+ description += f" (UniFi client {unifi_name})"
131
+ if port:
132
+ link = f"{switch_name} Port {port}" if switch_name else f"Port {port}"
133
+ description += f", {link}"
134
+ if mac:
135
+ description += f", MAC {mac}"
136
+ if client is None:
137
+ description += "; no active UniFi client"
138
+
139
+ module_bays: list[dict] = []
140
+ modules: list[dict] = []
141
+ bmc = bmc_data.get(name)
142
+ sources = set(interface_sources)
143
+ if bmc:
144
+ sources.add(str(bmc.get("source") or "redfish"))
145
+ add_bmc_components(module_bays, modules, catalog, bmc, device_type)
146
+
147
+ server: dict[str, Any] = {
148
+ "name": name,
149
+ "site": str(device.get("site") or site_slug),
150
+ "role": str(device.get("role") or "server"),
151
+ "device_type": device_type,
152
+ "status": status,
153
+ "serial": str(device.get("serial") or ""),
154
+ "description": description,
155
+ "tags": sorted({tag, *[str(item) for item in device.get("tags") or []]}),
156
+ "sources": sorted(sources),
157
+ "interfaces": interfaces,
158
+ }
159
+ if module_bays:
160
+ server["module_bays"] = module_bays
161
+ if modules:
162
+ server["modules"] = modules
163
+ log.debug(
164
+ "Built server %s: interfaces=%d, module_bays=%d, modules=%d",
165
+ name,
166
+ len(interfaces),
167
+ len(module_bays),
168
+ len(modules),
169
+ )
170
+ servers.append(server)
171
+
172
+ # Devices that are not UniFi clients or Talos nodes, but do have BMC
173
+ # observations. They already exist in the manual layer; this sparse entry
174
+ # only attaches the discovered components.
175
+ for device in manual["devices"]:
176
+ if (
177
+ device.get("talos_node")
178
+ or device.get("unifi_ip")
179
+ or str(device.get("role") or "") not in compute_roles(manual)
180
+ ):
181
+ continue
182
+ name = str(device.get("name") or "")
183
+ bmc = bmc_data.get(name)
184
+ if not name or not bmc:
185
+ continue
186
+ module_bays: list[dict] = []
187
+ modules: list[dict] = []
188
+ add_bmc_components(
189
+ module_bays, modules, catalog, bmc, str(device.get("device_type") or "")
190
+ )
191
+ if not modules:
192
+ continue
193
+ log.debug(
194
+ "Built BMC-only server %s: module_bays=%d, modules=%d",
195
+ name,
196
+ len(module_bays),
197
+ len(modules),
198
+ )
199
+ servers.append(
200
+ {
201
+ "name": name,
202
+ "site": str(device.get("site") or site_slug),
203
+ "role": str(device.get("role") or "server"),
204
+ "device_type": str(device.get("device_type") or ""),
205
+ "status": str(device.get("status") or "active"),
206
+ "serial": str(device.get("serial") or ""),
207
+ "description": str(device.get("description") or f"{name} via BMC"),
208
+ "tags": sorted(
209
+ {tag, *[str(item) for item in device.get("tags") or []]}
210
+ ),
211
+ "sources": sorted({"manual", str(bmc.get("source") or "redfish")}),
212
+ "module_bays": module_bays,
213
+ "modules": modules,
214
+ }
215
+ )
216
+ return servers
217
+
218
+ def build_nodes(
219
+ nodes: list[dict],
220
+ talos_dir: Path,
221
+ manual: dict[str, Any],
222
+ *,
223
+ talosctl: str | None = None,
224
+ talosconfig: str | None = None,
225
+ module_types: dict[str, dict[str, Any]] | None = None,
226
+ bmc_by_name: dict[str, dict[str, Any]] | None = None,
227
+ manual_by_node: dict[str, dict[str, Any]] | None = None,
228
+ ) -> list[dict]:
229
+ result: list[dict] = []
230
+ site_slug = require_site(manual)
231
+ tag = default_tag(manual)
232
+ manual_devices = manual["devices"]
233
+ manual_nodes = manual_by_node or {}
234
+ catalog = module_types if module_types is not None else {}
235
+ bmc_data = bmc_by_name or {}
236
+
237
+ for node in nodes:
238
+ metadata = node.get("metadata") or {}
239
+ status = node.get("status") or {}
240
+ node_info = status.get("nodeInfo") or {}
241
+ capacity = status.get("capacity") or {}
242
+ labels = metadata.get("labels") or {}
243
+ name = metadata.get("name")
244
+ if not name:
245
+ continue
246
+ manual_device = manual_nodes.get(name)
247
+ if manual_device is None:
248
+ manual_device = next(
249
+ (
250
+ device
251
+ for device in manual_devices
252
+ if device.get("talos_node") == name or device.get("name") == name
253
+ ),
254
+ None,
255
+ )
256
+ if manual_device is None:
257
+ log.warning("Talos node %s has no manual device entry", name)
258
+ device_name = str((manual_device or {}).get("name") or name)
259
+
260
+ internal_ip = None
261
+ for address in status.get("addresses") or []:
262
+ if address.get("type") == "InternalIP":
263
+ internal_ip = address.get("address")
264
+ break
265
+ internal_endpoint = str(internal_ip) if internal_ip else None
266
+ link = node_link(name, talos_dir)
267
+ if link:
268
+ iface_name, iface_address = link
269
+ else:
270
+ iface_name = "eth0"
271
+ iface_address = f"{internal_ip}/24" if internal_ip else None
272
+ if not iface_address:
273
+ continue
274
+ ready = any(
275
+ condition.get("type") == "Ready" and condition.get("status") == "True"
276
+ for condition in status.get("conditions") or []
277
+ )
278
+ control_plane_role, worker_role = talos_roles(manual)
279
+ role = control_plane_role if "node-role.kubernetes.io/control-plane" in labels else worker_role
280
+ if manual_device and manual_device.get("role"):
281
+ role = str(manual_device["role"])
282
+ device_type = str((manual_device or {}).get("device_type") or "")
283
+ description = (
284
+ f"{node_info.get('osImage', 'Talos')}, Kubernetes "
285
+ f"{node_info.get('kubeletVersion', 'unknown')}, kernel "
286
+ f"{node_info.get('kernelVersion', 'unknown')}, "
287
+ f"{cpu_cores(capacity.get('cpu')):g} CPU, "
288
+ f"{memory_gib(capacity.get('memory')):.1f} GiB RAM"
289
+ )
290
+ interfaces: list[dict] = []
291
+ members = node_bond_members(name, talos_dir)
292
+ macs: dict[str, str] = {}
293
+ if talosctl:
294
+ macs = node_link_macs(
295
+ talosctl, talosconfig, name, endpoint=internal_endpoint
296
+ )
297
+ for member in members:
298
+ entry: dict[str, Any] = {
299
+ "name": member,
300
+ "type": "1000base-t",
301
+ "enabled": True,
302
+ "description": "bond0 member (LACP)",
303
+ "sources": ["talos"],
304
+ }
305
+ if member in macs:
306
+ entry["mac_address"] = macs[member]
307
+ interfaces.append(entry)
308
+ if members:
309
+ iface_type = "lag"
310
+ else:
311
+ iface_type = _manual_interface_type(manual_device, iface_name) or "1000base-t"
312
+ primary_entry: dict[str, Any] = {
313
+ "name": iface_name,
314
+ "type": iface_type,
315
+ "enabled": True,
316
+ "description": "In-band management and cluster traffic",
317
+ "sources": ["talos"],
318
+ "ip_addresses": [
319
+ {
320
+ "address": iface_address,
321
+ "dns_name": name,
322
+ "primary": True,
323
+ }
324
+ ],
325
+ }
326
+ if iface_name in macs:
327
+ primary_entry["mac_address"] = macs[iface_name]
328
+ interfaces.append(primary_entry)
329
+
330
+ module_bays: list[dict] = []
331
+ modules: list[dict] = []
332
+ bay_disk_type = disk_bay_type(device_type)
333
+ dimm_class = memory_class(device_type)
334
+ bmc = bmc_data.get(device_name) or bmc_data.get(name)
335
+
336
+ if bmc:
337
+ add_bmc_components(module_bays, modules, catalog, bmc, device_type)
338
+
339
+ if not bmc or not bmc.get("cpu"):
340
+ if talosctl:
341
+ for index, processor in enumerate(
342
+ talos_table(
343
+ talosctl, talosconfig, name, "processors",
344
+ endpoint=internal_endpoint,
345
+ ), 1
346
+ ):
347
+ spec = processor.get("spec") or {}
348
+ if not spec.get("productName") and not spec.get("coreCount"):
349
+ continue
350
+ model = clean_model(str(spec.get("productName") or f"CPU {index}"))
351
+ manufacturer = normalize_manufacturer(
352
+ str(spec.get("manufacturer") or "Intel")
353
+ )
354
+ module_type = register_module_type(
355
+ catalog,
356
+ manufacturer=manufacturer,
357
+ model=model,
358
+ profile="CPU",
359
+ attributes={
360
+ "cores": int(spec.get("coreCount") or 0),
361
+ "speed": float(
362
+ spec.get("bootSpeedMhz") or spec.get("maxSpeedMhz") or 0
363
+ )
364
+ / 1000,
365
+ "architecture": "x86_64",
366
+ },
367
+ bay_types=["cpu-socket"],
368
+ part_number=model,
369
+ description="Processor",
370
+ sources=["talos"],
371
+ )
372
+ add_module_component(
373
+ module_bays,
374
+ modules,
375
+ str(spec.get("socket") or f"CPU {index}"),
376
+ "cpu-socket",
377
+ "Processor socket.",
378
+ module_type,
379
+ description=model,
380
+ sources=["talos"],
381
+ )
382
+
383
+ if not bmc or not bmc.get("memory"):
384
+ if talosctl:
385
+ for index, memory in enumerate(
386
+ talos_table(
387
+ talosctl, talosconfig, name, "memorymodules",
388
+ endpoint=internal_endpoint,
389
+ ), 1
390
+ ):
391
+ spec = memory.get("spec") or {}
392
+ if not spec.get("sizeMiB"):
393
+ continue
394
+ locator = str(spec.get("deviceLocator") or f"DIMM {index}")
395
+ bay_name = locator if locator.upper().startswith("DIMM") else f"DIMM {locator}"
396
+ model = str(spec.get("productName") or locator)
397
+ size_gb = int(spec.get("sizeMiB") or 0) // 1024
398
+ data_rate = int(spec.get("speed") or 0)
399
+ mem_manufacturer = infer_memory_manufacturer(
400
+ model, normalize_manufacturer(str(spec.get("manufacturer") or ""))
401
+ )
402
+ module_type = register_module_type(
403
+ catalog,
404
+ manufacturer=mem_manufacturer,
405
+ model=model,
406
+ profile="Memory",
407
+ attributes={
408
+ "size": size_gb,
409
+ "class": dimm_class,
410
+ "ecc": True,
411
+ "data_rate": data_rate,
412
+ },
413
+ bay_types=[f"dimm-{dimm_class.lower()}"],
414
+ part_number=model,
415
+ description="Memory module",
416
+ sources=["talos"],
417
+ )
418
+ add_module_component(
419
+ module_bays,
420
+ modules,
421
+ bay_name,
422
+ f"dimm-{dimm_class.lower()}",
423
+ "Memory module slot.",
424
+ module_type,
425
+ serial=str(spec.get("serialNumber") or ""),
426
+ description=f"{size_gb} GB {dimm_class} {data_rate} MT/s",
427
+ sources=["talos"],
428
+ )
429
+
430
+ if not bmc or not bmc.get("disks"):
431
+ if talosctl:
432
+ disks, _system_disk = node_disks(talosctl, talosconfig, name, endpoint=internal_endpoint)
433
+ for index, disk in enumerate(disks, 1):
434
+ size_gb = int(disk["size"] / 1_000_000_000)
435
+ kind = "HD" if disk["rotational"] else "SSD"
436
+ model = disk["model"] or f"Disk {index}"
437
+ module_type = register_module_type(
438
+ catalog,
439
+ manufacturer=disk_manufacturer(model),
440
+ model=model,
441
+ profile="Hard disk",
442
+ attributes={"size": size_gb, "type": kind},
443
+ bay_types=[bay_disk_type],
444
+ part_number=model,
445
+ description="Disk drive",
446
+ sources=["talos"],
447
+ )
448
+ add_module_component(
449
+ module_bays,
450
+ modules,
451
+ f"Drive {index}",
452
+ bay_disk_type,
453
+ "Drive bay.",
454
+ module_type,
455
+ serial=disk["serial"],
456
+ description=f"{size_gb} GB {kind}",
457
+ sources=["talos"],
458
+ )
459
+
460
+ device_entry: dict[str, Any] = {
461
+ "name": device_name,
462
+ "site": str((manual_device or {}).get("site") or site_slug),
463
+ "role": role,
464
+ "device_type": device_type,
465
+
466
+ "status": "active" if ready else "offline",
467
+ "serial": node_info.get("systemUUID") or "",
468
+ "description": description,
469
+ "tags": sorted({tag, *[str(item) for item in (manual_device or {}).get("tags") or []]}),
470
+ "sources": sorted({"talos", *([str(bmc.get("source") or "redfish")] if bmc else [])}),
471
+ "interfaces": interfaces,
472
+ }
473
+ platform = talos_platform(manual)
474
+ if platform:
475
+ device_entry["platform"] = platform
476
+ if module_bays:
477
+ device_entry["module_bays"] = module_bays
478
+ if modules:
479
+ device_entry["modules"] = modules
480
+ log.debug(
481
+ "Built Talos node %s: interfaces=%d, module_bays=%d, modules=%d",
482
+ device_name,
483
+ len(interfaces),
484
+ len(module_bays),
485
+ len(modules),
486
+ )
487
+ result.append(device_entry)
488
+ return result