diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2351b7d4..5f2c4dc5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -6,34 +6,9 @@ jobs: strategy: matrix: python-version: [3.8, 3.9, 3.10, 3.11, 3.12] - services: - mongodb: - image: mongo - ports: - - 27017:27017 - dynamodb: - image: amazon/dynamodb-local - ports: - - 8000:8000 - - postgresql: - image: postgres:latest - ports: - - 5433:5432 - env: - POSTGRES_PASSWORD: pwd - POSTGRES_USER: root - POSTGRES_DB: dummy - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - uses: actions/checkout@v4 - - uses: supercharge/redis-github-action@1.5.0 - - uses: niden/actions-memcached@v7 + - uses: hoverkraft-tech/compose-action@v2.0.1 - name: Install testing requirements run: pip3 install -r requirements/dev.txt - name: Run tests diff --git a/README.md b/README.md index 54187793..9a7ed91c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ def get(): ## Supported Storage Types -- Redis +- Redis (standalone and Sentinel) - Memcached - FileSystem - MongoDB diff --git a/docker-compose.yml b/docker-compose.yml index 3463412d..79969e61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,8 +41,152 @@ services: volumes: - postgres_data:/var/lib/postgresql/data + redis-master: + image: redis:latest + container_name: redis-master + command: + [ + "redis-server", + "--port", + "6380", + "--protected-mode", + "no" + ] + network_mode: host + volumes: + - redis_ha_master_data:/data + + + redis-slave-1: + image: redis:latest + container_name: redis-slave-1 + depends_on: + - redis-master + command: + [ + "redis-server", + "--port", + "6381", + "--replicaof", + "127.0.0.1", + "6380", + "--protected-mode", + "no" + ] + network_mode: host + volumes: + - redis_ha_slave_1_data:/data + + + redis-slave-2: + image: redis:latest + container_name: redis-slave-2 + depends_on: + - redis-master + command: + [ + "redis-server", + "--port", + "6382", + "--replicaof", + "127.0.0.1", + "6380", + "--protected-mode", + "no" + ] + network_mode: host + volumes: + - redis_ha_slave_2_data:/data + + + sentinel-1: + image: redis:latest + container_name: sentinel-1 + depends_on: + - redis-master + configs: + - source: sentinel + target: /data/sentinel.conf + mode: 0660 + uid: "999" + command: + [ + "redis-server", + "sentinel.conf", + "--port", + "26379", + "--sentinel", + ] + network_mode: host + ports: + - "26379:26379" + volumes: + - redis_ha_sentinel_1_data:/data + + + sentinel-2: + image: redis:latest + container_name: sentinel-2 + depends_on: + - redis-master + configs: + - source: sentinel + target: /data/sentinel.conf + mode: 0660 + uid: "999" + command: + [ + "redis-server", + "sentinel.conf", + "--port", + "26380", + "--sentinel", + ] + network_mode: host + volumes: + - redis_ha_sentinel_2_data:/data + + + sentinel-3: + image: redis:latest + container_name: sentinel-3 + depends_on: + - redis-master + configs: + - source: sentinel + target: /data/sentinel.conf + mode: 0660 + uid: "999" + command: + [ + "redis-server", + "sentinel.conf", + "--port", + "26381", + "--sentinel", + ] + network_mode: host + volumes: + - redis_ha_sentinel_3_data:/data + volumes: - postgres_data: - mongo_data: - redis_data: - dynamodb_data: \ No newline at end of file + postgres_data: + mongo_data: + redis_data: + dynamodb_data: + redis_ha_master_data: + redis_ha_slave_1_data: + redis_ha_slave_2_data: + redis_ha_sentinel_1_data: + redis_ha_sentinel_2_data: + redis_ha_sentinel_3_data: + +configs: + sentinel: + content: | + bind 0.0.0.0 + sentinel monitor mymaster 127.0.0.1 6380 2 + sentinel resolve-hostnames yes + sentinel down-after-milliseconds mymaster 5000 + sentinel failover-timeout mymaster 5000 + sentinel parallel-syncs mymaster 1 \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst index faae610d..da80a3ed 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,6 +15,7 @@ Anything documented here is part of the public API that Flask-Session provides, :members: regenerate .. autoclass:: flask_session.redis.RedisSessionInterface +.. autoclass:: flask_session.redis.RedisSentinelSessionInterface .. autoclass:: flask_session.memcached.MemcachedSessionInterface .. autoclass:: flask_session.filesystem.FileSystemSessionInterface .. autoclass:: flask_session.cachelib.CacheLibSessionInterface diff --git a/docs/config_example.rst b/docs/config_example.rst index 9960da07..cfc492af 100644 --- a/docs/config_example.rst +++ b/docs/config_example.rst @@ -17,6 +17,21 @@ If you do not set ``SESSION_REDIS``, Flask-Session will assume you are developin :meth:`redis.Redis` instance for you. It is expected you supply an instance of :meth:`redis.Redis` in production. +Similarly, if you use a high-availability setup for Redis using Sentinel you can use the following setup + +.. code-block:: python + + from redis import Sentinel + app.config['SESSION_TYPE'] = 'redissentinel' + app.config['SESSION_REDIS_SENTINEL'] = Sentinel( + [("127.0.0.1", 26379), ("127.0.0.1", 26380), ("127.0.0.1", 26381)], + ) + +It is expected that you set ``SESSION_REDIS_SENTINEL`` to your own :meth:`redis.Sentinel` instance. +The name of the master set is obtained via the config ``SESSION_REDIS_SENTINEL_MASTER_SET`` which defaults to ``mymaster``. + + + .. note:: By default, sessions in Flask-Session are permanent with an expiration of 31 days. \ No newline at end of file diff --git a/docs/config_reference.rst b/docs/config_reference.rst index 9fef8fd5..19761a9b 100644 --- a/docs/config_reference.rst +++ b/docs/config_reference.rst @@ -13,6 +13,7 @@ These are specific to Flask-Session. Specifies which type of session interface to use. Built-in session types: - **redis**: RedisSessionInterface + - **redissentinel**: RedisSentinelSessionInterface - **memcached**: MemcachedSessionInterface - **filesystem**: FileSystemSessionInterface (Deprecated in 0.7.0, will be removed in 1.0.0 in favor of CacheLibSessionInterface) - **cachelib**: CacheLibSessionInterface diff --git a/src/flask_session/__init__.py b/src/flask_session/__init__.py index 5d4caee9..96e5ca87 100644 --- a/src/flask_session/__init__.py +++ b/src/flask_session/__init__.py @@ -61,6 +61,9 @@ def _get_interface(self, app): # Redis settings SESSION_REDIS = config.get("SESSION_REDIS", Defaults.SESSION_REDIS) + SESSION_REDIS_SENTINEL = config.get("SESSION_REDIS_SENTINEL", Defaults.SESSION_REDIS_SENTINEL) + SESSION_REDIS_SENTINEL_MASTER_SET = config.get("SESSION_REDIS_SENTINEL_MASTER_SET", Defaults.SESSION_REDIS_SENTINEL_MASTER_SET) + # Memcached settings SESSION_MEMCACHED = config.get("SESSION_MEMCACHED", Defaults.SESSION_MEMCACHED) @@ -144,6 +147,14 @@ def _get_interface(self, app): **common_params, client=SESSION_REDIS, ) + elif SESSION_TYPE == "redissentinel": + from .redis import RedisSentinelSessionInterface + + session_interface = RedisSentinelSessionInterface( + **common_params, + client=SESSION_REDIS_SENTINEL, + master=SESSION_REDIS_SENTINEL_MASTER_SET, + ) elif SESSION_TYPE == "memcached": from .memcached import MemcachedSessionInterface diff --git a/src/flask_session/defaults.py b/src/flask_session/defaults.py index f1bc1501..e2b5ad89 100644 --- a/src/flask_session/defaults.py +++ b/src/flask_session/defaults.py @@ -16,6 +16,10 @@ class Defaults: # Redis settings SESSION_REDIS = None + # Redis Sentinal settings + SESSION_REDIS_SENTINEL = None + SESSION_REDIS_SENTINEL_MASTER_SET = "mymaster" + # Memcached settings SESSION_MEMCACHED = None diff --git a/src/flask_session/redis/__init__.py b/src/flask_session/redis/__init__.py index 65b6323a..84b0ae76 100644 --- a/src/flask_session/redis/__init__.py +++ b/src/flask_session/redis/__init__.py @@ -1 +1,2 @@ from .redis import RedisSession, RedisSessionInterface # noqa: F401 +from .redis_sentinel import RedisSentinelSession, RedisSentinelSessionInterface diff --git a/src/flask_session/redis/redis_sentinel.py b/src/flask_session/redis/redis_sentinel.py new file mode 100644 index 00000000..d94310b7 --- /dev/null +++ b/src/flask_session/redis/redis_sentinel.py @@ -0,0 +1,57 @@ +from typing import Optional + +from flask import Flask +from redis import Sentinel + +from .redis import RedisSessionInterface +from ..base import ServerSideSession +from ..defaults import Defaults + + +class RedisSentinelSession(ServerSideSession): + pass + + +class RedisSentinelSessionInterface(RedisSessionInterface): + """Uses the Redis key-value store deployed in a high availability mode as a session storage. (`redis-py` required) + + :param client: A ``redis.Sentinel`` instance. + :param master: The name of the master node. + :param key_prefix: A prefix that is added to all storage keys. + :param use_signer: Whether to sign the session id cookie or not. + :param permanent: Whether to use permanent session or not. + :param sid_length: The length of the generated session id in bytes. + :param serialization_format: The serialization format to use for the session data. + """ + + session_class = RedisSentinelSession + ttl = True + + def __init__( + self, + app: Flask, + client: Optional[Sentinel] = Defaults.SESSION_REDIS_SENTINEL, + master: str = Defaults.SESSION_REDIS_SENTINEL_MASTER_SET, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + ): + if client is None or not isinstance(client, Sentinel): + raise TypeError("No valid Sentinel instance provided.") + self.sentinel = client + self.master = master + super().__init__( + app, self.client, key_prefix, use_signer, permanent, sid_length, serialization_format + ) + self._client = None + + @property + def client(self): + return self.sentinel.master_for(self.master) + + @client.setter + def client(self, value): + # the _client is only needed and the setter only needed for the inheritance to work + self._client = value diff --git a/tests/test_redis.py b/tests/test_redis.py index 7c59964b..5c964cef 100644 --- a/tests/test_redis.py +++ b/tests/test_redis.py @@ -2,8 +2,9 @@ from contextlib import contextmanager import flask -from flask_session.redis import RedisSession -from redis import Redis +from flask_session.defaults import Defaults +from flask_session.redis import RedisSession, RedisSentinelSession +from redis import Redis, Sentinel class TestRedisSession: @@ -40,3 +41,46 @@ def test_redis_default(self, app_utils): json.loads(byte_string.decode("utf-8")) if byte_string else {} ) assert stored_session.get("value") == "44" + + +class TestRedisSentinelSession: + """This requires package: redis""" + + @contextmanager + def setup_sentinel(self): + self.sentinel = Sentinel( + [("127.0.0.1", 26379), ("127.0.0.1", 26380), ("127.0.0.1", 26381)], + # sentinel_kwargs={"password": "redispassword"}, + # socket_timeout=1 + ) + self.master: Redis = self.sentinel.master_for( + Defaults.SESSION_REDIS_SENTINEL_MASTER_SET + ) + self.master.flushall() + yield + self.master.flushall() + + def retrieve_stored_session(self, key): + master = self.sentinel.master_for( + Defaults.SESSION_REDIS_SENTINEL_MASTER_SET + ) + return master.get(key) + + def test_redis_default(self, app_utils): + with self.setup_sentinel(): + app = app_utils.create_app( + {"SESSION_TYPE": "redissentinel", "SESSION_REDIS_SENTINEL": self.sentinel} + ) + + with app.test_request_context(): + assert isinstance(flask.session, RedisSentinelSession) + app_utils.test_session(app) + + # Check if the session is stored in Redis + cookie = app_utils.test_session_with_cookie(app) + session_id = cookie.split(";")[0].split("=")[1] + byte_string = self.retrieve_stored_session(f"session:{session_id}") + stored_session = ( + json.loads(byte_string.decode("utf-8")) if byte_string else {} + ) + assert stored_session.get("value") == "44"