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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Power a board off before its SD card is switched to the host, and
|
|
2
|
+
check that it is off before the card is taken from it."""
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
# Why a board isn't confirmed off (BoardOffCheck.problem).
|
|
9
|
+
NO_PLUG = "no_plug"
|
|
10
|
+
UNREADABLE = "unreadable"
|
|
11
|
+
POWERED_ON = "powered_on"
|
|
12
|
+
NOT_OFF = "not_off" # a state other than ON or OFF (e.g. unknown)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BoardOffCheck:
|
|
17
|
+
"""Result of :func:`check_board_off`: ``problem`` is None when the
|
|
18
|
+
plug reports the board OFF, else one of NO_PLUG, UNREADABLE (``error``
|
|
19
|
+
says why), POWERED_ON or NOT_OFF (``state`` says which)."""
|
|
20
|
+
|
|
21
|
+
problem: Optional[str] = None
|
|
22
|
+
state: object = None # a PowerState when the plug was read
|
|
23
|
+
error: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_off(self) -> bool:
|
|
27
|
+
return self.problem is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_board_off(sbc) -> BoardOffCheck:
|
|
31
|
+
"""Whether ``sbc``'s plug reports it OFF: the one check behind every
|
|
32
|
+
"proceed only when the board is off" refusal (``sdwire host`` and
|
|
33
|
+
MCP ``sdwire_to_host``/``sdwire_ls``/``cat``/``info``, and the CLI
|
|
34
|
+
``sdwire ls``/``cat``/``info``). Fails closed: no plug, a plug that
|
|
35
|
+
can't be read, or any state but OFF is a problem. Each caller words
|
|
36
|
+
its own refusal."""
|
|
37
|
+
if not sbc.power_plug:
|
|
38
|
+
return BoardOffCheck(NO_PLUG)
|
|
39
|
+
from labctl.power import PowerController
|
|
40
|
+
from labctl.power.base import PowerState
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
state = PowerController.from_plug(sbc.power_plug).get_state()
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return BoardOffCheck(UNREADABLE, error=str(e))
|
|
46
|
+
if state == PowerState.OFF:
|
|
47
|
+
return BoardOffCheck(state=state)
|
|
48
|
+
if state == PowerState.ON:
|
|
49
|
+
return BoardOffCheck(POWERED_ON, state=state)
|
|
50
|
+
return BoardOffCheck(NOT_OFF, state=state)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def board_off_problem_text(
|
|
54
|
+
sbc_name: str, check: BoardOffCheck, *, sentence_start: bool = False
|
|
55
|
+
) -> str:
|
|
56
|
+
"""One sentence (no final period) saying why ``check`` doesn't confirm
|
|
57
|
+
``sbc_name`` off; callers add their own framing and hints. Generic
|
|
58
|
+
leading words start lower-case unless ``sentence_start``; the board
|
|
59
|
+
name is never re-cased."""
|
|
60
|
+
|
|
61
|
+
def generic(text: str) -> str:
|
|
62
|
+
return text[0].upper() + text[1:] if sentence_start else text
|
|
63
|
+
|
|
64
|
+
if check.problem == NO_PLUG:
|
|
65
|
+
return f"{sbc_name} has no power plug, so labctl can't confirm it is off"
|
|
66
|
+
if check.problem == UNREADABLE:
|
|
67
|
+
return generic(f"could not read the power state of {sbc_name}: {check.error}")
|
|
68
|
+
if check.problem == POWERED_ON:
|
|
69
|
+
return f"{sbc_name} is powered on"
|
|
70
|
+
state = getattr(check.state, "value", check.state)
|
|
71
|
+
return generic(
|
|
72
|
+
f"the power state of {sbc_name} is {state}, so labctl can't confirm "
|
|
73
|
+
"it is off"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Second look before treating the card as on the board (see read_access).
|
|
78
|
+
READ_RECHECK_SECONDS = 1.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_access(sbc, ctrl, *, force: bool = False):
|
|
82
|
+
"""How a read of the SD card (CLI ``sdwire ls``/``cat``/``info``, the
|
|
83
|
+
MCP read tools) reaches it. Returns ``(needs_switch, refusal)``:
|
|
84
|
+
|
|
85
|
+
- Card already on the host (``ctrl.card_on_host()`` probes the reader
|
|
86
|
+
for media): read it in place and leave the mux alone; the board isn't
|
|
87
|
+
using the card, so its power doesn't matter. ``(False, None)``.
|
|
88
|
+
- Card on the board: switch to the host and back only when
|
|
89
|
+
:func:`check_board_off` confirms it off (or ``force``):
|
|
90
|
+
``(True, None)``; otherwise ``(False, check)`` with the failed
|
|
91
|
+
:class:`BoardOffCheck` for the caller to word.
|
|
92
|
+
|
|
93
|
+
A card switched to the host moments ago (by another process) may still
|
|
94
|
+
be initialising and probe as "no medium"; concluding "on the board"
|
|
95
|
+
would make the cleanup switch it there, so it is probed once more
|
|
96
|
+
after :data:`READ_RECHECK_SECONDS`. (The mux can't be read back.)
|
|
97
|
+
"""
|
|
98
|
+
if ctrl.card_on_host():
|
|
99
|
+
return False, None
|
|
100
|
+
time.sleep(READ_RECHECK_SECONDS)
|
|
101
|
+
if ctrl.card_on_host():
|
|
102
|
+
return False, None
|
|
103
|
+
if force:
|
|
104
|
+
return True, None
|
|
105
|
+
check = check_board_off(sbc)
|
|
106
|
+
if not check.is_off:
|
|
107
|
+
return False, check
|
|
108
|
+
return True, None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class NoPlugError(RuntimeError):
|
|
112
|
+
"""The board has no power plug, so it can't be switched off (or known
|
|
113
|
+
to be off) before its SD card is taken; callers add how to proceed."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def no_plug_refusal(sbc) -> str:
|
|
117
|
+
return (
|
|
118
|
+
f"{sbc.name!r} has no power plug, so labctl can't switch it off or "
|
|
119
|
+
"confirm it is off before switching its SD card to the host; nothing "
|
|
120
|
+
"was written to the card"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def power_off_before_host_switch(sbc, *, no_plug_ok: bool = False) -> None:
|
|
125
|
+
"""Switch ``sbc`` off before its SD card goes to the host.
|
|
126
|
+
|
|
127
|
+
Raises RuntimeError, before the card is touched, when the board can't
|
|
128
|
+
be switched off: moving the card away from a running board risks bus
|
|
129
|
+
contention and a corrupted card. A board with no power plug fails
|
|
130
|
+
closed too (:class:`NoPlugError`), unless ``no_plug_ok``: the caller
|
|
131
|
+
has made sure it is off. ``no_plug_ok`` never skips a power-off that a
|
|
132
|
+
plug makes possible, nor excuses one that fails.
|
|
133
|
+
"""
|
|
134
|
+
if not sbc.power_plug:
|
|
135
|
+
if no_plug_ok:
|
|
136
|
+
return
|
|
137
|
+
raise NoPlugError(no_plug_refusal(sbc))
|
|
138
|
+
from labctl.power import PowerController
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
ok = PowerController.from_plug(sbc.power_plug).power_off()
|
|
142
|
+
except Exception as e:
|
|
143
|
+
ok, why = False, f": {e}"
|
|
144
|
+
else:
|
|
145
|
+
why = ""
|
|
146
|
+
if not ok:
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
f"could not power off {sbc.name!r} before switching its SD card "
|
|
149
|
+
f"to the host{why}; nothing was written to the card"
|
|
150
|
+
)
|
|
151
|
+
time.sleep(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def deploy_to_card(manager, sbc, ctrl, write) -> None:
|
|
155
|
+
"""Power ``sbc`` off, switch its card to the host, run ``write()``, and
|
|
156
|
+
hand the card back to the board.
|
|
157
|
+
|
|
158
|
+
Raises RuntimeError for any failure. Once the board is off, the error
|
|
159
|
+
says it was left off, and whether the card is still on the host.
|
|
160
|
+
"""
|
|
161
|
+
from labctl.actuators.runtime import _left_off, boards_on_plug
|
|
162
|
+
|
|
163
|
+
# A missing SDWire must not cost a running board its power.
|
|
164
|
+
try:
|
|
165
|
+
ctrl.ensure_present()
|
|
166
|
+
except Exception as e:
|
|
167
|
+
raise RuntimeError(f"{e}; nothing changed") from e
|
|
168
|
+
power_off_before_host_switch(sbc)
|
|
169
|
+
try:
|
|
170
|
+
ctrl.switch_to_host()
|
|
171
|
+
time.sleep(2)
|
|
172
|
+
write()
|
|
173
|
+
except Exception as e:
|
|
174
|
+
try:
|
|
175
|
+
ctrl.switch_to_dut()
|
|
176
|
+
card = ""
|
|
177
|
+
except Exception:
|
|
178
|
+
card = " (its SD card may still be on the host)"
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f"deploy to {sbc.name!r} failed: {e}; "
|
|
181
|
+
f"{_left_off(sbc, boards_on_plug(manager, sbc))}{card}"
|
|
182
|
+
) from e
|
|
183
|
+
try:
|
|
184
|
+
ctrl.switch_to_dut()
|
|
185
|
+
except Exception as e:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"deployed to {sbc.name!r}, but its SD card could not be switched "
|
|
188
|
+
f"back to the board: {e}; the card is still on the host and "
|
|
189
|
+
f"{_left_off(sbc, boards_on_plug(manager, sbc))}"
|
|
190
|
+
) from e
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serial port management for lab controller.
|
|
3
|
+
|
|
4
|
+
Handles ser2net configuration generation, serial port discovery,
|
|
5
|
+
and multi-client serial proxying.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from labctl.serial.proxy import ProxyClient, ProxyManager, SerialProxy, SessionLogger
|
|
9
|
+
from labctl.serial.ser2net import Ser2NetPort, generate_ser2net_config
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"generate_ser2net_config",
|
|
13
|
+
"Ser2NetPort",
|
|
14
|
+
"SerialProxy",
|
|
15
|
+
"ProxyManager",
|
|
16
|
+
"ProxyClient",
|
|
17
|
+
"SessionLogger",
|
|
18
|
+
]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automated boot reliability testing.
|
|
3
|
+
|
|
4
|
+
Deploys a kernel image, reboots the SBC multiple times, and captures
|
|
5
|
+
boot output to determine success rate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from labctl.serial.capture import capture_serial_output
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_run_file(path: Path | str, text: str, dir_fd: Optional[int] = None) -> None:
|
|
21
|
+
"""Write one run's output without following a planted link.
|
|
22
|
+
|
|
23
|
+
The output directory may be shared (e.g. group-writable
|
|
24
|
+
/var/lib/labctl/output), and the content is console output the DUT
|
|
25
|
+
controls. A symlink planted at ``run_NN.txt`` would otherwise redirect
|
|
26
|
+
the write to any file the service user can write, so open with
|
|
27
|
+
O_NOFOLLOW (fails with ELOOP on a symlink), and refuse a file with
|
|
28
|
+
other hard links. Anything that isn't a regular file (e.g. a planted
|
|
29
|
+
FIFO, which would otherwise block the open forever while the caller
|
|
30
|
+
holds the hardware lock) is refused without blocking. With ``dir_fd``
|
|
31
|
+
(a checked directory handle) the file is created relative to it, so the
|
|
32
|
+
directory path itself can't be swapped either. Raises OSError on
|
|
33
|
+
refusal.
|
|
34
|
+
"""
|
|
35
|
+
from labctl.core.safe_open import open_regular_nonblocking
|
|
36
|
+
|
|
37
|
+
fd = open_regular_nonblocking(
|
|
38
|
+
str(path), os.O_WRONLY | os.O_CREAT, 0o644, dir_fd=dir_fd
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
if os.fstat(fd).st_nlink > 1:
|
|
42
|
+
raise OSError(f"refusing to write {path}: it has other hard links")
|
|
43
|
+
os.ftruncate(fd, 0)
|
|
44
|
+
with os.fdopen(fd, "w", closefd=False) as f:
|
|
45
|
+
f.write(text)
|
|
46
|
+
finally:
|
|
47
|
+
os.close(fd)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class BootRunResult:
|
|
52
|
+
"""Result of a single boot run."""
|
|
53
|
+
|
|
54
|
+
run_number: int
|
|
55
|
+
passed: bool
|
|
56
|
+
elapsed_seconds: float
|
|
57
|
+
pattern_matched: bool
|
|
58
|
+
output: str = ""
|
|
59
|
+
last_line: str = ""
|
|
60
|
+
error: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BootTestResult:
|
|
65
|
+
"""Result of a complete boot test."""
|
|
66
|
+
|
|
67
|
+
sbc_name: str
|
|
68
|
+
expect_pattern: str
|
|
69
|
+
total_runs: int
|
|
70
|
+
timeout_per_run: float
|
|
71
|
+
image: Optional[str] = None
|
|
72
|
+
dest: Optional[str] = None
|
|
73
|
+
partition: Optional[int] = None
|
|
74
|
+
runs: list[BootRunResult] = field(default_factory=list)
|
|
75
|
+
# Per-run output files that couldn't be written (name: reason); the boot
|
|
76
|
+
# results still stand, but the caller must be told the logs are missing.
|
|
77
|
+
output_errors: list[str] = field(default_factory=list)
|
|
78
|
+
# Why the test stopped before all its runs (e.g. the caller's claim
|
|
79
|
+
# on the board was lost), or None when every run was made.
|
|
80
|
+
stopped_reason: Optional[str] = None
|
|
81
|
+
# The deploy ran (it leaves the board powered off until the first run).
|
|
82
|
+
deployed: bool = False
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def passed_count(self) -> int:
|
|
86
|
+
return sum(1 for r in self.runs if r.passed)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def failed_count(self) -> int:
|
|
90
|
+
return sum(1 for r in self.runs if not r.passed)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def pass_rate(self) -> float:
|
|
94
|
+
if not self.runs:
|
|
95
|
+
return 0.0
|
|
96
|
+
return self.passed_count / len(self.runs) * 100
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def avg_boot_time(self) -> float:
|
|
100
|
+
passed = [r.elapsed_seconds for r in self.runs if r.passed]
|
|
101
|
+
if not passed:
|
|
102
|
+
return 0.0
|
|
103
|
+
return sum(passed) / len(passed)
|
|
104
|
+
|
|
105
|
+
def format_summary(self) -> str:
|
|
106
|
+
"""Format as human-readable summary."""
|
|
107
|
+
lines = [
|
|
108
|
+
f"Boot Test Results: {self.sbc_name}",
|
|
109
|
+
"=" * 40,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
if self.image:
|
|
113
|
+
lines.append(
|
|
114
|
+
f"Image: {self.image} -> {self.dest} (partition {self.partition})"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
lines.append("Image: (no deploy, testing current SD contents)")
|
|
118
|
+
|
|
119
|
+
lines.append(f"Pattern: '{self.expect_pattern}'")
|
|
120
|
+
lines.append(f"Timeout: {self.timeout_per_run:.0f}s per boot")
|
|
121
|
+
lines.append("")
|
|
122
|
+
|
|
123
|
+
for r in self.runs:
|
|
124
|
+
status = "PASS" if r.passed else "FAIL"
|
|
125
|
+
detail = "pattern matched" if r.pattern_matched else ""
|
|
126
|
+
if not r.passed:
|
|
127
|
+
if r.error:
|
|
128
|
+
detail = r.error
|
|
129
|
+
elif r.last_line:
|
|
130
|
+
detail = f'timeout, last output: "{r.last_line}"'
|
|
131
|
+
else:
|
|
132
|
+
detail = "timeout, no output"
|
|
133
|
+
lines.append(
|
|
134
|
+
f"Run {r.run_number:2d}/{self.total_runs}: "
|
|
135
|
+
f"{status} ({r.elapsed_seconds:.1f}s) - {detail}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if self.stopped_reason:
|
|
139
|
+
lines.append(
|
|
140
|
+
f"STOPPED after {len(self.runs)} of {self.total_runs} runs: "
|
|
141
|
+
f"{self.stopped_reason}"
|
|
142
|
+
)
|
|
143
|
+
if not self.runs and self.deployed:
|
|
144
|
+
lines.append(
|
|
145
|
+
f"{self.sbc_name} was powered off for the deploy and is "
|
|
146
|
+
"left off."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
lines.append("")
|
|
150
|
+
if self.stopped_reason and not self.runs:
|
|
151
|
+
lines.append("Result: STOPPED before the first run (no boots tested)")
|
|
152
|
+
elif self.stopped_reason:
|
|
153
|
+
lines.append(
|
|
154
|
+
f"Result: INCOMPLETE, {self.passed_count}/{len(self.runs)} "
|
|
155
|
+
f"boots successful ({self.pass_rate:.0f}%) in "
|
|
156
|
+
f"{len(self.runs)} of {self.total_runs} runs"
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
lines.append(
|
|
160
|
+
f"Result: {self.passed_count}/{len(self.runs)} boots successful "
|
|
161
|
+
f"({self.pass_rate:.0f}%)"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if self.passed_count > 0:
|
|
165
|
+
lines.append(f"Average boot time (successful): {self.avg_boot_time:.1f}s")
|
|
166
|
+
|
|
167
|
+
# Failure mode breakdown
|
|
168
|
+
if self.failed_count > 0:
|
|
169
|
+
timeout_partial = sum(
|
|
170
|
+
1 for r in self.runs if not r.passed and not r.error and r.last_line
|
|
171
|
+
)
|
|
172
|
+
timeout_none = sum(
|
|
173
|
+
1 for r in self.runs if not r.passed and not r.error and not r.last_line
|
|
174
|
+
)
|
|
175
|
+
errors = sum(1 for r in self.runs if not r.passed and r.error)
|
|
176
|
+
lines.append("Failure modes:")
|
|
177
|
+
if timeout_partial:
|
|
178
|
+
lines.append(f" - Timeout with partial output: {timeout_partial}")
|
|
179
|
+
if timeout_none:
|
|
180
|
+
lines.append(f" - Timeout with no output: {timeout_none}")
|
|
181
|
+
if errors:
|
|
182
|
+
lines.append(f" - Errors: {errors}")
|
|
183
|
+
|
|
184
|
+
if self.output_errors:
|
|
185
|
+
lines.append("")
|
|
186
|
+
lines.append(
|
|
187
|
+
f"WARNING: {len(self.output_errors)} run output file(s) "
|
|
188
|
+
"were NOT saved:"
|
|
189
|
+
)
|
|
190
|
+
lines.extend(f" - {err}" for err in self.output_errors)
|
|
191
|
+
|
|
192
|
+
return "\n".join(lines)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def run_boot_test(
|
|
196
|
+
sbc_name: str,
|
|
197
|
+
expect_pattern: str,
|
|
198
|
+
tcp_host: str,
|
|
199
|
+
tcp_port: int,
|
|
200
|
+
power_cycle_fn,
|
|
201
|
+
runs: int = 10,
|
|
202
|
+
timeout: float = 30.0,
|
|
203
|
+
deploy_fn=None,
|
|
204
|
+
image: str | None = None,
|
|
205
|
+
dest: str | None = None,
|
|
206
|
+
partition: int = 1,
|
|
207
|
+
output_dir: str | None = None,
|
|
208
|
+
output_dir_fd: Optional[int] = None,
|
|
209
|
+
progress_fn=None,
|
|
210
|
+
before_run_fn=None,
|
|
211
|
+
) -> BootTestResult:
|
|
212
|
+
"""Run automated boot reliability test.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
sbc_name: Name of the SBC being tested
|
|
216
|
+
expect_pattern: Regex to match for "success"
|
|
217
|
+
tcp_host: Host where ser2net is running
|
|
218
|
+
tcp_port: TCP port for serial console
|
|
219
|
+
power_cycle_fn: Callable that power cycles the SBC (off, wait, on)
|
|
220
|
+
runs: Number of boot cycles
|
|
221
|
+
timeout: Seconds to wait per boot for pattern match
|
|
222
|
+
deploy_fn: Optional callable to deploy image before testing
|
|
223
|
+
image: Image filename (for reporting)
|
|
224
|
+
dest: Destination filename (for reporting)
|
|
225
|
+
partition: Partition number (for reporting)
|
|
226
|
+
output_dir: Save per-run output to files here
|
|
227
|
+
output_dir_fd: Optional open handle for ``output_dir`` (already
|
|
228
|
+
checked and created by the caller); files are created
|
|
229
|
+
relative to it and ``output_dir`` is only used for messages
|
|
230
|
+
progress_fn: Optional callback(run_number, total, result) for progress
|
|
231
|
+
before_run_fn: Optional callable run before each run's power cycle;
|
|
232
|
+
it returns None to go on, or a reason to stop the test there
|
|
233
|
+
(recorded as ``stopped_reason``). Used to stop when the
|
|
234
|
+
caller's claim on the board is lost mid-test.
|
|
235
|
+
"""
|
|
236
|
+
result = BootTestResult(
|
|
237
|
+
sbc_name=sbc_name,
|
|
238
|
+
expect_pattern=expect_pattern,
|
|
239
|
+
total_runs=runs,
|
|
240
|
+
timeout_per_run=timeout,
|
|
241
|
+
image=image,
|
|
242
|
+
dest=dest,
|
|
243
|
+
partition=partition,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Create output dir if needed (a caller-provided handle already exists).
|
|
247
|
+
# Before the deploy, which powers the board off: a failure here must
|
|
248
|
+
# leave the board as it was.
|
|
249
|
+
if output_dir and output_dir_fd is None:
|
|
250
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
251
|
+
|
|
252
|
+
# Deploy if requested
|
|
253
|
+
if deploy_fn:
|
|
254
|
+
deploy_fn()
|
|
255
|
+
result.deployed = True
|
|
256
|
+
|
|
257
|
+
for i in range(1, runs + 1):
|
|
258
|
+
if before_run_fn:
|
|
259
|
+
reason = before_run_fn()
|
|
260
|
+
if reason:
|
|
261
|
+
logger.warning("Boot test of %s stopped: %s", sbc_name, reason)
|
|
262
|
+
result.stopped_reason = reason
|
|
263
|
+
break
|
|
264
|
+
|
|
265
|
+
run_result = _run_single_boot(
|
|
266
|
+
run_number=i,
|
|
267
|
+
tcp_host=tcp_host,
|
|
268
|
+
tcp_port=tcp_port,
|
|
269
|
+
power_cycle_fn=power_cycle_fn,
|
|
270
|
+
expect_pattern=expect_pattern,
|
|
271
|
+
timeout=timeout,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
result.runs.append(run_result)
|
|
275
|
+
|
|
276
|
+
# Save per-run output
|
|
277
|
+
if output_dir and run_result.output:
|
|
278
|
+
name = f"run_{i:02d}.txt"
|
|
279
|
+
run_file = Path(output_dir) / name
|
|
280
|
+
try:
|
|
281
|
+
if output_dir_fd is not None:
|
|
282
|
+
_write_run_file(name, run_result.output, dir_fd=output_dir_fd)
|
|
283
|
+
else:
|
|
284
|
+
_write_run_file(run_file, run_result.output)
|
|
285
|
+
except OSError as e:
|
|
286
|
+
logger.warning("Not writing %s: %s", run_file, e)
|
|
287
|
+
result.output_errors.append(f"{name}: {e.strerror or e}")
|
|
288
|
+
|
|
289
|
+
if progress_fn:
|
|
290
|
+
progress_fn(i, runs, run_result)
|
|
291
|
+
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _run_single_boot(
|
|
296
|
+
run_number: int,
|
|
297
|
+
tcp_host: str,
|
|
298
|
+
tcp_port: int,
|
|
299
|
+
power_cycle_fn,
|
|
300
|
+
expect_pattern: str,
|
|
301
|
+
timeout: float,
|
|
302
|
+
) -> BootRunResult:
|
|
303
|
+
"""Execute a single boot cycle and capture output."""
|
|
304
|
+
try:
|
|
305
|
+
# Power cycle: off, wait, on
|
|
306
|
+
power_cycle_fn()
|
|
307
|
+
|
|
308
|
+
# Small delay for power to settle before capturing
|
|
309
|
+
time.sleep(1)
|
|
310
|
+
|
|
311
|
+
# Capture serial output
|
|
312
|
+
capture = capture_serial_output(
|
|
313
|
+
tcp_host=tcp_host,
|
|
314
|
+
tcp_port=tcp_port,
|
|
315
|
+
timeout=timeout,
|
|
316
|
+
until_pattern=expect_pattern,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Extract last non-empty line for failure reporting
|
|
320
|
+
last_line = ""
|
|
321
|
+
if capture.output:
|
|
322
|
+
output_lines = capture.output.strip().splitlines()
|
|
323
|
+
if output_lines:
|
|
324
|
+
last_line = output_lines[-1][:80] # Truncate
|
|
325
|
+
|
|
326
|
+
return BootRunResult(
|
|
327
|
+
run_number=run_number,
|
|
328
|
+
passed=capture.pattern_matched,
|
|
329
|
+
elapsed_seconds=capture.elapsed_seconds,
|
|
330
|
+
pattern_matched=capture.pattern_matched,
|
|
331
|
+
output=capture.output,
|
|
332
|
+
last_line=last_line,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
except RuntimeError as e:
|
|
336
|
+
return BootRunResult(
|
|
337
|
+
run_number=run_number,
|
|
338
|
+
passed=False,
|
|
339
|
+
elapsed_seconds=0.0,
|
|
340
|
+
pattern_matched=False,
|
|
341
|
+
error=str(e),
|
|
342
|
+
)
|