move_detect/deploy/bundle/install_offline.sh
유창욱 2ea0420de7 fix(delivery): resolve codex + workflow review findings across edge/cloud/deploy
Two independent adversarial reviews (codex CLI + a 6-dimension verification
workflow) of the delivery-hardening branch surfaced ~40 findings; this closes
the confirmed correctness/security/deploy blockers plus a codex re-review pass.

edge:
- geometry: track_displacement per-second threshold recalibrated (0.02->0.25,
  env-tunable via MOTION_THR, validated finite/positive); confidence-filter
  keypoints with a bbox-center fallback so hallucinated points don't fake motion
- fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident
  (freeze + reset recovery timer so recovery needs a fresh confident streak);
  baseline height only seeded from trusted frames
- queue: PostClient.send() returns a tri-state SendResult; only definite 4xx
  count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx)
  never deletes queued safety events; legacy rows persist their minted uuid
- config: reject placeholder / http / short DEVICE_SECRET at startup
- zones: warn+skip legacy pixel-space polygons that would silently match nothing

cloud:
- zone endpoints wired into P3-2b site-scope (cross-site read/write/delete
  closed) + role gate on list; catch narrowed so DB faults surface as 500
- snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan)
- ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE
  (event_id, content_hash) makes recovery idempotent under retry/race
- event list cursor carries full-precision occurred_at (no float rounding at the
  page boundary); string keyset end-to-end
- SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap
- runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit
  reap fails open

deploy:
- offline bundle hashes EVERY file (fatal on missing/failed/injected; models in
  SBOM; arch in tarball name; REQUIRE_SIGNATURE option)
- installer verifies before any mutation, Hailo arch preflight before install,
  atomic src swap, backend reconcile preserves a working operator choice
- systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert)

Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4
skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings +
fixes) with the second-pass findings folded in.
2026-07-17 19:24:42 +09:00

