From 91e769f6033867daaa951334ff51380275de5c84 Mon Sep 17 00:00:00 2001 From: Kevin Morris Date: Mon, 28 Jun 2021 08:49:02 -0700 Subject: [PATCH] FastAPI: add redis integration This includes the addition of the python-fakeredis package, used for stubbing python-redis when a user does not have a configured cache. Signed-off-by: Kevin Morris --- INSTALL | 2 +- aurweb/redis.py | 57 ++++++++++++++++++++++++++++++++++ conf/config.defaults | 4 ++- conf/config.dev | 3 ++ docker/scripts/install-deps.sh | 2 +- test/test_asgi.py | 18 +++++++++++ test/test_redis.py | 40 ++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 aurweb/redis.py create mode 100644 test/test_redis.py diff --git a/INSTALL b/INSTALL index c41a5c8e..fdeb64ca 100644 --- a/INSTALL +++ b/INSTALL @@ -57,7 +57,7 @@ read the instructions below. (FastAPI-Specific) - # pacman -S redis python-redis + # pacman -S redis python-redis python-fakeredis # systemctl enable --now redis 5) Create a new MySQL database and a user and import the aurweb SQL schema: diff --git a/aurweb/redis.py b/aurweb/redis.py new file mode 100644 index 00000000..6b8dede4 --- /dev/null +++ b/aurweb/redis.py @@ -0,0 +1,57 @@ +import logging + +import fakeredis + +from redis import ConnectionPool, Redis + +import aurweb.config + +logger = logging.getLogger(__name__) +pool = None + + +class FakeConnectionPool: + """ A fake ConnectionPool class which holds an internal reference + to a fakeredis handle. + + We normally deal with Redis by keeping its ConnectionPool globally + referenced so we can persist connection state through different calls + to redis_connection(), and since FakeRedis does not offer a ConnectionPool, + we craft one up here to hang onto the same handle instance as long as the + same instance is alive; this allows us to use a similar flow from the + redis_connection() user's perspective. + """ + + def __init__(self): + self.handle = fakeredis.FakeStrictRedis() + + def disconnect(self): + pass + + +def redis_connection(): # pragma: no cover + global pool + + disabled = aurweb.config.get("options", "cache") != "redis" + + # If we haven't initialized redis yet, construct a pool. + if disabled: + logger.debug("Initializing fake Redis instance.") + if pool is None: + pool = FakeConnectionPool() + return pool.handle + else: + logger.debug("Initializing real Redis instance.") + if pool is None: + redis_addr = aurweb.config.get("options", "redis_address") + pool = ConnectionPool.from_url(redis_addr) + + # Create a connection to the pool. + return Redis(connection_pool=pool) + + +def kill_redis(): + global pool + if pool: + pool.disconnect() + pool = None diff --git a/conf/config.defaults b/conf/config.defaults index ebc21e51..1b4c3a74 100644 --- a/conf/config.defaults +++ b/conf/config.defaults @@ -36,11 +36,13 @@ enable-maintenance = 1 maintenance-exceptions = 127.0.0.1 render-comment-cmd = /usr/local/bin/aurweb-rendercomment localedir = /srv/http/aurweb/aur.git/web/locale/ -# memcache or apc +; memcache, apc, or redis +; memcache/apc are supported in PHP, redis is supported in Python. cache = none cache_pkginfo_ttl = 86400 memcache_servers = 127.0.0.1:11211 salt_rounds = 12 +redis_address = redis://localhost [ratelimit] request_limit = 4000 diff --git a/conf/config.dev b/conf/config.dev index 566b655e..94a9630b 100644 --- a/conf/config.dev +++ b/conf/config.dev @@ -28,10 +28,13 @@ enable-maintenance = 0 localedir = YOUR_AUR_ROOT/web/locale ; In production, salt_rounds should be higher; suggested: 12. salt_rounds = 4 +; See config.defaults comment about cache. cache = none ; In docker, the memcached host is available. On a user's system, ; this should be set to localhost (most likely). memcache_servers = memcached:11211 +; If cache = 'redis' this address is used to connect to Redis. +redis_address = redis://127.0.0.1 [notifications] ; For development/testing, use /usr/bin/sendmail diff --git a/docker/scripts/install-deps.sh b/docker/scripts/install-deps.sh index 6b0ec48b..0405f29b 100755 --- a/docker/scripts/install-deps.sh +++ b/docker/scripts/install-deps.sh @@ -15,6 +15,6 @@ pacman -Syu --noconfirm --noprogressbar \ python-email-validator openssh python-lxml mariadb mariadb-libs \ python-isort flake8 cgit uwsgi uwsgi-plugin-cgi php php-fpm \ python-asgiref uvicorn python-feedgen memcached php-memcached \ - python-redis redis + python-redis redis python-fakeredis exec "$@" diff --git a/test/test_asgi.py b/test/test_asgi.py index 79b34daf..b8856741 100644 --- a/test/test_asgi.py +++ b/test/test_asgi.py @@ -9,6 +9,24 @@ from fastapi import HTTPException import aurweb.asgi import aurweb.config +import aurweb.redis + + +@pytest.mark.asyncio +async def test_asgi_startup_session_secret_exception(monkeypatch): + """ Test that we get an IOError on app_startup when we cannot + connect to options.redis_address. """ + + redis_addr = aurweb.config.get("options", "redis_address") + + def mock_get(section: str, key: str): + if section == "fastapi" and key == "session_secret": + return None + return redis_addr + + with mock.patch("aurweb.config.get", side_effect=mock_get): + with pytest.raises(Exception): + await aurweb.asgi.app_startup() @pytest.mark.asyncio diff --git a/test/test_redis.py b/test/test_redis.py new file mode 100644 index 00000000..82aebb57 --- /dev/null +++ b/test/test_redis.py @@ -0,0 +1,40 @@ +from unittest import mock + +import pytest + +import aurweb.config + +from aurweb.redis import redis_connection + + +@pytest.fixture +def rediss(): + """ Create a RedisStub. """ + def mock_get(section, key): + return "none" + + with mock.patch("aurweb.config.get", side_effect=mock_get): + aurweb.config.rehash() + redis = redis_connection() + aurweb.config.rehash() + + yield redis + + +def test_redis_stub(rediss): + # We don't yet have a test key set. + assert rediss.get("test") is None + + # Set the test key to abc. + rediss.set("test", "abc") + assert rediss.get("test").decode() == "abc" + + # Test expire. + rediss.expire("test", 0) + assert rediss.get("test") is None + + # Now, set the test key again and use delete() on it. + rediss.set("test", "abc") + assert rediss.get("test").decode() == "abc" + rediss.delete("test") + assert rediss.get("test") is None