From 71da162e1ffe892188adea1efe523fa8ffdf76d0 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 16 Aug 2026 18:12:04 +0200 Subject: [PATCH] feat(vision): il worker che espone il runner, versione stampata al build Container FastAPI separato (Dockerfile.vision, python:3.13-slim) che espone run_graph/engine_version del Task 2 via POST /run e GET /health, cosi' l'immagine del server principale non importa mai VisionSuite. engine_version() ora legge VISION_ENGINE_VERSION se impostata, altrimenti ricade su git rev-parse nel checkout di sviluppo, e non inventa mai un valore: senza nessuna delle due solleva un errore esplicito. Nel container il fallback a git non puo' funzionare (.git del submodule punta fuori dal build context), quindi Dockerfile.vision prende il commit come build arg e lo fissa in ambiente; i compose file lo passano da VISION_ENGINE_VERSION. Nessuna porta pubblicata e nessuna label Traefik sul servizio vision: e' raggiungibile solo dal server, su tmflow-net. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU --- .env.example | 4 ++ Dockerfile.vision | 33 ++++++++++ README.md | 46 +++++++++++++ docker-compose.dev.yml | 12 ++++ docker-compose.yml | 12 ++++ pyproject.toml | 2 +- src/vision/runner.py | 48 ++++++++++++-- src/vision/tests/test_runner.py | 34 ++++++++++ src/vision_worker/__init__.py | 0 src/vision_worker/main.py | 58 ++++++++++++++++ src/vision_worker/tests/__init__.py | 0 src/vision_worker/tests/test_worker.py | 91 ++++++++++++++++++++++++++ 12 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 Dockerfile.vision create mode 100644 src/vision_worker/__init__.py create mode 100644 src/vision_worker/main.py create mode 100644 src/vision_worker/tests/__init__.py create mode 100644 src/vision_worker/tests/test_worker.py diff --git a/.env.example b/.env.example index aba9d14..60ef3e7 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ STATION_CODE=ST-DEFAULT # another station's recipes would silently break traceability. STATION_SWITCH_ENABLED=0 +# --- Vision --- +# Internal address of the vision worker. Never exposed outside tmflow-net. +VISION_WORKER_URL=http://vision:8100 + # --- File Storage --- # Resolved against the project root in src/backend/config.py. # Default "uploads" maps to /uploads, mounted as a Docker diff --git a/Dockerfile.vision b/Dockerfile.vision new file mode 100644 index 0000000..a85e395 --- /dev/null +++ b/Dockerfile.vision @@ -0,0 +1,33 @@ +FROM python:3.13-slim + +# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d) +# needs libGL and glib at runtime even though it is never imported by name here. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app +COPY pyproject.toml uv.lock ./ +COPY vendor/ ./vendor/ +RUN uv sync --extra vision --frozen --no-dev + +COPY src/vision/ ./src/vision/ +COPY src/vision_worker/ ./src/vision_worker/ + +# Ruling R8: the version is stamped at build time, not discovered at runtime. +# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a +# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives +# outside the build context - so `git rev-parse HEAD` cannot work in here. +# Pass the submodule's commit in from the host, which does have git: +# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \ +# docker compose build vision +ARG VISION_ENGINE_VERSION +ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION} + +EXPOSE 8100 + +# Two workers, not four: each holds the vision stack in memory. +CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \ + "--host", "0.0.0.0", "--port", "8100", "--workers", "2"] diff --git a/README.md b/README.md index 88aca9d..05b3bc6 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,51 @@ uv sync --extra vision --extra dev --python 3.13 uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests ``` +Lo stesso vale per `src/vision_worker/tests/`, il piccolo servizio FastAPI che +espone il runner: dipende da `src.vision.runner` e quindi eredita lo stesso +vincolo su Python 3.13. + +```bash +uv run --python 3.13 --extra vision --extra dev pytest src/vision_worker/tests +``` + +### Il worker di visione (`Dockerfile.vision`) + +Il worker gira in un container separato dal server FastAPI principale, così +che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non +richieda di riavviare il traffico di produzione. Il container non pubblica +porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno +`http://vision:8100` sulla rete `tmflow-net`. + +Ogni misura riporta il commit di VisionSuite che l'ha prodotta +(`engine_version`), perché una stazione che misura con un motore diverso da +quello atteso deve poter essere identificata. Il container, però, non ha +accesso al repository Git del progetto principale — `vendor/visionsuite` è un +submodule, e la copia `.git` che lo collega al repository ospitante non +viene inclusa nel contesto di build — quindi la versione non può essere +scoperta al volo dentro l'immagine. Va invece **stampata al momento del +build**, leggendo il commit dalla macchina che esegue `docker compose build`, +dove il repository Git è presente per intero: + +```bash +VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \ + docker compose -f docker-compose.dev.yml build vision +``` + +La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION` +in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in +modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare. +Fuori da un container, in un checkout di sviluppo locale, la stessa funzione +ricade su `git rev-parse` se la variabile non è impostata — comodo per +lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza +passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi: +il `.git` del submodule non arriva nel contesto di build, e il worker +risponde con un errore esplicito su `/health` invece di indovinare una +versione o restituire `"unknown"`. La build va quindi sempre lanciata con la +variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando +mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml` +in produzione). + Stato corrente su `V3.0.0`: **360 pass, 0 fail** (212 backend + 148 frontend). Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano @@ -430,6 +475,7 @@ Copia `.env.example` in `.env` e configura: | `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) | | `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) | | `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. | +| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) | | `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) | | `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) | | `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) | diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 94018b7..46b54f6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -33,6 +33,7 @@ services: environment: DB_HOST: mysql UPLOAD_DIR: uploads + VISION_WORKER_URL: http://vision:8100 volumes: - upload_data:/app/uploads depends_on: @@ -41,6 +42,17 @@ services: networks: - tmflow-net + vision: + build: + context: . + dockerfile: Dockerfile.vision + args: + VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-} + container_name: tmflow-vision + restart: unless-stopped + networks: + - tmflow-net + client: build: context: . diff --git a/docker-compose.yml b/docker-compose.yml index fb96485..b3ead40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: environment: DB_HOST: mysql UPLOAD_DIR: uploads + VISION_WORKER_URL: http://vision:8100 volumes: - upload_data:/app/uploads depends_on: @@ -50,6 +51,17 @@ services: - tmflow-net - traefik-net + vision: + build: + context: . + dockerfile: Dockerfile.vision + args: + VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-} + container_name: tmflow-vision + restart: unless-stopped + networks: + - tmflow-net + client: build: context: . diff --git a/pyproject.toml b/pyproject.toml index 02d5ff9..b06c851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,4 +87,4 @@ vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true } [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests"] +testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"] diff --git a/src/vision/runner.py b/src/vision/runner.py index 91706a4..5884083 100644 --- a/src/vision/runner.py +++ b/src/vision/runner.py @@ -6,6 +6,7 @@ database, no filesystem beyond reading the pinned submodule's commit. """ from __future__ import annotations +import os import subprocess import time from dataclasses import dataclass, field @@ -36,12 +37,49 @@ class RunOutcome: @lru_cache(maxsize=1) +def _engine_version_from_git() -> str: + """Read the pinned commit from the submodule checkout. + + Cached because the subprocess call is not free and the answer cannot + change within a run. Kept separate from `engine_version()` so that the + (cheap) environment-variable check below is never served a stale answer, + and so a test can force this path to fail without poisoning the cache + for real callers. + """ + try: + return subprocess.check_output( + ["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"], + text=True, + stderr=subprocess.PIPE, + ).strip() + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimeError( + "engine_version: cannot determine which VisionSuite commit this " + "process is running. VISION_ENGINE_VERSION is not set, and " + f"`git -C {_SUBMODULE} rev-parse HEAD` failed ({exc}). " + "In the vision worker container this is expected - it has no " + "usable .git for the vendored submodule - so the image must be " + "built with `--build-arg VISION_ENGINE_VERSION=` " + "(see Dockerfile.vision). In a development checkout, make sure " + "the vendor/visionsuite submodule is initialised. Refusing to " + "guess: a station must not measure with an engine it cannot name." + ) from exc + + def engine_version() -> str: - """The pinned VisionSuite commit: stamped on every number produced.""" - return subprocess.check_output( - ["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"], - text=True, - ).strip() + """The pinned VisionSuite commit: stamped on every number produced. + + Resolution order: the VISION_ENGINE_VERSION environment variable (set at + container build time - Ruling R8, see Dockerfile.vision) if set and + non-empty; otherwise `git rev-parse HEAD` on the vendored submodule, + which works in a development checkout. Never falls back to a guess, an + empty string, or "unknown" - a station running an engine it cannot name + must refuse to measure rather than produce numbers nobody can trace. + """ + stamped = os.environ.get("VISION_ENGINE_VERSION", "").strip() + if stamped: + return stamped + return _engine_version_from_git() def run_graph( diff --git a/src/vision/tests/test_runner.py b/src/vision/tests/test_runner.py index 2faca7d..73c528d 100644 --- a/src/vision/tests/test_runner.py +++ b/src/vision/tests/test_runner.py @@ -18,6 +18,7 @@ pytest.importorskip( import numpy as np +from src.vision import runner from src.vision.runner import RunOutcome, engine_version, run_graph @@ -74,3 +75,36 @@ def test_a_broken_tool_does_not_sink_the_others(): def test_the_engine_says_which_version_it_is(): version = engine_version() assert len(version) == 40 # a full git SHA + + +def test_engine_version_honours_the_environment_variable(monkeypatch): + """A build-stamped VISION_ENGINE_VERSION wins over git, if set (Ruling R8: + the container has no usable .git for the vendored submodule, so the + worker image stamps the commit at build time instead of discovering it).""" + stamped = "a" * 40 + monkeypatch.setenv("VISION_ENGINE_VERSION", stamped) + runner._engine_version_from_git.cache_clear() + + try: + assert engine_version() == stamped + finally: + runner._engine_version_from_git.cache_clear() + + +def test_engine_version_raises_rather_than_inventing_one(monkeypatch): + """No env var and no resolvable git HEAD: refuse loudly. An engine that + cannot say what it is has no business producing measurements - it must + never fall back to a guess, an empty string, or "unknown".""" + monkeypatch.delenv("VISION_ENGINE_VERSION", raising=False) + runner._engine_version_from_git.cache_clear() + + def fake_check_output(*args, **kwargs): + raise FileNotFoundError("git: command not found") + + monkeypatch.setattr(runner.subprocess, "check_output", fake_check_output) + + try: + with pytest.raises(RuntimeError, match="VISION_ENGINE_VERSION"): + engine_version() + finally: + runner._engine_version_from_git.cache_clear() diff --git a/src/vision_worker/__init__.py b/src/vision_worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/vision_worker/main.py b/src/vision_worker/main.py new file mode 100644 index 0000000..a306042 --- /dev/null +++ b/src/vision_worker/main.py @@ -0,0 +1,58 @@ +"""The heavy container: the runner behind an internal API. + +Separate from the FastAPI server on purpose. The API image stays light, a +VisionSuite upgrade does not restart production traffic, and an execution that +crashes does not take the other tablets' requests down with it. +""" +from __future__ import annotations + +import json + +import numpy as np +from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status +from PIL import Image + +from src.vision.runner import engine_version, run_graph + +app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0") + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok", "engine_version": engine_version()} + + +@app.post("/run") +async def run( + image: UploadFile = File(...), + graph: str = Form(...), +) -> dict: + try: + parsed_graph = json.loads(graph) + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"graph is not valid JSON: {exc}", + ) from exc + + frame = np.array(Image.open(image.file).convert("L")) + + try: + outcome = run_graph(frame, parsed_graph) + except ValueError as exc: + # from_dict refuses a schema version it does not handle; that is a bad + # request, not a server fault. + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + return { + "outputs": outcome.outputs, + "failures": [ + {"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error} + for f in outcome.failures + ], + "engine_version": outcome.engine_version, + "duration_ms": outcome.duration_ms, + } diff --git a/src/vision_worker/tests/__init__.py b/src/vision_worker/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/vision_worker/tests/test_worker.py b/src/vision_worker/tests/test_worker.py new file mode 100644 index 0000000..bf26b97 --- /dev/null +++ b/src/vision_worker/tests/test_worker.py @@ -0,0 +1,91 @@ +"""The worker is a thin shell: it receives an image and a graph, and answers +with numbers.""" +import io +import json + +import pytest + +# Same reasoning as src/vision/tests/test_runner.py: the vision tree (and +# therefore this worker, which imports src.vision.runner) requires Python +# >=3.13. This must run before `import numpy` / `from src.vision_worker.main +# import app` so that `uv run pytest` on the default 3.11 environment reports +# this module as skipped, with a reason, instead of erroring out of +# collection. +pytest.importorskip( + "visionsuite.task", + reason=( + "the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d " + "declare it upstream); run it with " + "`uv run --python 3.13 --extra vision --extra dev pytest " + "src/vision_worker/tests`" + ), +) + +import numpy as np +from httpx import ASGITransport, AsyncClient +from PIL import Image + +from src.vision_worker.main import app + + +def a_disc_png(radius: int = 60, size: int = 400) -> bytes: + image = np.zeros((size, size), dtype=np.uint8) + yy, xx = np.ogrid[:size, :size] + centre = size // 2 + image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255 + buffer = io.BytesIO() + Image.fromarray(image).save(buffer, format="PNG") + return buffer.getvalue() + + +def a_circle_graph() -> dict: + from visionsuite.task import Task + from visionsuite.task.tools import DetectCircleTool, DiameterTool + + task = Task(name="prova") + found = task.add(DetectCircleTool(name="disco")) + found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0 + found.scan_size = 30.0 + quota = task.add(DiameterTool(name="diametro")) + quota.link("circle", found, "circle") + return task.to_dict() + + +@pytest.mark.asyncio +async def test_health_says_which_engine_it_carries(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://worker") as client: + response = await client.get("/health") + + assert response.status_code == 200 + assert len(response.json()["engine_version"]) == 40 + + +@pytest.mark.asyncio +async def test_run_returns_the_named_outputs(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://worker") as client: + response = await client.post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps(a_circle_graph())}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["failures"] == [] + assert any(key.endswith(".diameter") for key in body["outputs"]) + + +@pytest.mark.asyncio +async def test_a_malformed_graph_is_refused_not_crashed(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://worker") as client: + response = await client.post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps({"schema_version": 99, "tools": []})}, + ) + + assert response.status_code == 422 + assert "99" in response.json()["detail"]