navi/backend/services/navi_admin/admin_route.py
malice d644741c75 Add navi-admin service (extraction #7) (#7)
* Add navi-admin service (extraction #7)

Net-new fleet admin aggregator on :8427 — no port from recon (recon has no
/api/admin route; Phase A §3). Three @require_auth routes:
  GET /api/admin/fleet            fan-out to all 6 navi-* /api/admin/<svc>/info
                                  + recon /api/health, merged; never 5xx
                                  (failures land in errors[])
  GET /api/admin/recon/info       recon /api/health wrapped in the info shape
  GET /api/admin/navi-admin/info  self-describe

Fan-out forwards the caller's X-Authentik-Username so the @require_auth
upstreams accept it; per-service admin endpoints stay localhost-only (this is
the single edge-exposed admin surface). Service discovery: hardcoded list in
fleet.py (Option B). No secrets, no DB.

Deploy artifacts (NOT applied here): navi-admin.env.example, systemd unit,
nginx ^~ /api/admin snippet, and deploy/caddy notes for the @authed_api edit
(first Caddy change since #2).

12 hermetic tests (fleet happy-path, per-service timeout/500 → errors[],
auth-header forwarding, recon-down degraded-not-5xx, self-info no-secrets,
auth-required). Full monorepo suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #7 review fixes

1. Symmetric degraded-entry handling in fleet.build_fleet — every
   probed service now appears in `services` with a uniform degraded
   dict on failure (matches recon's existing pattern), AND in errors[].
   Operators see "everything I tried + which broke" consistently.
2. Catch ValueError specifically in _get_json — non-JSON 200 responses
   now surface as `error: 'invalid JSON'` instead of opaque 'ValueError'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #7 review fixes (round 2)

1. Unified degraded shape: wrap_recon_health calls _degraded_entry on
   failure — no more runtime.status vs runtime.recon_status asymmetry.
   Every probed service has the same shape on failure
   (runtime.status == 'unreachable'). recon-specific runtime fields
   (recon_status/recon_uptime/pipeline) remain only on the success path.
2. DRY'd git short-SHA helper into shared/git_sha.py — was duplicated in
   7 service app.py files + fleet.recon_git_sha. One implementation,
   one place to fix when behavior changes. Adds shared/tests (testpaths
   now includes "shared").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zvx-echo6 <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:21:00 -06:00

76 lines
2.9 KiB
Python

"""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):
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/navi-admin/info navi-admin's own admin-info (self-describe)
The per-service /api/admin/<svc>/info endpoints stay localhost-only (Phase A
§3/§7); this fleet endpoint is the single edge-exposed front door.
"""
import os
import time
from flask import Blueprint, jsonify, current_app, request
from shared.auth import require_auth
from shared.admin_info import build_info_response
from . import fleet
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
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))
@bp.route('/api/admin/navi-admin/info')
@require_auth
def navi_admin_info():
"""navi-admin's own admin-info. No secrets (Phase A §9) — only non-secret
URLs/paths. `dependencies` reuses the same probes the fleet runs."""
metrics = current_app.config['METRICS']
info = build_info_response(
service='navi-admin',
version=current_app.config.get('VERSION', 'unknown'),
port=PORT,
# 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),
filesystem=[], # navi-admin owns no files / no DB (Phase A §9)
runtime={
'uptime_s': round(time.time() - metrics['start_time'], 1),
'request_count': metrics['request_count'],
'last_error_at': metrics['last_error_at'],
},
)
return jsonify(info)