Skip to content

Commit 7886f6f

Browse files
authored
Run update call on recurring schedule (#1268)
Call to the update service every four hours, and use the BE origin type. Print a warning level log message if an update is available. This PR also introduces some refactoring from the previous PR: 1) Refactor the update client to be a singleton. 2) Set the instance ID once on application load. 3) Get rid of the feature flag - using the new service is now default.
1 parent 17731aa commit 7886f6f

File tree

9 files changed

+98
-62
lines changed

9 files changed

+98
-62
lines changed

src/codegate/api/v1.py

+11-8
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
from typing import List, Optional
22
from uuid import UUID
33

4+
import cachetools.func
45
import requests
56
import structlog
67
from fastapi import APIRouter, Depends, HTTPException, Query, Response
78
from fastapi.responses import StreamingResponse
89
from fastapi.routing import APIRoute
910
from pydantic import BaseModel, ValidationError
1011

11-
from codegate.config import API_DEFAULT_PAGE_SIZE, API_MAX_PAGE_SIZE
1212
import codegate.muxing.models as mux_models
13-
from codegate import Config, __version__
13+
from codegate import __version__
1414
from codegate.api import v1_models, v1_processing
15+
from codegate.config import API_DEFAULT_PAGE_SIZE, API_MAX_PAGE_SIZE
1516
from codegate.db.connection import AlreadyExistsError, DbReader
1617
from codegate.db.models import AlertSeverity, AlertTriggerType, Persona, WorkspaceWithModel
1718
from codegate.muxing.persona import (
@@ -20,7 +21,7 @@
2021
PersonaSimilarDescriptionError,
2122
)
2223
from codegate.providers import crud as provendcrud
23-
from codegate.updates.client import Origin, UpdateClient
24+
from codegate.updates.client import Origin, get_update_client_singleton
2425
from codegate.workspaces import crud
2526

2627
logger = structlog.get_logger("codegate")
@@ -32,7 +33,6 @@
3233

3334
# This is a singleton object
3435
dbreader = DbReader()
35-
update_client = UpdateClient(Config.get_config().update_service_url, __version__, dbreader)
3636

3737

3838
def uniq_name(route: APIRoute):
@@ -728,10 +728,7 @@ async def stream_sse():
728728
@v1.get("/version", tags=["Dashboard"], generate_unique_id_function=uniq_name)
729729
async def version_check():
730730
try:
731-
if Config.get_config().use_update_service:
732-
latest_version = await update_client.get_latest_version(Origin.FrontEnd)
733-
else:
734-
latest_version = v1_processing.fetch_latest_version()
731+
latest_version = _get_latest_version()
735732
# normalize the versions as github will return them with a 'v' prefix
736733
current_version = __version__.lstrip("v")
737734
latest_version_stripped = latest_version.lstrip("v")
@@ -885,3 +882,9 @@ async def delete_persona(persona_name: str):
885882
except Exception:
886883
logger.exception("Error while deleting persona")
887884
raise HTTPException(status_code=500, detail="Internal server error")
885+
886+
887+
@cachetools.func.ttl_cache(maxsize=128, ttl=20 * 60)
888+
def _get_latest_version():
889+
update_client = get_update_client_singleton()
890+
return update_client.get_latest_version(Origin.FrontEnd)

src/codegate/api/v1_processing.py

-12
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
from collections import defaultdict
44
from typing import AsyncGenerator, Dict, List, Optional, Tuple
55

6-
import cachetools.func
76
import regex as re
8-
import requests
97
import structlog
108

119
from codegate.api import v1_models
@@ -34,16 +32,6 @@
3432
]
3533

3634

