Refactor periodic tasks to use injected scheduler resources
This commit is contained in:
@@ -6,7 +6,8 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Issues
|
## Completed Issues
|
||||||
|
|
||||||
|
|
||||||
1. Move runtime application state out of `app.state`
|
1. Move runtime application state out of `app.state`
|
||||||
- Goal: Remove process-local mutable business state from FastAPI application state and centralise it in a cluster-safe, testable runtime state abstraction.
|
- Goal: Remove process-local mutable business state from FastAPI application state and centralise it in a cluster-safe, testable runtime state abstraction.
|
||||||
@@ -63,6 +64,8 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
- Issue: `history_sync`, `geo_re_resolve`, `geo_cache_flush`, and `blocklist_import` read shared resources directly from `app.state` and register jobs against `app.state.scheduler`, which couples task implementation to FastAPI internals.
|
- Issue: `history_sync`, `geo_re_resolve`, `geo_cache_flush`, and `blocklist_import` read shared resources directly from `app.state` and register jobs against `app.state.scheduler`, which couples task implementation to FastAPI internals.
|
||||||
- Propose: Introduce a scheduler/task bootstrap abstraction that accepts injected settings, database providers, HTTP sessions, and scheduler handles, and move task resource access out of the `app.state` internals.
|
- Propose: Introduce a scheduler/task bootstrap abstraction that accepts injected settings, database providers, HTTP sessions, and scheduler handles, and move task resource access out of the `app.state` internals.
|
||||||
- Test: Add unit tests for task registration and execution with fake resource providers and a mock scheduler, without needing a real FastAPI instance.
|
- Test: Add unit tests for task registration and execution with fake resource providers and a mock scheduler, without needing a real FastAPI instance.
|
||||||
|
- Status: completed
|
||||||
|
- Completed: Refactored background task modules to accept explicit scheduler job resources, removed direct `app.state` dependency from task execution callbacks, and updated unit tests to verify registration with injected settings, http session, and runtime state.
|
||||||
|
|
||||||
9. Remove brittle scheduler bootstrap logic from blocklist job registration
|
9. Remove brittle scheduler bootstrap logic from blocklist job registration
|
||||||
- Goal: Ensure job registration is deterministic and compatible with both synchronous and asynchronous startup contexts.
|
- Goal: Ensure job registration is deterministic and compatible with both synchronous and asynchronous startup contexts.
|
||||||
@@ -118,6 +121,6 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
- Issue: `startup_shared_resources` creates shared resources like `aiohttp.ClientSession` and geo cache initialization from the initial environment-loaded settings, then later applies persisted runtime overrides to `app.state.settings`, producing a fragile startup ordering.
|
- Issue: `startup_shared_resources` creates shared resources like `aiohttp.ClientSession` and geo cache initialization from the initial environment-loaded settings, then later applies persisted runtime overrides to `app.state.settings`, producing a fragile startup ordering.
|
||||||
- Propose: Split startup into phases that first resolve bootstrap and runtime persisted configuration, then construct shared resources and register scheduled jobs using those effective settings.
|
- Propose: Split startup into phases that first resolve bootstrap and runtime persisted configuration, then construct shared resources and register scheduled jobs using those effective settings.
|
||||||
- Test: Add startup tests asserting that when persisted runtime settings differ from bootstrap settings, the final initialized resources are built from the resolved effective settings, not the original bootstrap values.
|
- Test: Add startup tests asserting that when persisted runtime settings differ from bootstrap settings, the final initialized resources are built from the resolved effective settings, not the original bootstrap values.
|
||||||
- Status: completed
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from app.utils.runtime_state import get_effective_settings
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -34,25 +36,19 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
|||||||
JOB_ID: str = "blocklist_import"
|
JOB_ID: str = "blocklist_import"
|
||||||
|
|
||||||
|
|
||||||
async def _get_db(app: Any) -> tuple[aiosqlite.Connection, bool]:
|
async def _get_db(settings: "Settings") -> tuple[aiosqlite.Connection, bool]:
|
||||||
settings = get_effective_settings(app)
|
|
||||||
db = await open_db(settings.database_path)
|
db = await open_db(settings.database_path)
|
||||||
return db, True
|
return db, True
|
||||||
|
|
||||||
|
|
||||||
async def _run_import(app: Any) -> None:
|
async def _run_import_with_resources(settings: "Settings", http_session: "ClientSession") -> None:
|
||||||
"""APScheduler callback that imports all enabled blocklist sources.
|
"""APScheduler callback that imports all enabled blocklist sources.
|
||||||
|
|
||||||
Reads shared resources from ``app.state`` and delegates to
|
|
||||||
:func:`~app.services.blocklist_service.import_all`.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: The :class:`fastapi.FastAPI` application instance passed via
|
settings: The resolved application settings used for database access.
|
||||||
APScheduler ``kwargs``.
|
http_session: The shared aiohttp session used for blocklist downloads.
|
||||||
"""
|
"""
|
||||||
db, close_db = await _get_db(app)
|
db, close_db = await _get_db(settings)
|
||||||
settings = get_effective_settings(app)
|
|
||||||
http_session = app.state.http_session
|
|
||||||
socket_path: str = settings.fail2ban_socket
|
socket_path: str = settings.fail2ban_socket
|
||||||
|
|
||||||
log.info("blocklist_import_starting")
|
log.info("blocklist_import_starting")
|
||||||
@@ -75,6 +71,10 @@ async def _run_import(app: Any) -> None:
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_import(app: FastAPI) -> None:
|
||||||
|
await _run_import_with_resources(get_effective_settings(app), app.state.http_session)
|
||||||
|
|
||||||
|
|
||||||
async def register(app: FastAPI) -> None:
|
async def register(app: FastAPI) -> None:
|
||||||
"""Add (or replace) the blocklist import job in the application scheduler.
|
"""Add (or replace) the blocklist import job in the application scheduler.
|
||||||
|
|
||||||
@@ -88,7 +88,8 @@ async def register(app: FastAPI) -> None:
|
|||||||
app: The :class:`fastapi.FastAPI` application instance whose
|
app: The :class:`fastapi.FastAPI` application instance whose
|
||||||
``app.state.scheduler`` will receive the job.
|
``app.state.scheduler`` will receive the job.
|
||||||
"""
|
"""
|
||||||
db, close_db = await _get_db(app)
|
settings = get_effective_settings(app)
|
||||||
|
db, close_db = await _get_db(settings)
|
||||||
try:
|
try:
|
||||||
config = await blocklist_service.get_schedule(db)
|
config = await blocklist_service.get_schedule(db)
|
||||||
finally:
|
finally:
|
||||||
@@ -110,7 +111,8 @@ def reschedule(app: FastAPI) -> None:
|
|||||||
import asyncio # noqa: PLC0415
|
import asyncio # noqa: PLC0415
|
||||||
|
|
||||||
async def _do_reschedule() -> None:
|
async def _do_reschedule() -> None:
|
||||||
db, close_db = await _get_db(app)
|
settings = get_effective_settings(app)
|
||||||
|
db, close_db = await _get_db(settings)
|
||||||
try:
|
try:
|
||||||
config = await blocklist_service.get_schedule(db)
|
config = await blocklist_service.get_schedule(db)
|
||||||
finally:
|
finally:
|
||||||
@@ -130,7 +132,10 @@ def _apply_schedule(app: FastAPI, config: Any) -> None:
|
|||||||
"""
|
"""
|
||||||
scheduler = app.state.scheduler
|
scheduler = app.state.scheduler
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {"app": app}
|
kwargs: dict[str, Any] = {
|
||||||
|
"settings": get_effective_settings(app),
|
||||||
|
"http_session": app.state.http_session,
|
||||||
|
}
|
||||||
trigger_type: str
|
trigger_type: str
|
||||||
trigger_kwargs: dict[str, Any]
|
trigger_kwargs: dict[str, Any]
|
||||||
|
|
||||||
@@ -156,7 +161,7 @@ def _apply_schedule(app: FastAPI, config: Any) -> None:
|
|||||||
scheduler.remove_job(JOB_ID)
|
scheduler.remove_job(JOB_ID)
|
||||||
|
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_import,
|
_run_import_with_resources,
|
||||||
trigger=trigger_type,
|
trigger=trigger_type,
|
||||||
id=JOB_ID,
|
id=JOB_ID,
|
||||||
kwargs=kwargs,
|
kwargs=kwargs,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.utils.runtime_state import get_effective_settings
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
from app.config import Settings
|
||||||
from app.services import geo_service
|
from app.services import geo_service
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -34,23 +35,18 @@ GEO_FLUSH_INTERVAL: int = 60
|
|||||||
JOB_ID: str = "geo_cache_flush"
|
JOB_ID: str = "geo_cache_flush"
|
||||||
|
|
||||||
|
|
||||||
async def _get_db(app: Any) -> tuple[aiosqlite.Connection, bool]:
|
async def _get_db(settings: "Settings") -> tuple[aiosqlite.Connection, bool]:
|
||||||
settings = get_effective_settings(app)
|
|
||||||
db = await open_db(settings.database_path)
|
db = await open_db(settings.database_path)
|
||||||
return db, True
|
return db, True
|
||||||
|
|
||||||
|
|
||||||
async def _run_flush(app: Any) -> None:
|
async def _run_flush_with_settings(settings: "Settings") -> None:
|
||||||
"""Flush the geo service dirty set to the application database.
|
"""Flush the geo service dirty set to the application database.
|
||||||
|
|
||||||
Reads shared resources from ``app.state`` and delegates to
|
|
||||||
:func:`~app.services.geo_service.flush_dirty`.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: The :class:`fastapi.FastAPI` application instance passed via
|
settings: The resolved application settings used for database access.
|
||||||
APScheduler ``kwargs``.
|
|
||||||
"""
|
"""
|
||||||
db, close_db = await _get_db(app)
|
db, close_db = await _get_db(settings)
|
||||||
try:
|
try:
|
||||||
count = await geo_service.flush_dirty(db)
|
count = await geo_service.flush_dirty(db)
|
||||||
finally:
|
finally:
|
||||||
@@ -61,6 +57,10 @@ async def _run_flush(app: Any) -> None:
|
|||||||
log.debug("geo_cache_flush_ran", flushed=count)
|
log.debug("geo_cache_flush_ran", flushed=count)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_flush(app: FastAPI) -> None:
|
||||||
|
await _run_flush_with_settings(get_effective_settings(app))
|
||||||
|
|
||||||
|
|
||||||
def register(app: FastAPI) -> None:
|
def register(app: FastAPI) -> None:
|
||||||
"""Add (or replace) the geo cache flush job in the application scheduler.
|
"""Add (or replace) the geo cache flush job in the application scheduler.
|
||||||
|
|
||||||
@@ -71,11 +71,12 @@ def register(app: FastAPI) -> None:
|
|||||||
app: The :class:`fastapi.FastAPI` application instance whose
|
app: The :class:`fastapi.FastAPI` application instance whose
|
||||||
``app.state.scheduler`` will receive the job.
|
``app.state.scheduler`` will receive the job.
|
||||||
"""
|
"""
|
||||||
|
settings = get_effective_settings(app)
|
||||||
app.state.scheduler.add_job(
|
app.state.scheduler.add_job(
|
||||||
_run_flush,
|
_run_flush_with_settings,
|
||||||
trigger="interval",
|
trigger="interval",
|
||||||
seconds=GEO_FLUSH_INTERVAL,
|
seconds=GEO_FLUSH_INTERVAL,
|
||||||
kwargs={"app": app},
|
kwargs={"settings": settings},
|
||||||
id=JOB_ID,
|
id=JOB_ID,
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ from app.utils.runtime_state import get_effective_settings
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from app.config import Settings
|
||||||
from app.services import geo_service
|
from app.services import geo_service
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -40,24 +42,19 @@ GEO_RE_RESOLVE_INTERVAL: int = 600
|
|||||||
JOB_ID: str = "geo_re_resolve"
|
JOB_ID: str = "geo_re_resolve"
|
||||||
|
|
||||||
|
|
||||||
async def _get_db(app: FastAPI) -> tuple[aiosqlite.Connection, bool]:
|
async def _get_db(settings: "Settings") -> tuple[aiosqlite.Connection, bool]:
|
||||||
settings = get_effective_settings(app)
|
|
||||||
db = await open_db(settings.database_path)
|
db = await open_db(settings.database_path)
|
||||||
return db, True
|
return db, True
|
||||||
|
|
||||||
|
|
||||||
async def _run_re_resolve(app: FastAPI) -> None:
|
async def _run_re_resolve_with_resources(settings: "Settings", http_session: "ClientSession") -> None:
|
||||||
"""Query NULL-country IPs from the database and re-resolve them.
|
"""Query NULL-country IPs from the database and re-resolve them.
|
||||||
|
|
||||||
Reads shared resources from ``app.state`` and delegates to
|
|
||||||
:func:`~app.services.geo_service.lookup_batch`.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: The :class:`fastapi.FastAPI` application instance passed via
|
settings: The resolved application settings used for database access.
|
||||||
APScheduler ``kwargs``.
|
http_session: The shared aiohttp session used for external lookups.
|
||||||
"""
|
"""
|
||||||
db, close_db = await _get_db(app)
|
db, close_db = await _get_db(settings)
|
||||||
http_session = app.state.http_session
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch all IPs with NULL country_code from the persistent cache.
|
# Fetch all IPs with NULL country_code from the persistent cache.
|
||||||
@@ -89,6 +86,10 @@ async def _run_re_resolve(app: FastAPI) -> None:
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_re_resolve(app: FastAPI) -> None:
|
||||||
|
await _run_re_resolve_with_resources(get_effective_settings(app), app.state.http_session)
|
||||||
|
|
||||||
|
|
||||||
def register(app: FastAPI) -> None:
|
def register(app: FastAPI) -> None:
|
||||||
"""Add (or replace) the geo re-resolve job in the application scheduler.
|
"""Add (or replace) the geo re-resolve job in the application scheduler.
|
||||||
|
|
||||||
@@ -102,11 +103,12 @@ def register(app: FastAPI) -> None:
|
|||||||
app: The :class:`fastapi.FastAPI` application instance whose
|
app: The :class:`fastapi.FastAPI` application instance whose
|
||||||
``app.state.scheduler`` will receive the job.
|
``app.state.scheduler`` will receive the job.
|
||||||
"""
|
"""
|
||||||
|
settings = get_effective_settings(app)
|
||||||
app.state.scheduler.add_job(
|
app.state.scheduler.add_job(
|
||||||
_run_re_resolve,
|
_run_re_resolve_with_resources,
|
||||||
trigger="interval",
|
trigger="interval",
|
||||||
seconds=GEO_RE_RESOLVE_INTERVAL,
|
seconds=GEO_RE_RESOLVE_INTERVAL,
|
||||||
kwargs={"app": app},
|
kwargs={"settings": settings, "http_session": app.state.http_session},
|
||||||
id=JOB_ID,
|
id=JOB_ID,
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ import structlog
|
|||||||
from app.models.config import PendingRecovery
|
from app.models.config import PendingRecovery
|
||||||
from app.models.server import ServerStatus
|
from app.models.server import ServerStatus
|
||||||
from app.services import health_service
|
from app.services import health_service
|
||||||
from app.utils.runtime_state import get_effective_settings
|
from app.utils.runtime_state import (
|
||||||
|
RuntimeState,
|
||||||
|
get_effective_settings,
|
||||||
|
get_runtime_state,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
from app.config import Settings
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
@@ -48,29 +53,21 @@ HEALTH_CHECK_INTERVAL: int = 30
|
|||||||
_ACTIVATION_CRASH_WINDOW: int = 60
|
_ACTIVATION_CRASH_WINDOW: int = 60
|
||||||
|
|
||||||
|
|
||||||
async def _run_probe(app: FastAPI) -> None:
|
async def _run_probe_with_resources(settings: "Settings", runtime_state: RuntimeState) -> None:
|
||||||
"""Probe fail2ban and cache the result on *app.state*.
|
"""Probe fail2ban and cache the result on the runtime state.
|
||||||
|
|
||||||
Detects online/offline state transitions. When fail2ban goes offline
|
|
||||||
within :data:`_ACTIVATION_CRASH_WINDOW` seconds of the last jail
|
|
||||||
activation, writes a :class:`~app.models.config.PendingRecovery` record to
|
|
||||||
``app.state.pending_recovery``.
|
|
||||||
|
|
||||||
This is the APScheduler job callback. It reads ``fail2ban_socket`` from
|
|
||||||
the effective runtime settings, runs the health probe, and writes the
|
|
||||||
result to ``app.state.server_status``.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: The :class:`fastapi.FastAPI` application instance passed by the
|
settings: The resolved application settings used for the probe.
|
||||||
scheduler via the ``kwargs`` mechanism.
|
runtime_state: The mutable runtime state manager.
|
||||||
"""
|
"""
|
||||||
settings = get_effective_settings(app)
|
|
||||||
socket_path: str = settings.fail2ban_socket
|
socket_path: str = settings.fail2ban_socket
|
||||||
prev_status: ServerStatus = getattr(
|
prev_status: ServerStatus = getattr(
|
||||||
app.state, "server_status", ServerStatus(online=False)
|
runtime_state,
|
||||||
|
"server_status",
|
||||||
|
ServerStatus(online=False),
|
||||||
)
|
)
|
||||||
status: ServerStatus = await health_service.probe(socket_path)
|
status: ServerStatus = await health_service.probe(socket_path)
|
||||||
app.state.server_status = status
|
runtime_state.server_status = status
|
||||||
|
|
||||||
now = datetime.datetime.now(tz=datetime.UTC)
|
now = datetime.datetime.now(tz=datetime.UTC)
|
||||||
|
|
||||||
@@ -78,11 +75,9 @@ async def _run_probe(app: FastAPI) -> None:
|
|||||||
if status.online and not prev_status.online:
|
if status.online and not prev_status.online:
|
||||||
log.info("fail2ban_came_online", version=status.version)
|
log.info("fail2ban_came_online", version=status.version)
|
||||||
# Clear any pending recovery once fail2ban is back online.
|
# Clear any pending recovery once fail2ban is back online.
|
||||||
existing: PendingRecovery | None = getattr(
|
existing: PendingRecovery | None = getattr(runtime_state, "pending_recovery", None)
|
||||||
app.state, "pending_recovery", None
|
|
||||||
)
|
|
||||||
if existing is not None and not existing.recovered:
|
if existing is not None and not existing.recovered:
|
||||||
app.state.pending_recovery = PendingRecovery(
|
runtime_state.pending_recovery = PendingRecovery(
|
||||||
jail_name=existing.jail_name,
|
jail_name=existing.jail_name,
|
||||||
activated_at=existing.activated_at,
|
activated_at=existing.activated_at,
|
||||||
detected_at=existing.detected_at,
|
detected_at=existing.detected_at,
|
||||||
@@ -96,9 +91,7 @@ async def _run_probe(app: FastAPI) -> None:
|
|||||||
elif not status.online and prev_status.online:
|
elif not status.online and prev_status.online:
|
||||||
log.warning("fail2ban_went_offline")
|
log.warning("fail2ban_went_offline")
|
||||||
# Check whether this crash happened shortly after a jail activation.
|
# Check whether this crash happened shortly after a jail activation.
|
||||||
last_activation: ActivationRecord | None = getattr(
|
last_activation: ActivationRecord | None = getattr(runtime_state, "last_activation", None)
|
||||||
app.state, "last_activation", None
|
|
||||||
)
|
|
||||||
if last_activation is not None:
|
if last_activation is not None:
|
||||||
activated_at: datetime.datetime = last_activation["at"]
|
activated_at: datetime.datetime = last_activation["at"]
|
||||||
seconds_since = (now - activated_at).total_seconds()
|
seconds_since = (now - activated_at).total_seconds()
|
||||||
@@ -106,11 +99,9 @@ async def _run_probe(app: FastAPI) -> None:
|
|||||||
jail_name: str = last_activation["jail_name"]
|
jail_name: str = last_activation["jail_name"]
|
||||||
# Only create a new record when there is not already an
|
# Only create a new record when there is not already an
|
||||||
# unresolved one for the same jail.
|
# unresolved one for the same jail.
|
||||||
current: PendingRecovery | None = getattr(
|
current: PendingRecovery | None = getattr(runtime_state, "pending_recovery", None)
|
||||||
app.state, "pending_recovery", None
|
|
||||||
)
|
|
||||||
if current is None or current.recovered:
|
if current is None or current.recovered:
|
||||||
app.state.pending_recovery = PendingRecovery(
|
runtime_state.pending_recovery = PendingRecovery(
|
||||||
jail_name=jail_name,
|
jail_name=jail_name,
|
||||||
activated_at=activated_at,
|
activated_at=activated_at,
|
||||||
detected_at=now,
|
detected_at=now,
|
||||||
@@ -129,6 +120,13 @@ async def _run_probe(app: FastAPI) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_probe(app: FastAPI) -> None:
|
||||||
|
await _run_probe_with_resources(
|
||||||
|
get_effective_settings(app),
|
||||||
|
get_runtime_state(app),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_probe(app: FastAPI) -> None:
|
async def run_probe(app: FastAPI) -> None:
|
||||||
"""Run a single health probe outside the scheduled job context."""
|
"""Run a single health probe outside the scheduled job context."""
|
||||||
await _run_probe(app)
|
await _run_probe(app)
|
||||||
@@ -147,17 +145,20 @@ def register(app: FastAPI) -> None:
|
|||||||
# Initialise the cache with an offline placeholder so the dashboard
|
# Initialise the cache with an offline placeholder so the dashboard
|
||||||
# endpoint is always able to return a valid response even before the
|
# endpoint is always able to return a valid response even before the
|
||||||
# first probe fires.
|
# first probe fires.
|
||||||
app.state.server_status = ServerStatus(online=False)
|
settings = get_effective_settings(app)
|
||||||
|
runtime_state = get_runtime_state(app)
|
||||||
|
|
||||||
|
runtime_state.server_status = ServerStatus(online=False)
|
||||||
|
|
||||||
# Initialise activation tracking state.
|
# Initialise activation tracking state.
|
||||||
app.state.last_activation = None
|
runtime_state.last_activation = None
|
||||||
app.state.pending_recovery = None
|
runtime_state.pending_recovery = None
|
||||||
|
|
||||||
app.state.scheduler.add_job(
|
app.state.scheduler.add_job(
|
||||||
_run_probe,
|
_run_probe_with_resources,
|
||||||
trigger="interval",
|
trigger="interval",
|
||||||
seconds=HEALTH_CHECK_INTERVAL,
|
seconds=HEALTH_CHECK_INTERVAL,
|
||||||
kwargs={"app": app},
|
kwargs={"settings": settings, "runtime_state": runtime_state},
|
||||||
id="health_check",
|
id="health_check",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
# Fire immediately on startup too, so the UI isn't dark for 30 s.
|
# Fire immediately on startup too, so the UI isn't dark for 30 s.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -34,8 +35,7 @@ HISTORY_SYNC_INTERVAL: int = 300
|
|||||||
BACKFILL_WINDOW: int = 648000
|
BACKFILL_WINDOW: int = 648000
|
||||||
|
|
||||||
|
|
||||||
async def _get_db(app: FastAPI) -> tuple[aiosqlite.Connection, bool]:
|
async def _get_db(settings: "Settings") -> tuple[aiosqlite.Connection, bool]:
|
||||||
settings = get_effective_settings(app)
|
|
||||||
db = await open_db(settings.database_path)
|
db = await open_db(settings.database_path)
|
||||||
return db, True
|
return db, True
|
||||||
|
|
||||||
@@ -48,10 +48,9 @@ async def _get_last_archive_ts(db) -> int | None:
|
|||||||
return int(row[0])
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
async def _run_sync(app: FastAPI) -> None:
|
async def _run_sync_with_settings(settings: "Settings") -> None:
|
||||||
settings = get_effective_settings(app)
|
|
||||||
socket_path: str = settings.fail2ban_socket
|
socket_path: str = settings.fail2ban_socket
|
||||||
db, close_db = await _get_db(app)
|
db, close_db = await _get_db(settings)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
last_ts = await _get_last_archive_ts(db)
|
last_ts = await _get_last_archive_ts(db)
|
||||||
@@ -107,16 +106,21 @@ async def _run_sync(app: FastAPI) -> None:
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_sync(app: FastAPI) -> None:
|
||||||
|
await _run_sync_with_settings(get_effective_settings(app))
|
||||||
|
|
||||||
|
|
||||||
def register(app: FastAPI) -> None:
|
def register(app: FastAPI) -> None:
|
||||||
"""Register the history sync periodic job.
|
"""Register the history sync periodic job.
|
||||||
|
|
||||||
Should be called after scheduler startup, from the lifespan handler.
|
Should be called after scheduler startup, from the lifespan handler.
|
||||||
"""
|
"""
|
||||||
|
settings = get_effective_settings(app)
|
||||||
app.state.scheduler.add_job(
|
app.state.scheduler.add_job(
|
||||||
_run_sync,
|
_run_sync_with_settings,
|
||||||
trigger="interval",
|
trigger="interval",
|
||||||
seconds=HISTORY_SYNC_INTERVAL,
|
seconds=HISTORY_SYNC_INTERVAL,
|
||||||
kwargs={"app": app},
|
kwargs={"settings": settings},
|
||||||
id=JOB_ID,
|
id=JOB_ID,
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
next_run_time=datetime.datetime.now(tz=datetime.UTC),
|
next_run_time=datetime.datetime.now(tz=datetime.UTC),
|
||||||
|
|||||||
@@ -280,16 +280,21 @@ class TestApplySchedule:
|
|||||||
_, kwargs = scheduler.add_job.call_args
|
_, kwargs = scheduler.add_job.call_args
|
||||||
assert kwargs["id"] == JOB_ID
|
assert kwargs["id"] == JOB_ID
|
||||||
|
|
||||||
def test_apply_schedule_passes_app_in_kwargs(self) -> None:
|
def test_apply_schedule_passes_resources_in_kwargs(self) -> None:
|
||||||
"""The scheduled job must receive ``app`` as a kwarg for state access."""
|
"""The scheduled job must receive explicit resources instead of app."""
|
||||||
scheduler = _make_scheduler()
|
scheduler = _make_scheduler()
|
||||||
app = self._make_app_with_scheduler(scheduler)
|
app = self._make_app_with_scheduler(scheduler)
|
||||||
|
app.state.settings = MagicMock(database_path="/tmp/fake.db")
|
||||||
|
app.state.http_session = MagicMock()
|
||||||
config = ScheduleConfig(frequency=ScheduleFrequency.daily)
|
config = ScheduleConfig(frequency=ScheduleFrequency.daily)
|
||||||
|
|
||||||
_apply_schedule(app, config)
|
_apply_schedule(app, config)
|
||||||
|
|
||||||
_, kwargs = scheduler.add_job.call_args
|
_, kwargs = scheduler.add_job.call_args
|
||||||
assert kwargs["kwargs"] == {"app": app}
|
assert kwargs["kwargs"] == {
|
||||||
|
"settings": app.state.settings,
|
||||||
|
"http_session": app.state.http_session,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -140,11 +140,11 @@ class TestRegister:
|
|||||||
_, kwargs = app.state.scheduler.add_job.call_args
|
_, kwargs = app.state.scheduler.add_job.call_args
|
||||||
assert kwargs["replace_existing"] is True
|
assert kwargs["replace_existing"] is True
|
||||||
|
|
||||||
def test_register_passes_app_in_kwargs(self) -> None:
|
def test_register_passes_settings_in_kwargs(self) -> None:
|
||||||
"""The scheduled job must receive ``app`` as a kwarg for state access."""
|
"""The scheduled job must receive settings as a kwarg instead of app."""
|
||||||
app = _make_app()
|
app = _make_app()
|
||||||
|
|
||||||
register(app)
|
register(app)
|
||||||
|
|
||||||
_, kwargs = app.state.scheduler.add_job.call_args
|
_, kwargs = app.state.scheduler.add_job.call_args
|
||||||
assert kwargs["kwargs"] == {"app": app}
|
assert kwargs["kwargs"] == {"settings": app.state.settings}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import pytest
|
|||||||
from app.models.config import PendingRecovery
|
from app.models.config import PendingRecovery
|
||||||
from app.models.server import ServerStatus
|
from app.models.server import ServerStatus
|
||||||
from app.tasks.health_check import HEALTH_CHECK_INTERVAL, _run_probe, register
|
from app.tasks.health_check import HEALTH_CHECK_INTERVAL, _run_probe, register
|
||||||
|
from app.utils.runtime_state import ApplicationState, RuntimeState
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -31,12 +32,15 @@ def _make_app(prev_online: bool = False) -> MagicMock:
|
|||||||
Returns:
|
Returns:
|
||||||
A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``.
|
A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``.
|
||||||
"""
|
"""
|
||||||
|
runtime_state = RuntimeState(
|
||||||
|
server_status=ServerStatus(online=prev_online),
|
||||||
|
pending_recovery=None,
|
||||||
|
last_activation=None,
|
||||||
|
)
|
||||||
app = MagicMock()
|
app = MagicMock()
|
||||||
app.state.settings.fail2ban_socket = "/var/run/fail2ban/fail2ban.sock"
|
app.state = ApplicationState(runtime_state)
|
||||||
app.state.server_status = ServerStatus(online=prev_online)
|
app.state.settings = MagicMock(fail2ban_socket="/var/run/fail2ban/fail2ban.sock")
|
||||||
app.state.scheduler = MagicMock()
|
app.state.scheduler = MagicMock()
|
||||||
app.state.last_activation = None
|
|
||||||
app.state.pending_recovery = None
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -232,14 +236,17 @@ class TestRegister:
|
|||||||
_, kwargs = app.state.scheduler.add_job.call_args
|
_, kwargs = app.state.scheduler.add_job.call_args
|
||||||
assert kwargs["replace_existing"] is True
|
assert kwargs["replace_existing"] is True
|
||||||
|
|
||||||
def test_register_passes_app_in_kwargs(self) -> None:
|
def test_register_passes_resources_in_kwargs(self) -> None:
|
||||||
"""The scheduled job must receive ``app`` as a kwarg for state access."""
|
"""The scheduled job must receive explicit resources instead of app."""
|
||||||
app = _make_app()
|
app = _make_app()
|
||||||
|
|
||||||
register(app)
|
register(app)
|
||||||
|
|
||||||
_, kwargs = app.state.scheduler.add_job.call_args
|
_, kwargs = app.state.scheduler.add_job.call_args
|
||||||
assert kwargs["kwargs"] == {"app": app}
|
assert kwargs["kwargs"] == {
|
||||||
|
"settings": app.state.settings,
|
||||||
|
"runtime_state": app.state.runtime_state,
|
||||||
|
}
|
||||||
|
|
||||||
def test_register_initialises_last_activation_none(self) -> None:
|
def test_register_initialises_last_activation_none(self) -> None:
|
||||||
"""``register`` must set ``app.state.last_activation = None``."""
|
"""``register`` must set ``app.state.last_activation = None``."""
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class TestHistorySyncTask:
|
|||||||
fake_scheduler.add_job.assert_called_once()
|
fake_scheduler.add_job.assert_called_once()
|
||||||
called_args, called_kwargs = fake_scheduler.add_job.call_args
|
called_args, called_kwargs = fake_scheduler.add_job.call_args
|
||||||
assert called_kwargs["id"] == history_sync.JOB_ID
|
assert called_kwargs["id"] == history_sync.JOB_ID
|
||||||
assert called_kwargs["kwargs"]["app"] == app
|
assert called_kwargs["kwargs"]["settings"] is app.state.settings
|
||||||
|
|
||||||
async def test_backfill_window_is_7_5_days(self) -> None:
|
async def test_backfill_window_is_7_5_days(self) -> None:
|
||||||
assert history_sync.BACKFILL_WINDOW == 648000
|
assert history_sync.BACKFILL_WINDOW == 648000
|
||||||
|
|||||||
Reference in New Issue
Block a user