"""What a connector keeps across restarts. A connector holds connections and handles, and those are meant to go when the process does. A few things are not: the certificate a device presented on first contact, a token it paired with. :attr:`ConnectorNode.store` is where those go — a handful of short strings per connector package, in the engine's own database, so they survive a restart and need nothing ``fluksio serve`` does not have. Keyed by the connector's top-level package rather than by node: the nodes of one Hue bridge share the certificate pinned for it, and every node type a package installs shares the same keys. A superuser lists and clears them through ``/connectors/{node_type}/state``. """ from __future__ import annotations import re from datetime import UTC, datetime from sqlalchemy import delete, func from sqlmodel import Session, col, select from fluksio.models import ConnectorState #: A value is a short string — a fingerprint, a PEM, a token. Bytes worth #: keeping belong in the artifact store. MAX_VALUE_BYTES = 64 * 1024 #: Keys per connector package. Past this a connector is keeping something per #: reading rather than per device. MAX_KEYS = 256 #: What a top-level package can be called. CONNECTOR_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,99}") #: One URL path segment, so the reset route can name every key. KEY = re.compile(r"[^/\s]{1,200}") def namespace_of(cls: type) -> str: """The package a connector class keeps its state under.""" return cls.__module__.partition(".")[0] def _checked(key: str) -> str: if not KEY.fullmatch(key): raise ValueError( f"'{key}' is not a valid key: 1 to 200 characters, no slash or whitespace" ) return key class ConnectorStore: """Short strings one connector package keeps across restarts. Every call is a database round trip, so from the event loop it goes through ``asyncio.to_thread`` like any other blocking I/O. """ def __init__(self, connector: str) -> None: if not CONNECTOR_NAME.fullmatch(connector): raise ValueError(f"'{connector}' is not a connector package name") self.connector = connector def get(self, key: str) -> str | None: """The value stored under *key*, or ``None``.""" # Imported here: a connector imports this module, and its tests have # no business opening the engine's database by importing it. from fluksio.core.db import engine with Session(engine) as session: row = session.get(ConnectorState, (self.connector, _checked(key))) return None if row is None else row.value def set(self, key: str, value: str) -> None: """Store *value* under *key*, replacing what was there.""" from fluksio.core.db import engine, writing _checked(key) if len(value.encode()) > MAX_VALUE_BYTES: raise ValueError( f"'{key}' is too large to store: {MAX_VALUE_BYTES} bytes at most" ) with Session(engine) as session, writing(session): row = session.get(ConnectorState, (self.connector, key)) if row is None: count = session.exec( select(func.count()) .select_from(ConnectorState) .where(ConnectorState.connector == self.connector) ).one() if count >= MAX_KEYS: raise ValueError( f"{self.connector} already stores {MAX_KEYS} keys, the most " "a connector may keep" ) row = ConnectorState(connector=self.connector, key=key, value=value) else: row.value = value row.updated_at = datetime.now(UTC) session.add(row) session.commit() def delete(self, key: str) -> bool: """Forget *key*. Whether there was anything to forget.""" from fluksio.core.db import engine with Session(engine) as session: result = session.exec( delete(ConnectorState).where( col(ConnectorState.connector) == self.connector, col(ConnectorState.key) == _checked(key), ) ) session.commit() return bool(result.rowcount) def entries(self) -> list[ConnectorState]: """Every key this package keeps, in key order.""" from fluksio.core.db import engine with Session(engine) as session: return list( session.exec( select(ConnectorState) .where(ConnectorState.connector == self.connector) .order_by(col(ConnectorState.key)) ).all() ) def clear(self) -> int: """Forget every key. How many there were.""" from fluksio.core.db import engine with Session(engine) as session: result = session.exec( delete(ConnectorState).where( col(ConnectorState.connector) == self.connector ) ) session.commit() return int(result.rowcount)