Skip to content

Commit 424639e

Browse files
(feat) add resolver startup bootstrap
1 parent b550207 commit 424639e

3 files changed

Lines changed: 119 additions & 22 deletions

File tree

main.py

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111

1212
import asyncio
1313
import contextlib
14+
import os
1415
import logging
1516
import sys
1617
import time
1718
from collections.abc import AsyncIterator
1819
from contextlib import asynccontextmanager
1920

2021
import httpx
21-
import uvicorn
2222
from fastapi import FastAPI
2323
from fastapi.responses import JSONResponse
2424
from fastapi.routing import APIRoute
@@ -30,6 +30,7 @@
3030
from database import dispose_database, init_database, init_db
3131
from datasources.exceptions import BackendStartupTimeout
3232
from middleware.openapi import install_custom_openapi
33+
from middleware.runtime_ssl import RuntimeSSLOptions, run_uvicorn
3334
from services.rca_job_service import rca_job_service
3435
from services.security_service import InternalAuthMiddleware
3536

@@ -72,6 +73,32 @@ def _generate_operation_id(route: APIRoute) -> str:
7273
return route.name
7374

7475

76+
async def _bootstrap_database() -> None:
77+
timeout_seconds = float(os.getenv("DATABASE_STARTUP_TIMEOUT", "180"))
78+
retry_delay_seconds = float(os.getenv("DATABASE_STARTUP_RETRY_DELAY", "2"))
79+
deadline = time.monotonic() + timeout_seconds
80+
attempt = 0
81+
82+
while True:
83+
attempt += 1
84+
try:
85+
if settings.database_url:
86+
init_database(settings.database_url)
87+
init_db()
88+
log.info("Resolver database initialization completed")
89+
return
90+
except Exception as exc: # pylint: disable=broad-exception-caught
91+
if time.monotonic() >= deadline:
92+
raise RuntimeError("Resolver database did not become ready before startup timeout") from exc
93+
log.warning(
94+
"Resolver database not ready (attempt %d, retrying in %.1fs): %s",
95+
attempt,
96+
retry_delay_seconds,
97+
exc,
98+
)
99+
await asyncio.sleep(retry_delay_seconds)
100+
101+
75102
class ResolverReadyResponse(BaseModel):
76103
ready: bool = Field(description="Whether resolver dependencies are currently ready.")
77104
backends: dict[str, str] = Field(
@@ -175,8 +202,7 @@ async def _wait_for_all_bg(data_settings: Settings, tenant_id: str) -> None:
175202
@asynccontextmanager
176203
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
177204
if settings.database_url:
178-
init_database(settings.database_url)
179-
init_db()
205+
await _bootstrap_database()
180206
await rca_job_service.startup_recovery()
181207

182208
tenant_id = settings.default_tenant_id
@@ -245,21 +271,11 @@ async def ready() -> JSONResponse:
245271

246272

247273
if __name__ == "__main__":
248-
if settings.ssl_enabled:
249-
uvicorn.run(
250-
"main:app",
251-
host=settings.host,
252-
port=settings.port,
253-
log_level="info",
254-
access_log=True,
255-
ssl_certfile=settings.ssl_certfile,
256-
ssl_keyfile=settings.ssl_keyfile,
257-
)
258-
else:
259-
uvicorn.run(
260-
"main:app",
261-
host=settings.host,
262-
port=settings.port,
263-
log_level="info",
264-
access_log=True,
265-
)
274+
run_uvicorn(
275+
app,
276+
host=settings.host,
277+
port=settings.port,
278+
log_level="info",
279+
access_log=True,
280+
ssl_options=RuntimeSSLOptions.from_settings(settings),
281+
)

middleware/runtime_ssl.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Runtime SSL helpers for the Resolver service.
3+
4+
Copyright (c) 2026 Stefan Kumarasinghe.
5+
6+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
7+
License. You may obtain a copy of the License at
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from dataclasses import dataclass
14+
from typing import Any
15+
16+
17+
@dataclass(frozen=True)
18+
class RuntimeSSLOptions:
19+
ssl_certfile: str
20+
ssl_keyfile: str
21+
22+
@classmethod
23+
def from_settings(cls, settings: object) -> RuntimeSSLOptions | None:
24+
if not getattr(settings, "ssl_enabled"):
25+
return None
26+
27+
certfile = str(getattr(settings, "ssl_certfile", "")).strip()
28+
keyfile = str(getattr(settings, "ssl_keyfile", "")).strip()
29+
if not certfile or not keyfile:
30+
raise ValueError(
31+
"RESOLVER_SSL_ENABLED=true requires RESOLVER_SSL_CERTFILE and RESOLVER_SSL_KEYFILE to be set"
32+
)
33+
34+
return cls(ssl_certfile=certfile, ssl_keyfile=keyfile)
35+
36+
def to_uvicorn_kwargs(self) -> dict[str, str]:
37+
return {
38+
"ssl_certfile": self.ssl_certfile,
39+
"ssl_keyfile": self.ssl_keyfile,
40+
}
41+
42+
43+
def run_uvicorn(app: Any, *, ssl_options: RuntimeSSLOptions | None = None, **kwargs: Any) -> None:
44+
if ssl_options is not None:
45+
kwargs.update(ssl_options.to_uvicorn_kwargs())
46+
47+
import uvicorn # pylint: disable=import-outside-toplevel
48+
49+
uvicorn.run(app=app, **kwargs)

tests/test_api_route_surface_edges.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from api.routes import metrics as metrics_route
2727
from api.routes import traces as traces_route
2828
from custom_types import json as json_types
29+
from middleware.runtime_ssl import RuntimeSSLOptions
2930

3031

3132
class DemoModel(NpModel):
@@ -491,8 +492,39 @@ def test_dunder_main_runs_uvicorn_with_ssl(monkeypatch):
491492
)
492493
runpy.run_module("main", run_name="__main__")
493494

