fix(bundle): carry the Hailo HEF in the offline bundle, ONNX optional (P5-3)

The air-gapped bundle never fetched the Hailo HEF, so an offline Hailo
install had no pose model. Two latent breaks also made the offline path
unusable regardless of P5-3:

- build [2/5] ran `fetch_movenet` unconditionally; with the MoveNet
  source unreachable that aborted the whole build under `set -e`.
- install_offline copied only `*.onnx`, so even a HEF placed in the
  bundle never reached $PREFIX/models for the backend gate to find.

Fixes:
- build_offline_bundle.sh fetches the $HAILO_ARCH (default hailo8) HEF
  via edge.tools.fetch_hailo_hef (pinned sha256, aborts on mismatch) and
  stages yolov8s_pose.hef. MoveNet ONNX is now optional (guarded fetch →
  Hailo-only bundle if unavailable). onnxruntime moves to a best-effort
  optional wheel. MANIFEST lists models + available pose_backends.
- install_offline.sh copies *.hef (and tolerates a missing *.onnx).

Tests (test_deploy_artifacts.py): bundle fetches+stages the HEF, MoveNet
is optional (guarded), installer copies the HEF. Both scripts pass
`bash -n`. Full python suite: 152 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
This commit is contained in:
유창욱 2026-07-17 10:15:13 +09:00
parent 99070f5e87
commit 1fb7a5da58
4 changed files with 85 additions and 10 deletions

View file

