Skip to content

Commit df9906e

Browse files
committed
feat(hardware): in-band BMC/ipmitool read core + parsers (#609 phase 1)
Credential-free hardware health via local ipmitool on the node (SSH, in-band over /dev/ipmi0 — no BMC network credentials, no data<->management-plane bridge). Read-only: sdr/sel/fru/dcmi/chassis only, one SSH round via markers, graceful when ipmitool or the IPMI interface is absent (never mutates the host). Pure parsers (sensors/chassis/power/fru/sel) + a health rollup, fixture-tested without hardware (10 tests). API route + mandatory compliance-ack consent gate + audit-log + node Hardware panel follow; the network-Redfish opt-in is a later, warned phase.
1 parent 3424473 commit df9906e

2 files changed

Lines changed: 344 additions & 0 deletions

File tree

pegaprox/core/bmc.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
# -*- coding: utf-8 -*-
2+
"""In-band BMC / hardware health via local `ipmitool` (#609).
3+
4+
CREDENTIAL-FREE by design: instead of storing BMC network credentials and
5+
reaching the BMC over its management network (the "crown-jewels" path — see the
6+
Redfish opt-in phase), we SSH to the PVE node — access PegaProx already has for
7+
lm-sensors / fencing — and run `ipmitool` LOCALLY there. The host talks to its
8+
own BMC over the internal KCS interface (`/dev/ipmi0`) with no username/password
9+
and without bridging the data network to the out-of-band management plane.
10+
11+
Read-only: only `sdr` / `sel` / `fru` / `dcmi` / `chassis status` are issued —
12+
no power / virtual-media / firmware commands. Availability is opportunistic:
13+
if `ipmitool` or `/dev/ipmi0` is absent we return `available=False` and change
14+
nothing on the host (PegaProx never auto-enables the IPMI channel — the optional
15+
install + the compliance acknowledgement are gated at the API layer).
16+
17+
Parsers are pure (text -> dict) so they can be fixture-tested without hardware.
18+
"""
19+
20+
import re
21+
22+
# Markers so the whole read is ONE SSH round-trip; the orchestrator splits on them.
23+
_M_SENS, _M_CHAS, _M_POWER, _M_FRU, _M_SEL = (
24+
'__PP_SENSORS__', '__PP_CHASSIS__', '__PP_POWER__', '__PP_FRU__', '__PP_SEL__')
25+
26+
# Single shell script run on the node. Bails early (no host mutation) when the
27+
# in-band interface is missing, so we never touch a hardened box that removed it.
28+
INBAND_PROBE_CMD = (
29+
"if ! command -v ipmitool >/dev/null 2>&1; then echo __PP_NO_IPMITOOL__; exit 0; fi; "
30+
"if ! ipmitool mc info >/dev/null 2>&1; then echo __PP_NO_BMC__; exit 0; fi; "
31+
"echo " + _M_SENS + "; ipmitool sdr elist 2>/dev/null; "
32+
"echo " + _M_CHAS + "; ipmitool chassis status 2>/dev/null; "
33+
"echo " + _M_POWER + "; ipmitool dcmi power reading 2>/dev/null; "
34+
"echo " + _M_FRU + "; ipmitool fru print 0 2>/dev/null; "
35+
"echo " + _M_SEL + "; ipmitool sel elist last 25 2>/dev/null"
36+
)
37+
38+
# ipmitool sdr status tokens -> normalized health
39+
_SDR_STATUS = {'ok': 'ok', 'ns': 'na', 'nc': 'warning', 'cr': 'critical', 'nr': 'critical'}
40+
41+
42+
def _num(s):
43+
"""First numeric token in a string as float, or None."""
44+
m = re.search(r'-?\d+(?:\.\d+)?', s or '')
45+
return float(m.group(0)) if m else None
46+
47+
48+
def parse_sensors(text):
49+
"""`ipmitool sdr elist` -> [{name, kind, value, unit, reading, status}].
50+
51+
Line shape: ``Name | 01h | ok | 3.1 | 45 degrees C`` (pipe-separated, 5 cols).
52+
kind is derived from the reading unit (temp / fan / volt / power / other).
53+
"""
54+
out = []
55+
for line in (text or '').splitlines():
56+
if '|' not in line:
57+
continue
58+
cols = [c.strip() for c in line.split('|')]
59+
if len(cols) < 5:
60+
continue
61+
name, status_tok, reading = cols[0], cols[2].lower(), cols[4]
62+
if not name:
63+
continue
64+
rl = reading.lower()
65+
if 'degrees c' in rl or rl.endswith(' c') or 'degree' in rl:
66+
kind, unit = 'temp', '°C'
67+
elif 'rpm' in rl:
68+
kind, unit = 'fan', 'RPM'
69+
elif 'volt' in rl:
70+
kind, unit = 'volt', 'V'
71+
elif 'watt' in rl:
72+
kind, unit = 'power', 'W'
73+
elif 'amp' in rl:
74+
kind, unit = 'current', 'A'
75+
else:
76+
kind, unit = 'discrete', ''
77+
out.append({
78+
'name': name,
79+
'kind': kind,
80+
'value': _num(reading) if kind not in ('discrete',) else None,
81+
'unit': unit,
82+
'reading': reading,
83+
'status': _SDR_STATUS.get(status_tok, status_tok or 'na'),
84+
})
85+
return out
86+
87+
88+
def parse_chassis(text):
89+
"""`ipmitool chassis status` -> {power, intrusion, fault, ...}."""
90+
d = {}
91+
for line in (text or '').splitlines():
92+
if ':' not in line:
93+
continue
94+
k, v = line.split(':', 1)
95+
k, v = k.strip().lower(), v.strip()
96+
if k == 'system power':
97+
d['power'] = v
98+
elif 'intrusion' in k:
99+
d['intrusion'] = v
100+
elif 'fault' in k and v:
101+
d.setdefault('faults', []).append(f"{k}: {v}")
102+
return d
103+
104+
105+
def parse_power(text):
106+
"""`ipmitool dcmi power reading` -> instantaneous watts (float) or None."""
107+
for line in (text or '').splitlines():
108+
if 'instantaneous power reading' in line.lower():
109+
return _num(line)
110+
return None
111+
112+
113+
def parse_fru(text):
114+
"""`ipmitool fru print 0` -> {manufacturer, product, serial, part, board_*}."""
115+
kv = {}
116+
for line in (text or '').splitlines():
117+
if ':' not in line:
118+
continue
119+
k, v = line.split(':', 1)
120+
kv[k.strip().lower()] = v.strip()
121+
g = lambda *keys: next((kv[k] for k in keys if kv.get(k)), '')
122+
return {
123+
'manufacturer': g('product manufacturer', 'board mfg', 'chassis manufacturer'),
124+
'product': g('product name', 'board product'),
125+
'serial': g('product serial', 'board serial', 'chassis serial'),
126+
'part': g('product part number', 'board part number'),
127+
}
128+
129+
130+
def parse_sel(text, limit=25):
131+
"""`ipmitool sel elist` -> recent hardware events (newest first).
132+
133+
Line shape: ``12 | 07/14/2026 | 21:30:05 | Power Supply PSU2 | Failure detected | Asserted``
134+
"""
135+
events = []
136+
for line in (text or '').splitlines():
137+
if '|' not in line:
138+
continue
139+
cols = [c.strip() for c in line.split('|')]
140+
if len(cols) < 4:
141+
continue
142+
# cols: id, date, time, sensor, [description], [state]
143+
ts = (cols[1] + ' ' + cols[2]).strip() if len(cols) >= 3 else ''
144+
sensor = cols[3] if len(cols) > 3 else ''
145+
desc = cols[4] if len(cols) > 4 else ''
146+
state = cols[5] if len(cols) > 5 else ''
147+
low = (desc + ' ' + state + ' ' + sensor).lower()
148+
sev = 'critical' if any(w in low for w in ('fail', 'critical', 'fault', 'error', 'lost')) \
149+
else 'warning' if any(w in low for w in ('warn', 'non-critical', 'degrad', 'intrusion')) \
150+
else 'info'
151+
events.append({'id': cols[0], 'time': ts, 'sensor': sensor,
152+
'description': (desc + (' — ' + state if state else '')).strip(' —'),
153+
'severity': sev})
154+
events.reverse() # newest first
155+
return events[:limit]
156+
157+
158+
def _health_rollup(sensors, sel, chassis):
159+
"""Overall node hardware health from the parsed pieces: ok / warning / critical."""
160+
if any(s['status'] == 'critical' for s in sensors) or \
161+
any(e['severity'] == 'critical' for e in sel) or \
162+
(chassis.get('intrusion', '').lower() not in ('', 'inactive', 'not present', 'disabled')):
163+
return 'critical'
164+
if any(s['status'] == 'warning' for s in sensors) or \
165+
any(e['severity'] == 'warning' for e in sel):
166+
return 'warning'
167+
return 'ok'
168+
169+
170+
def parse_inband(raw):
171+
"""Split the marker-delimited SSH output and parse every section.
172+
173+
Returns {'available': bool, 'reason'?: str, 'health', 'sensors', 'chassis',
174+
'power_w', 'fru', 'events'}.
175+
"""
176+
raw = raw or ''
177+
if '__PP_NO_IPMITOOL__' in raw:
178+
return {'available': False, 'reason': 'ipmitool is not installed on this node'}
179+
if '__PP_NO_BMC__' in raw:
180+
return {'available': False, 'reason': 'no in-band BMC / IPMI interface (/dev/ipmi0) on this node'}
181+
182+
def section(marker, nxt):
183+
try:
184+
body = raw.split(marker, 1)[1]
185+
except IndexError:
186+
return ''
187+
for m in nxt:
188+
if m in body:
189+
body = body.split(m, 1)[0]
190+
return body
191+
192+
sensors = parse_sensors(section(_M_SENS, [_M_CHAS, _M_POWER, _M_FRU, _M_SEL]))
193+
chassis = parse_chassis(section(_M_CHAS, [_M_POWER, _M_FRU, _M_SEL]))
194+
power_w = parse_power(section(_M_POWER, [_M_FRU, _M_SEL]))
195+
fru = parse_fru(section(_M_FRU, [_M_SEL]))
196+
events = parse_sel(section(_M_SEL, []))
197+
if not (sensors or chassis or events or power_w is not None or any(fru.values())):
198+
return {'available': False, 'reason': 'in-band BMC returned no data'}
199+
return {
200+
'available': True,
201+
'health': _health_rollup(sensors, sel=events, chassis=chassis),
202+
'sensors': sensors,
203+
'chassis': chassis,
204+
'power_w': power_w,
205+
'fru': fru,
206+
'events': events,
207+
}
208+
209+
210+
def read_node_bmc_inband(mgr, node, timeout=15):
211+
"""Orchestrator: SSH to `node`, run the read-only ipmitool probe, parse it.
212+
213+
Credential-free (in-band). Returns the parse_inband() dict, or an
214+
{'available': False, 'reason': ...} on any SSH/resolution failure. Never
215+
mutates the node. The compliance acknowledgement + optional install are
216+
enforced by the caller (API layer), not here.
217+
"""
218+
try:
219+
ip = mgr._get_node_ip(node)
220+
if not ip:
221+
return {'available': False, 'reason': f'no SSH-reachable IP for node {node}'}
222+
user = getattr(mgr.config, 'ssh_user', None) or 'root'
223+
raw = mgr._ssh_run_command_output(ip, user, INBAND_PROBE_CMD, timeout=timeout)
224+
if raw is None or not raw.strip():
225+
return {'available': False, 'reason': 'no response from node (SSH unavailable?)'}
226+
return parse_inband(raw)
227+
except Exception as e: # noqa: BLE001 — surface as unavailable, never raise into the route
228+
return {'available': False, 'reason': f'in-band BMC read failed: {e}'}

tests/test_bmc_parsers.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# -*- coding: utf-8 -*-
2+
"""Fixture tests for the in-band BMC/ipmitool parsers (#609).
3+
4+
No hardware needed: `ipmitool` output is deterministic text, so we parse
5+
captured real-world samples and assert the normalized structure + health rollup.
6+
"""
7+
from pegaprox.core import bmc
8+
9+
SDR = """CPU1 Temp | 01h | ok | 3.1 | 45 degrees C
10+
CPU2 Temp | 02h | ok | 3.2 | 47 degrees C
11+
Inlet Temp | 04h | ok | 7.1 | 22 degrees C
12+
Fan1 | 41h | ok | 29.1 | 4200 RPM
13+
Fan2 | 42h | cr | 29.2 | 900 RPM
14+
PSU1 Status | 70h | ok | 10.1 | Presence detected
15+
Voltage 12V | 60h | ok | 60.1 | 12.10 Volts
16+
Pwr Consumption | 77h | ok | 7.1 | 210 Watts"""
17+
18+
CHASSIS = """System Power : on
19+
Power Overload : false
20+
Main Power Fault : false
21+
Chassis Intrusion : inactive"""
22+
23+
POWER = """ Instantaneous power reading: 210 Watts
24+
Minimum during sampling period: 120 Watts
25+
Maximum during sampling period: 340 Watts"""
26+
27+
FRU = """ Product Manufacturer : Dell Inc.
28+
Product Name : PowerEdge R740
29+
Product Part Number : 0ABCDE
30+
Product Serial : CN7016AB012345"""
31+
32+
SEL = """ 1 | 07/10/2026 | 08:00:00 | Fan #0x42 | Lower Critical going low | Asserted
33+
2 | 07/14/2026 | 21:30:05 | Power Supply PSU2 | Failure detected | Asserted"""
34+
35+
36+
def test_sensors():
37+
s = {x['name']: x for x in bmc.parse_sensors(SDR)}
38+
assert s['CPU1 Temp']['kind'] == 'temp' and s['CPU1 Temp']['value'] == 45 and s['CPU1 Temp']['unit'] == '°C'
39+
assert s['Fan2']['kind'] == 'fan' and s['Fan2']['value'] == 900 and s['Fan2']['status'] == 'critical'
40+
assert s['Voltage 12V']['kind'] == 'volt' and s['Voltage 12V']['value'] == 12.10
41+
assert s['Pwr Consumption']['kind'] == 'power' and s['Pwr Consumption']['value'] == 210
42+
assert s['PSU1 Status']['kind'] == 'discrete' and s['PSU1 Status']['value'] is None and s['PSU1 Status']['status'] == 'ok'
43+
44+
45+
def test_chassis():
46+
c = bmc.parse_chassis(CHASSIS)
47+
assert c['power'] == 'on' and c['intrusion'] == 'inactive'
48+
49+
50+
def test_power():
51+
assert bmc.parse_power(POWER) == 210.0
52+
assert bmc.parse_power('nothing here') is None
53+
54+
55+
def test_fru():
56+
f = bmc.parse_fru(FRU)
57+
assert f['manufacturer'] == 'Dell Inc.' and f['product'] == 'PowerEdge R740'
58+
assert f['serial'] == 'CN7016AB012345' and f['part'] == '0ABCDE'
59+
60+
61+
def test_sel_newest_first_and_severity():
62+
ev = bmc.parse_sel(SEL)
63+
assert ev[0]['id'] == '2' and 'PSU2' in ev[0]['sensor'] and ev[0]['severity'] == 'critical'
64+
assert ev[1]['id'] == '1' and ev[1]['severity'] == 'critical'
65+
assert ev[0]['time'] == '07/14/2026 21:30:05'
66+
67+
68+
def _full(sdr=SDR, chassis=CHASSIS, power=POWER, fru=FRU, sel=SEL):
69+
return (f"__PP_SENSORS__\n{sdr}\n__PP_CHASSIS__\n{chassis}\n"
70+
f"__PP_POWER__\n{power}\n__PP_FRU__\n{fru}\n__PP_SEL__\n{sel}\n")
71+
72+
73+
def test_parse_inband_full_and_critical_health():
74+
d = bmc.parse_inband(_full())
75+
assert d['available'] is True
76+
assert d['power_w'] == 210.0
77+
assert d['fru']['serial'] == 'CN7016AB012345'
78+
assert len(d['sensors']) == 8 and len(d['events']) == 2
79+
# Fan2 is 'cr' AND a critical PSU failure in the SEL -> node health critical
80+
assert d['health'] == 'critical'
81+
82+
83+
def test_parse_inband_clean_is_ok():
84+
clean_sdr = "CPU1 Temp | 01h | ok | 3.1 | 45 degrees C\nFan1 | 41h | ok | 29.1 | 4200 RPM"
85+
d = bmc.parse_inband(_full(sdr=clean_sdr, sel=""))
86+
assert d['available'] is True and d['health'] == 'ok'
87+
88+
89+
def test_intrusion_forces_critical():
90+
d = bmc.parse_inband(_full(chassis="System Power : on\nChassis Intrusion : active", sel=""))
91+
# even with clean sensors, an active chassis intrusion is critical
92+
d2 = bmc.parse_inband(_full(sdr="CPU1 Temp | 01h | ok | 3.1 | 45 degrees C",
93+
chassis="System Power : on\nChassis Intrusion : active", sel=""))
94+
assert d2['health'] == 'critical'
95+
96+
97+
def test_unavailable_paths():
98+
assert bmc.parse_inband('__PP_NO_IPMITOOL__\n')['available'] is False
99+
assert 'ipmitool' in bmc.parse_inband('__PP_NO_IPMITOOL__\n')['reason']
100+
assert bmc.parse_inband('__PP_NO_BMC__\n')['available'] is False
101+
assert bmc.parse_inband('')['available'] is False
102+
103+
104+
def test_read_orchestrator_graceful(monkeypatch):
105+
# fake manager: no SSH IP -> unavailable, never raises
106+
class M:
107+
class config: ssh_user = 'root'
108+
def _get_node_ip(self, n): return None
109+
assert bmc.read_node_bmc_inband(M(), 'pve1')['available'] is False
110+
111+
class M2:
112+
class config: ssh_user = 'root'
113+
def _get_node_ip(self, n): return '10.0.0.1'
114+
def _ssh_run_command_output(self, ip, user, cmd, timeout=15): return _full()
115+
r = bmc.read_node_bmc_inband(M2(), 'pve1')
116+
assert r['available'] is True and r['health'] == 'critical' and r['fru']['product'] == 'PowerEdge R740'

0 commit comments

Comments
 (0)