mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-24 18:26:51 +03:00
Dynamically install and load detector dependencies (#24156)
* Dynamically install and load detector dependencies * Cleanup * Cleanup
This commit is contained in:
+3
-12
@@ -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
|
||||
|
||||
@@ -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 -
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
Reference in New Issue
Block a user