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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
This commit is contained in:
2026-08-16 18:12:04 +02:00
parent 9387e7c306
commit 71da162e1f
12 changed files with 334 additions and 6 deletions
+4
View File
@@ -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 <project_root>/uploads, mounted as a Docker
+33
View File
@@ -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"]
+46
View File
@@ -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) |
+12
View File
@@ -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: .
+12
View File
@@ -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: .
+1 -1
View File
@@ -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"]
+43 -5
View File
@@ -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=<commit>` "
"(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(
+34
View File
@@ -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()
View File
+58
View File
@@ -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,
}
View File
+91
View File
@@ -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"]