feat(cli): hard-stop on incompatible-version response header
Every API response is inspected via httpx event_hooks. When the server reports X-Agnes-Min-Version > local, CLI prints a remediation message and exits 2. Latest-version drift continues to be handled by the update_check warning loop — no double-warning on every API call.
This commit is contained in:
parent
af2b866961
commit
2680a6724b
2 changed files with 106 additions and 1 deletions
|
|
@ -3,7 +3,9 @@
|
||||||
import atexit
|
import atexit
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
@ -15,6 +17,7 @@ from typing import Optional
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from cli.config import _config_dir, get_server_url, get_token
|
from cli.config import _config_dir, get_server_url, get_token
|
||||||
|
from cli.update_check import _installed_version, _version_lt
|
||||||
|
|
||||||
|
|
||||||
# PID-suffixed tmp / part files — see `_download_chunked` and
|
# PID-suffixed tmp / part files — see `_download_chunked` and
|
||||||
|
|
@ -213,6 +216,35 @@ def _translate_transport_error(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_version_headers(response: "httpx.Response") -> None:
|
||||||
|
"""Hard-stop the CLI when the server reports we're below min_version.
|
||||||
|
|
||||||
|
Drift warnings (`local < latest`) are already printed by the
|
||||||
|
update_check root callback in cli/main.py — no need to nag again on
|
||||||
|
every API call. This hook only enforces the hard floor.
|
||||||
|
"""
|
||||||
|
# Recursion barrier: `agnes self-upgrade` sets this for the duration
|
||||||
|
# of the upgrade. Without it, a /api/* call inside the install flow
|
||||||
|
# could exit 2 with "Run: agnes self-upgrade" — inside agnes
|
||||||
|
# self-upgrade. The sentinel is process-local and propagates to
|
||||||
|
# subprocesses via the explicit env= passed to the smoke test.
|
||||||
|
if os.environ.get("AGNES_SELF_UPGRADE_IN_PROGRESS") == "1":
|
||||||
|
return
|
||||||
|
latest = response.headers.get("X-Agnes-Latest-Version")
|
||||||
|
minv = response.headers.get("X-Agnes-Min-Version")
|
||||||
|
if not latest or not minv:
|
||||||
|
return
|
||||||
|
local = _installed_version()
|
||||||
|
if local == "unknown":
|
||||||
|
return
|
||||||
|
if _version_lt(local, minv):
|
||||||
|
sys.stderr.write(
|
||||||
|
f"error: agnes {local} is incompatible with server {latest} "
|
||||||
|
f"(min required: {minv}). Run: agnes self-upgrade\n"
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
def get_client(timeout: float = 30.0) -> httpx.Client:
|
def get_client(timeout: float = 30.0) -> httpx.Client:
|
||||||
"""Get an authenticated httpx client.
|
"""Get an authenticated httpx client.
|
||||||
|
|
||||||
|
|
@ -220,6 +252,13 @@ def get_client(timeout: float = 30.0) -> httpx.Client:
|
||||||
`api_*` helpers (one request, then close). The big-stream path
|
`api_*` helpers (one request, then close). The big-stream path
|
||||||
(`stream_download`) routes through `_get_shared_client()` to amortize
|
(`stream_download`) routes through `_get_shared_client()` to amortize
|
||||||
TLS handshakes and HTTP/2 multiplexing across N parquet downloads.
|
TLS handshakes and HTTP/2 multiplexing across N parquet downloads.
|
||||||
|
|
||||||
|
Wires `_check_version_headers` as a response event hook: every
|
||||||
|
metadata call sees the server's `X-Agnes-{Latest,Min}-Version`
|
||||||
|
headers and hard-stops if our local version is below the floor.
|
||||||
|
Hook is intentionally NOT wired on `_get_shared_client()` — that
|
||||||
|
client backs streaming parquet downloads where a `sys.exit(2)`
|
||||||
|
mid-stream would leak per-thread part files.
|
||||||
"""
|
"""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
@ -227,8 +266,9 @@ def get_client(timeout: float = 30.0) -> httpx.Client:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
return httpx.Client(
|
return httpx.Client(
|
||||||
base_url=get_server_url(),
|
base_url=get_server_url(),
|
||||||
headers=headers,
|
headers={**headers, "User-Agent": f"agnes/{_installed_version()} ({platform.system().lower()})"},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
event_hooks={"response": [_check_version_headers]},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
65
tests/test_client_version_check.py
Normal file
65
tests/test_client_version_check.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Verify cli/client.py:get_client() hard-stops on min_version mismatch."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_response(headers: dict) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code=200, headers=headers, content=b"{}", request=httpx.Request("GET", "http://x/"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_below_min_exits_with_code_2():
|
||||||
|
from cli.client import _check_version_headers
|
||||||
|
with patch("cli.client._installed_version", return_value="0.30.0"):
|
||||||
|
resp = _fake_response({
|
||||||
|
"X-Agnes-Latest-Version": "0.40.0",
|
||||||
|
"X-Agnes-Min-Version": "0.35.0",
|
||||||
|
})
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
_check_version_headers(resp)
|
||||||
|
assert exc.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_at_or_above_min_does_not_exit():
|
||||||
|
from cli.client import _check_version_headers
|
||||||
|
with patch("cli.client._installed_version", return_value="0.40.0"):
|
||||||
|
resp = _fake_response({
|
||||||
|
"X-Agnes-Latest-Version": "0.40.0",
|
||||||
|
"X-Agnes-Min-Version": "0.35.0",
|
||||||
|
})
|
||||||
|
_check_version_headers(resp) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_headers_no_enforcement():
|
||||||
|
"""Older server without middleware → no headers → no-op."""
|
||||||
|
from cli.client import _check_version_headers
|
||||||
|
with patch("cli.client._installed_version", return_value="0.10.0"):
|
||||||
|
resp = _fake_response({}) # empty headers
|
||||||
|
_check_version_headers(resp) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_local_version_no_enforcement():
|
||||||
|
"""Source-checkout / editable install → never block."""
|
||||||
|
from cli.client import _check_version_headers
|
||||||
|
with patch("cli.client._installed_version", return_value="unknown"):
|
||||||
|
resp = _fake_response({
|
||||||
|
"X-Agnes-Latest-Version": "0.40.0",
|
||||||
|
"X-Agnes-Min-Version": "0.35.0",
|
||||||
|
})
|
||||||
|
_check_version_headers(resp) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_upgrade_in_progress_disables_enforcement(monkeypatch):
|
||||||
|
"""Recursion barrier: while self-upgrade runs, no /api/* call may
|
||||||
|
block on min-version drift. Otherwise an in-flight upgrade could
|
||||||
|
sys.exit(2) with 'Run: agnes self-upgrade' from inside itself."""
|
||||||
|
from cli.client import _check_version_headers
|
||||||
|
monkeypatch.setenv("AGNES_SELF_UPGRADE_IN_PROGRESS", "1")
|
||||||
|
with patch("cli.client._installed_version", return_value="0.10.0"):
|
||||||
|
resp = _fake_response({
|
||||||
|
"X-Agnes-Latest-Version": "0.40.0",
|
||||||
|
"X-Agnes-Min-Version": "0.35.0",
|
||||||
|
})
|
||||||
|
_check_version_headers(resp) # must not raise
|
||||||
Loading…
Reference in a new issue