Skip to content
Merged
56 changes: 56 additions & 0 deletions bootstrap/update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Lightweight idempotent update script.
# Run after `git pull` to apply code + config changes without re-registering
# the runner or touching /etc/hil-bench/config.yaml.
#
# Usage: sudo ./bootstrap/update.sh [config-path]
set -euo pipefail

CONFIG_PATH="${1:-/etc/hil-bench/config.yaml}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
VENV="/opt/hil-bench/venv"
RUNNER_DIR="/opt/hil-bench/actions-runner"

echo "=== HIL Bench Update ==="
echo "Repo: ${REPO_DIR}"
echo "Config: ${CONFIG_PATH}"
echo ""

# ── 1. Reinstall benchctl into venv ──────────────────────────────────────

if [[ -d "$VENV" ]]; then
echo "--- Updating benchctl in venv ---"
"${VENV}/bin/pip" install --quiet -e "$REPO_DIR"
echo "benchctl updated"
else
echo "WARNING: venv not found at ${VENV} — run install_python_env.sh first"
fi

# ── 2. Ensure work directory exists ──────────────────────────────────────

mkdir -p /opt/hil-bench/_work
echo "Work directory: /opt/hil-bench/_work"

# ── 3. Refresh runner .env (if runner is installed) ──────────────────────

if [[ -d "$RUNNER_DIR" ]]; then
echo "--- Refreshing runner .env ---"
"${SCRIPT_DIR}/write_runner_env.sh" "$RUNNER_DIR" "$VENV" "$CONFIG_PATH"
else
echo "Runner not installed — skipping .env"
fi

# ── 4. Refresh systemd health timer ─────────────────────────────────────

echo "--- Refreshing health timer ---"
"${SCRIPT_DIR}/install_health_timer.sh" "$REPO_DIR"

# ── 5. Done ──────────────────────────────────────────────────────────────

echo ""
echo "=== Update complete ==="
if [[ -d "$RUNNER_DIR" ]]; then
echo "REMINDER: Restart the runner service to pick up changes:"
echo " sudo systemctl restart actions.runner.*.service"
fi
15 changes: 15 additions & 0 deletions bootstrap/write_runner_env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Write the runner .env file so jobs see the venv on PATH.
# Usage: write_runner_env.sh <runner-dir> <venv> <config-path>
set -euo pipefail

RUNNER_DIR="${1:?Usage: $0 <runner-dir> <venv> <config-path>}"
VENV="${2:?Usage: $0 <runner-dir> <venv> <config-path>}"
CONFIG_PATH="${3:?Usage: $0 <runner-dir> <venv> <config-path>}"

cat > "$RUNNER_DIR/.env" <<ENVEOF
PATH=${VENV}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
VIRTUAL_ENV=${VENV}
HIL_BENCH_CONFIG=${CONFIG_PATH}
ENVEOF
echo "Wrote ${RUNNER_DIR}/.env"
13 changes: 9 additions & 4 deletions examples/firmware-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ on:
branches: [main]
pull_request:

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

jobs:
Expand All @@ -33,14 +35,17 @@ jobs:

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

- name: Bench health check
run: benchctl health --check config --check probe

- name: Flash firmware
run: |
benchctl flash \
Expand Down
51 changes: 35 additions & 16 deletions src/hilbench/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,49 @@ def resolve_firmware_path(
firmware: str | Path,
workspace: Path = RUNNER_WORK_DIR,
) -> Path:
"""Resolve a firmware path — absolute or relative to runner workspace.
"""Resolve a firmware path — absolute, relative to CWD, or relative to workspace.