@ -25,7 +25,12 @@ WHEELHOUSE="$STAGE/wheelhouse"
PYVER="${PYVER:-3.11}" # Pi OS Bookworm ships CPython 3.11 PYVER="${PYVER:-3.11}" # Pi OS Bookworm ships CPython 3.11
PYABI="cp${PYVER//./}" # cp311 PYABI="cp${PYVER//./}" # cp311
TARGET_ARCH="${TARGET_ARCH:-aarch64}" TARGET_ARCH="${TARGET_ARCH:-aarch64}"
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless) HAILO_ARCH="${HAILO_ARCH:-hailo8}" # 26 TOPS AI HAT+ (Hailo-8); set hailo8l for the 13 TOPS AI Kit
# Mandatory for EVERY backend (cv2/numpy/requests are on the unconditional startup chain).
# onnxruntime is the CPU-MoveNet fallback only and publishes no 32-bit-ARM wheel / no sdist, so
# it is downloaded best-effort — a Hailo bundle need not carry it (install_offline treats it optional).
RUNTIME_PKGS=(numpy requests opencv-python-headless)
OPTIONAL_PKGS=(onnxruntime)
MODE="native" MODE="native"
[ "${1:-}" = "--cross" ] && MODE="cross" [ "${1:-}" = "--cross" ] && MODE="cross"
@ -33,12 +38,13 @@ MODE="native"
echo ">> build mode : $MODE" echo ">> build mode : $MODE"
echo ">> python target : $PYVER ($PYABI)" echo ">> python target : $PYVER ($PYABI)"
echo ">> target arch : $TARGET_ARCH" echo ">> target arch : $TARGET_ARCH"
echo ">> hailo arch : $HAILO_ARCH"
echo ">> output : $OUT_DIR" echo ">> output : $OUT_DIR"
rm -rf "$STAGE" rm -rf "$STAGE"
mkdir -p "$WHEELHOUSE" "$STAGE/src" "$STAGE/models" "$STAGE/deploy/systemd" "$STAGE/docs" mkdir -p "$WHEELHOUSE" "$STAGE/src" "$STAGE/models" "$STAGE/deploy/systemd" "$STAGE/docs"
echo ">> [1/5] downloading runtime wheels -> wheelhouse/" echo ">> [1/6] downloading runtime wheels -> wheelhouse/"
if [ "$MODE" = "cross" ]; then if [ "$MODE" = "cross" ]; then
# Best-effort cross download. Try the common manylinux aarch64 tags. # Best-effort cross download. Try the common manylinux aarch64 tags.
python3 -m pip download \ python3 -m pip download \
@ -47,6 +53,14 @@ if [ "$MODE" = "cross" ]; then
--platform "manylinux2014_${TARGET_ARCH}" \ --platform "manylinux2014_${TARGET_ARCH}" \
--platform "manylinux_2_28_${TARGET_ARCH}" \ --platform "manylinux_2_28_${TARGET_ARCH}" \
-d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}" -d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# CPU fallback wheel, best-effort — onnxruntime may have no matching aarch64/$PYABI tag.
python3 -m pip download \
--only-binary=:all: \
--python-version "$PYVER" --implementation cp --abi "$PYABI" \
--platform "manylinux2014_${TARGET_ARCH}" \
--platform "manylinux_2_28_${TARGET_ARCH}" \
-d "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime aarch64/$PYABI wheel unavailable; Hailo-only bundle (no CPU MoveNet)."
else else
# Native: wheels for THIS host's interpreter/arch (run on the Pi / arm64 CI). # Native: wheels for THIS host's interpreter/arch (run on the Pi / arm64 CI).
# Guard the ABI: numpy/onnxruntime ship version-specific cpXY wheels, so a build # Guard the ABI: numpy/onnxruntime ship version-specific cpXY wheels, so a build
@ -69,35 +83,56 @@ else
exit 1 exit 1
fi fi
python3 -m pip download -d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}" python3 -m pip download -d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# CPU fallback wheel, best-effort — a Hailo bundle is complete without it.
python3 -m pip download -d "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime unavailable for this host; Hailo-only bundle (no CPU MoveNet)."
fi fi
echo ">> [2/5] fetching MoveNet ONNX model (build-machine only)" echo ">> [2/6] fetching MoveNet ONNX model (optional CPU fallback; build-machine only)"
( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet ) # Optional: the MoveNet source may be unreachable, and a Hailo-8 delivery runs off the HEF. Do
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$STAGE/models/" # not abort the whole bundle over the CPU fallback — stage it if we can, otherwise carry on.
if ( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet ); then
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$STAGE/models/"
else
echo " -> MoveNet ONNX unavailable; Hailo-only bundle (CPU fallback not included)."
fi
echo ">> [3/5] staging edge source (package + pyproject)" echo ">> [3/6] fetching Hailo $HAILO_ARCH yolov8s_pose HEF (build-machine only)"
# Mandatory for a Hailo delivery: this is the primary pose backend. fetch_hailo_hef verifies the
# download against a pinned sha256 and refuses a mismatch, so a bad/tampered HEF aborts the build.
( cd "$REPO_ROOT" && HAILO_ARCH="$HAILO_ARCH" python3 -m edge.tools.fetch_hailo_hef --arch "$HAILO_ARCH" )
cp "$REPO_ROOT/models/yolov8s_pose.hef" "$STAGE/models/"
echo ">> [4/6] staging edge source (package + pyproject)"
cp -r "$REPO_ROOT/edge" "$STAGE/src/edge" cp -r "$REPO_ROOT/edge" "$STAGE/src/edge"
cp "$REPO_ROOT/pyproject.toml" "$STAGE/src/" cp "$REPO_ROOT/pyproject.toml" "$STAGE/src/"
# do not ship compiled caches # do not ship compiled caches
find "$STAGE/src" -name '__pycache__' -type d -prune -exec rm -rf {} + find "$STAGE/src" -name '__pycache__' -type d -prune -exec rm -rf {} +
echo ">> [4/5] staging deploy assets + install script" echo ">> [5/6] staging deploy assets + install script"
cp "$REPO_ROOT/deploy/systemd/move-detect-edge.service" "$STAGE/deploy/systemd/" cp "$REPO_ROOT/deploy/systemd/move-detect-edge.service" "$STAGE/deploy/systemd/"
cp "$REPO_ROOT/deploy/systemd/move-detect-edge.env.example" "$STAGE/deploy/systemd/" cp "$REPO_ROOT/deploy/systemd/move-detect-edge.env.example" "$STAGE/deploy/systemd/"
cp "$REPO_ROOT/deploy/bundle/install_offline.sh" "$STAGE/" cp "$REPO_ROOT/deploy/bundle/install_offline.sh" "$STAGE/"
cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true
chmod +x "$STAGE/install_offline.sh" chmod +x "$STAGE/install_offline.sh"
# Which pose backends this bundle can actually run, so the operator knows before it crosses the
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
{ {
echo "bundle: move-detect-edge" echo "bundle: move-detect-edge"
echo "built_mode: $MODE" echo "built_mode: $MODE"
echo "python: $PYVER ($PYABI)" echo "python: $PYVER ($PYABI)"
echo "target_arch: $TARGET_ARCH" echo "target_arch: $TARGET_ARCH"
echo "hailo_arch: $HAILO_ARCH"
echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')"
echo "models:"
ls -1 "$STAGE/models"
echo "wheels:" echo "wheels:"
ls -1 "$WHEELHOUSE" ls -1 "$WHEELHOUSE"
} > "$STAGE/MANIFEST.txt" } > "$STAGE/MANIFEST.txt"
echo ">> [5/5] packaging tarball" echo ">> [6/6] packaging tarball"
TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}.tar.gz" TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}.tar.gz"
tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle" tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle"
echo ">> DONE: $TARBALL" echo ">> DONE: $TARBALL"

View file

