From c95810011dfa6dcbfdfccf9491f8b4fe1352a86d Mon Sep 17 00:00:00 2001 From: Simon Holesch Date: Sat, 20 Apr 2024 02:07:31 +0200 Subject: [PATCH] Hub: Load config file on startup Add ASGI lifetime hooks, to get notified on startup and shutdown. Load the config file on startup and configure the root logger. --- doc/index.md | 1 + doc/reference/hub-configuration.md | 23 +++++++++ not_my_board/_hub.py | 83 +++++++++++++++++++++++++----- not_my_board/cli/__init__.py | 4 +- 4 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 doc/reference/hub-configuration.md diff --git a/doc/index.md b/doc/index.md index 0e3b52a..1b1d747 100644 --- a/doc/index.md +++ b/doc/index.md @@ -60,6 +60,7 @@ how-to-guides/usb-import :hidden: reference/cli +reference/hub-configuration reference/export-description reference/import-description ``` diff --git a/doc/reference/hub-configuration.md b/doc/reference/hub-configuration.md new file mode 100644 index 0000000..c704cea --- /dev/null +++ b/doc/reference/hub-configuration.md @@ -0,0 +1,23 @@ +# Hub Configuration + +The *Hub* loads its configuration on startup from +`/etc/not-my-board/not-my-board-hub.toml`. The file format is +[TOML](https://toml.io/en/). + +## Settings + +### `log_level` + +**Type:** String \ +**Required:** No + +Configures the log level. Can be one of `debug`, `info`, `warning` or `error`. + +## Example + +Here's an example of a *Hub* configuration: +```{code-block} toml +:caption: /etc/not-my-board/not-my-board-hub.toml + +log_level = "info" +``` diff --git a/not_my_board/_hub.py b/not_my_board/_hub.py index 4ba96eb..03ee65d 100644 --- a/not_my_board/_hub.py +++ b/not_my_board/_hub.py @@ -6,6 +6,7 @@ import ipaddress import itertools import logging +import pathlib import random import traceback @@ -21,37 +22,80 @@ valid_tokens = ("dummy-token-1", "dummy-token-2") -def hub(): +def run_hub(): asgineer.run(asgi_app, "uvicorn", ":2092") +async def asgi_app(scope, receive, send): + if scope["type"] == "lifespan": + # asgineer doesn't expose the lifespan hooks. Handle them here + # before handing over to asgineer + await _handle_lifespan(scope, receive, send) + else: + # to_asgi() decorator adds extra arguments + # pylint: disable=too-many-function-args + await _handle_request(scope, receive, send) + + +async def _handle_lifespan(scope, receive, send): + while True: + message = await receive() + if message["type"] == "lifespan.startup": + try: + config_file = pathlib.Path("/etc/not-my-board/not-my-board-hub.toml") + if config_file.exists(): + config = util.toml_loads(config_file.read_text()) + else: + config = {} + + hub = Hub() + await hub.startup(config) + scope["state"]["hub"] = hub + except Exception as err: + await send({"type": "lifespan.startup.failed", "message": str(err)}) + else: + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + try: + await hub.shutdown() + except Exception as err: + await send({"type": "lifespan.shutdown.failed", "message": str(err)}) + else: + await send({"type": "lifespan.shutdown.complete"}) + return + else: + logger.warning("Unknown lifespan message %s", message["type"]) + + @asgineer.to_asgi -async def asgi_app(request): +async def _handle_request(request): + hub = request.scope["state"]["hub"] + if isinstance(request, asgineer.WebsocketRequest): if request.path == "/ws-agent": - return await _handle_agent(request) + return await _handle_agent(hub, request) elif request.path == "/ws-exporter": - return await _handle_exporter(request) + return await _handle_exporter(hub, request) await request.close() return elif isinstance(request, asgineer.HttpRequest): if request.path == "/api/v1/places": - return await _hub.get_places() + return await hub.get_places() return 404, {}, "Page not found" -async def _handle_agent(ws): +async def _handle_agent(hub, ws): await _authorize_ws(ws) client_ip = ws.scope["client"][0] server = jsonrpc.Channel(ws.send, ws.receive_iter()) - await _hub.agent_communicate(client_ip, server) + await hub.agent_communicate(client_ip, server) -async def _handle_exporter(ws): +async def _handle_exporter(hub, ws): await _authorize_ws(ws) client_ip = ws.scope["client"][0] exporter = jsonrpc.Channel(ws.send, ws.receive_iter()) - await _hub.exporter_communicate(client_ip, exporter) + await hub.exporter_communicate(client_ip, exporter) async def _authorize_ws(ws): @@ -80,6 +124,24 @@ class Hub: def __init__(self): self._id_generator = itertools.count(start=1) + async def startup(self, config): + if "log_level" in config: + log_level_str = config["log_level"] + log_level_map = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + } + log_level = log_level_map[log_level_str] + + logging.basicConfig( + format="%(levelname)s: %(name)s: %(message)s", level=log_level + ) + + async def shutdown(self): + pass + @jsonrpc.hidden async def get_places(self): return {"places": [p.dict() for p in self._places.values()]} @@ -189,9 +251,6 @@ async def return_reservation(self, place_id): logger.info("Place returned, but it doesn't exist: %d", place_id) -_hub = Hub() - - def _unmap_ip(ip_str): """Resolve IPv4-mapped-on-IPv6 to an IPv4 address""" ip = ipaddress.ip_address(ip_str) diff --git a/not_my_board/cli/__init__.py b/not_my_board/cli/__init__.py index 866e8b0..4ede0e9 100644 --- a/not_my_board/cli/__init__.py +++ b/not_my_board/cli/__init__.py @@ -9,7 +9,7 @@ import not_my_board._util as util from not_my_board._agent import agent from not_my_board._export import export -from not_my_board._hub import hub +from not_my_board._hub import run_hub try: from ..__about__ import __version__ @@ -144,7 +144,7 @@ def add_cacert_arg(subparser): def _hub_command(_): - hub() + run_hub() async def _export_command(args):