"""Runtime state that used to live in module-level dicts. Three things were kept in process memory: which stacks are mid-deploy, the registry digests behind the "update available" badges, and the login rate limiter's counters. All three assumed exactly one uvicorn worker — nothing said so, and ``--workers 2`` would have silently given each worker its own copy — and all three were lost on restart. They are tables now. SQLite is already here; this needs no new dependency. Note that ``services/compose_service.py`` and ``services/update_service.py`` are shared with the agent, which has no database at all — so these tables are only ever touched from the central app's own routers and background loops. """ from __future__ import annotations from datetime import datetime, timezone from typing import Optional from sqlmodel import Field, SQLModel def _now() -> datetime: return datetime.now(timezone.utc) class StackLock(SQLModel, table=True): """A stack is mid-operation and must not be touched concurrently. ``docker compose`` has no locking of its own, so two simultaneous ``update`` calls — two browser tabs, or auto-update racing a manual click — would both run ``pull`` and ``up`` against the same project and fight over recreating containers. ``expires_at`` is what keeps a crashed worker from locking a stack forever: an expired row is simply taken over by the next caller. """ stack_id: str = Field(primary_key=True) action: str # "update", "start", "backup", … #: Free-form owner, for the log when a lock is stolen. Not a security control. owner: str = "" acquired_at: datetime = Field(default_factory=_now) expires_at: datetime class ImageStatus(SQLModel, table=True): """Cached result of one image's registry digest check. Persisted so a restart does not blank every update badge until the next background sweep (up to an hour), and so ``notified`` survives with it — otherwise every restart re-announced the same pending updates. """ image: str = Field(primary_key=True) update_available: bool = False current_digest: Optional[str] = None remote_digest: Optional[str] = None checked_at: float = 0.0 error: Optional[str] = None #: Whether an "update available" notification already went out for this #: image at its current state. notified: bool = False class LoginAttempt(SQLModel, table=True): """One login attempt, for the rate limiter. In memory this reset on every restart, so an attacker could clear their own budget by getting the process to restart — and with more than one worker the limit multiplied by the worker count. Rows are pruned as they age out. """ id: Optional[int] = Field(default=None, primary_key=True) ip: str = Field(index=True) at: datetime = Field(default_factory=_now, index=True)