@ -39,7 +39,11 @@ install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs"
rm -rf "$PREFIX/src/edge" rm -rf "$PREFIX/src/edge"
cp -r "$HERE/src/edge" "$PREFIX/src/edge" cp -r "$HERE/src/edge" "$PREFIX/src/edge"
cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true
cp "$HERE/models/"*.onnx "$PREFIX/models/" # Copy whichever pose models the bundle carries. Each is optional on its own — a Hailo bundle
# ships only the .hef, a CPU bundle only the .onnx — so a missing glob must not abort the install
# (the [5/6] backend gate below is what refuses a bundle with NO usable backend).
cp "$HERE/models/"*.hef "$PREFIX/models/" 2>/dev/null || true
cp "$HERE/models/"*.onnx "$PREFIX/models/" 2>/dev/null || true
cp "$HERE/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true cp "$HERE/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true
echo ">> [3/6] python venv + offline wheel install (--no-index)" echo ">> [3/6] python venv + offline wheel install (--no-index)"

View file

@ -206,9 +206,16 @@ misconfig/degradation fail LOUD.
- [x] **P5-2 [H]** `deploy/bundle/build_offline_bundle.sh:55` — a native build on x86 produces - [x] **P5-2 [H]** `deploy/bundle/build_offline_bundle.sh:55` — a native build on x86 produces
an aarch64-named tarball full of x86_64 wheels. **Fix:** assert `uname -m == TARGET_ARCH`. an aarch64-named tarball full of x86_64 wheels. **Fix:** assert `uname -m == TARGET_ARCH`.
DONE — native branch now hard-fails when uname -m != TARGET_ARCH. DONE — native branch now hard-fails when uname -m != TARGET_ARCH.
- [ ] **P5-3 [M]** `deploy/bundle/build_offline_bundle.sh:65` — the air-gapped bundle never - [x] **P5-3 [M]** `deploy/bundle/build_offline_bundle.sh:65` — the air-gapped bundle never
carries the Hailo HEF, so offline Hailo install is impossible. **Fix:** stage the HEF (via carries the Hailo HEF, so offline Hailo install is impossible. **Fix:** stage the HEF (via
`fetch_hailo_hef --arch`) into the bundle. `fetch_hailo_hef --arch`) into the bundle.
DONE — build now fetches the `$HAILO_ARCH` (default hailo8) HEF via `fetch_hailo_hef` (pinned
sha256, aborts on mismatch) and stages `yolov8s_pose.hef`. Also fixed two latent breaks that
made the offline path unusable regardless: the MoveNet ONNX fetch was unconditional so its dead
source aborted the whole build under `set -e` (now guarded/optional → Hailo-only bundle), and
`install_offline.sh` copied only `*.onnx` so a bundled HEF never reached `$PREFIX/models` for the
backend gate to find (now copies `*.hef` too). MANIFEST lists models + available pose_backends.
Tests: fetches/stages HEF, MoveNet optional, installer copies the HEF.
- [ ] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests, - [ ] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests,
MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin + MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin +
hash + manifest digests. hash + manifest digests.

View file

@ -10,6 +10,8 @@ UNIT = os.path.join(DEPLOY, "move-detect-edge.service")
SETUP_ONLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "setup_online.sh") SETUP_ONLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "setup_online.sh")
INSTALL_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle", INSTALL_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle",
"install_offline.sh") "install_offline.sh")
BUILD_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle",
"build_offline_bundle.sh")
# Keys the daemon consumes (edge/config.py:load_config + edge/daemon.py:main). # Keys the daemon consumes (edge/config.py:load_config + edge/daemon.py:main).
REQUIRED_ENV = { REQUIRED_ENV = {
@ -214,3 +216,30 @@ def test_offline_installer_gates_the_pose_backend():
assert "no usable pose backend" in sh assert "no usable pose backend" in sh
# It must WRITE the reconciled backend into the installed env, not leave the template's. # It must WRITE the reconciled backend into the installed env, not leave the template's.
assert "POSE_BACKEND=$POSE_BACKEND" in sh or "POSE_BACKEND=${POSE_BACKEND}" in sh assert "POSE_BACKEND=$POSE_BACKEND" in sh or "POSE_BACKEND=${POSE_BACKEND}" in sh
def test_offline_bundle_fetches_and_stages_the_hailo_hef():
with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# A Hailo-8 delivery runs off the HEF; the offline bundle must carry it, fetched on the build
# machine (the air-gapped Pi cannot). Without this the installer's backend gate finds no HEF.
assert "edge.tools.fetch_hailo_hef" in sh
assert "yolov8s_pose.hef" in sh # staged into the bundle's models/
def test_offline_bundle_treats_movenet_onnx_as_optional():
with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# The MoveNet source can be unreachable and a Hailo bundle is complete without the CPU
# fallback, so the fetch must be GUARDED (inside an `if`) — never an unconditional line that
# aborts the whole build under `set -e`.
assert "fetch_movenet ); then" in sh
assert "Hailo-only bundle" in sh
def test_offline_installer_copies_the_hef_into_place():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# install_offline used to copy only *.onnx, so a HEF in the bundle never reached
# $PREFIX/models and the Hailo backend gate ([5/6]) could never see it.
assert 'cp "$HERE/models/"*.hef' in sh