231 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
# Offline installer for the move-detect edge daemon. Runs on the air-gapped Pi 5
# (NO internet). Unpack the bundle produced by build_offline_bundle.sh, cd into it,
# then: sudo ./install_offline.sh
#
# Installs everything from the bundled wheelhouse with --no-index (zero fetches),
# runs the package straight from source (no build backend needed offline).
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "must run as root (sudo ./install_offline.sh)" >&2
exit 1
fi
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFIX=/opt/move-detect
STATE=/var/lib/move-detect
ETC=/etc/move-detect
SVC_USER=movedetect
WHEELHOUSE="$HERE/wheelhouse"
# Mandatory for every backend. onnxruntime is deliberately NOT here — see the optional
# install below; a Hailo bundle need not carry its wheel.
RUNTIME_PKGS=(numpy requests opencv-python-headless)
OPTIONAL_PKGS=(onnxruntime)
echo ">> [0/6] verify bundle integrity BEFORE touching the system"
# Verify EVERY bundled file against the build-time SHA256SUMS before any mutation of $PREFIX. A
# truncated/tampered bundle must abort HERE — with the previous install still intact — never after
# rm -rf has already destroyed it and left the Restart=always unit to run half-copied code. The
# build now hashes the daemon source, unit and this installer too, so tampered code cannot pass.
ALLOW_UNVERIFIED="${ALLOW_UNVERIFIED:-0}"
if [ -f "$HERE/SHA256SUMS" ]; then
( cd "$HERE" && sha256sum -c SHA256SUMS ) \
|| { echo "ERROR: bundle integrity check failed (SHA256SUMS mismatch) — refusing to install." >&2; exit 1; }
# sha256sum -c only checks LISTED files; an INJECTED extra wheel (which could win pip's version
# resolution) would pass unnoticed. Refuse any staged file not present in the manifest.
EXTRA="$( cd "$HERE" && comm -23 \
<(find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' | sed 's|^\./||' | sort) \
<(awk '{print $2}' SHA256SUMS | sed 's|^[*]||; s|^\./||' | sort) )"
if [ -n "$EXTRA" ]; then
echo "ERROR: bundle contains file(s) not listed in SHA256SUMS (possible injection):" >&2
echo "$EXTRA" >&2
exit 1
fi
# A detached signature proves PROVENANCE — an unsigned SHA256SUMS only detects corruption, since
# an attacker who can modify the bundle can strip the .asc and regenerate the hashes. Set
# REQUIRE_SIGNATURE=1 for untrusted transport to make a missing/invalid signature fatal.
REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-0}"
if [ -f "$HERE/SHA256SUMS.asc" ]; then
gpg --verify "$HERE/SHA256SUMS.asc" "$HERE/SHA256SUMS" 2>/dev/null \
|| { echo "ERROR: SHA256SUMS.asc present but its signature did NOT verify — refusing to install." >&2; exit 1; }
echo " -> SHA256SUMS signature verified"
elif [ "$REQUIRE_SIGNATURE" = 1 ]; then
echo "ERROR: REQUIRE_SIGNATURE=1 but this bundle has no SHA256SUMS.asc. An unsigned bundle" >&2
echo "detects corruption only, not tampering — refusing. Build with SIGN_KEY, or clear the flag." >&2
exit 1
else
echo " -> NOTE: unsigned bundle (corruption-detection only; set REQUIRE_SIGNATURE=1 to require a signature)."
fi
echo " -> bundle integrity OK"
elif [ "$ALLOW_UNVERIFIED" = 1 ]; then
echo " -> WARNING: no SHA256SUMS; ALLOW_UNVERIFIED=1 set — installing WITHOUT integrity check." >&2
else
echo "ERROR: no SHA256SUMS in this bundle — refusing to install unverified code. Rebuild with a" >&2
echo "current build_offline_bundle.sh, or (NOT recommended) re-run with ALLOW_UNVERIFIED=1." >&2
exit 1
fi
echo ">> [0b/6] Hailo arch preflight (BEFORE any mutation)"
# A hailo8 HEF on a hailo8l board (or vice-versa) imports fine but the daemon dies with
# HAILO_INVALID_HEF(26) on every start, forever. Catch the WRONG bundle here — using MANIFEST.txt
# (staged) + hailortcli (a system CLI, no venv needed) — so a mismatch aborts with the previous
# install still intact, not after the HEF/source/venv have already been overwritten.
BUNDLE_HAILO_ARCH="$(awk -F': ' '/^hailo_arch:/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}' "$HERE/MANIFEST.txt" 2>/dev/null || true)"
if [ -n "$BUNDLE_HAILO_ARCH" ]; then
BOARD_HAILO_ARCH=""
if command -v hailortcli >/dev/null 2>&1; then
BOARD_HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \
| awk -F': ' '/[Aa]rchitecture/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}')"
fi
if [ -n "$BOARD_HAILO_ARCH" ] && [ "$BUNDLE_HAILO_ARCH" != "$BOARD_HAILO_ARCH" ]; then
echo "ERROR: Hailo arch mismatch — this bundle's HEF is '$BUNDLE_HAILO_ARCH' but the board is" >&2
echo "'$BOARD_HAILO_ARCH'. It would crash-loop with HAILO_INVALID_HEF(26). Rebuild with" >&2
echo "HAILO_ARCH=$BOARD_HAILO_ARCH (deploy/bundle/build_offline_bundle.sh)." >&2
exit 1
elif [ -z "$BOARD_HAILO_ARCH" ]; then
echo " -> NOTE: could not read the board's Hailo arch (hailortcli?) to verify the bundle's" >&2
echo " '$BUNDLE_HAILO_ARCH'; if the daemon later fails HAILO_INVALID_HEF(26), the arch is wrong." >&2
else
echo " -> Hailo arch OK ($BUNDLE_HAILO_ARCH)"
fi
fi
echo ">> [1/6] service account + groups"
if ! id -u "$SVC_USER" >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER"
fi
# camera (video), Hailo accelerator (render/plugdev vary by image) — ignore missing groups
for g in video render plugdev hailo; do
getent group "$g" >/dev/null 2>&1 && usermod -aG "$g" "$SVC_USER" || true
done
echo ">> [2/6] lay down source, model, docs under $PREFIX"
install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs"
# Fully replace the package tree on re-install/upgrade, atomically. Stage the new tree beside the
# old and rename over it: an interrupted copy leaves the previous WORKING tree untouched (the
# Restart=always unit keeps running old code) instead of a half-copied one. The rename also avoids
# `cp -r SRC DEST` nesting SRC *inside* an existing DEST (edge/edge/...) and running the OLD code.
rm -rf "$PREFIX/src/edge.new"
cp -r "$HERE/src/edge" "$PREFIX/src/edge.new"
rm -rf "$PREFIX/src/edge"
mv "$PREFIX/src/edge.new" "$PREFIX/src/edge"
cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true
# 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
echo ">> [3/6] python venv + offline wheel install (--no-index)"
# Integrity was already verified up-front in step [0/6], before any system mutation.
# --system-site-packages so the venv can import the Pi SYSTEM packages that are NOT
# pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and
# picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install
# below adds our deps into the venv; where a system copy already satisfies a wheel's
# version constraint pip keeps it (ABI-compatible on the target images), otherwise the
# bundled wheel installs into the venv and shadows the system copy.
python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" --upgrade pip >/dev/null 2>&1 || true
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# Optional: only present if the bundle was built for the CPU MoveNet backend. Still --no-index
# (zero fetches, air-gap intact); absent simply means Hailo-only.
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime not in the wheelhouse; Hailo-only bundle (CPU MoveNet unavailable)."
echo ">> [4/6] verify the edge package imports offline"
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
# on the unconditional startup chain; the daemon's cloud clients import requests at startup).
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
# not carry it. Report, don't fail: a hard `import onnxruntime` here refuses the install over
# a package the daemon never loads.
if PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import onnxruntime" >/dev/null 2>&1; then
echo " -> onnxruntime present; CPU MoveNet available"
else
echo " -> onnxruntime absent; CPU MoveNet unavailable (set POSE_BACKEND=hailo in $ETC/edge.env)"
fi
echo ">> [5/6] pose backend gate + state dir + env file"
# Do NOT hand over a Pi whose POSE_BACKEND cannot load. The template ships POSE_BACKEND=movenet,
# but a Hailo-only bundle carries no onnxruntime/ONNX — installing it verbatim would crash-loop.
# Probe what can ACTUALLY run (like setup_online's [6/8]) and reconcile, or refuse.
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
pyhas() { PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; }
state() { if "$@" >/dev/null 2>&1; then echo present; else echo MISSING; fi; }
HAILO_OK=0; MOVENET_OK=0
if [ -f "$HEF_DEST" ] && pyhas hailo_platform; then HAILO_OK=1; fi
if [ -f "$ONNX_DEST" ] && pyhas onnxruntime; then MOVENET_OK=1; fi
if [ "$HAILO_OK" != 1 ] && [ "$MOVENET_OK" != 1 ]; then
echo "" >&2
echo "ERROR: no usable pose backend in this bundle — refusing to install a daemon that" >&2
echo "cannot start." >&2
echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform')" >&2
echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2
echo " Rebuild the bundle for the target backend (deploy/bundle/build_offline_bundle.sh)." >&2
exit 1
fi
# Prefer Hailo (the accelerator) when usable; MoveNet is the CPU fallback. (Arch already
# preflighted in [0b/6].)
if [ "$HAILO_OK" = 1 ]; then DEFAULT_BACKEND=hailo; else DEFAULT_BACKEND=movenet; fi
backend_usable() { # 0 (true) iff the named backend can actually load in THIS bundle
case "$1" in
hailo) [ "$HAILO_OK" = 1 ] ;;
movenet) [ "$MOVENET_OK" = 1 ] ;;
*) return 1 ;;
esac
}
install -d -m 0750 "$STATE"
chown "$SVC_USER":"$SVC_USER" "$STATE"
install -d -m 0750 "$ETC"
if [ ! -f "$ETC/edge.env" ]; then
POSE_BACKEND="$DEFAULT_BACKEND"
install -m 0640 "$HERE/deploy/systemd/move-detect-edge.env.example" "$ETC/edge.env"
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env"
echo " -> wrote $ETC/edge.env (POSE_BACKEND=$POSE_BACKEND; EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)"
else
# Preserve the operator's DELIBERATE backend choice when it still works in this bundle; only
# switch when the current one can't load — the crash-loop this reconcile exists to prevent
# (e.g. a stale POSE_BACKEND=movenet on a Hailo-only bundle with no onnxruntime).
CURRENT_BACKEND="$(sed -n 's|^POSE_BACKEND=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)"
if backend_usable "$CURRENT_BACKEND"; then
POSE_BACKEND="$CURRENT_BACKEND"
echo " -> $ETC/edge.env POSE_BACKEND=$POSE_BACKEND is usable in this bundle; kept"
else
POSE_BACKEND="$DEFAULT_BACKEND"
if grep -q '^POSE_BACKEND=' "$ETC/edge.env"; then
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env"
else
printf 'POSE_BACKEND=%s\n' "$POSE_BACKEND" >> "$ETC/edge.env"
fi
echo " -> reconciled $ETC/edge.env POSE_BACKEND: '${CURRENT_BACKEND:-unset}' (unusable) -> '$POSE_BACKEND'"
fi
fi
# For the hailo backend, HAILO_HEF must point at a HEF that actually EXISTS — correct a missing
# key OR one left pointing at a stale/absent path, else the daemon crash-loops on a bad path.
if [ "$POSE_BACKEND" = hailo ]; then
CURRENT_HEF="$(sed -n 's|^HAILO_HEF=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)"
if [ -z "$CURRENT_HEF" ] || [ ! -f "$CURRENT_HEF" ]; then
if grep -q '^HAILO_HEF=' "$ETC/edge.env"; then
sed -i "s|^HAILO_HEF=.*|HAILO_HEF=$HEF_DEST|" "$ETC/edge.env"
else
printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env"
fi
echo " -> set HAILO_HEF=$HEF_DEST (was '${CURRENT_HEF:-unset}')"
fi
fi
chown root:"$SVC_USER" "$ETC/edge.env"
echo ">> [6/6] systemd unit"
install -m 0644 "$HERE/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
systemctl daemon-reload
echo ""
echo "Installed. Next:"
echo " 1. edit $ETC/edge.env (device id/secret, cloud URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
echo " 2. sudo systemctl enable --now move-detect-edge"
echo " 3. journalctl -u move-detect-edge -f"