Move runtime application state into a dedicated runtime state manager

This commit is contained in:
2026-04-10 19:07:35 +02:00
parent 6b177f1881
commit ff92733f90
6 changed files with 223 additions and 55 deletions

View File

@@ -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"

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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(