Accepts:
- Absolute path: returned as-is if it exists
- Relative path: resolved against the runner workspace directory
- Glob pattern: if exactly one match, return it
Resolution order:
1. Absolute path: returned as-is
2. Relative to CWD: if it exists
3. Relative to runner workspace: if it exists
4. Glob against CWD, then workspace: if exactly one match
"""
path = Path(firmware)

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

# Relative to workspace — check direct match first, then try glob
resolved = workspace / path
if resolved.exists():
return resolved
cwd = Path.cwd()

# Try glob
# Relative to CWD first
cwd_resolved = cwd / path
if cwd_resolved.exists():
return cwd_resolved

# Relative to workspace
ws_resolved = workspace / path
if ws_resolved.exists():
return ws_resolved

# Try glob — CWD first, then workspace
pattern = str(firmware)
matches = sorted(workspace.glob(pattern))
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(matches)} files")

raise ArtifactError(f"firmware {firmware!r} not found in workspace {workspace}")
cwd_matches = sorted(cwd.glob(pattern))
if len(cwd_matches) == 1:
return cwd_matches[0]
if len(cwd_matches) > 1:
msg = f"ambiguous firmware path {firmware!r}: matched {len(cwd_matches)} files in CWD"
raise ArtifactError(msg)

ws_matches = sorted(workspace.glob(pattern))
if len(ws_matches) == 1:
return ws_matches[0]
if len(ws_matches) > 1:
msg = f"ambiguous firmware path {firmware!r}: matched {len(ws_matches)} files in workspace"
raise ArtifactError(msg)

raise ArtifactError(
f"firmware {firmware!r} not found in CWD ({cwd}) or workspace ({workspace})"
)
15 changes: 12 additions & 3 deletions src/hilbench/cli/health_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,29 @@
from rich.console import Console
from rich.table import Table

from hilbench.health import results_to_dicts, run_all_checks
from hilbench.health import CHECK_CATEGORIES, results_to_dicts, run_checks


@click.command()
@click.option("--json-output", "--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option(
"--check",
"-c",
"checks",
multiple=True,
type=click.Choice(CHECK_CATEGORIES, case_sensitive=False),
help="Run only specific checks (repeatable). Default: all.",
)
@click.pass_obj
def health(ctx: object, as_json: bool) -> None:
def health(ctx: object, as_json: bool, checks: tuple[str, ...]) -> None:
"""Run health checks on the bench."""
from hilbench.cli.main import Context

assert isinstance(ctx, Context)
console = Console()
cfg = ctx.config
results = run_all_checks(cfg)
categories = list(checks) if checks else None
results = run_checks(cfg, categories=categories)

try:
from hilbench.publisher import on_health_complete
Expand Down
34 changes: 27 additions & 7 deletions src/hilbench/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from hilbench.probe import probe_factory

if TYPE_CHECKING:
from collections.abc import Callable

from hilbench.config import BenchConfig

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -96,12 +98,30 @@ def check_runner_service() -> CheckResult:
)


def run_all_checks(config: BenchConfig) -> list[CheckResult]:
"""Run all health checks and return results."""
_CHECK_RUNNERS: dict[str, Callable[[BenchConfig], list[CheckResult]]] = {
"config": lambda cfg: [check_config(cfg)],
"probe": check_probe,
"serial": check_serial,
"gpio_chip": lambda cfg: [check_gpio_chip()],
"runner_service": lambda cfg: [check_runner_service()],
}

CHECK_CATEGORIES: list[str] = list(_CHECK_RUNNERS.keys())


def run_checks(
config: BenchConfig,
categories: list[str] | None = None,
) -> list[CheckResult]:
"""Run health checks, optionally filtered to specific categories."""
selected = categories if categories else CHECK_CATEGORIES
results: list[CheckResult] = []
results.append(check_config(config))
results.extend(check_probe(config))
results.extend(check_serial(config))
results.append(check_gpio_chip())
results.append(check_runner_service())
for cat in selected:
runner = _CHECK_RUNNERS[cat]
results.extend(runner(config))
return results


def run_all_checks(config: BenchConfig) -> list[CheckResult]:
"""Run all health checks and return results."""
return run_checks(config)
103 changes: 103 additions & 0 deletions tests/test_artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for firmware artifact path resolution."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from pathlib import Path

from hilbench.artifacts import resolve_firmware_path
from hilbench.exceptions import ArtifactError


class TestResolveFirmwarePath:
def test_absolute_path_returned_as_is(self, tmp_path: Path) -> None:
fw = tmp_path / "firmware.elf"
fw.write_bytes(b"\x00")
result = resolve_firmware_path(str(fw), workspace=tmp_path / "ws")
assert result == fw

def test_cwd_takes_priority_over_workspace(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A file in CWD is found before the workspace is checked."""
cwd_dir = tmp_path / "cwd"
ws_dir = tmp_path / "ws"
cwd_dir.mkdir()
ws_dir.mkdir()

cwd_fw = cwd_dir / "test.bin"
cwd_fw.write_bytes(b"\x01")
ws_fw = ws_dir / "test.bin"
ws_fw.write_bytes(b"\x02")

monkeypatch.chdir(cwd_dir)
result = resolve_firmware_path("test.bin", workspace=ws_dir)
assert result == cwd_fw

def test_workspace_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Falls back to workspace when CWD doesn't contain the file."""
cwd_dir = tmp_path / "cwd"
ws_dir = tmp_path / "ws"
cwd_dir.mkdir()
ws_dir.mkdir()

ws_fw = ws_dir / "test.bin"
ws_fw.write_bytes(b"\x02")

monkeypatch.chdir(cwd_dir)
result = resolve_firmware_path("test.bin", workspace=ws_dir)
assert result == ws_fw

def test_glob_cwd_first(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Glob matches CWD before workspace."""
cwd_dir = tmp_path / "cwd"
ws_dir = tmp_path / "ws"
cwd_dir.mkdir()
ws_dir.mkdir()

cwd_fw = cwd_dir / "firmware.elf"
cwd_fw.write_bytes(b"\x01")

monkeypatch.chdir(cwd_dir)
result = resolve_firmware_path("*.elf", workspace=ws_dir)
assert result == cwd_fw

def test_glob_workspace_fallback(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Glob falls back to workspace when no CWD matches."""
cwd_dir = tmp_path / "cwd"
ws_dir = tmp_path / "ws"
cwd_dir.mkdir()
ws_dir.mkdir()

ws_fw = ws_dir / "firmware.elf"
ws_fw.write_bytes(b"\x02")

monkeypatch.chdir(cwd_dir)
result = resolve_firmware_path("*.elf", workspace=ws_dir)
assert result == ws_fw

def test_not_found_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cwd_dir = tmp_path / "cwd"
ws_dir = tmp_path / "ws"
cwd_dir.mkdir()
ws_dir.mkdir()
monkeypatch.chdir(cwd_dir)

with pytest.raises(ArtifactError, match="not found"):
resolve_firmware_path("nope.bin", workspace=ws_dir)

def test_ambiguous_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
(cwd_dir / "a.elf").write_bytes(b"\x01")
(cwd_dir / "b.elf").write_bytes(b"\x02")
monkeypatch.chdir(cwd_dir)

with pytest.raises(ArtifactError, match="ambiguous"):
resolve_firmware_path("*.elf", workspace=tmp_path / "ws")
8 changes: 8 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ def test_health_no_config(self) -> None:
# Should fail — no config at default path
assert result.exit_code != 0

def test_health_check_config_only(self, sample_config_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(
cli, ["--config", str(sample_config_path), "health", "--check", "config"]
)
assert result.exit_code == 0
assert "config" in result.output

def test_publish_config_no_env(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["publish", "config"])
Expand Down
Loading
Loading