diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e96ee7e..ff4c8b5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -35,15 +35,12 @@ jobs: with: { python-version: "3.13" } # match Dockerfile's python:3.13-slim - run: pip install -r requirements.txt ruff # ruff F gate, scoped to the money path (tests/ and root-level scratch - # scripts excluded). Pre-existing errors are pinned per file+rule so NEW - # F errors anywhere in scope still fail; remove each pin when its owning - # plan lands: - # api/main.py F821 (missing os/hmac imports) -> plan 007 + # scripts excluded). All per-file pins have been cleared as their owning + # plans landed (api/main.py F821 os/hmac -> plan 007), so this is unpinned. - run: >- ruff check --select F run_live_paper.py trading_execution.py telegram_bot.py api/ alpaca_paper/ runtime/ storage/ strategy/ research/ - --per-file-ignores "api/main.py:F821" - run: python -m pytest -q env: DATABASE_URL: postgresql://momentum:momentum@localhost:5432/momentum diff --git a/admin_api.py b/admin_api.py index ae35379..87a07c7 100644 --- a/admin_api.py +++ b/admin_api.py @@ -86,6 +86,56 @@ def _editable(key: str) -> bool: and not any(m in key for m in SECRET_MARKERS)) +def _mask_value(key: str, value: str) -> str: + """Mask secret VALUES (*** + last 4). Uses the broader SECRET_MARKERS set so + DATABASE_URL / *_WEBHOOK_URL are masked too — narrowing to KEY|SECRET|TOKEN| + PASSWORD would leak the DB password in the config table.""" + if any(m in key.upper() for m in SECRET_MARKERS): + tail = value[-4:] if value and len(value) >= 4 else "" + return "***" + tail + return value + + +def _env_example_keys() -> set[str]: + """Keys documented in .env.example — the 'required' config surface used for + drift detection (a documented key absent from the effective env is drift).""" + path = os.path.join(os.path.dirname(__file__), ".env.example") + keys: set[str] = set() + try: + with open(path) as fh: + for line in fh: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + keys.add(line.split("=", 1)[0].strip()) + except Exception: # noqa: BLE001 + pass + return keys + + +def effective_config() -> dict: + """Per-key effective value + source for the admin config table. + + Merge order baked < compose < overrides.env (< learned). Sources we can prove + at runtime: ``overrides.env`` (from data/overrides.env) vs ``compose`` (the + process env — baked and compose are indistinguishable inside the container, so + both surface as ``compose``); a documented .env.example key that is absent + surfaces as ``missing`` (drift). Secret values are masked.""" + overrides = env_overrides.read_overrides() + example = _env_example_keys() + keys = set(example) | set(overrides) | {k for k in os.environ if _editable(k)} + out: dict[str, dict] = {} + for key in sorted(keys): + value = os.environ.get(key) + if value is None: + out[key] = {"value_masked": None, + "source": "missing" if key in example else "unset", + "drift": key in example} + continue + source = "overrides.env" if key in overrides else "compose" + out[key] = {"value_masked": _mask_value(key, str(value)), "source": source} + return out + + # ---------- docker over the unix socket (stdlib only) ---------- class _SockConn(http.client.HTTPConnection): def __init__(self): @@ -230,6 +280,8 @@ class Handler(BaseHTTPRequestHandler): "overrides": _safe_overrides()}) if path == "/api/profiles": return self._send(200, profiles()) + if path == "/api/config/effective": + return self._send(200, {"config": effective_config()}) return self._send(404, {"error": "unknown route"}) def do_POST(self): # noqa: N802 diff --git a/api/static/admin.html b/api/static/admin.html index 49a794f..14528ec 100644 --- a/api/static/admin.html +++ b/api/static/admin.html @@ -65,6 +65,9 @@ color:var(--bg);padding:10px 20px;border-radius:10px;font-size:14px;opacity:0;tr
| key | effective value | source |
|---|