navi/backend/services/navi_config/config_loader.py
Matt Johnson 565e774864 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>
2026-05-22 08:25:06 -06:00

71 lines
2.5 KiB
Python

"""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