* fix(sec): upgrade vulnerable deps * feat: add comprehensive logging to upgrade tests - Modify VersionRunner to write server logs to /tmp/upgrade-test-*.log files - Add pytest hook to automatically dump server logs on test failure - Add CI workflow step to show upgrade test logs (always runs) - Improves debuggability when upgrade tests fail in CI This addresses the issue where upgrade test failures in CI were impossible to debug because API server logs were not visible.
288 lines
9.7 KiB
Python
288 lines
9.7 KiB
Python
"""
|
|
Version runner for upgrade tests.
|
|
|
|
Manages running different git versions of the Hindsight API for upgrade testing.
|
|
Handles git checkout, venv creation, dependency installation, and server lifecycle.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ServerInfo:
|
|
"""Information about a running server."""
|
|
|
|
url: str
|
|
port: int
|
|
version: str
|
|
|
|
|
|
class VersionRunner:
|
|
"""
|
|
Manages running a specific git version of the Hindsight API.
|
|
|
|
For "HEAD" or "current", uses the current working directory.
|
|
For git tags (e.g., "v0.3.0"), clones the repo at that tag to a temp directory.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
version: str,
|
|
db_url: str,
|
|
port: int = 8890,
|
|
llm_provider: str | None = None,
|
|
llm_api_key: str | None = None,
|
|
llm_model: str | None = None,
|
|
):
|
|
"""
|
|
Initialize a version runner.
|
|
|
|
Args:
|
|
version: Git tag (e.g., "v0.3.0") or "HEAD"/"current" for current code
|
|
db_url: PostgreSQL connection URL
|
|
port: Port to run the API on
|
|
llm_provider: LLM provider (defaults to env var)
|
|
llm_api_key: LLM API key (defaults to env var)
|
|
llm_model: LLM model (defaults to env var)
|
|
"""
|
|
self.version = version
|
|
self.db_url = db_url
|
|
self.port = port
|
|
self.llm_provider = llm_provider or os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
|
|
self.llm_api_key = llm_api_key or os.getenv("HINDSIGHT_API_LLM_API_KEY") or os.getenv("GROQ_API_KEY")
|
|
self.llm_model = llm_model or os.getenv("HINDSIGHT_API_LLM_MODEL", "llama-3.3-70b-versatile")
|
|
|
|
self.work_dir: Path | None = None
|
|
self.process: subprocess.Popen | None = None
|
|
self._temp_dir: str | None = None
|
|
self._is_current = version.lower() in ("head", "current")
|
|
self.log_file: Path | None = None
|
|
self._log_handle = None
|
|
|
|
def _find_repo_root(self) -> Path:
|
|
"""Find the git repository root."""
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--show-toplevel"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return Path(result.stdout.strip())
|
|
|
|
def setup(self) -> None:
|
|
"""Checkout version and install dependencies."""
|
|
if self._is_current:
|
|
# Use current working directory
|
|
self.work_dir = self._find_repo_root()
|
|
logger.info(f"Using current code at {self.work_dir}")
|
|
return
|
|
|
|
# Create temp dir and checkout specific version
|
|
self._temp_dir = tempfile.mkdtemp(prefix=f"hindsight-{self.version}-")
|
|
self.work_dir = Path(self._temp_dir)
|
|
|
|
repo_root = self._find_repo_root()
|
|
logger.info(f"Cloning {repo_root} at {self.version} to {self.work_dir}")
|
|
|
|
# Shallow clone at specific tag
|
|
subprocess.run(
|
|
["git", "clone", "--depth", "1", "--branch", self.version, str(repo_root), str(self.work_dir)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
# Create venv and install
|
|
venv_path = self.work_dir / ".venv-upgrade-test"
|
|
logger.info(f"Creating venv at {venv_path}")
|
|
|
|
subprocess.run(["uv", "venv", str(venv_path)], check=True, capture_output=True)
|
|
|
|
api_path = self.work_dir / "hindsight-api"
|
|
logger.info(f"Installing hindsight-api from {api_path}")
|
|
|
|
# Install with uv pip - use --index-strategy for pytorch
|
|
subprocess.run(
|
|
[
|
|
"uv",
|
|
"pip",
|
|
"install",
|
|
"-e",
|
|
str(api_path),
|
|
"--python",
|
|
str(venv_path / "bin" / "python"),
|
|
"--index-strategy",
|
|
"unsafe-best-match",
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
env={**os.environ, "UV_INDEX": "pytorch=https://download.pytorch.org/whl/cpu"},
|
|
)
|
|
|
|
logger.info(f"Version {self.version} setup complete")
|
|
|
|
def _get_venv_path(self) -> Path:
|
|
"""Get the path to the venv for this version."""
|
|
if self._is_current:
|
|
# For current code, the venv is at the workspace root (uv workspace layout)
|
|
# Check both possible locations
|
|
workspace_venv = self.work_dir / ".venv"
|
|
api_venv = self.work_dir / "hindsight-api" / ".venv"
|
|
|
|
if (workspace_venv / "bin" / "hindsight-api").exists():
|
|
return workspace_venv
|
|
elif (api_venv / "bin" / "hindsight-api").exists():
|
|
return api_venv
|
|
else:
|
|
# Default to workspace root
|
|
return workspace_venv
|
|
return self.work_dir / ".venv-upgrade-test"
|
|
|
|
def start(self) -> ServerInfo:
|
|
"""
|
|
Start the API server.
|
|
|
|
Returns:
|
|
ServerInfo with the URL and port
|
|
"""
|
|
venv_path = self._get_venv_path()
|
|
hindsight_api_bin = venv_path / "bin" / "hindsight-api"
|
|
|
|
if not hindsight_api_bin.exists():
|
|
raise RuntimeError(f"hindsight-api binary not found at {hindsight_api_bin}")
|
|
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"HINDSIGHT_API_PORT": str(self.port),
|
|
"HINDSIGHT_API_DATABASE_URL": self.db_url,
|
|
"HINDSIGHT_API_HOST": "127.0.0.1",
|
|
"HINDSIGHT_API_LLM_PROVIDER": self.llm_provider,
|
|
"HINDSIGHT_API_LLM_API_KEY": self.llm_api_key or "",
|
|
"HINDSIGHT_API_LLM_MODEL": self.llm_model,
|
|
"PYTHONUNBUFFERED": "1",
|
|
}
|
|
)
|
|
|
|
logger.info(f"Starting {self.version} API on port {self.port}")
|
|
logger.info(f"Database URL: {self.db_url}")
|
|
|
|
# Determine working directory
|
|
# For HEAD/current, use a temp directory to avoid .env file from workspace root
|
|
# (hindsight-api loads .env with override=True which would override our env vars)
|
|
if self._is_current:
|
|
# Create a temp directory for HEAD to avoid workspace .env
|
|
self._head_cwd = tempfile.mkdtemp(prefix="hindsight-head-cwd-")
|
|
cwd = self._head_cwd
|
|
else:
|
|
cwd = str(self.work_dir)
|
|
self._head_cwd = None
|
|
|
|
# Create log file for server output
|
|
version_slug = self.version.replace("/", "-").replace(".", "-")
|
|
self.log_file = Path(f"/tmp/upgrade-test-{version_slug}-{self.port}.log")
|
|
self._log_handle = open(self.log_file, "w")
|
|
logger.info(f"Server logs will be written to {self.log_file}")
|
|
|
|
# Start the server
|
|
self.process = subprocess.Popen(
|
|
[str(hindsight_api_bin)],
|
|
env=env,
|
|
stdout=self._log_handle,
|
|
stderr=subprocess.STDOUT,
|
|
cwd=cwd,
|
|
)
|
|
|
|
self._wait_healthy()
|
|
|
|
url = f"http://127.0.0.1:{self.port}"
|
|
logger.info(f"Server {self.version} ready at {url}")
|
|
|
|
return ServerInfo(url=url, port=self.port, version=self.version)
|
|
|
|
def _wait_healthy(self, timeout: int = 120) -> None:
|
|
"""Wait for /health endpoint to respond."""
|
|
url = f"http://127.0.0.1:{self.port}/health"
|
|
deadline = time.time() + timeout
|
|
|
|
while time.time() < deadline:
|
|
# Check if process is still alive
|
|
if self.process and self.process.poll() is not None:
|
|
logs = self._read_logs()
|
|
raise RuntimeError(f"Server {self.version} exited unexpectedly.\nLogs:\n{logs}")
|
|
|
|
try:
|
|
resp = httpx.get(url, timeout=2)
|
|
if resp.status_code == 200:
|
|
return
|
|
except httpx.RequestError:
|
|
pass
|
|
|
|
time.sleep(1)
|
|
|
|
# Timeout - dump logs
|
|
if self.process:
|
|
self.process.terminate()
|
|
logs = self._read_logs()
|
|
raise TimeoutError(f"Server {self.version} not healthy after {timeout}s.\nLogs:\n{logs}")
|
|
|
|
def _read_logs(self) -> str:
|
|
"""Read current logs from the log file."""
|
|
if self.log_file and self.log_file.exists():
|
|
try:
|
|
# Flush the file handle first
|
|
if self._log_handle:
|
|
self._log_handle.flush()
|
|
return self.log_file.read_text()
|
|
except Exception as e:
|
|
return f"(failed to read logs: {e})"
|
|
return "(no log file found)"
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the server and cleanup temp directory."""
|
|
if self.process:
|
|
logger.info(f"Stopping {self.version} server")
|
|
self.process.terminate()
|
|
try:
|
|
self.process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning(f"Server {self.version} did not stop gracefully, killing")
|
|
self.process.kill()
|
|
self.process.wait()
|
|
self.process = None
|
|
|
|
# Close log file handle
|
|
if self._log_handle:
|
|
self._log_handle.close()
|
|
self._log_handle = None
|
|
|
|
if self._temp_dir and os.path.exists(self._temp_dir):
|
|
logger.info(f"Cleaning up {self._temp_dir}")
|
|
shutil.rmtree(self._temp_dir, ignore_errors=True)
|
|
self._temp_dir = None
|
|
|
|
# Clean up HEAD's temp cwd
|
|
if hasattr(self, "_head_cwd") and self._head_cwd and os.path.exists(self._head_cwd):
|
|
shutil.rmtree(self._head_cwd, ignore_errors=True)
|
|
self._head_cwd = None
|
|
|
|
def get_logs(self) -> str:
|
|
"""Get current server logs."""
|
|
return self._read_logs()
|
|
|
|
def __enter__(self) -> "VersionRunner":
|
|
self.setup()
|
|
return self
|
|
|
|
def __exit__(self, *args) -> None:
|
|
self.stop()
|