"""The database connection, made when it is known rather than at import. A wizard cannot offer a choice the process has already made, so connecting is deferred. Three states: **not configured** (serve the wizard, refuse the rest), **connected**, and **failed** — which is deliberately distinct, because the answer is "fix this" rather than "choose one" (decisions.md #75). `connect()` is re-entrant; the wizard calls it after choosing. `source()` reads the two places a value can come from, because `DatabaseSettings` defaults to SQLite and so cannot express "not configured" on its own. """ from __future__ import annotations import logging import os import re from dataclasses import dataclass from pathlib import Path from appcore import make_session_factory, upgrade_to_head from appcore.config import DatabaseSettings from . import tenancy log = logging.getLogger(__name__) ENV_PREFIX = "APP_DATABASE__" ENV_PASSWORD = ENV_PREFIX + "PASSWORD" # How to reach a database, not which one: set alone, these choose nothing. The # wizard's tip says to put the password here and leave its field empty, and with # no config file yet that counted as choosing the default, SQLite, so the step # that would have chosen Postgres was skipped (#75). REACH_ONLY = frozenset({ENV_PASSWORD, ENV_PREFIX + "PORT", ENV_PREFIX + "SSLMODE"}) # Seconds to wait for a database server before calling it unreachable. With no # limit, a server that took the connection and said nothing held the wizard's # probe for as long as the operating system would wait: minutes. CONNECT_TIMEOUT = 10 NOT_CONFIGURED = "not configured" FROM_ENVIRONMENT = "environment" FROM_FILE = "config file" # A `database:` heading with nothing under it: configured, and broken. The app # refuses to start rather than open the public wizard on an install that has # data (decisions.md #75). EMPTY_IN_FILE = "empty in config file" # --------------------------------------------------------------------------- # # Where did the setting come from? # --------------------------------------------------------------------------- # @dataclass(frozen=True) class Source: """Whether a database was chosen, and by whom.""" where: str # NOT_CONFIGURED | FROM_ENVIRONMENT | FROM_FILE detail: str = "" # the sentence the wizard shows @property def configured(self) -> bool: return self.where != NOT_CONFIGURED def source(settings) -> Source: """Report whether the database was set, and where it was set. The wizard uses this to decide between offering a choice and stating a fact. Stating the fact matters: someone whose Kubernetes deployment sets `APP_DATABASE__HOST` should be told that, not quietly handed a form whose every value would be ignored. """ env_names = sorted(name for name in os.environ if name.startswith(ENV_PREFIX)) if set(env_names) - REACH_ONLY: return Source(FROM_ENVIRONMENT, f"set by the {', '.join(env_names)} environment " f"variable{'s' if len(env_names) > 1 else ''}") from . import configfile try: written = configfile.load() except Exception: # a malformed file is the admin page's problem written = {} if isinstance(written, dict) and written.get("database"): return Source(FROM_FILE, f"set in {configfile.config_path()}") if isinstance(written, dict) and "database" in written: line = written.lc.key("database")[0] + 1 return Source(EMPTY_IN_FILE, f"Line {line} of {configfile.config_path()} starts a database: " "block with nothing in it. Fill in its settings, or delete the " "line to choose a database in the setup wizard.") return Source(NOT_CONFIGURED) def describe(db: DatabaseSettings) -> str: """One line naming what a settings object points at, without its password.""" if db.type == "sqlite": return f"SQLite at {db.path}" return f"Postgres — {db.name} on {db.host}:{db.port} as {db.user}" # --------------------------------------------------------------------------- # # Trying it before committing to it # --------------------------------------------------------------------------- # @dataclass(frozen=True) class Probe: ok: bool detail: str def probe(db: DatabaseSettings) -> Probe: """Connect, and say what happened — in the words of somebody who has to fix it, not in the words of a driver traceback. This runs before anything is written down. The wizard's "Test connection" button is the visible half; the invisible half is that choosing a database that does not work would otherwise be discovered at the next restart, with the config file already saved and the app no longer able to boot. """ from sqlalchemy import create_engine, text if db.type == "sqlite": problem = sqlite_path_problem(db.path) if problem: return Probe(False, problem) engine = None try: # SQLite opens a file and has nothing to wait for. limit = {} if db.type == "sqlite" else {"connect_timeout": CONNECT_TIMEOUT} engine = create_engine(db.url, pool_pre_ping=True, connect_args=limit) with engine.connect() as conn: if db.type == "sqlite": conn.execute(text("select 1")) return Probe(True, f"SQLite is usable at {db.path}.") version = conn.execute(text("show server_version")).scalar() return Probe(True, f"Connected to PostgreSQL {version}.") except Exception as exc: return Probe(False, _explain(db, exc)) finally: if engine is not None: engine.dispose() def sqlite_path_problem(path: str | None) -> str | None: """Whether a SQLite file could be created there, checked before connecting. SQLite is happy to *open* a path it cannot write and only fails later, on the first write — which in a container means the wizard appears to succeed and the app dies on the next request. The directory is the real question, and on a container it is usually an unmounted volume. """ if not path: return "No database file path was given." target = Path(path) if target.is_dir(): return f"{path} is a directory, not a database file." parent = target.parent if not parent.exists(): return (f"The directory {parent} does not exist. In a container this " f"usually means no volume is mounted there — check the volume " f"mapping before continuing, or the database will not survive " f"a restart.") if not os.access(parent, os.W_OK): return (f"The directory {parent} is not writable by this process " f"(running as uid {os.getuid()}). Fix the ownership of the " f"mounted volume, or choose a path the app can write.") if target.exists() and not os.access(target, os.W_OK): return f"{path} exists but is not writable by this process." return None # Driver wordings, per thing that is actually wrong. Two libraries and several # versions say each of these differently — psycopg3 says "failed to resolve # host" where libpq says "could not translate host name" — so match on a set of # phrases rather than on one, and keep the raw text as the fallback. _UNRESOLVED = ("could not translate host name", "name or service not known", "failed to resolve host", "nodename nor servname", "temporary failure in name resolution") _REFUSED = ("connection refused", "could not connect to server") _BAD_PASSWORD = ("password authentication failed", "no password supplied", "authentication failed for user") def _explain(db: DatabaseSettings, exc: Exception) -> str: """Turn a driver error into something actionable. The raw text is the fallback rather than the answer: "(psycopg. OperationalError) connection failed: :1 (::1), port 5432 failed" tells somebody setting this up for the first time nothing they can act on. """ raw = str(exc).strip() # SQLAlchemy prefixes the driver's exception class and appends a link to # its own error docs. Neither helps here. raw = re.sub(r"^\([\w.]+\)\s*", "", raw.splitlines()[0]) if raw else type(exc).__name__ if db.type != "postgres": return raw lowered = raw.lower() if any(phrase in lowered for phrase in _UNRESOLVED): return (f"The hostname {db.host!r} could not be resolved from inside this " f"container. Check the spelling — and if the database is another " f"container, that both are on the same network.") if any(phrase in lowered for phrase in _BAD_PASSWORD): return f"The server refused the credentials for user {db.user!r}." if any(phrase in lowered for phrase in _REFUSED): return (f"Nothing accepted a connection on {db.host}:{db.port}. Check the " f"port, that the server is running, and that it allows " f"connections from this address.") if "does not exist" in lowered and db.name in raw: return (f"Connected to the server, but it has no database called " f"{db.name!r}. Create it first — the app builds its own tables, " f"but it will not create the database itself.") if "timeout" in lowered or "timed out" in lowered: return (f"The connection to {db.host}:{db.port} timed out. That usually " f"means a firewall is dropping it rather than refusing it.") return raw # --------------------------------------------------------------------------- # # The connection itself # --------------------------------------------------------------------------- # class NotConfigured(RuntimeError): """Raised when something asks for a session before there is a database. Every route except the wizard is gated behind `is_ready()`, so this is a programming error rather than a user-facing state — which is why it says what to do about it. """ _factory = None _current: DatabaseSettings | None = None _migrations: str | None = None def set_migrations(path: str) -> None: """Where the Alembic scripts live. Set once, at import of the app.""" global _migrations _migrations = path def connect(db: DatabaseSettings, *, migrate: bool = True) -> None: """Run migrations and build the session factory, replacing any current one. Called at startup when a database was already configured, and again by the wizard when someone chooses one. Migrating here rather than at import is the point: a database that does not exist yet cannot be migrated, and one chosen thirty seconds ago has to be. """ global _factory, _current if migrate: if _migrations is None: raise RuntimeError("set_migrations() must be called before connect()") upgrade_to_head(db, _migrations) factory = make_session_factory(db) # The tenancy filter is attached per sessionmaker, so a replacement factory # needs its own listeners — without this, a database chosen in the wizard # would serve sessions with no portfolio scoping at all. tenancy.install(factory) _factory = factory _current = db log.info("database connected: %s", describe(db)) def start(settings) -> Source: """Connect at startup if a database was chosen, and refuse to start on a `database:` block left empty.""" found = source(settings) if found.where == EMPTY_IN_FILE: raise SystemExit(found.detail) if found.configured: connect(settings.database) return found def session(): """A new session, or a refusal that says why.""" if _factory is None: raise NotConfigured( "No database is configured. Complete the setup wizard, or set " "APP_DATABASE__* / a database: block in config.yaml." ) return _factory() def factory(): """The sessionmaker itself, for the places that need to pass one around.""" if _factory is None: raise NotConfigured("No database is configured.") return _factory def is_ready() -> bool: return _factory is not None def current() -> DatabaseSettings | None: """What we are connected to, for the pages that report it.""" return _current def reset() -> None: """Forget the connection. For tests, and for a wizard step that failed after connecting.""" global _factory, _current _factory = None _current = None