Skip to content

Commit 66e783c

Browse files
authored
Merge pull request #3 from Aharoni-Lab/fix/hil-ci-issues
Fix 5 HIL CI issues found during real-world bench setup
2 parents 7a586bb + 3594f3b commit 66e783c

9 files changed

Lines changed: 297 additions & 30 deletions

File tree

bootstrap/update.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Lightweight idempotent update script.
3+
# Run after `git pull` to apply code + config changes without re-registering
4+
# the runner or touching /etc/hil-bench/config.yaml.
5+
#
6+
# Usage: sudo ./bootstrap/update.sh [config-path]
7+
set -euo pipefail
8+
9+
CONFIG_PATH="${1:-/etc/hil-bench/config.yaml}"
10+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11+
REPO_DIR="$(dirname "$SCRIPT_DIR")"
12+
VENV="/opt/hil-bench/venv"
13+
RUNNER_DIR="/opt/hil-bench/actions-runner"
14+
15+
echo "=== HIL Bench Update ==="
16+
echo "Repo: ${REPO_DIR}"
17+
echo "Config: ${CONFIG_PATH}"
18+
echo ""
19+
20+
# ── 1. Reinstall benchctl into venv ──────────────────────────────────────
21+
22+
if [[ -d "$VENV" ]]; then
23+
echo "--- Updating benchctl in venv ---"
24+
"${VENV}/bin/pip" install --quiet -e "$REPO_DIR"
25+
echo "benchctl updated"
26+
else
27+
echo "WARNING: venv not found at ${VENV} — run install_python_env.sh first"
28+
fi
29+
30+
# ── 2. Ensure work directory exists ──────────────────────────────────────
31+
32+
mkdir -p /opt/hil-bench/_work
33+
echo "Work directory: /opt/hil-bench/_work"
34+
35+
# ── 3. Refresh runner .env (if runner is installed) ──────────────────────
36+
37+
if [[ -d "$RUNNER_DIR" ]]; then
38+
echo "--- Refreshing runner .env ---"
39+
"${SCRIPT_DIR}/write_runner_env.sh" "$RUNNER_DIR" "$VENV" "$CONFIG_PATH"
40+
else
41+
echo "Runner not installed — skipping .env"
42+
fi
43+
44+
# ── 4. Refresh systemd health timer ─────────────────────────────────────
45+
46+
echo "--- Refreshing health timer ---"
47+
"${SCRIPT_DIR}/install_health_timer.sh" "$REPO_DIR"
48+
49+
# ── 5. Done ──────────────────────────────────────────────────────────────
50+
51+
echo ""
52+
echo "=== Update complete ==="
53+
if [[ -d "$RUNNER_DIR" ]]; then
54+
echo "REMINDER: Restart the runner service to pick up changes:"
55+
echo " sudo systemctl restart actions.runner.*.service"
56+
fi

bootstrap/write_runner_env.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
# Write the runner .env file so jobs see the venv on PATH.
3+
# Usage: write_runner_env.sh <runner-dir> <venv> <config-path>
4+
set -euo pipefail
5+
6+
RUNNER_DIR="${1:?Usage: $0 <runner-dir> <venv> <config-path>}"
7+
VENV="${2:?Usage: $0 <runner-dir> <venv> <config-path>}"
8+
CONFIG_PATH="${3:?Usage: $0 <runner-dir> <venv> <config-path>}"
9+
10+
cat > "$RUNNER_DIR/.env" <<ENVEOF
11+
PATH=${VENV}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
12+
VIRTUAL_ENV=${VENV}
13+
HIL_BENCH_CONFIG=${CONFIG_PATH}
14+
ENVEOF
15+
echo "Wrote ${RUNNER_DIR}/.env"

examples/firmware-ci.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ on:
88
branches: [main]
99
pull_request:
1010

11-
# Only one HIL test per bench at a time
11+
# Only one HIL test per bench at a time.
12+
# Use the bench_name from your config as the concurrency group to ensure
13+
# multiple repos sharing a bench don't run simultaneously.
1214
concurrency:
13-
group: hil-samd51-bench01
15+
group: hil-${{ github.repository }}-${{ github.ref }}
1416
cancel-in-progress: false
1517

1618
jobs:
@@ -33,14 +35,17 @@ jobs:
3335

3436
hil-test:
3537
needs: build
36-
# Target a specific bench by its runner labels
37-
runs-on: [self-hosted, linux, ARM64, hil, samd51, bench01]
38+
# Use your bench_name as a label — install_runner.sh adds it automatically
39+
runs-on: [self-hosted, hil, my-bench-01]
3840
timeout-minutes: 10
3941
steps:
4042
- uses: actions/download-artifact@v4
4143
with:
4244
name: firmware
4345

46+
- name: Bench health check
47+
run: benchctl health --check config --check probe
48+
4449
- name: Flash firmware
4550
run: |
4651
benchctl flash \

