diff --git a/docs/DELIVERY.md b/docs/DELIVERY.md index 24f896f..f13f785 100644 --- a/docs/DELIVERY.md +++ b/docs/DELIVERY.md @@ -79,6 +79,7 @@ kubectl apply -f k8s/agent-deployment-v2.yaml | `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` | Redis 连接(明文)| 生产必需(或用 `REDIS_URL`)| | `REDIS_URL` | 完整连接串,优先于上面离散变量;`rediss://` 启用 TLS,凭据写在 URL 里 | 用托管 TLS Redis(Azure Cache / Redis Enterprise)时设置;经 Secret/secret_ref 注入,勿内联 | | `REDIS_PASSWORD` / `REDIS_SSL` | 离散方式的密码 / 启用 TLS(truthy)| 配合 `REDIS_HOST` 用;密码经 Secret 注入 | +| `REDIS_CLUSTER` | 用 Redis Cluster 协议端点(truthy)| 连 Azure Redis Enterprise(clusteringPolicy=OSSCluster)必设,否则多分片下 `keys()`/跨 slot 误路由 | | `REDIS_FAKE` / `ALLOW_MEMORY_STORE` | 内存回退 | **仅开发/CI**,生产禁用 | | `ENABLE_PLANNER_FALLBACK` | 无 Manager 分工时启用规划回退 | 默认关闭 | | `ENABLE_REVIEW_LOOP` / `MAX_REVIEW_CYCLES` | 主控评审/重做循环 + 汇总 | 默认关闭 / 默认 2 | diff --git a/k8s/orchestrator-deployment.yaml b/k8s/orchestrator-deployment.yaml index 903637a..e6114ef 100644 --- a/k8s/orchestrator-deployment.yaml +++ b/k8s/orchestrator-deployment.yaml @@ -47,18 +47,23 @@ spec: value: "6379" - name: REDIS_DB value: "0" - # To use a managed TLS Redis (e.g. Azure Cache / Redis Enterprise) - # instead of the in-cluster StatefulSet, drop the REDIS_HOST/PORT above - # and inject a credentialed URL from a Secret (never inline a password): - # - name: REDIS_URL - # valueFrom: - # secretKeyRef: - # name: swarm-integration # rediss://:@:10000/0 - # key: redis_url - # Or use discrete vars: REDIS_HOST/REDIS_PORT + REDIS_SSL=1 and + # To use a managed Redis (e.g. Azure Redis Enterprise heicode-rd) + # instead of the in-cluster StatefulSet, point REDIS_HOST at it, enable + # cluster mode (Enterprise uses clusteringPolicy=OSSCluster), and inject + # the access key from a Secret (never inline a password): + # - name: REDIS_HOST + # value: "heicode-rd.southeastasia.redis.azure.net" + # - name: REDIS_PORT + # value: "10000" + # - name: REDIS_CLUSTER # required for OSSCluster endpoints + # value: "1" + # - name: REDIS_SSL # "1" only if the DB clientProtocol=Encrypted + # value: "0" # - name: REDIS_PASSWORD # valueFrom: # secretKeyRef: { name: swarm-integration, key: redis_password } + # Or supply a single credentialed URL: REDIS_URL=rediss://:@host:10000/0 + # (also set REDIS_CLUSTER=1 for an OSSCluster endpoint). - name: LOG_LEVEL value: "INFO" resources: diff --git a/orchestrator/redis_client.py b/orchestrator/redis_client.py index 3edcff9..7df6782 100644 --- a/orchestrator/redis_client.py +++ b/orchestrator/redis_client.py @@ -45,6 +45,10 @@ class RedisClient: self.db = int(os.getenv("REDIS_DB", "0")) self.password = os.getenv("REDIS_PASSWORD") or None self.ssl = _truthy(os.getenv("REDIS_SSL")) + # Cluster mode is required for endpoints speaking the Redis Cluster protocol + # (e.g. Azure Redis Enterprise with clusteringPolicy=OSSCluster). A plain + # client there silently mis-routes keys() / cross-slot ops on multi-shard DBs. + self.cluster = _truthy(os.getenv("REDIS_CLUSTER")) def _fallback_allowed(self) -> bool: return _truthy(os.getenv("REDIS_FAKE")) or _truthy(os.getenv("ALLOW_MEMORY_STORE")) @@ -53,15 +57,28 @@ class RedisClient: """Build a real redis-py client from config (URL preferred, else discrete vars). Connection kwargs only — does NOT connect. Credential-bearing values - (URL/password) are never logged here or by callers. + (URL/password) are never logged here or by callers. When ``REDIS_CLUSTER`` + is set, a cluster-aware client is built (cluster mode has no DB select, so + ``REDIS_DB`` is ignored — Redis Cluster only exposes logical DB 0). """ if redis is None: raise RuntimeError("redis-py is not installed") common = dict( decode_responses=True, socket_connect_timeout=5, - socket_keepalive=True, ) + if self.cluster: + from redis.asyncio.cluster import RedisCluster # lazy: only when cluster mode on + if self.url: + return RedisCluster.from_url(self.url, **common) + kwargs = dict(host=self.host, port=self.port, **common) + if self.password: + kwargs["password"] = self.password + if self.ssl: + kwargs["ssl"] = True + return RedisCluster(**kwargs) + + common["socket_keepalive"] = True if self.url: # from_url honours the scheme: rediss:// → TLS, and any user:pass in the URL. return redis.Redis.from_url(self.url, **common) @@ -74,10 +91,11 @@ class RedisClient: def _target_desc(self) -> str: """Human-readable target for logs — no credentials. Never logs the URL itself.""" + mode = "cluster" if self.cluster else "standalone" if self.url: scheme = self.url.split("://", 1)[0] if "://" in self.url else "redis" - return f"REDIS_URL ({scheme}://, TLS={scheme == 'rediss'})" - return f"{self.host}:{self.port} (TLS={self.ssl})" + return f"REDIS_URL ({scheme}://, TLS={scheme == 'rediss'}, {mode})" + return f"{self.host}:{self.port} (TLS={self.ssl}, {mode})" def _make_fake_client(self): """Return an in-process fakeredis client (dev/CI fallback only).""" diff --git a/scripts/test-redis-connection-config.py b/scripts/test-redis-connection-config.py index da8467b..dae43e0 100644 --- a/scripts/test-redis-connection-config.py +++ b/scripts/test-redis-connection-config.py @@ -88,6 +88,22 @@ def test_url_precedence_and_tls(): print("ok: REDIS_URL precedence + rediss:// TLS, credentials not logged") +def test_cluster_mode(): + # OSS Cluster endpoint (e.g. Azure Redis Enterprise clusteringPolicy=OSSCluster): + # must build a cluster-aware client so keys()/cross-slot routing is correct. + c, _ = _fresh_client({ + "REDIS_CLUSTER": "1", + "REDIS_HOST": "heicode-rd.southeastasia.redis.azure.net", + "REDIS_PORT": "10000", + "REDIS_PASSWORD": "key-from-secret", + }) + real = c._make_real_client() # constructs lazily; does NOT connect + assert type(real).__name__ == "RedisCluster", type(real).__name__ + assert "cluster" in c._target_desc(), c._target_desc() + assert "key-from-secret" not in c._target_desc(), c._target_desc() + print("ok: REDIS_CLUSTER → RedisCluster client (OSS Cluster safe)") + + def main(): try: import redis # noqa: F401 @@ -97,6 +113,7 @@ def main(): test_default_plaintext() test_discrete_password_and_ssl() test_url_precedence_and_tls() + test_cluster_mode() print("\nALL PASS: redis connection config") return 0