Files
ssh-jumphost/app/db.py
2026-09-02 20:30:44 +02:00

99 lines
4.1 KiB
Python

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