grass-redis 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- grass_redis-0.1.0/PKG-INFO +105 -0
- grass_redis-0.1.0/README.md +81 -0
- grass_redis-0.1.0/pyproject.toml +45 -0
- grass_redis-0.1.0/setup.cfg +4 -0
- grass_redis-0.1.0/src/grass_redis/__init__.py +22 -0
- grass_redis-0.1.0/src/grass_redis/client.py +265 -0
- grass_redis-0.1.0/src/grass_redis.egg-info/PKG-INFO +105 -0
- grass_redis-0.1.0/src/grass_redis.egg-info/SOURCES.txt +9 -0
- grass_redis-0.1.0/src/grass_redis.egg-info/dependency_links.txt +1 -0
- grass_redis-0.1.0/src/grass_redis.egg-info/requires.txt +4 -0
- grass_redis-0.1.0/src/grass_redis.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grass-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redis Sentinel client with mandatory mutual TLS for high-availability Redis clusters
|
|
5
|
+
Author: Alex
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: redis>=5.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# grass-redis
|
|
26
|
+
|
|
27
|
+
Redis Sentinel client with mandatory mutual TLS.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Automatic failover via Sentinel
|
|
32
|
+
- Connection pooling and caching
|
|
33
|
+
- Read replicas for load distribution
|
|
34
|
+
- Mandatory TLS with client certificates
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e libs/python/grass-redis
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from grass_redis import (
|
|
47
|
+
RedisSentinelClient,
|
|
48
|
+
SentinelConfig,
|
|
49
|
+
load_passwords_from_env,
|
|
50
|
+
load_sentinel_hosts_from_env,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Load from environment (never hardcode)
|
|
54
|
+
sentinel_hosts = load_sentinel_hosts_from_env() # $REDIS_SENTINEL_HOSTS
|
|
55
|
+
passwords = load_passwords_from_env() # /etc/redis/certs/passwords.env
|
|
56
|
+
|
|
57
|
+
config = SentinelConfig(
|
|
58
|
+
sentinel_hosts=sentinel_hosts,
|
|
59
|
+
databases=passwords,
|
|
60
|
+
certs_dir=Path("/etc/redis/certs"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with RedisSentinelClient(config) as client:
|
|
64
|
+
primary = client.get_primary("db1")
|
|
65
|
+
primary.set("key", "value")
|
|
66
|
+
|
|
67
|
+
replica = client.get_replica("db1")
|
|
68
|
+
value = replica.get("key")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
**Environment (optional):**
|
|
74
|
+
```bash
|
|
75
|
+
export REDIS_SENTINEL_HOSTS="host1:26379,host2:26379,host3:26379"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Deploy certs via Ansible to `/etc/redis/certs/`:**
|
|
79
|
+
- `client.crt` - Client certificate (644)
|
|
80
|
+
- `client.key` - Private key (600)
|
|
81
|
+
- `ca.crt` - CA certificate (644)
|
|
82
|
+
- `passwords.env` - Passwords (600)
|
|
83
|
+
|
|
84
|
+
**`passwords.env` format:**
|
|
85
|
+
```bash
|
|
86
|
+
REDIS_SCRAPER_DB1_PASSWORD=xxx
|
|
87
|
+
REDIS_SCRAPER_DB2_PASSWORD=yyy
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Names are converted: `REDIS_SCRAPER_DB1_PASSWORD` → database `scraper-db1`
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
**SentinelConfig:**
|
|
95
|
+
- `sentinel_hosts` - List[(host, port)]
|
|
96
|
+
- `databases` - Dict[db_name, password]
|
|
97
|
+
- `certs_dir` - Path (default: /etc/redis/certs)
|
|
98
|
+
|
|
99
|
+
**RedisSentinelClient:**
|
|
100
|
+
- `get_primary(db)` - Connection for writes
|
|
101
|
+
- `get_replica(db)` - Connection for reads
|
|
102
|
+
- `discover_primary(db)` - Get current primary address
|
|
103
|
+
- `test_connection(db)` - PING test
|
|
104
|
+
|
|
105
|
+
See `grass-platform/reference_implementation/python/redis/` for examples.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# grass-redis
|
|
2
|
+
|
|
3
|
+
Redis Sentinel client with mandatory mutual TLS.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Automatic failover via Sentinel
|
|
8
|
+
- Connection pooling and caching
|
|
9
|
+
- Read replicas for load distribution
|
|
10
|
+
- Mandatory TLS with client certificates
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install -e libs/python/grass-redis
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from grass_redis import (
|
|
23
|
+
RedisSentinelClient,
|
|
24
|
+
SentinelConfig,
|
|
25
|
+
load_passwords_from_env,
|
|
26
|
+
load_sentinel_hosts_from_env,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Load from environment (never hardcode)
|
|
30
|
+
sentinel_hosts = load_sentinel_hosts_from_env() # $REDIS_SENTINEL_HOSTS
|
|
31
|
+
passwords = load_passwords_from_env() # /etc/redis/certs/passwords.env
|
|
32
|
+
|
|
33
|
+
config = SentinelConfig(
|
|
34
|
+
sentinel_hosts=sentinel_hosts,
|
|
35
|
+
databases=passwords,
|
|
36
|
+
certs_dir=Path("/etc/redis/certs"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
with RedisSentinelClient(config) as client:
|
|
40
|
+
primary = client.get_primary("db1")
|
|
41
|
+
primary.set("key", "value")
|
|
42
|
+
|
|
43
|
+
replica = client.get_replica("db1")
|
|
44
|
+
value = replica.get("key")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
**Environment (optional):**
|
|
50
|
+
```bash
|
|
51
|
+
export REDIS_SENTINEL_HOSTS="host1:26379,host2:26379,host3:26379"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Deploy certs via Ansible to `/etc/redis/certs/`:**
|
|
55
|
+
- `client.crt` - Client certificate (644)
|
|
56
|
+
- `client.key` - Private key (600)
|
|
57
|
+
- `ca.crt` - CA certificate (644)
|
|
58
|
+
- `passwords.env` - Passwords (600)
|
|
59
|
+
|
|
60
|
+
**`passwords.env` format:**
|
|
61
|
+
```bash
|
|
62
|
+
REDIS_SCRAPER_DB1_PASSWORD=xxx
|
|
63
|
+
REDIS_SCRAPER_DB2_PASSWORD=yyy
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Names are converted: `REDIS_SCRAPER_DB1_PASSWORD` → database `scraper-db1`
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
**SentinelConfig:**
|
|
71
|
+
- `sentinel_hosts` - List[(host, port)]
|
|
72
|
+
- `databases` - Dict[db_name, password]
|
|
73
|
+
- `certs_dir` - Path (default: /etc/redis/certs)
|
|
74
|
+
|
|
75
|
+
**RedisSentinelClient:**
|
|
76
|
+
- `get_primary(db)` - Connection for writes
|
|
77
|
+
- `get_replica(db)` - Connection for reads
|
|
78
|
+
- `discover_primary(db)` - Get current primary address
|
|
79
|
+
- `test_connection(db)` - PING test
|
|
80
|
+
|
|
81
|
+
See `grass-platform/reference_implementation/python/redis/` for examples.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "grass-redis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Redis Sentinel client with mandatory mutual TLS for high-availability Redis clusters"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Alex" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Database",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"redis>=5.0.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Update these URLs when publishing
|
|
39
|
+
[project.urls]
|
|
40
|
+
# Homepage = "https://github.com/your-org/grass-platform/tree/main/libs/python/grass-redis"
|
|
41
|
+
# Repository = "https://github.com/your-org/grass-platform"
|
|
42
|
+
# Issues = "https://github.com/your-org/grass-platform/issues"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
grass-redis: Production-ready Redis Sentinel client with mandatory mutual TLS.
|
|
3
|
+
|
|
4
|
+
This library provides a clean abstraction for connecting to Redis through Sentinel
|
|
5
|
+
with automatic failover, connection pooling, and mandatory TLS security.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from grass_redis.client import (
|
|
9
|
+
RedisSentinelClient,
|
|
10
|
+
SentinelConfig,
|
|
11
|
+
load_passwords_from_env,
|
|
12
|
+
load_sentinel_hosts_from_env,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"RedisSentinelClient",
|
|
19
|
+
"SentinelConfig",
|
|
20
|
+
"load_passwords_from_env",
|
|
21
|
+
"load_sentinel_hosts_from_env",
|
|
22
|
+
]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Redis Sentinel client with mandatory mutual TLS.
|
|
4
|
+
|
|
5
|
+
SECURITY: All connections require client certificates (client.crt, client.key, ca.crt).
|
|
6
|
+
TLS cannot be disabled - this is a security-first design.
|
|
7
|
+
|
|
8
|
+
Basic usage:
|
|
9
|
+
config = SentinelConfig(
|
|
10
|
+
sentinel_hosts=[("host1", 26379), ("host2", 26379)],
|
|
11
|
+
databases={"db1": "password1"},
|
|
12
|
+
certs_dir=Path("/etc/redis/certs"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
with RedisSentinelClient(config) as client:
|
|
16
|
+
primary = client.get_primary("db1")
|
|
17
|
+
primary.set("key", "value")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
import redis
|
|
26
|
+
from redis.sentinel import Sentinel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SentinelConfig:
|
|
31
|
+
"""
|
|
32
|
+
Configuration for Redis Sentinel cluster.
|
|
33
|
+
|
|
34
|
+
Required:
|
|
35
|
+
sentinel_hosts: List of (host, port) tuples
|
|
36
|
+
databases: Dict mapping database names to passwords
|
|
37
|
+
|
|
38
|
+
Optional:
|
|
39
|
+
certs_dir: Certificate directory (default: /etc/redis/certs)
|
|
40
|
+
socket_timeout: Socket operation timeout in seconds (default: 5)
|
|
41
|
+
socket_connect_timeout: Connection timeout in seconds (default: 5)
|
|
42
|
+
decode_responses: Decode responses as UTF-8 (default: True)
|
|
43
|
+
"""
|
|
44
|
+
sentinel_hosts: List[Tuple[str, int]]
|
|
45
|
+
databases: Dict[str, str]
|
|
46
|
+
certs_dir: Path = Path("/etc/redis/certs")
|
|
47
|
+
socket_timeout: int = 5
|
|
48
|
+
socket_connect_timeout: int = 5
|
|
49
|
+
decode_responses: bool = True
|
|
50
|
+
client_cert_name: str = "client.crt"
|
|
51
|
+
client_key_name: str = "client.key"
|
|
52
|
+
ca_cert_name: str = "ca.crt"
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
if not self.sentinel_hosts:
|
|
56
|
+
raise ValueError("At least one Sentinel host is required")
|
|
57
|
+
if not self.databases:
|
|
58
|
+
raise ValueError("At least one database is required")
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def client_cert_path(self) -> str:
|
|
62
|
+
return str(self.certs_dir / self.client_cert_name)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def client_key_path(self) -> str:
|
|
66
|
+
return str(self.certs_dir / self.client_key_name)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def ca_cert_path(self) -> str:
|
|
70
|
+
return str(self.certs_dir / self.ca_cert_name)
|
|
71
|
+
|
|
72
|
+
def verify_certificates(self) -> Tuple[bool, List[str]]:
|
|
73
|
+
"""Check that all required certificate files exist."""
|
|
74
|
+
missing = []
|
|
75
|
+
for path in [self.client_cert_path, self.client_key_path, self.ca_cert_path]:
|
|
76
|
+
if not Path(path).exists():
|
|
77
|
+
missing.append(path)
|
|
78
|
+
return len(missing) == 0, missing
|
|
79
|
+
|
|
80
|
+
def build_connection_kwargs(self, database: str, max_connections: Optional[int] = None) -> Dict:
|
|
81
|
+
"""Build redis connection kwargs with TLS configuration."""
|
|
82
|
+
kwargs = {
|
|
83
|
+
"password": self.databases[database],
|
|
84
|
+
"decode_responses": self.decode_responses,
|
|
85
|
+
"socket_timeout": self.socket_timeout,
|
|
86
|
+
"socket_connect_timeout": self.socket_connect_timeout,
|
|
87
|
+
"ssl": True,
|
|
88
|
+
"ssl_certfile": self.client_cert_path,
|
|
89
|
+
"ssl_keyfile": self.client_key_path,
|
|
90
|
+
"ssl_ca_certs": self.ca_cert_path,
|
|
91
|
+
}
|
|
92
|
+
if max_connections:
|
|
93
|
+
kwargs["max_connections"] = max_connections
|
|
94
|
+
return kwargs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RedisSentinelClient:
|
|
98
|
+
"""
|
|
99
|
+
Redis Sentinel client with automatic failover and connection pooling.
|
|
100
|
+
|
|
101
|
+
Features:
|
|
102
|
+
- Automatic primary discovery through Sentinel
|
|
103
|
+
- Connection pooling with caching
|
|
104
|
+
- Read replicas for load distribution
|
|
105
|
+
- Mandatory mutual TLS
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, config: SentinelConfig):
|
|
109
|
+
self.config = config
|
|
110
|
+
self._sentinel: Optional[Sentinel] = None
|
|
111
|
+
self._primary_clients: Dict[str, redis.Redis] = {}
|
|
112
|
+
self._replica_clients: Dict[str, redis.Redis] = {}
|
|
113
|
+
|
|
114
|
+
# Verify certificates exist
|
|
115
|
+
success, missing = self.config.verify_certificates()
|
|
116
|
+
if not success:
|
|
117
|
+
raise FileNotFoundError(f"Missing certificates: {', '.join(missing)}")
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def sentinel(self) -> Sentinel:
|
|
121
|
+
"""Get or create Sentinel connection (lazy initialization)."""
|
|
122
|
+
if self._sentinel is None:
|
|
123
|
+
self._sentinel = Sentinel(
|
|
124
|
+
self.config.sentinel_hosts,
|
|
125
|
+
sentinel_kwargs={
|
|
126
|
+
"socket_timeout": self.config.socket_timeout,
|
|
127
|
+
"socket_connect_timeout": self.config.socket_connect_timeout,
|
|
128
|
+
"ssl": True,
|
|
129
|
+
"ssl_certfile": self.config.client_cert_path,
|
|
130
|
+
"ssl_keyfile": self.config.client_key_path,
|
|
131
|
+
"ssl_ca_certs": self.config.ca_cert_path,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
return self._sentinel
|
|
135
|
+
|
|
136
|
+
def get_primary(self, database: str, max_connections: Optional[int] = None) -> redis.Redis:
|
|
137
|
+
"""Get primary connection for writes."""
|
|
138
|
+
if database not in self.config.databases:
|
|
139
|
+
raise KeyError(f"Database '{database}' not in config")
|
|
140
|
+
|
|
141
|
+
if database in self._primary_clients:
|
|
142
|
+
return self._primary_clients[database]
|
|
143
|
+
|
|
144
|
+
kwargs = self.config.build_connection_kwargs(database, max_connections)
|
|
145
|
+
primary = self.sentinel.master_for(database, **kwargs)
|
|
146
|
+
self._primary_clients[database] = primary
|
|
147
|
+
return primary
|
|
148
|
+
|
|
149
|
+
def get_replica(self, database: str, max_connections: Optional[int] = None) -> redis.Redis:
|
|
150
|
+
"""Get replica connection for reads."""
|
|
151
|
+
if database not in self.config.databases:
|
|
152
|
+
raise KeyError(f"Database '{database}' not in config")
|
|
153
|
+
|
|
154
|
+
if database in self._replica_clients:
|
|
155
|
+
return self._replica_clients[database]
|
|
156
|
+
|
|
157
|
+
kwargs = self.config.build_connection_kwargs(database, max_connections)
|
|
158
|
+
replica = self.sentinel.slave_for(database, **kwargs)
|
|
159
|
+
self._replica_clients[database] = replica
|
|
160
|
+
return replica
|
|
161
|
+
|
|
162
|
+
def discover_primary(self, database: str) -> Tuple[str, int]:
|
|
163
|
+
"""Get current primary address."""
|
|
164
|
+
return self.sentinel.discover_master(database)
|
|
165
|
+
|
|
166
|
+
def discover_replicas(self, database: str) -> List[Tuple[str, int]]:
|
|
167
|
+
"""Get all replica addresses."""
|
|
168
|
+
return self.sentinel.discover_slaves(database)
|
|
169
|
+
|
|
170
|
+
def test_connection(self, database: str, use_replica: bool = False) -> bool:
|
|
171
|
+
"""Test connection with PING."""
|
|
172
|
+
try:
|
|
173
|
+
client = self.get_replica(database) if use_replica else self.get_primary(database)
|
|
174
|
+
return client.ping()
|
|
175
|
+
except Exception:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
def close(self):
|
|
179
|
+
"""Close all connections."""
|
|
180
|
+
for client in self._primary_clients.values():
|
|
181
|
+
try:
|
|
182
|
+
client.close()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
for client in self._replica_clients.values():
|
|
186
|
+
try:
|
|
187
|
+
client.close()
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
self._primary_clients.clear()
|
|
191
|
+
self._replica_clients.clear()
|
|
192
|
+
self._sentinel = None
|
|
193
|
+
|
|
194
|
+
def __enter__(self):
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
198
|
+
self.close()
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
def __repr__(self) -> str:
|
|
202
|
+
return (
|
|
203
|
+
f"RedisSentinelClient("
|
|
204
|
+
f"sentinels={len(self.config.sentinel_hosts)}, "
|
|
205
|
+
f"databases={list(self.config.databases.keys())})"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Credential loading utilities
|
|
210
|
+
|
|
211
|
+
def load_passwords_from_env(password_file: Path = Path("/etc/redis/certs/passwords.env")) -> Dict[str, str]:
|
|
212
|
+
"""
|
|
213
|
+
Load Redis passwords from shell-style env file.
|
|
214
|
+
|
|
215
|
+
Format: REDIS_DBNAME_PASSWORD=xxx
|
|
216
|
+
Returns: {"dbname": "xxx"}
|
|
217
|
+
"""
|
|
218
|
+
if not password_file.exists():
|
|
219
|
+
raise FileNotFoundError(
|
|
220
|
+
f"Password file not found: {password_file}. "
|
|
221
|
+
"Deploy credentials via Ansible first."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
passwords = {}
|
|
225
|
+
with open(password_file) as f:
|
|
226
|
+
for line in f:
|
|
227
|
+
line = line.strip()
|
|
228
|
+
if line and not line.startswith("#") and "=" in line:
|
|
229
|
+
key, value = line.split("=", 1)
|
|
230
|
+
if key.startswith("REDIS_") and key.endswith("_PASSWORD"):
|
|
231
|
+
db_name = key[6:-9].lower().replace("_", "-")
|
|
232
|
+
passwords[db_name] = value.strip('"').strip("'")
|
|
233
|
+
return passwords
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_sentinel_hosts_from_env(env_var: str = "REDIS_SENTINEL_HOSTS") -> List[Tuple[str, int]]:
|
|
237
|
+
"""
|
|
238
|
+
Load Sentinel hosts from environment variable.
|
|
239
|
+
|
|
240
|
+
Format: host1:26379,host2:26379,host3:26379
|
|
241
|
+
Returns: [("host1", 26379), ("host2", 26379), ("host3", 26379)]
|
|
242
|
+
"""
|
|
243
|
+
hosts_str = os.environ.get(env_var)
|
|
244
|
+
if not hosts_str:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"Environment variable {env_var} not set. "
|
|
247
|
+
"Set it to: host1:26379,host2:26379,host3:26379"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
hosts = []
|
|
251
|
+
for entry in hosts_str.split(","):
|
|
252
|
+
entry = entry.strip()
|
|
253
|
+
if ":" not in entry:
|
|
254
|
+
raise ValueError(f"Invalid format '{entry}'. Expected 'host:port'")
|
|
255
|
+
host, port_str = entry.rsplit(":", 1)
|
|
256
|
+
try:
|
|
257
|
+
port = int(port_str)
|
|
258
|
+
except ValueError:
|
|
259
|
+
raise ValueError(f"Invalid port '{port_str}' for host '{host}'")
|
|
260
|
+
hosts.append((host, port))
|
|
261
|
+
|
|
262
|
+
if not hosts:
|
|
263
|
+
raise ValueError(f"No hosts found in {env_var}")
|
|
264
|
+
|
|
265
|
+
return hosts
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grass-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redis Sentinel client with mandatory mutual TLS for high-availability Redis clusters
|
|
5
|
+
Author: Alex
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: redis>=5.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# grass-redis
|
|
26
|
+
|
|
27
|
+
Redis Sentinel client with mandatory mutual TLS.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Automatic failover via Sentinel
|
|
32
|
+
- Connection pooling and caching
|
|
33
|
+
- Read replicas for load distribution
|
|
34
|
+
- Mandatory TLS with client certificates
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e libs/python/grass-redis
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from grass_redis import (
|
|
47
|
+
RedisSentinelClient,
|
|
48
|
+
SentinelConfig,
|
|
49
|
+
load_passwords_from_env,
|
|
50
|
+
load_sentinel_hosts_from_env,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Load from environment (never hardcode)
|
|
54
|
+
sentinel_hosts = load_sentinel_hosts_from_env() # $REDIS_SENTINEL_HOSTS
|
|
55
|
+
passwords = load_passwords_from_env() # /etc/redis/certs/passwords.env
|
|
56
|
+
|
|
57
|
+
config = SentinelConfig(
|
|
58
|
+
sentinel_hosts=sentinel_hosts,
|
|
59
|
+
databases=passwords,
|
|
60
|
+
certs_dir=Path("/etc/redis/certs"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with RedisSentinelClient(config) as client:
|
|
64
|
+
primary = client.get_primary("db1")
|
|
65
|
+
primary.set("key", "value")
|
|
66
|
+
|
|
67
|
+
replica = client.get_replica("db1")
|
|
68
|
+
value = replica.get("key")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
**Environment (optional):**
|
|
74
|
+
```bash
|
|
75
|
+
export REDIS_SENTINEL_HOSTS="host1:26379,host2:26379,host3:26379"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Deploy certs via Ansible to `/etc/redis/certs/`:**
|
|
79
|
+
- `client.crt` - Client certificate (644)
|
|
80
|
+
- `client.key` - Private key (600)
|
|
81
|
+
- `ca.crt` - CA certificate (644)
|
|
82
|
+
- `passwords.env` - Passwords (600)
|
|
83
|
+
|
|
84
|
+
**`passwords.env` format:**
|
|
85
|
+
```bash
|
|
86
|
+
REDIS_SCRAPER_DB1_PASSWORD=xxx
|
|
87
|
+
REDIS_SCRAPER_DB2_PASSWORD=yyy
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Names are converted: `REDIS_SCRAPER_DB1_PASSWORD` → database `scraper-db1`
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
**SentinelConfig:**
|
|
95
|
+
- `sentinel_hosts` - List[(host, port)]
|
|
96
|
+
- `databases` - Dict[db_name, password]
|
|
97
|
+
- `certs_dir` - Path (default: /etc/redis/certs)
|
|
98
|
+
|
|
99
|
+
**RedisSentinelClient:**
|
|
100
|
+
- `get_primary(db)` - Connection for writes
|
|
101
|
+
- `get_replica(db)` - Connection for reads
|
|
102
|
+
- `discover_primary(db)` - Get current primary address
|
|
103
|
+
- `test_connection(db)` - PING test
|
|
104
|
+
|
|
105
|
+
See `grass-platform/reference_implementation/python/redis/` for examples.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/grass_redis/__init__.py
|
|
4
|
+
src/grass_redis/client.py
|
|
5
|
+
src/grass_redis.egg-info/PKG-INFO
|
|
6
|
+
src/grass_redis.egg-info/SOURCES.txt
|
|
7
|
+
src/grass_redis.egg-info/dependency_links.txt
|
|
8
|
+
src/grass_redis.egg-info/requires.txt
|
|
9
|
+
src/grass_redis.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
grass_redis
|