diff --git a/maubot/db/__init__.py b/maubot/db/__init__.py index d6aeb09..68833ce 100644 --- a/maubot/db/__init__.py +++ b/maubot/db/__init__.py @@ -1,7 +1,7 @@ from mautrix.util.async_db import Database from .client import Client -from .instance import Instance +from .instance import DatabaseEngine, Instance from .upgrade import upgrade_table @@ -10,4 +10,4 @@ def init(db: Database) -> None: table.db = db -__all__ = ["upgrade_table", "init", "Client", "Instance"] +__all__ = ["upgrade_table", "init", "Client", "Instance", "DatabaseEngine"] diff --git a/maubot/db/instance.py b/maubot/db/instance.py index dff7064..5bb3f6a 100644 --- a/maubot/db/instance.py +++ b/maubot/db/instance.py @@ -16,6 +16,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, ClassVar +from enum import Enum from asyncpg import Record from attr import dataclass @@ -26,6 +27,11 @@ from mautrix.util.async_db import Database fake_db = Database.create("") if TYPE_CHECKING else None +class DatabaseEngine(Enum): + SQLITE = "sqlite" + POSTGRES = "postgres" + + @dataclass class Instance: db: ClassVar[Database] = fake_db @@ -35,21 +41,31 @@ class Instance: enabled: bool primary_user: UserID config_str: str + database_engine: DatabaseEngine | None + + @property + def database_engine_str(self) -> str | None: + return self.database_engine.value if self.database_engine else None @classmethod def _from_row(cls, row: Record | None) -> Instance | None: if row is None: return None - return cls(**row) + data = {**row} + db_engine = data.pop("database_engine", None) + return cls(**data, database_engine=DatabaseEngine(db_engine) if db_engine else None) + + _columns = "id, type, enabled, primary_user, config, database_engine" @classmethod async def all(cls) -> list[Instance]: - rows = await cls.db.fetch("SELECT id, type, enabled, primary_user, config FROM instance") + q = f"SELECT {cls._columns} FROM instance" + rows = await cls.db.fetch(q) return [cls._from_row(row) for row in rows] @classmethod async def get(cls, id: str) -> Instance | None: - q = "SELECT id, type, enabled, primary_user, config FROM instance WHERE id=$1" + q = f"SELECT {cls._columns} FROM instance WHERE id=$1" return cls._from_row(await cls.db.fetchrow(q, id)) async def update_id(self, new_id: str) -> None: @@ -58,17 +74,27 @@ class Instance: @property def _values(self): - return self.id, self.type, self.enabled, self.primary_user, self.config_str + return ( + self.id, + self.type, + self.enabled, + self.primary_user, + self.config_str, + self.database_engine_str, + ) async def insert(self) -> None: q = ( - "INSERT INTO instance (id, type, enabled, primary_user, config) " - "VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO instance (id, type, enabled, primary_user, config, database_engine) " + "VALUES ($1, $2, $3, $4, $5, $6)" ) await self.db.execute(q, *self._values) async def update(self) -> None: - q = "UPDATE instance SET type=$2, enabled=$3, primary_user=$4, config=$5 WHERE id=$1" + q = """ + UPDATE instance SET type=$2, enabled=$3, primary_user=$4, config=$5, database_engine=$6 + WHERE id=$1 + """ await self.db.execute(q, *self._values) async def delete(self) -> None: diff --git a/maubot/db/upgrade/__init__.py b/maubot/db/upgrade/__init__.py index 146e713..ed96422 100644 --- a/maubot/db/upgrade/__init__.py +++ b/maubot/db/upgrade/__init__.py @@ -2,4 +2,4 @@ from mautrix.util.async_db import UpgradeTable upgrade_table = UpgradeTable() -from . import v01_initial_revision +from . import v01_initial_revision, v02_instance_database_engine diff --git a/maubot/db/upgrade/v02_instance_database_engine.py b/maubot/db/upgrade/v02_instance_database_engine.py new file mode 100644 index 0000000..7d2d7e7 --- /dev/null +++ b/maubot/db/upgrade/v02_instance_database_engine.py @@ -0,0 +1,25 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2022 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from mautrix.util.async_db import Connection + +from . import upgrade_table + + +@upgrade_table.register(description="Store instance database engine") +async def upgrade_v2(conn: Connection) -> None: + await conn.execute("ALTER TABLE instance ADD COLUMN database_engine TEXT") diff --git a/maubot/instance.py b/maubot/instance.py index b9b1c23..4d797e4 100644 --- a/maubot/instance.py +++ b/maubot/instance.py @@ -34,7 +34,7 @@ from mautrix.util.config import BaseProxyConfig, RecursiveDict from mautrix.util.logging import TraceLogger from .client import Client -from .db import Instance as DBInstance +from .db import DatabaseEngine, Instance as DBInstance from .lib.plugin_db import ProxyPostgresDatabase from .loader import DatabaseType, PluginLoader, ZippedPluginLoader from .plugin_base import Plugin @@ -71,10 +71,21 @@ class PluginInstance(DBInstance): started: bool def __init__( - self, id: str, type: str, enabled: bool, primary_user: UserID, config: str = "" + self, + id: str, + type: str, + enabled: bool, + primary_user: UserID, + config: str = "", + database_engine: DatabaseEngine | None = None, ) -> None: super().__init__( - id=id, type=type, enabled=bool(enabled), primary_user=primary_user, config_str=config + id=id, + type=type, + enabled=bool(enabled), + primary_user=primary_user, + config_str=config, + database_engine=database_engine, ) def __hash__(self) -> int: @@ -111,6 +122,8 @@ class PluginInstance(DBInstance): "database": ( self.inst_db is not None and self.maubot.config["api_features.instance_database"] ), + "database_interface": self.loader.meta.database_type_str if self.loader else "unknown", + "database_engine": self.database_engine_str, } def _introspect_sqlalchemy(self) -> dict: @@ -269,12 +282,27 @@ class PluginInstance(DBInstance): self, upgrade_table: UpgradeTable | None = None, actually_start: bool = True ) -> None: if self.loader.meta.database_type == DatabaseType.SQLALCHEMY: + if self.database_engine is None: + await self.update_db_engine(DatabaseEngine.SQLITE) + elif self.database_engine == DatabaseEngine.POSTGRES: + raise RuntimeError( + "Instance database engine is marked as Postgres, but plugin uses legacy " + "database interface, which doesn't support postgres." + ) self.inst_db = sql.create_engine(f"sqlite:///{self._sqlite_db_path}") elif self.loader.meta.database_type == DatabaseType.ASYNCPG: + if self.database_engine is None: + if os.path.exists(self._sqlite_db_path) or not self.maubot.plugin_postgres_db: + await self.update_db_engine(DatabaseEngine.SQLITE) + else: + await self.update_db_engine(DatabaseEngine.POSTGRES) instance_db_log = db_log.getChild(self.id) - # TODO should there be a way to choose between SQLite and Postgres - # for individual instances? Maybe checking the existence of the SQLite file. - if self.maubot.plugin_postgres_db: + if self.database_engine == DatabaseEngine.POSTGRES: + if not self.maubot.plugin_postgres_db: + raise RuntimeError( + "Instance database engine is marked as Postgres, but this maubot isn't " + "configured to support Postgres for plugin databases" + ) self.inst_db = ProxyPostgresDatabase( pool=self.maubot.plugin_postgres_db, instance_id=self.id, @@ -334,7 +362,12 @@ class PluginInstance(DBInstance): self.log.debug("Disabling webapp after plugin meta reload") self.disable_webapp() if self.loader.meta.database: - await self.start_database(cls.get_db_upgrade_table()) + try: + await self.start_database(cls.get_db_upgrade_table()) + except Exception: + self.log.exception("Failed to start instance database") + await self.update_enabled(False) + return config_class = cls.get_config_class() if config_class: try: @@ -455,6 +488,11 @@ class PluginInstance(DBInstance): self.enabled = enabled await self.update() + async def update_db_engine(self, db_engine: DatabaseEngine | None) -> None: + if db_engine is not None and db_engine != self.database_engine: + self.database_engine = db_engine + await self.update() + @classmethod @async_getter_lock async def get( diff --git a/maubot/loader/meta.py b/maubot/loader/meta.py index f16937b..d368e24 100644 --- a/maubot/loader/meta.py +++ b/maubot/loader/meta.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import List +from typing import List, Optional from attr import dataclass from packaging.version import InvalidVersion, Version @@ -63,3 +63,7 @@ class PluginMeta(SerializableAttrs): extra_files: List[str] = [] dependencies: List[str] = [] soft_dependencies: List[str] = [] + + @property + def database_type_str(self) -> Optional[str]: + return self.database_type.value if self.database else None diff --git a/maubot/management/frontend/src/pages/dashboard/Instance.js b/maubot/management/frontend/src/pages/dashboard/Instance.js index c487fb0..049c5e5 100644 --- a/maubot/management/frontend/src/pages/dashboard/Instance.js +++ b/maubot/management/frontend/src/pages/dashboard/Instance.js @@ -43,7 +43,7 @@ class Instance extends BaseMainView { } get entryKeys() { - return ["id", "primary_user", "enabled", "started", "type", "config"] + return ["id", "primary_user", "enabled", "started", "type", "config", "database_engine"] } get initialState() { @@ -54,6 +54,7 @@ class Instance extends BaseMainView { started: true, type: "", config: "", + database_engine: "", saving: false, deleting: false,