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:
malice 2026-05-23 13:14:09 -06:00 committed by GitHub
commit 767818b88e
3 changed files with 24 additions and 139 deletions

View file

@ -1,11 +1,10 @@
"""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
``@require_auth`` (auth is also enforced at the Caddy edge in prod; the Flask
gate matches every other navi-* service and protects the localhost :8427 path):
Two routes, all ``@require_auth`` (auth is also enforced at the Caddy edge in
prod; the Flask gate matches every other navi-* service and protects the
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-* + recon
GET /api/admin/fleet fan-out aggregator across all navi-* services
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
@ -26,19 +25,10 @@ bp = Blueprint('navi_admin', __name__)
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')
@require_auth
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.
Never 5xx per-service failures land in `errors`."""
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.
config={
'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=[
{'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())},
],
dependencies=fleet.dependency_summaries(request.user_id),

View file

@ -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-* service's ``/api/admin/<svc>/info`` endpoint (and recon's pipeline
``/api/health``), merging them into one fleet response. Every per-service admin
endpoint is ``@require_auth``, so the fan-out forwards the caller's validated
``X-Authentik-Username`` header otherwise the upstreams would 401.
navi-* service's ``/api/admin/<svc>/info`` endpoint, merging them into one
fleet response. Every per-service admin endpoint is ``@require_auth``, so the
fan-out forwards the caller's validated ``X-Authentik-Username`` header —
otherwise the upstreams would 401.
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
@ -17,8 +17,6 @@ from concurrent.futures import ThreadPoolExecutor
import requests
from shared.git_sha import git_short_sha
# (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.
SERVICES = [
@ -30,22 +28,9 @@ SERVICES = [
('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
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():
try:
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'
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):
"""GET url, forwarding the auth header. Returns (json_or_None, latency_ms,
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):
"""Uniform 'service was probed but failed' entry — matches the
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
exactly one degraded shape: runtime.status == 'unreachable'."""
healthy or down. Used for every failure path, so there is exactly one
degraded shape: runtime.status == 'unreachable'."""
return {
'service': name, 'version': 'unknown', 'port': port,
'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):
"""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}]}.
Invariant: EVERY probed service appears in `services` a full info dict when
healthy, the uniform `_degraded_entry` shape (runtime.status == 'unreachable')
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()
targets = [(name, port, service_info_url(name, port)) for name, port in SERVICES]
@ -155,18 +105,13 @@ def build_fleet(auth_user):
services = {}
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]
recon_future = ex.submit(probe, RECON_SERVICE_NAME, recon_health_url(), auth_user, timeout)
for fut in futures:
name, port, full, error = fut.result()
services[name] = full if full is not None else _degraded_entry(name, port, error)
if 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 {
'services': services,
@ -176,13 +121,11 @@ def build_fleet(auth_user):
def dependency_summaries(auth_user):
"""{name, status, latency_ms[, error]} for recon-health + each navi-* admin
endpoint the same probes the fleet runs, reused for navi-admin's own
/info `dependencies`. Sequential (it's a rare, auth-gated call)."""
"""{name, status, latency_ms[, error]} for each navi-* admin endpoint — the
same probes the fleet runs, reused for navi-admin's own /info
`dependencies`. Sequential (it's a rare, auth-gated call)."""
timeout = fanout_timeout()
summaries = []
s, _, _ = probe('recon-health', recon_health_url(), auth_user, timeout)
summaries.append(s)
for name, port in SERVICES:
s, _, _ = probe(name, service_info_url(name, port), auth_user, timeout)
summaries.append(s)

View file

@ -3,7 +3,6 @@
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
(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 requests
@ -21,14 +20,6 @@ def _svc_info(name, port):
'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:
def __init__(self, status_code, json_data):
self.status_code = status_code
@ -57,15 +48,12 @@ def _router(behaviors, captured_headers):
def _all_ok():
"""Every navi-* service 200 + recon health 200."""
b = {port: ('ok', _svc_info(name, port)) for name, port in fleet.SERVICES}
b[fleet.RECON_PORT] = ('ok', _recon_health())
return b
"""Every navi-* service 200."""
return {port: ('ok', _svc_info(name, port)) for name, port in fleet.SERVICES}
@pytest.fixture
def client(monkeypatch):
monkeypatch.setattr(fleet, 'recon_git_sha', lambda: 'recon99')
return create_app().test_client()
@ -83,12 +71,11 @@ def _wire(monkeypatch, behaviors, captured):
def test_fleet_happy_path(client, monkeypatch, captured):
_wire(monkeypatch, _all_ok(), captured)
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 data['errors'] == []
assert data['fetched_at'].endswith('Z')
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):
@ -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)
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[fleet.RECON_PORT] = 'timeout' # recon health down
b[8421] = ('http', 502) # navi-traffic 502
_wire(monkeypatch, b, captured)
resp = client.get('/api/admin/fleet', headers=AUTH)
assert resp.status_code == 200 # never 5xx
data = resp.get_json()
# recon still present as a degraded entry (same uniform shape), AND in errors
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
# navi-traffic present (degraded) AND in errors — the uniform invariant
assert data['services']['navi-traffic']['runtime']['status'] == 'unreachable'
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'
# ── 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 ───────────────────────────────────────────────────────────────
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).
assert all('...' not in str(e['value']) and e['value'] != '****' for e in data['env'])
dep_names = {d['name'] for d in data['dependencies']}
assert 'recon-health' in 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()
fanned = {s['name']: s['port'] for s in data['config']['fanned_services']}
assert fanned == {name: port for name, port in fleet.SERVICES}
assert data['config']['recon']['git_sha'] == 'recon99'
# ── auth gating ─────────────────────────────────────────────────────────────
@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):
assert client.get(path).status_code == 401