src/hilbench/artifacts.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,49 @@ def resolve_firmware_path(
1616
firmware: str | Path,
1717
workspace: Path = RUNNER_WORK_DIR,
1818
) -> Path:
19-
"""Resolve a firmware path — absolute or relative to runner workspace.
19+
"""Resolve a firmware path — absolute, relative to CWD, or relative to workspace.
2020
21-
Accepts:
22-
- Absolute path: returned as-is if it exists
23-
- Relative path: resolved against the runner workspace directory
24-
- Glob pattern: if exactly one match, return it
21+
Resolution order:
22+
1. Absolute path: returned as-is
23+
2. Relative to CWD: if it exists
24+
3. Relative to runner workspace: if it exists
25+
4. Glob against CWD, then workspace: if exactly one match
2526
"""
2627
path = Path(firmware)
2728

2829
# Absolute path — trust the caller to handle missing files
2930
if path.is_absolute():
3031
return path
3132

32-
# Relative to workspace — check direct match first, then try glob
33-
resolved = workspace / path
34-
if resolved.exists():
35-
return resolved
33+
cwd = Path.cwd()
3634

37-
# Try glob
35+
# Relative to CWD first
36+
cwd_resolved = cwd / path
37+
if cwd_resolved.exists():
38+
return cwd_resolved
39+
40+
# Relative to workspace
41+
ws_resolved = workspace / path
42+
if ws_resolved.exists():
43+
return ws_resolved
44+
45+
# Try glob — CWD first, then workspace
3846
pattern = str(firmware)
39-
matches = sorted(workspace.glob(pattern))
40-
if len(matches) == 1:
41-
return matches[0]
42-
if len(matches) > 1:
43-
raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(matches)} files")
4447

45-
raise ArtifactError(f"firmware {firmware!r} not found in workspace {workspace}")
48+
cwd_matches = sorted(cwd.glob(pattern))
49+
if len(cwd_matches) == 1:
50+
return cwd_matches[0]
51+
if len(cwd_matches) > 1:
52+
msg = f"ambiguous firmware path {firmware!r}: matched {len(cwd_matches)} files in CWD"
53+
raise ArtifactError(msg)
54+
55+
ws_matches = sorted(workspace.glob(pattern))
56+
if len(ws_matches) == 1:
57+
return ws_matches[0]
58+
if len(ws_matches) > 1:
59+
msg = f"ambiguous firmware path {firmware!r}: matched {len(ws_matches)} files in workspace"
60+
raise ArtifactError(msg)
61+
62+
raise ArtifactError(
63+
f"firmware {firmware!r} not found in CWD ({cwd}) or workspace ({workspace})"
64+
)

src/hilbench/cli/health_cmd.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,29 @@
88
from rich.console import Console
99
from rich.table import Table
1010

11-
from hilbench.health import results_to_dicts, run_all_checks
11+
from hilbench.health import CHECK_CATEGORIES, results_to_dicts, run_checks
1212

1313

1414
@click.command()
1515
@click.option("--json-output", "--json", "as_json", is_flag=True, help="Output as JSON.")
16+
@click.option(
17+
"--check",
18+
"-c",
19+
"checks",
20+
multiple=True,
21+
type=click.Choice(CHECK_CATEGORIES, case_sensitive=False),
22+
help="Run only specific checks (repeatable). Default: all.",
23+
)
1624
@click.pass_obj
17-
def health(ctx: object, as_json: bool) -> None:
25+
def health(ctx: object, as_json: bool, checks: tuple[str, ...]) -> None:
1826
"""Run health checks on the bench."""
1927
from hilbench.cli.main import Context
2028

2129
assert isinstance(ctx, Context)
2230
console = Console()
2331
cfg = ctx.config
24-
results = run_all_checks(cfg)
32+
categories = list(checks) if checks else None
33+
results = run_checks(cfg, categories=categories)
2534

2635
try:
2736
from hilbench.publisher import on_health_complete

src/hilbench/health.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from hilbench.probe import probe_factory
1212

1313
if TYPE_CHECKING:
14+
from collections.abc import Callable
15+
1416
from hilbench.config import BenchConfig
1517

1618
logger = logging.getLogger(__name__)
@@ -96,12 +98,30 @@ def check_runner_service() -> CheckResult:
9698
)
9799

98100

99-
def run_all_checks(config: BenchConfig) -> list[CheckResult]:
100-
"""Run all health checks and return results."""
101+
_CHECK_RUNNERS: dict[str, Callable[[BenchConfig], list[CheckResult]]] = {
102+
"config": lambda cfg: [check_config(cfg)],
103+
"probe": check_probe,
104+
"serial": check_serial,
105+
"gpio_chip": lambda cfg: [check_gpio_chip()],
106+
"runner_service": lambda cfg: [check_runner_service()],
107+
}
108+
109+
CHECK_CATEGORIES: list[str] = list(_CHECK_RUNNERS.keys())
110+
111+
112+
def run_checks(
113+
config: BenchConfig,
114+
categories: list[str] | None = None,
115+
) -> list[CheckResult]:
116+
"""Run health checks, optionally filtered to specific categories."""
117+
selected = categories if categories else CHECK_CATEGORIES
101118
results: list[CheckResult] = []
102-
results.append(check_config(config))
103-
results.extend(check_probe(config))
104-
results.extend(check_serial(config))
105-
results.append(check_gpio_chip())
106-
results.append(check_runner_service())
119+
for cat in selected:
120+
runner = _CHECK_RUNNERS[cat]
121+
results.extend(runner(config))
107122
return results
123+
124+
125+
def run_all_checks(config: BenchConfig) -> list[CheckResult]:
126+
"""Run all health checks and return results."""
127+
return run_checks(config)

tests/test_artifacts.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Tests for firmware artifact path resolution."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
9+
if TYPE_CHECKING:
10+
from pathlib import Path
11+
12+
from hilbench.artifacts import resolve_firmware_path
13+
from hilbench.exceptions import ArtifactError
14+
15+
16+
class TestResolveFirmwarePath:
17+
def test_absolute_path_returned_as_is(self, tmp_path: Path) -> None:
18+
fw = tmp_path / "firmware.elf"
19+
fw.write_bytes(b"\x00")
20+
result = resolve_firmware_path(str(fw), workspace=tmp_path / "ws")
21+
assert result == fw
22+
23+
def test_cwd_takes_priority_over_workspace(
24+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
25+
) -> None:
26+
"""A file in CWD is found before the workspace is checked."""
27+
cwd_dir = tmp_path / "cwd"
28+
ws_dir = tmp_path / "ws"
29+
cwd_dir.mkdir()
30+
ws_dir.mkdir()
31+
32+
cwd_fw = cwd_dir / "test.bin"
33+
cwd_fw.write_bytes(b"\x01")
34+
ws_fw = ws_dir / "test.bin"
35+
ws_fw.write_bytes(b"\x02")
36+
37+
monkeypatch.chdir(cwd_dir)
38+
result = resolve_firmware_path("test.bin", workspace=ws_dir)
39+
assert result == cwd_fw
40+
41+
def test_workspace_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
42+
"""Falls back to workspace when CWD doesn't contain the file."""
43+
cwd_dir = tmp_path / "cwd"
44+
ws_dir = tmp_path / "ws"
45+
cwd_dir.mkdir()
46+
ws_dir.mkdir()
47+
48+
ws_fw = ws_dir / "test.bin"
49+
ws_fw.write_bytes(b"\x02")
50+
51+
monkeypatch.chdir(cwd_dir)
52+
result = resolve_firmware_path("test.bin", workspace=ws_dir)
53+
assert result == ws_fw
54+
55+
def test_glob_cwd_first(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
56+
"""Glob matches CWD before workspace."""
57+
cwd_dir = tmp_path / "cwd"
58+
ws_dir = tmp_path / "ws"
59+
cwd_dir.mkdir()
60+
ws_dir.mkdir()
61+
62+
cwd_fw = cwd_dir / "firmware.elf"
63+
cwd_fw.write_bytes(b"\x01")
64+
65+
monkeypatch.chdir(cwd_dir)
66+
result = resolve_firmware_path("*.elf", workspace=ws_dir)
67+
assert result == cwd_fw
68+
69+
def test_glob_workspace_fallback(
70+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
71+
) -> None:
72+
"""Glob falls back to workspace when no CWD matches."""
73+
cwd_dir = tmp_path / "cwd"
74+
ws_dir = tmp_path / "ws"
75+
cwd_dir.mkdir()
76+
ws_dir.mkdir()
77+
78+
ws_fw = ws_dir / "firmware.elf"
79+
ws_fw.write_bytes(b"\x02")
80+
81+
monkeypatch.chdir(cwd_dir)
82+
result = resolve_firmware_path("*.elf", workspace=ws_dir)
83+
assert result == ws_fw
84+
85+
def test_not_found_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
86+
cwd_dir = tmp_path / "cwd"
87+
ws_dir = tmp_path / "ws"
88+
cwd_dir.mkdir()
89+
ws_dir.mkdir()
90+
monkeypatch.chdir(cwd_dir)
91+
92+
with pytest.raises(ArtifactError, match="not found"):
93+
resolve_firmware_path("nope.bin", workspace=ws_dir)
94+
95+
def test_ambiguous_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
96+
cwd_dir = tmp_path / "cwd"
97+
cwd_dir.mkdir()
98+
(cwd_dir / "a.elf").write_bytes(b"\x01")
99+
(cwd_dir / "b.elf").write_bytes(b"\x02")
100+
monkeypatch.chdir(cwd_dir)
101+
102+
with pytest.raises(ArtifactError, match="ambiguous"):
103+
resolve_firmware_path("*.elf", workspace=tmp_path / "ws")

tests/test_cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ def test_health_no_config(self) -> None:
115115
# Should fail — no config at default path
116116
assert result.exit_code != 0
117117

118+
def test_health_check_config_only(self, sample_config_path: Path) -> None:
119+
runner = CliRunner()
120+
result = runner.invoke(
121+
cli, ["--config", str(sample_config_path), "health", "--check", "config"]
122+
)
123+
assert result.exit_code == 0
124+
assert "config" in result.output
125+
118126
def test_publish_config_no_env(self) -> None:
119127
runner = CliRunner()
120128
result = runner.invoke(cli, ["publish", "config"])

0 commit comments

Comments
 (0)