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
+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()