mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Merge pull request #1 from zvx-echo6/extraction-2-navi-config
Add navi-config service (extraction #2)
This commit is contained in:
commit
8a9b5d3337
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",
|
"Flask>=3.0",
|
||||||
"gunicorn>=21",
|
"gunicorn>=21",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
|
"PyYAML>=6",
|
||||||
"pytest>=8",
|
"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