embedded-lab-control 0.2.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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/health/checks.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health check implementations for lab controller.
|
|
3
|
+
|
|
4
|
+
Provides ping, serial, and power health checks for SBCs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from labctl.core.models import SBC, PortType, Status
|
|
16
|
+
from labctl.power.base import PowerController, PowerState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CheckType(Enum):
|
|
20
|
+
"""Types of health checks available."""
|
|
21
|
+
|
|
22
|
+
PING = "ping"
|
|
23
|
+
SERIAL = "serial"
|
|
24
|
+
POWER = "power"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CheckResult:
|
|
29
|
+
"""Result of a single health check."""
|
|
30
|
+
|
|
31
|
+
sbc_name: str
|
|
32
|
+
check_type: CheckType
|
|
33
|
+
success: bool
|
|
34
|
+
message: str
|
|
35
|
+
duration_ms: float
|
|
36
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def status_char(self) -> str:
|
|
40
|
+
"""Get single character status indicator."""
|
|
41
|
+
if self.success:
|
|
42
|
+
return "\u2713" # checkmark
|
|
43
|
+
return "\u2717" # X mark
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class HealthCheckSummary:
|
|
48
|
+
"""Summary of health checks for an SBC."""
|
|
49
|
+
|
|
50
|
+
sbc_name: str
|
|
51
|
+
ping_result: Optional[CheckResult] = None
|
|
52
|
+
serial_result: Optional[CheckResult] = None
|
|
53
|
+
power_result: Optional[CheckResult] = None
|
|
54
|
+
power_state: Optional[PowerState] = None
|
|
55
|
+
recommended_status: Optional[Status] = None
|
|
56
|
+
|
|
57
|
+
def determine_status(self) -> Status:
|
|
58
|
+
"""Determine recommended SBC status based on check results."""
|
|
59
|
+
# If ping fails, SBC is offline
|
|
60
|
+
if self.ping_result and not self.ping_result.success:
|
|
61
|
+
return Status.OFFLINE
|
|
62
|
+
|
|
63
|
+
# If power is off, SBC is offline
|
|
64
|
+
if self.power_state == PowerState.OFF:
|
|
65
|
+
return Status.OFFLINE
|
|
66
|
+
|
|
67
|
+
# If ping succeeds but serial fails, there's an error
|
|
68
|
+
if self.ping_result and self.ping_result.success:
|
|
69
|
+
if self.serial_result and not self.serial_result.success:
|
|
70
|
+
return Status.ERROR
|
|
71
|
+
|
|
72
|
+
# If ping succeeds, SBC is online
|
|
73
|
+
if self.ping_result and self.ping_result.success:
|
|
74
|
+
return Status.ONLINE
|
|
75
|
+
|
|
76
|
+
# Can't determine status
|
|
77
|
+
return Status.UNKNOWN
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HealthChecker:
|
|
81
|
+
"""Performs health checks on SBCs."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
ping_timeout: float = 2.0,
|
|
86
|
+
serial_timeout: float = 2.0,
|
|
87
|
+
):
|
|
88
|
+
"""
|
|
89
|
+
Initialize health checker.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
ping_timeout: Timeout for ping checks in seconds
|
|
93
|
+
serial_timeout: Timeout for serial port checks in seconds
|
|
94
|
+
"""
|
|
95
|
+
self.ping_timeout = ping_timeout
|
|
96
|
+
self.serial_timeout = serial_timeout
|
|
97
|
+
|
|
98
|
+
def ping_check(self, ip: str, sbc_name: str = "") -> CheckResult:
|
|
99
|
+
"""
|
|
100
|
+
Check if an IP address responds to ICMP ping.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
ip: IP address to ping
|
|
104
|
+
sbc_name: Name of SBC for result tracking
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
CheckResult with success/failure status
|
|
108
|
+
"""
|
|
109
|
+
start_time = time.time()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
# Use ping command with 1 packet and timeout
|
|
113
|
+
# -c 1: send 1 packet
|
|
114
|
+
# -W: timeout in seconds (Linux)
|
|
115
|
+
result = subprocess.run(
|
|
116
|
+
["ping", "-c", "1", "-W", str(int(self.ping_timeout)), ip],
|
|
117
|
+
capture_output=True,
|
|
118
|
+
timeout=self.ping_timeout + 1,
|
|
119
|
+
)
|
|
120
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
121
|
+
|
|
122
|
+
if result.returncode == 0:
|
|
123
|
+
return CheckResult(
|
|
124
|
+
sbc_name=sbc_name,
|
|
125
|
+
check_type=CheckType.PING,
|
|
126
|
+
success=True,
|
|
127
|
+
message=f"Host {ip} is reachable",
|
|
128
|
+
duration_ms=duration_ms,
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
return CheckResult(
|
|
132
|
+
sbc_name=sbc_name,
|
|
133
|
+
check_type=CheckType.PING,
|
|
134
|
+
success=False,
|
|
135
|
+
message=f"Host {ip} is unreachable",
|
|
136
|
+
duration_ms=duration_ms,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
except subprocess.TimeoutExpired:
|
|
140
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
141
|
+
return CheckResult(
|
|
142
|
+
sbc_name=sbc_name,
|
|
143
|
+
check_type=CheckType.PING,
|
|
144
|
+
success=False,
|
|
145
|
+
message=f"Ping to {ip} timed out",
|
|
146
|
+
duration_ms=duration_ms,
|
|
147
|
+
)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
150
|
+
return CheckResult(
|
|
151
|
+
sbc_name=sbc_name,
|
|
152
|
+
check_type=CheckType.PING,
|
|
153
|
+
success=False,
|
|
154
|
+
message=f"Ping error: {e}",
|
|
155
|
+
duration_ms=duration_ms,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def serial_check(self, host: str, port: int, sbc_name: str = "") -> CheckResult:
|
|
159
|
+
"""
|
|
160
|
+
Check if a serial port (via TCP) is accessible.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
host: Host address (usually localhost for ser2net)
|
|
164
|
+
port: TCP port number
|
|
165
|
+
sbc_name: Name of SBC for result tracking
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
CheckResult with success/failure status
|
|
169
|
+
"""
|
|
170
|
+
start_time = time.time()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
174
|
+
sock.settimeout(self.serial_timeout)
|
|
175
|
+
sock.connect((host, port))
|
|
176
|
+
sock.close()
|
|
177
|
+
|
|
178
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
179
|
+
return CheckResult(
|
|
180
|
+
sbc_name=sbc_name,
|
|
181
|
+
check_type=CheckType.SERIAL,
|
|
182
|
+
success=True,
|
|
183
|
+
message=f"Serial port {host}:{port} is accessible",
|
|
184
|
+
duration_ms=duration_ms,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
except socket.timeout:
|
|
188
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
189
|
+
return CheckResult(
|
|
190
|
+
sbc_name=sbc_name,
|
|
191
|
+
check_type=CheckType.SERIAL,
|
|
192
|
+
success=False,
|
|
193
|
+
message=f"Connection to {host}:{port} timed out",
|
|
194
|
+
duration_ms=duration_ms,
|
|
195
|
+
)
|
|
196
|
+
except ConnectionRefusedError:
|
|
197
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
198
|
+
return CheckResult(
|
|
199
|
+
sbc_name=sbc_name,
|
|
200
|
+
check_type=CheckType.SERIAL,
|
|
201
|
+
success=False,
|
|
202
|
+
message=f"Connection to {host}:{port} refused",
|
|
203
|
+
duration_ms=duration_ms,
|
|
204
|
+
)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
207
|
+
return CheckResult(
|
|
208
|
+
sbc_name=sbc_name,
|
|
209
|
+
check_type=CheckType.SERIAL,
|
|
210
|
+
success=False,
|
|
211
|
+
message=f"Serial check error: {e}",
|
|
212
|
+
duration_ms=duration_ms,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def serial_probe(
|
|
216
|
+
self,
|
|
217
|
+
host: str,
|
|
218
|
+
port: int,
|
|
219
|
+
probe_string: str = "\r\n",
|
|
220
|
+
expect_pattern: Optional[str] = None,
|
|
221
|
+
sbc_name: str = "",
|
|
222
|
+
) -> CheckResult:
|
|
223
|
+
"""
|
|
224
|
+
Send a probe string to serial port and optionally check for response.
|
|
225
|
+
|
|
226
|
+
This is a more active check than serial_check - it actually sends
|
|
227
|
+
data and optionally verifies the response matches a pattern.
|
|
228
|
+
|
|
229
|
+
Common use cases:
|
|
230
|
+
- Send Enter key, expect login prompt
|
|
231
|
+
- Send "echo test", expect "test" back
|
|
232
|
+
- Send newline, expect any response
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
host: Host address (usually localhost for ser2net)
|
|
236
|
+
port: TCP port number
|
|
237
|
+
probe_string: String to send to the serial port
|
|
238
|
+
expect_pattern: Optional pattern to look for in response (substring match)
|
|
239
|
+
sbc_name: Name of SBC for result tracking
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
CheckResult with success/failure status
|
|
243
|
+
"""
|
|
244
|
+
import select
|
|
245
|
+
|
|
246
|
+
start_time = time.time()
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
250
|
+
sock.settimeout(self.serial_timeout)
|
|
251
|
+
sock.connect((host, port))
|
|
252
|
+
|
|
253
|
+
# Clear any buffered data first
|
|
254
|
+
sock.setblocking(False)
|
|
255
|
+
try:
|
|
256
|
+
while True:
|
|
257
|
+
ready, _, _ = select.select([sock], [], [], 0.1)
|
|
258
|
+
if not ready:
|
|
259
|
+
break
|
|
260
|
+
data = sock.recv(4096)
|
|
261
|
+
if not data:
|
|
262
|
+
break
|
|
263
|
+
except BlockingIOError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
# Send probe string
|
|
267
|
+
sock.setblocking(True)
|
|
268
|
+
sock.settimeout(self.serial_timeout)
|
|
269
|
+
sock.sendall(probe_string.encode("utf-8"))
|
|
270
|
+
|
|
271
|
+
# Wait for response if pattern expected
|
|
272
|
+
if expect_pattern:
|
|
273
|
+
sock.setblocking(False)
|
|
274
|
+
response = b""
|
|
275
|
+
deadline = time.time() + self.serial_timeout
|
|
276
|
+
|
|
277
|
+
while time.time() < deadline:
|
|
278
|
+
try:
|
|
279
|
+
ready, _, _ = select.select(
|
|
280
|
+
[sock], [], [], min(0.1, deadline - time.time())
|
|
281
|
+
)
|
|
282
|
+
if ready:
|
|
283
|
+
data = sock.recv(4096)
|
|
284
|
+
if not data:
|
|
285
|
+
break
|
|
286
|
+
response += data
|
|
287
|
+
|
|
288
|
+
# Check if pattern found
|
|
289
|
+
if expect_pattern in response.decode(
|
|
290
|
+
"utf-8", errors="replace"
|
|
291
|
+
):
|
|
292
|
+
sock.close()
|
|
293
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
294
|
+
msg = f"Probe OK, found '{expect_pattern}'"
|
|
295
|
+
return CheckResult(
|
|
296
|
+
sbc_name=sbc_name,
|
|
297
|
+
check_type=CheckType.SERIAL,
|
|
298
|
+
success=True,
|
|
299
|
+
message=msg,
|
|
300
|
+
duration_ms=duration_ms,
|
|
301
|
+
)
|
|
302
|
+
except BlockingIOError:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
# Pattern not found
|
|
306
|
+
sock.close()
|
|
307
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
308
|
+
preview = response[:100].decode("utf-8", errors="replace")
|
|
309
|
+
msg = f"Pattern '{expect_pattern}' not found. Got: {repr(preview)}"
|
|
310
|
+
return CheckResult(
|
|
311
|
+
sbc_name=sbc_name,
|
|
312
|
+
check_type=CheckType.SERIAL,
|
|
313
|
+
success=False,
|
|
314
|
+
message=msg,
|
|
315
|
+
duration_ms=duration_ms,
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
# No pattern expected, just check for any response
|
|
319
|
+
sock.setblocking(False)
|
|
320
|
+
response = b""
|
|
321
|
+
deadline = time.time() + self.serial_timeout
|
|
322
|
+
|
|
323
|
+
while time.time() < deadline:
|
|
324
|
+
try:
|
|
325
|
+
ready, _, _ = select.select(
|
|
326
|
+
[sock], [], [], min(0.1, deadline - time.time())
|
|
327
|
+
)
|
|
328
|
+
if ready:
|
|
329
|
+
data = sock.recv(4096)
|
|
330
|
+
if data:
|
|
331
|
+
response += data
|
|
332
|
+
break
|
|
333
|
+
except BlockingIOError:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
sock.close()
|
|
337
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
338
|
+
|
|
339
|
+
if response:
|
|
340
|
+
msg = f"Serial probe successful, received {len(response)} bytes"
|
|
341
|
+
return CheckResult(
|
|
342
|
+
sbc_name=sbc_name,
|
|
343
|
+
check_type=CheckType.SERIAL,
|
|
344
|
+
success=True,
|
|
345
|
+
message=msg,
|
|
346
|
+
duration_ms=duration_ms,
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
return CheckResult(
|
|
350
|
+
sbc_name=sbc_name,
|
|
351
|
+
check_type=CheckType.SERIAL,
|
|
352
|
+
success=True,
|
|
353
|
+
message="Serial probe sent, no response (may be normal)",
|
|
354
|
+
duration_ms=duration_ms,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
except socket.timeout:
|
|
358
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
359
|
+
return CheckResult(
|
|
360
|
+
sbc_name=sbc_name,
|
|
361
|
+
check_type=CheckType.SERIAL,
|
|
362
|
+
success=False,
|
|
363
|
+
message=f"Serial probe to {host}:{port} timed out",
|
|
364
|
+
duration_ms=duration_ms,
|
|
365
|
+
)
|
|
366
|
+
except ConnectionRefusedError:
|
|
367
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
368
|
+
return CheckResult(
|
|
369
|
+
sbc_name=sbc_name,
|
|
370
|
+
check_type=CheckType.SERIAL,
|
|
371
|
+
success=False,
|
|
372
|
+
message=f"Connection to {host}:{port} refused",
|
|
373
|
+
duration_ms=duration_ms,
|
|
374
|
+
)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
377
|
+
return CheckResult(
|
|
378
|
+
sbc_name=sbc_name,
|
|
379
|
+
check_type=CheckType.SERIAL,
|
|
380
|
+
success=False,
|
|
381
|
+
message=f"Serial probe error: {e}",
|
|
382
|
+
duration_ms=duration_ms,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def power_check(
|
|
386
|
+
self, controller: PowerController, sbc_name: str = ""
|
|
387
|
+
) -> tuple[CheckResult, PowerState]:
|
|
388
|
+
"""
|
|
389
|
+
Check power state of an SBC via its power controller.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
controller: Power controller instance
|
|
393
|
+
sbc_name: Name of SBC for result tracking
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Tuple of (CheckResult, PowerState)
|
|
397
|
+
"""
|
|
398
|
+
start_time = time.time()
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
state = controller.get_state()
|
|
402
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
403
|
+
|
|
404
|
+
if state == PowerState.UNKNOWN:
|
|
405
|
+
return (
|
|
406
|
+
CheckResult(
|
|
407
|
+
sbc_name=sbc_name,
|
|
408
|
+
check_type=CheckType.POWER,
|
|
409
|
+
success=False,
|
|
410
|
+
message="Power state unknown",
|
|
411
|
+
duration_ms=duration_ms,
|
|
412
|
+
),
|
|
413
|
+
state,
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
return (
|
|
417
|
+
CheckResult(
|
|
418
|
+
sbc_name=sbc_name,
|
|
419
|
+
check_type=CheckType.POWER,
|
|
420
|
+
success=True,
|
|
421
|
+
message=f"Power is {state.value.upper()}",
|
|
422
|
+
duration_ms=duration_ms,
|
|
423
|
+
),
|
|
424
|
+
state,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
except Exception as e:
|
|
428
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
429
|
+
return (
|
|
430
|
+
CheckResult(
|
|
431
|
+
sbc_name=sbc_name,
|
|
432
|
+
check_type=CheckType.POWER,
|
|
433
|
+
success=False,
|
|
434
|
+
message=f"Power check error: {e}",
|
|
435
|
+
duration_ms=duration_ms,
|
|
436
|
+
),
|
|
437
|
+
PowerState.UNKNOWN,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def check_sbc(
|
|
441
|
+
self,
|
|
442
|
+
sbc: SBC,
|
|
443
|
+
check_types: Optional[list[CheckType]] = None,
|
|
444
|
+
) -> HealthCheckSummary:
|
|
445
|
+
"""
|
|
446
|
+
Run health checks on a single SBC.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
sbc: SBC to check
|
|
450
|
+
check_types: List of check types to run (default: all applicable)
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
HealthCheckSummary with all check results
|
|
454
|
+
"""
|
|
455
|
+
if check_types is None:
|
|
456
|
+
check_types = [CheckType.PING, CheckType.SERIAL, CheckType.POWER]
|
|
457
|
+
|
|
458
|
+
summary = HealthCheckSummary(sbc_name=sbc.name)
|
|
459
|
+
|
|
460
|
+
# Ping check - requires IP address
|
|
461
|
+
if CheckType.PING in check_types and sbc.primary_ip:
|
|
462
|
+
summary.ping_result = self.ping_check(sbc.primary_ip, sbc.name)
|
|
463
|
+
|
|
464
|
+
# Serial check - requires console port with TCP
|
|
465
|
+
if CheckType.SERIAL in check_types and sbc.serial_ports:
|
|
466
|
+
console_port = next(
|
|
467
|
+
(p for p in sbc.serial_ports if p.port_type == PortType.CONSOLE),
|
|
468
|
+
None,
|
|
469
|
+
)
|
|
470
|
+
if console_port and console_port.tcp_port:
|
|
471
|
+
summary.serial_result = self.serial_check(
|
|
472
|
+
"localhost", console_port.tcp_port, sbc.name
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Power check - requires power plug
|
|
476
|
+
if CheckType.POWER in check_types and sbc.power_plug:
|
|
477
|
+
controller = PowerController.from_plug(sbc.power_plug)
|
|
478
|
+
result, state = self.power_check(controller, sbc.name)
|
|
479
|
+
summary.power_result = result
|
|
480
|
+
summary.power_state = state
|
|
481
|
+
|
|
482
|
+
# Determine recommended status
|
|
483
|
+
summary.recommended_status = summary.determine_status()
|
|
484
|
+
|
|
485
|
+
return summary
|
|
486
|
+
|
|
487
|
+
def check_all(
|
|
488
|
+
self,
|
|
489
|
+
sbcs: list[SBC],
|
|
490
|
+
check_types: Optional[list[CheckType]] = None,
|
|
491
|
+
max_workers: int = 16,
|
|
492
|
+
) -> dict[str, HealthCheckSummary]:
|
|
493
|
+
"""
|
|
494
|
+
Run health checks on multiple SBCs in parallel.
|
|
495
|
+
|
|
496
|
+
The probes are network-I/O bound (ICMP ping, TCP serial open,
|
|
497
|
+
plug HTTP/UDP) so a small thread pool gives near-linear speedup:
|
|
498
|
+
total wall time becomes the *max* probe latency rather than the
|
|
499
|
+
sum, which is what makes it safe to run more frequently.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
sbcs: List of SBCs to check
|
|
503
|
+
check_types: List of check types to run (default: all applicable)
|
|
504
|
+
max_workers: Cap on concurrent probes. The pool is sized to
|
|
505
|
+
``min(len(sbcs), max_workers)``.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Dictionary mapping SBC names to their check summaries
|
|
509
|
+
"""
|
|
510
|
+
if not sbcs:
|
|
511
|
+
return {}
|
|
512
|
+
|
|
513
|
+
import concurrent.futures
|
|
514
|
+
|
|
515
|
+
results: dict[str, HealthCheckSummary] = {}
|
|
516
|
+
workers = min(len(sbcs), max_workers)
|
|
517
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
518
|
+
futures = {
|
|
519
|
+
executor.submit(self.check_sbc, sbc, check_types): sbc.name
|
|
520
|
+
for sbc in sbcs
|
|
521
|
+
}
|
|
522
|
+
for future in concurrent.futures.as_completed(futures):
|
|
523
|
+
sbc_name = futures[future]
|
|
524
|
+
try:
|
|
525
|
+
results[sbc_name] = future.result()
|
|
526
|
+
except Exception: # noqa: BLE001
|
|
527
|
+
# One SBC's probe failing must not silently drop it
|
|
528
|
+
# from the result; record an empty summary so the
|
|
529
|
+
# daemon still sees the row and can flag UNKNOWN.
|
|
530
|
+
summary = HealthCheckSummary(sbc_name=sbc_name)
|
|
531
|
+
summary.recommended_status = summary.determine_status()
|
|
532
|
+
results[sbc_name] = summary
|
|
533
|
+
return results
|