move_detect/tests/test_deploy_artifacts.py
유창욱 3f2823a764 refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.

- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match

Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
2026-07-20 11:30:52 +09:00

276 lines
14 KiB
Python

"""Guard: the systemd unit + env template must stay in sync with what the daemon
actually reads. If someone renames an env var in edge/config.py or edge/daemon.py
(or edits the unit), this fails instead of shipping a broken bundle.
"""
import os
DEPLOY = os.path.join(os.path.dirname(__file__), "..", "deploy", "systemd")
ENV_EXAMPLE = os.path.join(DEPLOY, "move-detect-edge.env.example")
UNIT = os.path.join(DEPLOY, "move-detect-edge.service")
SETUP_ONLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "setup_online.sh")
INSTALL_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle",
"install_offline.sh")
BUILD_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle",
"build_offline_bundle.sh")
# Keys the daemon consumes (edge/config.py:load_config + edge/daemon.py:main).
REQUIRED_ENV = {
"DEVICE_ID", "DEVICE_SECRET", "CAFE24_URL", "CAMERA_ID",
"MOVENET_ONNX", "UVC_DEVICE", "TARGET_FPS", "QUEUE_DB",
}
def _env_keys(text):
keys = set()
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
keys.add(line.split("=", 1)[0].strip())
return keys
def test_env_example_lists_every_required_key():
with open(ENV_EXAMPLE, encoding="utf-8") as f:
keys = _env_keys(f.read())
missing = REQUIRED_ENV - keys
assert not missing, f"env template missing required keys: {sorted(missing)}"
def test_env_example_values_have_no_inline_comments():
# systemd EnvironmentFile= does NOT strip inline comments — a trailing "# ..." on
# a KEY=value line becomes part of the value and breaks int()/float()/path/secret.
with open(ENV_EXAMPLE, encoding="utf-8") as f:
offenders = []
for line in f.read().splitlines():
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
value = s.split("=", 1)[1]
if "#" in value:
offenders.append(s)
assert not offenders, f"inline comments in env values break systemd: {offenders}"
def _directives(text, section=None):
"""Active Key=value lines of a systemd unit, comments stripped. Restricted to one
[Section] when given. Comments must be excluded: the unit deliberately *documents*
directives it must not carry (e.g. ReadWritePaths=), and a naive substring check
matches the warning against re-adding them."""
out, cur = [], None
for line in text.splitlines():
s = line.strip()
if not s or s.startswith(("#", ";")): # systemd honors both as comment leaders
continue
if s.startswith("[") and s.endswith("]"):
cur = s[1:-1]
continue
if section is None or cur == section:
out.append(s)
return "\n".join(out)
def test_systemd_unit_wiring():
# Every assertion binds to the [Section] that gives it meaning. A section-less check
# would pass on a directive moved into the wrong section, which is precisely the bug
# class that made StartLimitIntervalSec= a silent no-op for this unit.
with open(UNIT, encoding="utf-8") as f:
raw = f.read()
svc = _directives(raw, "Service")
assert "EnvironmentFile=/etc/move-detect/edge.env" in svc
assert "PYTHONPATH=/opt/move-detect/src" in svc
assert "python -m edge" in svc # runs the package entrypoint
assert "Restart=always" in svc # unattended resilience
assert "WantedBy=multi-user.target" in _directives(raw, "Install")
# Type=simple reports the start job done the instant fork() returns, so an exec-step
# failure (226/NAMESPACE, 216/GROUP, 217/USER, 203/EXEC) never reaches
# `systemctl enable --now` and setup_online.sh prints "Done." over a dead daemon.
assert "Type=exec" in svc
assert "Type=simple" not in svc
# StartLimitIntervalSec= is a [Unit] key. systemd 252's parser table has
# Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in
# [Service] it is an unknown key: silently ignored, leaving the default 5-starts/10s
# limit to defeat Restart=always.
assert "StartLimitIntervalSec=0" in _directives(raw, "Unit")
assert "StartLimit" not in svc
# ReadWritePaths=/var/lib/move-detect is dead config: StateDirectory= already emits a
# recursive read-write bind mount for that same path, which outranks a READWRITE entry
# and drops it before any path is resolved. Keep it out so it cannot be re-added as a
# "fix" for a state-dir problem it is incapable of causing or solving.
assert "StateDirectory=move-detect" in svc
assert "ReadWritePaths=" not in svc
def test_online_installer_guards_apt_and_surfaces_start_failures():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# [1/8] runs under `set -euo pipefail`. On a freshly-booted Pi, apt-daily and
# unattended-upgrades hold /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend;
# a bare apt-get then exits 100 with "Unable to lock directory /var/lib/apt/lists/"
# and kills the install. -o DPkg::Lock::Timeout does not cover the lists lock, so the
# wait has to live in the shell.
assert "apt_retry" in sh
for bare in ("\napt-get update", "\napt-get install"):
assert bare not in sh, "apt-get must go through apt_retry()"
# The lock-detection grep matches apt's C-locale strings, but apt gettext-translates its
# errors and sudo passes LANG/LC_* through. Without the locale pin, a ko_KR Pi misreads
# lock contention as a real error and fail-fasts — the exact bug apt_retry exists to fix.
assert "LC_ALL=C apt-get" in sh
# [8/8] must print WHY the daemon did not come up rather than dying under set -e on an
# opaque "Job for move-detect-edge.service failed". Assert the real invocation including
# flags: a bare "journalctl -xeu move-detect-edge" is also present in a human-facing echo
# hint, so the loose form passes even if the actual diagnostic call is deleted.
assert "journalctl -xeu move-detect-edge -n 50 --no-pager" in sh
assert "systemctl is-active --quiet move-detect-edge" in sh
# is-active is phase-dependent (a crash at T<=~0.2s is back in "active" by T+RestartSec);
# NRestarts is not, so it must stay as the AND alongside it.
assert "NRestarts" in sh
def test_onnxruntime_is_optional_but_a_pose_backend_is_mandatory():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# onnxruntime is imported inside MoveNetPoseAdapter.__init__ (edge/adapters/
# movenet_pose.py) and the Hailo path (edge/daemon.py) never imports the module at all —
# so it is the CPU-MoveNet-only dep. It publishes no sdist and no 32-bit-ARM wheel, so
# pip can legitimately find zero candidates and exit non-zero. On the same hard-fail line
# as requests/opencv under `set -e`, that killed the whole install on a Hailo-8 Pi that
# never needed it.
assert "pip\" install requests onnxruntime opencv-python-headless" not in sh, \
"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 server clients with no injected
# HTTP fn, so `import requests` fires at startup for every backend.
assert "install requests opencv-python-headless" in sh
# ...but the installer must not then hand over a Pi with NO working backend. build_pose()
# runs outside the daemon's try/except, so a missing backend is an uncaught traceback on
# every Restart=always cycle, forever. Fail at install time, naming the fix.
assert "no usable pose backend" in sh
# Probe capability, not intent: a HEF on disk does not mean HailoRT is installed, and a
# pip exit code does not mean the .so loads.
assert "import hailo_platform" in sh
assert "import onnxruntime" in sh
def test_installer_can_obtain_a_hef_without_the_operator_supplying_one():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# Nothing in this repo could produce a .hef: MoveNet had edge/tools/fetch_movenet.py,
# Hailo had no equivalent and no doc named a source — so [6/8] demanded a file the
# operator had no way to obtain, and the install dead-ended there.
assert "edge.tools.fetch_hailo_hef" in sh
# An explicit HAILO_HEF that does not exist is a typo, not a request to fall back. The old
# single guarded `cp` skipped it silently and printed the same HEF=MISSING table as the
# unset case, never echoing the bad value.
assert "does not exist" in sh
# The failure message must show whether the BOARD answered: that field separates "no HEF"
# from "driver installed, reboot pending" from "no Hailo at all" — three different fixes.
assert "hailortcli fw-control identify" in sh
assert "board=" in sh
# A Hailo-8 that reports no serial can leave auto-detect with nothing to read, so the
# operator must be able to force the arch. The installer forwards HAILO_ARCH as --arch,
# set -u-safe (${VAR:+..} expands to nothing when unset).
assert '${HAILO_ARCH:+--arch "$HAILO_ARCH"}' in sh
assert "HAILO_ARCH=hailo8" in sh
def test_reinstall_reconciles_pose_backend_in_the_env_file():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# Re-running IS the Hailo flow (install -> reboot for the driver -> re-run), so [6/8] can
# reach a different verdict than last time. Leaving edge.env wholly unchanged left run #1's
# POSE_BACKEND=movenet in place while [6/8] printed "hailo" — a crash loop the installer
# called a success.
assert "re-run reconciled" in sh
assert "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" in sh
# sed -i / >> can leave the file root:root; the unit reads it as movedetect via
# EnvironmentFile=, so the group must be restored or the start fails on permissions.
assert 'chown root:"$SVC_USER" "$ETC/edge.env"' in sh
def test_offline_installer_does_not_require_onnxruntime_to_verify_the_install():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# Same brittleness offline: a bundle built for a Hailo Pi need not carry onnxruntime, and
# this import check would fail the install for a package the daemon never loads.
assert "import edge.daemon, onnxruntime, cv2, numpy, requests" not in sh
def test_offline_installer_gates_the_pose_backend():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# The offline installer must not hand over a Pi whose configured POSE_BACKEND cannot load
# (a Hailo-only bundle installed with the template's POSE_BACKEND=movenet -> crash loop).
# Probe capability and set the working backend, or hard-fail — like setup_online's [6/8].
assert "import hailo_platform" in sh
assert "import onnxruntime" in sh
assert "no usable pose backend" in sh
# It must WRITE the reconciled backend into the installed env, not leave the template's.
assert "POSE_BACKEND=$POSE_BACKEND" in sh or "POSE_BACKEND=${POSE_BACKEND}" in sh
def test_offline_bundle_fetches_and_stages_the_hailo_hef():
with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# A Hailo-8 delivery runs off the HEF; the offline bundle must carry it, fetched on the build
# machine (the air-gapped Pi cannot). Without this the installer's backend gate finds no HEF.
assert "edge.tools.fetch_hailo_hef" in sh
assert "yolov8s_pose.hef" in sh # staged into the bundle's models/
def test_offline_bundle_treats_movenet_onnx_as_optional():
with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# The MoveNet source can be unreachable and a Hailo bundle is complete without the CPU
# fallback, so the fetch must be GUARDED (inside an `if`) — never an unconditional line that
# aborts the whole build under `set -e`.
assert "fetch_movenet ); then" in sh
assert "Hailo-only bundle" in sh
def test_offline_installer_copies_the_hef_into_place():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# install_offline used to copy only *.onnx, so a HEF in the bundle never reached
# $PREFIX/models and the Hailo backend gate ([5/6]) could never see it.
assert 'cp "$HERE/models/"*.hef' in sh
def test_offline_bundle_emits_pinned_integrity_manifest():
with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# Air-gap supply-chain: the target can't re-resolve from PyPI, so the build must pin exact
# versions, hash EVERY artifact (not just wheels/models — the daemon source, unit and installer
# too), and ship an SBOM. GPG signing is optional (SIGN_KEY).
assert "find . -type f" in sh and "sha256sum > SHA256SUMS" in sh # hashes every staged file
assert "! -name SHA256SUMS" in sh # except the manifest itself
assert "requirements.lock" in sh # exact name==version pins for a reproducible rebuild
assert "sbom.json" in sh
assert "CycloneDX" in sh
assert 'for mdl in "$STAGE"/models/*' in sh # SBOM includes the ML model weights, not just wheels
assert 'if [ -n "${SIGN_KEY:-}" ]; then' in sh # optional detached signature
def test_offline_installer_verifies_integrity_before_install():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# A truncated/tampered bundle must fail loudly BEFORE any system mutation — not after rm -rf
# has destroyed the previous install and the pip step has already run.
assert "sha256sum -c SHA256SUMS" in sh
assert "integrity check failed" in sh
# Ordering (the substance of the fix): the verify must come BEFORE the first $PREFIX mutation
# and before the offline wheel install.
verify_at = sh.index("sha256sum -c SHA256SUMS")
assert verify_at < sh.index('rm -rf "$PREFIX/src/edge') # before destroying the old tree
assert verify_at < sh.index("install --no-index") # before installing wheels
# A missing SHA256SUMS must be FATAL (refuse), not a warn-and-proceed that installs blind.
assert "refusing to install unverified code" in sh