From: Vasek Sraier Date: Thu, 6 Jan 2022 08:56:23 +0000 (+0100) Subject: manager: signals handling for shutdown (SIGINT) and config reload (SIGHUP) X-Git-Tag: v6.0.0a1~63 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=2df42adbf05461505feae44919f83c3d75adb5b7;p=thirdparty%2Fknot-resolver.git manager: signals handling for shutdown (SIGINT) and config reload (SIGHUP) closes #41 closes #42 --- diff --git a/manager/knot-resolver-manager.service b/manager/knot-resolver-manager.service index e11de1b71..277998c26 100644 --- a/manager/knot-resolver-manager.service +++ b/manager/knot-resolver-manager.service @@ -6,6 +6,8 @@ After=dbus.service [Service] ExecStart=python3 -m knot_resolver_manager --config=/etc/knot-resolver/config.yml KillSignal=SIGINT +# See systemd.service(5) for explanation, why we should replace this with a blocking request +ExecReload=kill -HUP $MAINPID [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/manager/knot_resolver_manager/compat/asyncio.py b/manager/knot_resolver_manager/compat/asyncio.py index d7cf150c1..5db8add53 100644 --- a/manager/knot_resolver_manager/compat/asyncio.py +++ b/manager/knot_resolver_manager/compat/asyncio.py @@ -83,3 +83,8 @@ def run(coro: Coroutine[Any, T, NoneType], debug: Optional[bool] = None) -> Awai return loop.run_until_complete(coro) # asyncio.run would cancel all running tasks, but it would use internal API for that # so let's ignore it and let the tasks die + + +def add_async_signal_handler(signal: int, callback: Callable[[], Awaitable[None]]): + loop = asyncio.get_event_loop() + loop.add_signal_handler(signal, lambda: create_task(callback())) diff --git a/manager/knot_resolver_manager/kres_manager.py b/manager/knot_resolver_manager/kres_manager.py index 1dc772134..c4205afc6 100644 --- a/manager/knot_resolver_manager/kres_manager.py +++ b/manager/knot_resolver_manager/kres_manager.py @@ -53,6 +53,7 @@ class KresManager: self._watchdog_task = create_task(self._watchdog()) await self._load_system_state() + # registering the function calls them immediately, therefore after this, the config is applied await config_store.register_verifier(self.validate_config) await config_store.register_on_change_callback(self.apply_config) diff --git a/manager/knot_resolver_manager/server.py b/manager/knot_resolver_manager/server.py index 0d2fc5788..4f879b884 100644 --- a/manager/knot_resolver_manager/server.py +++ b/manager/knot_resolver_manager/server.py @@ -1,6 +1,7 @@ import asyncio import logging import os +import signal import sys from http import HTTPStatus from pathlib import Path @@ -14,6 +15,7 @@ from aiohttp.web_response import json_response from aiohttp.web_runner import AppRunner, TCPSite, UnixSite from knot_resolver_manager import log +from knot_resolver_manager.compat import asyncio as asyncio_compat from knot_resolver_manager.config_store import ConfigStore from knot_resolver_manager.constants import DEFAULT_MANAGER_CONFIG_FILE from knot_resolver_manager.datamodel.config_schema import KresConfig @@ -59,7 +61,7 @@ class Server: # This is top-level class containing pretty much everything. Instead of global # variables, we use instance attributes. That's why there are so many and it's # ok. - def __init__(self, store: ConfigStore): + def __init__(self, store: ConfigStore, config_path: Optional[Path]): # config store & server dynamic reconfiguration self.config_store = store @@ -69,6 +71,7 @@ class Server: self.listen: Optional[Listen] = None self.site: Union[NoneType, TCPSite, UnixSite] = None self.listen_lock = asyncio.Lock() + self._config_path: Optional[Path] = config_path self.shutdown_event = asyncio.Event() @@ -86,8 +89,27 @@ class Server: return Result.ok(None) + async def sigint_handler(self): + logger.info("Received SIGINT, triggering graceful shutdown") + self.shutdown_event.set() + + async def sighup_handler(self) -> None: + logger.info("Received SIGHUP, reloading configuration file") + if self._config_path is None: + logger.warning("The manager was started with inlined configuration - can't reload") + else: + data = await readfile(self._config_path) + config = KresConfig(parse_yaml(data)) + + try: + await self.config_store.update(config) + except KresdManagerException as e: + logger.error(f"Reloading of the configuration file failed. {e}") + async def start(self): self._setup_routes() + asyncio_compat.add_async_signal_handler(signal.SIGINT, self.sigint_handler) + asyncio_compat.add_async_signal_handler(signal.SIGHUP, self.sighup_handler) await self.runner.setup() await self.config_store.register_verifier(self._deny_listen_address_changes) await self.config_store.register_on_change_callback(self._reconfigure) @@ -225,9 +247,6 @@ _DEFAULT_SENTINEL = _DefaultSentinel() async def _load_raw_config(config: Union[Path, ParsedTree, _DefaultSentinel]) -> ParsedTree: # Initial configuration of the manager - if isinstance(config, _DefaultSentinel): - # use default - config = DEFAULT_MANAGER_CONFIG_FILE if isinstance(config, Path): if not config.exists(): raise KresdManagerException( @@ -286,12 +305,20 @@ def _set_working_directory(config_raw: ParsedTree): os.chdir(config.server.rundir.to_path()) -async def start_server(config: Union[Path, ParsedTree, _DefaultSentinel] = _DEFAULT_SENTINEL): +async def start_server(config: Union[Path, ParsedTree] = DEFAULT_MANAGER_CONFIG_FILE): start_time = time() + manager: Optional[KresManager] = None + + # Block signals during initialization to force their processing once everything is ready + signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT, signal.SIGHUP}) # before starting server, initialize the subprocess controller, config store, etc. Any errors during inicialization # are fatal try: + # Make sure that the config path does not change meaning when we change working directory + if isinstance(config, Path): + config = config.absolute() + # Preprocess config - load from file or in general take it to the last step before validation. config_raw = await _load_raw_config(config) @@ -326,10 +353,13 @@ async def start_server(config: Union[Path, ParsedTree, _DefaultSentinel] = _DEFA # At this point, all backend functionality-providing components are initialized. It's therefore save to start # the API server. - server = Server(config_store) + server = Server(config_store, config if isinstance(config, Path) else None) await server.start() logger.info(f"Manager fully initialized and running in {round(time() - start_time, 3)} seconds") + # Now we are ready to process all signals + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGINT, signal.SIGHUP}) + await server.wait_for_shutdown() # After triggering shutdown, we neet to clean everything up