feat(infra): add Redis service and client for Phase 0 [FASE-0.1]
Add Redis 7 to Docker Compose with healthcheck and persistence, separate logical DBs for blacklist and cache, singleton redis client helpers, and unit tests with fakeredis.
This commit is contained in:
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# ── Redis ─────────────────────────────────────────────────────────
|
# ── Redis ─────────────────────────────────────────────────────────
|
||||||
REDIS_URL: str = "redis://redis:6379/0"
|
REDIS_URL: str = "redis://redis:6379/0"
|
||||||
|
# Logical DB indices on the same Redis instance (PATH in URL is overridden).
|
||||||
|
REDIS_TOKEN_BLACKLIST_DB: int = 1
|
||||||
|
REDIS_CACHE_DB: int = 2
|
||||||
|
|
||||||
# ── CORS ─────────────────────────────────────────────────────────
|
# ── CORS ─────────────────────────────────────────────────────────
|
||||||
# Comma-separated list of allowed origins, or a JSON array.
|
# Comma-separated list of allowed origins, or a JSON array.
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
"""Redis client singleton.
|
"""Redis client factories.
|
||||||
|
|
||||||
Provides a lazily-initialised Redis connection that is reused across
|
``settings.REDIS_URL`` selects the default logical database (usually ``0``).
|
||||||
the application. The connection URL is read from ``settings.REDIS_URL``.
|
Token blacklist and application cache use separate logical DBs on the same
|
||||||
|
Redis instance (``REDIS_TOKEN_BLACKLIST_DB``, ``REDIS_CACHE_DB``) so keys never
|
||||||
|
collide and TTL policies can differ per workload.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
from app.infrastructure.redis_client import get_redis
|
from app.infrastructure.redis_client import get_redis, get_redis_blacklist
|
||||||
|
|
||||||
r = get_redis()
|
get_redis().set("key", "value", ex=300)
|
||||||
r.set("key", "value", ex=300)
|
get_redis_blacklist().setex("blacklist:…", ttl, "1")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
@@ -19,16 +24,43 @@ from app.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_redis_client: redis.Redis | None = None
|
_clients: dict[str, redis.Redis] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_url_with_db(base_url: str, db_index: int) -> str:
|
||||||
|
"""Return *base_url* with its path replaced by ``/{db_index}``."""
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
path = f"/{db_index}"
|
||||||
|
return urlunparse(
|
||||||
|
(parsed.scheme, parsed.netloc, path, "", "", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(url: str) -> redis.Redis:
|
||||||
|
if url not in _clients:
|
||||||
|
_clients[url] = redis.from_url(url, decode_responses=True)
|
||||||
|
logger.info("Redis client connected to %s", url)
|
||||||
|
return _clients[url]
|
||||||
|
|
||||||
|
|
||||||
def get_redis() -> redis.Redis:
|
def get_redis() -> redis.Redis:
|
||||||
"""Return a shared Redis client, creating it on first call."""
|
"""Default Redis connection (URL from ``settings.REDIS_URL``)."""
|
||||||
global _redis_client
|
return _get_client(settings.REDIS_URL)
|
||||||
if _redis_client is None:
|
|
||||||
_redis_client = redis.from_url(
|
|
||||||
settings.REDIS_URL,
|
def get_redis_blacklist() -> redis.Redis:
|
||||||
decode_responses=True,
|
"""Redis DB used for JWT revocation (``jti`` keys with TTL)."""
|
||||||
)
|
url = _redis_url_with_db(
|
||||||
logger.info("Redis client connected to %s", settings.REDIS_URL)
|
settings.REDIS_URL,
|
||||||
return _redis_client
|
settings.REDIS_TOKEN_BLACKLIST_DB,
|
||||||
|
)
|
||||||
|
return _get_client(url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis_cache() -> redis.Redis:
|
||||||
|
"""Redis DB reserved for shared cache (scores, queues, etc.)."""
|
||||||
|
url = _redis_url_with_db(
|
||||||
|
settings.REDIS_URL,
|
||||||
|
settings.REDIS_CACHE_DB,
|
||||||
|
)
|
||||||
|
return _get_client(url)
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ docxtpl>=0.18.0
|
|||||||
pytest
|
pytest
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
httpx
|
httpx
|
||||||
|
fakeredis>=2.23.0
|
||||||
|
|||||||
61
backend/tests/test_redis_client.py
Normal file
61
backend/tests/test_redis_client.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Redis URL helpers and blacklist DB wiring (FASE 0.1)."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.infrastructure import redis_client as rc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fakeredis():
|
||||||
|
import fakeredis as fr
|
||||||
|
|
||||||
|
return fr.FakeRedis(decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_redis_url_with_db_replaces_index():
|
||||||
|
assert (
|
||||||
|
rc._redis_url_with_db("redis://localhost:6379/0", 2)
|
||||||
|
== "redis://localhost:6379/2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_redis_blacklist_uses_configured_db(monkeypatch):
|
||||||
|
monkeypatch.setattr(rc, "_clients", {})
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
def fake_from_url(url, **kwargs):
|
||||||
|
captured.append(url)
|
||||||
|
m = MagicMock()
|
||||||
|
m.setex = MagicMock()
|
||||||
|
return m
|
||||||
|
|
||||||
|
monkeypatch.setattr("redis.from_url", fake_from_url)
|
||||||
|
monkeypatch.setattr(settings, "REDIS_URL", "redis://redis:6379/0")
|
||||||
|
monkeypatch.setattr(settings, "REDIS_TOKEN_BLACKLIST_DB", 9)
|
||||||
|
|
||||||
|
c = rc.get_redis_blacklist()
|
||||||
|
c.setex("k", 10, "v")
|
||||||
|
assert captured == ["redis://redis:6379/9"]
|
||||||
|
c.setex.assert_called_once_with("k", 10, "v")
|
||||||
|
|
||||||
|
|
||||||
|
def test_redis_set_get_ttl(fakeredis, monkeypatch):
|
||||||
|
"""Integration-style check against FakeRedis (no real server)."""
|
||||||
|
monkeypatch.setattr(rc, "_clients", {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.infrastructure.redis_client.get_redis_blacklist",
|
||||||
|
lambda: fakeredis,
|
||||||
|
)
|
||||||
|
from app.auth import blacklist_token, is_token_blacklisted
|
||||||
|
|
||||||
|
jti = str(uuid.uuid4())
|
||||||
|
exp = time.time() + 3600
|
||||||
|
blacklist_token(jti, exp)
|
||||||
|
assert is_token_blacklisted(jti) is True
|
||||||
|
ttl = fakeredis.ttl(f"blacklist:{jti}")
|
||||||
|
assert 0 < ttl <= 3600
|
||||||
@@ -80,6 +80,8 @@ services:
|
|||||||
MINIO_BUCKET: ${MINIO_BUCKET:-evidence}
|
MINIO_BUCKET: ${MINIO_BUCKET:-evidence}
|
||||||
MINIO_SECURE: ${MINIO_SECURE:-false}
|
MINIO_SECURE: ${MINIO_SECURE:-false}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
REDIS_TOKEN_BLACKLIST_DB: ${REDIS_TOKEN_BLACKLIST_DB:-1}
|
||||||
|
REDIS_CACHE_DB: ${REDIS_CACHE_DB:-2}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-}
|
CORS_ORIGINS: ${CORS_ORIGINS:-}
|
||||||
AEGIS_ENV: ${AEGIS_ENV:-production}
|
AEGIS_ENV: ${AEGIS_ENV:-production}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
|
|
||||||
# ── FastAPI Backend ────────────────────────────────────────────────────────
|
# ── FastAPI Backend ────────────────────────────────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
@@ -89,6 +89,8 @@ services:
|
|||||||
ACCESS_TOKEN_EXPIRE_MINUTES: 60
|
ACCESS_TOKEN_EXPIRE_MINUTES: 60
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
REDIS_TOKEN_BLACKLIST_DB: "1"
|
||||||
|
REDIS_CACHE_DB: "2"
|
||||||
# MinIO
|
# MinIO
|
||||||
MINIO_ENDPOINT: minio:9000
|
MINIO_ENDPOINT: minio:9000
|
||||||
MINIO_ACCESS_KEY: minioadmin
|
MINIO_ACCESS_KEY: minioadmin
|
||||||
|
|||||||
Reference in New Issue
Block a user