mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
* 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>
This commit is contained in:
parent
15cad0abf4
commit
d644741c75
21 changed files with 716 additions and 87 deletions
|
|
@ -69,6 +69,25 @@ public. The reverse bundle fans out to Photon, the SpatiaLite timezone DB,
|
||||||
navi-landclass (HTTP), and the planet-DEM PMTiles — each degrading to `null`
|
navi-landclass (HTTP), and the planet-DEM PMTiles — each degrading to `null`
|
||||||
independently, never 5xx.
|
independently, never 5xx.
|
||||||
|
|
||||||
|
## Run (local) — navi-admin (extraction #7)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest services/navi_admin/tests/ -v
|
||||||
|
|
||||||
|
# No secrets — read-only HTTP fan-out over localhost (see
|
||||||
|
# deploy/env/navi-admin.env.example). Owns no DB.
|
||||||
|
.venv/bin/gunicorn 'services.navi_admin.app:create_app()' \
|
||||||
|
--bind 127.0.0.1:8427 --workers 2
|
||||||
|
```
|
||||||
|
|
||||||
|
`navi-admin` is the fleet admin front door: `/api/admin/fleet` fans out to every
|
||||||
|
navi-* service's localhost `/api/admin/<svc>/info` + recon's `/api/health`
|
||||||
|
(merged, never 5xx — failures land in `errors[]`); `/api/admin/recon/info` wraps
|
||||||
|
recon's health into the uniform shape; `/api/admin/navi-admin/info` self-describes.
|
||||||
|
All `@require_auth`. The per-service admin endpoints stay localhost-only; this is
|
||||||
|
the single edge-exposed admin surface (needs a Caddy `@authed_api` edit — see
|
||||||
|
`deploy/caddy/navi-admin.caddy.notes.md`).
|
||||||
|
|
||||||
## The admin-info convention (§4.5)
|
## The admin-info convention (§4.5)
|
||||||
|
|
||||||
Every service exposes `GET /api/admin/<service-name>/info`, gated by `require_auth`,
|
Every service exposes `GET /api/admin/<service-name>/info`, gated by `require_auth`,
|
||||||
|
|
|
||||||
54
backend/deploy/caddy/navi-admin.caddy.notes.md
Normal file
54
backend/deploy/caddy/navi-admin.caddy.notes.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# navi-admin — Caddy edit notes (deploy task, NOT done in the PR)
|
||||||
|
|
||||||
|
navi-admin's `/api/admin/*` is **auth-gated** (every admin-info is `@require_auth`).
|
||||||
|
Today the `navi.echo6.co` Caddy block on **CT 101** (`192.168.1.241 → pct 101`,
|
||||||
|
`/etc/caddy/Caddyfile`) routes `/api/admin/*` through `@public_api` (path `/api/*`)
|
||||||
|
with **no auth** → it would serve the fleet/admin endpoints unauthenticated.
|
||||||
|
|
||||||
|
So the deploy task must add `/api/admin/*` to the `@authed_api` matcher. **This is
|
||||||
|
the first Caddy edit since extraction #2** (Phase A §6/§8).
|
||||||
|
|
||||||
|
## The edit
|
||||||
|
|
||||||
|
Inside the `navi.echo6.co { ... }` block, the `@authed_api` matcher:
|
||||||
|
|
||||||
|
**Before**
|
||||||
|
```caddyfile
|
||||||
|
@authed_api {
|
||||||
|
path /api/contacts /api/contacts/* /api/auth/whoami /api/traffic /api/traffic/*
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (append `/api/admin/*`)
|
||||||
|
```caddyfile
|
||||||
|
@authed_api {
|
||||||
|
path /api/contacts /api/contacts/* /api/auth/whoami /api/traffic /api/traffic/* /api/admin/*
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No other change. `@authed_api` already runs `forward_auth https://auth.echo6.co`
|
||||||
|
then `reverse_proxy 100.64.0.24:8440`; adding the path makes `/api/admin/*` take
|
||||||
|
that authed path instead of falling through to `@public_api`. Because Caddy
|
||||||
|
evaluates the `handle` blocks in source order and `@authed_api` is defined before
|
||||||
|
`@public_api`, the new path wins for `/api/admin/*` while `/api/*` still catches
|
||||||
|
everything else publicly.
|
||||||
|
|
||||||
|
## Apply (on CT 101)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.241 "pct exec 101 -- caddy validate --config /etc/caddy/Caddyfile"
|
||||||
|
ssh root@192.168.1.241 "pct exec 101 -- systemctl reload caddy" # acme/admin off → reload is fine here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this is the only Caddy entry needed
|
||||||
|
|
||||||
|
The per-service `/api/admin/<svc>/info` endpoints stay **localhost-only** — they
|
||||||
|
are never edge-routed (nginx has no per-service admin block; navi-admin reaches
|
||||||
|
them over `127.0.0.1`). Only navi-admin's single `/api/admin` front door is
|
||||||
|
edge-exposed, so only this one path needs adding to `@authed_api`.
|
||||||
|
|
||||||
|
## nginx side (VM 1130)
|
||||||
|
|
||||||
|
Pair this with the `^~ /api/admin → 127.0.0.1:8427` block in
|
||||||
|
`deploy/nginx/navi-admin.conf.snippet`, added before the `location /api/`
|
||||||
|
catch-all in `/etc/nginx/sites-available/navi.echo6.co`.
|
||||||
21
backend/deploy/env/navi-admin.env.example
vendored
Normal file
21
backend/deploy/env/navi-admin.env.example
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# navi-admin — /etc/navi-backend/navi-admin.env
|
||||||
|
# Net-new fleet admin aggregator (extraction #7). NO SECRETS — read-only HTTP
|
||||||
|
# fan-out over localhost to each navi-* service's /api/admin/<svc>/info plus
|
||||||
|
# recon's /api/health. Owns no DB / no writable state (nothing under
|
||||||
|
# /var/lib/navi-backend/).
|
||||||
|
|
||||||
|
# recon's pipeline-health endpoint, wrapped by /api/admin/recon/info.
|
||||||
|
RECON_HEALTH_URL=http://127.0.0.1:8420/api/health
|
||||||
|
|
||||||
|
# recon's deploy clone, for git-SHA discovery in /api/admin/recon/info.
|
||||||
|
# NOTE: recon deploys at /opt/recon on VM 1130 (a git repo, readable by zvx) —
|
||||||
|
# NOT /home/zvx/projects/repos/recon as the Phase B prompt's example assumed
|
||||||
|
# (that path doesn't exist). Verified during the build; see PR description.
|
||||||
|
RECON_REPO_PATH=/opt/recon
|
||||||
|
|
||||||
|
# OPTIONAL: per-service fan-out timeout (seconds). Default 3.0 in code.
|
||||||
|
# NAVI_ADMIN_FANOUT_TIMEOUT_S=3
|
||||||
|
|
||||||
|
# Service discovery is a hardcoded list in services/navi_admin/fleet.py
|
||||||
|
# (Option B) — the set of navi-* services only changes when a new extraction
|
||||||
|
# ships, the same moment we'd edit this service to add it. No env list.
|
||||||
30
backend/deploy/nginx/navi-admin.conf.snippet
Normal file
30
backend/deploy/nginx/navi-admin.conf.snippet
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# =============================================================================
|
||||||
|
# navi-admin — nginx integration for the navi.echo6.co vhost
|
||||||
|
#
|
||||||
|
# ONE block. Add INSIDE the existing
|
||||||
|
# server { server_name navi.echo6.co; ... }
|
||||||
|
# block, BEFORE the existing `location /api/ { ... }` catch-all (which currently
|
||||||
|
# sends /api/admin/* to recon:8420 → 404, since recon has no /api/admin route).
|
||||||
|
#
|
||||||
|
# `^~ /api/admin` (NO trailing slash): the `^~` (extraction-#1 lesson) makes
|
||||||
|
# nginx skip regex evaluation; the no-slash prefix catches /api/admin/fleet,
|
||||||
|
# /api/admin/recon/info, and /api/admin/navi-admin/info.
|
||||||
|
#
|
||||||
|
# IMPORTANT — this route is AUTH-GATED, unlike the public navi-* prefixes. It
|
||||||
|
# needs the matching Caddy edit (see deploy/caddy/navi-admin.caddy.notes.md):
|
||||||
|
# add /api/admin/* to @authed_api so forward_auth runs before this proxy. The
|
||||||
|
# X-Authentik-Username header set below is what navi-admin forwards on its
|
||||||
|
# localhost fan-out to each per-service @require_auth /api/admin/<svc>/info.
|
||||||
|
#
|
||||||
|
# The per-service /api/admin/<svc>/info endpoints stay localhost-only — they are
|
||||||
|
# NOT exposed here. This single /api/admin front door (the fleet aggregator) is
|
||||||
|
# the only edge-reachable admin surface.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
location ^~ /api/admin {
|
||||||
|
proxy_pass http://127.0.0.1:8427;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Authentik-Username $http_x_authentik_username;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
add_header X-Cache-Status BYPASS;
|
||||||
|
}
|
||||||
15
backend/deploy/systemd/navi-admin.service
Normal file
15
backend/deploy/systemd/navi-admin.service
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[Unit]
|
||||||
|
Description=navi-admin — fleet admin-info aggregator (Echo6 navi-backend, extraction #7)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=zvx
|
||||||
|
WorkingDirectory=/home/zvx/projects/repos/navi-backend
|
||||||
|
EnvironmentFile=/etc/navi-backend/navi-admin.env
|
||||||
|
ExecStart=/home/zvx/projects/repos/navi-backend/.venv/bin/gunicorn 'services.navi_admin.app:create_app()' --bind 127.0.0.1:8427 --workers 2
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -32,4 +32,4 @@ include = ["shared*", "services*"]
|
||||||
namespaces = true
|
namespaces = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["services"]
|
testpaths = ["services", "shared"]
|
||||||
|
|
|
||||||
0
backend/services/navi_admin/__init__.py
Normal file
0
backend/services/navi_admin/__init__.py
Normal file
76
backend/services/navi_admin/admin_route.py
Normal file
76
backend/services/navi_admin/admin_route.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""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)
|
||||||
38
backend/services/navi_admin/app.py
Normal file
38
backend/services/navi_admin/app.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""navi-admin Flask application factory + gunicorn entry.
|
||||||
|
|
||||||
|
Gunicorn entry:
|
||||||
|
gunicorn 'services.navi_admin.app:create_app()' --bind 127.0.0.1:8427 --workers 2
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
|
from . import admin_route
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
app.config['VERSION'] = git_short_sha()
|
||||||
|
app.config['METRICS'] = {
|
||||||
|
'start_time': time.time(),
|
||||||
|
'request_count': 0,
|
||||||
|
'last_error_at': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _count_request():
|
||||||
|
app.config['METRICS']['request_count'] += 1
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _track_errors(response):
|
||||||
|
if response.status_code >= 500:
|
||||||
|
app.config['METRICS']['last_error_at'] = time.strftime(
|
||||||
|
'%Y-%m-%dT%H:%M:%SZ', time.gmtime()
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.register_blueprint(admin_route.bp)
|
||||||
|
return app
|
||||||
189
backend/services/navi_admin/fleet.py
Normal file
189
backend/services/navi_admin/fleet.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
"""Fleet fan-out + recon-health wrapping 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
editing this file to add it — so an env list would add a moving part with no
|
||||||
|
payoff. (One source of truth: the ports/names live here only.)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
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 = [
|
||||||
|
('navi-traffic', 8421), # #1 TomTom traffic tile proxy
|
||||||
|
('navi-config', 8422), # #2 deployment profile API
|
||||||
|
('navi-contacts', 8423), # #3 contacts + address book
|
||||||
|
('navi-landclass', 8424), # #4 PAD-US land classification
|
||||||
|
('navi-places', 8425), # #5 OSM place detail + enrichment
|
||||||
|
('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))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return DEFAULT_FANOUT_TIMEOUT_S
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
headers = {'X-Authentik-Username': auth_user} if auth_user else {}
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=headers, timeout=timeout)
|
||||||
|
latency_ms = round((time.monotonic() - start) * 1000, 1)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None, latency_ms, f'HTTP {resp.status_code}'
|
||||||
|
return resp.json(), latency_ms, None
|
||||||
|
except requests.Timeout:
|
||||||
|
return None, round((time.monotonic() - start) * 1000, 1), 'timeout'
|
||||||
|
except ValueError:
|
||||||
|
# 200 with a non-JSON body (e.g. a misrouted upstream serving HTML).
|
||||||
|
# json.JSONDecodeError subclasses ValueError — report it plainly.
|
||||||
|
return None, round((time.monotonic() - start) * 1000, 1), 'invalid JSON'
|
||||||
|
except Exception as exc:
|
||||||
|
return None, round((time.monotonic() - start) * 1000, 1), type(exc).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def probe(name, url, auth_user, timeout=None):
|
||||||
|
"""One GET → (summary, full_json, error). summary is the {name, status,
|
||||||
|
latency_ms[, error]} shape the per-service admin endpoints use for deps."""
|
||||||
|
full, latency_ms, error = _get_json(url, auth_user, timeout or fanout_timeout())
|
||||||
|
summary = {'name': name, 'status': 'ok' if error is None else 'error',
|
||||||
|
'latency_ms': latency_ms}
|
||||||
|
if error:
|
||||||
|
summary['error'] = error
|
||||||
|
return summary, full, error
|
||||||
|
|
||||||
|
|
||||||
|
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'."""
|
||||||
|
return {
|
||||||
|
'service': name, 'version': 'unknown', 'port': port,
|
||||||
|
'config': {}, 'env': [],
|
||||||
|
'dependencies': [{'name': f'{name}-info', 'status': 'error', 'error': error}],
|
||||||
|
'filesystem': [],
|
||||||
|
'runtime': {'status': 'unreachable'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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."""
|
||||||
|
timeout = fanout_timeout()
|
||||||
|
targets = [(name, port, service_info_url(name, port)) for name, port in SERVICES]
|
||||||
|
|
||||||
|
def _fetch(name, port, url):
|
||||||
|
_, full, error = probe(name, url, auth_user, timeout)
|
||||||
|
return name, port, full, error
|
||||||
|
|
||||||
|
services = {}
|
||||||
|
errors = []
|
||||||
|
with ThreadPoolExecutor(max_workers=len(targets) + 1) 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,
|
||||||
|
'fetched_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||||
|
'errors': errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)."""
|
||||||
|
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)
|
||||||
|
return summaries
|
||||||
0
backend/services/navi_admin/tests/__init__.py
Normal file
0
backend/services/navi_admin/tests/__init__.py
Normal file
212
backend/services/navi_admin/tests/test_admin.py
Normal file
212
backend/services/navi_admin/tests/test_admin.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""Hermetic tests for navi-admin's fleet aggregator (extraction #7).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
import services.navi_admin.fleet as fleet
|
||||||
|
from services.navi_admin.app import create_app
|
||||||
|
|
||||||
|
AUTH = {'X-Authentik-Username': 'matt'}
|
||||||
|
|
||||||
|
|
||||||
|
def _svc_info(name, port):
|
||||||
|
"""A canonical per-service admin-info (build_info_response shape)."""
|
||||||
|
return {'service': name, 'version': 'abc1234', 'port': port, 'config': {},
|
||||||
|
'env': [], 'dependencies': [], 'filesystem': [],
|
||||||
|
'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
|
||||||
|
self._json = json_data
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._json
|
||||||
|
|
||||||
|
|
||||||
|
def _router(behaviors, captured_headers):
|
||||||
|
"""behaviors: {port:int -> ('ok', json) | ('http', code) | 'timeout' | 'conn'}."""
|
||||||
|
def fake_get(url, headers=None, timeout=None):
|
||||||
|
captured_headers.append(headers or {})
|
||||||
|
for port, behavior in behaviors.items():
|
||||||
|
if f':{port}/' in url:
|
||||||
|
if behavior == 'timeout':
|
||||||
|
raise requests.Timeout()
|
||||||
|
if behavior == 'conn':
|
||||||
|
raise requests.ConnectionError()
|
||||||
|
kind, payload = behavior
|
||||||
|
if kind == 'http':
|
||||||
|
return _FakeResp(payload, {})
|
||||||
|
return _FakeResp(200, payload)
|
||||||
|
raise AssertionError(f'unexpected GET {url}')
|
||||||
|
return fake_get
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
monkeypatch.setattr(fleet, 'recon_git_sha', lambda: 'recon99')
|
||||||
|
return create_app().test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def captured():
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(monkeypatch, behaviors, captured):
|
||||||
|
monkeypatch.setattr(fleet.requests, 'get', _router(behaviors, captured))
|
||||||
|
|
||||||
|
|
||||||
|
# ── fleet fan-out ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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'}
|
||||||
|
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):
|
||||||
|
b = _all_ok()
|
||||||
|
b[8425] = 'timeout' # navi-places times out
|
||||||
|
_wire(monkeypatch, b, captured)
|
||||||
|
data = client.get('/api/admin/fleet', headers=AUTH).get_json()
|
||||||
|
# Invariant: still present in services, as a uniform degraded entry...
|
||||||
|
assert data['services']['navi-places']['runtime']['status'] == 'unreachable'
|
||||||
|
assert data['services']['navi-places']['port'] == 8425
|
||||||
|
# ...and recorded in errors.
|
||||||
|
assert {'service': 'navi-places', 'error': 'timeout'} in data['errors']
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_service_http_500_lands_in_errors(client, monkeypatch, captured):
|
||||||
|
b = _all_ok()
|
||||||
|
b[8426] = ('http', 500) # navi-geo 500s
|
||||||
|
_wire(monkeypatch, b, captured)
|
||||||
|
data = client.get('/api/admin/fleet', headers=AUTH).get_json()
|
||||||
|
assert data['services']['navi-geo']['runtime']['status'] == 'unreachable'
|
||||||
|
assert {'service': 'navi-geo', 'error': 'HTTP 500'} in data['errors']
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_forwards_auth_header(client, monkeypatch, captured):
|
||||||
|
_wire(monkeypatch, _all_ok(), captured)
|
||||||
|
client.get('/api/admin/fleet', headers=AUTH)
|
||||||
|
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):
|
||||||
|
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
|
||||||
|
assert data['services']['navi-traffic']['runtime']['status'] == 'unreachable'
|
||||||
|
assert {'service': 'navi-traffic', 'error': 'HTTP 502'} in data['errors']
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_service_returns_html_lands_as_invalid_json(client, monkeypatch, captured):
|
||||||
|
"""200 OK with a non-JSON body (e.g. a misrouted upstream returning HTML) must
|
||||||
|
surface as 'invalid JSON' in errors[], not the opaque 'ValueError'."""
|
||||||
|
b = _all_ok()
|
||||||
|
|
||||||
|
class _HtmlResp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
raise ValueError('not JSON')
|
||||||
|
|
||||||
|
def fake_get(url, headers=None, timeout=None):
|
||||||
|
captured.append(headers or {})
|
||||||
|
if ':8425/' in url:
|
||||||
|
return _HtmlResp()
|
||||||
|
return _router(b, [])(url, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr(fleet.requests, 'get', fake_get)
|
||||||
|
data = client.get('/api/admin/fleet', headers=AUTH).get_json()
|
||||||
|
assert {'service': 'navi-places', 'error': 'invalid JSON'} in data['errors']
|
||||||
|
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):
|
||||||
|
_wire(monkeypatch, _all_ok(), captured)
|
||||||
|
data = client.get('/api/admin/navi-admin/info', headers=AUTH).get_json()
|
||||||
|
assert data['service'] == 'navi-admin' and data['port'] == 8427
|
||||||
|
assert data['filesystem'] == [] # owns no files/DB
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_info_config_lists_fanned_services(client, monkeypatch, captured):
|
||||||
|
_wire(monkeypatch, _all_ok(), 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'])
|
||||||
|
def test_auth_required(client, path):
|
||||||
|
assert client.get(path).status_code == 401
|
||||||
|
|
@ -3,35 +3,23 @@
|
||||||
Gunicorn entry:
|
Gunicorn entry:
|
||||||
gunicorn 'services.navi_config.app:create_app()' --bind 127.0.0.1:8422 --workers 2
|
gunicorn 'services.navi_config.app:create_app()' --bind 127.0.0.1:8422 --workers 2
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
from . import config_route, admin
|
from . import config_route, admin
|
||||||
from .config_loader import reset_cache
|
from .config_loader import reset_cache
|
||||||
|
|
||||||
|
|
||||||
def _git_sha():
|
|
||||||
"""Short git SHA of the working tree at startup, or 'unknown' off-repo."""
|
|
||||||
try:
|
|
||||||
sha = subprocess.check_output(
|
|
||||||
['git', 'rev-parse', '--short', 'HEAD'],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
return sha or 'unknown'
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
||||||
# NOTE: with gunicorn --workers 2 these counters are per-worker (each worker
|
# NOTE: with gunicorn --workers 2 these counters are per-worker (each worker
|
||||||
# is its own process); they are indicative, not cluster-wide totals.
|
# is its own process); they are indicative, not cluster-wide totals.
|
||||||
app.config['VERSION'] = _git_sha()
|
app.config['VERSION'] = git_short_sha()
|
||||||
app.config['METRICS'] = {
|
app.config['METRICS'] = {
|
||||||
'start_time': time.time(),
|
'start_time': time.time(),
|
||||||
'request_count': 0,
|
'request_count': 0,
|
||||||
|
|
|
||||||
|
|
@ -6,34 +6,22 @@ Gunicorn entry:
|
||||||
Serves two blueprints: contacts (10 routes, auth-gated) and address_book
|
Serves two blueprints: contacts (10 routes, auth-gated) and address_book
|
||||||
(2 routes, public), plus the §4.5 admin-info endpoint.
|
(2 routes, public), plus the §4.5 admin-info endpoint.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
from . import contacts_route, address_book_route, admin
|
from . import contacts_route, address_book_route, admin
|
||||||
from . import address_book as address_book_mod
|
from . import address_book as address_book_mod
|
||||||
|
|
||||||
|
|
||||||
def _git_sha():
|
|
||||||
"""Short git SHA of the working tree at startup, or 'unknown' off-repo."""
|
|
||||||
try:
|
|
||||||
sha = subprocess.check_output(
|
|
||||||
['git', 'rev-parse', '--short', 'HEAD'],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
return sha or 'unknown'
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
||||||
# NOTE: with gunicorn --workers 2 these counters are per-worker.
|
# NOTE: with gunicorn --workers 2 these counters are per-worker.
|
||||||
app.config['VERSION'] = _git_sha()
|
app.config['VERSION'] = git_short_sha()
|
||||||
app.config['METRICS'] = {
|
app.config['METRICS'] = {
|
||||||
'start_time': time.time(),
|
'start_time': time.time(),
|
||||||
'request_count': 0,
|
'request_count': 0,
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,22 @@
|
||||||
Gunicorn entry:
|
Gunicorn entry:
|
||||||
gunicorn 'services.navi_geo.app:create_app()' --bind 127.0.0.1:8426 --workers 2
|
gunicorn 'services.navi_geo.app:create_app()' --bind 127.0.0.1:8426 --workers 2
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
from . import geo_route, admin
|
from . import geo_route, admin
|
||||||
from . import geocode as geocode_mod
|
from . import geocode as geocode_mod
|
||||||
from . import netsyms
|
from . import netsyms
|
||||||
from . import address_book
|
from . import address_book
|
||||||
|
|
||||||
|
|
||||||
def _git_sha():
|
|
||||||
try:
|
|
||||||
sha = subprocess.check_output(
|
|
||||||
['git', 'rev-parse', '--short', 'HEAD'],
|
|
||||||
stderr=subprocess.DEVNULL, text=True,
|
|
||||||
).strip()
|
|
||||||
return sha or 'unknown'
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
app.config['VERSION'] = _git_sha()
|
app.config['VERSION'] = git_short_sha()
|
||||||
app.config['METRICS'] = {
|
app.config['METRICS'] = {
|
||||||
'start_time': time.time(),
|
'start_time': time.time(),
|
||||||
'request_count': 0,
|
'request_count': 0,
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,22 @@
|
||||||
Gunicorn entry:
|
Gunicorn entry:
|
||||||
gunicorn 'services.navi_landclass.app:create_app()' --bind 127.0.0.1:8424 --workers 2
|
gunicorn 'services.navi_landclass.app:create_app()' --bind 127.0.0.1:8424 --workers 2
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
from . import landclass_route, admin
|
from . import landclass_route, admin
|
||||||
from . import db
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
def _git_sha():
|
|
||||||
"""Short git SHA of the working tree at startup, or 'unknown' off-repo."""
|
|
||||||
try:
|
|
||||||
sha = subprocess.check_output(
|
|
||||||
['git', 'rev-parse', '--short', 'HEAD'],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
return sha or 'unknown'
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
||||||
# NOTE: with gunicorn --workers 2 these counters are per-worker.
|
# NOTE: with gunicorn --workers 2 these counters are per-worker.
|
||||||
app.config['VERSION'] = _git_sha()
|
app.config['VERSION'] = git_short_sha()
|
||||||
app.config['METRICS'] = {
|
app.config['METRICS'] = {
|
||||||
'start_time': time.time(),
|
'start_time': time.time(),
|
||||||
'request_count': 0,
|
'request_count': 0,
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,22 @@
|
||||||
Gunicorn entry:
|
Gunicorn entry:
|
||||||
gunicorn 'services.navi_places.app:create_app()' --bind 127.0.0.1:8425 --workers 2
|
gunicorn 'services.navi_places.app:create_app()' --bind 127.0.0.1:8425 --workers 2
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
from . import place_route, admin
|
from . import place_route, admin
|
||||||
from . import overture
|
from . import overture
|
||||||
from . import place_cache
|
from . import place_cache
|
||||||
from . import config as places_config
|
from . import config as places_config
|
||||||
|
|
||||||
|
|
||||||
def _git_sha():
|
|
||||||
try:
|
|
||||||
sha = subprocess.check_output(
|
|
||||||
['git', 'rev-parse', '--short', 'HEAD'],
|
|
||||||
stderr=subprocess.DEVNULL, text=True,
|
|
||||||
).strip()
|
|
||||||
return sha or 'unknown'
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
app.config['VERSION'] = _git_sha()
|
app.config['VERSION'] = git_short_sha()
|
||||||
app.config['METRICS'] = {
|
app.config['METRICS'] = {
|
||||||
'start_time': time.time(),
|
'start_time': time.time(),
|
||||||
'request_count': 0,
|
'request_count': 0,
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,22 @@
|
||||||
Gunicorn entry:
|
Gunicorn entry:
|
||||||
gunicorn 'services.navi_traffic.app:create_app()' --bind 127.0.0.1:8421 --workers 2
|
gunicorn 'services.navi_traffic.app:create_app()' --bind 127.0.0.1:8421 --workers 2
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
from . import traffic, admin
|
from . import traffic, admin
|
||||||
|
|
||||||
|
|
||||||
def _git_sha():
|
|
||||||
"""Short git SHA of the working tree at startup, or 'unknown' off-repo."""
|
|
||||||
try:
|
|
||||||
sha = subprocess.check_output(
|
|
||||||
['git', 'rev-parse', '--short', 'HEAD'],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
return sha or 'unknown'
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
||||||
# NOTE: with gunicorn --workers 2 these counters are per-worker (each worker
|
# NOTE: with gunicorn --workers 2 these counters are per-worker (each worker
|
||||||
# is its own process); they are indicative, not cluster-wide totals.
|
# is its own process); they are indicative, not cluster-wide totals.
|
||||||
app.config['VERSION'] = _git_sha()
|
app.config['VERSION'] = git_short_sha()
|
||||||
app.config['METRICS'] = {
|
app.config['METRICS'] = {
|
||||||
'start_time': time.time(),
|
'start_time': time.time(),
|
||||||
'request_count': 0,
|
'request_count': 0,
|
||||||
|
|
|
||||||
29
backend/shared/git_sha.py
Normal file
29
backend/shared/git_sha.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Shared git short-SHA helper.
|
||||||
|
|
||||||
|
Used by every navi-* service's create_app() for the `version` field in
|
||||||
|
admin-info, and by navi-admin's fleet.recon_git_sha to read recon's deployed
|
||||||
|
SHA from its clone path. One implementation, one place to fix when behavior
|
||||||
|
needs changing.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def git_short_sha(repo_path: str | None = None) -> str:
|
||||||
|
"""Return ``git rev-parse --short HEAD`` for the given repo path, or for the
|
||||||
|
current working directory if path is None. Returns 'unknown' on any failure
|
||||||
|
(no git, no repo, permission denied, detached HEAD, etc.) — never raises.
|
||||||
|
|
||||||
|
repo_path: explicit repo to query (uses ``git -C <path>``); None = current
|
||||||
|
cwd (the systemd unit's WorkingDirectory in prod).
|
||||||
|
"""
|
||||||
|
cmd = ['git']
|
||||||
|
if repo_path is not None:
|
||||||
|
cmd.extend(['-C', repo_path])
|
||||||
|
cmd.extend(['rev-parse', '--short', 'HEAD'])
|
||||||
|
try:
|
||||||
|
sha = subprocess.check_output(
|
||||||
|
cmd, stderr=subprocess.DEVNULL, text=True, timeout=3,
|
||||||
|
).strip()
|
||||||
|
return sha or 'unknown'
|
||||||
|
except Exception:
|
||||||
|
return 'unknown'
|
||||||
0
backend/shared/tests/__init__.py
Normal file
0
backend/shared/tests/__init__.py
Normal file
14
backend/shared/tests/test_git_sha.py
Normal file
14
backend/shared/tests/test_git_sha.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Hermetic tests for shared.git_sha.git_short_sha."""
|
||||||
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_short_sha_returns_unknown_on_bad_path(tmp_path):
|
||||||
|
# tmp_path is an empty dir, not a git repo → graceful 'unknown', no raise.
|
||||||
|
assert git_short_sha(str(tmp_path)) == 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_short_sha_in_real_repo():
|
||||||
|
# The suite runs from inside the navi-backend repo, so the cwd lookup works.
|
||||||
|
sha = git_short_sha()
|
||||||
|
assert sha != 'unknown'
|
||||||
|
assert len(sha) >= 7 # short SHA, sanity bound
|
||||||
Loading…
Add table
Add a link
Reference in a new issue