diff --git a/deploy/README.md b/deploy/README.md index 04fe207..1bb71bc 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,7 +3,7 @@ Offline-installable bundle + systemd unit for the `move-detect` edge daemon. The bundle is fully self-contained (all wheels + pose model + source): the Pi performs **zero network fetches** at install or run time. The only runtime -network use is the cloud POST / config-poll / heartbeat, and even that tolerates +network use is the server POST / config-poll / heartbeat, and even that tolerates being offline (events buffer in the local sqlite queue and flush on reconnect). > Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the @@ -81,8 +81,8 @@ sudo systemctl enable --now move-detect-edge journalctl -u move-detect-edge -f ``` -`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the cloud (see -`cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled +`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see +`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled model (`/opt/move-detect/models/movenet_lightning.onnx`). ## 4. Verify the air gap (do this before trusting a build) @@ -93,7 +93,7 @@ model (`/opt/move-detect/models/movenet_lightning.onnx`). - [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 + numpy + requests all import from the venv). - [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows - frames processed and (if the cloud is reachable) heartbeats. + frames processed and (if the server is reachable) heartbeats. - [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore → the queue flushes. (End-to-end behavior verified in the edge e2e harness.) @@ -125,14 +125,14 @@ validated). USB webcam stays available anytime via `CAMERA_BACKEND=uvc`. **Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share -the Hailo device (round-robin) and the offline queue, and cloud zones are routed to the +the Hailo device (round-robin) and the offline queue, and server zones are routed to the matching camera. Example — CSI + USB together: ```ini CAMERAS=csi:0:cam-01,uvc:0:cam-02 ``` -Each `camera_id` must match the `camera_id` on that camera's zones in the cloud. +Each `camera_id` must match the `camera_id` on that camera's zones in the server. > **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't > carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer @@ -141,7 +141,7 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the cloud. ## Not bundled (by design) -- **MySQL / the cloud app** — a separate host; see `cloud/docs/cloud-deploy.md`. +- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`. - **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled HEF, and the CSI camera stack are Pi system packages installed out-of-band (above); the default CPU MoveNet + USB path needs none of them. diff --git a/deploy/bundle/install_offline.sh b/deploy/bundle/install_offline.sh index 27cc14a..e18e4eb 100755 --- a/deploy/bundle/install_offline.sh +++ b/deploy/bundle/install_offline.sh @@ -136,7 +136,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv" 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). +# on the unconditional startup chain; the daemon's server 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 @@ -226,6 +226,6 @@ 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 " 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" diff --git a/deploy/setup_online.sh b/deploy/setup_online.sh index a57a949..905806a 100755 --- a/deploy/setup_online.sh +++ b/deploy/setup_online.sh @@ -6,7 +6,7 @@ # sudo deploy/setup_online.sh # # Non-interactive: pass values as env vars, e.g. -# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \ +# 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: @@ -89,7 +89,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv" # cv2 edge/snapshot.py imports it at MODULE level, and that module is on the # unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND # can reach build_pose() without it. -# requests the daemon builds its cloud/alert clients with no injected HTTP fn, so +# requests the daemon builds its server/alert clients with no injected HTTP fn, so # `import requests` fires at startup regardless of backend. # numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives # transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless @@ -233,7 +233,7 @@ else : "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}" if [ -t 0 ]; then [ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID - [ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; } + [ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from server): " DEVICE_SECRET; echo; } [ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https:///move_detect — include the subdir!): " CAFE24_URL [ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS fi @@ -254,7 +254,7 @@ TARGET_FPS=12 EOF chown root:"$SVC_USER" "$ETC/edge.env" chmod 0640 "$ETC/edge.env" - [ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the cloud will accept events." + [ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the server will accept events." echo " -> wrote $ETC/edge.env" fi diff --git a/deploy/systemd/move-detect-edge.service b/deploy/systemd/move-detect-edge.service index aeac345..88b77d2 100644 --- a/deploy/systemd/move-detect-edge.service +++ b/deploy/systemd/move-detect-edge.service @@ -1,7 +1,7 @@ # move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망). # Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then # sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge -# The daemon tolerates the cloud being offline (events buffer in the local queue +# The daemon tolerates the server being offline (events buffer in the local queue # and flush on reconnect), so this unit only *wants* the network, not requires it. [Unit] diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index 7d6312a..14019a8 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -14,13 +14,13 @@ Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low A safety system that *looks* healthy while doing nothing is the worst failure. Make every misconfig/degradation fail LOUD. -- [x] **P1-1 [H]** `edge/cloudclient.py`, `edge/alert.py` — `requests` sends the default +- [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default `python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True` turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA (`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx. DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`; - tests in test_alert.py / test_cloudclient.py. + tests in test_alert.py / test_serverclient.py. - [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt, docs — `CAFE24_URL` must be `https:///move_detect` (subdir mount). Empty/typo/ missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates. @@ -35,7 +35,7 @@ misconfig/degradation fail LOUD. DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera; test_daemon.py locks the rate-limit. - [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where - `source.read()` returned nothing, so the cloud cannot tell a dead device from a live one. + `source.read()` returned nothing, so the server cannot tell a dead device from a live one. **Fix:** real fps + per-camera frame-age/health in the heartbeat body. DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors; test_daemon.py locks fps math + failing-camera flag. @@ -54,12 +54,12 @@ misconfig/degradation fail LOUD. poison head unwedges the queue; test locks the drain-past-poison behavior. - [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable - client-generated `event_id` in the payload; cloud dedups on it. + client-generated `event_id` in the payload; server dedups on it. DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at - creation, preserved through the offline queue + every retry). Cloud: `events.event_uuid` + creation, preserved through the offline queue + every retry). Server: `events.event_uuid` CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key. - Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (cloud). + Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server). - [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError at every start → infinite systemd restart loop. **Fix:** validate, default sanely. DONE — `frame_interval_from_env` validates; test_config.py locks it. @@ -70,7 +70,7 @@ misconfig/degradation fail LOUD. ## Phase 2 — Core safety correctness (fall / zone / tracking) -- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `cloud/public/assets/zone-draw.js:196` — zones are +- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides. DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size @@ -125,13 +125,13 @@ misconfig/degradation fail LOUD. face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox. DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top fallback when the nose keypoint is low-confidence. test_faceblur.py locks both. -- [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored +- [x] **P3-2 [H]** `server/src/Ingest/IngestController.php:68` — worker snapshots are stored forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days` config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`. DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it. -- [x] **P3-2b [M]** `cloud/src/Dashboard/EventListController.php:17` — `site_scope` is stored +- [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored in the session but never enforced; any authenticated user sees every device's worker snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`. DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto @@ -149,42 +149,42 @@ misconfig/degradation fail LOUD. pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet is the optional CPU fallback); flag for whoever needs CPU MoveNet. -## Phase 4 — Cloud hardening (shared hosting) +## Phase 4 — Server hardening (shared hosting) -- [x] **P4-1 [H]** `cloud/public/index.php:106` — no top-level try/catch; a PDOException +- [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On). **Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`; ship `.user.ini`. DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"} + error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse backstop. Package build verified. -- [x] **P4-2 [H]** `cloud/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate +- [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage error. DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit (shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood. -- [x] **P4-3 [M]** `cloud/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits +- [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired rows. DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded, gated 1/100). Full PHP suite green. -- [x] **P4-4 [M]** `cloud/src/Http/Router.php:18` — route params are never `rawurldecode`d; a +- [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a `camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode captured params. DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases. -- [x] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at +- [x] **P4-5 [M]** `server/src/Dashboard/EventListController.php:17` — event list hard-capped at newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination. DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory), a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes `limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`. -- [x] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a +- [x] **P4-6 [M]** `server/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a clear error. DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException) → `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double. -- [x] **P4-7 [L]** `cloud/src/Ingest/IngestController.php:64` — snapshot write failure is +- [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:** order write→insert, log failures, reconcile orphans. DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode, @@ -192,20 +192,20 @@ misconfig/degradation fail LOUD. the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent` (throwing repo double → asserts 201, event stored, no leftover *.jpg). -- [x] **P4-8 [L]** `cloud/src/Auth/AuthController.php:31` — login form emits a CSRF token that +- [x] **P4-8 [L]** `server/src/Auth/AuthController.php:31` — login form emits a CSRF token that `login` never checks. **Fix:** verify it (or drop it). DONE — verified (completed the half-built defense, not dropped). login() checks `Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added `testLoginRejectsMissingOrWrongCsrf`. -- [x] **P4-9 [L]** `cloud/src/Auth/AuthController.php:71` — logout calls `regenerate()` after +- [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after `session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder. DONE — logout() rotates the id then wipes (was regenerate-after-destroy). -- [x] **P4-10 [L]** `cloud/src/Ingest/EventValidator.php:26` — no string length caps → an +- [x] **P4-10 [L]** `server/src/Ingest/EventValidator.php:26` — no string length caps → an overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap lengths in the validator. DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest. -- [x] **P4-11 [L]** `cloud/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/` +- [x] **P4-11 [L]** `server/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/` (parse_url scheme-relative). **Fix:** collapse/normalize. ## Phase 5 — Install / bundle / supply-chain @@ -243,7 +243,7 @@ misconfig/degradation fail LOUD. `/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware. DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera. -- [x] **P5-6 [L]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs +- [x] **P5-6 [L]** `server/deploy/build_cafe24_package.sh` — dev cache dirs (`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude. --- @@ -253,7 +253,7 @@ misconfig/degradation fail LOUD. (updated as each item lands — newest first) - **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done. - P1-9: edge `event_uuid` idempotency key (stable through the queue) + cloud dedup on a UNIQUE + P1-9: edge `event_uuid` idempotency key (stable through the queue) + server dedup on a UNIQUE column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration + provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3: @@ -272,16 +272,16 @@ misconfig/degradation fail LOUD. · (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning knobs — tune with real camera footage on the Pi.) -- **2026-07-17 — P4-6 + P4-8 landed (cloud error/auth hardening).** ZoneConfig ownership-mismatch +- **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation (P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM). - **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared (P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH - correctness (P2-3,4,5). Remaining: P1-9 (deferred to cloud batch), P2-6..11 (mostly threshold + correctness (P2-3,4,5). Remaining: P1-9 (deferred to server batch), P2-6..11 (mostly threshold tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy - decision: how many days), Phase 4 (cloud hardening, 11 items — mostly clear), Phase 5 (P5-2..6). + decision: how many days), Phase 4 (server hardening, 11 items — mostly clear), Phase 5 (P5-2..6). Nothing committed yet. DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package. \ No newline at end of file diff --git a/docs/pilot-runbook.md b/docs/pilot-runbook.md index b6c21cc..ca3bd25 100644 --- a/docs/pilot-runbook.md +++ b/docs/pilot-runbook.md @@ -1,10 +1,10 @@ # P1 파일럿 런북 (노트북 웹캠 엔드투엔드) -이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다. +이 문서는 노트북 웹캠 하나로 **엣지 → 서버 → 관리자 응답**의 최소 경로를 돌리는 절차다. 실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6). - 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST. -- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림. +- 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림. --- @@ -25,23 +25,23 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose. --- -## 2. 클라우드 로컬 기동 +## 2. 서버 로컬 기동 로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은 -[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만. +[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만. -1. DB 생성 후 스키마 임포트: `mysql < cloud/schema.sql` (10개 테이블). -2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 +1. DB 생성 후 스키마 임포트: `mysql < server/schema.sql` (10개 테이블). +2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 (`snapshot_dir`는 쓰기 가능 경로로). 3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. - CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: + CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` - `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` - `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` -4. 기동: `php -S localhost:8000 -t cloud/public` +4. 기동: `php -S localhost:8000 -t server/public` - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 - `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조. + `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조. --- @@ -53,7 +53,7 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose. |-----|------|------| | `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` | | `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` | -| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` | +| `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` | | `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` | | `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` | | `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` | @@ -94,7 +94,7 @@ python -m edge # 또는 move-detect-edge ```bash pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip) -cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings) +cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # 서버(0 warnings) ``` 모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` — diff --git a/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md b/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md index 18444a0..f10a84e 100644 --- a/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md +++ b/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md @@ -1,4 +1,4 @@ -# P1-B 클라우드 수신·대시보드 (Cloud Ingest + Dashboard) Implementation Plan +# P1-B 서버 수신·대시보드 (Server Ingest + Dashboard) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. @@ -10,22 +10,22 @@ ## Global Constraints -- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `cloud/src/`). -- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `cloud/public/`만 웹 노출. +- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `server/src/`). +- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `server/public/`만 웹 노출. - **HMAC 계약(P1-A와 정확히 일치):** 서명 = `hmac-sha256(nonce + timestamp + body, per_device_secret)` hex. P1-A `edge/alert.py::sign`과 바이트 동일하게 검증. 헤더 `X-Signature`, `X-Nonce`, `X-Timestamp`. - **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초). -- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`cloud/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지. +- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`server/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지. - **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증. - **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer. - **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트. -- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`cloud/config.php`는 `.gitignore`), 하드코딩 금지. +- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`server/config.php`는 `.gitignore`), 하드코딩 금지. - **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24). - 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로. ## File Structure ``` -cloud/ +server/ composer.json # PSR-4 autoload + phpunit(dev) phpunit.xml config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore) @@ -64,7 +64,7 @@ cloud/ ## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit) **Files:** -- Create: `cloud/composer.json`, `cloud/phpunit.xml`, `cloud/src/Config.php`, `cloud/config.example.php`, `cloud/tests/ConfigTest.php`, `cloud/.gitignore` +- Create: `server/composer.json`, `server/phpunit.xml`, `server/src/Config.php`, `server/config.example.php`, `server/tests/ConfigTest.php`, `server/.gitignore` **Interfaces:** - Consumes: (없음) @@ -74,7 +74,7 @@ cloud/ ```json { - "name": "move-detect/cloud", + "name": "move-detect/server", "type": "project", "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11" }, @@ -95,7 +95,7 @@ cloud/ ``` -- [ ] **Step 3: `.gitignore`** (cloud/) +- [ ] **Step 3: `.gitignore`** (server/) ``` /vendor/ @@ -104,7 +104,7 @@ cloud/ !/storage/snapshots/.gitkeep ``` -- [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php` +- [ ] **Step 4: 실패 테스트** — `server/tests/ConfigTest.php` ```php bool, 'errors' => string[], 'clean' => array]`. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목. -- [ ] **Step 1: 실패 테스트** — `cloud/tests/EventValidatorTest.php` +- [ ] **Step 1: 실패 테스트** — `server/tests/EventValidatorTest.php` ```php bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]`. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 `['ok'=>false]`. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트). -- [ ] **Step 1: 실패 테스트** — `cloud/tests/ImageProcessorTest.php` +- [ ] **Step 1: 실패 테스트** — `server/tests/ImageProcessorTest.php` ```php add('GET', '/zones', fn() => $render('zones')); $r->dispatch(Request::fromGlobals())->send(); ``` -- [ ] **Step 4: .htaccess** — `cloud/public/.htaccess` +- [ ] **Step 4: .htaccess** — `server/public/.htaccess` ```apache RewriteEngine On @@ -1994,8 +1994,8 @@ RewriteRule ^ index.php [QSA,L] Run: `./vendor/bin/phpunit` (전체) — green. ```bash -git add cloud/public cloud/src/View cloud/tests/WiringSmokeTest.php -git commit -m "feat(cloud): front controller wiring + views + zone-draw UI" +git add server/public server/src/View server/tests/WiringSmokeTest.php +git commit -m "feat(server): front controller wiring + views + zone-draw UI" ``` --- @@ -2005,13 +2005,13 @@ git commit -m "feat(cloud): front controller wiring + views + zone-draw UI" > ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차. **Files:** -- Create: `cloud/src/Db/Database.php`, `cloud/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `cloud/schema.sql`, `cloud/docs/cloud-deploy.md`, `cloud/tests/PdoIntegrationTest.php` +- Create: `server/src/Db/Database.php`, `server/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `server/schema.sql`, `server/docs/server-deploy.md`, `server/tests/PdoIntegrationTest.php` **Interfaces:** - Consumes: repository 인터페이스(T3), `Config`(T1) - Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement). -- [ ] **Step 1: Database factory** — `cloud/src/Db/Database.php` +- [ ] **Step 1: Database factory** — `server/src/Db/Database.php` ```php **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(클라우드) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 클라우드 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.) +**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(서버) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 서버 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.) -**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 클라우드 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) CloudClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 클라우드 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트. +**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 서버 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) ServerClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 서버 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트. -**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 클라우드는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지. +**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 서버는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지. ## Global Constraints -- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 클라우드 스위트 **0 warnings/0 deprecations** 유지. +- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 서버 스위트 **0 warnings/0 deprecations** 유지. - **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`). - **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐. -- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(클라우드 T14가 이미 검증). PostClient 서명 재사용. +- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(서버 T14가 이미 검증). PostClient 서명 재사용. - **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑. - YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음. @@ -21,7 +21,7 @@ ``` edge/ - cloudclient.py # CloudClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST) + serverclient.py # ServerClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST) zones.py # zone rows(dict) -> Zone objects; thresholds runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones() daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main() @@ -30,33 +30,33 @@ edge/ movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap tools/ fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/ -cloud/ +server/ src/View/event_detail.php # (replace stub) HTML detail shell + script public/assets/detail.js # fetch GET /events/{id} -> render meta + public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical public/index.php # (edit) add GET /events/{id}/view HTML route -tests/ (edge) + cloud/tests/ +tests/ (edge) + server/tests/ docs/pilot-runbook.md ``` --- -## Task 1: CloudClient — config 폴링 + 하트비트 (HMAC 서명) +## Task 1: ServerClient — config 폴링 + 하트비트 (HMAC 서명) -**Files:** Create `edge/cloudclient.py`, `tests/test_cloudclient.py` +**Files:** Create `edge/serverclient.py`, `tests/test_serverclient.py` **Interfaces:** - Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`. - Produces: - - `CloudClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)` + - `ServerClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)` - `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None. - `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx. -- [ ] **Step 1: 실패 테스트** — `tests/test_cloudclient.py` +- [ ] **Step 1: 실패 테스트** — `tests/test_serverclient.py` ```python import json -from edge.cloudclient import CloudClient +from edge.serverclient import ServerClient from edge.alert import sign class _Resp: @@ -68,30 +68,30 @@ def test_fetch_config_signs_and_parses(): def fake_get(url, headers=None, timeout=None): captured["url"] = url; captured["headers"] = headers return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}})) - c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get) + c = ServerClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get) cfg = c.fetch_config() assert cfg["version"] == 3 assert "device_id=pi-1" in captured["url"] assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "") def test_fetch_config_none_on_error(): - c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401)) + c = ServerClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401)) assert c.fetch_config() is None def test_send_heartbeat_signs_body(): captured = {} def fake_post(url, data=None, headers=None, timeout=None): captured["data"] = data; captured["headers"] = headers; return _Resp(200) - c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post) + c = ServerClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post) assert c.send_heartbeat({"fps": 12.0}) is True body = captured["data"] assert json.loads(body)["device_id"] == "pi-1" assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body) ``` -- [ ] **Step 2: 실패 확인** — `pytest tests/test_cloudclient.py -v` → FAIL (ModuleNotFoundError) +- [ ] **Step 2: 실패 확인** — `pytest tests/test_serverclient.py -v` → FAIL (ModuleNotFoundError) -- [ ] **Step 3: 구현** — `edge/cloudclient.py` +- [ ] **Step 3: 구현** — `edge/serverclient.py` ```python import json @@ -99,7 +99,7 @@ import json from edge.alert import sign -class CloudClient: +class ServerClient: def __init__(self, base_url: str, device_id: str, secret: str, nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0): self.base_url = base_url.rstrip("/") @@ -146,16 +146,16 @@ class CloudClient: return 200 <= resp.status_code < 300 ``` -- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_cloudclient.py -v` → PASS +- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_serverclient.py -v` → PASS ```bash -git add edge/cloudclient.py tests/test_cloudclient.py -git commit -m "feat(edge): CloudClient for config poll + heartbeat (HMAC-signed)" +git add edge/serverclient.py tests/test_serverclient.py +git commit -m "feat(edge): ServerClient for config poll + heartbeat (HMAC-signed)" ``` --- -## Task 2: 클라우드 zone 행 → Zone 객체 로딩 +## Task 2: 서버 zone 행 → Zone 객체 로딩 **Files:** Create `edge/zones.py`, `tests/test_zones.py` @@ -225,7 +225,7 @@ def thresholds_from_config(config: dict) -> dict: ```bash git add edge/zones.py tests/test_zones.py -git commit -m "feat(edge): parse cloud config zones/thresholds into Zone objects" +git commit -m "feat(edge): parse server config zones/thresholds into Zone objects" ``` --- @@ -341,7 +341,7 @@ git commit -m "feat(edge): PipelineRuntime assembles components + hot-swaps zone **Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml` **Interfaces:** -- Consumes: `PipelineRuntime` (T3), `CloudClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py). +- Consumes: `PipelineRuntime` (T3), `ServerClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py). - Produces: - `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict. - `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리) @@ -427,7 +427,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, def main() -> None: # pragma: no cover (integration entrypoint, exercised on device) import time from edge.config import load_config - from edge.cloudclient import CloudClient + from edge.serverclient import ServerClient from edge.alert import PostClient from edge.queue import OfflineQueue from edge.runtime import PipelineRuntime @@ -438,7 +438,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de nonce = lambda: os.urandom(8).hex() now = time.time base = cfg.cafe24_url.rstrip("/") - client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now) + client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post) source = UvcSource(int(os.environ.get("UVC_DEVICE", "0"))) @@ -591,15 +591,15 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)" --- -## Task 6: 클라우드 사고 상세 뷰 — 스냅샷 표시 +## Task 6: 서버 사고 상세 뷰 — 스냅샷 표시 -**Files:** Modify `cloud/src/View/event_detail.php`; Create `cloud/public/assets/detail.js`; Modify `cloud/public/index.php`; Modify `cloud/tests/WiringSmokeTest.php` +**Files:** Modify `server/src/View/event_detail.php`; Create `server/public/assets/detail.js`; Modify `server/public/index.php`; Modify `server/tests/WiringSmokeTest.php` **Interfaces:** - Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image). - Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + ` ``` -- [ ] **Step 3: detail.js** — `cloud/public/assets/detail.js` +- [ ] **Step 3: detail.js** — `server/public/assets/detail.js` ```javascript (function () { @@ -653,18 +653,18 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)" $r->add('GET', '/events/{id}/view', fn() => $render('event_detail')); ``` -- [ ] **Step 5: 통과 + 커밋** — 전체 `cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l cloud/public/index.php`. +- [ ] **Step 5: 통과 + 커밋** — 전체 `cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l server/public/index.php`. ```bash -git add cloud/src/View/event_detail.php cloud/public/assets/detail.js cloud/public/index.php cloud/tests/WiringSmokeTest.php -git commit -m "feat(cloud): incident detail view renders snapshot images" +git add server/src/View/event_detail.php server/public/assets/detail.js server/public/index.php server/tests/WiringSmokeTest.php +git commit -m "feat(server): incident detail view renders snapshot images" ``` --- ## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio -**Files:** Modify `cloud/public/assets/app.js` +**Files:** Modify `server/public/assets/app.js` **Interfaces:** - Consumes: `GET /api/events` (기존 폴링). @@ -702,11 +702,11 @@ function updateAlarm(events) { 그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출. -- [ ] **Step 2: 통과 + 커밋** — `node --check cloud/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경). +- [ ] **Step 2: 통과 + 커밋** — `node --check server/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경). ```bash -git add cloud/public/assets/app.js -git commit -m "feat(cloud): dashboard rows link to detail + desk Web Audio on critical" +git add server/public/assets/app.js +git commit -m "feat(server): dashboard rows link to detail + desk Web Audio on critical" ``` --- @@ -735,8 +735,8 @@ def test_real_movenet_runs(): - [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차: 1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`). - 2. **클라우드:** `php -S localhost:8000 -t cloud/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록. - 3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(클라우드와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`. + 2. **서버:** `php -S localhost:8000 -t server/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록. + 3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(서버와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`. 4. **실행:** `move-detect-edge` (또는 `python -m edge`). 5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush. 6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속. @@ -753,7 +753,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test" ## Self-Review **1. 최소경로 커버리지 (기능검토 1~7):** -- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→클라우드 흐름(3) → T4 데몬+기존 PostClient ✓ +- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→서버 흐름(3) → T4 데몬+기존 PostClient ✓ - 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓ - config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓ - **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지). @@ -761,7 +761,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test" **2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시). -**3. Type consistency:** `CloudClient.sign` = PostClient/클라우드 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관. +**3. Type consistency:** `ServerClient.sign` = PostClient/서버 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관. --- diff --git a/edge/alert.py b/edge/alert.py index dcf6919..fb3953e 100644 --- a/edge/alert.py +++ b/edge/alert.py @@ -8,7 +8,7 @@ from edge.events import Event # Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent # with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC -# and URL. Send an explicit UA on every request. CloudClient imports this too. +# and URL. Send an explicit UA on every request. ServerClient imports this too. USER_AGENT = "move-detect-edge/1.0" @@ -65,7 +65,7 @@ class PostClient: resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout, allow_redirects=False) except Exception: - # Connection refused / DNS / timeout — the cloud is unreachable, not rejecting us. + # Connection refused / DNS / timeout — the server is unreachable, not rejecting us. return SendResult.TRANSIENT code = resp.status_code if 200 <= code < 300: diff --git a/edge/config.py b/edge/config.py index 659ffd7..eac65cf 100644 --- a/edge/config.py +++ b/edge/config.py @@ -49,7 +49,7 @@ def queue_db_path(env: dict) -> str: # Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real # provisioned value. A unit left at any of these uploads nothing while looking healthy. _PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx") -# Per-device secrets issued by the cloud are HMAC-derived (long hex); anything shorter is a +# Per-device secrets issued by the server are HMAC-derived (long hex); anything shorter is a # stub left in the template, not a real key. _MIN_SECRET_LEN = 16 @@ -82,13 +82,13 @@ def require_upload_config(cfg: Config) -> None: problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)") elif len(secret) < _MIN_SECRET_LEN: problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the " - "per-device secret issued by the cloud, not a stub") + "per-device secret issued by the server, not a stub") if problems: raise ValueError( "FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run " "but upload nothing. CAFE24_URL must include the app path, e.g. " - "https:///move_detect ; DEVICE_SECRET is the per-device secret from the cloud." + "https:///move_detect ; DEVICE_SECRET is the per-device secret from the server." ) diff --git a/edge/daemon.py b/edge/daemon.py index 710b770..bea7708 100644 --- a/edge/daemon.py +++ b/edge/daemon.py @@ -63,7 +63,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, counters["syncs"] += 1 if heartbeat_every and i % heartbeat_every == 0: # Measured fps over the window since the last beat, and per-camera health, so - # the cloud can tell a live device from a degraded/dead one (was hardcoded 0.0 + # the server can tell a live device from a degraded/dead one (was hardcoded 0.0 # + a frames counter that ticked even when no frame was read). t0, f0 = beat_anchor elapsed = now - t0 @@ -133,7 +133,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de from dataclasses import replace from edge.config import (frame_interval_from_env, load_config, queue_db_path, require_upload_config) - from edge.cloudclient import CloudClient + from edge.serverclient import ServerClient from edge.alert import PostClient from edge.queue import OfflineQueue from edge.runtime import PipelineRuntime @@ -161,7 +161,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de nonce = lambda: os.urandom(8).hex() now = time.time base = cfg.cafe24_url.rstrip("/") - client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now) + client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) queue = OfflineQueue(queue_db_path(dict(os.environ)), post) diff --git a/edge/events.py b/edge/events.py index 5c8fb04..4590323 100644 --- a/edge/events.py +++ b/edge/events.py @@ -52,7 +52,7 @@ class Event: spatial: tuple | None # (x, y) 유령사고 등 공간 기준 # Stable idempotency key: generated ONCE at event creation and carried unchanged through the # offline queue and every retry, so a POST that succeeded server-side but timed out - # client-side is deduplicated by the cloud (see cloud IngestController) instead of stored twice. + # client-side is deduplicated by the server (see server IngestController) instead of stored twice. event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex) def to_payload(self, device_id: str) -> dict: diff --git a/edge/queue.py b/edge/queue.py index c7aaf4b..a8709a4 100644 --- a/edge/queue.py +++ b/edge/queue.py @@ -27,7 +27,7 @@ class OfflineQueue: def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]: # Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the - # cloud dedups it. Rows queued before this field existed simply get a fresh uuid (they + # server dedups it. Rows queued before this field existed simply get a fresh uuid (they # predate dedup, so a first-and-only delivery is correct for them). uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {} e = Event(EventType(payload["event_type"]), Severity(payload["severity"]), @@ -70,7 +70,7 @@ class OfflineQueue: # A row queued before event_uuid existed has none stored; _rehydrate minted a fresh # one just now. Persist it so every retry of this row reuses the SAME key — otherwise # each flush sends a different uuid and a timeout-retried delivery duplicates on the - # cloud (the P1-9 bug, still open for transitional rows). + # server (the P1-9 bug, still open for transitional rows). if not payload.get("event_uuid"): payload["event_uuid"] = event.event_uuid self.db.execute("UPDATE queue SET payload = ? WHERE id = ?", diff --git a/edge/cloudclient.py b/edge/serverclient.py similarity index 99% rename from edge/cloudclient.py rename to edge/serverclient.py index 8ddbec3..1182abf 100644 --- a/edge/cloudclient.py +++ b/edge/serverclient.py @@ -3,7 +3,7 @@ import json from edge.alert import USER_AGENT, sign -class CloudClient: +class ServerClient: def __init__(self, base_url: str, device_id: str, secret: str, nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0): self.base_url = base_url.rstrip("/") diff --git a/edge/zones.py b/edge/zones.py index d075910..9b2ea7c 100644 --- a/edge/zones.py +++ b/edge/zones.py @@ -18,7 +18,7 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool: def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: - """Parse cloud config zone rows into Zone objects. When camera_id is given, only + """Parse server config zone rows into Zone objects. When camera_id is given, only rows for that camera are kept (multi-camera routing); None keeps all rows.""" out: list[Zone] = [] rows = config.get("zones", []) or [] diff --git a/cloud/.gitignore b/server/.gitignore similarity index 100% rename from cloud/.gitignore rename to server/.gitignore diff --git a/cloud/composer.json b/server/composer.json similarity index 91% rename from cloud/composer.json rename to server/composer.json index bd8621c..b978fca 100644 --- a/cloud/composer.json +++ b/server/composer.json @@ -1,5 +1,5 @@ { - "name": "move-detect/cloud", + "name": "move-detect/server", "type": "project", "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11" }, diff --git a/cloud/composer.lock b/server/composer.lock similarity index 100% rename from cloud/composer.lock rename to server/composer.lock diff --git a/cloud/config.example.php b/server/config.example.php similarity index 100% rename from cloud/config.example.php rename to server/config.example.php diff --git a/cloud/deploy/README-UPLOAD.md b/server/deploy/README-UPLOAD.md similarity index 100% rename from cloud/deploy/README-UPLOAD.md rename to server/deploy/README-UPLOAD.md diff --git a/cloud/deploy/build_cafe24_package.sh b/server/deploy/build_cafe24_package.sh similarity index 91% rename from cloud/deploy/build_cafe24_package.sh rename to server/deploy/build_cafe24_package.sh index 7096db5..3bffef5 100755 --- a/cloud/deploy/build_cafe24_package.sh +++ b/server/deploy/build_cafe24_package.sh @@ -5,11 +5,11 @@ # THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a # folder. The hosting account executes nothing — no shell, no composer, no installer: the # operator uploads files over FTP and edits config.php. That is also why this lives under -# cloud/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as +# server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as # root on the target. # # Run on the BUILD machine (needs composer + php — dev-only tools, never on the host): -# cloud/deploy/build_cafe24_package.sh +# server/deploy/build_cafe24_package.sh # # Output: dist/cafe24/ and dist/move-detect-cafe24.zip # @@ -42,7 +42,7 @@ APP_DIR="${APP_DIR-move_detect}" PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CLOUD="$REPO_ROOT/cloud" +SERVER="$REPO_ROOT/server" OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}" STAGE="$OUT_DIR/cafe24" ZIP="$OUT_DIR/move-detect-cafe24.zip" @@ -75,15 +75,15 @@ echo ">> [2/6] generating the no-dev autoloader" # would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's # `new PhpSessionStore()` — the 4th statement of every request. # -# --no-interaction so a build box without a TTY cannot hang. Composer rewrites cloud/vendor/ +# --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/ # in place, so restore the dev autoloader on exit or local phpunit breaks. restore_dev_autoloader() { - echo ">> restoring the dev autoloader in cloud/vendor (so local phpunit keeps working)" - ( cd "$CLOUD" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true + echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)" + ( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true } trap restore_dev_autoloader EXIT -( cd "$CLOUD" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction ) +( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction ) # Precondition for everything below: this package stages ONLY composer's generated # autoloader, which is correct exactly while the app has no runtime packages. The day @@ -91,7 +91,7 @@ trap restore_dev_autoloader EXIT # the host fatals on a missing class. Fail here, on the commit that adds the dependency, # rather than on the hosting account. php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \ - "$CLOUD/composer.lock" || { + "$SERVER/composer.lock" || { echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2 echo " autoloader, so their code would be missing from the package. Teach" >&2 echo " step [3/6] to stage the real vendor tree before shipping again." >&2 @@ -100,14 +100,14 @@ php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)" mkdir -p "$PRIV/vendor/composer" -cp "$CLOUD/vendor/autoload.php" "$PRIV/vendor/autoload.php" +cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php" # Copy only composer's own generated files, by name. A glob over vendor/* would drag in # phpunit/, sebastian/, myclabs/ and friends. for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \ autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do - cp "$CLOUD/vendor/composer/$f" "$PRIV/vendor/composer/$f" + cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f" done -cp "$CLOUD/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true +cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true # installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not # regenerate them — they are the package inventory written by the last `composer install`, @@ -141,18 +141,18 @@ if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep - fi echo ">> [4/6] staging app source" -cp -r "$CLOUD/src/." "$PRIV/src/" -cp -r "$CLOUD/public/." "$WEB/" -cp "$CLOUD/schema.sql" "$PRIV/schema.sql" -# The hardened DocumentRoot .htaccess replaces cloud/public/'s dev-local rewrite-only one. -cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess" -cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php" -cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php" +cp -r "$SERVER/src/." "$PRIV/src/" +cp -r "$SERVER/public/." "$WEB/" +cp "$SERVER/schema.sql" "$PRIV/schema.sql" +# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one. +cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess" +cp "$SERVER/deploy/config.template.php" "$PRIV/config.php" +cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php" # .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On). -cp "$CLOUD/deploy/user.ini" "$WEB/.user.ini" -cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md" +cp "$SERVER/deploy/user.ini" "$WEB/.user.ini" +cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md" -# `cp -r` above copies whatever is in cloud/src|public — defensively strip dev/tooling caches +# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches # so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served # package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed. find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \ diff --git a/cloud/deploy/config.template.php b/server/deploy/config.template.php similarity index 100% rename from cloud/deploy/config.template.php rename to server/deploy/config.template.php diff --git a/cloud/deploy/htaccess.www b/server/deploy/htaccess.www similarity index 100% rename from cloud/deploy/htaccess.www rename to server/deploy/htaccess.www diff --git a/cloud/deploy/preflight.php b/server/deploy/preflight.php similarity index 100% rename from cloud/deploy/preflight.php rename to server/deploy/preflight.php diff --git a/cloud/deploy/user.ini b/server/deploy/user.ini similarity index 100% rename from cloud/deploy/user.ini rename to server/deploy/user.ini diff --git a/cloud/docs/cloud-deploy.md b/server/docs/server-deploy.md similarity index 94% rename from cloud/docs/cloud-deploy.md rename to server/docs/server-deploy.md index 9e23ac7..a7531d4 100644 --- a/cloud/docs/cloud-deploy.md +++ b/server/docs/server-deploy.md @@ -1,13 +1,13 @@ -# Cafe24 Cloud Deploy Guide (P1-B) +# Cafe24 Server Deploy Guide (P1-B) -This document covers deploying the `cloud/` PHP application (ingest API + dashboard) to a +This document covers deploying the `server/` PHP application (ingest API + dashboard) to a Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with prepared statements — there is nothing to `composer install` on the production host. ## 1. Directory layout on the server -Only `cloud/public/` may be reachable from the web. Everything else must sit **outside** +Only `server/public/` may be reachable from the web. Everything else must sit **outside** the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is), or be blocked by hosting-level access rules if your plan cannot place files outside the web root at all. @@ -27,7 +27,7 @@ web root at all. └── assets/ sit at the FTP root — see below. ``` -`cloud/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from +`server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a subdirectory of one — in the latter case the app root is two levels up, not one, because @@ -39,7 +39,7 @@ views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without t declared as `/login` would 404 under `https://host/move_detect/login` and every asset would resolve against the co-hosted site. -`cloud/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=` +`server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=` (empty) to mount the app directly in `www/`. **`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional @@ -49,7 +49,7 @@ every request, including §10's own smoke check. There are no runtime Composer * autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine: ```bash -cd cloud && composer dump-autoload --no-dev --classmap-authoritative --no-interaction +cd server && composer dump-autoload --no-dev --classmap-authoritative --no-interaction ``` That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy @@ -63,7 +63,7 @@ deliberately excludes. them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`. -`cloud/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the +`server/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the tree below automatically — prefer it over assembling the transfer by hand. If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is @@ -81,7 +81,7 @@ web-readable. Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over SSH if available) — do not assume, since PHP builds vary by hosting plan: -- [ ] **PHP version** matches `cloud/composer.json`'s `"php": ">=8.2"` (the app is written +- [ ] **PHP version** matches `server/composer.json`'s `"php": ">=8.2"` (the app is written and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact version Cafe24 provisions for your plan). - [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include @@ -112,7 +112,7 @@ SSH if available) — do not assume, since PHP builds vary by hosting plan: ## 3. `config.php` Copy the example and fill in real values — **never commit `config.php`** (already covered -by `cloud/.gitignore`): +by `server/.gitignore`): ```bash cp config.example.php config.php @@ -139,7 +139,7 @@ or version control. ## 4. Import the schema -`cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`, +`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`, `events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`). 1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this diff --git a/cloud/migrations/2026-07-17-p1-9-p3-2b.sql b/server/migrations/2026-07-17-p1-9-p3-2b.sql similarity index 100% rename from cloud/migrations/2026-07-17-p1-9-p3-2b.sql rename to server/migrations/2026-07-17-p1-9-p3-2b.sql diff --git a/cloud/migrations/2026-07-20-move-table-prefix.sql b/server/migrations/2026-07-20-move-table-prefix.sql similarity index 100% rename from cloud/migrations/2026-07-20-move-table-prefix.sql rename to server/migrations/2026-07-20-move-table-prefix.sql diff --git a/cloud/phpunit.xml b/server/phpunit.xml similarity index 100% rename from cloud/phpunit.xml rename to server/phpunit.xml diff --git a/cloud/public/.htaccess b/server/public/.htaccess similarity index 100% rename from cloud/public/.htaccess rename to server/public/.htaccess diff --git a/cloud/public/assets/app.css b/server/public/assets/app.css similarity index 100% rename from cloud/public/assets/app.css rename to server/public/assets/app.css diff --git a/cloud/public/assets/app.js b/server/public/assets/app.js similarity index 100% rename from cloud/public/assets/app.js rename to server/public/assets/app.js diff --git a/cloud/public/assets/detail.js b/server/public/assets/detail.js similarity index 100% rename from cloud/public/assets/detail.js rename to server/public/assets/detail.js diff --git a/cloud/public/assets/login.js b/server/public/assets/login.js similarity index 100% rename from cloud/public/assets/login.js rename to server/public/assets/login.js diff --git a/cloud/public/assets/zone-draw.js b/server/public/assets/zone-draw.js similarity index 100% rename from cloud/public/assets/zone-draw.js rename to server/public/assets/zone-draw.js diff --git a/cloud/public/index.php b/server/public/index.php similarity index 100% rename from cloud/public/index.php rename to server/public/index.php diff --git a/cloud/schema.sql b/server/schema.sql similarity index 100% rename from cloud/schema.sql rename to server/schema.sql diff --git a/cloud/src/Api/ConfigController.php b/server/src/Api/ConfigController.php similarity index 100% rename from cloud/src/Api/ConfigController.php rename to server/src/Api/ConfigController.php diff --git a/cloud/src/Api/HeartbeatController.php b/server/src/Api/HeartbeatController.php similarity index 100% rename from cloud/src/Api/HeartbeatController.php rename to server/src/Api/HeartbeatController.php diff --git a/cloud/src/Auth/AuthController.php b/server/src/Auth/AuthController.php similarity index 100% rename from cloud/src/Auth/AuthController.php rename to server/src/Auth/AuthController.php diff --git a/cloud/src/Auth/CsrfController.php b/server/src/Auth/CsrfController.php similarity index 100% rename from cloud/src/Auth/CsrfController.php rename to server/src/Auth/CsrfController.php diff --git a/cloud/src/Auth/Rbac.php b/server/src/Auth/Rbac.php similarity index 100% rename from cloud/src/Auth/Rbac.php rename to server/src/Auth/Rbac.php diff --git a/cloud/src/Auth/Session.php b/server/src/Auth/Session.php similarity index 100% rename from cloud/src/Auth/Session.php rename to server/src/Auth/Session.php diff --git a/cloud/src/Config.php b/server/src/Config.php similarity index 100% rename from cloud/src/Config.php rename to server/src/Config.php diff --git a/cloud/src/Dashboard/EventDetailController.php b/server/src/Dashboard/EventDetailController.php similarity index 100% rename from cloud/src/Dashboard/EventDetailController.php rename to server/src/Dashboard/EventDetailController.php diff --git a/cloud/src/Dashboard/EventListController.php b/server/src/Dashboard/EventListController.php similarity index 100% rename from cloud/src/Dashboard/EventListController.php rename to server/src/Dashboard/EventListController.php diff --git a/cloud/src/Dashboard/SnapshotProxy.php b/server/src/Dashboard/SnapshotProxy.php similarity index 100% rename from cloud/src/Dashboard/SnapshotProxy.php rename to server/src/Dashboard/SnapshotProxy.php diff --git a/cloud/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php similarity index 100% rename from cloud/src/Dashboard/ZoneConfigController.php rename to server/src/Dashboard/ZoneConfigController.php diff --git a/cloud/src/Db/Database.php b/server/src/Db/Database.php similarity index 100% rename from cloud/src/Db/Database.php rename to server/src/Db/Database.php diff --git a/cloud/src/Geometry/Polygon.php b/server/src/Geometry/Polygon.php similarity index 100% rename from cloud/src/Geometry/Polygon.php rename to server/src/Geometry/Polygon.php diff --git a/cloud/src/Http/Request.php b/server/src/Http/Request.php similarity index 100% rename from cloud/src/Http/Request.php rename to server/src/Http/Request.php diff --git a/cloud/src/Http/Response.php b/server/src/Http/Response.php similarity index 100% rename from cloud/src/Http/Response.php rename to server/src/Http/Response.php diff --git a/cloud/src/Http/Router.php b/server/src/Http/Router.php similarity index 100% rename from cloud/src/Http/Router.php rename to server/src/Http/Router.php diff --git a/cloud/src/Ingest/EventValidator.php b/server/src/Ingest/EventValidator.php similarity index 97% rename from cloud/src/Ingest/EventValidator.php rename to server/src/Ingest/EventValidator.php index 600ec2a..cc25ded 100644 --- a/cloud/src/Ingest/EventValidator.php +++ b/server/src/Ingest/EventValidator.php @@ -32,7 +32,7 @@ final class EventValidator if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id'; if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s'; // Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends - // it on retry so the cloud can dedup. Enforce the exact shape (matches CHAR(32) + the + // it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the // snapshot-uuid convention); a malformed key is a bug, not a silent pass. if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) { $errors[] = 'bad:event_uuid'; diff --git a/cloud/src/Ingest/ImageProcessor.php b/server/src/Ingest/ImageProcessor.php similarity index 100% rename from cloud/src/Ingest/ImageProcessor.php rename to server/src/Ingest/ImageProcessor.php diff --git a/cloud/src/Ingest/IngestController.php b/server/src/Ingest/IngestController.php similarity index 100% rename from cloud/src/Ingest/IngestController.php rename to server/src/Ingest/IngestController.php diff --git a/cloud/src/Repository/AuditRepository.php b/server/src/Repository/AuditRepository.php similarity index 100% rename from cloud/src/Repository/AuditRepository.php rename to server/src/Repository/AuditRepository.php diff --git a/cloud/src/Repository/DeviceRepository.php b/server/src/Repository/DeviceRepository.php similarity index 100% rename from cloud/src/Repository/DeviceRepository.php rename to server/src/Repository/DeviceRepository.php diff --git a/cloud/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php similarity index 100% rename from cloud/src/Repository/EventRepository.php rename to server/src/Repository/EventRepository.php diff --git a/cloud/src/Repository/NonceRepository.php b/server/src/Repository/NonceRepository.php similarity index 100% rename from cloud/src/Repository/NonceRepository.php rename to server/src/Repository/NonceRepository.php diff --git a/cloud/src/Repository/Pdo/PdoAuditRepository.php b/server/src/Repository/Pdo/PdoAuditRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoAuditRepository.php rename to server/src/Repository/Pdo/PdoAuditRepository.php diff --git a/cloud/src/Repository/Pdo/PdoDeviceRepository.php b/server/src/Repository/Pdo/PdoDeviceRepository.php similarity index 96% rename from cloud/src/Repository/Pdo/PdoDeviceRepository.php rename to server/src/Repository/Pdo/PdoDeviceRepository.php index a67c9aa..720802d 100644 --- a/cloud/src/Repository/Pdo/PdoDeviceRepository.php +++ b/server/src/Repository/Pdo/PdoDeviceRepository.php @@ -31,7 +31,7 @@ final class PdoDeviceRepository implements DeviceRepository // A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController) // only reaches this after secretFor() already succeeded for the same // device_id, so the row is guaranteed to exist. Device rows are provisioned - // out-of-band by an admin (see cloud/docs/cloud-deploy.md), never created + // out-of-band by an admin (see server/docs/server-deploy.md), never created // implicitly from an inbound heartbeat. $st = $this->pdo->prepare( 'UPDATE move_devices diff --git a/cloud/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoEventRepository.php rename to server/src/Repository/Pdo/PdoEventRepository.php diff --git a/cloud/src/Repository/Pdo/PdoNonceRepository.php b/server/src/Repository/Pdo/PdoNonceRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoNonceRepository.php rename to server/src/Repository/Pdo/PdoNonceRepository.php diff --git a/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php b/server/src/Repository/Pdo/PdoRateLimiterRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoRateLimiterRepository.php rename to server/src/Repository/Pdo/PdoRateLimiterRepository.php diff --git a/cloud/src/Repository/Pdo/PdoSnapshotRepository.php b/server/src/Repository/Pdo/PdoSnapshotRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoSnapshotRepository.php rename to server/src/Repository/Pdo/PdoSnapshotRepository.php diff --git a/cloud/src/Repository/Pdo/PdoThresholdRepository.php b/server/src/Repository/Pdo/PdoThresholdRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoThresholdRepository.php rename to server/src/Repository/Pdo/PdoThresholdRepository.php diff --git a/cloud/src/Repository/Pdo/PdoUserRepository.php b/server/src/Repository/Pdo/PdoUserRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoUserRepository.php rename to server/src/Repository/Pdo/PdoUserRepository.php diff --git a/cloud/src/Repository/Pdo/PdoZoneRepository.php b/server/src/Repository/Pdo/PdoZoneRepository.php similarity index 100% rename from cloud/src/Repository/Pdo/PdoZoneRepository.php rename to server/src/Repository/Pdo/PdoZoneRepository.php diff --git a/cloud/src/Repository/RateLimiterRepository.php b/server/src/Repository/RateLimiterRepository.php similarity index 100% rename from cloud/src/Repository/RateLimiterRepository.php rename to server/src/Repository/RateLimiterRepository.php diff --git a/cloud/src/Repository/SnapshotRepository.php b/server/src/Repository/SnapshotRepository.php similarity index 100% rename from cloud/src/Repository/SnapshotRepository.php rename to server/src/Repository/SnapshotRepository.php diff --git a/cloud/src/Repository/ThresholdRepository.php b/server/src/Repository/ThresholdRepository.php similarity index 100% rename from cloud/src/Repository/ThresholdRepository.php rename to server/src/Repository/ThresholdRepository.php diff --git a/cloud/src/Repository/UserRepository.php b/server/src/Repository/UserRepository.php similarity index 100% rename from cloud/src/Repository/UserRepository.php rename to server/src/Repository/UserRepository.php diff --git a/cloud/src/Repository/ZoneOwnershipException.php b/server/src/Repository/ZoneOwnershipException.php similarity index 100% rename from cloud/src/Repository/ZoneOwnershipException.php rename to server/src/Repository/ZoneOwnershipException.php diff --git a/cloud/src/Repository/ZoneRepository.php b/server/src/Repository/ZoneRepository.php similarity index 100% rename from cloud/src/Repository/ZoneRepository.php rename to server/src/Repository/ZoneRepository.php diff --git a/cloud/src/Security/Csrf.php b/server/src/Security/Csrf.php similarity index 100% rename from cloud/src/Security/Csrf.php rename to server/src/Security/Csrf.php diff --git a/cloud/src/Security/Hmac.php b/server/src/Security/Hmac.php similarity index 100% rename from cloud/src/Security/Hmac.php rename to server/src/Security/Hmac.php diff --git a/cloud/src/Security/Password.php b/server/src/Security/Password.php similarity index 100% rename from cloud/src/Security/Password.php rename to server/src/Security/Password.php diff --git a/cloud/src/Security/SiteScope.php b/server/src/Security/SiteScope.php similarity index 100% rename from cloud/src/Security/SiteScope.php rename to server/src/Security/SiteScope.php diff --git a/cloud/src/View/event_detail.php b/server/src/View/event_detail.php similarity index 100% rename from cloud/src/View/event_detail.php rename to server/src/View/event_detail.php diff --git a/cloud/src/View/events.php b/server/src/View/events.php similarity index 100% rename from cloud/src/View/events.php rename to server/src/View/events.php diff --git a/cloud/src/View/layout.php b/server/src/View/layout.php similarity index 100% rename from cloud/src/View/layout.php rename to server/src/View/layout.php diff --git a/cloud/src/View/login.php b/server/src/View/login.php similarity index 100% rename from cloud/src/View/login.php rename to server/src/View/login.php diff --git a/cloud/src/View/zones.php b/server/src/View/zones.php similarity index 100% rename from cloud/src/View/zones.php rename to server/src/View/zones.php diff --git a/cloud/storage/snapshots/.gitkeep b/server/storage/snapshots/.gitkeep similarity index 100% rename from cloud/storage/snapshots/.gitkeep rename to server/storage/snapshots/.gitkeep diff --git a/cloud/tests/ApiSyncTest.php b/server/tests/ApiSyncTest.php similarity index 100% rename from cloud/tests/ApiSyncTest.php rename to server/tests/ApiSyncTest.php diff --git a/cloud/tests/AuthControllerTest.php b/server/tests/AuthControllerTest.php similarity index 100% rename from cloud/tests/AuthControllerTest.php rename to server/tests/AuthControllerTest.php diff --git a/cloud/tests/BasePathTest.php b/server/tests/BasePathTest.php similarity index 100% rename from cloud/tests/BasePathTest.php rename to server/tests/BasePathTest.php diff --git a/cloud/tests/ConfigTest.php b/server/tests/ConfigTest.php similarity index 100% rename from cloud/tests/ConfigTest.php rename to server/tests/ConfigTest.php diff --git a/cloud/tests/DashboardTest.php b/server/tests/DashboardTest.php similarity index 100% rename from cloud/tests/DashboardTest.php rename to server/tests/DashboardTest.php diff --git a/cloud/tests/EventValidatorTest.php b/server/tests/EventValidatorTest.php similarity index 100% rename from cloud/tests/EventValidatorTest.php rename to server/tests/EventValidatorTest.php diff --git a/cloud/tests/HmacTest.php b/server/tests/HmacTest.php similarity index 100% rename from cloud/tests/HmacTest.php rename to server/tests/HmacTest.php diff --git a/cloud/tests/HttpTest.php b/server/tests/HttpTest.php similarity index 100% rename from cloud/tests/HttpTest.php rename to server/tests/HttpTest.php diff --git a/cloud/tests/ImageProcessorTest.php b/server/tests/ImageProcessorTest.php similarity index 100% rename from cloud/tests/ImageProcessorTest.php rename to server/tests/ImageProcessorTest.php diff --git a/cloud/tests/IngestControllerTest.php b/server/tests/IngestControllerTest.php similarity index 99% rename from cloud/tests/IngestControllerTest.php rename to server/tests/IngestControllerTest.php index b962ca9..3b806ff 100644 --- a/cloud/tests/IngestControllerTest.php +++ b/server/tests/IngestControllerTest.php @@ -125,7 +125,7 @@ final class IngestControllerTest extends TestCase public function testDeduplicatesRepeatedEventUuid(): void { // A POST that succeeded server-side but timed out client-side is re-sent with the SAME - // event_uuid (fresh nonce). The cloud must return the already-stored event, not duplicate. + // event_uuid (fresh nonce). The server must return the already-stored event, not duplicate. [$c, $events] = $this->make(); $uuid = str_repeat('a', 32); $p = $this->payload(); $p['event_uuid'] = $uuid; diff --git a/cloud/tests/PasswordCsrfTest.php b/server/tests/PasswordCsrfTest.php similarity index 100% rename from cloud/tests/PasswordCsrfTest.php rename to server/tests/PasswordCsrfTest.php diff --git a/cloud/tests/PdoIntegrationTest.php b/server/tests/PdoIntegrationTest.php similarity index 100% rename from cloud/tests/PdoIntegrationTest.php rename to server/tests/PdoIntegrationTest.php diff --git a/cloud/tests/RbacTest.php b/server/tests/RbacTest.php similarity index 100% rename from cloud/tests/RbacTest.php rename to server/tests/RbacTest.php diff --git a/cloud/tests/RepositoryContractTest.php b/server/tests/RepositoryContractTest.php similarity index 100% rename from cloud/tests/RepositoryContractTest.php rename to server/tests/RepositoryContractTest.php diff --git a/cloud/tests/RouterTest.php b/server/tests/RouterTest.php similarity index 100% rename from cloud/tests/RouterTest.php rename to server/tests/RouterTest.php diff --git a/cloud/tests/SiteScopeTest.php b/server/tests/SiteScopeTest.php similarity index 100% rename from cloud/tests/SiteScopeTest.php rename to server/tests/SiteScopeTest.php diff --git a/cloud/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php similarity index 100% rename from cloud/tests/Support/InMemoryRepositories.php rename to server/tests/Support/InMemoryRepositories.php diff --git a/cloud/tests/WiringSmokeTest.php b/server/tests/WiringSmokeTest.php similarity index 100% rename from cloud/tests/WiringSmokeTest.php rename to server/tests/WiringSmokeTest.php diff --git a/cloud/tests/ZoneConfigTest.php b/server/tests/ZoneConfigTest.php similarity index 100% rename from cloud/tests/ZoneConfigTest.php rename to server/tests/ZoneConfigTest.php diff --git a/tests/test_alert.py b/tests/test_alert.py index f6cb12b..df623dd 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -55,7 +55,7 @@ def test_send_classifies_5xx_as_transient(): assert c.send(_event(), []) is SendResult.TRANSIENT def test_send_classifies_4xx_as_rejected(): - # A definite 4xx (not 429) means the cloud will never accept this event — the queue counts it + # A definite 4xx (not 429) means the server will never accept this event — the queue counts it # toward the poison drop cap so it can eventually be discarded and unblock the queue. client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, http_post=lambda *a, **k: _FakeResp(400)) diff --git a/tests/test_cafe24_package.py b/tests/test_cafe24_package.py index 6186403..d60eac5 100644 --- a/tests/test_cafe24_package.py +++ b/tests/test_cafe24_package.py @@ -18,16 +18,16 @@ import subprocess import pytest ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -# cloud/deploy/, not deploy/: deploy/ belongs to the Raspberry Pi — its own README opens with +# server/deploy/, not deploy/: deploy/ belongs to the Raspberry Pi — its own README opens with # "Edge deployment (Raspberry Pi 5, air-gapped)" and everything in it is run as root ON the -# Pi. Cafe24 runs nothing; the package is only ever uploaded. Deploying cloud/ belongs to -# cloud/, which already owns cloud/docs/cloud-deploy.md. -BUILD = os.path.join(ROOT, "cloud", "deploy", "build_cafe24_package.sh") -HTACCESS = os.path.join(ROOT, "cloud", "deploy", "htaccess.www") -CONFIG_TMPL = os.path.join(ROOT, "cloud", "deploy", "config.template.php") -CONFIG_EXAMPLE = os.path.join(ROOT, "cloud", "config.example.php") -CLOUD_DEPLOY_DOC = os.path.join(ROOT, "cloud", "docs", "cloud-deploy.md") -CONFIG_PHP = os.path.join(ROOT, "cloud", "src", "Config.php") +# Pi. Cafe24 runs nothing; the package is only ever uploaded. Deploying server/ belongs to +# server/, which already owns server/docs/server-deploy.md. +BUILD = os.path.join(ROOT, "server", "deploy", "build_cafe24_package.sh") +HTACCESS = os.path.join(ROOT, "server", "deploy", "htaccess.www") +CONFIG_TMPL = os.path.join(ROOT, "server", "deploy", "config.template.php") +CONFIG_EXAMPLE = os.path.join(ROOT, "server", "config.example.php") +SERVER_DEPLOY_DOC = os.path.join(ROOT, "server", "docs", "server-deploy.md") +CONFIG_PHP = os.path.join(ROOT, "server", "src", "Config.php") def _read(path): @@ -38,7 +38,7 @@ def _read(path): # Paths are passed to bash/shellcheck relative to ROOT: Git Bash's bash is an MSYS program, # so it resolves neither C:\a\b (backslashes vanish) nor C:/a/b (not a POSIX path) — it # wants /c/a/b. A relative path plus cwd=ROOT sidesteps the conversion on every platform. -BUILD_REL = os.path.join("cloud", "deploy", "build_cafe24_package.sh").replace("\\", "/") +BUILD_REL = os.path.join("server", "deploy", "build_cafe24_package.sh").replace("\\", "/") def _config_keys_read_by_app(): @@ -48,14 +48,14 @@ def _config_keys_read_by_app(): def test_build_script_exists_and_is_executable_bash(): - assert os.path.isfile(BUILD), "cloud/deploy/build_cafe24_package.sh is missing" + assert os.path.isfile(BUILD), "server/deploy/build_cafe24_package.sh is missing" assert _read(BUILD).startswith("#!"), "build script needs a shebang" def test_build_script_generates_a_no_dev_autoloader(): sh = _read(BUILD) - # cloud/public/index.php:3 hard-requires ../vendor/autoload.php, and - # cloud/src/Auth/Session.php declares three symbols under one filename (hence + # server/public/index.php:3 hard-requires ../vendor/autoload.php, and + # server/src/Auth/Session.php declares three symbols under one filename (hence # composer.json's classmap entry) — so a hand-rolled PSR-4 loader cannot resolve # App\Auth\PhpSessionStore. Composer's classmap dump is the only correct artifact. assert "composer dump-autoload" in sh @@ -94,7 +94,7 @@ def test_build_script_mounts_the_app_in_a_subdirectory_of_www(): def test_app_finds_its_root_by_walking_up_not_by_a_hardcoded_depth(): - index = _read(os.path.join(ROOT, "cloud", "public", "index.php")) + index = _read(os.path.join(ROOT, "server", "public", "index.php")) # A hardcoded '/..' resolves to www/ under a subdirectory mount — exactly where # config.php must never live. The walk-up makes one package work at either depth. assert "APP_ROOT" in index @@ -115,7 +115,7 @@ def test_app_finds_its_root_by_walking_up_not_by_a_hardcoded_depth(): def test_build_script_creates_the_snapshot_dir(): sh = _read(BUILD) - # Nothing in cloud/src ever creates this directory, and a failed write is swallowed + # Nothing in server/src ever creates this directory, and a failed write is swallowed # by IngestController's `continue` — the API still answers 201 and the evidence is # gone for good. assert "storage/snapshots" in sh @@ -155,8 +155,8 @@ def test_config_example_lists_every_key_the_app_reads(): assert not missing, f"config.example.php omits keys App\\Config reads: {sorted(missing)}" -def test_cloud_deploy_doc_does_not_tell_operators_to_drop_vendor(): - doc = _read(CLOUD_DEPLOY_DOC) +def test_server_deploy_doc_does_not_tell_operators_to_drop_vendor(): + doc = _read(SERVER_DEPLOY_DOC) # public/index.php:3 is an unconditional `require` of ../vendor/autoload.php. # "Do not upload vendor/" bricks every request, including the doc's own smoke check. assert "Do not upload `vendor/`" not in doc diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 2aa7bde..c89dc99 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -68,7 +68,7 @@ def test_loop_logs_swallowed_step_exception_rate_limited(): def test_heartbeat_reports_real_fps_and_camera_health(): - # A hardcoded fps=0.0 makes the cloud's device-health columns fiction; it can't tell a + # A hardcoded fps=0.0 makes the server's device-health columns fiction; it can't tell a # live device from a dead one. The heartbeat must carry the measured fps and per-camera # health so a degraded device is visible remotely. rt, cl, q = _Runtime(), _Client(), _Queue() @@ -95,7 +95,7 @@ def test_heartbeat_flags_a_failing_camera(): iterations=10, sync_every=99, heartbeat_every=10, flush_every=99, log=lambda *a, **k: None) m = cl.metrics[0] - assert m["cameras_erroring"] == 1 # the cloud can now see the camera is down + assert m["cameras_erroring"] == 1 # the server can now see the camera is down def test_loop_survives_flush_exception(): diff --git a/tests/test_deploy_artifacts.py b/tests/test_deploy_artifacts.py index 67a8ca4..5ba54d4 100644 --- a/tests/test_deploy_artifacts.py +++ b/tests/test_deploy_artifacts.py @@ -147,7 +147,7 @@ def test_onnxruntime_is_optional_but_a_pose_backend_is_mandatory(): "onnxruntime must not share a hard-fail pip line with the mandatory deps" # cv2 and requests stay mandatory: edge/snapshot.py imports cv2 at module level on the - # unconditional startup chain, and the daemon builds its cloud clients with no injected + # unconditional startup chain, and the daemon builds its server clients with no injected # HTTP fn, so `import requests` fires at startup for every backend. assert "install requests opencv-python-headless" in sh diff --git a/tests/test_queue.py b/tests/test_queue.py index 8de4568..fc375ba 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -41,7 +41,7 @@ def test_flush_resends_when_recovered(tmp_path): class _PoisonClient: - """Rejects one specific event forever with a definite 4xx the cloud will never accept.""" + """Rejects one specific event forever with a definite 4xx the server will never accept.""" def __init__(self, poison_id): self.poison = poison_id self.sent = [] @@ -66,7 +66,7 @@ def test_queue_is_bounded_and_keeps_newest(tmp_path): def test_event_uuid_survives_the_queue_roundtrip(tmp_path): # A re-sent event (failed once -> queued -> flushed) must carry the SAME event_uuid so the - # cloud dedups it instead of storing a duplicate alert. This is the whole point of the key. + # server dedups it instead of storing a duplicate alert. This is the whole point of the key. class _CaptureClient: def __init__(self): self.fail = True @@ -123,7 +123,7 @@ def test_sustained_outage_never_drops_events(tmp_path): def test_legacy_row_without_uuid_reuses_minted_uuid_across_retries(tmp_path): # A row queued before event_uuid existed has none stored. On first flush a fresh uuid is # minted; it MUST be persisted so every retry reuses the SAME key — else each failed-then- - # retried delivery sends a different uuid and the cloud stores duplicates (the P1-9 bug). + # retried delivery sends a different uuid and the server stores duplicates (the P1-9 bug). db = str(tmp_path / "q.db") con = sqlite3.connect(db) con.execute("CREATE TABLE queue (id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, " diff --git a/tests/test_cloudclient.py b/tests/test_serverclient.py similarity index 80% rename from tests/test_cloudclient.py rename to tests/test_serverclient.py index 497cbe2..134ef44 100644 --- a/tests/test_cloudclient.py +++ b/tests/test_serverclient.py @@ -1,5 +1,5 @@ import json -from edge.cloudclient import CloudClient +from edge.serverclient import ServerClient from edge.alert import sign class _Resp: @@ -11,7 +11,7 @@ def test_fetch_config_signs_and_parses(): def fake_get(url, headers=None, timeout=None, **kw): captured["url"] = url; captured["headers"] = headers; captured["kw"] = kw return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}})) - c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get) + c = ServerClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get) cfg = c.fetch_config() assert cfg["version"] == 3 assert "device_id=pi-1" in captured["url"] @@ -21,7 +21,7 @@ def test_fetch_config_signs_and_parses(): assert captured["kw"].get("allow_redirects") is False def test_fetch_config_none_on_error(): - c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401)) + c = ServerClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401)) assert c.fetch_config() is None def test_send_heartbeat_signs_body(): @@ -29,7 +29,7 @@ def test_send_heartbeat_signs_body(): def fake_post(url, data=None, headers=None, timeout=None, **kw): captured["data"] = data; captured["headers"] = headers; captured["kw"] = kw return _Resp(200) - c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post) + c = ServerClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post) assert c.send_heartbeat({"fps": 12.0}) is True body = captured["data"] assert json.loads(body)["device_id"] == "pi-1"