""" Datenbankzugriff (SQLite, WAL-Modus) + einfacher, idempotenter Migration-Runner. Bewusst ohne schweres ORM gehalten: alle Queries sind strikt parametrisiert (nie String-Concat mit Nutzereingaben), siehe Security-Konzept 6.6. """ from __future__ import annotations import logging from pathlib import Path import aiosqlite from app.config import settings logger = logging.getLogger("jumphost.db") MIGRATIONS_DIR = Path(__file__).parent / "db" / "migrations" _connection: aiosqlite.Connection | None = None async def init_db() -> None: """Legt das Datenverzeichnis an, oeffnet die DB und wendet Migrationen an.""" settings.data_dir.mkdir(parents=True, exist_ok=True, mode=0o700) settings.recordings_dir.mkdir(parents=True, exist_ok=True, mode=0o700) global _connection _connection = await aiosqlite.connect(settings.db_path, isolation_level=None) await _connection.execute("PRAGMA journal_mode = WAL;") await _connection.execute("PRAGMA foreign_keys = ON;") await _connection.execute("PRAGMA busy_timeout = 5000;") # E5 (Umsetzungsauftrag Teil E): ohne explizites PRAGMA synchronous # verwendet SQLite seinen kompilierten Standard FULL -- das fsynct bei # JEDEM COMMIT, unabhaengig vom (an sich korrekten) WAL-Journal-Modus. # aiosqlite bedient alle Anfragen ueber EINEN gemeinsamen Hintergrund- # Thread (die einzige Verbindung der Anwendung, siehe E.0/E5-Befund) -- # ein fsync dort serialisiert sich vor JEDE andere wartende Anfrage # jedes anderen Benutzers. NORMAL ist die von SQLite fuer WAL-Betrieb # dokumentierte Empfehlung: fsynct nur noch an Checkpoint-Grenzen, # bleibt dabei garantiert unbeschaedigt bei einem Anwendungsabsturz # (der hier relevante Fall) -- nur bei einem Stromausfall/OS-Crash # koennen die letzten, unmittelbar davor committeten Transaktionen # verloren gehen (nicht: die DB korrumpieren). await _connection.execute("PRAGMA synchronous = NORMAL;") await _apply_migrations(_connection) try: settings.db_path.chmod(0o600) except OSError: logger.warning("Konnte Dateirechte der DB nicht setzen (%s)", settings.db_path) async def _apply_migrations(conn: aiosqlite.Connection) -> None: await conn.execute( "CREATE TABLE IF NOT EXISTS schema_migrations " "(filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')))" ) applied = {row[0] async for row in await conn.execute("SELECT filename FROM schema_migrations")} for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")): if migration_file.name in applied: continue logger.info("Wende Migration an: %s", migration_file.name) sql = migration_file.read_text(encoding="utf-8") await conn.executescript(sql) # Fremdschluesselpruefung NACH jeder Migration: PRAGMA foreign_key_check # liefert nur eine Ergebnismenge zurueck, die ein per executescript() # ausgefuehrtes Skript NICHT auswertet (das PRAGMA innerhalb des # Skripts selbst ist daher wirkungslos) -- deshalb wird es hier, # ausserhalb von executescript(), explizit erneut ausgefuehrt und # das Ergebnis geprueft. Verletzungen brechen den Start hart ab, # statt eine inkonsistente Datenbank stillschweigend zu uebernehmen # (siehe 0014_drop_tenants.sql-Kommentar). violations = await (await conn.execute("PRAGMA foreign_key_check")).fetchall() if violations: raise RuntimeError( f"Migration {migration_file.name} hat Fremdschluessel-Verletzungen " f"hinterlassen, Start abgebrochen: {violations!r}" ) await conn.execute( "INSERT INTO schema_migrations (filename) VALUES (?)", (migration_file.name,) ) async def close_db() -> None: global _connection if _connection is not None: await _connection.close() _connection = None def get_db() -> aiosqlite.Connection: if _connection is None: raise RuntimeError("Datenbank ist nicht initialisiert (init_db() aufrufen).") return _connection