diff options
-rw-r--r-- | Dockerfile | 17 | ||||
-rw-r--r-- | container/entrypoint | 62 | ||||
-rw-r--r-- | container/gunicorn.conf.py | 1 | ||||
-rw-r--r-- | doc/administration.rst | 1 | ||||
-rw-r--r-- | doc/administration/docker.rst | 160 | ||||
-rw-r--r-- | fietsboek/__init__.py | 30 | ||||
-rw-r--r-- | fietsboek/scripts/__init__.py | 37 | ||||
-rw-r--r-- | fietsboek/scripts/fietscron.py | 9 | ||||
-rw-r--r-- | fietsboek/updater/__init__.py | 125 |
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) |