Move runtime application state into a dedicated runtime state manager
This commit is contained in:
@@ -21,6 +21,7 @@ from app.config import Settings
|
||||
from app.models.auth import Session
|
||||
from app.models.config import PendingRecovery
|
||||
from app.models.server import ServerStatus
|
||||
from app.utils.runtime_state import RuntimeState
|
||||
from app.utils.time_utils import utc_now
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -35,6 +36,7 @@ class AppState(Protocol):
|
||||
server_status: ServerStatus
|
||||
pending_recovery: PendingRecovery | None
|
||||
last_activation: dict[str, datetime.datetime] | None
|
||||
runtime_state: RuntimeState
|
||||
|
||||
|
||||
_COOKIE_NAME = "bangui_session"
|
||||
|
||||
@@ -45,6 +45,7 @@ from app.routers import (
|
||||
)
|
||||
from app.startup import startup_shared_resources
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
|
||||
from app.utils.runtime_state import ApplicationState, RuntimeState
|
||||
from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_cache
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -283,7 +284,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
# Store settings on app.state so the lifespan handler can access them.
|
||||
# Store immutable configuration and the dedicated runtime state manager on
|
||||
# app.state. Runtime state values are proxied through the wrapper so the
|
||||
# shared Starlette state bag itself does not hold mutable business state.
|
||||
app.state = ApplicationState(RuntimeState())
|
||||
app.state.settings = resolved_settings
|
||||
set_setup_complete_cache(app, False)
|
||||
|
||||
|
||||
85
backend/app/utils/runtime_state.py
Normal file
85
backend/app/utils/runtime_state.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Centralise mutable runtime application state.
|
||||
|
||||
Runtime state is kept outside of Starlette's raw ``app.state`` storage and
|
||||
exposed through a controlled state manager object. This keeps the FastAPI
|
||||
framework state bag limited to shared infrastructure handles and immutable
|
||||
configuration while still allowing existing code to access runtime values via
|
||||
attribute proxying.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from starlette.datastructures import State
|
||||
|
||||
from app.models.server import ServerStatus
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.models.config import PendingRecovery
|
||||
|
||||
ActivationRecord = dict[str, datetime.datetime]
|
||||
|
||||
_RUNTIME_ATTRIBUTES: frozenset[str] = frozenset(
|
||||
{
|
||||
"setup_complete_cached",
|
||||
"server_status",
|
||||
"pending_recovery",
|
||||
"last_activation",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeState:
|
||||
"""Mutable runtime state for the current application instance."""
|
||||
|
||||
setup_complete_cached: bool = False
|
||||
server_status: ServerStatus = field(default_factory=lambda: ServerStatus(online=False))
|
||||
pending_recovery: PendingRecovery | None = None
|
||||
last_activation: ActivationRecord | None = None
|
||||
|
||||
|
||||
class ApplicationState(State):
|
||||
"""Application state wrapper that delegates runtime state access.
|
||||
|
||||
This allows runtime values to be stored in a dedicated
|
||||
:class:`RuntimeState` instance while preserving the familiar attribute-based
|
||||
``app.state`` API for the rest of the application.
|
||||
"""
|
||||
|
||||
def __init__(self, runtime_state: RuntimeState, state: dict[str, Any] | None = None):
|
||||
super().__init__(state)
|
||||
object.__setattr__(self, "_runtime_state", runtime_state)
|
||||
|
||||
@property
|
||||
def runtime_state(self) -> RuntimeState:
|
||||
"""Return the dedicated runtime state manager."""
|
||||
return object.__getattribute__(self, "_runtime_state")
|
||||
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
if key in _RUNTIME_ATTRIBUTES:
|
||||
return getattr(self.runtime_state, key)
|
||||
return super().__getattr__(key)
|
||||
|
||||
def __setattr__(self, key: str, value: Any) -> None:
|
||||
if key in _RUNTIME_ATTRIBUTES:
|
||||
setattr(self.runtime_state, key, value)
|
||||
return
|
||||
super().__setattr__(key, value)
|
||||
|
||||
def __delattr__(self, key: str) -> None:
|
||||
if key in _RUNTIME_ATTRIBUTES:
|
||||
delattr(self.runtime_state, key)
|
||||
return
|
||||
super().__delattr__(key)
|
||||
|
||||
|
||||
def get_runtime_state(app: Any) -> RuntimeState:
|
||||
"""Return the runtime state manager for the current FastAPI application."""
|
||||
state = getattr(app, "state", None)
|
||||
if state is None or not hasattr(state, "runtime_state"):
|
||||
raise AttributeError("Runtime state has not been initialised on the application.")
|
||||
return state.runtime_state
|
||||
@@ -2,17 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.utils.runtime_state import get_runtime_state
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
def is_setup_complete_cached(app: FastAPI) -> bool:
|
||||
"""Return the cached setup completion state from application state."""
|
||||
return getattr(app.state, "setup_complete_cached", False)
|
||||
"""Return the cached setup completion state from the runtime state manager."""
|
||||
return get_runtime_state(app).setup_complete_cached
|
||||
|
||||
|
||||
def set_setup_complete_cache(app: FastAPI, completed: bool) -> None:
|
||||
"""Set the cached setup completion state on application state."""
|
||||
app.state.setup_complete_cached = completed
|
||||
"""Set the cached setup completion state on the runtime state manager."""
|
||||
get_runtime_state(app).setup_complete_cached = completed
|
||||
|
||||
|
||||
def invalidate_setup_complete_cache(app: FastAPI) -> None:
|
||||
@@ -21,4 +26,4 @@ def invalidate_setup_complete_cache(app: FastAPI) -> None:
|
||||
This helper exists so the cache can be invalidated explicitly if the
|
||||
application state changes during runtime.
|
||||
"""
|
||||
app.state.setup_complete_cached = False
|
||||
get_runtime_state(app).setup_complete_cached = False
|
||||
|
||||
@@ -60,6 +60,28 @@ def test_create_app_skips_cors_when_no_origins_are_configured() -> None:
|
||||
assert cors_middleware == []
|
||||
|
||||
|
||||
def test_create_app_initialises_runtime_state_manager() -> None:
|
||||
"""The FastAPI app exposes a dedicated runtime state manager on app.state."""
|
||||
settings = Settings(
|
||||
database_path="/tmp/test.db",
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
app = create_app(settings=settings)
|
||||
runtime_state = app.state.runtime_state
|
||||
|
||||
assert runtime_state.setup_complete_cached is False
|
||||
assert runtime_state.server_status.online is False
|
||||
assert runtime_state.pending_recovery is None
|
||||
assert runtime_state.last_activation is None
|
||||
assert app.state.server_status.online is False
|
||||
|
||||
|
||||
def test_create_app_disables_cors_by_default() -> None:
|
||||
"""The FastAPI app does not add CORS middleware when no origins are configured by environment."""
|
||||
settings = Settings(
|
||||
|
||||
Reference in New Issue
Block a user