feat(server): expose APP_VERSION + MIN_COMPAT_CLI_VERSION on /api/* response headers
Adds X-Agnes-Latest-Version and X-Agnes-Min-Version headers to every /api/* response. CLI consumes these to hard-stop on incompatible drift. MIN_COMPAT_CLI_VERSION ships at 0.0.0 — no enforcement until a deliberate wire-protocol break bumps it. Also dedupes app version logic: app/main.py:_app_version() helper deleted, replaced by app/version.py:APP_VERSION as the single source of truth. test_app_version.py rewritten to target app.version.
This commit is contained in:
parent
56483989cf
commit
57170bc556
4 changed files with 113 additions and 43 deletions
25
app/main.py
25
app/main.py
|
|
@ -23,8 +23,6 @@ except ImportError:
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from importlib.metadata import PackageNotFoundError
|
|
||||||
from importlib.metadata import version as _pkg_version
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
@ -36,18 +34,7 @@ from app.logging_config import setup_logging
|
||||||
|
|
||||||
setup_logging("app")
|
setup_logging("app")
|
||||||
|
|
||||||
|
from app.version import APP_VERSION, MIN_COMPAT_CLI_VERSION
|
||||||
def _app_version() -> str:
|
|
||||||
"""Product version for FastAPI title / OpenAPI schema.
|
|
||||||
|
|
||||||
Single source of truth is `pyproject.toml` `[project].version`; we read
|
|
||||||
it back via `importlib.metadata` at runtime so `/docs`, `/openapi.json`,
|
|
||||||
`/api/version`, `/cli/latest`, and `da --version` can never drift.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _pkg_version("agnes-the-ai-analyst")
|
|
||||||
except PackageNotFoundError:
|
|
||||||
return "dev"
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -183,7 +170,7 @@ def create_app() -> FastAPI:
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="AI Data Analyst",
|
title="AI Data Analyst",
|
||||||
description="Data distribution platform for AI analytical systems",
|
description="Data distribution platform for AI analytical systems",
|
||||||
version=_app_version(),
|
version=APP_VERSION,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
# Intentionally NOT debug=DEBUG: FastAPI's debug=True installs
|
# Intentionally NOT debug=DEBUG: FastAPI's debug=True installs
|
||||||
# Starlette's ServerErrorMiddleware which intercepts unhandled
|
# Starlette's ServerErrorMiddleware which intercepts unhandled
|
||||||
|
|
@ -195,6 +182,14 @@ def create_app() -> FastAPI:
|
||||||
debug=False,
|
debug=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _add_version_headers(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
response.headers["X-Agnes-Latest-Version"] = APP_VERSION
|
||||||
|
response.headers["X-Agnes-Min-Version"] = MIN_COMPAT_CLI_VERSION
|
||||||
|
return response
|
||||||
|
|
||||||
# FastAPI debug toolbar — only when DEBUG=1 in env. Injects per-request
|
# FastAPI debug toolbar — only when DEBUG=1 in env. Injects per-request
|
||||||
# HTML overlay (headers, routes, timer, profiling, logs) on any HTML
|
# HTML overlay (headers, routes, timer, profiling, logs) on any HTML
|
||||||
# response; harmless on JSON. Inner try/except is for the import only:
|
# response; harmless on JSON. Inner try/except is for the import only:
|
||||||
|
|
|
||||||
24
app/version.py
Normal file
24
app/version.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Single source of truth for app + CLI compat versions.
|
||||||
|
|
||||||
|
`APP_VERSION` is read from package metadata so it tracks `pyproject.toml`
|
||||||
|
without a manual literal to keep in sync.
|
||||||
|
|
||||||
|
`MIN_COMPAT_CLI_VERSION` is the oldest CLI version the server still accepts
|
||||||
|
on `/api/*`. Bumped manually when shipping a wire-protocol break. Day-one
|
||||||
|
value of "0.0.0" means no enforcement — set the floor the first time a
|
||||||
|
deliberate break ships.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from importlib.metadata import PackageNotFoundError
|
||||||
|
from importlib.metadata import version as _pkg_version
|
||||||
|
|
||||||
|
|
||||||
|
def _read_app_version() -> str:
|
||||||
|
try:
|
||||||
|
return _pkg_version("agnes-the-ai-analyst")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return "0.0.0+dev"
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION = _read_app_version()
|
||||||
|
MIN_COMPAT_CLI_VERSION = "0.0.0"
|
||||||
|
|
@ -1,36 +1,58 @@
|
||||||
"""Pin that the FastAPI `version=` is read dynamically from package metadata.
|
"""Pin that APP_VERSION reads from package metadata, not a hardcoded literal,
|
||||||
|
and that the FastAPI app's `version=` field surfaces it end-to-end."""
|
||||||
The OpenAPI schema (`/openapi.json`, `/docs`) advertises this version. A
|
|
||||||
hardcoded literal — the previous state — silently drifts from
|
|
||||||
`pyproject.toml` on every bump, leaving `/openapi.json` reporting a stale
|
|
||||||
version while `/api/version`, `/cli/latest`, and `da --version` all
|
|
||||||
report the bumped one.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
import importlib
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
def test_app_version_reads_package_metadata():
|
|
||||||
"""`_app_version()` must call importlib.metadata.version with the
|
|
||||||
canonical package name, not return a hardcoded literal."""
|
|
||||||
with patch("app.main._pkg_version", return_value="9.9.9") as mock_pkg_ver:
|
|
||||||
from app.main import _app_version
|
|
||||||
assert _app_version() == "9.9.9"
|
|
||||||
mock_pkg_ver.assert_called_once_with("agnes-the-ai-analyst")
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_version_falls_back_to_dev_when_package_missing():
|
@pytest.fixture
|
||||||
"""Source-checkout without install → report 'dev', not crash."""
|
def _restore_app_modules():
|
||||||
|
"""Reload-with-real-metadata so subsequent tests see the genuine
|
||||||
|
APP_VERSION / FastAPI app instance, not the patched-in fake from this
|
||||||
|
file's tests."""
|
||||||
|
yield
|
||||||
|
import app.version
|
||||||
|
importlib.reload(app.version)
|
||||||
|
import app.main
|
||||||
|
importlib.reload(app.main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_reads_package_metadata(_restore_app_modules):
|
||||||
|
# Patch the source `importlib.metadata.version` rather than the alias
|
||||||
|
# bound into app.version at import time — `importlib.reload(app.version)`
|
||||||
|
# re-runs the `from importlib.metadata import version as _pkg_version`
|
||||||
|
# line, which would otherwise re-fetch the unpatched original and
|
||||||
|
# silently neuter the test.
|
||||||
|
with patch("importlib.metadata.version", return_value="9.9.9") as mock_pkg_ver:
|
||||||
|
import app.version
|
||||||
|
importlib.reload(app.version)
|
||||||
|
assert app.version.APP_VERSION == "9.9.9"
|
||||||
|
# `assert_called_with` (not `assert_called_once_with`) — `import
|
||||||
|
# app.version` may have triggered an initial load before reload,
|
||||||
|
# giving two calls. We only care that the package name is canonical.
|
||||||
|
mock_pkg_ver.assert_called_with("agnes-the-ai-analyst")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_falls_back_when_package_missing(_restore_app_modules):
|
||||||
from importlib.metadata import PackageNotFoundError
|
from importlib.metadata import PackageNotFoundError
|
||||||
with patch("app.main._pkg_version", side_effect=PackageNotFoundError):
|
with patch("importlib.metadata.version", side_effect=PackageNotFoundError):
|
||||||
from app.main import _app_version
|
import app.version
|
||||||
assert _app_version() == "dev"
|
importlib.reload(app.version)
|
||||||
|
assert app.version.APP_VERSION == "0.0.0+dev"
|
||||||
|
|
||||||
|
|
||||||
def test_fastapi_app_version_matches_package_metadata():
|
def test_fastapi_app_version_matches_app_version_constant(_restore_app_modules):
|
||||||
"""End-to-end: what FastAPI stores in `app.version` is whatever
|
"""End-to-end: FastAPI's app.version (consumed by /openapi.json and
|
||||||
`_app_version()` returned — not a stale literal."""
|
/docs) must equal app.version.APP_VERSION. Guards the wiring at
|
||||||
with patch("app.main._pkg_version", return_value="7.7.7"):
|
`app/main.py:186 version=APP_VERSION` against accidental literal."""
|
||||||
from app.main import create_app
|
import app.version
|
||||||
app = create_app()
|
import app.main
|
||||||
assert app.version == "7.7.7"
|
|
||||||
|
# Reload both so we read post-patch values consistently.
|
||||||
|
with patch("importlib.metadata.version", return_value="7.7.7"):
|
||||||
|
importlib.reload(app.version)
|
||||||
|
importlib.reload(app.main)
|
||||||
|
assert app.main.app.version == "7.7.7"
|
||||||
|
assert app.main.app.version == app.version.APP_VERSION
|
||||||
|
|
|
||||||
29
tests/test_version_headers_middleware.py
Normal file
29
tests/test_version_headers_middleware.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Verify /api/* responses carry X-Agnes-Latest-Version + X-Agnes-Min-Version."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_response_carries_version_headers():
|
||||||
|
from app.main import app
|
||||||
|
from app.version import APP_VERSION, MIN_COMPAT_CLI_VERSION
|
||||||
|
client = TestClient(app)
|
||||||
|
# /api/version is unauthenticated and cheap.
|
||||||
|
resp = client.get("/api/version")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Headers must equal the constants in app.version, not just be parseable.
|
||||||
|
# When MIN_COMPAT_CLI_VERSION is deliberately bumped in a future PR, this
|
||||||
|
# test is updated in the same PR — the review-discipline guardrail.
|
||||||
|
assert resp.headers["X-Agnes-Latest-Version"] == APP_VERSION
|
||||||
|
assert resp.headers["X-Agnes-Min-Version"] == MIN_COMPAT_CLI_VERSION
|
||||||
|
# Day-one floor pin: drop or update this assertion when the floor moves.
|
||||||
|
assert resp.headers["X-Agnes-Min-Version"] == "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_api_response_does_not_carry_version_headers():
|
||||||
|
from app.main import app
|
||||||
|
client = TestClient(app)
|
||||||
|
# /cli/latest is under /cli, not /api — should NOT carry the headers.
|
||||||
|
resp = client.get("/cli/latest")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "X-Agnes-Latest-Version" not in resp.headers
|
||||||
|
assert "X-Agnes-Min-Version" not in resp.headers
|
||||||
Loading…
Reference in a new issue