-
Notifications
You must be signed in to change notification settings - Fork 1
Fix/redis ssl #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix/redis ssl #13
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,76 @@ | ||||||||||||||||||||||
| from urllib.parse import quote | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| from cloudrift.cache.base import CacheBackend | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| _VALID_SSL_CERT_REQS = ("CERT_NONE", "CERT_OPTIONAL", "CERT_REQUIRED") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def cache_broker_url( | ||||||||||||||||||||||
| provider: str, | ||||||||||||||||||||||
| host: str, | ||||||||||||||||||||||
| port: int, | ||||||||||||||||||||||
| password: str = "", | ||||||||||||||||||||||
| db: int = 0, | ||||||||||||||||||||||
| ssl_cert_reqs: str = "CERT_NONE", | ||||||||||||||||||||||
| ) -> str: | ||||||||||||||||||||||
| """Return a Redis URL (``redis://`` or ``rediss://``) suitable for clients | ||||||||||||||||||||||
| that require URL-based configuration — most notably Celery, which cannot | ||||||||||||||||||||||
| consume a :class:`CacheBackend` directly. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Args: | ||||||||||||||||||||||
| provider: ``"redis"`` (self-hosted), ``"elasticache"`` (AWS), or | ||||||||||||||||||||||
| ``"azure_redis"``. | ||||||||||||||||||||||
| host: Redis host. | ||||||||||||||||||||||
| port: Redis port (6379 for plain, 6380 for TLS, 10000 for some Azure | ||||||||||||||||||||||
| tiers — pass the value the cluster actually listens on). | ||||||||||||||||||||||
| password: Optional. Omit (or pass empty string) for unauthenticated | ||||||||||||||||||||||
| self-hosted Redis. For ``elasticache`` / ``azure_redis`` this is the | ||||||||||||||||||||||
| AUTH token / access key. | ||||||||||||||||||||||
| db: Redis database index. | ||||||||||||||||||||||
| ssl_cert_reqs: TLS verification mode for cloud providers. One of | ||||||||||||||||||||||
| ``CERT_NONE`` / ``CERT_OPTIONAL`` / ``CERT_REQUIRED`` (defaults to | ||||||||||||||||||||||
| ``CERT_REQUIRED``). Ignored when ``provider == "redis"``. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Notes: | ||||||||||||||||||||||
| Token-based auth (ElastiCache IAM, Azure Managed Identity / Service | ||||||||||||||||||||||
| Principal) cannot be expressed in a static URL — for those, configure | ||||||||||||||||||||||
| the consumer (e.g. Celery) with a CredentialProvider instead of a URL. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Celery's Redis transport does not forward the ``ssl_cert_reqs`` query | ||||||||||||||||||||||
| parameter to redis-py; when used as a Celery broker URL the value is | ||||||||||||||||||||||
| silently ignored. To enforce non-default cert verification with Celery, | ||||||||||||||||||||||
| set ``broker_use_ssl`` (e.g. ``{"ssl_cert_reqs": ssl.CERT_REQUIRED}``) | ||||||||||||||||||||||
| on the Celery app config in addition to passing this URL. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| # Validate eagerly so a bad value fails at the call site rather than at | ||||||||||||||||||||||
| # connection time — applies to every provider, even where it's unused. | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||||||
| if ssl_cert_reqs not in _VALID_SSL_CERT_REQS: | ||||||||||||||||||||||
| raise ValueError( | ||||||||||||||||||||||
| f"Invalid ssl_cert_reqs: {ssl_cert_reqs!r}. " | ||||||||||||||||||||||
| f"Must be one of: {', '.join(_VALID_SSL_CERT_REQS)}." | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| if not isinstance(db, int) or isinstance(db, bool) or db < 0: | ||||||||||||||||||||||
| raise ValueError(f"Invalid db: {db!r}. Must be a non-negative integer.") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # When a password is present, include the ``default`` username so the URL is | ||||||||||||||||||||||
| # valid against Redis 6+ ACL deployments (``redis://default:pw@host``). | ||||||||||||||||||||||
| # Without it, ``redis://:pw@host`` can silently fail to authenticate. The | ||||||||||||||||||||||
| # password is percent-encoded so special characters (@ : / ? # %) don't | ||||||||||||||||||||||
| # corrupt the URL. | ||||||||||||||||||||||
| auth = f"default:{quote(password, safe='')}@" if password else "" | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Also worth noting: redis-py itself does respect |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if provider == "redis": | ||||||||||||||||||||||
| return f"redis://{auth}{host}:{port}/{db}" | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the plain redis provider, a non-empty password produces redis://:password@host:6379/0 — colon prefix, no username. This is spec-valid and most clients handle it, but it differs from the redis://default:password@... form expected by Redis 6+ ACL-based auth. Worth a brief comment clarifying the intent, or switching to the explicit form to avoid silent auth failures against ACL-enabled clusters. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Password characters that are special in a URI (
Suggested change
|
||||||||||||||||||||||
| if provider in ("elasticache", "azure_redis"): | ||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||||||||||||||||||||||
| f"rediss://{auth}{host}:{port}/{db}" | ||||||||||||||||||||||
| f"?ssl_cert_reqs={ssl_cert_reqs}" | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ssl_cert_reqs is a free-form string with no validation. An invalid value (e.g. CERT_INVALID) gets silently embedded in the URL and only surfaces as a confusing error from the Redis client at connection time, far from the call site. Suggest validating eagerly:
Suggested change
|
||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| raise ValueError( | ||||||||||||||||||||||
| f"Unsupported cache provider for broker URL: {provider!r}. " | ||||||||||||||||||||||
| "Must be one of: 'redis', 'elasticache', 'azure_redis'." | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def get_cache(provider: str, auth_method: str, **kwargs) -> CacheBackend: | ||||||||||||||||||||||
| """Factory to instantiate a cache backend. | ||||||||||||||||||||||
|
|
@@ -40,4 +111,4 @@ def get_cache(provider: str, auth_method: str, **kwargs) -> CacheBackend: | |||||||||||||||||||||
| return factory(**kwargs) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| __all__ = ["CacheBackend", "get_cache"] | ||||||||||||||||||||||
| __all__ = ["CacheBackend", "get_cache", "cache_broker_url"] | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,24 @@ async def ttl(self, key: str) -> int: | |
| async def keys(self, pattern: str = "*") -> list[str]: | ||
| """Return all keys matching *pattern*. Avoid on large keyspaces in production.""" | ||
|
|
||
| @abstractmethod | ||
| async def scan( | ||
| self, | ||
| cursor: int = 0, | ||
| match: str | None = None, | ||
| count: int | None = None, | ||
| ) -> tuple[int, list[bytes]]: | ||
| """Incremental keyspace iteration. | ||
|
|
||
| Returns ``(next_cursor, keys)``. Iterate until ``next_cursor == 0``. | ||
| Preferred over :meth:`keys` in production: bounded per-call work. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider adding a note to the abstract method doc, or asserting in |
||
| """ | ||
|
|
||
| @abstractmethod | ||
| async def getdel(self, key: str) -> bytes | None: | ||
| """Atomically get and delete *key*. Returns the value, or ``None`` | ||
| if the key did not exist. Requires Redis ≥ 6.2.""" | ||
|
|
||
| @abstractmethod | ||
| async def hget(self, key: str, field: str) -> bytes | None: | ||
| """Return the value of *field* in the hash stored at *key*.""" | ||
|
|
@@ -304,6 +322,29 @@ async def ttl(self, key: str) -> int: | |
| except RedisError as e: | ||
| raise CacheError(str(e)) from e | ||
|
|
||
| async def scan( | ||
| self, | ||
| cursor: int = 0, | ||
| match: str | None = None, | ||
| count: int | None = None, | ||
| ) -> tuple[int, list[bytes]]: | ||
| kwargs: dict = {} | ||
| if match is not None: | ||
| kwargs["match"] = match | ||
| if count is not None: | ||
| kwargs["count"] = count | ||
| try: | ||
| next_cursor, keys = await self._client.scan(cursor=cursor, **kwargs) | ||
| return int(next_cursor), keys | ||
| except RedisError as e: | ||
| raise CacheError(str(e)) from e | ||
|
|
||
| async def getdel(self, key: str) -> bytes | None: | ||
| try: | ||
| return await self._client.getdel(key) | ||
| except RedisError as e: | ||
| raise CacheError(str(e)) from e | ||
|
|
||
| async def keys(self, pattern: str = "*") -> list[str]: | ||
| try: | ||
| result = await self._client.keys(pattern) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Worth noting in the docstring that
ssl_cert_reqsas a URL query param is not forwarded by Celery's Redis transport — callers still need to configuressl_optionson the Celery app separately when using this URL as a broker.