Dynamically install and load detector dependencies (#24156)

* Dynamically install and load detector dependencies

* Cleanup

* Cleanup
This commit is contained in:
Nicolas Mowen
2026-09-12 07:30:04 -06:00
parent 53b04f44ef
commit d183f03fee
18 changed files with 1386 additions and 115 deletions
+3 -12
View File
@@ -202,10 +202,6 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt && \
pip3 wheel --wheel-dir=/wheels -r /requirements-dev.txt; \
fi
# Install HailoRT & Wheels
RUN --mount=type=bind,source=docker/main/install_hailort.sh,target=/deps/install_hailort.sh \
/deps/install_hailort.sh
# Collect deps in a single layer
FROM scratch AS deps-rootfs
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
@@ -216,7 +212,6 @@ COPY --from=libusb-build /usr/local/lib /usr/local/lib
COPY --from=tempio /rootfs/ /
COPY --from=s6-overlay /rootfs/ /
COPY --from=models /rootfs/ /
COPY --from=wheels /rootfs/ /
COPY docker/main/rootfs/ /
@@ -294,16 +289,12 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
pip3 install -U /deps/wheels/*.whl
# Install Axera Engine
RUN pip3 install https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3-frigate/axengine-0.1.3-py3-none-any.whl
# The Hailo, MemryX, and Axera runtimes are installed at first start by
# frigate/util/runtime_deps.py, only when that detector is configured.
# Axera's native libraries are bind mounted from the host.
ENV PATH="${PATH}:/usr/bin/axcl"
ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/axcl"
# Install MemryX runtime (requires libgomp (OpenMP) in the final docker image)
RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \
bash -c "bash /deps/install_memryx.sh"
COPY --from=deps-rootfs / /
RUN ldconfig
-32
View File
@@ -1,32 +0,0 @@
#!/bin/bash
set -euxo pipefail
hailo_version="4.21.0"
# sha256 digests of the release artifacts; update when bumping hailo_version.
# The runtime tarball is keyed by TARGETARCH, the wheel by the python arch tag.
declare -A hailort_checksums=(
["amd64"]="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3"
["arm64"]="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f"
["x86_64"]="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6"
["aarch64"]="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then
arch="aarch64"
fi
# downloaded rather than streamed into tar because streaming and verifying the
# digest before extraction are mutually exclusive
wget -qO /tmp/hailort.tar.gz "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz"
echo "${hailort_checksums[${TARGETARCH}]} /tmp/hailort.tar.gz" | sha256sum -c -
tar -C / -xzf /tmp/hailort.tar.gz
rm -f /tmp/hailort.tar.gz
wheel="/wheels/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
mkdir -p /wheels
wget -qO "${wheel}" "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
echo "${hailort_checksums[${arch}]} ${wheel}" | sha256sum -c -
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
set -e
# Download the MxAccl for Frigate github release
wget https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip -O /tmp/mxaccl.zip
unzip /tmp/mxaccl.zip -d /tmp
mv /tmp/mx_accl_frigate-2.1.0 /opt/mx_accl_frigate
rm /tmp/mxaccl.zip
# Install Python dependencies
pip3 install -r /opt/mx_accl_frigate/freeze
# Link the Python package dynamically
SITE_PACKAGES=$(python3 -c "import site; print(site.getsitepackages()[0])")
ln -s /opt/mx_accl_frigate/memryx "$SITE_PACKAGES/memryx"
# Copy architecture-specific shared libraries
ARCH=$(uname -m)
if [[ "$ARCH" == "x86_64" ]]; then
cp /opt/mx_accl_frigate/memryx/x86/libmemx.so* /usr/lib/x86_64-linux-gnu/
cp /opt/mx_accl_frigate/memryx/x86/libmx_accl.so* /usr/lib/x86_64-linux-gnu/
elif [[ "$ARCH" == "aarch64" ]]; then
cp /opt/mx_accl_frigate/memryx/arm/libmemx.so* /usr/lib/aarch64-linux-gnu/
cp /opt/mx_accl_frigate/memryx/arm/libmx_accl.so* /usr/lib/aarch64-linux-gnu/
else
echo "Unsupported architecture: $ARCH"
exit 1
fi
# Refresh linker cache
ldconfig
+1 -8
View File
@@ -57,19 +57,12 @@ pywebpush == 2.0.*
pyclipper == 1.3.*
shapely == 2.0.*
rapidfuzz==3.12.*
# HailoRT Wheels
appdirs==1.4.*
# HailoRT
argcomplete==2.0.*
contextlib2==0.6.*
distlib==0.3.*
filelock==3.8.*
future==0.18.*
importlib-metadata==5.1.*
importlib-resources==5.1.*
netaddr==0.8.*
netifaces==0.10.*
verboselogs==1.7.*
virtualenv==20.17.*
prometheus-client == 0.21.*
# TFLite
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
@@ -17,6 +17,11 @@ if [[ "$runs_as_root" -eq 0 ]]; then
export HOME=/config
fi
# detector runtimes installed at first start (pip install --user) live under
# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib"
export PATH="${PATH}:${HOME}/.local/bin"
# opt out of openvino telemetry
if [ -e /usr/local/bin/opt_in_out ]; then
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
+4 -2
View File
@@ -38,7 +38,7 @@ Try the device grants and `EXTRA_GROUPS` first. The `frigate` service runs the A
A listed service also stops honoring a [custom ffmpeg or go2rtc build](/configuration/advanced/system#custom-dependencies) kept in `/config`, since that directory stays owned by the unprivileged user and a binary there would run as root. `FRIGATE_RUN_AS_ROOT=true` has no such restriction.
Recordings and exports are owned by `PUID`/`PGID` as soon as they're written, even by a root service. Snapshots, thumbnails, and other files under `clips/` are corrected on each restart, so they can show as root-owned from the host until then. A listed service also keeps root's home directory, so library caches go to the container layer instead of `/config`.
Recordings and exports are owned by `PUID`/`PGID` as soon as they're written, even by a root service. Snapshots, thumbnails, and other files under `clips/` are corrected on each restart, so they can show as root-owned from the host until then. A listed service also keeps root's home directory, so library caches go to the container layer instead of `/config`. The same applies to the [detector runtimes](/frigate/network_requirements#detector-runtimes) Frigate installs at first start (Hailo, MemryX, AXEngine): a root `frigate` service installs them into `/root/.local`, which is lost when the container is recreated, and never loads a copy left behind in `/config/.local`.
Listing all three services is not the same as `FRIGATE_RUN_AS_ROOT=true`. The escape hatch never touches ownership; the list keeps the ownership handling active. A few more details:
@@ -303,6 +303,8 @@ Size `/tmp` deliberately. It now carries nginx's config copy and its five proxy
The self signed certificate is written to `/config/tls`, which stays writable. Certificates you mount at `/etc/letsencrypt/live/frigate` work unchanged and still take precedence.
[Detector runtimes](/frigate/network_requirements#detector-runtimes) that Frigate installs at first start (Hailo, MemryX, AXEngine) are staged in `/tmp` and installed into `/config/.local`, so they work with a read-only root filesystem in the default mode and under `user:`. A root `frigate` service installs into `/root/.local` instead, which a read-only root filesystem prevents; either leave `frigate` out of `FRIGATE_ROOT_SERVICES` or drop `read_only`.
Soak a hardened deployment for 24 hours against real cameras before relying on it. A read-only root filesystem turns an occasional write into a failure that startup won't reveal.
### Never starting as root
@@ -320,7 +322,7 @@ This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starti
### Per-variant exceptions
- **Rockchip** needs `- /sys/:/sys/:ro` alongside its device nodes, in addition to everything above.
- **MemryX** and **QNAP Container Station** still require `privileged: true` per their own documentation, which gives back most of what this layout removes. MemryX also downloads its models to `/memryx_models` on the root filesystem, so it can't run read-only regardless.
- **MemryX** and **QNAP Container Station** still require `privileged: true` per their own documentation, which gives back most of what this layout removes. MemryX also downloads its models to `/memryx_models` on the root filesystem, so it can't run read-only regardless. Its SDK is installed into `/config/.local` like the other detector runtimes.
## Network isolation
@@ -297,6 +297,12 @@ If no custom model is provided, the Hailo detector downloads a default model fro
:::
:::info
The HailoRT runtime is not part of the Frigate image. It is downloaded and installed into `/config/.local` the first time a Hailo detector is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
:::
### Configuration {#configuration-hailo}
When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**.
@@ -568,6 +574,12 @@ See the [installation docs](../frigate/installation.md#memryx-mx3) for informati
To configure a MemryX detector, simply set the `type` attribute to `memryx` and follow the configuration guide below.
:::info
The MemryX SDK is not part of the Frigate image. It is downloaded and installed into `/config/.local` the first time a MemryX detector is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
:::
### Configuration {#configuration-memryx}
<ModelConfigDropdown detectorTitle="MemryX" models={objectDetectorsModels.memryx.models} />
@@ -828,6 +840,12 @@ The AXEngine detector downloads its default model from HuggingFace on first star
:::
:::info
The AXEngine python package is not part of the Frigate image. It is downloaded and installed into `/config/.local` the first time an AXEngine detector is configured, verified against a pinned checksum, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the file yourself.
:::
### Configuration {#configuration-axengine}
When configuring the AXEngine detector, you have to specify the model name.
+6
View File
@@ -124,6 +124,8 @@ Additionally, the USB Coral draws a considerable amount of power. If using any o
The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments.
The HailoRT runtime is not part of the Frigate image; Frigate downloads and installs it at first start once a Hailo detector is configured. Containers without internet access can provide the files themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes).
#### Installation
:::warning
@@ -315,6 +317,8 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/2p1/get_started/install_hardware.html).
The MemryX SDK used inside the container is not part of the Frigate image; Frigate downloads and installs it at first start once a MemryX detector is configured. Containers without internet access can provide the file themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes). The host side driver still has to be installed as described below.
Then follow these steps for installing the correct driver/runtime configuration:
1. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/memryx/user_installation.sh).
@@ -479,6 +483,8 @@ Follow these steps for installation:
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
The AXEngine python package is not part of the Frigate image; Frigate downloads and installs it at first start once an AXEngine detector is configured. Containers without internet access can provide the file themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes).
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
```yaml
+19 -1
View File
@@ -56,6 +56,24 @@ The default CPU, EdgeTPU, and OpenVINO object detection models are bundled into
:::
### Detector Runtimes
The SDKs for a few hardware detectors are not shipped in the Frigate image. They are downloaded the first time that detector is configured, verified against checksums pinned in the Frigate release, and installed into the Frigate user's home directory (`/config/.local` by default). Once installed they are not downloaded again until a Frigate release pins a new version.
| Detector | Version | Files | Source |
| -------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | 4.21.0 | `hailort-debian12-amd64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_x86_64.whl` on x86, `hailort-debian12-arm64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_aarch64.whl` on arm64 | [GitHub release](https://github.com/frigate-nvr/hailort/releases/tag/v4.21.0) |
| [MemryX MX3](/configuration/object_detectors#memryx-mx3) | 2.1.0 | `mx_accl_frigate-2.1.0.zip` (the release source archive, renamed) | [GitHub archive](https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip) |
| [AXERA AXEngine](/configuration/object_detectors#axera) | 0.1.3 | `axengine-0.1.3-py3-none-any.whl` | [GitHub release](https://github.com/AXERA-TECH/pyaxengine/releases/tag/0.1.3-frigate) |
If the container cannot reach GitHub, provide the files yourself:
1. Download the files for your architecture on a machine with internet access.
2. Place them, with exactly the file names listed above, in `/config/model_cache/runtimes/<detector>/`, where `<detector>` is the detector `type` from your config (`hailo8l`, `memryx`, or `axengine`).
3. Start Frigate. Files whose checksum matches are installed without any download; a file with the wrong checksum is discarded and downloaded again, so a failed startup log names the file to replace.
The `GITHUB_ENDPOINT` mirror variable below applies to these downloads as well.
### Preventing Model Downloads
If you have already downloaded all required models and want to prevent Frigate from attempting any outbound connections to HuggingFace or the Transformers library, set the following environment variables on your Frigate container:
@@ -79,7 +97,7 @@ If your Frigate instance has restricted internet access, you can point model dow
| Environment Variable | Default | Used By |
| ----------------------------------- | ----------------------------------- | --------------------------------------------- |
| `HF_ENDPOINT` | `https://huggingface.co` | Semantic search, Sherpa-ONNX, AXEngine models |
| `GITHUB_ENDPOINT` | `https://github.com` | Face recognition, LPR, RKNN models |
| `GITHUB_ENDPOINT` | `https://github.com` | Face recognition, LPR, RKNN models, detector runtimes |
| `GITHUB_RAW_ENDPOINT` | `https://raw.githubusercontent.com` | Bird classification |
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Unset (Keras uses its own default) | Custom classification training |
+23
View File
@@ -50,6 +50,7 @@ from frigate.debug_replay import (
cleanup_replay_cameras,
)
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.detector_types import api_types
from frigate.detectors.device import build_detector_config, runner_names
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
from frigate.events.audio import AudioProcessor
@@ -88,6 +89,7 @@ from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import UntrackedSharedMemory
from frigate.util.ownership import chown_to_runtime
from frigate.util.process import FrigateProcess
from frigate.util.runtime_deps import RuntimeDependencyError
from frigate.util.services import set_file_limit
from frigate.version import VERSION
from frigate.watchdog import FrigateWatchdog
@@ -361,6 +363,26 @@ class FrigateApp:
)
self.dispatcher.profile_manager = self.profile_manager
def ensure_detector_dependencies(self) -> None:
"""Install runtimes for the configured detector types.
Runs before any detector process starts so one install serves them
all and the user site is on sys.path before the forkserver copies it.
A failure is logged and startup continues; the detector process then
fails on its own with a clear import error.
"""
detector_types = {
spec.detector
for model in self.config.models
for spec in self.config.devices_for_model(model)
}
for detector_type in sorted(detector_types):
try:
api_types[detector_type].ensure_dependencies()
except RuntimeDependencyError as err:
logger.error("Unable to prepare the %s runtime: %s", detector_type, err)
def start_detectors(self) -> None:
model_cameras: dict[SceneEnum, list[str]] = {
model.scene: [] for model in self.config.models
@@ -590,6 +612,7 @@ class FrigateApp:
# Ensure global state.
self.ensure_dirs()
self.ensure_detector_dependencies()
# Set soft file limits.
set_file_limit()
+23
View File
@@ -1,9 +1,11 @@
import logging
from abc import ABC, abstractmethod
from typing import ClassVar
import numpy as np
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
from frigate.util.runtime_deps import RuntimeManifest, activate, ensure_installed
logger = logging.getLogger(__name__)
@@ -12,6 +14,10 @@ class DetectionApi(ABC):
type_key: str
supported_models: list[ModelTypeEnum]
# pinned SDK artifacts that are installed at runtime instead of being
# shipped in the image; None when the runtime is already available
runtime_manifest: ClassVar[RuntimeManifest | None] = None
@abstractmethod
def __init__(self, detector_config: BaseDetectorConfig):
self.detector_config = detector_config
@@ -23,6 +29,23 @@ class DetectionApi(ABC):
def detect_raw(self, tensor_input):
pass
@classmethod
def ensure_dependencies(cls) -> None:
"""Download and install this detector's runtime if it is not present.
Runs once in the main process before detector processes start, so a
single install serves every process and the user site is on sys.path
before it is inherited.
"""
if cls.runtime_manifest is not None:
ensure_installed(cls.runtime_manifest)
@classmethod
def activate_dependencies(cls) -> None:
"""Make the installed runtime importable in the current process."""
if cls.runtime_manifest is not None:
activate(cls.runtime_manifest)
def calculate_grids_strides(self, expanded=True) -> None:
grids = []
expanded_strides = []
+24 -1
View File
@@ -10,11 +10,28 @@ from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
from frigate.util.model import post_process_yolo
from frigate.util.runtime_deps import Artifact, ArtifactKind, RuntimeManifest
logger = logging.getLogger(__name__)
DETECTOR_KEY = "axengine"
# The AXEngine python package is installed at first start rather than shipped
# in the image; its native libraries are bind mounted from the host.
AXENGINE_VERSION = "0.1.3"
AXENGINE_MANIFEST = RuntimeManifest(
name=DETECTOR_KEY,
version=AXENGINE_VERSION,
artifacts=(
Artifact(
url=f"https://github.com/AXERA-TECH/pyaxengine/releases/download/{AXENGINE_VERSION}-frigate/axengine-{AXENGINE_VERSION}-py3-none-any.whl",
sha256="e995b8a887b067dc3456512aae2fa9c84f70e708c28b11caf184efdc254c64ae",
kind=ArtifactKind.wheel,
),
),
import_check="axengine",
)
supported_models = {
ModelTypeEnum.yologeneric: "frigate-yolov9-.*$",
}
@@ -34,12 +51,18 @@ class AxengineDetectorConfig(BaseDetectorConfig):
class Axengine(DetectionApi):
type_key = DETECTOR_KEY
runtime_manifest = AXENGINE_MANIFEST
def __init__(self, config: AxengineDetectorConfig):
self.activate_dependencies()
try:
import axengine as axe
except ModuleNotFoundError:
raise ImportError("AXEngine is not installed.") from None
raise ImportError(
"AXEngine is not installed. Frigate installs it at startup when an "
"axengine detector is configured; check the startup log for errors."
) from None
logger.info("__init__ axengine")
super().__init__(config)
+62 -2
View File
@@ -16,6 +16,14 @@ from frigate.detectors.detector_config import (
BaseDetectorConfig,
)
from frigate.object_detection.util import RequestStore, ResponseStore
from frigate.util.runtime_deps import (
ArchiveDest,
ArchiveMapping,
Artifact,
ArtifactKind,
RuntimeManifest,
find_tool,
)
logger = logging.getLogger(__name__)
@@ -52,11 +60,58 @@ H8L_DEFAULT_MODEL = "yolov6n.hef"
H8_DEFAULT_URL = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8/yolov6n.hef"
H8L_DEFAULT_URL = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8l/yolov6n.hef"
# HailoRT is installed at first start rather than shipped in the image. The
# tarball carries libhailort and hailortcli, the wheel the python bindings.
HAILORT_VERSION = "4.21.0"
HAILORT_RELEASE = (
f"https://github.com/frigate-nvr/hailort/releases/download/v{HAILORT_VERSION}"
)
HAILORT_MAPPINGS = (
ArchiveMapping("rootfs/usr/local/lib/", ArchiveDest.lib),
ArchiveMapping("rootfs/usr/local/bin/", ArchiveDest.bin),
)
HAILORT_MANIFEST = RuntimeManifest(
name=DETECTOR_KEY,
version=HAILORT_VERSION,
artifacts=(
Artifact(
url=f"{HAILORT_RELEASE}/hailort-debian12-amd64.tar.gz",
sha256="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3",
kind=ArtifactKind.archive,
mappings=HAILORT_MAPPINGS,
machines=("x86_64",),
),
Artifact(
url=f"{HAILORT_RELEASE}/hailort-debian12-arm64.tar.gz",
sha256="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f",
kind=ArtifactKind.archive,
mappings=HAILORT_MAPPINGS,
machines=("aarch64",),
),
Artifact(
url=f"{HAILORT_RELEASE}/hailort-{HAILORT_VERSION}-cp311-cp311-linux_x86_64.whl",
sha256="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6",
kind=ArtifactKind.wheel,
machines=("x86_64",),
),
Artifact(
url=f"{HAILORT_RELEASE}/hailort-{HAILORT_VERSION}-cp311-cp311-linux_aarch64.whl",
sha256="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d",
kind=ArtifactKind.wheel,
machines=("aarch64",),
),
),
preload=(f"libhailort.so.{HAILORT_VERSION}",),
import_check="hailo_platform",
)
def detect_hailo_arch():
try:
result = subprocess.run(
["hailortcli", "fw-control", "identify"], capture_output=True, text=True
[find_tool("hailortcli"), "fw-control", "identify"],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(f"Inference error: {result.stderr}")
@@ -96,7 +151,10 @@ class HailoAsyncInference:
VDevice,
)
except ModuleNotFoundError:
pass
raise ImportError(
"HailoRT is not installed. Frigate installs it at startup when a "
"Hailo detector is configured; check the startup log for errors."
) from None
self.input_store = input_store
self.output_store = output_store
@@ -202,9 +260,11 @@ class HailoAsyncInference:
# ----------------- HailoDetector Class ----------------- #
class HailoDetector(DetectionApi):
type_key = DETECTOR_KEY
runtime_manifest = HAILORT_MANIFEST
def __init__(self, detector_config: "HailoDetectorConfig"):
global ARCH
self.activate_dependencies()
ARCH = detect_hailo_arch()
self.cache_dir = MODEL_CACHE_DIR
self.device_type = detector_config.device
+53 -3
View File
@@ -18,11 +18,58 @@ from frigate.detectors.detector_config import (
)
from frigate.util.file import FileLock
from frigate.util.model import xyxy_to_xywh_for_nms
from frigate.util.runtime_deps import (
ArchiveDest,
ArchiveMapping,
Artifact,
ArtifactKind,
RuntimeManifest,
)
logger = logging.getLogger(__name__)
DETECTOR_KEY = "memryx"
# The MemryX SDK is installed at first start rather than shipped in the image.
# The release archive holds the python package with its compiled extensions
# and, per architecture, the shared libraries they link against. libmemx has
# no SONAME, so the libraries are resolved through LD_LIBRARY_PATH.
MEMRYX_VERSION = "2.1.0"
MEMRYX_ARCHIVE_ROOT = f"mx_accl_frigate-{MEMRYX_VERSION}/memryx/"
MEMRYX_LIBS = ("libmemx.so*", "libmx_accl.so*")
MEMRYX_MANIFEST = RuntimeManifest(
name=DETECTOR_KEY,
version=MEMRYX_VERSION,
artifacts=(
Artifact(
url=f"https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v{MEMRYX_VERSION}.zip",
sha256="54d1971dee54688541561b7bd9136342be51707b68c11a2a5e575fa19e69bc51",
kind=ArtifactKind.archive,
filename=f"mx_accl_frigate-{MEMRYX_VERSION}.zip",
mappings=(
ArchiveMapping(
MEMRYX_ARCHIVE_ROOT, ArchiveDest.site_packages, subdir="memryx"
),
ArchiveMapping(
f"{MEMRYX_ARCHIVE_ROOT}x86/",
ArchiveDest.lib,
include=MEMRYX_LIBS,
machines=("x86_64",),
),
ArchiveMapping(
f"{MEMRYX_ARCHIVE_ROOT}arm/",
ArchiveDest.lib,
include=MEMRYX_LIBS,
machines=("aarch64",),
),
),
),
),
preload=("libmemx.so", "libmx_accl.so.2"),
import_check="memryx",
needs_ld_library_path=True,
)
# Configuration class for model settings
class ModelConfig(BaseModel):
@@ -56,17 +103,20 @@ class MemryXDetector(DetectionApi):
ModelTypeEnum.yologeneric, # Treated as yolov9 in MemryX implementation
ModelTypeEnum.yolox,
]
runtime_manifest = MEMRYX_MANIFEST
def __init__(self, detector_config):
"""Initialize MemryX detector with the provided configuration."""
self.activate_dependencies()
try:
# Import MemryX SDK
from memryx import AsyncAccl
except ModuleNotFoundError:
raise ImportError(
"MemryX SDK is not installed. Install it and set up MIX environment."
"MemryX SDK is not installed. Frigate installs it at startup when "
"a MemryX detector is configured; check the startup log for errors."
) from None
return
# Initialize stop_event as None, will be set later by set_stop_event()
self.stop_event = None
@@ -78,7 +128,7 @@ class MemryXDetector(DetectionApi):
detector_config.model.model_type = model_cfg.model_type
else:
logger.info(
"model_type not set in config — defaulting to yolonas for MemryX."
"model_type not set in config, defaulting to yolonas for MemryX"
)
detector_config.model.model_type = ModelTypeEnum.yolonas
+563
View File
@@ -0,0 +1,563 @@
"""Tests for runtime installation of detector SDKs into the user site."""
import ctypes
import hashlib
import io
import json
import os
import stat
import subprocess
import sys
import tarfile
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
from frigate.util import runtime_deps
from frigate.util.runtime_deps import (
ArchiveDest,
ArchiveMapping,
Artifact,
ArtifactKind,
RuntimeDependencyError,
RuntimeManifest,
activate,
ensure_installed,
find_tool,
)
LIB_PREFIX = "rootfs/usr/local/lib/"
BIN_PREFIX = "rootfs/usr/local/bin/"
MAPPINGS = (
ArchiveMapping(LIB_PREFIX, ArchiveDest.lib),
ArchiveMapping(BIN_PREFIX, ArchiveDest.bin),
)
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def make_tarball(members: dict[str, bytes | str], exec_names=()) -> bytes:
"""Build a tar.gz; a str value is a symlink target, bytes a file."""
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
for name, content in members.items():
info = tarfile.TarInfo(name)
if isinstance(content, str):
info.type = tarfile.SYMTYPE
info.linkname = content
archive.addfile(info)
else:
info.size = len(content)
info.mode = 0o755 if name in exec_names else 0o644
archive.addfile(info, io.BytesIO(content))
return buffer.getvalue()
def make_zip(members: dict[str, bytes | str]) -> bytes:
"""Build a zip the way GitHub archives do, symlinks included."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, content in members.items():
info = zipfile.ZipInfo(name)
if isinstance(content, str):
info.external_attr = (stat.S_IFLNK | 0o777) << 16
archive.writestr(info, content)
else:
info.external_attr = (stat.S_IFREG | 0o644) << 16
archive.writestr(info, content)
return buffer.getvalue()
class RuntimeDepsTestCase(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
self.base = root / "base"
self.site = self.base / "lib" / "python" / "site-packages"
self.cache = root / "cache"
self.downloads: list[str] = []
self.download_content: bytes | None = None
self.sys_path = list(sys.path)
runtime_deps._loaded_libs.clear()
def download(url: str, save_path: str, silent: bool = False) -> Path:
self.downloads.append(url)
if self.download_content is None:
raise OSError("network is off")
Path(save_path).write_bytes(self.download_content)
return Path(save_path)
self.patches = [
patch.object(runtime_deps, "user_base", lambda: self.base),
patch.object(runtime_deps, "user_site", lambda: self.site),
patch.object(runtime_deps, "cache_dir", lambda name: self.cache / name),
patch.object(runtime_deps.site, "ENABLE_USER_SITE", True),
patch(
"frigate.util.downloader.ModelDownloader.download_from_url", download
),
patch("os.geteuid", return_value=1000),
patch.dict("os.environ", {}, clear=True),
]
for p in self.patches:
p.start()
def tearDown(self) -> None:
for p in reversed(self.patches):
p.stop()
sys.path[:] = self.sys_path
self.tmp.cleanup()
def archive_manifest(
self, data: bytes, version: str = "1.0", name: str = "test"
) -> RuntimeManifest:
return RuntimeManifest(
name=name,
version=version,
artifacts=(
Artifact(
url=f"https://github.com/org/repo/releases/download/v{version}/runtime.tar.gz",
sha256=sha256(data),
kind=ArtifactKind.archive,
mappings=MAPPINGS,
),
),
)
def preseed(self, manifest: RuntimeManifest, data: bytes) -> Path:
path = self.cache / manifest.name / manifest.artifacts[0].name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
class TestFetch(RuntimeDepsTestCase):
def test_preseeded_file_with_matching_sha256_skips_download(self) -> None:
data = make_tarball({f"{LIB_PREFIX}libfoo.so": b"lib"})
manifest = self.archive_manifest(data)
self.preseed(manifest, data)
ensure_installed(manifest)
self.assertEqual(self.downloads, [])
self.assertEqual((self.base / "lib" / "libfoo.so").read_bytes(), b"lib")
def test_preseeded_file_with_wrong_sha256_is_downloaded_again(self) -> None:
data = make_tarball({f"{LIB_PREFIX}libfoo.so": b"lib"})
manifest = self.archive_manifest(data)
self.preseed(manifest, b"corrupt")
self.download_content = data
ensure_installed(manifest)
self.assertEqual(len(self.downloads), 1)
self.assertTrue((self.base / "lib" / "libfoo.so").is_file())
def test_download_with_wrong_sha256_raises_and_removes_file(self) -> None:
data = make_tarball({f"{LIB_PREFIX}libfoo.so": b"lib"})
manifest = self.archive_manifest(data)
self.download_content = b"tampered"
with self.assertRaises(RuntimeDependencyError):
ensure_installed(manifest)
self.assertFalse((self.cache / "test" / "runtime.tar.gz").exists())
self.assertFalse((self.base / "lib" / "libfoo.so").exists())
def test_download_failure_names_the_preseed_directory(self) -> None:
manifest = self.archive_manifest(b"whatever")
with self.assertRaises(RuntimeDependencyError) as ctx:
ensure_installed(manifest)
self.assertIn(str(self.cache / "test"), str(ctx.exception))
def test_github_endpoint_mirror_is_honored(self) -> None:
data = make_tarball({f"{LIB_PREFIX}libfoo.so": b"lib"})
manifest = self.archive_manifest(data)
self.download_content = data
with patch.dict("os.environ", {"GITHUB_ENDPOINT": "https://mirror.test/"}):
ensure_installed(manifest)
self.assertEqual(
self.downloads,
["https://mirror.test/org/repo/releases/download/v1.0/runtime.tar.gz"],
)
def test_non_github_urls_are_left_alone(self) -> None:
with patch.dict("os.environ", {"GITHUB_ENDPOINT": "https://mirror.test"}):
self.assertEqual(
runtime_deps.resolve_url("https://example.com/a.whl"),
"https://example.com/a.whl",
)
class TestInstall(RuntimeDepsTestCase):
def test_archive_extracts_lib_and_bin_with_exec_bits_and_symlinks(self) -> None:
data = make_tarball(
{
f"{LIB_PREFIX}libfoo.so.1": b"lib",
f"{LIB_PREFIX}libfoo.so": "libfoo.so.1",
f"{BIN_PREFIX}tool": b"#!/bin/sh\n",
"rootfs/etc/ignored": b"no",
},
exec_names=(f"{BIN_PREFIX}tool",),
)
manifest = self.archive_manifest(data)
self.preseed(manifest, data)
ensure_installed(manifest)
lib = self.base / "lib"
self.assertEqual((lib / "libfoo.so.1").read_bytes(), b"lib")
self.assertEqual(os.readlink(lib / "libfoo.so"), "libfoo.so.1")
self.assertTrue(os.access(self.base / "bin" / "tool", os.X_OK))
self.assertFalse((self.base / "etc").exists())
stamp = json.loads(
(self.base / "share" / "frigate" / "runtimes" / "test.json").read_text()
)
self.assertEqual(stamp["version"], "1.0")
self.assertEqual(len(stamp["files"]), 3)
def test_zip_archive_with_include_filter_and_site_packages_dest(self) -> None:
data = make_zip(
{
"pkg-1.0/pkg/__init__.py": b"",
"pkg-1.0/pkg/x86/libx.so.2": b"lib",
"pkg-1.0/pkg/x86/libx.so": "libx.so.2",
"pkg-1.0/pkg/x86/other.txt": b"skip",
}
)
manifest = RuntimeManifest(
name="test",
version="1.0",
artifacts=(
Artifact(
url="https://github.com/org/pkg/archive/refs/tags/v1.0.zip",
sha256=sha256(data),
kind=ArtifactKind.archive,
filename="pkg-1.0.zip",
mappings=(
ArchiveMapping(
"pkg-1.0/pkg/", ArchiveDest.site_packages, subdir="pkg"
),
ArchiveMapping(
"pkg-1.0/pkg/x86/", ArchiveDest.lib, include=("libx.so*",)
),
),
),
),
)
self.preseed(manifest, data)
ensure_installed(manifest)
self.assertTrue((self.site / "pkg" / "__init__.py").is_file())
self.assertEqual(os.readlink(self.base / "lib" / "libx.so"), "libx.so.2")
self.assertTrue((self.base / "lib" / "libx.so.2").is_file())
self.assertFalse((self.base / "lib" / "other.txt").exists())
def test_archive_member_escaping_destination_is_rejected(self) -> None:
data = make_tarball({f"{LIB_PREFIX}../../../../escaped": b"evil"})
manifest = self.archive_manifest(data)
self.preseed(manifest, data)
with self.assertRaises(RuntimeDependencyError):
ensure_installed(manifest)
self.assertFalse((Path(self.tmp.name) / "escaped").exists())
def test_symlink_pointing_outside_its_directory_is_rejected(self) -> None:
data = make_tarball({f"{LIB_PREFIX}libfoo.so": "../../etc/passwd"})
manifest = self.archive_manifest(data)
self.preseed(manifest, data)
with self.assertRaises(RuntimeDependencyError):
ensure_installed(manifest)
def test_wheel_is_installed_with_pip_into_the_user_site(self) -> None:
data = b"not really a wheel"
manifest = RuntimeManifest(
name="test",
version="1.0",
artifacts=(
Artifact(
url="https://example.com/thing-1.0-py3-none-any.whl",
sha256=sha256(data),
kind=ArtifactKind.wheel,
),
),
)
self.preseed(manifest, data)
completed = subprocess.CompletedProcess([], 0, stdout="", stderr="")
with patch.object(
runtime_deps.subprocess, "run", return_value=completed
) as run:
ensure_installed(manifest)
args = run.call_args.args[0]
self.assertEqual(args[:4], [sys.executable, "-m", "pip", "install"])
self.assertIn("--user", args)
self.assertIn("--no-index", args)
self.assertIn("--no-deps", args)
self.assertTrue(args[-1].endswith("thing-1.0-py3-none-any.whl"))
# the staged copy is installed, not the one in the cache
self.assertFalse(args[-1].startswith(str(self.cache)))
def test_failed_pip_install_raises(self) -> None:
data = b"wheel"
manifest = RuntimeManifest(
name="test",
version="1.0",
artifacts=(
Artifact(
url="https://example.com/thing-1.0-py3-none-any.whl",
sha256=sha256(data),
kind=ArtifactKind.wheel,
),
),
)
self.preseed(manifest, data)
completed = subprocess.CompletedProcess([], 1, stdout="", stderr="boom")
with (
patch.object(runtime_deps.subprocess, "run", return_value=completed),
self.assertRaises(RuntimeDependencyError),
):
ensure_installed(manifest)
def test_matching_stamp_skips_a_second_install(self) -> None:
data = make_tarball({f"{LIB_PREFIX}libfoo.so": b"lib"})
manifest = self.archive_manifest(data)
self.preseed(manifest, data)
ensure_installed(manifest)
with patch.object(runtime_deps, "_extract_archive") as extract:
ensure_installed(manifest)
extract.assert_not_called()
def test_missing_installed_file_triggers_a_reinstall(self) -> None:
data = make_tarball({f"{LIB_PREFIX}libfoo.so": b"lib"})
manifest = self.archive_manifest(data)
self.preseed(manifest, data)
ensure_installed(manifest)
(self.base / "lib" / "libfoo.so").unlink()
ensure_installed(manifest)
self.assertTrue((self.base / "lib" / "libfoo.so").is_file())
def test_version_bump_reinstalls_and_removes_old_files(self) -> None:
old = make_tarball({f"{LIB_PREFIX}libfoo.so.1": b"old"})
old_manifest = self.archive_manifest(old, version="1.0")
self.preseed(old_manifest, old)
ensure_installed(old_manifest)
new = make_tarball({f"{LIB_PREFIX}libfoo.so.2": b"new"})
new_manifest = self.archive_manifest(new, version="2.0")
self.preseed(new_manifest, new)
ensure_installed(new_manifest)
self.assertFalse((self.base / "lib" / "libfoo.so.1").exists())
self.assertEqual((self.base / "lib" / "libfoo.so.2").read_bytes(), b"new")
def test_only_artifacts_for_the_current_machine_are_used(self) -> None:
x86 = make_tarball({f"{LIB_PREFIX}libx86.so": b"x"})
arm = make_tarball({f"{LIB_PREFIX}libarm.so": b"a"})
manifest = RuntimeManifest(
name="test",
version="1.0",
artifacts=(
Artifact(
url="https://example.com/x86.tar.gz",
sha256=sha256(x86),
kind=ArtifactKind.archive,
mappings=MAPPINGS,
machines=("x86_64",),
),
Artifact(
url="https://example.com/arm.tar.gz",
sha256=sha256(arm),
kind=ArtifactKind.archive,
mappings=MAPPINGS,
machines=("aarch64",),
),
),
)
(self.cache / "test").mkdir(parents=True)
(self.cache / "test" / "x86.tar.gz").write_bytes(x86)
(self.cache / "test" / "arm.tar.gz").write_bytes(arm)
with patch.object(runtime_deps.platform, "machine", return_value="aarch64"):
ensure_installed(manifest)
self.assertTrue((self.base / "lib" / "libarm.so").is_file())
self.assertFalse((self.base / "lib" / "libx86.so").exists())
class TestRootGuard(RuntimeDepsTestCase):
"""A root frigate service must never load code from a uid-1000 tree."""
def test_refuses_a_runtime_user_writable_base_under_granular_root(self) -> None:
manifest = self.archive_manifest(b"data")
with (
patch.object(runtime_deps, "user_base", lambda: Path("/config/.local")),
patch("os.geteuid", return_value=0),
patch.dict("os.environ", {"FRIGATE_ROOT_SERVICES": "frigate"}),
):
with self.assertRaises(RuntimeDependencyError) as ctx:
ensure_installed(manifest)
self.assertIn("FRIGATE_ROOT_SERVICES", str(ctx.exception))
self.assertIsNotNone(runtime_deps._usable_reason())
def test_root_owned_base_is_allowed_under_granular_root(self) -> None:
with (
patch.object(runtime_deps, "user_base", lambda: Path("/root/.local")),
patch("os.geteuid", return_value=0),
patch.dict("os.environ", {"FRIGATE_ROOT_SERVICES": "frigate"}),
):
self.assertIsNone(runtime_deps._usable_reason())
def test_escape_hatch_is_not_guarded(self) -> None:
with (
patch.object(runtime_deps, "user_base", lambda: Path("/config/.local")),
patch("os.geteuid", return_value=0),
patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "true"}),
):
self.assertIsNone(runtime_deps._usable_reason())
def test_disabled_user_site_is_refused(self) -> None:
with patch.object(runtime_deps.site, "ENABLE_USER_SITE", False):
self.assertIsNotNone(runtime_deps._usable_reason())
def test_activate_skips_a_refused_runtime(self) -> None:
self.site.mkdir(parents=True)
with (
patch.object(runtime_deps, "user_base", lambda: Path("/config/.local")),
patch("os.geteuid", return_value=0),
patch.dict("os.environ", {"FRIGATE_ROOT_SERVICES": "frigate"}),
):
activate(RuntimeManifest(name="test", version="1", artifacts=()))
self.assertNotIn(str(self.site), sys.path)
class TestActivate(RuntimeDepsTestCase):
def test_adds_the_user_site_ahead_of_system_site_packages(self) -> None:
self.site.mkdir(parents=True)
activate(RuntimeManifest(name="test", version="1", artifacts=()))
self.assertIn(str(self.site), sys.path)
system = [
i
for i, p in enumerate(sys.path)
if p.endswith(("site-packages", "dist-packages")) and p != str(self.site)
]
if system:
self.assertLess(sys.path.index(str(self.site)), system[0])
def test_preloads_libraries_in_order_with_rtld_global(self) -> None:
lib = self.base / "lib"
lib.mkdir(parents=True)
(lib / "liba.so").write_bytes(b"")
(lib / "libb.so").write_bytes(b"")
manifest = RuntimeManifest(
name="test", version="1", artifacts=(), preload=("liba.so", "libb.so")
)
with patch.object(runtime_deps.ctypes, "CDLL") as cdll:
activate(manifest)
activate(manifest)
self.assertEqual(
[c.args[0] for c in cdll.call_args_list],
[str(lib / "liba.so"), str(lib / "libb.so")],
)
self.assertTrue(
all(c.kwargs["mode"] == ctypes.RTLD_GLOBAL for c in cdll.call_args_list)
)
def test_missing_ld_library_path_is_reported(self) -> None:
manifest = RuntimeManifest(
name="test", version="1", artifacts=(), needs_ld_library_path=True
)
with self.assertLogs(runtime_deps.logger, level="WARNING") as logs:
activate(manifest)
self.assertTrue(any("LD_LIBRARY_PATH" in line for line in logs.output))
with (
patch.dict(
"os.environ", {"LD_LIBRARY_PATH": f"/usr/lib:{self.base / 'lib'}"}
),
self.assertNoLogs(runtime_deps.logger, level="WARNING"),
):
activate(manifest)
def test_find_tool_prefers_the_user_bin(self) -> None:
tool = self.base / "bin" / "sh"
tool.parent.mkdir(parents=True)
tool.write_text("#!/bin/sh\n")
tool.chmod(0o755)
self.assertEqual(find_tool("sh"), str(tool))
self.assertEqual(find_tool("definitely-not-a-tool"), "definitely-not-a-tool")
class TestDeclaredManifests(unittest.TestCase):
"""Every manifest a detector declares must be complete and pinned."""
def test_manifests_are_pinned_for_both_architectures(self) -> None:
from frigate.detectors import api_types
manifests = [
api.runtime_manifest
for api in api_types.values()
if api.runtime_manifest is not None
]
self.assertGreaterEqual(len(manifests), 3)
for manifest in manifests:
with self.subTest(manifest=manifest.name):
self.assertTrue(manifest.version)
self.assertTrue(manifest.import_check)
for artifact in manifest.artifacts:
self.assertRegex(artifact.sha256, r"^[0-9a-f]{64}$")
self.assertTrue(artifact.url.startswith("https://"))
for machine in ("x86_64", "aarch64"):
self.assertTrue(
any(
not a.machines or machine in a.machines
for a in manifest.artifacts
),
f"no artifact for {machine}",
)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -69,7 +69,7 @@ def frigate_service_is_granular_root() -> bool:
return any("".join(entry.split()) == "frigate" for entry in entries)
def _is_runtime_user_writable(path: str) -> bool:
def is_runtime_user_writable(path: str) -> bool:
"""Report whether a path resolves inside a runtime-user-writable tree."""
resolved = os.path.realpath(path)
return any(
@@ -104,7 +104,7 @@ def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
elif path in INCLUDED_FFMPEG_VERSIONS:
version = path
else:
if not (frigate_service_is_granular_root() and _is_runtime_user_writable(path)):
if not (frigate_service_is_granular_root() and is_runtime_user_writable(path)):
return f"{path}/bin/{binary}"
_warn_ignored_ffmpeg_path(path)
+35 -21
View File
@@ -1,7 +1,9 @@
"""RKNN model conversion utility for Frigate."""
import importlib
import logging
import os
import site
import subprocess
import sys
import time
@@ -101,30 +103,42 @@ def ensure_torch_dependencies() -> bool:
except ImportError:
logger.info("PyTorch not found, attempting to install...")
try:
subprocess.check_call(
[
sys.executable,
"-m",
"pip",
"install",
"--break-system-packages",
"setuptools<81",
"torch",
"torchvision",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
result = subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--break-system-packages",
"setuptools<81",
"torch",
"torchvision",
],
capture_output=True,
text=True,
)
import torch # type: ignore # noqa: F401
logger.info("PyTorch installed successfully")
return True
except (subprocess.CalledProcessError, ImportError) as e:
logger.error(f"Failed to install PyTorch: {e}")
if result.returncode != 0:
logger.error("Failed to install PyTorch:\n%s", result.stderr[-4000:])
return False
# as an unprivileged user pip falls back to the user site, which is
# only on sys.path at startup if it already existed
user_site = site.getusersitepackages()
if os.path.isdir(user_site) and user_site not in sys.path:
site.addsitedir(user_site)
importlib.invalidate_caches()
try:
import torch # type: ignore # noqa: F401
except ImportError as e:
logger.error(f"Failed to import PyTorch after installing it: {e}")
return False
logger.info("PyTorch installed successfully")
return True
def ensure_rknn_toolkit() -> bool:
"""Ensure RKNN toolkit is available."""
+545
View File
@@ -0,0 +1,545 @@
"""Runtime installation of optional accelerator SDKs into the user site.
Detector runtimes that are only useful with specific hardware are not shipped
in the image. A detector declares a RuntimeManifest of pinned, checksummed
artifacts, and ensure_installed() fetches and installs them into the runtime
user's home (pip's --user location) the first time that detector is
configured. Everything is derived from site.getuserbase() so the location
follows $HOME: /config/.local for the unprivileged service, /root/.local for
a root service, never a mix of the two.
"""
import ctypes
import fnmatch
import hashlib
import importlib
import importlib.util
import json
import logging
import os
import platform
import shutil
import site
import stat
import subprocess
import sys
import tarfile
import tempfile
import zipfile
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from frigate.const import MODEL_CACHE_DIR
from frigate.util.config import (
frigate_service_is_granular_root,
is_runtime_user_writable,
)
from frigate.util.file import FileLock
logger = logging.getLogger(__name__)
GITHUB_URL = "https://github.com"
DOCS_URL = "https://docs.frigate.video/frigate/network_requirements#detector-runtimes"
class RuntimeDependencyError(RuntimeError):
"""A runtime could not be installed or is not allowed to be used."""
class ArtifactKind(StrEnum):
wheel = "wheel"
archive = "archive"
class ArchiveDest(StrEnum):
lib = "lib"
bin = "bin"
site_packages = "site_packages"
@dataclass(frozen=True)
class ArchiveMapping:
"""Copy archive members under `prefix` into a directory of the user base.
`include` holds optional basename globs; when set, only matching members
are extracted. `machines` restricts the mapping to platform.machine()
values, empty meaning all.
"""
prefix: str
dest: ArchiveDest
subdir: str = ""
include: tuple[str, ...] = ()
machines: tuple[str, ...] = ()
@dataclass(frozen=True)
class Artifact:
url: str
sha256: str
kind: ArtifactKind
filename: str | None = None
mappings: tuple[ArchiveMapping, ...] = ()
machines: tuple[str, ...] = ()
@property
def name(self) -> str:
return self.filename or os.path.basename(self.url.split("?")[0])
@dataclass(frozen=True)
class RuntimeManifest:
"""The pinned artifacts that make up one detector's runtime.
`preload` lists shared libraries under <user base>/lib to load with
RTLD_GLOBAL before the SDK import, in dependency order. Libraries without
a SONAME cannot be satisfied that way and need <user base>/lib on
LD_LIBRARY_PATH at exec time; `needs_ld_library_path` makes activate()
warn when it is missing.
"""
name: str
version: str
artifacts: tuple[Artifact, ...]
preload: tuple[str, ...] = ()
import_check: str = ""
needs_ld_library_path: bool = False
def digest(self) -> str:
parts = [self.name, self.version, *sorted(a.sha256 for a in self.artifacts)]
return hashlib.sha256("\n".join(parts).encode()).hexdigest()
_loaded_libs: dict[str, ctypes.CDLL] = {}
def user_base() -> Path:
return Path(site.getuserbase())
def user_site() -> Path:
return Path(site.getusersitepackages())
def cache_dir(name: str) -> Path:
"""The directory downloads land in, and where offline users pre-seed them."""
return Path(MODEL_CACHE_DIR) / "runtimes" / name
def resolve_url(url: str) -> str:
"""Apply the GITHUB_ENDPOINT mirror to GitHub release URLs."""
if url.startswith(f"{GITHUB_URL}/"):
endpoint = os.environ.get("GITHUB_ENDPOINT", GITHUB_URL).rstrip("/")
return f"{endpoint}{url[len(GITHUB_URL) :]}"
return url
def find_tool(name: str) -> str:
"""Resolve a CLI tool, preferring the runtime-installed copy."""
candidate = user_base() / "bin" / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
return shutil.which(name) or name
def sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _usable_reason() -> str | None:
"""Why the user site must not be used, or None when it may be."""
if not site.ENABLE_USER_SITE:
return "the user site-packages directory is disabled for this interpreter"
base = user_base()
if frigate_service_is_granular_root() and is_runtime_user_writable(str(base)):
return (
f"{base} is writable by the unprivileged user while "
"FRIGATE_ROOT_SERVICES runs frigate as root"
)
return None
def _artifacts_for_machine(manifest: RuntimeManifest) -> list[Artifact]:
machine = platform.machine()
return [a for a in manifest.artifacts if not a.machines or machine in a.machines]
def _fetch(artifact: Artifact, cache: Path) -> Path:
"""Return a verified copy of the artifact in the cache, downloading if needed."""
path = cache / artifact.name
if path.is_file():
if sha256_of(path) == artifact.sha256:
logger.info("Using pre-seeded runtime file %s", path)
return path
logger.warning("Checksum mismatch for %s, downloading again", path)
path.unlink()
# imported here so the detector API does not pull in the IPC stack
from frigate.util.downloader import ModelDownloader
url = resolve_url(artifact.url)
logger.info("Downloading runtime file %s", url)
try:
ModelDownloader.download_from_url(url, str(path), silent=True)
except Exception as err:
raise RuntimeDependencyError(
f"Unable to download {url}: {err}. Without internet access, "
f"download it elsewhere and place it in {cache} (see {DOCS_URL})"
) from err
if sha256_of(path) != artifact.sha256:
path.unlink()
raise RuntimeDependencyError(
f"Checksum mismatch for {url}; the file was removed from {cache}"
)
return path
def _stage(path: Path, artifact: Artifact, staging: Path) -> Path:
"""Copy into the private staging dir and verify there.
The cache directory is writable by the runtime user, so the copy that is
installed is the one that was verified, not whatever is in the cache at
install time.
"""
staged = staging / path.name
shutil.copyfile(path, staged)
if sha256_of(staged) != artifact.sha256:
raise RuntimeDependencyError(f"{path} changed while being installed")
return staged
def _install_wheel(path: Path) -> None:
result = subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--user",
"--no-index",
"--no-deps",
"--force-reinstall",
"--no-warn-script-location",
str(path),
],
capture_output=True,
text=True,
env={**os.environ, "PIP_BREAK_SYSTEM_PACKAGES": "1"},
)
if result.returncode != 0:
logger.error("pip install of %s failed:\n%s", path.name, result.stderr[-4000:])
raise RuntimeDependencyError(f"pip install of {path.name} failed")
logger.debug("pip install of %s:\n%s", path.name, result.stdout)
def _dest_root(mapping: ArchiveMapping) -> Path:
if mapping.dest is ArchiveDest.site_packages:
root = user_site()
else:
root = user_base() / mapping.dest.value
return root / mapping.subdir if mapping.subdir else root
def _mappings_for(
member_name: str, mappings: list[ArchiveMapping]
) -> list[tuple[ArchiveMapping, str]]:
"""Every mapping a member falls under; prefixes may overlap."""
matches = []
for mapping in mappings:
if not member_name.startswith(mapping.prefix):
continue
relative = member_name[len(mapping.prefix) :]
if not relative or relative.endswith("/"):
continue
if mapping.include and not any(
fnmatch.fnmatch(os.path.basename(relative), p) for p in mapping.include
):
continue
matches.append((mapping, relative))
return matches
def _safe_dest(root: Path, relative: str) -> Path:
dest = root / relative
resolved_root = os.path.realpath(root)
if not os.path.realpath(dest).startswith(f"{resolved_root}{os.sep}"):
raise RuntimeDependencyError(f"Archive member {relative} escapes {root}")
return dest
def _write_member(dest: Path, data_source, mode: int) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(f"{dest.name}.tmp")
with open(tmp, "wb") as f:
shutil.copyfileobj(data_source, f)
os.chmod(tmp, (mode & 0o777) or 0o644)
os.replace(tmp, dest)
def _write_symlink(dest: Path, target: str) -> None:
# only links to a sibling file are recreated; anything else is dropped
if "/" in target or target in ("", ".", ".."):
raise RuntimeDependencyError(f"Archive symlink {dest.name} -> {target}")
dest.parent.mkdir(parents=True, exist_ok=True)
dest.unlink(missing_ok=True)
os.symlink(target, dest)
def _extract_archive(path: Path, artifact: Artifact) -> list[str]:
"""Extract mapped members into the user base and return their paths."""
machine = platform.machine()
mappings = [m for m in artifact.mappings if not m.machines or machine in m.machines]
written: list[str] = []
if path.name.endswith(".zip"):
with zipfile.ZipFile(path) as archive:
for info in archive.infolist():
if info.is_dir():
continue
mode = info.external_attr >> 16
for mapping, relative in _mappings_for(info.filename, mappings):
dest = _safe_dest(_dest_root(mapping), relative)
if stat.S_ISLNK(mode):
_write_symlink(dest, archive.read(info).decode())
else:
with archive.open(info) as source:
_write_member(dest, source, mode)
written.append(str(dest))
else:
with tarfile.open(path, "r:*") as archive:
for member in archive:
if not (member.isfile() or member.issym()):
continue
for mapping, relative in _mappings_for(member.name, mappings):
dest = _safe_dest(_dest_root(mapping), relative)
if member.issym():
_write_symlink(dest, member.linkname)
else:
source = archive.extractfile(member)
assert source is not None
with source:
_write_member(dest, source, member.mode)
written.append(str(dest))
if not written:
raise RuntimeDependencyError(f"{path.name} contained no expected files")
return written
def _stamp_path(name: str) -> Path:
return user_base() / "share" / "frigate" / "runtimes" / f"{name}.json"
def _read_stamp(name: str) -> dict | None:
try:
with open(_stamp_path(name)) as f:
stamp = json.load(f)
except (OSError, ValueError):
return None
return stamp if isinstance(stamp, dict) else None
def _write_stamp(manifest: RuntimeManifest, files: list[str]) -> None:
path = _stamp_path(manifest.name)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(
{
"version": manifest.version,
"digest": manifest.digest(),
"files": files,
},
f,
indent=2,
)
def _remove_stamped_files(stamp: dict | None) -> None:
for file in (stamp or {}).get("files", []):
Path(file).unlink(missing_ok=True)
def _is_current(manifest: RuntimeManifest, stamp: dict | None) -> bool:
if (
stamp is None
or stamp.get("version") != manifest.version
or stamp.get("digest") != manifest.digest()
):
return False
if not all(os.path.lexists(f) for f in stamp.get("files", [])):
return False
if manifest.import_check:
_add_user_site()
return importlib.util.find_spec(manifest.import_check) is not None
return True
def _add_user_site() -> None:
"""Put the user site on sys.path the way interpreter startup would.
site.addsitedir() appends, but at startup the user site precedes the
system site-packages, so the entry is moved ahead of them to keep that
precedence.
"""
path = str(user_site())
if path in sys.path or not os.path.isdir(path):
return
site.addsitedir(path)
importlib.invalidate_caches()
system_sites = [
i
for i, p in enumerate(sys.path)
if p.endswith(("site-packages", "dist-packages")) and p != path
]
if system_sites:
sys.path.remove(path)
sys.path.insert(system_sites[0], path)
def activate(manifest: RuntimeManifest) -> None:
"""Make an installed runtime importable in the current process.
Idempotent, and safe to call when nothing is installed; the SDK import
then fails with its own error.
"""
reason = _usable_reason()
if reason is not None:
logger.warning("Ignoring the %s runtime because %s", manifest.name, reason)
return
_add_user_site()
lib_dir = user_base() / "lib"
if manifest.needs_ld_library_path and str(lib_dir) not in os.environ.get(
"LD_LIBRARY_PATH", ""
).split(":"):
logger.warning(
"%s is not on LD_LIBRARY_PATH; the %s runtime may fail to load",
lib_dir,
manifest.name,
)
for soname in manifest.preload:
path = str(lib_dir / soname)
if path in _loaded_libs:
continue
if not os.path.exists(path):
logger.debug("Runtime library %s is not installed", path)
continue
try:
_loaded_libs[path] = ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
except OSError as err:
logger.warning("Unable to preload %s: %s", path, err)
def ensure_installed(manifest: RuntimeManifest) -> None:
"""Install the manifest into the user site unless it already is.
Meant to run once in the main process before detector processes start,
so sys.path is inherited by them. Raises RuntimeDependencyError when the
runtime cannot be installed or must not be used.
"""
reason = _usable_reason()
if reason is not None:
raise RuntimeDependencyError(
f"Refusing to install the {manifest.name} runtime because {reason}"
)
cache = cache_dir(manifest.name)
cache.mkdir(parents=True, exist_ok=True)
with FileLock(cache / ".install.lock", timeout=600):
stamp = _read_stamp(manifest.name)
if _is_current(manifest, stamp):
logger.debug(
"%s runtime %s is already installed", manifest.name, manifest.version
)
activate(manifest)
return
logger.info("Installing the %s runtime %s", manifest.name, manifest.version)
_remove_stamped_files(stamp)
staging = Path(tempfile.mkdtemp(prefix=f"frigate-{manifest.name}-"))
files: list[str] = []
try:
for artifact in _artifacts_for_machine(manifest):
staged = _stage(_fetch(artifact, cache), artifact, staging)
if artifact.kind is ArtifactKind.wheel:
_install_wheel(staged)
else:
files.extend(_extract_archive(staged, artifact))
except (OSError, tarfile.TarError, zipfile.BadZipFile) as err:
raise RuntimeDependencyError(
f"Unable to install the {manifest.name} runtime: {err}"
) from err
finally:
shutil.rmtree(staging, ignore_errors=True)
_write_stamp(manifest, files)
activate(manifest)
logger.info(
"Installed the %s runtime %s into %s",
manifest.name,
manifest.version,
user_base(),
)