[MIG] session_redis: Migration to 18.0

This commit is contained in:
Maksym Yankin
2025-01-30 19:36:18 +02:00
parent 34bd6693e0
commit 20a580d1fc
5 changed files with 101 additions and 109 deletions
+1 -2
View File
@@ -14,6 +14,5 @@
"python": ["redis"], "python": ["redis"],
}, },
"website": "https://github.com/camptocamp/odoo-cloud-platform", "website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [], "installable": True,
"installable": False,
} }
+40 -33
View File
@@ -3,6 +3,8 @@
import logging import logging
import os import os
from pathlib import Path
from typing import Optional
from odoo import http from odoo import http
from odoo.tools import config from odoo.tools import config
@@ -21,29 +23,32 @@ except ImportError:
_logger.debug("Cannot 'import redis'.") _logger.debug("Cannot 'import redis'.")
def is_true(strval): def is_true(strval: Optional[str]) -> bool:
return bool(strtobool(strval or "0".lower())) """Convert string value to boolean."""
return bool(strtobool((strval or "0").lower()))
sentinel_host = os.environ.get("ODOO_SESSION_REDIS_SENTINEL_HOST") # Retrieve Redis session configurations from environment variables
sentinel_master_name = os.environ.get("ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME") sentinel_host = os.getenv("ODOO_SESSION_REDIS_SENTINEL_HOST")
sentinel_master_name = os.getenv("ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME")
if sentinel_host and not sentinel_master_name: if sentinel_host and not sentinel_master_name:
raise Exception( raise Exception(
"ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME must be defined " "ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME must be defined "
"when using session_redis" "when using session_redis"
) )
sentinel_port = int(os.environ.get("ODOO_SESSION_REDIS_SENTINEL_PORT", 26379)) sentinel_port = int(os.getenv("ODOO_SESSION_REDIS_SENTINEL_PORT", 26379))
host = os.environ.get("ODOO_SESSION_REDIS_HOST", "localhost") host = os.getenv("ODOO_SESSION_REDIS_HOST", "localhost")
port = int(os.environ.get("ODOO_SESSION_REDIS_PORT", 6379)) port = int(os.getenv("ODOO_SESSION_REDIS_PORT", 6379))
prefix = os.environ.get("ODOO_SESSION_REDIS_PREFIX") prefix = os.getenv("ODOO_SESSION_REDIS_PREFIX")
url = os.environ.get("ODOO_SESSION_REDIS_URL") url = os.getenv("ODOO_SESSION_REDIS_URL")
password = os.environ.get("ODOO_SESSION_REDIS_PASSWORD") password = os.getenv("ODOO_SESSION_REDIS_PASSWORD")
expiration = os.environ.get("ODOO_SESSION_REDIS_EXPIRATION") expiration = os.getenv("ODOO_SESSION_REDIS_EXPIRATION")
anon_expiration = os.environ.get("ODOO_SESSION_REDIS_EXPIRATION_ANONYMOUS") anon_expiration = os.getenv("ODOO_SESSION_REDIS_EXPIRATION_ANONYMOUS")
@lazy_property @lazy_property
def session_store(self): def session_store(self) -> RedisSessionStore:
"""Configure Redis session storage."""
if sentinel_host: if sentinel_host:
sentinel = Sentinel([(sentinel_host, sentinel_port)], password=password) sentinel = Sentinel([(sentinel_host, sentinel_port)], password=password)
redis_client = sentinel.master_for(sentinel_master_name) redis_client = sentinel.master_for(sentinel_master_name)
@@ -61,30 +66,32 @@ def session_store(self):
def purge_fs_sessions(path): def purge_fs_sessions(path):
for fname in os.listdir(path): """Remove old file-based sessions."""
path = os.path.join(path, fname) session_path = Path(path)
if not session_path.exists():
_logger.warning(f"Session directory '{session_path}' does not exist.")
return
for session_file in session_path.iterdir():
try: try:
os.unlink(path) session_file.unlink()
except OSError: _logger.debug(f"Deleted session file: {session_file}")
_logger.warning("OS Error during purge of redis sessions.") except PermissionError:
_logger.warning(f"Permission denied while deleting session file: {session_file}")
except OSError as e:
_logger.warning(f"Error deleting session file {session_file}: {str(e)}")
if is_true(os.environ.get("ODOO_SESSION_REDIS")): if is_true(os.getenv("ODOO_SESSION_REDIS")):
storage_info = f"Redis with prefix '{prefix}' on "
if sentinel_host: if sentinel_host:
_logger.debug( storage_info += f"Sentinel {sentinel_host}:{sentinel_port}"
"HTTP sessions stored in Redis with prefix '%s'. "
"Using Sentinel on %s:%s",
prefix or "",
sentinel_host,
sentinel_port,
)
else: else:
_logger.debug( storage_info += f"{host}:{port}"
"HTTP sessions stored in Redis with prefix '%s' on " "%s:%s",
prefix or "", _logger.debug("HTTP sessions stored in %s.", storage_info)
host,
port,
)
http.Application.session_store = session_store http.Application.session_store = session_store
# clean the existing sessions on the file system
# Clean existing sessions stored in the file system
purge_fs_sessions(config.session_dir) purge_fs_sessions(config.session_dir)
+13 -12
View File
@@ -3,8 +3,9 @@
import json import json
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Dict
import dateutil import dateutil.parser
class SessionEncoder(json.JSONEncoder): class SessionEncoder(json.JSONEncoder):
@@ -13,30 +14,30 @@ class SessionEncoder(json.JSONEncoder):
So that we can later recompose them if they were stored in the session So that we can later recompose them if they were stored in the session
""" """
def default(self, obj): def default(self, obj: Any) -> Any:
if isinstance(obj, datetime): if isinstance(obj, datetime):
return {"_type": "datetime_isoformat", "value": obj.isoformat()} return {"_type": "datetime_isoformat", "value": obj.isoformat()}
elif isinstance(obj, date): if isinstance(obj, date):
return {"_type": "date_isoformat", "value": obj.isoformat()} return {"_type": "date_isoformat", "value": obj.isoformat()}
elif isinstance(obj, set): if isinstance(obj, set):
return {"_type": "set", "value": tuple(obj)} return {"_type": "set", "value": tuple(sorted(obj))}
return json.JSONEncoder.default(self, obj) return super().default(obj)
class SessionDecoder(json.JSONDecoder): class SessionDecoder(json.JSONDecoder):
"""Decode json, recomposing recordsets and date/datetime""" """Decode json, recomposing recordsets and date/datetime"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, object_hook=self.object_hook, **kwargs) super().__init__(*args, object_hook=self.object_hook, **kwargs)
def object_hook(self, obj): def object_hook(self, obj: Dict[str, Any]) -> Any:
"""Convert serialized data back into its original Python type."""
if "_type" not in obj: if "_type" not in obj:
return obj return obj
type_ = obj["_type"] if obj["_type"] == "datetime_isoformat":
if type_ == "datetime_isoformat":
return dateutil.parser.parse(obj["value"]) return dateutil.parser.parse(obj["value"])
elif type_ == "date_isoformat": if obj["_type"] == "date_isoformat":
return dateutil.parser.parse(obj["value"]).date() return dateutil.parser.parse(obj["value"]).date()
elif type_ == "set": if obj["_type"] == "set":
return set(obj["value"]) return set(obj["value"])
return obj return obj
+37 -57
View File
@@ -3,6 +3,7 @@
import json import json
import logging import logging
from typing import Optional, List, Dict, Any
from odoo.service import security from odoo.service import security
from odoo.tools._vendor.sessions import SessionStore from odoo.tools._vendor.sessions import SessionStore
@@ -24,93 +25,72 @@ class RedisSessionStore(SessionStore):
self, self,
redis, redis,
session_class=None, session_class=None,
prefix="", prefix: str = "",
expiration=None, expiration: Optional[int] = None,
anon_expiration=None, anon_expiration: Optional[int] = None,
): ) -> None:
super().__init__(session_class=session_class) super().__init__(session_class=session_class)
self.redis = redis self.redis = redis
if expiration is None: self.expiration = expiration if expiration is not None else DEFAULT_SESSION_TIMEOUT
self.expiration = DEFAULT_SESSION_TIMEOUT self.anon_expiration = anon_expiration if anon_expiration is not None else DEFAULT_SESSION_TIMEOUT_ANONYMOUS
else: self.prefix = f"session:{prefix}:" if prefix else "session:"
self.expiration = expiration
if anon_expiration is None:
self.anon_expiration = DEFAULT_SESSION_TIMEOUT_ANONYMOUS
else:
self.anon_expiration = anon_expiration
self.prefix = "session:"
if prefix:
self.prefix = f"{self.prefix}:{prefix}:"
def build_key(self, sid): def build_key(self, sid: str) -> str:
"""Build the Redis key for a session ID."""
return f"{self.prefix}{sid}" return f"{self.prefix}{sid}"
def save(self, session): def save(self, session) -> Optional[bool]:
"""Save session data in Redis with an expiration time."""
key = self.build_key(session.sid) key = self.build_key(session.sid)
# allow to set a custom expiration for a session # allow to set a custom expiration for a session
# such as a very short one for monitoring requests # such as a very short one for monitoring requests
if session.uid: expiration = session.expiration or (self.expiration if session.uid else self.anon_expiration)
expiration = session.expiration or self.expiration
else:
expiration = session.expiration or self.anon_expiration
if _logger.isEnabledFor(logging.DEBUG):
if session.uid:
user_msg = f"user '{session.login}' (id: {session.uid})"
else:
user_msg = "anonymous user"
_logger.debug(
"saving session with key '%s' and " "expiration of %s seconds for %s",
key,
expiration,
user_msg,
)
data = json.dumps(dict(session), cls=json_encoding.SessionEncoder).encode( if _logger.isEnabledFor(logging.DEBUG):
"utf-8" user_msg = f"user '{session.login}' (id: {session.uid})" if session.uid else "anonymous user"
) _logger.debug(f"Saving session '{key}' with expiration {expiration} seconds for {user_msg}")
data = json.dumps(dict(session), cls=json_encoding.SessionEncoder).encode("utf-8")
if self.redis.set(key, data): if self.redis.set(key, data):
return self.redis.expire(key, expiration) return self.redis.expire(key, expiration)
return None
def delete(self, session): def delete(self, session) -> int:
"""Delete a session from Redis."""
key = self.build_key(session.sid) key = self.build_key(session.sid)
_logger.debug("deleting session with key %s", key) _logger.debug(f"Deleting session with '{key}'")
return self.redis.delete(key) return self.redis.delete(key)
def get(self, sid): def get(self, sid: str):
"""Retrieve a session from Redis, or return a new one if not found."""
if not self.is_valid_key(sid): if not self.is_valid_key(sid):
_logger.debug( _logger.debug(f"Invalid session ID '{sid}' requested, returning a new session.")
"session with invalid sid '%s' has been asked, " "returning a new one",
sid,
)
return self.new() return self.new()
key = self.build_key(sid) key = self.build_key(sid)
saved = self.redis.get(key) saved = self.redis.get(key)
if not saved: if not saved:
_logger.debug( _logger.debug(f"Non-existent session '{key}' requested, returning a new session.")
"session with non-existent key '%s' has been asked, "
"returning a new one",
key,
)
return self.new() return self.new()
try: try:
data = json.loads(saved.decode("utf-8"), cls=json_encoding.SessionDecoder) data = json.loads(saved.decode("utf-8"), cls=json_encoding.SessionDecoder)
except ValueError: except (ValueError, json.JSONDecodeError):
_logger.debug( _logger.warning(f"Corrupt session data for key '{key}', resetting session.")
"session for key '%s' has been asked but its json "
"content could not be read, it has been reset",
key,
)
data = {} data = {}
return self.session_class(data, sid, False) return self.session_class(data, sid, False)
def list(self): def list(self) -> List[str]:
keys = self.redis.keys("%s*" % self.prefix) """List all session keys in Redis."""
# More efficient scanning
keys = [key for key in self.redis.scan_iter(f"{self.prefix}*")]
_logger.debug("a listing redis keys has been called") _logger.debug("a listing redis keys has been called")
return [key[len(self.prefix) :] for key in keys] return [key.decode("utf-8")[len(self.prefix) :] for key in keys]
def rotate(self, session, env): def rotate(self, session, env) -> None:
"""Rotate session ID and regenerate session token if user is logged in."""
self.delete(session) self.delete(session)
session.sid = self.generate_key() session.sid = self.generate_key()
if session.uid and env: if session.uid and env:
+10 -5
View File
@@ -1,3 +1,5 @@
from typing import Union
_MAP = { _MAP = {
"y": True, "y": True,
"yes": True, "yes": True,
@@ -14,8 +16,11 @@ _MAP = {
} }
def strtobool(value): def strtobool(value: Union[str, int]) -> bool:
try: """Convert a string or integer representation to a boolean value."""
return _MAP[str(value).lower()] result = _MAP.get(str(value).strip().lower())
except KeyError as error:
raise ValueError(f'"{value}" is not a valid bool value') from error if result is None:
raise ValueError(f"Invalid boolean value: {repr(value)}")
return result