494-
assert captured["app"] == "main:app"
495+
assert captured["app"].title == "Resolver Analysis Engine"
495496
assert captured["host"] == "0.0.0.0"
496497
assert captured["port"] == 9443
497498
assert captured["ssl_certfile"] == "/tmp/cert.pem"
498499
assert captured["ssl_keyfile"] == "/tmp/key.pem"
500+
501+
502+
def test_dunder_main_runs_uvicorn_without_ssl(monkeypatch):
503+
captured = {}
504+
505+
monkeypatch.delenv("RESOLVER_SSL_ENABLED", raising=False)
506+
monkeypatch.delenv("RESOLVER_SSL_CERTFILE", raising=False)
507+
monkeypatch.delenv("RESOLVER_SSL_KEYFILE", raising=False)
508+
monkeypatch.setenv("RESOLVER_HOST", "127.0.0.1")
509+
monkeypatch.setenv("RESOLVER_PORT", "4322")
510+
import config as config_module
511+
512+
importlib.reload(config_module)
513+
monkeypatch.setitem(
514+
sys.modules, "uvicorn", types.SimpleNamespace(run=lambda app, **kwargs: captured.update({"app": app, **kwargs}))
515+
)
516+
runpy.run_module("main", run_name="__main__")
517+
518+
assert captured["app"].title == "Resolver Analysis Engine"
519+
assert captured["host"] == "127.0.0.1"
520+
assert captured["port"] == 4322
521+
assert "ssl_certfile" not in captured
522+
assert "ssl_keyfile" not in captured
523+
524+
525+
def test_runtime_ssl_options_requires_paths():
526+
with pytest.raises(
527+
ValueError,
528+
match="RESOLVER_SSL_ENABLED=true requires RESOLVER_SSL_CERTFILE and RESOLVER_SSL_KEYFILE to be set",
529+
):
530+
RuntimeSSLOptions.from_settings(types.SimpleNamespace(ssl_enabled=True, ssl_certfile="", ssl_keyfile=""))

0 commit comments

Comments
 (0)