From 9ed759befcf61566238c94694c571121a9ee9e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sat, 25 Jul 2026 19:21:01 +0900 Subject: [PATCH] docs(deploy): fix MySQL/runbook/secret guidance; offline camera probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker. - pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine only; stop the shipped doc from telling a Pi operator the bundle "does not exist". - install_offline.sh: instruct the camera probe before enable (mirrors setup_online). - setup_online.sh: chmod 600 device.env on read; README documents delete + rotate. - systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time. --- deploy/README.md | 38 ++++++++--- deploy/bundle/install_offline.sh | 9 ++- deploy/setup_online.sh | 83 +++++++++++++------------ deploy/systemd/move-detect-edge.service | 6 ++ docs/pilot-runbook.md | 67 +++++++++++++++----- server/docs/server-deploy.md | 17 +++-- tests/test_deploy_artifacts.py | 73 +++++++++++++++++----- 7 files changed, 208 insertions(+), 85 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 826aec7..5f0509f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -23,19 +23,41 @@ being offline (events buffer in the local sqlite queue and flush on reconnect). ## Quick start — Pi with internet (one command) -If the Pi itself has internet, skip the offline bundle entirely. Copy this repo onto -the Pi (git clone or scp), then: +If the Pi itself has internet, skip the offline bundle entirely. Copy the built delivery +folder onto the Pi — **copy the working tree, not a fresh `git clone`**: the pose model +`models/yolov8s_pose.hef` is git-ignored (a build artifact), so a clone arrives without it. +Use `scp -r`/USB from a build machine that has already staged `models/` (see step 1). Then: ```bash sudo deploy/setup_online.sh ``` -It installs the system packages (`python3-picamera2`, and `hailo-all` if present), -builds the venv, pip-installs the runtime deps, fetches the MoveNet model, writes -`/etc/move-detect/edge.env` (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / -`CAMERAS`, or take them from env vars), installs the systemd unit, and starts the service. -It begins on CPU MoveNet; drop a `yolov8s_pose.hef` in and pass `HAILO_HEF=...` (or edit -`edge.env`) to switch to Hailo. The rest of this doc is the **air-gapped** path. +It installs the system packages (`python3-picamera2`, and `hailo-all` if present), builds +the venv, pip-installs the runtime deps, **places the pose model from the delivery's +`models/`** (it does NOT download models on the Pi — the HEF/ONNX are fetched on the build +machine per the air-gap rule, `build pulls, target doesn't`), writes `/etc/move-detect/edge.env` +(prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `CAMERAS`, or take them from env +vars), probes every camera, installs the systemd unit, and starts the service. It uses Hailo +when `models/yolov8s_pose.hef` is present (or pass `HAILO_HEF=/path/to.hef`), else CPU MoveNet +when `models/movenet_lightning.onnx` + onnxruntime are present. The rest of this doc is the +**air-gapped** path. + +### Secret handling (`deploy/device.env`) + +If you pre-provision with `deploy/device.env`, that file holds this device's **HMAC secret in +plaintext**. It is gitignored (never committed), but it rides onto the Pi with the "copy the +working tree" transfer. After a successful install: + +1. **Delete it:** `shred -u deploy/device.env` (the live secret now lives only in + `/etc/move-detect/edge.env`, `0640 root:movedetect`). The installer already `chmod 600`s it + while running, but that is defense-in-depth, not a substitute for removing it. +2. **Rotate if it was ever exposed** (shared over chat, a shell history, a shared drive): + update `move_devices.secret` on the server and the `DEVICE_SECRET` in the Pi's + `/etc/move-detect/edge.env`, then restart the service. A leaked secret lets anyone forge + signed events/heartbeats for this device. + +Prefer `device.env` over passing `DEVICE_SECRET=...` on the `sudo` command line, which leaves the +secret in the shell history. --- diff --git a/deploy/bundle/install_offline.sh b/deploy/bundle/install_offline.sh index e18e4eb..9c6fd34 100755 --- a/deploy/bundle/install_offline.sh +++ b/deploy/bundle/install_offline.sh @@ -227,5 +227,10 @@ systemctl daemon-reload echo "" echo "Installed. Next:" echo " 1. edit $ETC/edge.env (device id/secret, server 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" +echo " 2. camera check BEFORE enabling — a wrong /dev/video index or an unseated CSI ribbon" +echo " otherwise yields a green unit that silently monitors one camera less (build_runtimes" +echo " logs-and-skips a camera it cannot open). This mirrors setup_online.sh's [8/9] probe," +echo " which the offline path cannot run at install time (CAMERAS is only set in step 1):" +echo " sudo sh -c 'cd $PREFIX/src && CAMERAS=\$(sed -n \"s/^CAMERAS=//p\" $ETC/edge.env) $PREFIX/venv/bin/python -m edge.probe'" +echo " 3. sudo systemctl enable --now move-detect-edge" +echo " 4. journalctl -u move-detect-edge -f" diff --git a/deploy/setup_online.sh b/deploy/setup_online.sh index c7579c1..ceb6d27 100755 --- a/deploy/setup_online.sh +++ b/deploy/setup_online.sh @@ -12,10 +12,11 @@ # # Non-interactive: pass values as env vars (these override device.env), e.g. # sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://server CAMERAS=csi:0:cam-01,uvc:0:cam-02 \ -# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh -# The Hailo HEF is normally auto-fetched for the detected board; you do not need HAILO_HEF. -# If the board can't be auto-detected (a Hailo-8 that reports no serial/identity), name it: -# sudo HAILO_ARCH=hailo8 deploy/setup_online.sh # 26 TOPS AI HAT+ (hailo8l = 13 TOPS AI Kit) +# deploy/setup_online.sh +# MODELS ARE NOT DOWNLOADED HERE. The pose HEF (and optional MoveNet ONNX) ship IN the delivery, +# staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh) — air-gap rule "build pulls, +# target doesn't". Copy the delivery (with its models/) to the Pi and this installer places them. +# Have a HEF elsewhere on the Pi? Point at it: HAILO_HEF=/home/pi/yolov8s_pose.hef. # Interactive (a real terminal): it prompts for anything not passed. set -euo pipefail @@ -111,13 +112,18 @@ python3 -m venv --system-site-packages "$PREFIX/venv" "$PREFIX/venv/bin/pip" install onnxruntime \ || echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)." -echo ">> [5/9] MoveNet CPU model" -# Best-effort for the same reason: this model is inert without onnxruntime, and a download -# hiccup must not kill an install whose backend is Hailo. fetch_movenet is pure stdlib, so it -# runs fine with onnxruntime absent. -( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet ) \ - || echo " WARN: MoveNet ONNX download failed — CPU MoveNet unavailable (Hailo unaffected)." -cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true +echo ">> [5/9] MoveNet CPU model (from the delivery, not downloaded)" +# Models ship IN the delivery, staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh +# fetches them there) and carried across with the repo copy — air-gap rule "build pulls, target +# doesn't". This installer NEVER downloads a model on the Pi. On-device fetch was removed because +# it was fragile in two ways at once: a partial repo copy made `-m edge.tools.fetch_*` fail with a +# ModuleNotFoundError that the `|| echo WARN` mislabeled as "download failed", and the MoveNet URL +# is dead upstream anyway. MoveNet is the OPTIONAL CPU fallback; stage it only if present. +if cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null; then + echo " -> MoveNet ONNX staged from the delivery" +else + echo " note: no MoveNet ONNX in the delivery (expected for a Hailo device)." +fi echo ">> [6/9] pose backend" HEF_DEST="$PREFIX/models/yolov8s_pose.hef" @@ -130,25 +136,16 @@ if [ -n "${HAILO_HEF:-}" ]; then [ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; } cp "$HAILO_HEF" "$HEF_DEST" echo " -> HEF installed from HAILO_HEF=$HAILO_HEF" +elif [ -f "$REPO_ROOT/models/yolov8s_pose.hef" ]; then + # The delivery carries the board-matching HEF, staged on the BUILD machine + # (build_offline_bundle.sh runs fetch_hailo_hef there, where hailortcli or --arch picks + # hailo8 vs hailo8l). This installer only PLACES it — it never downloads or guesses an arch + # on the Pi. Air-gap rule: build pulls, target doesn't. + cp "$REPO_ROOT/models/yolov8s_pose.hef" "$HEF_DEST" + echo " -> HEF staged from the delivery (models/yolov8s_pose.hef)" else - # No HEF supplied: fetch the one matching THIS board. Nothing in this repo could previously - # produce a .hef — MoveNet had fetch_movenet.py, Hailo had nothing and no doc named a - # source, so [6/9] demanded a file the operator had no way to obtain. - # fetch_hailo_hef refuses to guess: it reads the arch from `hailortcli fw-control identify` - # and exits non-zero when no board answers — exactly the "no Hailo here" case that must fall - # through to MoveNet rather than install an h8 HEF that dies at load with - # HAILO_INVALID_HEF(26). Pure stdlib, so it runs with hailo_platform/onnxruntime absent. - # ONLINE-ONLY by design (rules/common/build-deployment.md); the air-gapped path carries the - # .hef in the bundle instead. Best-effort — the probes below decide if a backend exists. - # HAILO_ARCH (optional): force the arch when the board can't be auto-detected — e.g. a - # Hailo-8 whose firmware reports no serial/identity, or a Pi where hailortcli is not yet on - # PATH. ${VAR:+..} expands to nothing when unset (safe under `set -u`), so the auto-detect - # path is unchanged for everyone who does not set it; the inner quotes keep a spaced value - # one word. fetch_hailo_hef also reads HAILO_ARCH from the inherited env, so this is - # belt-and-suspenders with a direct `HAILO_ARCH=hailo8 sudo deploy/setup_online.sh`. - ( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_hailo_hef \ - --dest "$HEF_DEST" ${HAILO_ARCH:+--arch "$HAILO_ARCH"} ) \ - || echo " WARN: no Hailo HEF installed (no board detected, or the download failed)." + echo " WARN: no Hailo HEF in the delivery (models/yolov8s_pose.hef) and HAILO_HEF unset —" + echo " no Hailo backend. The probe below decides if CPU MoveNet can carry the install." fi # Probe what can ACTUALLY run, in the venv the service will use. A HEF on disk is not enough @@ -186,17 +183,17 @@ else echo "" >&2 echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2 echo "" >&2 - echo " A) Hailo-8 HAT (the intended path). The installer downloads the matching HEF" >&2 - echo " ITSELF — you do not need to supply one. It only needs the board to answer:" >&2 - echo " sudo apt-get install -y hailo-all && sudo reboot" >&2 - echo " The driver only creates /dev/hailo0 after that reboot; until then the board" >&2 - echo " cannot be identified and no HEF can be chosen for it. Then confirm:" >&2 - echo " hailortcli fw-control identify # -> Device Architecture: HAILO8" >&2 - echo " If identify prints NO 'Device Architecture' line (a Hailo-8 that reports no" >&2 - echo " serial/identity), name the arch yourself and re-run:" >&2 - echo " sudo HAILO_ARCH=hailo8 deploy/setup_online.sh # hailo8l = 13 TOPS AI Kit" >&2 - echo " (Already have a HEF? sudo HAILO_HEF=/path/to/yolov8s_pose.hef deploy/setup_online.sh)" >&2 - echo " B) CPU MoveNet:" >&2 + echo " A) Hailo-8 HAT (the intended path). The HEF ships IN the delivery" >&2 + echo " (models/yolov8s_pose.hef, staged on the build machine) — this installer does NOT" >&2 + echo " download it. Read the table above:" >&2 + echo " HEF=MISSING -> this Pi's copy of the delivery has no" >&2 + echo " models/yolov8s_pose.hef. Re-copy it, or point at one:" >&2 + echo " sudo HAILO_HEF=/path/to/yolov8s_pose.hef deploy/setup_online.sh" >&2 + echo " hailo_platform=MISSING -> the HailoRT driver/runtime is not up yet:" >&2 + echo " sudo apt-get install -y hailo-all && sudo reboot" >&2 + echo " (/dev/hailo0 appears only after that reboot). Confirm:" >&2 + echo " hailortcli fw-control identify # -> Device Architecture: HAILO8" >&2 + echo " B) CPU MoveNet: put models/movenet_lightning.onnx in the delivery, and" >&2 echo " $PREFIX/venv/bin/pip install onnxruntime" >&2 echo "" >&2 echo " If onnxruntime AND hailo-all both refuse to install, check the userland arch:" >&2 @@ -241,6 +238,12 @@ else PROVISION="${PROVISION:-$REPO_ROOT/deploy/device.env}" if [ -f "$PROVISION" ]; then echo ">> reading pre-provisioned values from $PROVISION" + # device.env holds the plaintext HMAC secret. It arrived via "copy the working tree" and may + # be world-readable (umask 022). Lock it to root-only now so it is not left readable by every + # local account for the life of the device. The secret still lands in /etc/move-detect/edge.env + # (0640 root:movedetect); once the install succeeds you should DELETE this file and rotate the + # secret if it was ever exposed — see deploy/README.md "Secret handling". + chmod 600 "$PROVISION" 2>/dev/null || true while IFS='=' read -r k v; do case "$k" in DEVICE_ID|DEVICE_SECRET|CAFE24_URL|CAMERAS) ;; *) continue ;; esac [ -n "${!k:-}" ] || printf -v "$k" '%s' "$v" diff --git a/deploy/systemd/move-detect-edge.service b/deploy/systemd/move-detect-edge.service index 88b77d2..e16fd9a 100644 --- a/deploy/systemd/move-detect-edge.service +++ b/deploy/systemd/move-detect-edge.service @@ -9,6 +9,12 @@ Description=move-detect edge daemon (workplace-safety AI CCTV) Documentation=file:/opt/move-detect/docs/pilot-runbook.md Wants=network-online.target After=network-online.target +# Order after clock sync so Event.occurred_at is real wall time, not the stale fake-hwclock time a +# Pi 5 (no RTC battery) boots with. Detector TIMERS are already immune (they run on a monotonic +# clock — see daemon.main), so this only affects timestamp accuracy; Wants (not Requires) so an +# air-gapped Pi with no reachable NTP still starts rather than hanging forever on time-sync. +Wants=time-sync.target +After=time-sync.target # Never stop retrying. This MUST live in [Unit]: systemd's parser table knows # Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in # [Service] it is an unknown key — silently ignored, leaving the default 5-starts/10s diff --git a/docs/pilot-runbook.md b/docs/pilot-runbook.md index ca3bd25..5ae0887 100644 --- a/docs/pilot-runbook.md +++ b/docs/pilot-runbook.md @@ -1,17 +1,45 @@ -# P1 파일럿 런북 (노트북 웹캠 엔드투엔드) +# 파일럿 · 운영 런북 -이 문서는 노트북 웹캠 하나로 **엣지 → 서버 → 관리자 응답**의 최소 경로를 돌리는 절차다. -실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6). +이 문서는 두 경로를 다룬다. **자신의 상황에 맞는 절에서 시작하라:** -- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST. +- **§0 폐쇄망 Pi 현장 설치·운영** — Hailo·Pi5 실장비를 오프라인 번들로 설치한 경우. systemd 유닛의 + `Documentation=`이 이 문서를 가리키므로 현장 운영자는 **§0만** 보면 된다. +- **§1~§6 노트북 웹캠 개발 파일럿** — 개발/데모 머신에서 CPU MoveNet로 엔드투엔드를 돌리는 최소 경로. + **이 절들의 `pip install`·모델 `fetch` 명령은 개발/빌드 머신 전용이다 — 폐쇄망 Pi에서 실행하지 말 것.** + +- 엣지: `python -m edge` (또는 `move-detect-edge`) — pose → 감지 → HMAC 서명 POST. - 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림. --- -## 1. 빌드 머신 준비 (모델 · onnxruntime) +## 0. 폐쇄망 Pi 현장 설치·운영 -> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서 -> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다. +실장비 Hailo·Pi5는 **오프라인 전체 번들**로 설치한다. 런타임 휠·pose 모델(HEF)·소스·systemd 유닛이 +모두 번들에 동봉되어 있으므로 **Pi에서 인터넷을 받지 않는다** (아래 §1의 `pip install`/`fetch`는 절대 +실행 금지 — 빌드 머신 전용). + +1. **설치:** 번들 절차는 [deploy/README.md](../deploy/README.md)를 따른다. 빌드 머신에서 + `deploy/bundle/build_offline_bundle.sh`(aarch64)로 번들을 만들어 Pi로 옮기고, + `sudo ./install_offline.sh`가 SHA256SUMS 검증 → 오프라인 휠 설치 → HEF 배치 → 유닛 설치를 수행한다. + (온라인 프로비저닝은 `sudo deploy/setup_online.sh` — 카메라 프로브까지 자동.) +2. **멀티카메라:** `/etc/move-detect/edge.env`의 `CAMERAS=csi:0:cam-01,uvc:0:cam-02` + 형식으로 1 Pi에 2~4대를 배선한다(형식 `backend:device:camera_id`). +3. **enable 전 카메라 프로브** (설치 스크립트가 출력하는 명령) — 잘못된 인덱스/미체결 리본이 만드는 + "초록불인데 한 대 덜 감시" 상태를 현장에서 잡는다. +4. **기동·확인:** `sudo systemctl enable --now move-detect-edge` → `journalctl -u move-detect-edge -f`. +5. **시크릿 위생:** 프로비저닝에 `deploy/device.env`를 썼다면 설치 후 삭제·필요시 로테이션 + (deploy/README.md "Secret handling"). + +> 시크릿·서버 URL·모델 경로 등 env 키는 §3 표와 동일하다. 감지 임계값은 서버 대시보드의 구역·임계값이 +> 다음 config 폴링에 반영된다. + +--- + +## 1. 빌드/개발 머신 준비 (모델 · onnxruntime) — 폐쇄망 Pi에서 실행 금지 + +> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망 Pi)에서는 +> 인터넷을 받지 않으며, 아래 두 명령을 Pi에서 실행하면 안 된다(§0 참조 — Pi는 이미 번들로 설치됨). +> 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다. ```bash pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들) @@ -83,10 +111,13 @@ python -m edge # 또는 move-detect-edge ≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것. 6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 → 복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송. -7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명). +7. **하트비트:** **30초 시간기반**(`heartbeat_interval_s=30`)으로 `POST /api/heartbeat`(서명). + 부하와 무관하게 주기가 일정해 서버의 오프라인 판정(120초, 4배 마진)이 정상 장치를 오프라인으로 + 깜빡이지 않는다. temp/throttle/queue_pending/config_version 텔레메트리를 함께 싣는다. -> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로 -> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다. +> cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()` +> 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 +> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계). --- @@ -102,11 +133,15 @@ cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # --- -## 6. 파일럿 한계 (명시) +## 6. 한계 (명시) -- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속. -- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2. -- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요). -- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save` - 패키징은 별도 후속 태스크. +> **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를 +> 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다. + +- **노트북 파일럿은 단일 카메라 · CPU pose(저 fps)** — 폐쇄망 Pi(§0)는 Hailo 가속 + 멀티카메라 + (`CAMERAS=` 다중 배선)를 사용한다. +- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2(양끝 인프라는 존재, 배선 대기). +- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook·미확인 에스컬레이션 메일은 후속(P3). +- **폐쇄망 전체 번들은 이미 존재한다** — `deploy/bundle/`에 오프라인 휠·HEF·소스·SHA256SUMS 검증 + 설치를 제공한다(§0). 노트북 파일럿만 `pip install`/`fetch`로 편의 실행할 뿐이다. - **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3). diff --git a/server/docs/server-deploy.md b/server/docs/server-deploy.md index a7531d4..a157d13 100644 --- a/server/docs/server-deploy.md +++ b/server/docs/server-deploy.md @@ -151,12 +151,19 @@ or version control. on the host afterwards, and leaving DDL lying around outside the web root is unnecessary attack surface for no benefit. -If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL -(on `role`, `event_type`, `severity`, `level`) will be parsed but silently not enforced — -harmless, since the application already whitelists these values before every insert +**Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression +default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a +hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an +older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1 +blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel +(or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either +request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied +timestamp (the code already passes one on every insert). + +Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL +(on `role`, `event_type`, `severity`, `level`) are parsed but silently not enforced — harmless, +since the application already whitelists these values before every insert (`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`). -Everything else in the schema (tables, keys, indexes, `JSON` columns) requires only -MySQL 5.7+. ## 5. Storage directory diff --git a/tests/test_deploy_artifacts.py b/tests/test_deploy_artifacts.py index 5ba54d4..7e9ee50 100644 --- a/tests/test_deploy_artifacts.py +++ b/tests/test_deploy_artifacts.py @@ -12,6 +12,7 @@ INSTALL_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundl "install_offline.sh") BUILD_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle", "build_offline_bundle.sh") +PILOT_RUNBOOK = os.path.join(os.path.dirname(__file__), "..", "docs", "pilot-runbook.md") # Keys the daemon consumes (edge/config.py:load_config + edge/daemon.py:main). REQUIRED_ENV = { @@ -161,26 +162,24 @@ def test_onnxruntime_is_optional_but_a_pose_backend_is_mandatory(): assert "import onnxruntime" in sh -def test_installer_can_obtain_a_hef_without_the_operator_supplying_one(): +def test_online_installer_stages_a_delivered_hef_and_never_downloads_one(): with open(SETUP_ONLINE, encoding="utf-8") as f: sh = f.read() - # Nothing in this repo could produce a .hef: MoveNet had edge/tools/fetch_movenet.py, - # Hailo had no equivalent and no doc named a source — so [6/8] demanded a file the - # operator had no way to obtain, and the install dead-ended there. - assert "edge.tools.fetch_hailo_hef" in sh - # An explicit HAILO_HEF that does not exist is a typo, not a request to fall back. The old - # single guarded `cp` skipped it silently and printed the same HEF=MISSING table as the - # unset case, never echoing the bad value. + # On-device model fetch was removed: it failed two ways at once. A partial repo copy onto the + # Pi made `python -m edge.tools.fetch_*` raise ModuleNotFoundError, which the `|| echo WARN` + # fallbacks mislabeled as "download failed" (sending the operator after a phantom network/board + # problem); and the MoveNet URL is dead upstream. Models now ship IN the delivery, staged on the + # BUILD machine — air-gap rule "build pulls, target doesn't". The installer must NOT fetch. + assert "edge.tools.fetch_hailo_hef" not in sh + assert "edge.tools.fetch_movenet" not in sh + # It PLACES the HEF the delivery carries (models/yolov8s_pose.hef), or an explicit HAILO_HEF path. + assert "$REPO_ROOT/models/yolov8s_pose.hef" in sh + # An explicit HAILO_HEF that does not exist is a typo, not a request to fall back. assert "does not exist" in sh - # The failure message must show whether the BOARD answered: that field separates "no HEF" + # The failure table must still show whether the BOARD answered: that field separates "no HEF" # from "driver installed, reboot pending" from "no Hailo at all" — three different fixes. assert "hailortcli fw-control identify" in sh assert "board=" in sh - # A Hailo-8 that reports no serial can leave auto-detect with nothing to read, so the - # operator must be able to force the arch. The installer forwards HAILO_ARCH as --arch, - # set -u-safe (${VAR:+..} expands to nothing when unset). - assert '${HAILO_ARCH:+--arch "$HAILO_ARCH"}' in sh - assert "HAILO_ARCH=hailo8" in sh def test_reinstall_reconciles_pose_backend_in_the_env_file(): @@ -218,6 +217,52 @@ def test_offline_installer_gates_the_pose_backend(): assert "POSE_BACKEND=$POSE_BACKEND" in sh or "POSE_BACKEND=${POSE_BACKEND}" in sh +def test_bundled_runbook_covers_the_airgap_pi_path_not_just_the_laptop_pilot(): + # This runbook is staged into the offline bundle and is the systemd unit's Documentation= + # target, so a Pi field operator opens it. It must direct them to the air-gapped install path, + # and must NOT still claim the air-gap bundle does not exist (which used to send the operator to + # `pip install` on a closed network at commissioning). + with open(PILOT_RUNBOOK, encoding="utf-8") as f: + doc = f.read() + assert ("install_offline" in doc) or ("deploy/README.md" in doc) + assert "폐쇄망 전체 번들 아님" not in doc + + +def test_bundled_runbook_is_what_the_unit_documents_and_the_bundle_ships(): + # Guard the wiring: the unit points at pilot-runbook.md and the bundle stages it. If either the + # filename or the staging changes, this fails instead of shipping a dangling Documentation=. + with open(UNIT, encoding="utf-8") as f: + unit = f.read() + with open(BUILD_OFFLINE, encoding="utf-8") as f: + build = f.read() + assert "pilot-runbook.md" in unit + assert "pilot-runbook.md" in build + + +def test_offline_installer_tells_the_operator_to_probe_cameras_before_enabling(): + with open(INSTALL_OFFLINE, encoding="utf-8") as f: + sh = f.read() + # The offline path cannot auto-probe at install time (CAMERAS is only set when the operator + # edits edge.env afterwards), so it must at least INSTRUCT the camera check before enable — + # otherwise a mis-indexed camera produces a green unit monitoring one camera less, the exact + # failure setup_online.sh's [8/9] probe was added to prevent. The probe command must precede + # the enable instruction. + assert "edge.probe" in sh + probe_at = sh.index("edge.probe") + enable_at = sh.index("systemctl enable --now move-detect-edge", probe_at) + assert probe_at < enable_at # probe instruction comes before the enable instruction + + +def test_offline_bundle_stages_the_probe_module_for_the_camera_check(): + with open(BUILD_OFFLINE, encoding="utf-8") as f: + sh = f.read() + # The bundle stages the whole edge/ package, so `python -m edge.probe` is available on the + # air-gapped Pi. (A stale hand-built dist/ that predates edge/probe.py must be regenerated.) + assert 'cp -r "$REPO_ROOT/edge" "$STAGE/src/edge"' in sh + probe = os.path.join(os.path.dirname(__file__), "..", "edge", "probe.py") + assert os.path.isfile(probe) # the module the instruction invokes actually exists in source + + def test_offline_bundle_fetches_and_stages_the_hailo_hef(): with open(BUILD_OFFLINE, encoding="utf-8") as f: sh = f.read()