Merge pull request #1 from zvx-echo6/extraction-2-navi-config

Add navi-config service (extraction #2)
This commit is contained in:
malice 2026-05-22 09:28:51 -06:00 committed by GitHub
commit 8a9b5d3337
10 changed files with 331 additions and 0 deletions

View 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;
}

View 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

View file

@ -13,6 +13,7 @@ dependencies = [
"Flask>=3.0",
"gunicorn>=21",
"requests>=2.31",
"PyYAML>=6",
"pytest>=8",
]

View file

View 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)

View 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

View 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

View 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

View 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