Compare commits

...

2 Commits

Author SHA1 Message Date
Ferdinand Thiessen ee38e46c12 [core] Cleanup + Fix loading migrations of (dis)abled plugins
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 22:59:19 +02:00
Ferdinand Thiessen d3530cc15f The enabled state of plugins is now loaded from database rather than config file
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 21:22:47 +02:00
13 changed files with 157 additions and 79 deletions

View File

@ -1,4 +1,5 @@
from pathlib import Path
alembic_migrations = str(Path(__file__).resolve().parent / "migrations")
alembic_migrations_path = str(Path(__file__).resolve().parent / "migrations")
alembic_script_path = str(Path(__file__).resolve().parent)

View File

@ -5,14 +5,12 @@ from flask_cors import CORS
from datetime import datetime, date
from flask.json import JSONEncoder, jsonify
from importlib.metadata import entry_points
from sqlalchemy.exc import OperationalError
from werkzeug.exceptions import HTTPException
from flaschengeist import logger
from flaschengeist.models import Plugin
from flaschengeist.controller import pluginController
from flaschengeist.utils.hook import Hook
from flaschengeist.plugins import AuthPlugin
from flaschengeist.config import config, configure_app
from flaschengeist.config import configure_app
class CustomJSONEncoder(JSONEncoder):
@ -39,40 +37,29 @@ class CustomJSONEncoder(JSONEncoder):
@Hook("plugins.loaded")
def load_plugins(app: Flask):
app.config["FG_PLUGINS"] = {}
all_plugins = entry_points(group="flaschengeist.plugins")
for entry_point in entry_points(group="flaschengeist.plugins"):
logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})")
if entry_point.name == config["FLASCHENGEIST"]["auth"] or (
entry_point.name in config and config[entry_point.name].get("enabled", False)
):
logger.debug(f"Load plugin {entry_point.name}")
try:
plugin = entry_point.load()(entry_point, config=config.get(entry_point.name, {}))
if hasattr(plugin, "blueprint") and plugin.blueprint is not None:
app.register_blueprint(plugin.blueprint)
except:
logger.error(
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.",
exc_info=True,
)
continue
if isinstance(plugin, AuthPlugin):
if entry_point.name != config["FLASCHENGEIST"]["auth"]:
logger.debug(f"Unload not configured AuthPlugin {entry_point.name}")
del plugin
continue
else:
logger.info(f"Using authentication plugin: {entry_point.name}")
app.config["FG_AUTH_BACKEND"] = plugin
else:
logger.info(f"Using plugin: {entry_point.name}")
app.config["FG_PLUGINS"][entry_point.name] = plugin
else:
logger.debug(f"Skip disabled plugin {entry_point.name}")
if "FG_AUTH_BACKEND" not in app.config:
logger.fatal("No authentication plugin configured or authentication plugin not found")
raise RuntimeError("No authentication plugin configured or authentication plugin not found")
for plugin in pluginController.get_enabled_plugins():
logger.debug(f"Searching for enabled plugin {plugin.name}")
entry_point = all_plugins.select(name=plugin.name)
if not entry_point:
logger.error(
f"Plugin {plugin.name} was enabled, but could not be found.",
exc_info=True,
)
continue
try:
loaded = entry_point[0].load()(entry_point[0])
if hasattr(plugin, "blueprint") and plugin.blueprint is not None:
app.register_blueprint(plugin.blueprint)
except:
logger.error(
f"Plugin {plugin.name} was enabled, but could not be loaded due to an error.",
exc_info=True,
)
continue
logger.info(f"Loaded plugin: {plugin.name}")
app.config["FG_PLUGINS"][plugin.name] = loaded
def create_app(test_config=None, cli=False):

View File

@ -3,7 +3,7 @@ from click.decorators import pass_context
from flask.cli import with_appcontext
from flask_migrate import upgrade
from flaschengeist.alembic import alembic_migrations
from flaschengeist.alembic import alembic_migrations_path
from flaschengeist.cli.plugin_cmd import install_plugin_command
from flaschengeist.utils.hook import Hook
@ -14,7 +14,7 @@ from flaschengeist.utils.hook import Hook
@Hook("plugins.installed")
def install(ctx):
# Install database
upgrade(alembic_migrations, revision="heads")
upgrade(alembic_migrations_path, revision="heads")
# Install plugins
install_plugin_command(ctx, [], True)

View File

