racklint 0.1.0__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.
racklint/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ # racklint/src/racklint/__init__.py
2
+ """racklint: check servers against NetBox before they go into service.
3
+
4
+ The names in __all__ are the public API. Anything else may change before 1.0.
5
+ """
6
+
7
+ from racklint.baseline import FirmwareBaseline, FirmwareRule
8
+ from racklint.checks import Check, FirmwareCheck, GpuCheck, MemoryCheck, NicCheck, SerialCheck, default_checks
9
+ from racklint.engine import ActualReader, check_nodes, iter_results
10
+ from racklint.errors import BaselineError, InventoryError, NetBoxError, RacklintError
11
+ from racklint.models import (
12
+ ActualNode,
13
+ Dimm,
14
+ ExpectedNode,
15
+ Finding,
16
+ FirmwareItem,
17
+ Gpu,
18
+ GpuSpec,
19
+ HardwareSpec,
20
+ MemorySpec,
21
+ NetworkPort,
22
+ NodeResult,
23
+ PlannedInterface,
24
+ Report,
25
+ Status,
26
+ )
27
+ from racklint.netbox import NetBoxSource, netbox_client
28
+ from racklint.redfish import BmcAccess, RedfishError, RedfishReader
29
+
30
+ __all__ = [
31
+ "ActualNode",
32
+ "ActualReader",
33
+ "BaselineError",
34
+ "BmcAccess",
35
+ "Check",
36
+ "Dimm",
37
+ "ExpectedNode",
38
+ "Finding",
39
+ "FirmwareBaseline",
40
+ "FirmwareCheck",
41
+ "FirmwareItem",
42
+ "FirmwareRule",
43
+ "Gpu",
44
+ "GpuCheck",
45
+ "GpuSpec",
46
+ "HardwareSpec",
47
+ "InventoryError",
48
+ "MemoryCheck",
49
+ "MemorySpec",
50
+ "NetBoxError",
51
+ "NetBoxSource",
52
+ "NetworkPort",
53
+ "NicCheck",
54
+ "NodeResult",
55
+ "PlannedInterface",
56
+ "RacklintError",
57
+ "RedfishError",
58
+ "RedfishReader",
59
+ "Report",
60
+ "SerialCheck",
61
+ "Status",
62
+ "check_nodes",
63
+ "default_checks",
64
+ "iter_results",
65
+ "netbox_client",
66
+ ]
racklint/baseline.py ADDED
@@ -0,0 +1,29 @@
1
+ # racklint/src/racklint/baseline.py
2
+ """The firmware baseline: the approved versions for each device type, kept in a YAML file under review."""
3
+
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+ import yaml
8
+ from pydantic import Field, ValidationError
9
+
10
+ from racklint.errors import BaselineError
11
+ from racklint.models import FrozenModel
12
+
13
+
14
+ class FirmwareRule(FrozenModel):
15
+ """Firmware items whose Id matches `match`, an fnmatch glob, must run one of the `approved` versions."""
16
+
17
+ match: str
18
+ approved: tuple[str, ...] = Field(min_length=1)
19
+
20
+
21
+ class FirmwareBaseline(FrozenModel):
22
+ device_types: dict[str, tuple[FirmwareRule, ...]]
23
+
24
+ @classmethod
25
+ def load(cls, path: Path) -> Self:
26
+ try:
27
+ return cls.model_validate(yaml.safe_load(path.read_text()))
28
+ except (OSError, yaml.YAMLError, ValidationError) as error:
29
+ raise BaselineError(f"cannot load firmware baseline {path}: {error}") from error
racklint/checks.py ADDED
@@ -0,0 +1,116 @@
1
+ # racklint/src/racklint/checks.py
2
+ """The checks: each compares one aspect of a node's plan with what its BMC reports.
3
+
4
+ A check is pure. It returns one message per problem it finds, and no messages when the node is as planned.
5
+ """
6
+
7
+ from fnmatch import fnmatchcase
8
+ from typing import Protocol
9
+
10
+ from racklint.baseline import FirmwareBaseline
11
+ from racklint.models import ActualNode, ExpectedNode
12
+
13
+ SPEC = "config context 'racklint'"
14
+
15
+
16
+ def not_planned(item: str) -> str:
17
+ return f"not planned in NetBox: {item}"
18
+
19
+
20
+ class Check(Protocol):
21
+ name: str
22
+
23
+ def run(self, expected: ExpectedNode, actual: ActualNode) -> list[str]: ...
24
+
25
+
26
+ class SerialCheck:
27
+ name = "serial"
28
+
29
+ def run(self, expected: ExpectedNode, actual: ActualNode) -> list[str]:
30
+ if expected.serial is None:
31
+ return [not_planned("serial")]
32
+ if expected.serial in actual.serials:
33
+ return []
34
+ reported = ", ".join(actual.serials) or "none"
35
+ return [f"expected {expected.serial}, BMC reports {reported}"]
36
+
37
+
38
+ class GpuCheck:
39
+ name = "gpu"
40
+
41
+ def run(self, expected: ExpectedNode, actual: ActualNode) -> list[str]:
42
+ if expected.spec is None:
43
+ return [not_planned(SPEC)]
44
+ planned = expected.spec.gpu
45
+ problems: list[str] = []
46
+ if len(actual.gpus) != planned.count:
47
+ problems.append(f"{len(actual.gpus)} found, {planned.count} planned")
48
+ problems += [
49
+ f"{gpu.id} is {gpu.model!r}, planned {planned.model!r}"
50
+ for gpu in actual.gpus
51
+ if gpu.model != planned.model
52
+ ]
53
+ return problems
54
+
55
+
56
+ class MemoryCheck:
57
+ name = "memory"
58
+
59
+ def run(self, expected: ExpectedNode, actual: ActualNode) -> list[str]:
60
+ if expected.spec is None:
61
+ return [not_planned(SPEC)]
62
+ planned = expected.spec.memory
63
+ count = len(actual.dimms)
64
+ total_mib = sum(dimm.capacity_mib for dimm in actual.dimms)
65
+ if count == planned.dimm_count and total_mib == planned.total_gib * 1024:
66
+ return []
67
+ found = f"{count} DIMMs ({total_mib / 1024:g} GiB)"
68
+ return [f"{found}, {planned.dimm_count} planned ({planned.total_gib} GiB)"]
69
+
70
+
71
+ class NicCheck:
72
+ """Planned MACs must be reported; interfaces planned without a MAC are only counted."""
73
+
74
+ name = "nic"
75
+
76
+ def run(self, expected: ExpectedNode, actual: ActualNode) -> list[str]:
77
+ if not expected.interfaces:
78
+ return [not_planned("interfaces")]
79
+ reported = {port.mac for port in actual.ports if port.mac}
80
+ problems = [
81
+ f"{interface.name} ({interface.mac}) not found"
82
+ for interface in expected.interfaces
83
+ if interface.mac and interface.mac not in reported
84
+ ]
85
+ if len(actual.ports) < len(expected.interfaces):
86
+ problems.append(f"{len(actual.ports)} ports found, {len(expected.interfaces)} planned")
87
+ return problems
88
+
89
+
90
+ class FirmwareCheck:
91
+ """Every baseline rule must match a firmware item, and every match must run an approved version."""
92
+
93
+ name = "firmware"
94
+
95
+ def __init__(self, baseline: FirmwareBaseline) -> None:
96
+ self._baseline = baseline
97
+
98
+ def run(self, expected: ExpectedNode, actual: ActualNode) -> list[str]:
99
+ rules = self._baseline.device_types.get(expected.device_type)
100
+ if rules is None:
101
+ return [f"no firmware baseline for device type {expected.device_type}"]
102
+ problems: list[str] = []
103
+ for rule in rules:
104
+ matched = [item for item in actual.firmware if fnmatchcase(item.id, rule.match)]
105
+ if not matched:
106
+ problems.append(f"no firmware item matches {rule.match!r}")
107
+ problems += [
108
+ f"{item.id} {item.version} not in approved [{', '.join(rule.approved)}]"
109
+ for item in matched
110
+ if item.version not in rule.approved
111
+ ]
112
+ return problems
113
+
114
+
115
+ def default_checks(baseline: FirmwareBaseline) -> list[Check]:
116
+ return [SerialCheck(), GpuCheck(), MemoryCheck(), NicCheck(), FirmwareCheck(baseline)]
racklint/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ # racklint/src/racklint/cli.py
2
+ """The racklint command line. It wires NetBox, the BMC reader, the checks and a renderer together."""
3
+
4
+ import asyncio
5
+ import ssl
6
+ from collections.abc import Sequence
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import Annotated, NoReturn
10
+
11
+ import typer
12
+ from pydantic import ValidationError
13
+ from rich.console import Console
14
+
15
+ from racklint.baseline import FirmwareBaseline
16
+ from racklint.checks import default_checks
17
+ from racklint.engine import check_nodes
18
+ from racklint.errors import BaselineError, NetBoxError
19
+ from racklint.models import Report
20
+ from racklint.netbox import NetBoxSource, netbox_client
21
+ from racklint.redfish import BmcAccess, RedfishReader
22
+ from racklint.render import JsonRenderer, Renderer, TableRenderer
23
+ from racklint.settings import Settings
24
+
25
+ EXIT_NOT_PASSED = 1
26
+ EXIT_USAGE = 2
27
+
28
+
29
+ class OutputFormat(StrEnum):
30
+ TABLE = "table"
31
+ JSON = "json"
32
+
33
+
34
+ RENDERERS: dict[OutputFormat, Renderer] = {
35
+ OutputFormat.TABLE: TableRenderer(),
36
+ OutputFormat.JSON: JsonRenderer(),
37
+ }
38
+
39
+ app = typer.Typer(add_completion=False, no_args_is_help=True)
40
+
41
+
42
+ @app.callback()
43
+ def main() -> None:
44
+ """Check servers against NetBox and a firmware baseline before they go into service."""
45
+
46
+
47
+ @app.command()
48
+ def check(
49
+ baseline: Annotated[Path, typer.Option(help="Firmware baseline YAML file.")],
50
+ rack: Annotated[str | None, typer.Option(help="Check every device in this NetBox rack.")] = None,
51
+ site: Annotated[
52
+ str | None, typer.Option(help="Site slug, if the rack name exists in several sites.")
53
+ ] = None,
54
+ device: Annotated[
55
+ list[str] | None, typer.Option(help="Check this NetBox device. Repeat for more.")
56
+ ] = None,
57
+ output_format: Annotated[
58
+ OutputFormat, typer.Option("--format", help="Output format.")
59
+ ] = OutputFormat.TABLE,
60
+ concurrency: Annotated[int | None, typer.Option(min=1, help="BMCs to read at once.")] = None,
61
+ ca_bundle: Annotated[
62
+ Path | None, typer.Option(exists=True, dir_okay=False, help="CA bundle for BMC TLS certificates.")
63
+ ] = None,
64
+ insecure: Annotated[bool, typer.Option("--insecure", help="Do not verify BMC TLS certificates.")] = False,
65
+ ) -> None:
66
+ """Check each node. Exit 1 if any node fails or cannot be read."""
67
+ if bool(rack) == bool(device):
68
+ _exit_usage("pass either --rack or --device")
69
+ try:
70
+ settings = load_settings(concurrency, ca_bundle, insecure)
71
+ except ValidationError as error:
72
+ _exit_usage(describe_settings_error(error))
73
+ try:
74
+ firmware_baseline = FirmwareBaseline.load(baseline)
75
+ report = asyncio.run(_check(settings, firmware_baseline, rack, site, device or []))
76
+ except (BaselineError, NetBoxError) as error:
77
+ _exit_usage(str(error))
78
+ RENDERERS[output_format].render(report, Console())
79
+ raise typer.Exit(0 if report.passed else EXIT_NOT_PASSED)
80
+
81
+
82
+ def load_settings(concurrency: int | None, ca_bundle: Path | None, insecure: bool) -> Settings:
83
+ """Settings from the environment, overridden by the command-line options that were given."""
84
+ overrides: dict[str, object] = {}
85
+ if concurrency is not None:
86
+ overrides["concurrency"] = concurrency
87
+ if ca_bundle is not None:
88
+ overrides["bmc_ca_bundle"] = ca_bundle
89
+ if insecure:
90
+ overrides["bmc_verify_tls"] = False
91
+ return Settings().model_copy(update=overrides)
92
+
93
+
94
+ def describe_settings_error(error: ValidationError) -> str:
95
+ problems = (
96
+ f"RACKLINT_{'_'.join(str(part) for part in problem['loc']).upper()}: {problem['msg']}"
97
+ for problem in error.errors()
98
+ )
99
+ return "invalid settings: " + "; ".join(problems)
100
+
101
+
102
+ def bmc_access(settings: Settings) -> BmcAccess:
103
+ verify: ssl.SSLContext | bool = settings.bmc_verify_tls
104
+ if verify and settings.bmc_ca_bundle is not None:
105
+ verify = ssl.create_default_context(cafile=settings.bmc_ca_bundle)
106
+ return BmcAccess(
107
+ username=settings.bmc_username,
108
+ password=settings.bmc_password.get_secret_value(),
109
+ scheme=settings.bmc_scheme,
110
+ verify=verify,
111
+ max_requests=settings.bmc_concurrency,
112
+ )
113
+
114
+
115
+ async def _check(
116
+ settings: Settings, baseline: FirmwareBaseline, rack: str | None, site: str | None, devices: Sequence[str]
117
+ ) -> Report:
118
+ async with netbox_client(str(settings.netbox_url), settings.netbox_token.get_secret_value()) as client:
119
+ source = NetBoxSource(client)
120
+ nodes = await source.rack(rack, site) if rack else await source.devices(devices)
121
+ reader = RedfishReader(bmc_access(settings))
122
+ return await check_nodes(nodes, reader, default_checks(baseline), settings.concurrency)
123
+
124
+
125
+ def _exit_usage(message: str) -> NoReturn:
126
+ Console(stderr=True).print(f"racklint: {message}", markup=False, highlight=False)
127
+ raise typer.Exit(EXIT_USAGE)
racklint/engine.py ADDED
@@ -0,0 +1,60 @@
1
+ # racklint/src/racklint/engine.py
2
+ """Check every node: read its BMC, run the checks on what it reports, and collect the results."""
3
+
4
+ import asyncio
5
+ from collections.abc import AsyncIterator, Coroutine, Sequence
6
+ from typing import Any, Protocol
7
+
8
+ from racklint.checks import Check, not_planned
9
+ from racklint.errors import InventoryError
10
+ from racklint.models import ActualNode, ExpectedNode, Finding, NodeResult, Report, Status
11
+
12
+
13
+ class ActualReader(Protocol):
14
+ async def read(self, bmc_address: str) -> ActualNode:
15
+ """Return what the BMC reports, or raise InventoryError."""
16
+ ...
17
+
18
+
19
+ async def check_nodes(
20
+ nodes: Sequence[ExpectedNode], reader: ActualReader, checks: Sequence[Check], concurrency: int
21
+ ) -> Report:
22
+ """Check the nodes with at most `concurrency` BMCs read at once. Results keep the order of `nodes`."""
23
+ results = await asyncio.gather(*_bounded(nodes, reader, checks, concurrency))
24
+ return Report(results=tuple(results))
25
+
26
+
27
+ async def iter_results(
28
+ nodes: Sequence[ExpectedNode], reader: ActualReader, checks: Sequence[Check], concurrency: int
29
+ ) -> AsyncIterator[NodeResult]:
30
+ """Like check_nodes, but yield each node's result as soon as it is ready, for live progress."""
31
+ for finished in asyncio.as_completed(_bounded(nodes, reader, checks, concurrency)):
32
+ yield await finished
33
+
34
+
35
+ def _bounded(
36
+ nodes: Sequence[ExpectedNode], reader: ActualReader, checks: Sequence[Check], concurrency: int
37
+ ) -> list[Coroutine[Any, Any, NodeResult]]:
38
+ """One check per node, sharing a limit of `concurrency` BMC reads at a time."""
39
+ slots = asyncio.Semaphore(concurrency)
40
+
41
+ async def check_in_slot(node: ExpectedNode) -> NodeResult:
42
+ async with slots:
43
+ return await check_node(node, reader, checks)
44
+
45
+ return [check_in_slot(node) for node in nodes]
46
+
47
+
48
+ async def check_node(node: ExpectedNode, reader: ActualReader, checks: Sequence[Check]) -> NodeResult:
49
+ if node.bmc_address is None:
50
+ missing = Finding(check="bmc", message=not_planned("BMC address"))
51
+ return NodeResult(node=node.name, bmc_address=None, status=Status.FAIL, findings=(missing,))
52
+ try:
53
+ actual = await reader.read(node.bmc_address)
54
+ except InventoryError as error:
55
+ return NodeResult(node=node.name, bmc_address=node.bmc_address, status=Status.ERROR, error=str(error))
56
+ findings = tuple(
57
+ Finding(check=check.name, message=message) for check in checks for message in check.run(node, actual)
58
+ )
59
+ status = Status.FAIL if findings else Status.PASS
60
+ return NodeResult(node=node.name, bmc_address=node.bmc_address, status=status, findings=findings)
racklint/errors.py ADDED
@@ -0,0 +1,18 @@
1
+ # racklint/src/racklint/errors.py
2
+ """The errors racklint raises. A caller can catch RacklintError to handle all of them."""
3
+
4
+
5
+ class RacklintError(Exception):
6
+ """Base class of every error racklint raises on purpose."""
7
+
8
+
9
+ class BaselineError(RacklintError):
10
+ """The firmware baseline is missing or invalid."""
11
+
12
+
13
+ class NetBoxError(RacklintError):
14
+ """NetBox could not be read, or does not hold what was asked for."""
15
+
16
+
17
+ class InventoryError(RacklintError):
18
+ """A node's actual state could not be read from its BMC."""
racklint/models.py ADDED
@@ -0,0 +1,131 @@
1
+ # racklint/src/racklint/models.py
2
+ """The state racklint works on: what NetBox plans, what a BMC reports, and the verdict on each node."""
3
+
4
+ import re
5
+ from enum import StrEnum
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import AfterValidator, BaseModel, ConfigDict
9
+
10
+ _MAC_SEPARATORS = re.compile(r"[:.\-]")
11
+ _MAC_DIGITS = re.compile(r"[0-9a-f]{12}")
12
+
13
+
14
+ def normalise_mac(value: str) -> str:
15
+ """Return a MAC address as lower-case, colon-separated pairs, whichever separators it came with."""
16
+ digits = _MAC_SEPARATORS.sub("", value.lower())
17
+ if not _MAC_DIGITS.fullmatch(digits):
18
+ raise ValueError(f"not a MAC address: {value!r}")
19
+ return ":".join(digits[index : index + 2] for index in range(0, 12, 2))
20
+
21
+
22
+ MacAddress = Annotated[str, AfterValidator(normalise_mac)]
23
+
24
+
25
+ class FrozenModel(BaseModel):
26
+ """Immutable, with surrounding whitespace stripped from every string."""
27
+
28
+ model_config = ConfigDict(frozen=True, str_strip_whitespace=True)
29
+
30
+
31
+ # What NetBox plans
32
+
33
+
34
+ class GpuSpec(FrozenModel):
35
+ count: int
36
+ model: str
37
+
38
+
39
+ class MemorySpec(FrozenModel):
40
+ dimm_count: int
41
+ total_gib: int
42
+
43
+
44
+ class HardwareSpec(FrozenModel):
45
+ """The `racklint` key of a device's config context."""
46
+
47
+ gpu: GpuSpec
48
+ memory: MemorySpec
49
+
50
+
51
+ class PlannedInterface(FrozenModel):
52
+ name: str
53
+ mac: MacAddress | None = None
54
+
55
+
56
+ class ExpectedNode(FrozenModel):
57
+ """A device as NetBox plans it. None means NetBox does not record the value."""
58
+
59
+ name: str
60
+ device_type: str
61
+ serial: str | None = None
62
+ bmc_address: str | None = None
63
+ spec: HardwareSpec | None = None
64
+ interfaces: tuple[PlannedInterface, ...] = ()
65
+
66
+
67
+ # What a BMC reports
68
+
69
+
70
+ class Gpu(FrozenModel):
71
+ id: str
72
+ model: str
73
+
74
+
75
+ class Dimm(FrozenModel):
76
+ id: str
77
+ capacity_mib: int
78
+
79
+
80
+ class NetworkPort(FrozenModel):
81
+ id: str
82
+ mac: MacAddress | None = None
83
+
84
+
85
+ class FirmwareItem(FrozenModel):
86
+ id: str
87
+ version: str
88
+
89
+
90
+ class ActualNode(FrozenModel):
91
+ """A node as its BMC reports it."""
92
+
93
+ serials: tuple[str, ...] = ()
94
+ gpus: tuple[Gpu, ...] = ()
95
+ dimms: tuple[Dimm, ...] = ()
96
+ ports: tuple[NetworkPort, ...] = ()
97
+ firmware: tuple[FirmwareItem, ...] = ()
98
+
99
+
100
+ # The verdict
101
+
102
+
103
+ class Status(StrEnum):
104
+ PASS = "pass"
105
+ FAIL = "fail"
106
+ ERROR = "error"
107
+
108
+
109
+ class Finding(FrozenModel):
110
+ check: str
111
+ message: str
112
+
113
+
114
+ class NodeResult(FrozenModel):
115
+ node: str
116
+ bmc_address: str | None
117
+ status: Status
118
+ findings: tuple[Finding, ...] = ()
119
+ error: str | None = None
120
+
121
+
122
+ class Report(FrozenModel):
123
+ schema_version: Literal[1] = 1
124
+ results: tuple[NodeResult, ...]
125
+
126
+ def count(self, status: Status) -> int:
127
+ return sum(1 for result in self.results if result.status == status)
128
+
129
+ @property
130
+ def passed(self) -> bool:
131
+ return all(result.status == Status.PASS for result in self.results)
racklint/netbox.py ADDED
@@ -0,0 +1,116 @@
1
+ # racklint/src/racklint/netbox.py
2
+ """Read the planned state of devices from the NetBox REST API. racklint only ever sends GET requests."""
3
+
4
+ from collections import defaultdict
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from pydantic import ValidationError
10
+
11
+ from racklint.errors import NetBoxError
12
+ from racklint.models import ExpectedNode, HardwareSpec, PlannedInterface
13
+
14
+ Record = dict[str, Any]
15
+
16
+ CONFIG_CONTEXT_KEY = "racklint"
17
+ PAGE_SIZE = 250
18
+ TIMEOUT = httpx.Timeout(30.0)
19
+
20
+
21
+ def auth_header(token: str) -> str:
22
+ """v2 tokens (NetBox 4.5+, prefixed nbt_) use the Bearer scheme; legacy tokens use Token."""
23
+ scheme = "Bearer" if token.startswith("nbt_") else "Token"
24
+ return f"{scheme} {token}"
25
+
26
+
27
+ def netbox_client(url: str, token: str) -> httpx.AsyncClient:
28
+ return httpx.AsyncClient(
29
+ base_url=url,
30
+ headers={"Authorization": auth_header(token), "Accept": "application/json"},
31
+ timeout=TIMEOUT,
32
+ )
33
+
34
+
35
+ class NetBoxSource:
36
+ """Turns NetBox devices into ExpectedNodes, reading devices and their interfaces in bulk."""
37
+
38
+ def __init__(self, client: httpx.AsyncClient) -> None:
39
+ self._client = client
40
+
41
+ async def rack(self, name: str, site: str | None = None) -> list[ExpectedNode]:
42
+ query = {"name": name} if site is None else {"name": name, "site": site}
43
+ racks = await self._list("dcim/racks", query)
44
+ if not racks:
45
+ raise NetBoxError(f"rack {name!r} not found")
46
+ if len(racks) > 1:
47
+ sites = ", ".join(sorted(rack["site"]["slug"] for rack in racks))
48
+ raise NetBoxError(f"rack {name!r} exists in several sites ({sites}); pass --site")
49
+ devices = await self._list("dcim/devices", {"rack_id": racks[0]["id"]})
50
+ if not devices:
51
+ raise NetBoxError(f"rack {name!r} has no devices")
52
+ return await self._expected(devices)
53
+
54
+ async def devices(self, names: Sequence[str]) -> list[ExpectedNode]:
55
+ devices = await self._list("dcim/devices", {"name": list(names)})
56
+ missing = set(names) - {device["name"] for device in devices}
57
+ if missing:
58
+ raise NetBoxError(f"devices not found: {', '.join(sorted(missing))}")
59
+ return await self._expected(devices)
60
+
61
+ async def _expected(self, devices: list[Record]) -> list[ExpectedNode]:
62
+ interfaces = await self._list(
63
+ "dcim/interfaces",
64
+ {"device_id": [device["id"] for device in devices], "kind": "physical", "mgmt_only": "false"},
65
+ )
66
+ planned: defaultdict[int, list[PlannedInterface]] = defaultdict(list)
67
+ for interface in interfaces:
68
+ planned[interface["device"]["id"]].append(_planned_interface(interface))
69
+ return [_expected_node(device, planned[device["id"]]) for device in devices]
70
+
71
+ async def _list(self, endpoint: str, query: Record) -> list[Record]:
72
+ """Every object a list endpoint returns for `query`, page by page."""
73
+ records: list[Record] = []
74
+ while True:
75
+ page = await self._get(f"/api/{endpoint}/", query | {"limit": PAGE_SIZE, "offset": len(records)})
76
+ records.extend(page["results"])
77
+ if not page["results"] or len(records) >= page["count"]:
78
+ return records
79
+
80
+ async def _get(self, path: str, params: Record) -> Record:
81
+ try:
82
+ response = await self._client.get(path, params=params)
83
+ response.raise_for_status()
84
+ page: Record = response.json()
85
+ except (httpx.HTTPError, ValueError) as error:
86
+ raise NetBoxError(f"NetBox request failed: {error}") from error
87
+ return page
88
+
89
+
90
+ def _expected_node(device: Record, interfaces: list[PlannedInterface]) -> ExpectedNode:
91
+ oob_ip = device.get("oob_ip")
92
+ return ExpectedNode(
93
+ name=device["name"],
94
+ device_type=device["device_type"]["slug"],
95
+ serial=device.get("serial") or None,
96
+ bmc_address=oob_ip["address"].split("/")[0] if oob_ip else None,
97
+ spec=_hardware_spec(device),
98
+ interfaces=tuple(interfaces),
99
+ )
100
+
101
+
102
+ def _hardware_spec(device: Record) -> HardwareSpec | None:
103
+ context = device.get("config_context") or {}
104
+ if CONFIG_CONTEXT_KEY not in context:
105
+ return None
106
+ try:
107
+ return HardwareSpec.model_validate(context[CONFIG_CONTEXT_KEY])
108
+ except ValidationError as error:
109
+ raise NetBoxError(
110
+ f"{device['name']}: invalid {CONFIG_CONTEXT_KEY!r} config context: {error}"
111
+ ) from error
112
+
113
+
114
+ def _planned_interface(interface: Record) -> PlannedInterface:
115
+ primary_mac = interface.get("primary_mac_address")
116
+ return PlannedInterface(name=interface["name"], mac=primary_mac["mac_address"] if primary_mac else None)
racklint/py.typed ADDED
File without changes
racklint/redfish.py ADDED
@@ -0,0 +1,172 @@
1
+ # racklint/src/racklint/redfish.py
2
+ """Read a node's hardware from its BMC over Redfish, without the host OS running.
3
+
4
+ The reader starts at the service root and follows @odata.id links, so it relies on no vendor-specific
5
+ paths. GPUs are the Processors with ProcessorType "GPU" in every ComputerSystem.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import ssl
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from racklint.errors import InventoryError
17
+ from racklint.models import ActualNode, Dimm, FirmwareItem, Gpu, NetworkPort
18
+
19
+ Resource = dict[str, Any]
20
+
21
+ SERVICE_ROOT = "/redfish/v1"
22
+ TIMEOUT = httpx.Timeout(15.0, connect=5.0)
23
+ ATTEMPTS = 3
24
+ BACKOFF_SECONDS = 0.5
25
+
26
+
27
+ class RedfishError(InventoryError):
28
+ """The BMC could not be read."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class BmcAccess:
33
+ """How to reach the BMCs: one set of credentials and TLS settings for all of them."""
34
+
35
+ username: str
36
+ password: str
37
+ scheme: str = "https"
38
+ verify: ssl.SSLContext | bool = True
39
+ max_requests: int = 4
40
+
41
+
42
+ class RedfishReader:
43
+ """The engine's ActualReader for BMCs that speak Redfish."""
44
+
45
+ def __init__(self, access: BmcAccess) -> None:
46
+ self._access = access
47
+
48
+ async def read(self, bmc_address: str) -> ActualNode:
49
+ async with httpx.AsyncClient(
50
+ base_url=f"{self._access.scheme}://{_url_host(bmc_address)}",
51
+ auth=httpx.BasicAuth(self._access.username, self._access.password),
52
+ verify=self._access.verify,
53
+ timeout=TIMEOUT,
54
+ ) as http:
55
+ try:
56
+ return await read_inventory(RedfishClient(http, self._access.max_requests))
57
+ except (KeyError, TypeError, ValueError) as error:
58
+ raise RedfishError(f"unexpected Redfish payload: {error!r}") from error
59
+
60
+
61
+ class RedfishClient:
62
+ """GETs resources from one BMC, at most `max_requests` at a time, retrying transient failures."""
63
+
64
+ def __init__(self, http: httpx.AsyncClient, max_requests: int, backoff: float = BACKOFF_SECONDS) -> None:
65
+ self._http = http
66
+ self._slots = asyncio.Semaphore(max_requests)
67
+ self._backoff = backoff
68
+
69
+ async def get(self, path: str) -> Resource:
70
+ async with self._slots:
71
+ response = await self._get_with_retries(path)
72
+ if response.status_code == httpx.codes.UNAUTHORIZED:
73
+ raise RedfishError("authentication failed")
74
+ if response.is_error:
75
+ raise RedfishError(f"GET {path} returned HTTP {response.status_code}")
76
+ try:
77
+ resource = response.json()
78
+ except ValueError:
79
+ resource = None
80
+ if not isinstance(resource, dict):
81
+ raise RedfishError(f"GET {path} returned a body that is not a JSON object")
82
+ return resource
83
+
84
+ async def _get_with_retries(self, path: str) -> httpx.Response:
85
+ for attempt in range(ATTEMPTS - 1):
86
+ # Connection errors, timeouts, 5xx and 429 are worth another try; any other answer is final.
87
+ with contextlib.suppress(httpx.TransportError):
88
+ response = await self._http.get(path)
89
+ if not _is_transient(response.status_code):
90
+ return response
91
+ await asyncio.sleep(self._backoff * 2**attempt)
92
+ try:
93
+ return await self._http.get(path)
94
+ except httpx.TransportError as error:
95
+ raise RedfishError(f"GET {path} failed: {error!r}") from error
96
+
97
+
98
+ def _is_transient(status_code: int) -> bool:
99
+ return status_code == httpx.codes.TOO_MANY_REQUESTS or status_code >= httpx.codes.INTERNAL_SERVER_ERROR
100
+
101
+
102
+ def _url_host(address: str) -> str:
103
+ """An IPv6 address needs brackets in a URL."""
104
+ return f"[{address}]" if ":" in address else address
105
+
106
+
107
+ async def read_inventory(client: RedfishClient) -> ActualNode:
108
+ """Walk the service one level at a time, fetching the collections of each level concurrently."""
109
+ root = await client.get(SERVICE_ROOT)
110
+ systems, chassis, update_service = await asyncio.gather(
111
+ _members(client, root, "Systems"),
112
+ _members(client, root, "Chassis"),
113
+ _follow(client, root, "UpdateService"),
114
+ )
115
+ processors, modules, adapters, firmware = await asyncio.gather(
116
+ _members_of_each(client, systems, "Processors"),
117
+ _members_of_each(client, systems, "Memory"),
118
+ _members_of_each(client, chassis, "NetworkAdapters"),
119
+ _members(client, update_service, "FirmwareInventory"),
120
+ )
121
+ functions = await _members_of_each(client, adapters, "NetworkDeviceFunctions")
122
+ return ActualNode(
123
+ serials=tuple(system["SerialNumber"] for system in systems if system.get("SerialNumber")),
124
+ gpus=tuple(_gpu(processor) for processor in processors if processor.get("ProcessorType") == "GPU"),
125
+ dimms=tuple(_dimm(module) for module in modules if _is_enabled(module)),
126
+ ports=tuple(_port(function) for function in functions),
127
+ firmware=tuple(_firmware_item(item) for item in firmware),
128
+ )
129
+
130
+
131
+ async def _follow(client: RedfishClient, resource: Resource | None, key: str) -> Resource | None:
132
+ """The resource that `resource[key]` links to, if there is such a link."""
133
+ link = resource.get(key) if resource else None
134
+ return await client.get(link["@odata.id"]) if link else None
135
+
136
+
137
+ async def _members(client: RedfishClient, resource: Resource | None, key: str) -> list[Resource]:
138
+ """The members of the collection that `resource[key]` links to; none if there is no such link."""
139
+ collection = await _follow(client, resource, key)
140
+ if collection is None:
141
+ return []
142
+ return list(
143
+ await asyncio.gather(*(client.get(member["@odata.id"]) for member in collection.get("Members", [])))
144
+ )
145
+
146
+
147
+ async def _members_of_each(client: RedfishClient, resources: list[Resource], key: str) -> list[Resource]:
148
+ nested = await asyncio.gather(*(_members(client, resource, key) for resource in resources))
149
+ return [member for members in nested for member in members]
150
+
151
+
152
+ def _gpu(processor: Resource) -> Gpu:
153
+ return Gpu(id=processor["Id"], model=processor.get("Model") or "")
154
+
155
+
156
+ def _dimm(module: Resource) -> Dimm:
157
+ return Dimm(id=module["Id"], capacity_mib=module["CapacityMiB"])
158
+
159
+
160
+ def _port(function: Resource) -> NetworkPort:
161
+ ethernet = function.get("Ethernet") or {}
162
+ return NetworkPort(
163
+ id=function["Id"], mac=ethernet.get("PermanentMACAddress") or ethernet.get("MACAddress")
164
+ )
165
+
166
+
167
+ def _firmware_item(item: Resource) -> FirmwareItem:
168
+ return FirmwareItem(id=item["Id"], version=item.get("Version") or "")
169
+
170
+
171
+ def _is_enabled(resource: Resource) -> bool:
172
+ return (resource.get("Status") or {}).get("State") == "Enabled"
racklint/render.py ADDED
@@ -0,0 +1,46 @@
1
+ # racklint/src/racklint/render.py
2
+ """Present a report: a table for people, JSON for pipelines."""
3
+
4
+ from typing import Protocol
5
+
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from racklint.models import NodeResult, Report, Status
11
+
12
+ STATUS_STYLES = {Status.PASS: "bold green", Status.FAIL: "bold red", Status.ERROR: "bold yellow"}
13
+
14
+
15
+ class Renderer(Protocol):
16
+ def render(self, report: Report, console: Console) -> None: ...
17
+
18
+
19
+ class TableRenderer:
20
+ def render(self, report: Report, console: Console) -> None:
21
+ table = Table("Node", "BMC", "Status", "Reasons")
22
+ for result in report.results:
23
+ # Text, not str: reasons such as "approved [1.4.0]" must not be read as rich markup.
24
+ table.add_row(
25
+ Text(result.node),
26
+ Text(result.bmc_address or "-"),
27
+ Text(result.status.upper(), style=STATUS_STYLES[result.status]),
28
+ Text("\n".join(_reasons(result))),
29
+ )
30
+ console.print(table)
31
+ console.print(
32
+ f"{report.count(Status.PASS)} passed, {report.count(Status.FAIL)} failed, "
33
+ f"{report.count(Status.ERROR)} errored",
34
+ highlight=False,
35
+ )
36
+
37
+
38
+ class JsonRenderer:
39
+ def render(self, report: Report, console: Console) -> None:
40
+ console.out(report.model_dump_json(indent=2), highlight=False)
41
+
42
+
43
+ def _reasons(result: NodeResult) -> list[str]:
44
+ if result.error is not None:
45
+ return [result.error]
46
+ return [f"{finding.check}: {finding.message}" for finding in result.findings]
racklint/settings.py ADDED
@@ -0,0 +1,24 @@
1
+ # racklint/src/racklint/settings.py
2
+ """The CLI's settings, from RACKLINT_* environment variables or a .env file in the working directory.
3
+
4
+ The library does not use them: it takes explicit objects such as BmcAccess.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ from pydantic import FilePath, HttpUrl, PositiveInt, SecretStr
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+
13
+ class Settings(BaseSettings):
14
+ model_config = SettingsConfigDict(env_prefix="RACKLINT_", env_file=".env", extra="ignore", frozen=True)
15
+
16
+ netbox_url: HttpUrl
17
+ netbox_token: SecretStr
18
+ bmc_username: str
19
+ bmc_password: SecretStr
20
+ bmc_scheme: Literal["https", "http"] = "https"
21
+ bmc_verify_tls: bool = True
22
+ bmc_ca_bundle: FilePath | None = None
23
+ concurrency: PositiveInt = 16
24
+ bmc_concurrency: PositiveInt = 4
@@ -0,0 +1,342 @@
1
+ Metadata-Version: 2.4
2
+ Name: racklint
3
+ Version: 0.1.0
4
+ Summary: Check bare-metal and GPU servers against NetBox before they go into service.
5
+ Author: Reza Iskandar Achmad
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/riskand/racklint
8
+ Keywords: redfish,bmc,netbox,commissioning,gpu,data-centre
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: System :: Hardware
16
+ Classifier: Topic :: System :: Systems Administration
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: httpx>=0.28
22
+ Requires-Dist: pydantic>=2.9
23
+ Requires-Dist: pydantic-settings>=2.6
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: rich>=13.9
26
+ Requires-Dist: typer>=0.15
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.13; extra == "dev"
29
+ Requires-Dist: pytest>=8.3; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.25; extra == "dev"
31
+ Requires-Dist: pytest-cov>=6.0; extra == "dev"
32
+ Requires-Dist: respx>=0.22; extra == "dev"
33
+ Requires-Dist: ruff>=0.8; extra == "dev"
34
+ Requires-Dist: types-pyyaml>=6.0; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # racklint
38
+
39
+ **Check servers against the inventory before they go into service.**
40
+
41
+ racklint is a commissioning validator for bare-metal and GPU clusters. It reads what each server reports through
42
+ its BMC over [Redfish](https://www.dmtf.org/standards/redfish) and compares that with what
43
+ [NetBox](https://netbox.dev) says should be there. Every mismatch is flagged: a wrong GPU count, a wrong serial
44
+ number, firmware not on the approved baseline, a missing NIC.
45
+
46
+ You can use it in two ways: as the `racklint` command, or as a Python library.
47
+
48
+ ## Why
49
+
50
+ When a rack is brought up, the question is not "what hardware is in it?" but "is the hardware in it what we
51
+ ordered and planned for?" Existing tools such as [netbox-sync](https://github.com/bb-Ricardo/netbox-sync) and
52
+ [netbox-agent](https://github.com/Solvik/netbox-agent) answer the first question: they read the hardware and
53
+ write it into NetBox, so NetBox ends up describing whatever was found.
54
+
55
+ racklint works the other way. NetBox holds the planned state and stays the source of truth. racklint checks the
56
+ hardware against it, changes nothing, and fails loudly on any difference. That makes it useful as a gate: a
57
+ node only moves to the next stage of bring-up once racklint passes it.
58
+
59
+ It is read-only. It sends only GET requests to NetBox and to the BMCs. The host OS does not need to be running.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install racklint
65
+ ```
66
+
67
+ racklint needs Python 3.12 or later.
68
+
69
+ ## Command line
70
+
71
+ ### Settings
72
+
73
+ The command reads its settings from `RACKLINT_*` environment variables, or from a `.env` file in the working
74
+ directory.
75
+
76
+ | Setting | Default | Meaning |
77
+ |---|---|---|
78
+ | `RACKLINT_NETBOX_URL` | required | Base URL of NetBox, for example `https://netbox.example.net` |
79
+ | `RACKLINT_NETBOX_TOKEN` | required | NetBox API token |
80
+ | `RACKLINT_BMC_USERNAME` | required | User name for every BMC |
81
+ | `RACKLINT_BMC_PASSWORD` | required | Password for every BMC |
82
+ | `RACKLINT_BMC_SCHEME` | `https` | `https` or `http` |
83
+ | `RACKLINT_BMC_VERIFY_TLS` | `true` | Verify BMC TLS certificates |
84
+ | `RACKLINT_BMC_CA_BUNDLE` | unset | CA bundle file used to verify BMC certificates |
85
+ | `RACKLINT_CONCURRENCY` | `16` | BMCs read at once |
86
+ | `RACKLINT_BMC_CONCURRENCY` | `4` | Requests in flight to one BMC |
87
+
88
+ A token that starts with `nbt_` (NetBox 4.5 and later) is sent as a bearer token. Any other token is sent as a
89
+ legacy `Token`.
90
+
91
+ ### Run a check
92
+
93
+ ```bash
94
+ racklint check --rack R01 --baseline baseline.yaml
95
+ racklint check --device gpu-01 --device gpu-02 --baseline baseline.yaml
96
+ ```
97
+
98
+ Pass exactly one of `--rack` and `--device`.
99
+
100
+ | Option | Meaning |
101
+ |---|---|
102
+ | `--rack NAME` | Check every device in this NetBox rack |
103
+ | `--site SLUG` | Site slug. Needed only if the rack name exists in several sites |
104
+ | `--device NAME` | Check this device. Repeat for more |
105
+ | `--baseline PATH` | Firmware baseline file. Required |
106
+ | `--format table\|json` | Output format. Default `table` |
107
+ | `--concurrency N` | BMCs to read at once. Overrides `RACKLINT_CONCURRENCY` |
108
+ | `--ca-bundle PATH` | CA bundle for BMC certificates. Overrides `RACKLINT_BMC_CA_BUNDLE` |
109
+ | `--insecure` | Do not verify BMC certificates. Overrides `RACKLINT_BMC_VERIFY_TLS` |
110
+
111
+ The baseline is a YAML file with the approved firmware versions for each NetBox device type. Each `match` is a
112
+ case-sensitive glob on the `Id` of a member of the BMC's firmware inventory. Versions must be quoted strings.
113
+
114
+ ```yaml
115
+ device_types:
116
+ gpu-node-h100: # NetBox device-type slug
117
+ - match: "BMC"
118
+ approved: ["1.4.0"]
119
+ - match: "GPU_*"
120
+ approved: ["96.00.89.00.01"]
121
+ ```
122
+
123
+ ### Exit codes
124
+
125
+ | Code | Meaning |
126
+ |---|---|
127
+ | `0` | Every node passed |
128
+ | `1` | At least one node failed, or its BMC could not be read |
129
+ | `2` | Usage or configuration error: bad arguments, invalid settings or baseline, NetBox unreachable or returning an error, rack or device not found |
130
+
131
+ Use the exit code to gate a pipeline.
132
+
133
+ ### Output
134
+
135
+ The table has one row per node and one line per reason, then a summary:
136
+
137
+ ```console
138
+ ┏━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
139
+ ┃ Node ┃ BMC ┃ Status ┃ Reasons ┃
140
+ ┡━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
141
+ │ n01 │ 192.0.2.11 │ PASS │ │
142
+ │ n02 │ 192.0.2.12 │ PASS │ │
143
+ │ n03 │ 192.0.2.13 │ FAIL │ gpu: 7 found, 8 planned │
144
+ │ n04 │ 192.0.2.14 │ PASS │ │
145
+ │ n05 │ 192.0.2.15 │ FAIL │ memory: 31 DIMMs (1984 GiB), 32 planned (2048 │
146
+ │ │ │ │ GiB) │
147
+ │ │ │ │ firmware: BMC 1.3.2 not in approved [1.4.0] │
148
+ │ n06 │ 192.0.2.16 │ PASS │ │
149
+ └──────┴────────────┴────────┴─────────────────────────────────────────────────┘
150
+ 4 passed, 2 failed, 0 errored
151
+ ```
152
+
153
+ A node has status `PASS`, `FAIL` or `ERROR`. `ERROR` means racklint could not read the BMC (a connection
154
+ failure after retries, a timeout, a rejected login or an invalid reply). No checks run for that node, and the
155
+ reason is the error.
156
+
157
+ With `--format json` the output is the report as JSON. This is one failing node:
158
+
159
+ ```json
160
+ {
161
+ "schema_version": 1,
162
+ "results": [
163
+ {
164
+ "node": "n03",
165
+ "bmc_address": "192.0.2.13",
166
+ "status": "fail",
167
+ "findings": [
168
+ {
169
+ "check": "gpu",
170
+ "message": "7 found, 8 planned"
171
+ }
172
+ ],
173
+ "error": null
174
+ }
175
+ ]
176
+ }
177
+ ```
178
+
179
+ `status` is `pass`, `fail` or `error`. `error` is set only when the status is `error`.
180
+
181
+ ## Python library
182
+
183
+ The names exported from `racklint` (its `__all__`) are the public API. Anything else may change before 1.0. The
184
+ package ships type hints (`py.typed`). The names fall into these groups:
185
+
186
+ | Group | Names |
187
+ |---|---|
188
+ | State | `ExpectedNode`, `PlannedInterface`, `HardwareSpec`, `GpuSpec`, `MemorySpec`, `ActualNode`, `Gpu`, `Dimm`, `NetworkPort`, `FirmwareItem` |
189
+ | Verdict | `Report`, `NodeResult`, `Finding`, `Status` |
190
+ | Running checks | `check_nodes`, `iter_results`, `ActualReader`, `Check`, `default_checks`, `SerialCheck`, `GpuCheck`, `MemoryCheck`, `NicCheck`, `FirmwareCheck` |
191
+ | Sources | `NetBoxSource`, `netbox_client`, `RedfishReader`, `BmcAccess`, `FirmwareBaseline`, `FirmwareRule` |
192
+ | Errors | `RacklintError`, `BaselineError`, `NetBoxError`, `InventoryError`, `RedfishError` |
193
+
194
+ The library is async and does not read `RACKLINT_*` settings: you pass it explicit objects. This example reads
195
+ the plan for rack R01 from NetBox, reads each BMC and prints a verdict per node. It takes its connection details
196
+ from the environment.
197
+
198
+ ```python
199
+ import asyncio
200
+ import os
201
+ from pathlib import Path
202
+
203
+ from racklint import (
204
+ BmcAccess,
205
+ FirmwareBaseline,
206
+ NetBoxSource,
207
+ RedfishReader,
208
+ check_nodes,
209
+ default_checks,
210
+ netbox_client,
211
+ )
212
+
213
+
214
+ async def main() -> None:
215
+ async with netbox_client(os.environ["NETBOX_URL"], os.environ["NETBOX_TOKEN"]) as client:
216
+ nodes = await NetBoxSource(client).rack("R01")
217
+
218
+ access = BmcAccess(username=os.environ["BMC_USERNAME"], password=os.environ["BMC_PASSWORD"])
219
+ baseline = FirmwareBaseline.load(Path("baseline.yaml"))
220
+ report = await check_nodes(nodes, RedfishReader(access), default_checks(baseline), concurrency=16)
221
+
222
+ for result in report.results:
223
+ print(result.node, result.status, [finding.message for finding in result.findings])
224
+ print("all passed" if report.passed else "not all passed")
225
+
226
+
227
+ asyncio.run(main())
228
+ ```
229
+
230
+ `check_nodes` returns a `Report` with the results in the order of `nodes`. To act on each result as soon as it
231
+ is ready, for live progress, loop over `iter_results` instead. It takes the same arguments and yields
232
+ `NodeResult`s in the order they finish:
233
+
234
+ ```python
235
+ import asyncio
236
+ import os
237
+ from pathlib import Path
238
+
239
+ from racklint import (
240
+ BmcAccess,
241
+ FirmwareBaseline,
242
+ NetBoxSource,
243
+ RedfishReader,
244
+ default_checks,
245
+ iter_results,
246
+ netbox_client,
247
+ )
248
+
249
+
250
+ async def main() -> None:
251
+ async with netbox_client(os.environ["NETBOX_URL"], os.environ["NETBOX_TOKEN"]) as client:
252
+ nodes = await NetBoxSource(client).devices(["gpu-01", "gpu-02"])
253
+
254
+ access = BmcAccess(username=os.environ["BMC_USERNAME"], password=os.environ["BMC_PASSWORD"])
255
+ checks = default_checks(FirmwareBaseline.load(Path("baseline.yaml")))
256
+ async for result in iter_results(nodes, RedfishReader(access), checks, concurrency=16):
257
+ print(result.node, result.status)
258
+
259
+
260
+ asyncio.run(main())
261
+ ```
262
+
263
+ `NetBoxSource` raises `NetBoxError` and `FirmwareBaseline.load` raises `BaselineError`; both are subclasses of
264
+ `RacklintError`. A BMC that cannot be read does not raise: its node gets status `error`.
265
+
266
+ A `FirmwareBaseline` can also be built from a dict with `FirmwareBaseline.model_validate`, so an application
267
+ can keep the baseline in its own database.
268
+
269
+ The NetBox source is optional. A NetBox plugin runs inside NetBox, so it can build `ExpectedNode`s from the
270
+ Django ORM instead of the REST API, then call `asyncio.run(check_nodes(...))` from a background job, which is
271
+ synchronous code.
272
+
273
+ ## What it checks
274
+
275
+ | Check | Fails when | Example reason |
276
+ |---|---|---|
277
+ | Serial | The device's serial matches the serial of no system on the BMC | `serial: expected SN-R01-03, BMC reports SN-R01-30` |
278
+ | GPU | The GPU count differs, or a GPU's model differs from the planned model | `gpu: 7 found, 8 planned` |
279
+ | Memory | The enabled DIMM count or the total capacity differs | `memory: 31 DIMMs (1984 GiB), 32 planned (2048 GiB)` |
280
+ | NIC | A planned MAC address is not reported, or fewer ports are reported than interfaces are planned | `nic: eth2 (a0:88:c2:00:03:03) not found` |
281
+ | Firmware | The device type has no baseline, a baseline entry matches no firmware item, or a matched item's version is not approved | `firmware: BMC 1.3.2 not in approved [1.4.0]` |
282
+
283
+ A node passes only if every check passes. Firmware items that match no baseline entry are ignored, and so are
284
+ reported ports beyond the plan.
285
+
286
+ If NetBox lacks a value a check needs, the node fails with `not planned in NetBox: <item>`. A device with no
287
+ BMC address fails with that reason alone.
288
+
289
+ ### What NetBox must hold
290
+
291
+ | Item | Where racklint reads it |
292
+ |---|---|
293
+ | Serial | The device's `serial` field |
294
+ | BMC address | The device's out-of-band IP (`oob_ip`). The prefix length is dropped |
295
+ | Device type | The device type's slug. It selects the firmware baseline |
296
+ | NICs | The device's physical interfaces that are not management-only. A MAC address is compared if the interface has a primary MAC; without one, racklint only counts the port |
297
+ | GPU and memory | The `racklint` key of the device's config context |
298
+
299
+ Assign the config context to the device type, so every device of that type gets it:
300
+
301
+ ```yaml
302
+ racklint:
303
+ gpu: {count: 8, model: "NVIDIA H100 80GB HBM3"}
304
+ memory: {dimm_count: 32, total_gib: 2048}
305
+ ```
306
+
307
+ An invalid `racklint` config context is a configuration error and stops the run with exit code `2`.
308
+
309
+ On the BMC side, racklint starts at `/redfish/v1` and follows the links the service returns. It hardcodes no
310
+ vendor paths. It reads systems, processors, memory, network adapters and the firmware inventory. It uses HTTP
311
+ Basic authentication on every request and opens no Redfish sessions. It makes up to three attempts at a
312
+ request that fails with a connection error, a timeout, HTTP 5xx or 429, and uses a connect timeout of 5
313
+ seconds and a read timeout of 15 seconds.
314
+
315
+ ## Try it without hardware
316
+
317
+ [`examples/no-hardware/`](examples/no-hardware) is a Docker Compose stack with a real NetBox and six mock BMCs,
318
+ two of them built wrong on purpose. One command checks the rack, and the summary line is
319
+ `4 passed, 2 failed, 0 errored`. See its README for the steps and what is planted.
320
+
321
+ ## Limitations
322
+
323
+ - GPUs are found only as Redfish `Processors` with `ProcessorType == "GPU"`. A BMC that exposes GPUs another way
324
+ shows zero GPUs.
325
+ - One set of BMC credentials applies to every node in a run.
326
+ - Read-only by design. racklint reports differences and never fixes them in NetBox or on a BMC.
327
+
328
+ ## Related work
329
+
330
+ | Project | What it does |
331
+ |---|---|
332
+ | [bb-Ricardo/netbox-sync](https://github.com/bb-Ricardo/netbox-sync) | Syncs VMware and Redfish sources into NetBox |
333
+ | [Solvik/netbox-agent](https://github.com/Solvik/netbox-agent) | Agent on each server that pushes hardware into NetBox |
334
+ | [bb-Ricardo/check_redfish](https://github.com/bb-Ricardo/check_redfish) | Monitoring plugin for component health over Redfish |
335
+ | OpenStack Ironic, Metal3, MAAS, Warewulf | Full provisioning platforms with BMC inspection |
336
+
337
+ racklint is smaller than any of these and does one thing: it compares the hardware with NetBox's plan and
338
+ reports the differences.
339
+
340
+ ## License
341
+
342
+ [MIT](LICENSE)
@@ -0,0 +1,18 @@
1
+ racklint/__init__.py,sha256=bojv7B69WLbBExWdzBTSHo228u975WGYZvSgBWQCtnY,1547
2
+ racklint/baseline.py,sha256=z8F8dhqap-QLGKEY3TUFPdnSlJX9Mjeh-RUWNQOTVfM,941
3
+ racklint/checks.py,sha256=LFoPLibnfX7FMs0Uonp9WQ_fgry4eTOFf9Ab-wC7ng8,4079
4
+ racklint/cli.py,sha256=Pv1z9cImRE1tGCIc9YDDrN7DAX8PhDqQh0sgUMrVufY,4810
5
+ racklint/engine.py,sha256=HUHpyRmn5zP-8qhSn650TbZGEaylViWADGsHX1VNRpY,2640
6
+ racklint/errors.py,sha256=wMWcJwjw4y2KrQ_IuEbQDkD-PPq6XedYBknPNFPHlsc,527
7
+ racklint/models.py,sha256=nF4O2atKijrHEHU_4IMexQ_hqLkjzAuSjSuVaFnGTfE,2851
8
+ racklint/netbox.py,sha256=aZoSS5LExYhq9swGVPTokniRg1hnsINWlMDhqBkDook,4723
9
+ racklint/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ racklint/redfish.py,sha256=TNtc4N3T0oLb34vObUgfgqPAjVDwQgdt33nA7IFDM7o,6660
11
+ racklint/render.py,sha256=se4d_x3H57icAudg1IYNqbiv_Ry-tnGE8Du-EdJITDg,1588
12
+ racklint/settings.py,sha256=9nNhYnPbdB8GI0x1OGTWyNsNQi6g0sHACN74YsQyd5I,813
13
+ racklint-0.1.0.dist-info/licenses/LICENSE,sha256=-PMFfPbf8g1LaUdw7cgrVXF5YYF-RA5uLEtkHsWI4y4,1077
14
+ racklint-0.1.0.dist-info/METADATA,sha256=DfryvogWVMEtw38RPiX9vdYd7j52ANsjL2lYP0Ok1iY,14812
15
+ racklint-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
16
+ racklint-0.1.0.dist-info/entry_points.txt,sha256=iienqnMceoWGpJhQDTEQD3TV3LGxaJgfnWQVEINRDa8,46
17
+ racklint-0.1.0.dist-info/top_level.txt,sha256=34Bk7HMWKC2vp2sZunhaz9C3wFHCk7d2io577xjGLrM,9
18
+ racklint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ racklint = racklint.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Reza Iskandar Achmad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ racklint