aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Dockerfile17
-rw-r--r--container/entrypoint62
-rw-r--r--container/gunicorn.conf.py1
-rw-r--r--doc/administration.rst1
-rw-r--r--doc/administration/docker.rst160
-rw-r--r--fietsboek/__init__.py30
-rw-r--r--fietsboek/scripts/__init__.py37
-rw-r--r--fietsboek/scripts/fietscron.py9
-rw-r--r--fietsboek/updater/__init__.py125
9 files changed, 386 insertions, 56 deletions
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..262cfac
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+FROM python:3.11
+RUN pip install gunicorn psycopg2-binary mysqlclient
+
+RUN mkdir /package
+WORKDIR /package/
+COPY ["fietsboek", "fietsboek"]
+COPY ["pyproject.toml", "README.md", "LICENSE.txt", "CHANGELOG.rst", "production.ini", "."]
+RUN pip install .
+
+COPY --chmod=755 ["container/entrypoint", "/bin/entrypoint"]
+COPY ["container/gunicorn.conf.py", "/fietsboek/gunicorn.conf.py"]
+
+VOLUME /fietsboek/data /fietsboek/database /fietsboek/pages
+WORKDIR /fietsboek
+
+ENTRYPOINT ["/bin/entrypoint"]
+CMD ["gunicorn", "--paste", "/fietsboek/fietsboek.ini"]
diff --git a/container/entrypoint b/container/entrypoint
new file mode 100644
index 0000000..c7f3b2d
--- /dev/null
+++ b/container/entrypoint
@@ -0,0 +1,62 @@
+#!/bin/bash
+set -euo pipefail
+
+error() {
+ >&2 echo "[ERROR] $1"
+}
+
+CONFIG="/fietsboek/fietsboek.ini"
+if [[ ! -f "$CONFIG" ]] ; then
+ # Copy the default config over
+ cp /package/production.ini "$CONFIG"
+ # Change default settings
+ : ${SQLALCHEMY_URL:=sqlite:////fietsboek/database/fietsboek.sqlite}
+ sed -i '/^sqlalchemy\.url = /c sqlalchemy.url = '"$SQLALCHEMY_URL" "$CONFIG"
+
+ if [[ ! -v REDIS_URL ]] ; then
+ error "need REDIS_URL set"
+ exit 1
+ fi
+ sed -i '/^\[app:main\]/a redis.url = '"$REDIS_URL" "$CONFIG"
+
+ : ${DATA_DIR:=/fietsboek/data}
+ mkdir -p "$DATA_DIR"
+ sed -i '/^\[app:main\]/a fietsboek.data_dir = '"$DATA_DIR" "$CONFIG"
+
+ : ${SESSION_KEY:=$(openssl rand -hex 20)}
+ sed -i '/^session_key = /c session_key = '"$SESSION_KEY" "$CONFIG"
+
+ sed -i '/^\[app:main\]/a fietsboek.pages = /fietsboek/pages' "$CONFIG"
+
+ : ${ENABLE_ACCOUNT_REGISTRATION:=true}
+ sed -i '/^enable_account_registration =/c enable_account_registration = '"$ENABLE_ACCOUNT_REGISTRATION" "$CONFIG"
+
+ if [[ -v DEFAULT_LOCALE_NAME ]] ; then
+ sed -i '/^pyramid\.default_locale_name =/c pyramid.default_locale_name = '"$DEFAULT_LOCALE_NAME" "$CONFIG"
+ fi
+
+ if [[ -v EMAIL_FROM ]] ; then
+ sed -i '/^email\.from =/c email.from = '"$EMAIL_FROM" "$CONFIG"
+ fi
+ if [[ -v EMAIL_SMTP_URL ]] ; then
+ sed -i '/^email\.smtp_url =/c email.smtp_url = '"$EMAIL_SMTP_URL" "$CONFIG"
+ fi
+ if [[ -v EMAIL_USERNAME ]] ; then
+ sed -i '/^email\.username =/c email.username = '"$EMAIL_USERNAME" "$CONFIG"
+ fi
+ if [[ -v EMAIL_PASSWORD ]] ; then
+ sed -i '/^email\.password =/c email.password = '"$EMAIL_PASSWORD" "$CONFIG"
+ fi
+
+ if [[ -v LOGLEVEL ]] ; then
+ # We are only changing the fietsboek log level here, as SQLAlchemy
+ # produces a lot of noise.
+ sed -i '/\[logger_fietsboek\]/{N;s/level = .*/level = '"$LOGLEVEL"'/}' "$CONFIG"
+ fi
+fi
+
+# Make sure the data schema is up to date, or rather initialize it if this is
+# the first time
+(cd /package && fietsupdate update -fc "$CONFIG")
+
+exec "$@"
diff --git a/container/gunicorn.conf.py b/container/gunicorn.conf.py
new file mode 100644
index 0000000..dc574f2
--- /dev/null
+++ b/container/gunicorn.conf.py
@@ -0,0 +1 @@
+bind = "0.0.0.0:8000"
diff --git a/doc/administration.rst b/doc/administration.rst
index 6cf0f52..e09258b 100644
--- a/doc/administration.rst
+++ b/doc/administration.rst
@@ -12,6 +12,7 @@ Administration Guide
administration/cronjobs
administration/custom-pages
administration/maintenance-mode
+ administration/docker
This guide contains information pertaining to the administration of a Fietsboek
instance. This includes the installation, updating, configuration, and backing
diff --git a/doc/administration/docker.rst b/doc/administration/docker.rst
new file mode 100644
index 0000000..ed44a05
--- /dev/null
+++ b/doc/administration/docker.rst
@@ -0,0 +1,160 @@
+Container Setup
+===============
+
+Like most applications, Fietsboek can be installed in a Docker container. This
+allows you to have Fietsboek isolated from the rest of the system, e.g. if you
+need to run a newer Python version.
+
+.. note::
+
+ Fietsboek's ``Dockerfile`` has been tested with Podman_, but it should
+ work just as well with Docker_.
+
+ The commands in this article use ``docker`` for the sake of consistency,
+ but it will work the same if you replace ``docker`` with ``podman`` (or use
+ ``podman-docker``).
+
+.. _Podman: https://podman.io/
+.. _Docker: https://www.docker.com/
+
+Building the Image
+------------------
+
+You can build the image by running ``docker build`` in the main directory. Note
+that the build will use the currently checked out version of Fietsboek, so make
+sure you are on the right version that you want to use::
+
+ docker build -t fietsboek .
+
+Container Layout
+----------------
+
+The container reserves three volumes:
+
+* ``/fietsboek/database`` for the SQL database.
+* ``/fietsboek/data`` for the track data.
+* ``/fietsboek/pages`` for the custom pages.
+
+Per default, the configuration file is expected at
+``/fietsboek/fietsboek.ini``, and the `Gunicorn configuration`_ at
+``/fietsboek/gunicorn.conf.py``.
+
+.. _Gunicorn configuration: https://docs.gunicorn.org/en/latest/configure.html#configuration-file
+
+Configuration
+-------------
+
+There are two ways to configure the Fietsboek instance in the container. You
+can either mount a custom configuration file to ``/fietsboek/fietsboek.ini``,
+or you can change common settings via environment variables. Note that you need
+to choose one way, as mounting a custom configuration will disable the
+environment variable parsing.
+
+If you need to change a lot of settings, using a custom configuration file is
+the prefered way. It follows the same syntax and settings as described in
+:doc:`configuration`. Keep the container layout in mind when setting the data
+directory and database location (or make sure that they are on other volumes).
+
+An example command could look like this::
+
+ docker run -v ./production.ini:/fietsboek/fietsboek.ini fietsboek
+
+If you simply want to get a quick instance up and running, the method of
+overwriting single configuration values via environment variables is quicker.
+This way, you do not need to provide the full configuration file, but simply
+the options that diverge from the default values::
+
+ docker run -e REDIS_URL=redis://localhost fietsboek
+
+The following environment variables are supported:
+
+=============================== ===============================
+Environment variable Setting
+=============================== ===============================
+``REDIS_URL`` [#f1]_ ``redis.url``
+``SQLALCHEMY_URL`` ``sqlalchemy.url``
+``DATA_DIR`` ``fietsboek.data_dir``
+``SESSION_KEY`` [#f2]_ ``session_key``
+``ENABLE_ACCOUNT_REGISTRATION`` ``enable_account_registration``
+``DEFAULT_LOCALE_NAME`` ``pyramid.default_locale_name``
+``EMAIL_FROM`` ``email.from``
+``EMAIL_SMTP_URL`` ``email.smtp_url``
+``EMAIL_USERNAME`` ``email.username``
+``EMAIL_PASSWORD`` ``email.password``
+``LOGLEVEL`` ``[logger_fietsboek] level``
+=============================== ===============================
+
+.. [#f1] Required.
+.. [#f2] Defaults to a random string.
+
+The ``fietsboek.pages`` setting is not exposed, as you can simply mount the
+directory containing the pages to ``/fietsboek/pages``.
+
+Upgrading
+---------
+
+The image is configured to automatically run ``fietsupdate`` at the start.
+Therefore, simply updating the image to the new Fietsboek version should work
+and the container will automatically migrate the data:
+
+.. code-block:: bash
+
+ # ... assume we checked out the new code, build the new image:
+ docker build -t fietsboek .
+ # Run the new code, taking the data from the old version:
+ docker run --volumes-from=OLD_CONTAINER_ID -e ... fietsboek
+
+.. warning::
+
+ As always, back up your data before doing an update!
+
+Maintenance & Cronjobs
+----------------------
+
+The image contains the maintenance tools ``fietsctl`` and ``fietscron``. You
+can execute them in the container:
+
+.. code-block:: bash
+
+ # Get a user list ...
+ docker exec -ti CONTAINER_ID fietsctl userlist
+ # Run the cronjobs ...
+ docker exec -ti CONTAINER_ID fietscron
+ # ... and so on
+
+Docker Compose
+--------------
+
+Since Fietsboek needs other services (such as a Redis_ instance, and optionally
+a SQL server like PostgreSQL_ or MariaDB_), it makes sense to put those
+services together in a ``docker-compose.yml`` file.
+
+A minimal example is given here:
+
+.. code-block:: yaml
+
+ services:
+ redis:
+ image: redis
+
+ fietsboek:
+ image: fietsboek
+ ports:
+ - "8000:8000"
+ environment:
+ - REDIS_URL=redis://redis
+ volumes:
+ - fietsboek-data:/fietsboek/data
+ - fietsboek-database:/fietsboek/database
+
+ volumes:
+ fietsboek-data:
+ fietsboek-database:
+
+
+This example shows how to use the environment-based configuration mechanism to
+get a instance up quickly, without changing the defaults too much.
+
+.. _Redis: https://redis.com/
+.. _PostgreSQL: https://www.postgresql.org/
+.. _MariaDB: https://mariadb.org/
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index 41e8a04..ee7d8b7 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -14,6 +14,7 @@ Content
-------
"""
import importlib.metadata
+import logging
from pathlib import Path
from typing import Callable, Optional
@@ -33,9 +34,12 @@ from . import transformers
from .data import DataManager
from .pages import Pages
from .security import SecurityPolicy
+from .updater import Updater, UpdateState
__VERSION__ = importlib.metadata.version("fietsboek")
+LOGGER = logging.getLogger(__name__)
+
def locale_negotiator(request: Request) -> Optional[str]:
"""Negotiates the right locale to use.
@@ -88,12 +92,36 @@ def maintenance_mode(
return tween
-def main(_global_config, **settings):
+def check_update_state(config_uri: str):
+ """Checks the update state of the data, and logs a warning if there is a
+ mismatch.
+
+ :param config_uri: Path to the configuration file.
+ """
+ updater = Updater(config_uri)
+ updater.load()
+ state = updater.state()
+
+ if state == UpdateState.OUTDATED:
+ LOGGER.warning(
+ "The data seems to be outdated - make sure to run the fietsupdate migrations!"
+ )
+ elif state == UpdateState.TOO_NEW:
+ LOGGER.warning("The data seems to be too new, make sure to update the code accordingly!")
+ elif state == UpdateState.UNKNOWN:
+ LOGGER.warning("Could not determine version state of the data - check `fietsupdate status`")
+
+
+def main(global_config, **settings):
"""This function returns a Pyramid WSGI application."""
# Avoid a circular import by not importing at the top level
# pylint: disable=import-outside-toplevel,cyclic-import
from .views.tileproxy import TileRequester
+ # In tests this isn't passed, so guard against it
+ if "__file__" in global_config:
+ check_update_state(global_config["__file__"])
+
parsed_config = mod_config.parse(settings)
settings["jinja2.newstyle"] = True
diff --git a/fietsboek/scripts/__init__.py b/fietsboek/scripts/__init__.py
index ea36d96..b8a883a 100644
--- a/fietsboek/scripts/__init__.py
+++ b/fietsboek/scripts/__init__.py
@@ -1,5 +1,39 @@
"""Various command line scripts to interact with the fietsboek installation."""
+from typing import Any, Optional
+
import click
+from click.core import ParameterSource
+
+
+class ConfigOption(click.Path):
+ """Special :class:`~click.Path` subclass to print a more helpful error
+ message.
+
+ This class recognizes if the config option is not given and prints a hint
+ if the file does not exist in this case.
+ """
+
+ def __init__(self):
+ super().__init__(exists=True, dir_okay=False)
+
+ def convert(
+ self,
+ value: Any,
+ param: Optional[click.Parameter],
+ ctx: Optional[click.Context],
+ ) -> Any:
+ try:
+ return super().convert(value, param, ctx)
+ except click.BadParameter as exc:
+ if param is not None and param.name is not None and ctx is not None:
+ source = ctx.get_parameter_source(param.name)
+ if source == ParameterSource.DEFAULT:
+ exc.message += (
+ f"\n\nHint: {value!r} is the default, "
+ "try specifying a configuration file explicitely."
+ )
+ raise exc
+
# We keep this as a separate option that is added to each subcommand as Click
# (unlike argparse) cannot handle "--help" without the required arguments of
@@ -10,8 +44,9 @@ import click
config_option = click.option(
"-c",
"--config",
- type=click.Path(exists=True, dir_okay=False),
+ type=ConfigOption(),
required=True,
+ default="fietsboek.ini",
help="Path to the Fietsboek configuration file",
)
diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py
index f0dc438..d8476a9 100644
--- a/fietsboek/scripts/fietscron.py
+++ b/fietsboek/scripts/fietscron.py
@@ -16,18 +16,13 @@ from .. import config as mod_config
from .. import hittekaart, models
from ..config import Config
from ..data import DataManager
+from . import config_option
LOGGER = logging.getLogger(__name__)
@click.command()
-@click.option(
- "-c",
- "--config",
- type=click.Path(exists=True, dir_okay=False),
- required=True,
- help="Path to the Fietsboek configuration file",
-)
+@config_option
def cli(config):
"""Runs regular maintenance operations on the instance.
diff --git a/fietsboek/updater/__init__.py b/fietsboek/updater/__init__.py
index 9e2e7f0..5faa805 100644
--- a/fietsboek/updater/__init__.py
+++ b/fietsboek/updater/__init__.py
@@ -1,12 +1,13 @@
"""Updating (data migration) logic for fietsboek."""
import datetime
+import enum
import importlib.resources
import importlib.util
import logging
import random
import string
from pathlib import Path
-from typing import List
+from typing import Optional
import alembic.command
import alembic.config
@@ -50,6 +51,26 @@ class Down(UpdateScript):
"""
+class UpdateState(enum.Enum):
+ """State of the applied updates.
+
+ This represents a "summary" of the output that ``fietsupdate status``
+ produces.
+ """
+
+ OKAY = enum.auto()
+ """Everything is good, the data is up to date."""
+
+ OUTDATED = enum.auto()
+ """The data is outdated, the update process should be run."""
+
+ TOO_NEW = enum.auto()
+ """The data contains revisions that are not known to Fietsboek yet."""
+
+ UNKNOWN = enum.auto()
+ """The data version could not be determined."""
+
+
class Updater:
"""A class that implements the updating logic.
@@ -57,20 +78,19 @@ class Updater:
them in the right order.
"""
- def __init__(self, config_path):
+ def __init__(self, config_path: str):
self.config_path = config_path
self.settings = pyramid.paster.get_appsettings(config_path)
self.alembic_config = alembic.config.Config(config_path)
- self.scripts = {}
- self.forward_dependencies = {}
- self.backward_dependencies = {}
+ self.scripts: dict[str, "UpdateScript"] = {}
+ self.forward_dependencies: dict[str, list[str]] = {}
+ self.backward_dependencies: dict[str, list[str]] = {}
@property
- def version_file(self):
+ def version_file(self) -> Path:
"""Returns the path to the version file.
:return: The path to the data's version file.
- :rytpe: pathlib.Path
"""
data_dir = Path(self.settings["fietsboek.data_dir"])
return data_dir / "VERSION"
@@ -99,20 +119,17 @@ class Updater:
down_alembic = possible_alembic
script.down_alembic = down_alembic
- def exists(self, revision_id):
+ def exists(self, revision_id: str) -> bool:
"""Checks if the revision with the given ID exists.
:param revision_id: ID of the revision to check.
- :type revision_id: str
:return: True if the revision exists.
- :rtype: bool
"""
return revision_id in self.scripts
- def current_versions(self):
+ def current_versions(self) -> list[str]:
"""Reads the current version of the data.
- :rtype: list[str]
:return: The versions, or an empty list if no versions are found.
"""
try:
@@ -121,7 +138,7 @@ class Updater:
except FileNotFoundError:
return []
- def _transitive_versions(self):
+ def _transitive_versions(self) -> set[str]:
versions = set()
queue = self.current_versions()
while queue:
@@ -131,21 +148,25 @@ class Updater:
queue.extend(self.scripts[current].previous)
return versions
- def _reverse_versions(self):
+ def _reverse_versions(self) -> set[str]:
all_versions = set(script.id for script in self.scripts.values())
return all_versions - self._transitive_versions()
- def stamp(self, versions):
+ def stamp(self, versions: list[str]):
"""Stampts the given version into the version file.
This does not run any updates, it simply updates the version information.
:param version: The versions to stamp.
- :type version: list[str]
"""
self.version_file.write_text("\n".join(versions), encoding="utf-8")
- def _pick_updates(self, wanted, applied, dependencies):
+ def _pick_updates(
+ self,
+ wanted: str,
+ applied: set[str],
+ dependencies: dict[str, list[str]],
+ ) -> set[str]:
to_apply = set()
queue = [wanted]
while queue:
@@ -156,9 +177,9 @@ class Updater:
queue.extend(dependencies[current])
return to_apply
- def _make_schedule(self, wanted, dependencies):
+ def _make_schedule(self, wanted: set[str], dependencies: dict[str, list[str]]) -> list[str]:
wanted = set(wanted)
- queue: List[str] = []
+ queue: list[str] = []
while wanted:
next_updates = {
update
@@ -169,19 +190,18 @@ class Updater:
wanted -= next_updates
return queue
- def _stamp_versions(self, old, new):
+ def _stamp_versions(self, old: list[str], new: list[str]):
versions = self.current_versions()
versions = [version for version in versions if version not in old]
versions.extend(new)
self.stamp(versions)
- def upgrade(self, target):
+ def upgrade(self, target: str):
"""Run the tasks to upgrade to the given target.
This ensures that all previous migrations are also run.
:param target: The target revision.
- :type target: str
"""
# First, we figure out which tasks we have already applied and which
# still need applying. This is pretty much a BFS over the current
@@ -198,13 +218,12 @@ class Updater:
script.upgrade(self.settings, self.alembic_config)
self._stamp_versions(script.previous, [script.id])
- def downgrade(self, target):
+ def downgrade(self, target: str):
"""Run the tasks to downgrade to the given target.
This ensures that all succeeding down-migrations are also run.
:param target: The target revision.
- :type target: str
"""
# This is basically the same as upgrade() but with the reverse
# dependencies instead.
@@ -218,16 +237,14 @@ class Updater:
script.downgrade(self.settings, self.alembic_config)
self._stamp_versions([script.id], script.previous)
- def new_revision(self, revision_id=None):
+ def new_revision(self, revision_id: Optional[str] = None) -> str:
"""Creates a new revision with the current versions as dependencies and
the current alembic version.
:param revision_id: The revision ID to use. By default, a random string
will be generated.
- :type revision_id: str
:return: The filename of the revision file in the ``updater/``
directory.
- :rtype: str
"""
if not revision_id:
revision_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
@@ -260,15 +277,14 @@ class Updater:
fobj.write(revision)
return filename
- def heads(self):
+ def heads(self) -> list[str]:
"""Returns all "heads", that are the latest revisions.
:return: The heads.
- :rtype: list[str]
"""
return [rev_id for (rev_id, deps) in self.backward_dependencies.items() if not deps]
- def has_applied(self, revision_id, backward=False):
+ def has_applied(self, revision_id: str, backward: bool = False) -> bool:
"""Checks whether the given revision is applied.
By default, this checks if a given update is applied, i.e. the current
@@ -281,60 +297,79 @@ class Updater:
:meth:`exists` to check whether the revision actually exists.
:param revision_id: The revision to check.
- :type revision_id: str
:param backward: Whether to switch the comparison direction.
- :type backward: bool
:return: ``True`` if the current version at least matches the asked
revision ID.
- :rtype: bool
"""
if not backward:
return revision_id in self._transitive_versions()
return revision_id in self._reverse_versions() | set(self.current_versions())
+ def state(self) -> UpdateState:
+ """Checks the update state of the instance.
+
+ This returns a condensed version of what ``fietsupdate status``
+ outputs.
+
+ :return: The update state of the data.
+ """
+ state = UpdateState.OKAY
+ current = self.current_versions()
+ heads = self.heads()
+ if current:
+ for i in current:
+ if not self.exists(i):
+ state = UpdateState.TOO_NEW
+ else:
+ return UpdateState.UNKNOWN
+ updates = set(heads) - set(current)
+ if updates:
+ if state != UpdateState.OKAY:
+ # We are both too new and too old, so something is pretty wrong
+ return UpdateState.UNKNOWN
+ return UpdateState.OUTDATED
+ return state
+
class UpdateScript:
"""Represents an update script."""
- def __init__(self, source, name):
+ def __init__(self, source: str, name: str):
self.name = name
spec = importlib.util.spec_from_loader(f"{__name__}.{name}", None)
self.module = importlib.util.module_from_spec(spec) # type: ignore
assert self.module
exec(source, self.module.__dict__) # pylint: disable=exec-used
- self.down_alembic = None
+ self.down_alembic: Optional[str] = None
def __repr__(self):
return f"<{__name__}.{self.__class__.__name__} name={self.name!r} id={self.id!r}>"
@property
- def id(self):
+ def id(self) -> str:
"""Returns the ID of the update.
- :rtype: str
:return: The id of the update
"""
return self.module.update_id
@property
- def previous(self):
+ def previous(self) -> list[str]:
"""Returns all dependencies of the update.
- :rtype: list[str]
:return: The IDs of all dependencies of the update.
"""
return getattr(self.module, "previous", [])
@property
- def alembic_version(self):
+ def alembic_version(self) -> str:
"""Returns the alembic revisions of the update.
- :rtype: list[str]
:return: The needed alembic revisions.
"""
return self.module.alembic_revision
- def upgrade(self, config, alembic_config):
+ def upgrade(self, config: dict, alembic_config: alembic.config.Config):
"""Runs the upgrade migrations of this update script.
This first runs the pre_alembic task, then the alembic migration, and
@@ -344,9 +379,7 @@ class UpdateScript:
executed.
:param config: The app configuration.
- :type config: dict
:param alembic_config: The alembic config to use.
- :type alembic_config: alembic.config.Config
"""
LOGGER.info("[up] Running pre-alembic task for %s", self.id)
self.module.Up().pre_alembic(config)
@@ -355,15 +388,13 @@ class UpdateScript:
LOGGER.info("[up] Running post-alembic task for %s", self.id)
self.module.Up().post_alembic(config)
- def downgrade(self, config, alembic_config):
+ def downgrade(self, config: dict, alembic_config: alembic.config.Config):
"""Runs the downgrade migrations of this update script.
See also :meth:`upgrade`.
:param config: The app configuration.
- :type config: dict
:param alembic_config: The alembic config to use.
- :type alembic_config: alembic.config.Config
"""
LOGGER.info("[down] Running pre-alembic task for %s", self.id)
self.module.Down().pre_alembic(config)