mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Add navi-config service (extraction #2 PR-B)
New services/navi_config/ on :8422, mirroring recon's /api/config contract:
- config_route.py: GET /api/config -> jsonify(get_deployment_config())
with Cache-Control: public, max-age=300 (byte-for-byte recon's response).
- config_loader.py: faithful port of recon lib/deployment_config.py. Reads
RECON_PROFILE (default "home") and NAVI_CONFIG_PROFILES_DIR (default
/opt/recon/config/profiles, so it serves the SAME files recon does during
cutover). yaml.safe_load, module-level cache. Lazy load (vs recon's eager
import-time load) so the module imports cleanly off-VM and a missing
profile surfaces as HTTP 500 at request time rather than a failed import.
- admin.py: /api/admin/navi-config/info per handoff §4.5, require_auth gated.
env values (NAVI_CONFIG_PROFILES_DIR, RECON_PROFILE) are non-secret paths/
names, shown as-is (no mask_key); dependencies=[]; filesystem reports the
active profile path + exists/readable.
- app.py: create_app() factory mirroring navi_traffic, same metrics wiring;
resets the loader cache per instance so each worker/test reloads fresh.
Deploy artifacts: systemd unit (:8422) and an nginx snippet using
`location ^~ /api/config` (the ^~ convention from extraction #1 so the asset
.png/.css regex can't shadow it). No proxy_cache zone — the response is
already cached in-process and via Cache-Control: max-age=300; emits a literal
X-Cache-Status: BYPASS for parity with navi-traffic.
Adds PyYAML>=6 to deps. Tests (services/navi_config/tests): 200 + parsed dict,
Cache-Control header, RECON_PROFILE override, default=home, missing profile=500.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad097432fc
commit
565e774864
10 changed files with 331 additions and 0 deletions
27
backend/deploy/nginx/navi-config.conf.snippet
Normal file
27
backend/deploy/nginx/navi-config.conf.snippet
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# =============================================================================
|
||||
# navi-config — nginx integration for the navi.echo6.co vhost
|
||||
#
|
||||
# ONE block. Add it INSIDE the existing
|
||||
# server { server_name navi.echo6.co; ... }
|
||||
# block, placed BEFORE the existing `location /api/ { ... }` block.
|
||||
#
|
||||
# `^~` prefix is the convention for /api/<thing> blocks here: it makes nginx
|
||||
# skip regex evaluation when this is the longest prefix match, so the vhost's
|
||||
# `location ~* \.(...)$` asset-regex can never shadow it (the lesson from
|
||||
# extraction #1 — see navi-traffic.conf.snippet).
|
||||
#
|
||||
# No proxy_cache zone for this route: the response is already cached two ways —
|
||||
# in-process (navi-config caches the parsed profile after first read) and at
|
||||
# the client/CDN via `Cache-Control: public, max-age=300` set by the handler.
|
||||
# A server-side nginx cache would add nothing. We still emit a literal
|
||||
# `X-Cache-Status: BYPASS` header for parity with the navi-traffic block so the
|
||||
# admin/debug view shows a consistent field across services.
|
||||
# -----------------------------------------------------------------------------
|
||||
location ^~ /api/config {
|
||||
proxy_pass http://127.0.0.1:8422;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 10s;
|
||||
|
||||
add_header X-Cache-Status BYPASS;
|
||||
}
|
||||
15
backend/deploy/systemd/navi-config.service
Normal file
15
backend/deploy/systemd/navi-config.service
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=navi-config — deployment profile API (Echo6 navi-backend, extraction #2)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=zvx
|
||||
WorkingDirectory=/home/zvx/projects/repos/navi-backend
|
||||
EnvironmentFile=/etc/navi-backend/navi-config.env
|
||||
ExecStart=/home/zvx/projects/repos/navi-backend/.venv/bin/gunicorn 'services.navi_config.app:create_app()' --bind 127.0.0.1:8422 --workers 2
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -13,6 +13,7 @@ dependencies = [
|
|||
"Flask>=3.0",
|
||||
"gunicorn>=21",
|
||||
"requests>=2.31",
|
||||
"PyYAML>=6",
|
||||
"pytest>=8",
|
||||
]
|
||||
|
||||
|
|
|
|||
0
backend/services/navi_config/__init__.py
Normal file
0
backend/services/navi_config/__init__.py
Normal file
49
backend/services/navi_config/admin.py
Normal file
49
backend/services/navi_config/admin.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""navi-config admin-info endpoint (handoff §4.5).
|
||||
|
||||
``GET /api/admin/navi-config/info`` — Authentik-gated, read-only.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from flask import Blueprint, jsonify, current_app
|
||||
|
||||
from shared.auth import require_auth
|
||||
from shared.admin_info import build_info_response
|
||||
|
||||
from .config_loader import profiles_dir, profile_name, active_profile_path
|
||||
|
||||
bp = Blueprint('admin', __name__)
|
||||
|
||||
PORT = 8422
|
||||
|
||||
|
||||
@bp.route('/api/admin/navi-config/info')
|
||||
@require_auth
|
||||
def navi_config_info():
|
||||
metrics = current_app.config['METRICS']
|
||||
path = active_profile_path()
|
||||
# NOTE: these env values are NOT secrets — a directory path and a profile
|
||||
# name — so they are shown as-is. mask_key() is only for credential-bearing
|
||||
# vars (cf. navi-traffic's TOMTOM_API_KEY); there are none here.
|
||||
info = build_info_response(
|
||||
service='navi-config',
|
||||
version=current_app.config.get('VERSION', 'unknown'),
|
||||
port=PORT,
|
||||
config={},
|
||||
env=[
|
||||
{'name': 'NAVI_CONFIG_PROFILES_DIR', 'value': profiles_dir()},
|
||||
{'name': 'RECON_PROFILE', 'value': profile_name()},
|
||||
],
|
||||
dependencies=[], # no upstream HTTP — config is a local file read
|
||||
filesystem=[{
|
||||
'path': path,
|
||||
'exists': os.path.exists(path),
|
||||
'readable': os.access(path, os.R_OK),
|
||||
}],
|
||||
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)
|
||||
59
backend/services/navi_config/app.py
Normal file
59
backend/services/navi_config/app.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""navi-config Flask application factory + gunicorn entry.
|
||||
|
||||
Gunicorn entry:
|
||||
gunicorn 'services.navi_config.app:create_app()' --bind 127.0.0.1:8422 --workers 2
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from . import config_route, admin
|
||||
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():
|
||||
app = Flask(__name__)
|
||||
|
||||
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
||||
# NOTE: with gunicorn --workers 2 these counters are per-worker (each worker
|
||||
# is its own process); they are indicative, not cluster-wide totals.
|
||||
app.config['VERSION'] = _git_sha()
|
||||
app.config['METRICS'] = {
|
||||
'start_time': time.time(),
|
||||
'request_count': 0,
|
||||
'last_error_at': None,
|
||||
}
|
||||
|
||||
# Fresh profile load per app instance — each gunicorn worker (and each test)
|
||||
# picks up the current RECON_PROFILE / NAVI_CONFIG_PROFILES_DIR env.
|
||||
reset_cache()
|
||||
|
||||
@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(config_route.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
return app
|
||||
71
backend/services/navi_config/config_loader.py
Normal file
71
backend/services/navi_config/config_loader.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Deployment profile loader for navi-config.
|
||||
|
||||
Behavior-faithful port of recon's ``lib/deployment_config.py``: read the active
|
||||
profile name from ``RECON_PROFILE`` (default ``home``), load the matching
|
||||
``<dir>/<profile>.yaml``, ``yaml.safe_load`` it, and cache the parsed dict in a
|
||||
module global. The profiles directory is configurable via
|
||||
``NAVI_CONFIG_PROFILES_DIR`` (default ``/opt/recon/config/profiles`` — the same
|
||||
files recon serves, so the two agree byte-for-byte during cutover).
|
||||
|
||||
Difference from recon: recon eagerly loads at import time (fail-fast at start).
|
||||
Here the load is lazy (first ``get_deployment_config()`` call), so the module
|
||||
imports cleanly even where the default dir is absent (e.g. CI / a dev box), and
|
||||
a missing profile surfaces as an HTTP 500 at request time rather than a failed
|
||||
import. ``reset_cache()`` lets ``create_app()`` (and tests) force a fresh load.
|
||||
"""
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
DEFAULT_PROFILES_DIR = '/opt/recon/config/profiles'
|
||||
|
||||
_config_cache = None
|
||||
|
||||
|
||||
def profiles_dir():
|
||||
"""Directory holding the profile YAMLs (env-overridable)."""
|
||||
return os.environ.get('NAVI_CONFIG_PROFILES_DIR', DEFAULT_PROFILES_DIR)
|
||||
|
||||
|
||||
def profile_name():
|
||||
"""Active profile name (env-overridable), matching recon's RECON_PROFILE."""
|
||||
return os.environ.get('RECON_PROFILE', 'home')
|
||||
|
||||
|
||||
def active_profile_path():
|
||||
"""Full path to the active profile YAML."""
|
||||
return os.path.join(profiles_dir(), f'{profile_name()}.yaml')
|
||||
|
||||
|
||||
def load_deployment_config():
|
||||
"""Load and cache the active profile. Raises FileNotFoundError if absent."""
|
||||
global _config_cache
|
||||
path = active_profile_path()
|
||||
if not os.path.exists(path):
|
||||
directory = profiles_dir()
|
||||
try:
|
||||
available = ', '.join(
|
||||
f.replace('.yaml', '') for f in os.listdir(directory) if f.endswith('.yaml')
|
||||
)
|
||||
except OSError:
|
||||
available = '(profiles dir missing)'
|
||||
raise FileNotFoundError(
|
||||
f"Deployment profile '{profile_name()}' not found at {path}. "
|
||||
f"Available profiles: {available}"
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
_config_cache = yaml.safe_load(f)
|
||||
return _config_cache
|
||||
|
||||
|
||||
def get_deployment_config():
|
||||
"""Return the cached deployment config dict, loading it on first use."""
|
||||
if _config_cache is None:
|
||||
load_deployment_config()
|
||||
return _config_cache
|
||||
|
||||
|
||||
def reset_cache():
|
||||
"""Drop the cached config so the next access reloads (env may have changed)."""
|
||||
global _config_cache
|
||||
_config_cache = None
|
||||
20
backend/services/navi_config/config_route.py
Normal file
20
backend/services/navi_config/config_route.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""`/api/config` route — mirrors recon `lib/api.py:api_config`.
|
||||
|
||||
Returns the entire deployment profile dict as JSON with
|
||||
``Cache-Control: public, max-age=300`` — byte-for-byte the same contract recon
|
||||
serves today, so the frontend sees no difference at cutover.
|
||||
"""
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from .config_loader import get_deployment_config
|
||||
|
||||
bp = Blueprint('config', __name__)
|
||||
|
||||
|
||||
@bp.route('/api/config')
|
||||
def api_config():
|
||||
"""Return deployment profile config for frontend consumption."""
|
||||
config = get_deployment_config()
|
||||
resp = jsonify(config)
|
||||
resp.headers['Cache-Control'] = 'public, max-age=300'
|
||||
return resp
|
||||
0
backend/services/navi_config/tests/__init__.py
Normal file
0
backend/services/navi_config/tests/__init__.py
Normal file
89
backend/services/navi_config/tests/test_config.py
Normal file
89
backend/services/navi_config/tests/test_config.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Tests for navi-config `/api/config`.
|
||||
|
||||
Writes fixture profile YAMLs under a tmp dir, points
|
||||
``NAVI_CONFIG_PROFILES_DIR`` at it, and exercises the response, headers, the
|
||||
``RECON_PROFILE`` override, and the missing-profile path.
|
||||
|
||||
Note: the test client does NOT set ``app.testing = True`` — that would make
|
||||
Flask re-raise unhandled exceptions instead of returning a response. We want
|
||||
the missing-profile case to surface as an HTTP 500 (which is the production
|
||||
behavior: a bad/missing profile fails the request loudly), so we let Flask's
|
||||
default error handling produce the 500.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from services.navi_config.app import create_app
|
||||
|
||||
HOME_YAML = """\
|
||||
profile: home
|
||||
region_name: "North America"
|
||||
services:
|
||||
geocode: "/api/geocode"
|
||||
valhalla: "/valhalla"
|
||||
auth:
|
||||
login_url: "/outpost.goauthentik.io/start?rd=%2F"
|
||||
logout_url: "https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/"
|
||||
features:
|
||||
has_contacts: true
|
||||
"""
|
||||
|
||||
MINIMAL_YAML = """\
|
||||
profile: minimal_pi
|
||||
region_name: "Idaho"
|
||||
features:
|
||||
has_contacts: false
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profiles_dir(tmp_path, monkeypatch):
|
||||
(tmp_path / 'home.yaml').write_text(HOME_YAML)
|
||||
(tmp_path / 'minimal_pi.yaml').write_text(MINIMAL_YAML)
|
||||
monkeypatch.setenv('NAVI_CONFIG_PROFILES_DIR', str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _client():
|
||||
# No app.testing = True — see module docstring (we want a 500, not a raise).
|
||||
return create_app().test_client()
|
||||
|
||||
|
||||
def test_config_returns_parsed_dict(profiles_dir, monkeypatch):
|
||||
monkeypatch.setenv('RECON_PROFILE', 'home')
|
||||
resp = _client().get('/api/config')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['profile'] == 'home'
|
||||
assert data['region_name'] == 'North America'
|
||||
assert data['services']['geocode'] == '/api/geocode'
|
||||
# the auth block added in PR-A flows through unchanged
|
||||
assert data['auth']['login_url'] == '/outpost.goauthentik.io/start?rd=%2F'
|
||||
|
||||
|
||||
def test_cache_control_header(profiles_dir, monkeypatch):
|
||||
monkeypatch.setenv('RECON_PROFILE', 'home')
|
||||
resp = _client().get('/api/config')
|
||||
assert resp.headers['Cache-Control'] == 'public, max-age=300'
|
||||
|
||||
|
||||
def test_recon_profile_env_override(profiles_dir, monkeypatch):
|
||||
monkeypatch.setenv('RECON_PROFILE', 'minimal_pi')
|
||||
resp = _client().get('/api/config')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['profile'] == 'minimal_pi'
|
||||
assert data['region_name'] == 'Idaho'
|
||||
|
||||
|
||||
def test_default_profile_is_home(profiles_dir, monkeypatch):
|
||||
# No RECON_PROFILE set -> defaults to "home"
|
||||
monkeypatch.delenv('RECON_PROFILE', raising=False)
|
||||
resp = _client().get('/api/config')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['profile'] == 'home'
|
||||
|
||||
|
||||
def test_missing_profile_returns_500(profiles_dir, monkeypatch):
|
||||
monkeypatch.setenv('RECON_PROFILE', 'does_not_exist')
|
||||
resp = _client().get('/api/config')
|
||||
assert resp.status_code == 500
|
||||
Loading…
Add table
Add a link
Reference in a new issue