diff --git a/.github/workflows/hosting-runtime.yml b/.github/workflows/hosting-runtime.yml index 6324837..104fa67 100644 --- a/.github/workflows/hosting-runtime.yml +++ b/.github/workflows/hosting-runtime.yml @@ -7,6 +7,7 @@ on: - k8s/local-hosting/** - observability.py - main.py + - utils/model_download.py - .github/workflows/hosting-runtime.yml workflow_dispatch: {} diff --git a/.github/workflows/model-install-tests.yml b/.github/workflows/model-install-tests.yml new file mode 100644 index 0000000..ffe72ae --- /dev/null +++ b/.github/workflows/model-install-tests.yml @@ -0,0 +1,16 @@ +name: Model installation regression +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + model-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m unittest discover -s tests -p test_model_download.py -v diff --git a/k8s/local-hosting/Dockerfile b/k8s/local-hosting/Dockerfile index e6d2f3c..8b512c5 100644 --- a/k8s/local-hosting/Dockerfile +++ b/k8s/local-hosting/Dockerfile @@ -8,6 +8,7 @@ RUN python -c 'from backends.onnx.backend import ONNXBackend; from PIL import Im RUN python -m pip install --no-cache-dir sentry-sdk==2.73.0 COPY observability.py main.py /app/ +COPY utils/model_download.py /app/utils/model_download.py ARG SENTRY_RELEASE ENV SENTRY_DSN=https://8bd44d815901211012fcae52d51371f9@o4512337837686784.ingest.us.sentry.io/4512337894703104 \ SENTRY_ENVIRONMENT=production \ diff --git a/tests/test_model_download.py b/tests/test_model_download.py new file mode 100644 index 0000000..3b51013 --- /dev/null +++ b/tests/test_model_download.py @@ -0,0 +1,37 @@ +"""Exercise installation across actual filesystem boundaries, without a network.""" +import io +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch +from utils.model_download import ensure_tflite_ssd_mobilenet_v1, ModelDownloadError + + +class ModelInstallTest(unittest.TestCase): + def archive(self): + data = io.BytesIO() + with zipfile.ZipFile(data, 'w') as z: + z.writestr('detect.tflite', b'model-fixture') + return data.getvalue() + + def test_cross_filesystem_install(self): + if not Path('/dev/shm').is_dir(): + self.skipTest('requires a separate writable tmpfs') + with tempfile.TemporaryDirectory(dir='/dev/shm') as directory: + root = Path(directory) + self.assertNotEqual(root.stat().st_dev, Path(tempfile.gettempdir()).stat().st_dev) + with patch('urllib.request.urlopen', return_value=io.BytesIO(self.archive())): + ensure_tflite_ssd_mobilenet_v1(model_path=str(root/'model'), labels_path=str(root/'labels')) + self.assertEqual((root/'model').read_bytes(), b'model-fixture') + self.assertFalse(list(root.glob('.light-object-detect-*'))) + + def test_failed_refresh_keeps_existing_model(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root/'model').write_bytes(b'known-good') + with patch('urllib.request.urlopen', side_effect=OSError('interrupted')): + with self.assertRaises(ModelDownloadError): + ensure_tflite_ssd_mobilenet_v1(model_path=str(root/'model'), labels_path=str(root/'labels'), force=True) + self.assertEqual((root/'model').read_bytes(), b'known-good') + self.assertFalse(list(root.glob('.light-object-detect-*'))) diff --git a/utils/model_download.py b/utils/model_download.py index 4af0bcd..7475b38 100644 --- a/utils/model_download.py +++ b/utils/model_download.py @@ -143,7 +143,7 @@ def ensure_tflite_ssd_mobilenet_v1( model_file.parent.mkdir(parents=True, exist_ok=True) try: - with tempfile.TemporaryDirectory(prefix="light-object-detect-") as tmpdir: + with tempfile.TemporaryDirectory(prefix=".light-object-detect-", dir=model_file.parent) as tmpdir: tmp_dir = Path(tmpdir) zip_dest = tmp_dir / "model.zip" @@ -169,7 +169,7 @@ def ensure_tflite_ssd_mobilenet_v1( if not extracted_path.exists(): raise ModelDownloadError("Extraction succeeded but file is missing on disk.") - # Atomic-ish replace + # Staging and destination share a filesystem, so replacement is atomic. tmp_model = tmp_dir / "detect.tmp.tflite" tmp_model.write_bytes(extracted_path.read_bytes()) tmp_model.replace(model_file)