37-
@cachetools.func.ttl_cache(maxsize=128, ttl=20 * 60)
38-
def fetch_latest_version() -> str:
39-
url = "https://api.github.com/repos/stacklok/codegate/releases/latest"
40-
headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
41-
response = requests.get(url, headers=headers, timeout=5)
42-
response.raise_for_status()
43-
data = response.json()
44-
return data.get("tag_name", "unknown")
45-
46-
4735
async def generate_sse_events() -> AsyncGenerator[str, None]:
4836
"""
4937
SSE generator from queue

src/codegate/cli.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from uvicorn.config import Config as UvicornConfig
1212
from uvicorn.server import Server
1313

14+
import codegate
1415
from codegate.ca.codegate_ca import CertificateAuthority
1516
from codegate.codegate_logging import LogFormat, LogLevel, setup_logging
1617
from codegate.config import Config, ConfigurationError
@@ -25,6 +26,8 @@
2526
from codegate.providers.copilot.provider import CopilotProvider
2627
from codegate.server import init_app
2728
from codegate.storage.utils import restore_storage_backup
29+
from codegate.updates.client import init_update_client_singleton
30+
from codegate.updates.scheduled import ScheduledUpdateChecker
2831
from codegate.workspaces import crud as wscrud
2932

3033

@@ -322,9 +325,17 @@ def serve( # noqa: C901
322325
logger = structlog.get_logger("codegate").bind(origin="cli")
323326

324327
init_db_sync(cfg.db_path)
325-
init_instance(cfg.db_path)
328+
instance_id = init_instance(cfg.db_path)
326329
init_session_if_not_exists(cfg.db_path)
327330

331+
# Initialize the update checking logic.
332+
update_client = init_update_client_singleton(
333+
cfg.update_service_url, codegate.__version__, instance_id
334+
)
335+
update_checker = ScheduledUpdateChecker(update_client)
336+
update_checker.daemon = True
337+
update_checker.start()
338+
328339
# Check certificates and create CA if necessary
329340
logger.info("Checking certificates and creating CA if needed")
330341
ca = CertificateAuthority.get_instance()

src/codegate/clients/__init__.py

Whitespace-only changes.

src/codegate/config.py

-10
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,6 @@ def from_env(cls) -> "Config":
220220
config.db_path = os.environ["CODEGATE_DB_PATH"]
221221
if "CODEGATE_VEC_DB_PATH" in os.environ:
222222
config.vec_db_path = os.environ["CODEGATE_VEC_DB_PATH"]
223-
if "CODEGATE_USE_UPDATE_SERVICE" in os.environ:
224-
config.use_update_service = cls.__bool_from_string(
225-
os.environ["CODEGATE_USE_UPDATE_SERVICE"]
226-
)
227223
if "CODEGATE_UPDATE_SERVICE_URL" in os.environ:
228224
config.update_service_url = os.environ["CODEGATE_UPDATE_SERVICE_URL"]
229225

@@ -258,7 +254,6 @@ def load(
258254
force_certs: Optional[bool] = None,
259255
db_path: Optional[str] = None,
260256
vec_db_path: Optional[str] = None,
261-
use_update_service: Optional[bool] = None,
262257
update_service_url: Optional[str] = None,
263258
) -> "Config":
264259
"""Load configuration with priority resolution.
@@ -288,7 +283,6 @@ def load(
288283
force_certs: Optional flag to force certificate generation
289284
db_path: Optional path to the main SQLite database file
290285
vec_db_path: Optional path to the vector SQLite database file
291-
use_update_service: Optional flag to enable the update service
292286
update_service_url: Optional URL for the update service
293287
294288
Returns:
@@ -342,8 +336,6 @@ def load(
342336
config.db_path = env_config.db_path
343337
if "CODEGATE_VEC_DB_PATH" in os.environ:
344338
config.vec_db_path = env_config.vec_db_path
345-
if "CODEGATE_USE_UPDATE_SERVICE" in os.environ:
346-
config.use_update_service = env_config.use_update_service
347339
if "CODEGATE_UPDATE_SERVICE_URL" in os.environ:
348340
config.update_service_url = env_config.update_service_url
349341

@@ -386,8 +378,6 @@ def load(
386378
config.vec_db_path = vec_db_path
387379
if force_certs is not None:
388380
config.force_certs = force_certs
389-
if use_update_service is not None:
390-
config.use_update_service = use_update_service
391381
if update_service_url is not None:
392382
config.update_service_url = update_service_url
393383

src/codegate/db/connection.py

+10-5
Original file line numberDiff line numberDiff line change
@@ -600,10 +600,11 @@ async def delete_persona(self, persona_id: str) -> None:
600600
conditions = {"id": persona_id}
601601
await self._execute_with_no_return(sql, conditions)
602602

603-
async def init_instance(self) -> None:
603+
async def init_instance(self) -> str:
604604
"""
605605
Initializes instance details in the database.
606606
"""
607+
instance_id = str(uuid.uuid4())
607608
sql = text(
608609
"""
609610
INSERT INTO instance (id, created_at)
@@ -613,13 +614,14 @@ async def init_instance(self) -> None:
613614

614615
try:
615616
instance = Instance(
616-
id=str(uuid.uuid4()),
617+
id=instance_id,
617618
created_at=datetime.datetime.now(datetime.timezone.utc),
618619
)
619620
await self._execute_with_no_return(sql, instance.model_dump())
620621
except IntegrityError as e:
621622
logger.debug(f"Exception type: {type(e)}")
622623
raise AlreadyExistsError("Instance already initialized.")
624+
return instance_id
623625

624626

625627
class DbReader(DbCodeGate):
@@ -1326,18 +1328,21 @@ def init_session_if_not_exists(db_path: Optional[str] = None):
13261328
logger.info("Session in DB initialized successfully.")
13271329

13281330

1329-
def init_instance(db_path: Optional[str] = None):
1331+
def init_instance(db_path: Optional[str] = None) -> str:
13301332
db_reader = DbReader(db_path)
13311333
instance = asyncio.run(db_reader.get_instance())
13321334
# Initialize instance if not already initialized.
13331335
if not instance:
13341336
db_recorder = DbRecorder(db_path)
13351337
try:
1336-
asyncio.run(db_recorder.init_instance())
1338+
instance_id = asyncio.run(db_recorder.init_instance())
1339+
logger.info("Instance initialized successfully.")
1340+
return instance_id
13371341
except Exception as e:
13381342
logger.error(f"Failed to initialize instance in DB: {e}")
13391343
raise
1340-
logger.info("Instance initialized successfully.")
1344+
else:
1345+
return instance[0].id
13411346

13421347

13431348
if __name__ == "__main__":

src/codegate/updates/client.py

+23-20
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,32 @@
11
from enum import Enum
22

3-
import cachetools.func
43
import requests
54
import structlog
65

7-
from codegate.db.connection import DbReader
8-
96
logger = structlog.get_logger("codegate")
107

118

9+
__update_client_singleton = None
10+
11+
1212
# Enum representing whether the request is coming from the front-end or the back-end.
1313
class Origin(Enum):
1414
FrontEnd = "FE"
1515
BackEnd = "BE"
1616

1717

1818
class UpdateClient:
19-
def __init__(self, update_url: str, current_version: str, db_reader: DbReader):
19+
def __init__(self, update_url: str, current_version: str, instance_id: str):
2020
self.__update_url = update_url
2121
self.__current_version = current_version
22-
self.__db_reader = db_reader
23-
self.__instance_id = None
22+
self.__instance_id = instance_id
2423

25-
async def get_latest_version(self, origin: Origin) -> str:
24+
def get_latest_version(self, origin: Origin) -> str:
2625
"""
2726
Retrieves the latest version of CodeGate from updates.codegate.ai
2827
"""
29-
logger.info(f"Fetching latest version from {self.__update_url}")
30-
instance_id = await self.__get_instance_id()
31-
return self.__fetch_latest_version(instance_id, origin)
32-
33-
@cachetools.func.ttl_cache(maxsize=128, ttl=20 * 60)
34-
def __fetch_latest_version(self, instance_id: str, origin: Origin) -> str:
3528
headers = {
36-
"X-Instance-ID": instance_id,
29+
"X-Instance-ID": self.__instance_id,
3730
"User-Agent": f"codegate/{self.__current_version} {origin.value}",
3831
}
3932

@@ -46,9 +39,19 @@ def __fetch_latest_version(self, instance_id: str, origin: Origin) -> str:
4639
logger.error(f"Error fetching latest version from f{self.__update_url}: {e}")
4740
return "unknown"
4841

49-
# Lazy load the instance ID from the DB.
50-
async def __get_instance_id(self):
51-
if self.__instance_id is None:
52-
instance_data = await self.__db_reader.get_instance()
53-
self.__instance_id = instance_data[0].id
54-
return self.__instance_id
42+
43+
# Use a singleton since we do not have a good way of doing dependency injection
44+
# with the API endpoints.
45+
def init_update_client_singleton(
46+
update_url: str, current_version: str, instance_id: str
47+
) -> UpdateClient:
48+
global __update_client_singleton
49+
__update_client_singleton = UpdateClient(update_url, current_version, instance_id)
50+
return __update_client_singleton
51+
52+
53+
def get_update_client_singleton() -> UpdateClient:
54+
global __update_client_singleton
55+
if __update_client_singleton is None:
56+
raise ValueError("UpdateClient singleton not initialized")
57+
return __update_client_singleton

src/codegate/updates/scheduled.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import threading
2+
import time
3+
4+
import structlog
5+
6+
import codegate
7+
from codegate.updates.client import Origin, UpdateClient
8+
9+
logger = structlog.get_logger("codegate")
10+
11+
12+
class ScheduledUpdateChecker(threading.Thread):
13+
"""
14+
ScheduledUpdateChecker calls the UpdateClient on a recurring interval.
15+
This is implemented as a separate thread to avoid blocking the main thread.
16+
A dedicated scheduling library could have been used, but the requirements
17+
are trivial, and a simple hand-rolled solution is sufficient.
18+
"""
19+
20+
def __init__(self, client: UpdateClient, interval_seconds: int = 14400): # 4 hours in seconds
21+
super().__init__()
22+
self.__client = client
23+
self.__interval_seconds = interval_seconds
24+
25+
def run(self):
26+
"""
27+
Overrides the `run` method of threading.Thread.
28+
"""
29+
while True:
30+
logger.info("Checking for CodeGate updates")
31+
latest = self.__client.get_latest_version(Origin.BackEnd)
32+
if latest != codegate.__version__:
33+
logger.warning(f"A new version of CodeGate is available: {latest}")
34+
time.sleep(self.__interval_seconds)

tests/test_server.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,20 @@ def test_health_check(test_client: TestClient) -> None:
7575
assert response.json() == {"status": "healthy"}
7676

7777

78-
@patch("codegate.api.v1_processing.fetch_latest_version", return_value="foo")
79-
def test_version_endpoint(mock_fetch_latest_version, test_client: TestClient) -> None:
78+
@patch("codegate.api.v1._get_latest_version")
79+
def test_version_endpoint(mock_get_latest_version, test_client: TestClient) -> None:
8080
"""Test the version endpoint."""
81+
# Mock the __get_latest_version function to return a specific version
82+
mock_get_latest_version.return_value = "v1.2.3"
83+
8184
response = test_client.get("/api/v1/version")
8285
assert response.status_code == 200
8386

8487
response_data = response.json()
85-
86-
assert response_data["current_version"] == __version__.lstrip("v")
87-
assert response_data["latest_version"] == "foo"
88-
assert isinstance(response_data["is_latest"], bool)
88+
assert response_data["current_version"] == "0.1.7"
89+
assert response_data["latest_version"] == "1.2.3"
8990
assert response_data["is_latest"] is False
91+
assert response_data["error"] is None
9092

9193

9294
@patch("codegate.pipeline.sensitive_data.manager.SensitiveDataManager")

0 commit comments

Comments
 (0)