mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
decouple: drop navi-admin → recon /api/health coupling
Per Matt's directive that navi-* should not call any /api/* on recon. navi-admin was the only navi service doing so (polling recon's /api/health and surfacing it in /api/admin/recon/info + the /api/admin/fleet fan-out). navi-admin is now the navi-only fleet view; recon has its own dashboard for recon-pipeline health. - admin_route.py: delete the /api/admin/recon/info handler; drop the recon config entry + RECON_HEALTH_URL/RECON_REPO_PATH env entries from /api/admin/navi-admin/info; refresh docstrings. - fleet.py: remove recon constants, recon_health_url/recon_repo_path/ recon_git_sha/wrap_recon_health, the now-unused shared.git_sha import, and the recon arms in build_fleet + dependency_summaries. /api/admin/fleet now reports only the 6 navi-* services. - tests: drop the 2 recon/info tests + recon scaffolding; strip recon assertions from fleet + self-info tests. 12 relevant tests pass (10 admin + 2 git_sha). shared/git_sha.py KEPT unchanged — it's a generic git_short_sha(path) helper used by every service's create_app(), not recon-specific. RECON_HEALTH_URL + RECON_REPO_PATH in /etc/navi-backend/navi-admin.env are now dead — flagged for out-of-band post-merge cleanup. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ae82cee46a
commit
767818b88e
3 changed files with 24 additions and 139 deletions
|
|
@ -1,11 +1,10 @@
|
||||||
"""navi-admin routes — the fleet admin front door (extraction #7).
|
"""navi-admin routes — the fleet admin front door (extraction #7).
|
||||||
|
|
||||||
Net-new (recon has no admin-info to port — Phase A §3). Three routes, all
|
Two routes, all ``@require_auth`` (auth is also enforced at the Caddy edge in
|
||||||
``@require_auth`` (auth is also enforced at the Caddy edge in prod; the Flask
|
prod; the Flask gate matches every other navi-* service and protects the
|
||||||
gate matches every other navi-* service and protects the localhost :8427 path):
|
localhost :8427 path):
|
||||||
|
|
||||||
GET /api/admin/recon/info recon's /api/health wrapped in the info shape
|
GET /api/admin/fleet fan-out aggregator across all navi-* services
|
||||||
GET /api/admin/fleet fan-out aggregator across all navi-* + recon
|
|
||||||
GET /api/admin/navi-admin/info navi-admin's own admin-info (self-describe)
|
GET /api/admin/navi-admin/info navi-admin's own admin-info (self-describe)
|
||||||
|
|
||||||
The per-service /api/admin/<svc>/info endpoints stay localhost-only (Phase A
|
The per-service /api/admin/<svc>/info endpoints stay localhost-only (Phase A
|
||||||
|
|
@ -26,19 +25,10 @@ bp = Blueprint('navi_admin', __name__)
|
||||||
PORT = 8427
|
PORT = 8427
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/api/admin/recon/info')
|
|
||||||
@require_auth
|
|
||||||
def recon_info():
|
|
||||||
"""recon's pipeline /api/health, wrapped into the uniform info shape.
|
|
||||||
recon down → a degraded info dict, not a 5xx."""
|
|
||||||
_, health, error = fleet.probe('recon', fleet.recon_health_url(), request.user_id)
|
|
||||||
return jsonify(fleet.wrap_recon_health(health, error))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/api/admin/fleet')
|
@bp.route('/api/admin/fleet')
|
||||||
@require_auth
|
@require_auth
|
||||||
def fleet_info():
|
def fleet_info():
|
||||||
"""Fan out to every navi-* service + recon over localhost, merged. Forwards
|
"""Fan out to every navi-* service over localhost, merged. Forwards
|
||||||
the caller's X-Authentik-Username so the @require_auth upstreams accept it.
|
the caller's X-Authentik-Username so the @require_auth upstreams accept it.
|
||||||
Never 5xx — per-service failures land in `errors`."""
|
Never 5xx — per-service failures land in `errors`."""
|
||||||
return jsonify(fleet.build_fleet(request.user_id))
|
return jsonify(fleet.build_fleet(request.user_id))
|
||||||
|
|
@ -57,12 +47,8 @@ def navi_admin_info():
|
||||||
# What it aggregates — non-secret, documents the fleet membership.
|
# What it aggregates — non-secret, documents the fleet membership.
|
||||||
config={
|
config={
|
||||||
'fanned_services': [{'name': n, 'port': p} for n, p in fleet.SERVICES],
|
'fanned_services': [{'name': n, 'port': p} for n, p in fleet.SERVICES],
|
||||||
'recon': {'name': fleet.RECON_SERVICE_NAME, 'port': fleet.RECON_PORT,
|
|
||||||
'git_sha': fleet.recon_git_sha()},
|
|
||||||
},
|
},
|
||||||
env=[
|
env=[
|
||||||
{'name': 'RECON_HEALTH_URL', 'value': fleet.recon_health_url()},
|
|
||||||
{'name': 'RECON_REPO_PATH', 'value': fleet.recon_repo_path()},
|
|
||||||
{'name': 'NAVI_ADMIN_FANOUT_TIMEOUT_S', 'value': str(fleet.fanout_timeout())},
|
{'name': 'NAVI_ADMIN_FANOUT_TIMEOUT_S', 'value': str(fleet.fanout_timeout())},
|
||||||
],
|
],
|
||||||
dependencies=fleet.dependency_summaries(request.user_id),
|
dependencies=fleet.dependency_summaries(request.user_id),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"""Fleet fan-out + recon-health wrapping for navi-admin.
|
"""Fleet fan-out for navi-admin.
|
||||||
|
|
||||||
navi-admin is a stateless aggregator: it fans out over localhost to each
|
navi-admin is a stateless aggregator: it fans out over localhost to each
|
||||||
navi-* service's ``/api/admin/<svc>/info`` endpoint (and recon's pipeline
|
navi-* service's ``/api/admin/<svc>/info`` endpoint, merging them into one
|
||||||
``/api/health``), merging them into one fleet response. Every per-service admin
|
fleet response. Every per-service admin endpoint is ``@require_auth``, so the
|
||||||
endpoint is ``@require_auth``, so the fan-out forwards the caller's validated
|
fan-out forwards the caller's validated ``X-Authentik-Username`` header —
|
||||||
``X-Authentik-Username`` header — otherwise the upstreams would 401.
|
otherwise the upstreams would 401.
|
||||||
|
|
||||||
Service discovery: a hardcoded module-level list (Option B). The set of navi-*
|
Service discovery: a hardcoded module-level list (Option B). The set of navi-*
|
||||||
services changes only when we ship a new extraction — the same moment we'd be
|
services changes only when we ship a new extraction — the same moment we'd be
|
||||||
|
|
@ -17,8 +17,6 @@ from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from shared.git_sha import git_short_sha
|
|
||||||
|
|
||||||
# (service-name, port) for every shipped navi-* service. The admin-info path is
|
# (service-name, port) for every shipped navi-* service. The admin-info path is
|
||||||
# always /api/admin/<service-name>/info. Add a row when a new extraction ships.
|
# always /api/admin/<service-name>/info. Add a row when a new extraction ships.
|
||||||
SERVICES = [
|
SERVICES = [
|
||||||
|
|
@ -30,22 +28,9 @@ SERVICES = [
|
||||||
('navi-geo', 8426), # #6 geocode + reverse + reverse bundle
|
('navi-geo', 8426), # #6 geocode + reverse + reverse bundle
|
||||||
]
|
]
|
||||||
|
|
||||||
RECON_SERVICE_NAME = 'recon'
|
|
||||||
RECON_PORT = 8420
|
|
||||||
|
|
||||||
DEFAULT_RECON_HEALTH_URL = 'http://127.0.0.1:8420/api/health'
|
|
||||||
DEFAULT_RECON_REPO_PATH = '/opt/recon' # actual deploy path on VM 1130 (a git repo)
|
|
||||||
DEFAULT_FANOUT_TIMEOUT_S = 3.0
|
DEFAULT_FANOUT_TIMEOUT_S = 3.0
|
||||||
|
|
||||||
|
|
||||||
def recon_health_url():
|
|
||||||
return os.environ.get('RECON_HEALTH_URL', DEFAULT_RECON_HEALTH_URL)
|
|
||||||
|
|
||||||
|
|
||||||
def recon_repo_path():
|
|
||||||
return os.environ.get('RECON_REPO_PATH', DEFAULT_RECON_REPO_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
def fanout_timeout():
|
def fanout_timeout():
|
||||||
try:
|
try:
|
||||||
return float(os.environ.get('NAVI_ADMIN_FANOUT_TIMEOUT_S', DEFAULT_FANOUT_TIMEOUT_S))
|
return float(os.environ.get('NAVI_ADMIN_FANOUT_TIMEOUT_S', DEFAULT_FANOUT_TIMEOUT_S))
|
||||||
|
|
@ -57,12 +42,6 @@ def service_info_url(name, port):
|
||||||
return f'http://127.0.0.1:{port}/api/admin/{name}/info'
|
return f'http://127.0.0.1:{port}/api/admin/{name}/info'
|
||||||
|
|
||||||
|
|
||||||
def recon_git_sha():
|
|
||||||
"""recon's deployed git SHA, or 'unknown'. Reads from recon_repo_path()
|
|
||||||
(default /opt/recon on VM 1130, Phase A-verified)."""
|
|
||||||
return git_short_sha(recon_repo_path())
|
|
||||||
|
|
||||||
|
|
||||||
def _get_json(url, auth_user, timeout):
|
def _get_json(url, auth_user, timeout):
|
||||||
"""GET url, forwarding the auth header. Returns (json_or_None, latency_ms,
|
"""GET url, forwarding the auth header. Returns (json_or_None, latency_ms,
|
||||||
error_or_None) where error is 'timeout' | 'HTTP <code>' | exception name."""
|
error_or_None) where error is 'timeout' | 'HTTP <code>' | exception name."""
|
||||||
|
|
@ -98,8 +77,8 @@ def probe(name, url, auth_user, timeout=None):
|
||||||
def _degraded_entry(name, port, error):
|
def _degraded_entry(name, port, error):
|
||||||
"""Uniform 'service was probed but failed' entry — matches the
|
"""Uniform 'service was probed but failed' entry — matches the
|
||||||
build_info_response shape so callers see the same keys whether the service is
|
build_info_response shape so callers see the same keys whether the service is
|
||||||
healthy or down. Used for every failure path (navi-* AND recon), so there is
|
healthy or down. Used for every failure path, so there is exactly one
|
||||||
exactly one degraded shape: runtime.status == 'unreachable'."""
|
degraded shape: runtime.status == 'unreachable'."""
|
||||||
return {
|
return {
|
||||||
'service': name, 'version': 'unknown', 'port': port,
|
'service': name, 'version': 'unknown', 'port': port,
|
||||||
'config': {}, 'env': [],
|
'config': {}, 'env': [],
|
||||||
|
|
@ -109,43 +88,14 @@ def _degraded_entry(name, port, error):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def wrap_recon_health(health, error):
|
|
||||||
"""Wrap recon's /api/health into an admin-info-shaped dict (service:'recon').
|
|
||||||
|
|
||||||
recon has no admin-info endpoint (Phase A §3); /api/health is the closest
|
|
||||||
input. On success its components become `dependencies` and its pipeline/status
|
|
||||||
become recon-specific `runtime` fields (meaningful only when recon is up). On
|
|
||||||
failure → the same uniform _degraded_entry shape as every other service (no
|
|
||||||
recon-specific data exists to preserve when recon is unreachable)."""
|
|
||||||
if health is None:
|
|
||||||
return _degraded_entry(RECON_SERVICE_NAME, RECON_PORT, error or 'unreachable')
|
|
||||||
version = recon_git_sha()
|
|
||||||
components = health.get('components', {})
|
|
||||||
dependencies = [
|
|
||||||
{'name': cname, 'status': cval.get('status'), **{k: v for k, v in cval.items() if k != 'status'}}
|
|
||||||
for cname, cval in components.items()
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
'service': RECON_SERVICE_NAME, 'version': version, 'port': RECON_PORT,
|
|
||||||
'config': {}, 'env': [],
|
|
||||||
'dependencies': dependencies,
|
|
||||||
'filesystem': [],
|
|
||||||
'runtime': {
|
|
||||||
'recon_status': health.get('status'),
|
|
||||||
'recon_uptime': health.get('uptime'),
|
|
||||||
'pipeline': health.get('pipeline', {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_fleet(auth_user):
|
def build_fleet(auth_user):
|
||||||
"""Fan out to all navi-* services + recon in parallel; merge. Never raises.
|
"""Fan out to all navi-* services in parallel; merge. Never raises.
|
||||||
|
|
||||||
Returns {services: {<name>: <info>}, fetched_at: ISO8601, errors: [{service, error}]}.
|
Returns {services: {<name>: <info>}, fetched_at: ISO8601, errors: [{service, error}]}.
|
||||||
Invariant: EVERY probed service appears in `services` — a full info dict when
|
Invariant: EVERY probed service appears in `services` — a full info dict when
|
||||||
healthy, the uniform `_degraded_entry` shape (runtime.status == 'unreachable')
|
healthy, the uniform `_degraded_entry` shape (runtime.status == 'unreachable')
|
||||||
when it fails — and `errors` is a parallel listing of which ones failed and
|
when it fails — and `errors` is a parallel listing of which ones failed and
|
||||||
why. No special cases: recon failures use the same degraded shape."""
|
why."""
|
||||||
timeout = fanout_timeout()
|
timeout = fanout_timeout()
|
||||||
targets = [(name, port, service_info_url(name, port)) for name, port in SERVICES]
|
targets = [(name, port, service_info_url(name, port)) for name, port in SERVICES]
|
||||||
|
|
||||||
|
|
@ -155,18 +105,13 @@ def build_fleet(auth_user):
|
||||||
|
|
||||||
services = {}
|
services = {}
|
||||||
errors = []
|
errors = []
|
||||||
with ThreadPoolExecutor(max_workers=len(targets) + 1) as ex:
|
with ThreadPoolExecutor(max_workers=len(targets)) as ex:
|
||||||
futures = [ex.submit(_fetch, name, port, url) for name, port, url in targets]
|
futures = [ex.submit(_fetch, name, port, url) for name, port, url in targets]
|
||||||
recon_future = ex.submit(probe, RECON_SERVICE_NAME, recon_health_url(), auth_user, timeout)
|
|
||||||
for fut in futures:
|
for fut in futures:
|
||||||
name, port, full, error = fut.result()
|
name, port, full, error = fut.result()
|
||||||
services[name] = full if full is not None else _degraded_entry(name, port, error)
|
services[name] = full if full is not None else _degraded_entry(name, port, error)
|
||||||
if error:
|
if error:
|
||||||
errors.append({'service': name, 'error': error})
|
errors.append({'service': name, 'error': error})
|
||||||
_, recon_health, recon_error = recon_future.result()
|
|
||||||
services[RECON_SERVICE_NAME] = wrap_recon_health(recon_health, recon_error)
|
|
||||||
if recon_error:
|
|
||||||
errors.append({'service': RECON_SERVICE_NAME, 'error': recon_error})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'services': services,
|
'services': services,
|
||||||
|
|
@ -176,13 +121,11 @@ def build_fleet(auth_user):
|
||||||
|
|
||||||
|
|
||||||
def dependency_summaries(auth_user):
|
def dependency_summaries(auth_user):
|
||||||
"""{name, status, latency_ms[, error]} for recon-health + each navi-* admin
|
"""{name, status, latency_ms[, error]} for each navi-* admin endpoint — the
|
||||||
endpoint — the same probes the fleet runs, reused for navi-admin's own
|
same probes the fleet runs, reused for navi-admin's own /info
|
||||||
/info `dependencies`. Sequential (it's a rare, auth-gated call)."""
|
`dependencies`. Sequential (it's a rare, auth-gated call)."""
|
||||||
timeout = fanout_timeout()
|
timeout = fanout_timeout()
|
||||||
summaries = []
|
summaries = []
|
||||||
s, _, _ = probe('recon-health', recon_health_url(), auth_user, timeout)
|
|
||||||
summaries.append(s)
|
|
||||||
for name, port in SERVICES:
|
for name, port in SERVICES:
|
||||||
s, _, _ = probe(name, service_info_url(name, port), auth_user, timeout)
|
s, _, _ = probe(name, service_info_url(name, port), auth_user, timeout)
|
||||||
summaries.append(s)
|
summaries.append(s)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
No live calls: services.navi_admin.fleet.requests.get is mocked to a router
|
No live calls: services.navi_admin.fleet.requests.get is mocked to a router
|
||||||
keyed on the port in the URL, so we exercise the real _get_json mapping
|
keyed on the port in the URL, so we exercise the real _get_json mapping
|
||||||
(timeout -> 'timeout', non-200 -> 'HTTP <code>') and the real fan-out/merge.
|
(timeout -> 'timeout', non-200 -> 'HTTP <code>') and the real fan-out/merge.
|
||||||
recon_git_sha is stubbed so tests don't depend on /opt/recon.
|
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -21,14 +20,6 @@ def _svc_info(name, port):
|
||||||
'runtime': {'uptime_s': 1.0, 'request_count': 0, 'last_error_at': None}}
|
'runtime': {'uptime_s': 1.0, 'request_count': 0, 'last_error_at': None}}
|
||||||
|
|
||||||
|
|
||||||
def _recon_health(status='healthy'):
|
|
||||||
return {'status': status, 'uptime': '2026-05-23T00:00:00Z',
|
|
||||||
'components': {'qdrant': {'status': 'up', 'vectors': 42},
|
|
||||||
'tei': {'status': 'up'},
|
|
||||||
'nfs': {'status': 'up'}},
|
|
||||||
'pipeline': {'total': 100, 'done': 90}}
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResp:
|
class _FakeResp:
|
||||||
def __init__(self, status_code, json_data):
|
def __init__(self, status_code, json_data):
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
|
|
@ -57,15 +48,12 @@ def _router(behaviors, captured_headers):
|
||||||
|
|
||||||
|
|
||||||
def _all_ok():
|
def _all_ok():
|
||||||
"""Every navi-* service 200 + recon health 200."""
|
"""Every navi-* service 200."""
|
||||||
b = {port: ('ok', _svc_info(name, port)) for name, port in fleet.SERVICES}
|
return {port: ('ok', _svc_info(name, port)) for name, port in fleet.SERVICES}
|
||||||
b[fleet.RECON_PORT] = ('ok', _recon_health())
|
|
||||||
return b
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(monkeypatch):
|
def client(monkeypatch):
|
||||||
monkeypatch.setattr(fleet, 'recon_git_sha', lambda: 'recon99')
|
|
||||||
return create_app().test_client()
|
return create_app().test_client()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -83,12 +71,11 @@ def _wire(monkeypatch, behaviors, captured):
|
||||||
def test_fleet_happy_path(client, monkeypatch, captured):
|
def test_fleet_happy_path(client, monkeypatch, captured):
|
||||||
_wire(monkeypatch, _all_ok(), captured)
|
_wire(monkeypatch, _all_ok(), captured)
|
||||||
data = client.get('/api/admin/fleet', headers=AUTH).get_json()
|
data = client.get('/api/admin/fleet', headers=AUTH).get_json()
|
||||||
expected = {name for name, _ in fleet.SERVICES} | {'recon'}
|
expected = {name for name, _ in fleet.SERVICES}
|
||||||
assert set(data['services'].keys()) == expected
|
assert set(data['services'].keys()) == expected
|
||||||
assert data['errors'] == []
|
assert data['errors'] == []
|
||||||
assert data['fetched_at'].endswith('Z')
|
assert data['fetched_at'].endswith('Z')
|
||||||
assert data['services']['navi-geo']['port'] == 8426
|
assert data['services']['navi-geo']['port'] == 8426
|
||||||
assert data['services']['recon']['runtime']['recon_status'] == 'healthy'
|
|
||||||
|
|
||||||
|
|
||||||
def test_fleet_service_timeout_lands_in_errors(client, monkeypatch, captured):
|
def test_fleet_service_timeout_lands_in_errors(client, monkeypatch, captured):
|
||||||
|
|
@ -118,18 +105,14 @@ def test_fleet_forwards_auth_header(client, monkeypatch, captured):
|
||||||
assert captured and all(h.get('X-Authentik-Username') == 'matt' for h in captured)
|
assert captured and all(h.get('X-Authentik-Username') == 'matt' for h in captured)
|
||||||
|
|
||||||
|
|
||||||
def test_fleet_never_5xx_when_recon_down_and_a_service_errors(client, monkeypatch, captured):
|
def test_fleet_never_5xx_when_a_service_errors(client, monkeypatch, captured):
|
||||||
b = _all_ok()
|
b = _all_ok()
|
||||||
b[fleet.RECON_PORT] = 'timeout' # recon health down
|
|
||||||
b[8421] = ('http', 502) # navi-traffic 502
|
b[8421] = ('http', 502) # navi-traffic 502
|
||||||
_wire(monkeypatch, b, captured)
|
_wire(monkeypatch, b, captured)
|
||||||
resp = client.get('/api/admin/fleet', headers=AUTH)
|
resp = client.get('/api/admin/fleet', headers=AUTH)
|
||||||
assert resp.status_code == 200 # never 5xx
|
assert resp.status_code == 200 # never 5xx
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
# recon still present as a degraded entry (same uniform shape), AND in errors
|
# navi-traffic present (degraded) AND in errors — the uniform invariant
|
||||||
assert data['services']['recon']['runtime']['status'] == 'unreachable'
|
|
||||||
assert {'service': 'recon', 'error': 'timeout'} in data['errors']
|
|
||||||
# navi-traffic also present (degraded) AND in errors — the uniform invariant
|
|
||||||
assert data['services']['navi-traffic']['runtime']['status'] == 'unreachable'
|
assert data['services']['navi-traffic']['runtime']['status'] == 'unreachable'
|
||||||
assert {'service': 'navi-traffic', 'error': 'HTTP 502'} in data['errors']
|
assert {'service': 'navi-traffic', 'error': 'HTTP 502'} in data['errors']
|
||||||
|
|
||||||
|
|
@ -157,31 +140,6 @@ def test_fleet_service_returns_html_lands_as_invalid_json(client, monkeypatch, c
|
||||||
assert data['services']['navi-places']['runtime']['status'] == 'unreachable'
|
assert data['services']['navi-places']['runtime']['status'] == 'unreachable'
|
||||||
|
|
||||||
|
|
||||||
# ── recon/info wrapper ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_recon_info_wraps_health(client, monkeypatch, captured):
|
|
||||||
_wire(monkeypatch, {fleet.RECON_PORT: ('ok', _recon_health())}, captured)
|
|
||||||
data = client.get('/api/admin/recon/info', headers=AUTH).get_json()
|
|
||||||
assert data['service'] == 'recon' and data['port'] == 8420
|
|
||||||
assert data['version'] == 'recon99'
|
|
||||||
dep_names = {d['name'] for d in data['dependencies']}
|
|
||||||
assert {'qdrant', 'tei', 'nfs'} <= dep_names
|
|
||||||
assert data['runtime']['recon_status'] == 'healthy'
|
|
||||||
assert data['runtime']['pipeline'] == {'total': 100, 'done': 90}
|
|
||||||
|
|
||||||
|
|
||||||
def test_recon_info_recon_down_is_degraded_not_5xx(client, monkeypatch, captured):
|
|
||||||
_wire(monkeypatch, {fleet.RECON_PORT: 'timeout'}, captured)
|
|
||||||
resp = client.get('/api/admin/recon/info', headers=AUTH)
|
|
||||||
assert resp.status_code == 200 # degraded, not 5xx
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data['service'] == 'recon'
|
|
||||||
# Uniform degraded shape (same as every other service) — no recon_status special case.
|
|
||||||
assert data['runtime']['status'] == 'unreachable'
|
|
||||||
assert data['dependencies'][0]['status'] == 'error'
|
|
||||||
assert data['dependencies'][0]['name'] == 'recon-info'
|
|
||||||
|
|
||||||
|
|
||||||
# ── self-info ───────────────────────────────────────────────────────────────
|
# ── self-info ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_self_info_no_secrets_and_lists_deps(client, monkeypatch, captured):
|
def test_self_info_no_secrets_and_lists_deps(client, monkeypatch, captured):
|
||||||
|
|
@ -192,7 +150,6 @@ def test_self_info_no_secrets_and_lists_deps(client, monkeypatch, captured):
|
||||||
# No masked secrets anywhere in env (Phase A §9 — none exist).
|
# No masked secrets anywhere in env (Phase A §9 — none exist).
|
||||||
assert all('...' not in str(e['value']) and e['value'] != '****' for e in data['env'])
|
assert all('...' not in str(e['value']) and e['value'] != '****' for e in data['env'])
|
||||||
dep_names = {d['name'] for d in data['dependencies']}
|
dep_names = {d['name'] for d in data['dependencies']}
|
||||||
assert 'recon-health' in dep_names
|
|
||||||
assert {name for name, _ in fleet.SERVICES} <= dep_names
|
assert {name for name, _ in fleet.SERVICES} <= dep_names
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -201,12 +158,11 @@ def test_self_info_config_lists_fanned_services(client, monkeypatch, captured):
|
||||||
data = client.get('/api/admin/navi-admin/info', headers=AUTH).get_json()
|
data = client.get('/api/admin/navi-admin/info', headers=AUTH).get_json()
|
||||||
fanned = {s['name']: s['port'] for s in data['config']['fanned_services']}
|
fanned = {s['name']: s['port'] for s in data['config']['fanned_services']}
|
||||||
assert fanned == {name: port for name, port in fleet.SERVICES}
|
assert fanned == {name: port for name, port in fleet.SERVICES}
|
||||||
assert data['config']['recon']['git_sha'] == 'recon99'
|
|
||||||
|
|
||||||
|
|
||||||
# ── auth gating ─────────────────────────────────────────────────────────────
|
# ── auth gating ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.parametrize('path', [
|
@pytest.mark.parametrize('path', [
|
||||||
'/api/admin/fleet', '/api/admin/recon/info', '/api/admin/navi-admin/info'])
|
'/api/admin/fleet', '/api/admin/navi-admin/info'])
|
||||||
def test_auth_required(client, path):
|
def test_auth_required(client, path):
|
||||||
assert client.get(path).status_code == 401
|
assert client.get(path).status_code == 401
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue