From d3161f5c05d8ad652007c05d6b6861cdb17b9abd Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 19 Jan 2023 21:43:57 +0100 Subject: implement maintenance mode --- CHANGELOG.rst | 5 +++ doc/administration.rst | 1 + doc/administration/maintenance-mode.rst | 70 +++++++++++++++++++++++++++++++++ fietsboek/__init__.py | 31 ++++++++++++++- fietsboek/data.py | 14 +++++++ fietsboek/scripts/fietscron.py | 9 ++++- fietsboek/scripts/fietsctl.py | 38 ++++++++++++++++++ 7 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 doc/administration/maintenance-mode.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dd4bfab..8b08927 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,11 @@ Changelog Unreleased ---------- +Added +^^^^^ + +- The maintenance mode. + 0.5.0 - 2023-01-12 ------------------ diff --git a/doc/administration.rst b/doc/administration.rst index 706a4cb..6cf0f52 100644 --- a/doc/administration.rst +++ b/doc/administration.rst @@ -11,6 +11,7 @@ Administration Guide administration/backup administration/cronjobs administration/custom-pages + administration/maintenance-mode 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/maintenance-mode.rst b/doc/administration/maintenance-mode.rst new file mode 100644 index 0000000..d02e27a --- /dev/null +++ b/doc/administration/maintenance-mode.rst @@ -0,0 +1,70 @@ +Maintenance Mode +================ + +Sometimes, it can be useful to deactivate your fietsboek instance temporarily, +for example when you want to apply updates, or you need to otherwise fiddle +with the data. Deactivating your instance avoids any "race issues" or accesses +to the database and such, which might be in inconsistent states. + +To help with this situation, fietsboek provides a *maintenance mode*. This +allows you to deactivate fietsboek without having to fiddle with your webserver +settings. + +In the maintenance mode, fietsboek will reply to any HTTP request with a ``503 +Service Unavailable`` response, and won't process the requests further. + +.. note:: + + The maintenance mode is implemented early in fietsboek, however if you have + custom HTTP proxies or other WSGI middleware between fietsboek and the + outside world, the maintenance mode will not affect those. + + In those cases, it might be better to deactivate the whole pipeline, + depending on the setup and the middleware used — at least if those also + access the database. + +In addition to not replying to HTTP requests, ``fietscron`` will also respect +the maintenance mode and do nothing. This is to avoid accidental interference +with long tasks like updating, such that a cronjob doesn't access data in an +invalid state. + +Unlike ``fietscron`` and the HTTP requests, ``fietsctl`` and ``fietsupdate`` +will **continue to work**. + +Controlling the Maintenance Mode +-------------------------------- + +You can enable and disable the maintenance mode using the ``fietsctl`` script. +To enable the mode, simply pass a reason (that will be shown to users) to +``fietsctl maintenance-mode``:: + + fietsctl -c production.ini maintenance-mode "Updating the instance" + +Similarly, you can disable the maintenance mode by passing the ``--disable`` +flag:: + + fietsctl -c production.ini maintenance-mode --disable + +To check the current status of the maintenance mode, call the subcommand +without extra arguments:: + + fietsctl -c production.ini maintenance-mode + +Manually Intervening with Maintenance +------------------------------------- + +If you cannot (or don't want to) use the ``fietsctl maintenance-mode`` command, +you can also manually control the maintenance mode: The :file:`MAINTENANCE` +file in the data directory controls whether the mode is enabled or disabled. + +To enable the maintenance mode, it is enough to ensure that the file exists:: + + touch data-dir/MAINTENANCE + +The reason for the maintenance mode is saved in the file content:: + + echo "Updating the instance" > data-dir/MAINTENANCE + +To disable the maintenance mode, remove the file again:: + + rm data-dir/MAINTENANCE diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py index 423e901..10ba164 100644 --- a/fietsboek/__init__.py +++ b/fietsboek/__init__.py @@ -14,14 +14,17 @@ Content ------- """ from pathlib import Path -from typing import Optional +from typing import Callable, Optional import importlib_metadata import redis from pyramid.config import Configurator from pyramid.csrf import CookieCSRFStoragePolicy +from pyramid.httpexceptions import HTTPServiceUnavailable from pyramid.i18n import default_locale_negotiator +from pyramid.registry import Registry from pyramid.request import Request +from pyramid.response import Response from pyramid.session import SignedCookieSessionFactory from . import config as mod_config @@ -59,6 +62,31 @@ def locale_negotiator(request: Request) -> Optional[str]: return negotiated +def maintenance_mode( + handler: Callable[[Request], Response], _registry: Registry +) -> Callable[[Request], Response]: + """A Pyramid Tween that handles the maintenance mode. + + Note that we do this as a tween to ensure we check for the maintenance mode + as early as possible. This avoids hitting the DB, which might be in an + inconsistent state. + + :param handler: The next handler in the tween/view chain. + :param registry: The application registry. + """ + + def tween(request: Request) -> Response: + maintenance = request.data_manager.maintenance_mode() + if maintenance is None: + return handler(request) + + return HTTPServiceUnavailable( + f"This fietsboek is currently in maintenance mode: {maintenance}", + ) + + return tween + + def main(_global_config, **settings): """This function returns a Pyramid WSGI application.""" parsed_config = mod_config.parse(settings) @@ -90,6 +118,7 @@ def main(_global_config, **settings): config.include("pyramid_jinja2") config.include(".routes") config.include(".models") + config.add_tween(".maintenance_mode") config.scan() config.add_translation_dirs("fietsboek:locale/") for pack in parsed_config.language_packs: diff --git a/fietsboek/data.py b/fietsboek/data.py index f32e24f..96acaec 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -54,6 +54,20 @@ class DataManager: def _track_data_dir(self, track_id): return self.data_dir / "tracks" / str(track_id) + def maintenance_mode(self) -> Optional[str]: + """Checks whether the maintenance mode is enabled. + + If maintenance mode is enabled, returns the reason given. + + If maintenance mode is disabled, returns ``None``. + + :return: The maintenance mode state. + """ + try: + return (self.data_dir / "MAINTENANCE").read_text() + except FileNotFoundError: + return None + def initialize(self, track_id: int) -> "TrackDataDir": """Creates the data directory for a track. diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index 3b22ac2..a142f39 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -37,9 +37,14 @@ def cli(config): settings = pyramid.paster.get_appsettings(config) config = mod_config.parse(settings) - engine = create_engine(config.sqlalchemy_url) - + # Do this early to reduce the chances of "accidentally" hitting the + # database when maintenance mode is turned on. data_manager = DataManager(config.data_dir) + if data_manager.maintenance_mode() is not None: + LOGGER.info("Skipping cronjob tasks due to maintenance mode") + return + + engine = create_engine(config.sqlalchemy_url) LOGGER.debug("Starting maintenance tasks") remove_old_uploads(engine) diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py index 400fa9b..a1f14f7 100644 --- a/fietsboek/scripts/fietsctl.py +++ b/fietsboek/scripts/fietsctl.py @@ -137,6 +137,26 @@ def cmd_passwd(env, args): return EXIT_OKAY +def cmd_maintenance_mode(env, args): + """Check the status of the maintenance mode, or activate or deactive it. + + With neither `--disable` nor `reason` given, just checks the state of the + maintenance mode. + """ + data_manager = env["request"].data_manager + if args.reason is None and not args.disable: + maintenance = data_manager.maintenance_mode() + if maintenance is None: + print("Maintenance mode is disabled") + else: + print(f"Maintenance mode is enabled: {maintenance}") + elif args.disable: + (data_manager.data_dir / "MAINTENANCE").unlink() + else: + (data_manager.data_dir / "MAINTENANCE").write_text(args.reason) + return EXIT_OKAY + + def cmd_version(): """Show the installed fietsboek version.""" name = __name__.split(".", 1)[0] @@ -247,6 +267,24 @@ def parse_args(argv): ) p_passwd.set_defaults(func=cmd_passwd) + p_maintenance = subparsers.add_parser( + "maintenance-mode", + help="enable or disable the maintenance mode", + description=cmd_maintenance_mode.__doc__, + ) + group = p_maintenance.add_mutually_exclusive_group(required=False) + group.add_argument( + "--disable", + action="store_true", + help="disable the maintenance mode", + ) + group.add_argument( + "reason", + nargs="?", + help="reason for the maintenance", + ) + p_maintenance.set_defaults(func=cmd_maintenance_mode) + return parser.parse_args(argv[1:]), parser -- cgit v1.2.3