mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-08 15:52:48 +03:00
Dynamically install and load detector dependencies (#24156)
* Dynamically install and load detector dependencies * Cleanup * Cleanup
This commit is contained in:
@@ -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