From 941e824c3438d8cc41e1880a68a6fdbd8c3f7165 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 16 Jan 2025 11:33:04 +0000 Subject: [PATCH 1/6] feat: version endpoint --- poetry.lock | 6 +++--- pyproject.toml | 2 +- src/codegate/server.py | 41 ++++++++++++++++++++++++++++++++++++++++- tests/test_server.py | 15 +++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4bbc6011..7e0bda3f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1241,12 +1241,12 @@ proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", " [[package]] name = "llama-cpp-python" -version = "0.3.6" +version = "0.3.5" description = "Python bindings for the llama.cpp library" optional = false python-versions = ">=3.8" files = [ - {file = "llama_cpp_python-0.3.6.tar.gz", hash = "sha256:86e35a8888274466958e24201b856cd71c8def0ea72e14312be13da96c15c7a4"}, + {file = "llama_cpp_python-0.3.5.tar.gz", hash = "sha256:f5ce47499d53d3973e28ca5bdaf2dfe820163fa3fb67e3050f98e2e9b58d2cf6"}, ] [package.dependencies] @@ -3094,4 +3094,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.12,<4.0" -content-hash = "cfc4a4a6768c0fc2dfe628317ee1ab95da3a21e02bd5180b3fc0381aa8fab2ab" +content-hash = "e843018c33a2e3e4b1e8b2a5dab98db41b19fd783ca6618ec44fe58b0d7b8849" diff --git a/pyproject.toml b/pyproject.toml index c378ea68..09558288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ build = ">=1.0.0" wheel = ">=0.40.0" litellm = ">=1.52.11" pytest-asyncio = "0.25.2" -llama_cpp_python = ">=0.3.2" +llama_cpp_python = "==0.3.5" scikit-learn = ">=1.6.0" python-dotenv = ">=1.0.1" diff --git a/src/codegate/server.py b/src/codegate/server.py index b995fdd7..38cbe1c7 100644 --- a/src/codegate/server.py +++ b/src/codegate/server.py @@ -1,10 +1,12 @@ import traceback +from typing import AsyncGenerator import structlog -from fastapi import APIRouter, FastAPI, Request +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from starlette.middleware.errors import ServerErrorMiddleware +from httpx import AsyncClient, HTTPStatusError from codegate import __description__, __version__ from codegate.dashboard.dashboard import dashboard_router @@ -27,6 +29,23 @@ async def custom_error_handler(request, exc: Exception): logger.error(traceback.print_list(extracted_traceback[-3:])) return JSONResponse({"error": str(exc)}, status_code=500) +async def get_http_client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient() as client: + yield client + +async def fetch_latest_version(client: AsyncClient) -> str: + url = "https://api.github.com/repos/stacklok/codegate/releases/latest" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + } + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + return data.get("tag_name", "unknown") + except HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=str(e)) def init_app(pipeline_factory: PipelineFactory) -> FastAPI: """Create the FastAPI application.""" @@ -94,6 +113,26 @@ async def log_user_agent(request: Request, call_next): async def health_check(): return {"status": "healthy"} + @system_router.get("/version") + async def version_check(client: AsyncClient = Depends(get_http_client)): + try: + latest_version = await fetch_latest_version(client) + + # normalize the versions as github will return them with a 'v' prefix + current_version = __version__.lstrip('v') + latest_version_stripped = latest_version.lstrip('v') + + is_latest: bool = latest_version_stripped == current_version + + return { + "current_version": current_version, + "latest_version": latest_version_stripped, + "is_latest": is_latest, + } + except HTTPException as e: + return {"current_version": __version__, "latest_version": "unknown", "error": e.detail} + + app.include_router(system_router) app.include_router(dashboard_router) diff --git a/tests/test_server.py b/tests/test_server.py index b6770ae5..fc14b78a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -81,6 +81,21 @@ def test_health_check(test_client: TestClient) -> None: assert response.status_code == 200 assert response.json() == {"status": "healthy"} +def test_version_endpoint(test_client: TestClient) -> None: + """Test the version endpoint.""" + response = test_client.get("/version") + assert response.status_code == 200 + + response_data = response.json() + assert "current_version" in response_data + assert isinstance(response_data["current_version"], str) + + assert "latest_version" in response_data + assert isinstance(response_data["latest_version"], str) + + assert "is_latest" in response_data + assert isinstance(response_data["is_latest"], bool) + @patch("codegate.pipeline.secrets.manager.SecretsManager") @patch("codegate.server.ProviderRegistry") From 49b1f4ab6c5c1ef571eda9c61b823614e5a3e1ff Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 16 Jan 2025 14:51:10 +0000 Subject: [PATCH 2/6] chore: move version check into dashboard router, mock Github API call in tests --- src/codegate/dashboard/dashboard.py | 50 +++++++++++++++++++++++++++-- src/codegate/server.py | 42 +----------------------- tests/test_server.py | 19 ++++++----- 3 files changed, 58 insertions(+), 53 deletions(-) diff --git a/src/codegate/dashboard/dashboard.py b/src/codegate/dashboard/dashboard.py index 35d1efd5..eafb9740 100644 --- a/src/codegate/dashboard/dashboard.py +++ b/src/codegate/dashboard/dashboard.py @@ -1,10 +1,13 @@ import asyncio +from concurrent.futures import ThreadPoolExecutor import json from typing import AsyncGenerator, List, Optional +from httpx import AsyncClient, HTTPStatusError import structlog -from fastapi import APIRouter, Depends, FastAPI +from fastapi import APIRouter, Depends, FastAPI, HTTPException from fastapi.responses import StreamingResponse +from codegate import __version__ from codegate.dashboard.post_processing import ( parse_get_alert_conversation, @@ -18,13 +21,30 @@ dashboard_router = APIRouter(tags=["Dashboard"]) db_reader = None - def get_db_reader(): global db_reader if db_reader is None: db_reader = DbReader() return db_reader +def get_http_client() -> AsyncClient: + return AsyncClient() + +async def fetch_latest_version(client: AsyncClient) -> str: + url = "https://api.github.com/repos/stacklok/codegate/releases/latest" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + } + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + return data.get("tag_name", "unknown") + +def fetch_latest_version_sync(client: AsyncClient) -> str: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop.run_until_complete(fetch_latest_version(client)) @dashboard_router.get("/dashboard/messages") def get_messages(db_reader: DbReader = Depends(get_db_reader)) -> List[Conversation]: @@ -61,6 +81,32 @@ async def stream_sse(): """ return StreamingResponse(generate_sse_events(), media_type="text/event-stream") +@dashboard_router.get("/dashboard/version") +def version_check(client: AsyncClient = Depends(get_http_client)): + try: + with ThreadPoolExecutor() as executor: + latest_version = executor.submit(fetch_latest_version_sync, client).result() + + # normalize the versions as github will return them with a 'v' prefix + current_version = __version__.lstrip('v') + latest_version_stripped = latest_version.lstrip('v') + + is_latest: bool = latest_version_stripped == current_version + + return { + "current_version": current_version, + "latest_version": latest_version_stripped, + "is_latest": is_latest, + "error": None, + } + except HTTPException as e: + return { + "current_version": __version__, + "latest_version": "unknown", + "is_latest": None, + "error": e.detail + } + def generate_openapi(): # Create a temporary FastAPI app instance diff --git a/src/codegate/server.py b/src/codegate/server.py index 38cbe1c7..351d4851 100644 --- a/src/codegate/server.py +++ b/src/codegate/server.py @@ -2,11 +2,10 @@ from typing import AsyncGenerator import structlog -from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request +from fastapi import APIRouter, Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from starlette.middleware.errors import ServerErrorMiddleware -from httpx import AsyncClient, HTTPStatusError from codegate import __description__, __version__ from codegate.dashboard.dashboard import dashboard_router @@ -20,7 +19,6 @@ logger = structlog.get_logger("codegate") - async def custom_error_handler(request, exc: Exception): """This is a Middleware to handle exceptions and log them.""" # Capture the stack trace @@ -29,24 +27,6 @@ async def custom_error_handler(request, exc: Exception): logger.error(traceback.print_list(extracted_traceback[-3:])) return JSONResponse({"error": str(exc)}, status_code=500) -async def get_http_client() -> AsyncGenerator[AsyncClient, None]: - async with AsyncClient() as client: - yield client - -async def fetch_latest_version(client: AsyncClient) -> str: - url = "https://api.github.com/repos/stacklok/codegate/releases/latest" - headers = { - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28" - } - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - data = response.json() - return data.get("tag_name", "unknown") - except HTTPStatusError as e: - raise HTTPException(status_code=e.response.status_code, detail=str(e)) - def init_app(pipeline_factory: PipelineFactory) -> FastAPI: """Create the FastAPI application.""" app = FastAPI( @@ -113,26 +93,6 @@ async def log_user_agent(request: Request, call_next): async def health_check(): return {"status": "healthy"} - @system_router.get("/version") - async def version_check(client: AsyncClient = Depends(get_http_client)): - try: - latest_version = await fetch_latest_version(client) - - # normalize the versions as github will return them with a 'v' prefix - current_version = __version__.lstrip('v') - latest_version_stripped = latest_version.lstrip('v') - - is_latest: bool = latest_version_stripped == current_version - - return { - "current_version": current_version, - "latest_version": latest_version_stripped, - "is_latest": is_latest, - } - except HTTPException as e: - return {"current_version": __version__, "latest_version": "unknown", "error": e.detail} - - app.include_router(system_router) app.include_router(dashboard_router) diff --git a/tests/test_server.py b/tests/test_server.py index fc14b78a..7bf6ef47 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -81,21 +81,20 @@ def test_health_check(test_client: TestClient) -> None: assert response.status_code == 200 assert response.json() == {"status": "healthy"} -def test_version_endpoint(test_client: TestClient) -> None: +@pytest.mark.usefixtures("test_client") +@patch("codegate.dashboard.dashboard.fetch_latest_version", new_callable=AsyncMock) +def test_version_endpoint(mock_fetch_latest_version, test_client) -> None: """Test the version endpoint.""" - response = test_client.get("/version") + mock_fetch_latest_version.return_value = "foo" + + response = test_client.get("/dashboard/version") assert response.status_code == 200 response_data = response.json() - assert "current_version" in response_data - assert isinstance(response_data["current_version"], str) - - assert "latest_version" in response_data - assert isinstance(response_data["latest_version"], str) - - assert "is_latest" in response_data - assert isinstance(response_data["is_latest"], bool) + assert response_data["current_version"] == __version__.lstrip('v') + assert response_data["latest_version"] == "foo" + assert response_data["is_latest"] is False @patch("codegate.pipeline.secrets.manager.SecretsManager") @patch("codegate.server.ProviderRegistry") From bea209a6bf16e375720d81ffa3ba691a3020e3ea Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 16 Jan 2025 14:53:23 +0000 Subject: [PATCH 3/6] chore: revert unecessary changes to server.py --- src/codegate/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/codegate/server.py b/src/codegate/server.py index 351d4851..5a93c95a 100644 --- a/src/codegate/server.py +++ b/src/codegate/server.py @@ -1,8 +1,7 @@ import traceback -from typing import AsyncGenerator import structlog -from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi import APIRouter, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from starlette.middleware.errors import ServerErrorMiddleware @@ -19,6 +18,7 @@ logger = structlog.get_logger("codegate") + async def custom_error_handler(request, exc: Exception): """This is a Middleware to handle exceptions and log them.""" # Capture the stack trace @@ -27,6 +27,7 @@ async def custom_error_handler(request, exc: Exception): logger.error(traceback.print_list(extracted_traceback[-3:])) return JSONResponse({"error": str(exc)}, status_code=500) + def init_app(pipeline_factory: PipelineFactory) -> FastAPI: """Create the FastAPI application.""" app = FastAPI( @@ -96,4 +97,4 @@ async def health_check(): app.include_router(system_router) app.include_router(dashboard_router) - return app + return app \ No newline at end of file From 8f683f7759febfbb9756bc53af16216d84ba3b42 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 16 Jan 2025 16:01:39 +0000 Subject: [PATCH 4/6] fix(versions endpoint): add logging, handle broader range of exceptions, don't return raw error detail to client --- src/codegate/dashboard/dashboard.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/codegate/dashboard/dashboard.py b/src/codegate/dashboard/dashboard.py index eafb9740..79c9ea5f 100644 --- a/src/codegate/dashboard/dashboard.py +++ b/src/codegate/dashboard/dashboard.py @@ -100,12 +100,23 @@ def version_check(client: AsyncClient = Depends(get_http_client)): "error": None, } except HTTPException as e: + logger.error(f"HTTPException: {e.detail}") return { "current_version": __version__, "latest_version": "unknown", "is_latest": None, - "error": e.detail + "error": "Failed to fetch the latest version" } + except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + return { + "current_version": __version__, + "latest_version": "unknown", + "is_latest": None, + "error": "An unexpected error occurred" + } + finally: + client.aclose() def generate_openapi(): From 59ad0a7f7d201c66ef567e349b0d9fba9f719128 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 17 Jan 2025 09:10:09 +0000 Subject: [PATCH 5/6] refactor(version endpoint): use requests instead of httpx + ThreadPoolExecutor --- src/codegate/dashboard/dashboard.py | 30 +++++++++-------------------- tests/test_server.py | 8 +++----- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/codegate/dashboard/dashboard.py b/src/codegate/dashboard/dashboard.py index 79c9ea5f..9468b98c 100644 --- a/src/codegate/dashboard/dashboard.py +++ b/src/codegate/dashboard/dashboard.py @@ -1,11 +1,10 @@ import asyncio -from concurrent.futures import ThreadPoolExecutor import json from typing import AsyncGenerator, List, Optional -from httpx import AsyncClient, HTTPStatusError +import requests import structlog -from fastapi import APIRouter, Depends, FastAPI, HTTPException +from fastapi import APIRouter, Depends, FastAPI from fastapi.responses import StreamingResponse from codegate import __version__ @@ -27,25 +26,17 @@ def get_db_reader(): db_reader = DbReader() return db_reader -def get_http_client() -> AsyncClient: - return AsyncClient() - -async def fetch_latest_version(client: AsyncClient) -> str: +def fetch_latest_version() -> str: url = "https://api.github.com/repos/stacklok/codegate/releases/latest" headers = { "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } - response = await client.get(url, headers=headers) + response = requests.get(url, headers=headers) response.raise_for_status() data = response.json() return data.get("tag_name", "unknown") -def fetch_latest_version_sync(client: AsyncClient) -> str: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop.run_until_complete(fetch_latest_version(client)) - @dashboard_router.get("/dashboard/messages") def get_messages(db_reader: DbReader = Depends(get_db_reader)) -> List[Conversation]: """ @@ -82,10 +73,9 @@ async def stream_sse(): return StreamingResponse(generate_sse_events(), media_type="text/event-stream") @dashboard_router.get("/dashboard/version") -def version_check(client: AsyncClient = Depends(get_http_client)): +def version_check(): try: - with ThreadPoolExecutor() as executor: - latest_version = executor.submit(fetch_latest_version_sync, client).result() + latest_version = fetch_latest_version() # normalize the versions as github will return them with a 'v' prefix current_version = __version__.lstrip('v') @@ -99,13 +89,13 @@ def version_check(client: AsyncClient = Depends(get_http_client)): "is_latest": is_latest, "error": None, } - except HTTPException as e: - logger.error(f"HTTPException: {e.detail}") + except requests.RequestException as e: + logger.error(f"RequestException: {str(e)}") return { "current_version": __version__, "latest_version": "unknown", "is_latest": None, - "error": "Failed to fetch the latest version" + "error": "An error occurred while fetching the latest version" } except Exception as e: logger.error(f"Unexpected error: {str(e)}") @@ -115,8 +105,6 @@ def version_check(client: AsyncClient = Depends(get_http_client)): "is_latest": None, "error": "An unexpected error occurred" } - finally: - client.aclose() def generate_openapi(): diff --git a/tests/test_server.py b/tests/test_server.py index 7bf6ef47..ad3b3541 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -81,12 +81,9 @@ def test_health_check(test_client: TestClient) -> None: assert response.status_code == 200 assert response.json() == {"status": "healthy"} -@pytest.mark.usefixtures("test_client") -@patch("codegate.dashboard.dashboard.fetch_latest_version", new_callable=AsyncMock) -def test_version_endpoint(mock_fetch_latest_version, test_client) -> None: +@patch("codegate.dashboard.dashboard.fetch_latest_version", return_value="foo") +def test_version_endpoint(mock_fetch_latest_version, test_client: TestClient) -> None: """Test the version endpoint.""" - mock_fetch_latest_version.return_value = "foo" - response = test_client.get("/dashboard/version") assert response.status_code == 200 @@ -94,6 +91,7 @@ def test_version_endpoint(mock_fetch_latest_version, test_client) -> None: assert response_data["current_version"] == __version__.lstrip('v') assert response_data["latest_version"] == "foo" + assert isinstance(response_data["is_latest"], bool) assert response_data["is_latest"] is False @patch("codegate.pipeline.secrets.manager.SecretsManager") From a261db74b1092885a394deb2b6bfd38b41b49018 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 17 Jan 2025 09:12:35 +0000 Subject: [PATCH 6/6] fix(version endpoint): add timeout to request to github API --- src/codegate/dashboard/dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codegate/dashboard/dashboard.py b/src/codegate/dashboard/dashboard.py index 9468b98c..89e15314 100644 --- a/src/codegate/dashboard/dashboard.py +++ b/src/codegate/dashboard/dashboard.py @@ -32,7 +32,7 @@ def fetch_latest_version() -> str: "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } - response = requests.get(url, headers=headers) + response = requests.get(url, headers=headers, timeout=5) response.raise_for_status() data = response.json() return data.get("tag_name", "unknown")