Files
srbhr--resume-matcher/apps/backend/e2e_monitor/servers.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

134 lines
4.9 KiB
Python

"""Boot/teardown the backend (+ optional frontend) for a run.
The backend is spawned with DATA_DIR pointed at the bundle's ``data/`` dir, so
the dev's real database.json and uploads are never touched, and the DATA_DIR-
aware reads (feature flags / content language, via ``config_cache``) use the
bundle's copied ``config.json``.
The LLM key/provider is resolved separately, via ``app.config.load_config_file``
which reads the repo's real ``apps/backend/data/config.json`` (a hardcoded path,
NOT the bundle copy). That is intentional: the run uses the dev's configured
provider, and the opt-in gate (``e2e_monitor.gate``) has already verified that
real config carries a usable key before any move runs.
Process stdout/stderr stream into the bundle's log files — a durable log trail
with no change to app/ logging.
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
from e2e_monitor import API_BASE
from e2e_monitor.bundle import Bundle
BACKEND_HEALTH = f"{API_BASE}/health"
FRONTEND_URL = "http://127.0.0.1:3000/"
def _port_is_free(port: int) -> bool:
"""True if nothing is listening on 127.0.0.1:<port>."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
return sock.connect_ex(("127.0.0.1", port)) != 0
_REPO_BACKEND = Path(__file__).resolve().parents[1] # apps/backend
_REPO_ROOT = _REPO_BACKEND.parents[1] # repo root
_REAL_CONFIG = _REPO_BACKEND / "data" / "config.json"
@dataclass
class Servers:
bundle: Bundle
procs: list[subprocess.Popen] = field(default_factory=list)
log_files: list[Any] = field(default_factory=list)
frontend_up: bool = False
def _wait(self, url: str, timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
if httpx.get(url, timeout=2.0).status_code < 500:
return True
except httpx.HTTPError:
pass
time.sleep(1.0)
return False
def boot(self, *, with_frontend: bool = True) -> dict[str, bool]:
self.bundle.data_dir.mkdir(parents=True, exist_ok=True)
if _REAL_CONFIG.exists():
shutil.copy2(_REAL_CONFIG, self.bundle.data_dir / "config.json")
if not _port_is_free(8000):
raise RuntimeError(
"port 8000 is already in use — stop any running backend so the "
"monitor can bind its own isolated instance (DATA_DIR isolation "
"depends on owning the port)."
)
be_log = (self.bundle.logs_dir / "backend.log").open("w")
self.log_files.append(be_log)
env = {
"DATA_DIR": str(self.bundle.data_dir),
"PORT": "8000",
"HOST": "127.0.0.1",
"RELOAD": "false",
"FRONTEND_BASE_URL": FRONTEND_URL.rstrip("/"),
}
self.procs.append(subprocess.Popen(
["uv", "run", "app"],
cwd=_REPO_BACKEND,
stdout=be_log,
stderr=subprocess.STDOUT,
env={**os.environ, **env},
))
if not self._wait(BACKEND_HEALTH, timeout_s=60):
raise RuntimeError("backend did not become healthy on :8000")
if with_frontend and shutil.which("node") and shutil.which("npm"):
if not _port_is_free(3000):
# Something is on :3000 — require a 200 from the root before trusting
# it as the frontend (it proxies to our :8000). _wait() accepts any
# <500 (incl. 404), so an unrelated HTTP service squatting the port
# would be mistaken for a frontend; demand 200. Any failure leaves
# frontend_up False and renders just skip.
try:
self.frontend_up = httpx.get(FRONTEND_URL, timeout=5.0).status_code == 200
except httpx.HTTPError:
self.frontend_up = False
else:
fe_log = (self.bundle.logs_dir / "frontend.log").open("w")
self.log_files.append(fe_log)
self.procs.append(subprocess.Popen(
["npm", "run", "dev"], cwd=_REPO_ROOT / "apps" / "frontend",
stdout=fe_log, stderr=subprocess.STDOUT, env={**os.environ},
))
self.frontend_up = self._wait(FRONTEND_URL, timeout_s=120)
return {"frontend_up": self.frontend_up}
def teardown(self) -> None:
for p in reversed(self.procs):
p.terminate()
for p in reversed(self.procs):
try:
p.wait(timeout=10)
except subprocess.TimeoutExpired:
p.kill()
self.procs.clear()
for f in self.log_files:
try:
f.close()
except Exception:
pass
self.log_files.clear()