@ -77,17 +77,6 @@ def configure_app(app, test_config=None):
configure_logger()
# Always enable this builtin plugins!
update_dict(
config,
{
"auth": {"enabled": True},
"roles": {"enabled": True},
"users": {"enabled": True},
"scheduler": {"enabled": True},
},
)
if "secret_key" not in config["FLASCHENGEIST"]:
logger.critical("No secret key was configured, please configure one for production systems!")
raise RuntimeError("No secret key was configured")

View File

@ -1,5 +1,5 @@
from flaschengeist.utils.hook import Hook
from flaschengeist.models import User, Role
from ..utils.hook import Hook
from ..models import User, Role
class Message:

View File

@ -8,14 +8,35 @@ import sqlalchemy
from typing import Union
from flask import current_app
from werkzeug.exceptions import NotFound
from sqlalchemy.exc import OperationalError
from importlib.metadata import entry_points
from .. import logger
from ..database import db
from ..utils import Hook
from ..utils.hook import Hook
from ..models import Plugin, PluginSetting, Notification
def get_enabled_plugins():
try:
enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all()
except OperationalError as e:
class PluginStub:
def __init__(self, name) -> None:
self.name = name
logger.error("Could not connect to database or database not initialized! No plugins enabled!")
logger.debug("Can not query enabled plugins", exc_info=True)
enabled_plugins = [
PluginStub("auth"),
PluginStub("roles"),
PluginStub("users"),
PluginStub("scheduler"),
]
return enabled_plugins
def get_setting(plugin_id: str, name: str, **kwargs):
"""Get plugin setting from database
@ -29,9 +50,7 @@ def get_setting(plugin_id: str, name: str, **kwargs):
`KeyError` if no such setting exists in the database
"""
try:
setting = (
PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one()
)
setting = PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one()
return setting.value
except sqlalchemy.orm.exc.NoResultFound:
if "default" in kwargs:
@ -49,9 +68,7 @@ def set_setting(plugin_id: str, name: str, value):
value: Value to be stored
"""
setting = (
PluginSetting.query.filter(PluginSetting.plugin == plugin_id)
.filter(PluginSetting.name == name)
.one_or_none()
PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one_or_none()
)
if setting is not None:
if value is None:
@ -81,3 +98,64 @@ def notify(plugin_id: str, user, text: str, data=None):
db.session.add(n)
db.session.commit()
return n.id
@Hook("plugins.installed")
def install_plugin(plugin_name: str):
logger.debug(f"Installing plugin {plugin_name}")
entry_point = entry_points(group="flaschengeist.plugins", name=plugin_name)
if not entry_point:
raise NotFound
plugin = entry_point[0].load()(entry_point[0])
entity = Plugin(name=plugin.name, version=plugin.version)
db.session.add(entity)
db.session.commit()
return entity
@Hook("plugin.uninstalled")
def uninstall_plugin(plugin_id: Union[str, int]):
plugin = disable_plugin(plugin_id)
logger.debug(f"Uninstall plugin {plugin.name}")
entity = current_app.config["FG_PLUGINS"][plugin.name]
entity.uninstall()
del current_app.config["FG_PLUGINS"][plugin.name]
db.session.delete(plugin)
db.session.commit()
@Hook("plugins.enabled")
def enable_plugin(plugin_id: Union[str, int]):
logger.debug(f"Enabling plugin {plugin_id}")
plugin: Plugin = Plugin.query
if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
if plugin is None:
logger.debug("Plugin not installed, trying to install")
plugin = install_plugin(plugin_id)
else:
plugin = plugin.get(plugin_id)
if plugin is None:
raise NotFound
plugin.enabled = True
db.session.commit()
return plugin
@Hook("plugins.disabled")
def disable_plugin(plugin_id: Union[str, int]):
logger.debug(f"Disabling plugin {plugin_id}")
plugin: Plugin = Plugin.query
if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
else:
plugin = plugin.get(plugin_id)
if plugin is None:
raise NotFound
plugin.enabled = False
db.session.commit()
return plugin

View File

@ -4,7 +4,9 @@ from flask_sqlalchemy import SQLAlchemy
from importlib.metadata import EntryPoint, entry_points, distribution
from sqlalchemy import MetaData
from flaschengeist.alembic import alembic_script_path
from flaschengeist import logger
from flaschengeist.controller import pluginController
# https://alembic.sqlalchemy.org/en/latest/naming.html
metadata = MetaData(
@ -31,25 +33,26 @@ def configure_alembic(config: Config):
uninstall can break the alembic version management.
"""
# Set main script location
config.set_main_option(
"script_location", str(distribution("flaschengeist").locate_file("") / "flaschengeist" / "alembic")
)
config.set_main_option("script_location", alembic_script_path)
# Set Flaschengeist's migrations
migrations = [config.get_main_option("script_location") + "/migrations"]
# Gather all migration paths
ep: EntryPoint
for ep in entry_points(group="flaschengeist.plugins"):
all_plugins = entry_points(group="flaschengeist.plugins")
for plugin in pluginController.get_enabled_plugins():
entry_point = all_plugins.select(name=plugin.name)
if not entry_point:
continue
try:
directory = ep.dist.locate_file("")
for loc in ep.module.split(".") + ["migrations"]:
directory = entry_point.dist.locate_file("")
for loc in entry_point.module.split(".") + ["migrations"]:
directory /= loc
if directory.exists():
logger.debug(f"Adding migration version path {directory}")
migrations.append(str(directory.resolve()))
except:
logger.warning(f"Could not load migrations of plugin {ep.name} for database migration.")
logger.warning(f"Could not load migrations of plugin {plugin.name} for database migration.")
logger.debug("Plugin loading failed", exc_info=True)
# write back seperator (we changed it if neither seperator nor locations were specified)

View File

@ -2,4 +2,4 @@ from .session import *
from .user import *
from .plugin import *
from .notification import *
from .image import *
from .image import *

View File

@ -8,10 +8,17 @@ from ..database.types import Serial, UtcDateTime, ModelSerializeMixin
class Notification(db.Model, ModelSerializeMixin):
__tablename__ = "notification"
id: int = db.Column("id", Serial, primary_key=True)
plugin: str = db.Column(db.String(127), nullable=False)
text: str = db.Column(db.Text)
data: Any = db.Column(db.PickleType(protocol=4))
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
user_id_: int = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False)
user_: User = db.relationship("User")
user_id_: int = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False)
plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"), nullable=False)
user_: "User" = db.relationship("User")
plugin_: "Plugin" = db.relationship(
"Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan")
)
@property
def plugin(self):
return self.plugin_.name

View File

@ -17,11 +17,13 @@ role_permission_association_table = db.Table(
db.Column("permission_id", Serial, db.ForeignKey("permission.id")),
)
class Permission(db.Model, ModelSerializeMixin):
__tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True)
_id = db.Column("id", Serial, primary_key=True)
_plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
class Role(db.Model, ModelSerializeMixin):

View File

@ -115,10 +115,10 @@ class BasePlugin:
```
"""
def __init__(self, entry_point: EntryPoint, config=None):
def __init__(self, entry_point: EntryPoint):
"""Constructor called by create_app
Args:
config: Dict configuration containing the plugin section
entry_point: EntryPoint from which this plugin was loaded
"""
self.version = entry_point.dist.version
self.name = entry_point.name
@ -127,6 +127,8 @@ class BasePlugin:
def install(self):
"""Installation routine
Also called when updating the plugin, compare `version` and `installed_version`.
Is always called with Flask application context,
it is called after the plugin permissions are installed.
"""
@ -143,6 +145,14 @@ class BasePlugin:
"""
pass
@property
def installed_version(self):
"""Installed version of the plugin"""
from ..controller import pluginController
self.__installed_version = pluginController.get_installed_version(self.name)
return self.__installed_version
def get_setting(self, name: str, **kwargs):
"""Get plugin setting from database

View File

@ -2,6 +2,7 @@ from flask import Blueprint
from datetime import datetime, timedelta
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.HTTP import no_content
@ -38,8 +39,8 @@ def scheduled(id: str, replace=False, **kwargs):
class SchedulerPlugin(BasePlugin):
def __init__(self, entry_point, config=None):
super().__init__(entry_point, config)
def __init__(self, entry_point):
super().__init__(entry_point)
self.blueprint = Blueprint(self.name, __name__)
def __view_func():
@ -52,9 +53,9 @@ class SchedulerPlugin(BasePlugin):
except:
logger.error("Error while executing scheduled tasks!", exc_info=True)
cron = None if config is None else config.get("cron", "passive_web").lower()
cron = config.get("scheduler", {}).get("cron", "passive_web").lower()
if cron is None or cron == "passive_web":
if cron == "passive_web":
self.blueprint.teardown_app_request(__passiv_func)
elif cron == "active_web":
self.blueprint.add_url_rule("/cron", view_func=__view_func)

View File

@ -10,7 +10,7 @@ from . import permissions
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.plugins import BasePlugin
from flaschengeist.models.user import User
from flaschengeist.models import User
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController
from flaschengeist.utils.HTTP import created, no_content