feat(redis): complete cluster connection support

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-29 17:36:06 +08:00
parent dad3c15f92
commit 3db3c9685f
32 changed files with 961 additions and 477 deletions

View file

@ -32,6 +32,25 @@ POSTGRES_PASSWORD=TODO_change_to_a_strong_database_password
REDIS_BIND_ADDRESS=127.0.0.1
REDIS_PORT=6379
# Optional external Redis Cluster. Leave commented to use bundled standalone Redis.
# All advertised node addresses must be reachable from the server container.
# SPRING_DATA_REDIS_CLUSTER_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379
# SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
# SPRING_DATA_REDIS_USERNAME=
# SPRING_DATA_REDIS_PASSWORD=
# SPRING_DATA_REDIS_SSL_ENABLED=true
# SPRING_DATA_REDIS_CONNECT_TIMEOUT=5s
# SPRING_DATA_REDIS_TIMEOUT=3s
# SPRING_DATA_REDIS_CLIENT_NAME=skillhub
# Optional external Redis Sentinel. Sentinel takes precedence if both Sentinel
# and Cluster settings are present.
# SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
# SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379
# SPRING_DATA_REDIS_SENTINEL_USERNAME=
# SPRING_DATA_REDIS_SENTINEL_PASSWORD=
# SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=true
# Host ports exposed by the app containers.
API_PORT=8080
WEB_PORT=80

View file

@ -27,6 +27,28 @@ POSTGRES_PASSWORD=change-this-postgres-password
REDIS_BIND_ADDRESS=127.0.0.1
REDIS_PORT=6379
# Optional external Redis connection. Leave these commented to use the bundled
# standalone Redis service. For Redis Cluster, every advertised node address
# must be reachable from the server container.
# SPRING_DATA_REDIS_CLUSTER_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379
# SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
# SPRING_DATA_REDIS_USERNAME=
# SPRING_DATA_REDIS_PASSWORD=
# SPRING_DATA_REDIS_SSL_ENABLED=false
# SPRING_DATA_REDIS_CONNECT_TIMEOUT=5s
# SPRING_DATA_REDIS_TIMEOUT=3s
# SPRING_DATA_REDIS_CLIENT_NAME=skillhub
# Optional external Redis Sentinel. Sentinel takes precedence if both Sentinel
# and Cluster settings are present. Use separate credentials when Sentinel ACL
# differs from the Redis data nodes.
# SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
# SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379
# SPRING_DATA_REDIS_SENTINEL_USERNAME=
# SPRING_DATA_REDIS_SENTINEL_PASSWORD=
# SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=true
API_PORT=8080
WEB_PORT=80
SESSION_COOKIE_SECURE=false

View file

@ -94,6 +94,12 @@ jobs:
args: >-
--set redis.architecture=replication
--set redis.sentinel.enabled=true
- name: external-redis-cluster
description: 外部 Redis Cluster
args: >-
--set redis.enabled=false
--set externalRedis.cluster.enabled=true
--set-json 'externalRedis.cluster.nodes=["redis-0.example.com:6379","redis-1.example.com:6379","redis-2.example.com:6379"]'
- name: ingress-tls-certmanager
description: Ingress + TLS + cert-manager
args: >-

View file

@ -1,4 +1,4 @@
.PHONY: build build-backend build-backend-app build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-web typecheck-cli typecheck-web validate-release-config verify-redis web-deps web-install web-install-ci
.PHONY: build build-backend build-backend-app build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
DEV_DIR := .dev
DEV_SERVER_PID := $(DEV_DIR)/server.pid
@ -395,5 +395,5 @@ docs-build: ## 构建文档站点
docs-preview: ## 预览构建后的文档站点
cd docs/skillhub && npm run preview
verify-redis: ## 验证 Redis 配置(支持 standalone 和 cluster 模式)
bash scripts/verify-redis-config.sh
test-redis-cluster: ## 使用真实 Redis Cluster 验证 Spring Data、Session 和 Redisson Stream
./scripts/redis-cluster-integration-test.sh

View file

@ -1,116 +0,0 @@
# Redis Cluster Support Implementation Summary
## Overview
Added support for both standalone and cluster Redis deployment modes to SkillHub.
## Changes Made
### 1. New Configuration Class
**File:** `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisConfig.java`
- Created a new configuration class that supports both standalone and cluster modes
- Uses `@ConditionalOnProperty` to select the appropriate connection factory based on `spring.data.redis.mode`
- Default mode is `standalone` for backward compatibility
- Both modes use Lettuce as the Redis client
### 2. Updated Configuration Files
#### application.yml
Added new configuration properties:
```yaml
spring:
data:
redis:
mode: ${SPRING_DATA_REDIS_MODE:standalone}
database: ${SPRING_DATA_REDIS_DATABASE:0}
cluster:
nodes: ${SPRING_DATA_REDIS_CLUSTER_NODES:}
max-redirects: ${SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS:3}
```
#### application-local.yml
Updated to explicitly specify standalone mode for local development.
### 3. Example Configuration
**File:** `server/skillhub-app/src/main/resources/application-cluster-example.yml`
- Provides a complete example of cluster configuration
- Shows how to configure multiple cluster nodes
- Includes environment variable examples
### 4. Documentation
**File:** `server/skillhub-app/src/main/resources/REDIS-CONFIG-GUIDE.md`
- Comprehensive guide on using both modes
- Configuration examples for YAML and environment variables
- Instructions for switching between modes
### 5. Test Coverage
**File:** `server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConfigTest.java`
- Basic test to verify Redis template is configured correctly
- Tests basic Redis operations
## Usage
### Standalone Mode (Default)
```bash
# No changes needed - works as before
make dev-all
```
Or explicitly:
```bash
SPRING_DATA_REDIS_MODE=standalone make dev-all
```
### Cluster Mode
```bash
SPRING_DATA_REDIS_MODE=cluster \
SPRING_DATA_REDIS_CLUSTER_NODES=redis-node1:6379,redis-node2:6379,redis-node3:6379 \
make dev-all
```
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `SPRING_DATA_REDIS_MODE` | Redis mode: standalone or cluster | standalone |
| `SPRING_DATA_REDIS_HOST` | Redis host (standalone mode) | localhost |
| `SPRING_DATA_REDIS_PORT` | Redis port (standalone mode) | 6379 |
| `SPRING_DATA_REDIS_PASSWORD` | Redis password | (empty) |
| `SPRING_DATA_REDIS_DATABASE` | Redis database number (standalone) | 0 |
| `SPRING_DATA_REDIS_CLUSTER_NODES` | Cluster nodes (comma-separated) | (empty) |
| `SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS` | Max redirects for cluster | 3 |
## Backward Compatibility
- Existing deployments continue to work without any changes
- Default behavior remains standalone mode
- All existing environment variables are still supported
- No breaking changes to the API or configuration structure
## Testing
To test the configuration:
1. **Standalone mode:**
```bash
make dev-all
# Verify Redis connectivity in logs
```
2. **Cluster mode:**
```bash
# Set up a Redis cluster first
SPRING_DATA_REDIS_MODE=cluster \
SPRING_DATA_REDIS_CLUSTER_NODES=node1:6379,node2:6379,node3:6379 \
make dev-all
```
## Notes
- The implementation uses Spring Boot's Lettuce connection factory
- Session storage and all Redis-dependent features work with both modes
- Cluster mode requires proper Redis cluster setup before use
- For production cluster deployments, ensure proper network configuration and security

View file

@ -294,6 +294,10 @@ Sentinel 地址。Chart **不负责数据库架构切换时的数据迁移**
standalone → replication、Redis standalone/replication → Sentinel 等切换,必须由
运维人员在 Chart 之外完成备份、恢复、PVC 复用或其他迁移方案。
外部 Redis Cluster 由云服务或运维系统提供Chart 只负责注入连接配置,不创建
Cluster也不将其计入上述内置架构运行时验证范围。应用侧应另行验证 Spring
Data、Spring Session 与 Redisson Stream 链路。
### Redis Sentinel
内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和
@ -333,6 +337,48 @@ server:
value: "false"
```
### 外部 Redis Cluster
Chart 不创建内置 Redis Cluster。生产环境的 Cluster 由云 Redis 或独立运维系统
提供SkillHub 通过标准 Spring Boot 配置连接。至少配置一个 seed 节点,且
Cluster 通告的所有节点地址都必须能从 Server Pod 访问:
```yaml
redis:
enabled: false
externalRedis:
username: skillhub
tls:
enabled: true
connectTimeout: 5s
timeout: 3s
clientName: skillhub-server
cluster:
enabled: true
nodes:
- redis-0.example.com:6379
- redis-1.example.com:6379
- redis-2.example.com:6379
maxRedirects: 5
```
Redis Cluster 只支持数据库 `0``maxRedirects` 交给 Spring Data/Lettuce 处理;
Redisson 使用同一节点、ACL、TLS 与超时配置并自行处理 Cluster 路由。密码建议
通过 `existingSecret``redis-password` 提供:
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set redis.enabled=false \
--set existingSecret=skillhub-production-secret \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-0.example.com:6379","redis-1.example.com:6379","redis-2.example.com:6379"]'
```
`externalRedis.sentinel.enabled``externalRedis.cluster.enabled` 互斥;内置
`redis.enabled=true` 时也不能启用外部 Cluster。
### 存储配置
| 参数 | 描述 | 默认值 |

View file

@ -190,8 +190,13 @@ app.kubernetes.io/component: scanner
{{- printf "%s-master" (include "skillhub.redis.fullname" .) -}}
{{- end -}}
{{- else -}}
{{- if .Values.externalRedis.cluster.enabled -}}
{{- $node := first .Values.externalRedis.cluster.nodes -}}
{{- first (splitList ":" $node) -}}
{{- else -}}
{{- .Values.externalRedis.host -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- /* Redis Port */}}
@ -203,7 +208,10 @@ app.kubernetes.io/component: scanner
{{- print "6379" -}}
{{- end -}}
{{- else -}}
{{- if .Values.externalRedis.sentinel.enabled -}}
{{- if .Values.externalRedis.cluster.enabled -}}
{{- $node := first .Values.externalRedis.cluster.nodes -}}
{{- last (splitList ":" $node) -}}
{{- else if .Values.externalRedis.sentinel.enabled -}}
{{- $node := first .Values.externalRedis.sentinel.nodes -}}
{{- last (splitList ":" $node) -}}
{{- else -}}
@ -245,7 +253,10 @@ app.kubernetes.io/component: scanner
{{- if .Values.redis.enabled -}}
{{- include "skillhub.redis.host" . -}}
{{- else -}}
{{- if .Values.externalRedis.sentinel.enabled -}}
{{- if .Values.externalRedis.cluster.enabled -}}
{{- $node := first .Values.externalRedis.cluster.nodes -}}
{{- first (splitList ":" $node) -}}
{{- else if .Values.externalRedis.sentinel.enabled -}}
{{- $node := first .Values.externalRedis.sentinel.nodes -}}
{{- first (splitList ":" $node) -}}
{{- else -}}

View file

@ -109,6 +109,11 @@ spec:
value: {{ .Values.externalRedis.sentinel.masterSet | default "mymaster" | quote }}
- name: SPRING_DATA_REDIS_SENTINEL_NODES
value: {{ join "," .Values.externalRedis.sentinel.nodes | quote }}
{{- else if and (not .Values.redis.enabled) .Values.externalRedis.cluster.enabled }}
- name: SPRING_DATA_REDIS_CLUSTER_NODES
value: {{ join "," .Values.externalRedis.cluster.nodes | quote }}
- name: SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS
value: {{ .Values.externalRedis.cluster.maxRedirects | quote }}
{{- else }}
- name: SPRING_DATA_REDIS_HOST
valueFrom:
@ -122,6 +127,31 @@ spec:
key: redis-port
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.username }}
- name: SPRING_DATA_REDIS_USERNAME
value: {{ .Values.externalRedis.username | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.tls.enabled }}
- name: SPRING_DATA_REDIS_SSL_ENABLED
value: "true"
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.connectTimeout }}
- name: SPRING_DATA_REDIS_CONNECT_TIMEOUT
value: {{ .Values.externalRedis.connectTimeout | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.timeout }}
- name: SPRING_DATA_REDIS_TIMEOUT
value: {{ .Values.externalRedis.timeout | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.clientName }}
- name: SPRING_DATA_REDIS_CLIENT_NAME
value: {{ .Values.externalRedis.clientName | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled .Values.externalRedis.sentinel.username }}
- name: SPRING_DATA_REDIS_SENTINEL_USERNAME
value: {{ .Values.externalRedis.sentinel.username | quote }}
{{- end }}
{{- if or (and .Values.redis.enabled .Values.redis.sentinel.enabled) (and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled) }}
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
@ -145,7 +175,7 @@ spec:
key: redis-sentinel-password
{{- end }}
optional: true
{{- else if or .Values.redis.enabled .Values.externalRedis.password }}
{{- else if or .Values.redis.enabled .Values.externalRedis.password .Values.existingSecret }}
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
secretKeyRef:

View file

@ -56,12 +56,33 @@
{{- if and (not .Values.postgresql.enabled) (not .Values.externalDatabase.host) -}}
{{- fail "postgresql.enabled=false requires externalDatabase.host for dependency checks" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) (not .Values.externalRedis.sentinel.enabled) (not .Values.externalRedis.host) -}}
{{- if and .Values.externalRedis.sentinel.enabled .Values.externalRedis.cluster.enabled -}}
{{- fail "externalRedis.sentinel.enabled and externalRedis.cluster.enabled are mutually exclusive" -}}
{{- end -}}
{{- if and .Values.redis.enabled .Values.externalRedis.cluster.enabled -}}
{{- fail "externalRedis.cluster.enabled=true requires redis.enabled=false" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) (not .Values.externalRedis.sentinel.enabled) (not .Values.externalRedis.cluster.enabled) (not .Values.externalRedis.host) -}}
{{- fail "redis.enabled=false requires externalRedis.host" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled (not .Values.externalRedis.sentinel.nodes) -}}
{{- fail "external Redis Sentinel requires at least one externalRedis.sentinel.nodes entry" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.cluster.enabled (not .Values.externalRedis.cluster.nodes) -}}
{{- fail "external Redis Cluster requires at least one externalRedis.cluster.nodes entry" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.cluster.enabled -}}
{{- range $node := .Values.externalRedis.cluster.nodes -}}
{{- if not (regexMatch "^[A-Za-z0-9._-]+:[0-9]{1,5}$" $node) -}}
{{- fail (printf "externalRedis.cluster.nodes entry must use host:port: %s" $node) -}}
{{- end -}}
{{- $parts := splitList ":" $node -}}
{{- $port := last $parts | int -}}
{{- if or (lt $port 1) (gt $port 65535) -}}
{{- fail (printf "externalRedis.cluster.nodes port must be between 1 and 65535: %s" $node) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- if and .Values.s3.endpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.endpoint)) -}}
{{- fail "s3.endpoint must be an absolute HTTP(S) URL" -}}
{{- end -}}

View file

@ -123,18 +123,65 @@ render external-sentinel "$CHART_DIR" \
--set postgresql.enabled=false \
--set externalDatabase.host=db.example.com \
--set redis.enabled=false \
--set externalRedis.username=redis-user \
--set externalRedis.password=redis-password \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.sentinel.username=sentinel-user \
--set externalRedis.sentinel.password=sentinel-password \
--set-json 'externalRedis.sentinel.nodes=["sentinel-a:26379","sentinel-b:26379"]' \
--show-only templates/server-deployment.yaml >"$TMP_DIR/external-sentinel.yaml"
grep -Fq 'value: "sentinel-a"' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
grep -A1 -F 'name: SPRING_DATA_REDIS_USERNAME' "$TMP_DIR/external-sentinel.yaml" \
| grep -Fq 'value: "redis-user"'
grep -A1 -F 'name: SPRING_DATA_REDIS_SENTINEL_USERNAME' "$TMP_DIR/external-sentinel.yaml" \
| grep -Fq 'value: "sentinel-user"'
if grep -Fq 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/external-sentinel.yaml"; then
fail "external Sentinel must preserve Redisson address consistency checks by default"
fi
render external-cluster "$CHART_DIR" \
--set postgresql.enabled=false \
--set externalDatabase.host=db.example.com \
--set redis.enabled=false \
--set existingSecret=skillhub-production-secret \
--set externalRedis.username=skillhub \
--set externalRedis.tls.enabled=true \
--set externalRedis.connectTimeout=5s \
--set externalRedis.timeout=3s \
--set externalRedis.clientName=skillhub-server \
--set externalRedis.cluster.enabled=true \
--set externalRedis.cluster.maxRedirects=7 \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379","redis-b.example.com:6380"]' \
--show-only templates/server-deployment.yaml >"$TMP_DIR/external-cluster.yaml"
grep -A1 -F 'name: REDIS_HOST' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "redis-a.example.com"'
grep -A1 -F 'name: REDIS_PORT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "6379"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLUSTER_NODES' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "redis-a.example.com:6379,redis-b.example.com:6380"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "7"'
grep -A1 -F 'name: SPRING_DATA_REDIS_USERNAME' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "skillhub"'
grep -A1 -F 'name: SPRING_DATA_REDIS_SSL_ENABLED' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "true"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CONNECT_TIMEOUT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "5s"'
grep -A1 -F 'name: SPRING_DATA_REDIS_TIMEOUT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "3s"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLIENT_NAME' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "skillhub-server"'
grep -A4 -F 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'name: skillhub-production-secret'
if grep -Fq 'name: SPRING_DATA_REDIS_HOST' "$TMP_DIR/external-cluster.yaml"; then
fail "external Redis Cluster must not render standalone host configuration"
fi
if grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_NODES' "$TMP_DIR/external-cluster.yaml"; then
fail "external Redis Cluster must not render Sentinel configuration"
fi
render special "$CHART_DIR" \
--set-string 'bootstrapAdmin.displayName=Ops: Admin' \
--show-only templates/configmap.yaml >"$TMP_DIR/special.yaml"
@ -229,6 +276,26 @@ assert_rejected old-postgres-env --set-json 'postgresql.primary.extraEnv=[{"name
assert_rejected old-sentinel-password --set redis.auth.sentinelPassword=unused
assert_rejected old-sentinel-nodes --set redis.sentinel.nodes=unused
assert_rejected old-sentinel-service-switch --set redis.sentinel.service.enabled=false
assert_rejected internal-and-external-cluster \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379"]'
assert_rejected sentinel-and-cluster \
--set redis.enabled=false \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.sentinel.nodes=["sentinel-a.example.com:26379"]' \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379"]'
assert_rejected cluster-without-nodes \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true
assert_rejected cluster-invalid-node \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com"]'
assert_rejected cluster-invalid-port \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:65536"]'
assert_rejected invalid-fullname --set fullnameOverride=INVALID_NAME
assert_rejected old-ingress-host --set ingress.host=old.example.com
assert_rejected old-ingress-tls-object --set ingress.tls.enabled=true

View file

@ -210,15 +210,27 @@
"externalRedis": {
"type": "object",
"additionalProperties": false,
"required": ["host", "port", "password", "sentinel"],
"required": ["host", "port", "username", "password", "tls", "connectTimeout", "timeout", "clientName", "sentinel", "cluster"],
"properties": {
"host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"username": { "type": "string" },
"password": { "type": "string" },
"tls": {
"type": "object",
"additionalProperties": false,
"required": ["enabled"],
"properties": {
"enabled": { "type": "boolean" }
}
},
"connectTimeout": { "type": "string" },
"timeout": { "type": "string" },
"clientName": { "type": "string" },
"sentinel": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "masterSet", "nodes", "password"],
"required": ["enabled", "masterSet", "nodes", "username", "password"],
"properties": {
"enabled": { "type": "boolean" },
"masterSet": { "type": "string", "minLength": 1 },
@ -226,8 +238,22 @@
"type": "array",
"items": { "type": "string", "pattern": "^[^:]+:[0-9]+$" }
},
"username": { "type": "string" },
"password": { "type": "string" }
}
},
"cluster": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "nodes", "maxRedirects"],
"properties": {
"enabled": { "type": "boolean" },
"nodes": {
"type": "array",
"items": { "type": "string", "pattern": "^[^:]+:[0-9]+$" }
},
"maxRedirects": { "type": "integer", "minimum": 0 }
}
}
}
},

View file

@ -257,12 +257,23 @@ redis:
externalRedis:
host: ""
port: 6379
username: ""
password: ""
tls:
enabled: false
connectTimeout: ""
timeout: ""
clientName: ""
sentinel:
enabled: false
masterSet: mymaster
nodes: []
username: ""
password: ""
cluster:
enabled: false
nodes: []
maxRedirects: 5
# ============================================================================
# Server 配置

View file

@ -53,8 +53,22 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo}
REDIS_HOST: redis
REDIS_PORT: 6379
SPRING_DATA_REDIS_HOST: ${SPRING_DATA_REDIS_HOST:-redis}
SPRING_DATA_REDIS_PORT: ${SPRING_DATA_REDIS_PORT:-6379}
SPRING_DATA_REDIS_USERNAME:
SPRING_DATA_REDIS_PASSWORD:
SPRING_DATA_REDIS_DATABASE:
SPRING_DATA_REDIS_SENTINEL_MASTER:
SPRING_DATA_REDIS_SENTINEL_NODES:
SPRING_DATA_REDIS_SENTINEL_USERNAME:
SPRING_DATA_REDIS_SENTINEL_PASSWORD:
SPRING_DATA_REDIS_CLUSTER_NODES:
SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS:
SPRING_DATA_REDIS_SSL_ENABLED:
SPRING_DATA_REDIS_CONNECT_TIMEOUT:
SPRING_DATA_REDIS_TIMEOUT:
SPRING_DATA_REDIS_CLIENT_NAME:
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST:
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}

View file

@ -59,6 +59,8 @@ cp secret.yaml.example secret.yaml
| spring-datasource-url | PostgreSQL 连接 URL | 是 |
| spring-datasource-username | 数据库用户名 | 是 |
| spring-datasource-password | 数据库密码 | 是 |
| redis-password | Redis 数据节点密码 | 否 |
| redis-sentinel-password | Redis Sentinel 独立密码 | 否 |
| bootstrap-admin-password | 管理员密码 | 是 |
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
@ -86,9 +88,38 @@ redis-host: your-redis-host
redis-port: "6379"
```
2. 修改 `base/secret.yaml` 中的数据库连接:
连接外部 Redis Cluster 时改为配置节点列表。保留 `redis-host`/`redis-port`
不会影响 Cluster 选择:
```yaml
redis-cluster-nodes: "redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379"
redis-cluster-max-redirects: "5"
redis-username: "skillhub"
redis-ssl-enabled: "true"
redis-connect-timeout: "5s"
redis-timeout: "3s"
redis-client-name: "skillhub"
```
所有 Cluster 节点通告的地址都必须能从 Server Pod 访问。Redis Cluster 只支持
数据库 `0`
连接外部 Redis Sentinel 时配置 master、节点和独立 ACLSentinel 配置优先于
Cluster 和单机 host/port
```yaml
redis-username: "skillhub"
redis-sentinel-master: "mymaster"
redis-sentinel-nodes: "sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379"
redis-sentinel-username: "sentinel-user"
redis-sentinel-check-list: "true"
```
2. 修改 `base/secret.yaml` 中的数据库和 Redis 凭据:
```yaml
spring-datasource-url: jdbc:postgresql://your-postgres-host:5432/skillhub
redis-password: your-redis-password
redis-sentinel-password: your-sentinel-password
```
3. 部署:
@ -171,6 +202,17 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/
|---|---|---|
| redis-host | redis | Redis 主机地址 |
| redis-port | 6379 | Redis 端口 |
| redis-cluster-nodes | 未设置 | 外部 Redis Cluster 节点,逗号分隔的 `host:port` |
| redis-cluster-max-redirects | 未设置 | Cluster MOVED/ASK 最大重定向次数 |
| redis-sentinel-master | 未设置 | Sentinel master set 名称 |
| redis-sentinel-nodes | 未设置 | Sentinel 节点,逗号分隔的 `host:port` |
| redis-sentinel-username | 未设置 | Sentinel ACL 用户名 |
| redis-sentinel-check-list | true | 是否校验 Sentinel 返回的节点列表 |
| redis-username | 未设置 | Redis ACL 用户名 |
| redis-ssl-enabled | 未设置 | 是否使用 TLS |
| redis-connect-timeout | 未设置 | Redis 建连超时 |
| redis-timeout | 未设置 | Redis 命令超时 |
| redis-client-name | 未设置 | Redis 客户端名称 |
| storage-base-path | /var/lib/skillhub/storage | 技能存储路径 |
| skillhub-storage-provider | local | 存储类型local/s3 |
| skill-scanner-enabled | true | 是否启用扫描器 |
@ -190,6 +232,8 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/
| spring-datasource-url | PostgreSQL 连接 URL | 是 |
| spring-datasource-username | 数据库用户名 | 是 |
| spring-datasource-password | 数据库密码 | 是 |
| redis-password | Redis 数据节点密码 | 否 |
| redis-sentinel-password | Redis Sentinel 独立密码 | 否 |
| bootstrap-admin-password | 管理员密码 | 是 |
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |

View file

@ -53,6 +53,84 @@ spec:
configMapKeyRef:
name: skillhub-config
key: redis-port
- name: SPRING_DATA_REDIS_CLUSTER_NODES
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-cluster-nodes
optional: true
- name: SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-cluster-max-redirects
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_MASTER
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-master
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_NODES
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-nodes
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_USERNAME
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-username
optional: true
- name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-check-list
optional: true
- name: SPRING_DATA_REDIS_USERNAME
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-username
optional: true
- name: SPRING_DATA_REDIS_SSL_ENABLED
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-ssl-enabled
optional: true
- name: SPRING_DATA_REDIS_CONNECT_TIMEOUT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-connect-timeout
optional: true
- name: SPRING_DATA_REDIS_TIMEOUT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-timeout
optional: true
- name: SPRING_DATA_REDIS_CLIENT_NAME
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-client-name
optional: true
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: skillhub-secret
key: redis-password
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_PASSWORD
valueFrom:
secretKeyRef:
name: skillhub-secret
key: redis-sentinel-password
optional: true
# Storage
- name: STORAGE_BASE_PATH

View file

@ -8,6 +8,21 @@ data:
# 使用内置 Redisoverlays/with-infra保持 redis
redis-host: redis
redis-port: "6379"
# 连接外部 Redis Cluster 时取消下面几行注释。Cluster 节点存在时,
# Spring Boot 会自动忽略上面的单机 host/port。
# redis-cluster-nodes: "redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379"
# redis-cluster-max-redirects: "5"
# redis-username: "skillhub"
# redis-ssl-enabled: "true"
# redis-connect-timeout: "5s"
# redis-timeout: "3s"
# redis-client-name: "skillhub"
# 连接外部 Redis Sentinel 时取消下面几行注释。Sentinel 配置优先于
# Cluster 和单机 host/port。
# redis-sentinel-master: "mymaster"
# redis-sentinel-nodes: "sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379"
# redis-sentinel-username: "sentinel-user"
# redis-sentinel-check-list: "true"
# 技能存储路径
storage-base-path: /var/lib/skillhub/storage

View file

@ -15,6 +15,11 @@ stringData:
spring-datasource-username: skillhub
spring-datasource-password: change-me
# Redis 数据节点密码单机、Sentinel 与 Cluster 共用;无密码时留空)
redis-password: ""
# Redis Sentinel 独立密码;与数据节点相同时也可填写同一个值
redis-sentinel-password: ""
# Bootstrap 管理员密码(敏感)
bootstrap-admin-password: ChangeMe!2026

View file

@ -107,7 +107,56 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- Web UI: `SKILLHUB_PUBLIC_BASE_URL`
- Backend API: `http://localhost:8080`
### 5.2 关键文件
### 5.2 连接外部 Redis Cluster
发布 Compose 默认仍使用内置单机 Redis。连接外部 Redis Cluster 时,在
`.env.release` 中设置标准 Spring Boot 配置,不需要额外的模式开关:
```dotenv
SPRING_DATA_REDIS_CLUSTER_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379
SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
SPRING_DATA_REDIS_USERNAME=skillhub
SPRING_DATA_REDIS_PASSWORD=replace-with-secret
SPRING_DATA_REDIS_SSL_ENABLED=true
SPRING_DATA_REDIS_CONNECT_TIMEOUT=5s
SPRING_DATA_REDIS_TIMEOUT=3s
```
Cluster 节点返回给客户端的所有地址必须能从 `server` 容器访问。Redis Cluster
只支持数据库 `0`;不要为 Cluster 设置非零的
`SPRING_DATA_REDIS_DATABASE`。配置 Cluster 节点后Spring Boot 自动忽略单机
`host`/`port`Compose 中的内置 Redis 容器仍会启动,但不会被 Server 使用。
对真实 Cluster 运行功能检查:
```bash
REDIS_CLUSTER_TEST_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379 \
REDIS_CLUSTER_TEST_USERNAME=skillhub \
REDIS_CLUSTER_TEST_PASSWORD=replace-with-secret \
make test-redis-cluster
```
该检查覆盖 Spring Data 读写、Spring Session 保存/读取/删除和 Redisson Stream。
### 5.3 连接外部 Redis Sentinel
Sentinel 使用标准 Spring Boot 配置。数据节点和 Sentinel 可以使用不同 ACL
```dotenv
SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379
SPRING_DATA_REDIS_USERNAME=skillhub
SPRING_DATA_REDIS_PASSWORD=replace-with-data-node-secret
SPRING_DATA_REDIS_SENTINEL_USERNAME=sentinel-user
SPRING_DATA_REDIS_SENTINEL_PASSWORD=replace-with-sentinel-secret
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=true
```
Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Sentinel
返回地址与客户端入口不一致的环境中,可以将
`SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST` 设为 `false`
### 5.4 关键文件
- `compose.release.yml`
- 使用发布镜像,不在用户机器上执行本地构建
@ -121,7 +170,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- 在启动前校验 `.env.release`
- 可提前拦截占位值、URL 格式错误、缺失的 OSS 凭据、危险的明文默认值
### 5.3 镜像标签约定
### 5.5 镜像标签约定
- `edge`
- `main` 分支最新构建

View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
NODES=${REDIS_CLUSTER_TEST_NODES:-${SPRING_DATA_REDIS_CLUSTER_NODES:-}}
if [[ -z "$NODES" ]]; then
echo "ERROR: REDIS_CLUSTER_TEST_NODES or SPRING_DATA_REDIS_CLUSTER_NODES is required" >&2
exit 2
fi
export REDIS_CLUSTER_TEST_NODES="$NODES"
export REDIS_CLUSTER_TEST_USERNAME="${REDIS_CLUSTER_TEST_USERNAME:-${SPRING_DATA_REDIS_USERNAME:-}}"
export REDIS_CLUSTER_TEST_PASSWORD="${REDIS_CLUSTER_TEST_PASSWORD:-${SPRING_DATA_REDIS_PASSWORD:-}}"
export REDIS_CLUSTER_TEST_SSL_ENABLED="${REDIS_CLUSTER_TEST_SSL_ENABLED:-${SPRING_DATA_REDIS_SSL_ENABLED:-false}}"
cd "$REPO_ROOT/server"
JDK_JAVA_OPTIONS="${JDK_JAVA_OPTIONS:-} -XX:+EnableDynamicAgentLoading" \
./mvnw -pl skillhub-app -am test \
-Dtest=RedisClusterIntegrationTest \
-Dsurefire.failIfNoSpecifiedTests=false

View file

@ -86,6 +86,70 @@ write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minim
printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env"
expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false"
valid_redis_cluster_env="$tmp/valid-redis-cluster.env"
write_env "$valid_redis_cluster_env" "release-download-secret-32-bytes-minimum"
cat >>"$valid_redis_cluster_env" <<'EOF'
SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com:6379,redis-b.example.com:6380
SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
SPRING_DATA_REDIS_SSL_ENABLED=true
EOF
"$SCRIPT" "$valid_redis_cluster_env" >/dev/null
invalid_redis_cluster_node_env="$tmp/invalid-redis-cluster-node.env"
write_env "$invalid_redis_cluster_node_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com" >>"$invalid_redis_cluster_node_env"
expect_fail "$invalid_redis_cluster_node_env" "SPRING_DATA_REDIS_CLUSTER_NODES entries must use host:port"
invalid_redis_cluster_port_env="$tmp/invalid-redis-cluster-port.env"
write_env "$invalid_redis_cluster_port_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com:65536" >>"$invalid_redis_cluster_port_env"
expect_fail "$invalid_redis_cluster_port_env" "SPRING_DATA_REDIS_CLUSTER_NODES port must be between 1 and 65535"
invalid_redis_redirects_env="$tmp/invalid-redis-redirects.env"
write_env "$invalid_redis_redirects_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=-1" >>"$invalid_redis_redirects_env"
expect_fail "$invalid_redis_redirects_env" "SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS must be a non-negative integer"
invalid_redis_database_env="$tmp/invalid-redis-database.env"
write_env "$invalid_redis_database_env" "release-download-secret-32-bytes-minimum"
cat >>"$invalid_redis_database_env" <<'EOF'
SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com:6379
SPRING_DATA_REDIS_DATABASE=1
EOF
expect_fail "$invalid_redis_database_env" "SPRING_DATA_REDIS_DATABASE must be 0 when SPRING_DATA_REDIS_CLUSTER_NODES is set"
valid_redis_sentinel_env="$tmp/valid-redis-sentinel.env"
write_env "$valid_redis_sentinel_env" "release-download-secret-32-bytes-minimum"
cat >>"$valid_redis_sentinel_env" <<'EOF'
SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-a.example.com:26379,sentinel-b.example.com:26379
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=false
EOF
"$SCRIPT" "$valid_redis_sentinel_env" >/dev/null
missing_redis_sentinel_nodes_env="$tmp/missing-redis-sentinel-nodes.env"
write_env "$missing_redis_sentinel_nodes_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster" >>"$missing_redis_sentinel_nodes_env"
expect_fail "$missing_redis_sentinel_nodes_env" "SPRING_DATA_REDIS_SENTINEL_NODES is required when SPRING_DATA_REDIS_SENTINEL_MASTER is set"
missing_redis_sentinel_master_env="$tmp/missing-redis-sentinel-master.env"
write_env "$missing_redis_sentinel_master_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-a.example.com:26379" >>"$missing_redis_sentinel_master_env"
expect_fail "$missing_redis_sentinel_master_env" "SPRING_DATA_REDIS_SENTINEL_MASTER is required when SPRING_DATA_REDIS_SENTINEL_NODES is set"
invalid_redis_sentinel_port_env="$tmp/invalid-redis-sentinel-port.env"
write_env "$invalid_redis_sentinel_port_env" "release-download-secret-32-bytes-minimum"
cat >>"$invalid_redis_sentinel_port_env" <<'EOF'
SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-a.example.com:70000
EOF
expect_fail "$invalid_redis_sentinel_port_env" "SPRING_DATA_REDIS_SENTINEL_NODES port must be between 1 and 65535"
invalid_redis_sentinel_check_env="$tmp/invalid-redis-sentinel-check.env"
write_env "$invalid_redis_sentinel_check_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=yes" >>"$invalid_redis_sentinel_check_env"
expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST must be true or false"
draft_env="$tmp/draft.env"
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in

View file

@ -126,6 +126,74 @@ validate_min_length() {
fi
}
validate_non_negative_integer() {
var_name="$1"
eval "var_value=\${$var_name:-}"
case "$var_value" in
"") ;;
*[!0-9]*) error "$var_name must be a non-negative integer" ;;
esac
}
validate_redis_nodes() {
var_name="$1"
eval "nodes=\${$var_name:-}"
if [ -z "$nodes" ]; then
return 0
fi
old_ifs="$IFS"
IFS=","
for node in $nodes; do
host=${node%:*}
port=${node##*:}
if [ -z "$host" ] || [ "$host" = "$node" ]; then
error "$var_name entries must use host:port"
continue
fi
case "$host" in
*[!A-Za-z0-9._-]*)
error "$var_name contains an invalid host: $host"
;;
esac
case "$port" in
*[!0-9]*|"")
error "$var_name contains an invalid port: $node"
;;
*)
if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
error "$var_name port must be between 1 and 65535: $node"
fi
;;
esac
done
IFS="$old_ifs"
}
validate_redis_sentinel_configuration() {
master="${SPRING_DATA_REDIS_SENTINEL_MASTER:-}"
nodes="${SPRING_DATA_REDIS_SENTINEL_NODES:-}"
if [ -n "$master" ] && [ -z "$nodes" ]; then
error "SPRING_DATA_REDIS_SENTINEL_NODES is required when SPRING_DATA_REDIS_SENTINEL_MASTER is set"
fi
if [ -n "$nodes" ] && [ -z "$master" ]; then
error "SPRING_DATA_REDIS_SENTINEL_MASTER is required when SPRING_DATA_REDIS_SENTINEL_NODES is set"
fi
validate_redis_nodes SPRING_DATA_REDIS_SENTINEL_NODES
}
validate_redis_cluster_database() {
if [ -z "${SPRING_DATA_REDIS_CLUSTER_NODES:-}" ]; then
return 0
fi
case "${SPRING_DATA_REDIS_DATABASE:-0}" in
0) ;;
*) error "SPRING_DATA_REDIS_DATABASE must be 0 when SPRING_DATA_REDIS_CLUSTER_NODES is set" ;;
esac
}
require_non_empty SKILLHUB_PUBLIC_BASE_URL
validate_url SKILLHUB_PUBLIC_BASE_URL
validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL
@ -154,11 +222,17 @@ validate_boolean BOOTSTRAP_ADMIN_ENABLED
validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO
validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET
validate_boolean SPRING_DATA_REDIS_SSL_ENABLED
validate_boolean SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
validate_port POSTGRES_PORT
validate_port REDIS_PORT
validate_port API_PORT
validate_port WEB_PORT
validate_non_negative_integer SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS
validate_redis_nodes SPRING_DATA_REDIS_CLUSTER_NODES
validate_redis_cluster_database
validate_redis_sentinel_configuration
require_non_empty POSTGRES_DB
require_non_empty POSTGRES_USER

View file

@ -1,76 +0,0 @@
#!/bin/bash
# Redis Configuration Verification Script
# This script helps verify Redis configuration is working correctly
set -e
echo "=== Redis Configuration Verification ==="
echo ""
# Check if running in standalone or cluster mode
REDIS_MODE=${SPRING_DATA_REDIS_MODE:-standalone}
echo "Redis Mode: $REDIS_MODE"
if [ "$REDIS_MODE" = "standalone" ]; then
REDIS_HOST=${SPRING_DATA_REDIS_HOST:-${REDIS_HOST:-localhost}}
REDIS_PORT=${SPRING_DATA_REDIS_PORT:-${REDIS_PORT:-6379}}
echo "Host: $REDIS_HOST"
echo "Port: $REDIS_PORT"
# Test connection
echo "Testing Redis connection..."
if command -v redis-cli &> /dev/null; then
if redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" ping | grep -q PONG; then
echo "✓ Redis connection successful"
else
echo "✗ Redis connection failed"
exit 1
fi
else
echo "⚠ redis-cli not found, skipping connection test"
fi
elif [ "$REDIS_MODE" = "cluster" ]; then
REDIS_NODES=${SPRING_DATA_REDIS_CLUSTER_NODES:-}
if [ -z "$REDIS_NODES" ]; then
echo "✗ Cluster nodes not configured"
exit 1
fi
echo "Cluster Nodes: $REDIS_NODES"
# Test each node
IFS=',' read -ra NODES <<< "$REDIS_NODES"
for node in "${NODES[@]}"; do
HOST=$(echo "$node" | cut -d: -f1)
PORT=$(echo "$node" | cut -d: -f2)
echo "Testing node: $HOST:$PORT"
if command -v redis-cli &> /dev/null; then
if redis-cli -h "$HOST" -p "$PORT" ping | grep -q PONG; then
echo "✓ Node $HOST:$PORT is reachable"
else
echo "✗ Node $HOST:$PORT is not reachable"
exit 1
fi
else
echo "⚠ redis-cli not found, skipping connection test for $HOST:$PORT"
fi
done
else
echo "✗ Invalid Redis mode: $REDIS_MODE"
echo "Valid modes: standalone, cluster"
exit 1
fi
echo ""
echo "=== Configuration Summary ==="
echo "Mode: $REDIS_MODE"
if [ "$REDIS_MODE" = "standalone" ]; then
echo "Database: ${SPRING_DATA_REDIS_DATABASE:-0}"
elif [ "$REDIS_MODE" = "cluster" ]; then
echo "Max Redirects: ${SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS:-3}"
fi
echo ""
echo "✓ Redis configuration verification completed successfully"

View file

@ -1,119 +0,0 @@
package com.iflytek.skillhub.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.util.List;
/**
* Redis configuration supporting both standalone and cluster modes.
* Mode selection is controlled via spring.data.redis.mode property:
* - standalone (default): Single Redis instance
* - cluster: Redis Cluster with multiple nodes
*/
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host:localhost}")
private String host;
@Value("${spring.data.redis.port:6379}")
private int port;
@Value("${spring.data.redis.password:}")
private String password;
@Value("${spring.data.redis.database:0}")
private int database;
@Value("${spring.data.redis.cluster.nodes:}")
private List<String> clusterNodes;
@Value("${spring.data.redis.cluster.max-redirects:3}")
private int maxRedirects;
/**
* Creates Redis connection factory for standalone mode.
*/
@Bean
@ConditionalOnProperty(name = "spring.data.redis.mode", havingValue = "standalone", matchIfMissing = true)
@ConditionalOnMissingBean(name = "redisConnectionFactory")
public LettuceConnectionFactory redisStandaloneConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(host);
config.setPort(port);
if (password != null && !password.isEmpty()) {
config.setPassword(password);
}
config.setDatabase(database);
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build();
return new LettuceConnectionFactory(config, clientConfig);
}
/**
* Creates Redis connection factory for cluster mode.
*/
@Bean
@ConditionalOnProperty(name = "spring.data.redis.mode", havingValue = "cluster")
@ConditionalOnMissingBean(name = "redisConnectionFactory")
public LettuceConnectionFactory redisClusterConnectionFactory() {
if (clusterNodes == null || clusterNodes.isEmpty()) {
throw new IllegalStateException("Redis cluster nodes must be configured when using cluster mode");
}
RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration();
clusterConfig.setClusterNodes(clusterNodes.stream()
.map(node -> {
String[] parts = node.split(":");
if (parts.length == 2) {
return new org.springframework.data.redis.connection.RedisNode(
parts[0], Integer.parseInt(parts[1]));
} else {
throw new IllegalArgumentException("Invalid cluster node format: " + node);
}
})
.toList());
if (password != null && !password.isEmpty()) {
clusterConfig.setPassword(password);
}
clusterConfig.setMaxRedirects(maxRedirects);
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build();
return new LettuceConnectionFactory(clusterConfig, clientConfig);
}
/**
* Creates RedisTemplate bean for both modes.
*/
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
StringRedisSerializer keySerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}

View file

@ -2,10 +2,12 @@ package com.iflytek.skillhub.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.ClusterServersConfig;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
@ -19,6 +21,7 @@ import java.util.List;
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(RedissonClient.class)
public RedissonClient redissonClient(
RedisProperties redisProperties,
@Value("${skillhub.redis.sentinel.check-sentinels-list:true}") boolean checkSentinelsList) {
@ -35,6 +38,10 @@ public class RedissonConfig {
configureSentinelServers(config, redisProperties, checkSentinelsList);
return config;
}
if (hasClusterConfiguration(redisProperties)) {
configureClusterServers(config, redisProperties);
return config;
}
SingleServerConfig singleServerConfig = config.useSingleServer()
.setAddress(resolveAddress(redisProperties))
@ -64,6 +71,20 @@ public class RedissonConfig {
if (StringUtils.hasText(redisProperties.getSentinel().getPassword())) {
sentinelServersConfig.setSentinelPassword(redisProperties.getSentinel().getPassword());
}
if (StringUtils.hasText(redisProperties.getSentinel().getUsername())) {
sentinelServersConfig.setSentinelUsername(redisProperties.getSentinel().getUsername());
}
}
private static void configureClusterServers(Config config, RedisProperties redisProperties) {
ClusterServersConfig clusterServersConfig = config.useClusterServers();
redisProperties.getCluster().getNodes().stream()
.map(String::trim)
.filter(StringUtils::hasText)
.map(node -> withRedisScheme(node, redisProperties))
.forEach(clusterServersConfig::addNodeAddress);
applySharedSettings(clusterServersConfig, redisProperties);
}
private static boolean hasSentinelConfiguration(RedisProperties redisProperties) {
@ -73,6 +94,12 @@ public class RedissonConfig {
&& !redisProperties.getSentinel().getNodes().isEmpty();
}
private static boolean hasClusterConfiguration(RedisProperties redisProperties) {
return redisProperties.getCluster() != null
&& redisProperties.getCluster().getNodes() != null
&& redisProperties.getCluster().getNodes().stream().anyMatch(StringUtils::hasText);
}
private static void applySharedSettings(org.redisson.config.BaseConfig<?> serverConfig,
RedisProperties redisProperties) {
if (StringUtils.hasText(redisProperties.getUsername())) {

View file

@ -1,82 +0,0 @@
# Redis Configuration Guide
SkillHub supports both **standalone** and **cluster** Redis deployment modes.
## Configuration Modes
### Standalone Mode (Default)
This is the default mode for local development and simple deployments.
**Configuration in `application.yml`:**
```yaml
spring:
data:
redis:
mode: standalone # or omit this line (default)
host: localhost
port: 6379
password: ""
database: 0
```
**Environment Variables:**
```bash
SPRING_DATA_REDIS_MODE=standalone
SPRING_DATA_REDIS_HOST=localhost
SPRING_DATA_REDIS_PORT=6379
SPRING_DATA_REDIS_PASSWORD=""
SPRING_DATA_REDIS_DATABASE=0
```
### Cluster Mode
For production environments requiring high availability and scalability.
**Configuration in `application.yml`:**
```yaml
spring:
data:
redis:
mode: cluster
password: "your-password" # optional
cluster:
nodes:
- redis-node1:6379
- redis-node2:6379
- redis-node3:6379
max-redirects: 3
```
**Environment Variables:**
```bash
SPRING_DATA_REDIS_MODE=cluster
SPRING_DATA_REDIS_CLUSTER_NODES=redis-node1:6379,redis-node2:6379,redis-node3:6379
SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=3
SPRING_DATA_REDIS_PASSWORD=your-password
```
## Switching Between Modes
To switch from standalone to cluster mode:
1. Set `SPRING_DATA_REDIS_MODE=cluster`
2. Configure cluster nodes via `SPRING_DATA_REDIS_CLUSTER_NODES`
3. Restart the application
To switch back to standalone mode:
1. Set `SPRING_DATA_REDIS_MODE=standalone` (or unset it)
2. Configure single node via `SPRING_DATA_REDIS_HOST` and `SPRING_DATA_REDIS_PORT`
3. Restart the application
## Example Configurations
See `application-cluster-example.yml` for a complete cluster configuration example.
## Notes
- The default mode is `standalone` if `spring.data.redis.mode` is not specified
- Cluster mode requires at least one node to be configured
- Both modes use Lettuce as the Redis client
- Session storage and all Redis-dependent features work with both modes

View file

@ -1,23 +0,0 @@
# Redis Cluster Configuration Example
# Copy this file to application-cluster.yml and modify the cluster nodes as needed
spring:
data:
redis:
mode: cluster
password: ${REDIS_PASSWORD:}
cluster:
# List of cluster nodes in host:port format
nodes:
- redis-node1:6379
- redis-node2:6379
- redis-node3:6379
- redis-node4:6379
- redis-node5:6379
- redis-node6:6379
max-redirects: 3
# For environment variables, set:
# SPRING_DATA_REDIS_MODE=cluster
# SPRING_DATA_REDIS_CLUSTER_NODES=redis-node1:6379,redis-node2:6379,redis-node3:6379
# SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=3

View file

@ -11,11 +11,8 @@ spring:
password: skillhub_dev
data:
redis:
mode: standalone
host: localhost
port: 6379
password:
database: 0
session:
store-type: redis
security:

View file

@ -39,14 +39,10 @@ spring:
maximum-pool-size: ${DB_POOL_MAX_SIZE:10}
data:
redis:
mode: ${SPRING_DATA_REDIS_MODE:standalone} # standalone or cluster
host: ${SPRING_DATA_REDIS_HOST:${REDIS_HOST:localhost}}
port: ${SPRING_DATA_REDIS_PORT:${REDIS_PORT:6379}}
password: ${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}}
database: ${SPRING_DATA_REDIS_DATABASE:0}
cluster:
nodes: ${SPRING_DATA_REDIS_CLUSTER_NODES:}
max-redirects: ${SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS:3}
session:
store-type: redis
redis:

View file

@ -0,0 +1,136 @@
package com.iflytek.skillhub.config;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.redisson.Redisson;
import org.redisson.api.RStream;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.redisson.config.Config;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.redis.RedisIndexedSessionRepository;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.redisson.api.stream.StreamAddArgs.entry;
@Tag("redis-cluster")
@EnabledIfEnvironmentVariable(named = "REDIS_CLUSTER_TEST_NODES", matches = ".+")
class RedisClusterIntegrationTest {
@Test
void clusterSupportsSpringDataSessionsAndRedissonStreams() {
List<String> nodes = clusterNodes();
RedisClusterConfiguration clusterConfiguration = new RedisClusterConfiguration(nodes);
String username = System.getenv("REDIS_CLUSTER_TEST_USERNAME");
String password = System.getenv("REDIS_CLUSTER_TEST_PASSWORD");
if (username != null && !username.isBlank()) {
clusterConfiguration.setUsername(username);
}
if (password != null && !password.isBlank()) {
clusterConfiguration.setPassword(password);
}
boolean sslEnabled = Boolean.parseBoolean(System.getenv("REDIS_CLUSTER_TEST_SSL_ENABLED"));
LettuceClientConfiguration.LettuceClientConfigurationBuilder clientConfiguration =
LettuceClientConfiguration.builder();
if (sslEnabled) {
clientConfiguration.useSsl();
}
LettuceConnectionFactory connectionFactory =
new LettuceConnectionFactory(clusterConfiguration, clientConfiguration.build());
connectionFactory.afterPropertiesSet();
RedisProperties properties = new RedisProperties();
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(nodes);
properties.setCluster(cluster);
properties.setUsername(username);
properties.setPassword(password);
properties.getSsl().setEnabled(sslEnabled);
Config redissonConfig = RedissonConfig.createConfig(properties);
String keyPrefix = "skillhub:redis-cluster-smoke:" + UUID.randomUUID();
RedissonClient redissonClient = Redisson.create(redissonConfig);
try {
verifySpringData(connectionFactory, keyPrefix);
verifySpringSession(connectionFactory, keyPrefix);
verifyRedissonStream(redissonClient, keyPrefix);
} finally {
redissonClient.shutdown();
connectionFactory.destroy();
}
}
private void verifySpringData(LettuceConnectionFactory connectionFactory, String keyPrefix) {
StringRedisTemplate template = new StringRedisTemplate(connectionFactory);
String key = keyPrefix + ":value";
template.opsForValue().set(key, "ok");
assertThat(template.opsForValue().get(key)).isEqualTo("ok");
template.delete(key);
}
private void verifySpringSession(LettuceConnectionFactory connectionFactory, String keyPrefix) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
RedisIndexedSessionRepository repository = new RedisIndexedSessionRepository(template);
repository.setRedisKeyNamespace(keyPrefix + ":session");
repository.afterPropertiesSet();
try {
SessionRepository<Session> sessionRepository = sessionRepository(repository);
Session session = sessionRepository.createSession();
session.setAttribute("userId", "cluster-user");
sessionRepository.save(session);
Session loaded = sessionRepository.findById(session.getId());
assertThat(loaded).isNotNull();
assertThat(loaded.<String>getAttribute("userId")).isEqualTo("cluster-user");
sessionRepository.deleteById(session.getId());
assertThat(sessionRepository.findById(session.getId())).isNull();
} finally {
repository.destroy();
}
}
private void verifyRedissonStream(RedissonClient redissonClient, String keyPrefix) {
RStream<String, String> stream = redissonClient.getStream(keyPrefix + ":stream", StringCodec.INSTANCE);
try {
stream.add(entry("status", "ok"));
assertThat(stream.size()).isEqualTo(1);
} finally {
stream.delete();
}
}
private List<String> clusterNodes() {
return Arrays.stream(System.getenv("REDIS_CLUSTER_TEST_NODES").split(","))
.map(String::trim)
.filter(node -> !node.isEmpty())
.toList();
}
@SuppressWarnings({"rawtypes", "unchecked"})
private SessionRepository<Session> sessionRepository(RedisIndexedSessionRepository repository) {
return (SessionRepository) repository;
}
}

View file

@ -1,40 +0,0 @@
package com.iflytek.skillhub.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Test to verify Redis configuration works correctly.
*/
@SpringBootTest
@ActiveProfiles("test")
class RedisConfigTest {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Test
void testRedisTemplateIsConfigured() {
assertThat(redisTemplate).isNotNull();
}
@Test
void testRedisConnection() {
// Test basic Redis operations
String key = "test:key";
String value = "test:value";
redisTemplate.opsForValue().set(key, value);
Object retrieved = redisTemplate.opsForValue().get(key);
assertThat(retrieved).isEqualTo(value);
// Cleanup
redisTemplate.delete(key);
}
}

View file

@ -0,0 +1,83 @@
package com.iflytek.skillhub.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisSentinelConfiguration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import static org.assertj.core.api.Assertions.assertThat;
class RedisConnectionConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(RedisAutoConfiguration.class));
@Test
void autoConfiguration_keepsStandaloneAsTheDefault() {
contextRunner
.withPropertyValues(
"spring.data.redis.host=redis.internal",
"spring.data.redis.port=6380",
"spring.data.redis.database=4")
.run(context -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
RedisStandaloneConfiguration standalone = factory.getStandaloneConfiguration();
assertThat(factory.getClusterConfiguration()).isNull();
assertThat(factory.getSentinelConfiguration()).isNull();
assertThat(standalone).isNotNull();
assertThat(standalone.getHostName()).isEqualTo("redis.internal");
assertThat(standalone.getPort()).isEqualTo(6380);
assertThat(standalone.getDatabase()).isEqualTo(4);
});
}
@Test
void autoConfiguration_selectsClusterFromStandardSpringProperties() {
contextRunner
.withPropertyValues(
"spring.data.redis.cluster.nodes=redis-1:6379,redis-2:6380",
"spring.data.redis.cluster.max-redirects=5",
"spring.data.redis.username=skillhub",
"spring.data.redis.password=secret")
.run(context -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
RedisClusterConfiguration cluster = factory.getClusterConfiguration();
assertThat(factory.isClusterAware()).isTrue();
assertThat(factory.getSentinelConfiguration()).isNull();
assertThat(cluster).isNotNull();
assertThat(cluster.getClusterNodes())
.extracting(node -> node.getHost() + ":" + node.getPort())
.containsExactly("redis-1:6379", "redis-2:6380");
assertThat(cluster.getMaxRedirects()).isEqualTo(5);
assertThat(cluster.getUsername()).isEqualTo("skillhub");
assertThat(cluster.getPassword().map(String::new).orElse(null)).isEqualTo("secret");
});
}
@Test
void autoConfiguration_prefersSentinelWhenSentinelAndClusterAreBothConfigured() {
contextRunner
.withPropertyValues(
"spring.data.redis.sentinel.master=mymaster",
"spring.data.redis.sentinel.nodes=sentinel-1:26379,sentinel-2:26379",
"spring.data.redis.cluster.nodes=redis-1:6379,redis-2:6380")
.run(context -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
RedisSentinelConfiguration sentinel = factory.getSentinelConfiguration();
assertThat(factory.isRedisSentinelAware()).isTrue();
assertThat(factory.getClusterConfiguration()).isNull();
assertThat(sentinel).isNotNull();
assertThat(sentinel.getMaster().getName()).isEqualTo("mymaster");
assertThat(sentinel.getSentinels())
.extracting(node -> node.getHost() + ":" + node.getPort())
.containsExactlyInAnyOrder("sentinel-1:26379", "sentinel-2:26379");
});
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.config;
import org.junit.jupiter.api.Test;
import org.redisson.config.ClusterServersConfig;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
@ -122,6 +123,77 @@ class RedissonConfigTest {
assertThat(sentinelConfig.getPassword()).isEqualTo("master-secret");
}
@Test
void createConfig_appliesSentinelUsernameWhenSet() throws Exception {
RedisProperties properties = new RedisProperties();
RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel();
sentinel.setMaster("mymaster");
sentinel.setNodes(List.of("redis-sentinel-1:26379"));
sentinel.setUsername("sentinel-user");
properties.setSentinel(sentinel);
Config config = RedissonConfig.createConfig(properties);
SentinelServersConfig sentinelConfig = sentinelConfig(config);
assertThat(sentinelConfig.getSentinelUsername()).isEqualTo("sentinel-user");
}
@Test
void createConfig_usesClusterServersWhenClusterNodesArePresent() throws Exception {
RedisProperties properties = new RedisProperties();
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(List.of("redis-cluster-1:6379", " redis-cluster-2:6380 "));
properties.setCluster(cluster);
properties.setUsername("skillhub");
properties.setPassword("secret");
properties.setClientName("skillhub-stream");
properties.setTimeout(Duration.ofSeconds(3));
properties.setConnectTimeout(Duration.ofSeconds(5));
Config config = RedissonConfig.createConfig(properties);
ClusterServersConfig clusterConfig = clusterConfig(config);
assertThat(config.isClusterConfig()).isTrue();
assertThat(clusterConfig.getNodeAddresses())
.containsExactly("redis://redis-cluster-1:6379", "redis://redis-cluster-2:6380");
assertThat(clusterConfig.getUsername()).isEqualTo("skillhub");
assertThat(clusterConfig.getPassword()).isEqualTo("secret");
assertThat(clusterConfig.getClientName()).isEqualTo("skillhub-stream");
assertThat(clusterConfig.getTimeout()).isEqualTo(3000);
assertThat(clusterConfig.getConnectTimeout()).isEqualTo(5000);
}
@Test
void createConfig_usesSecureSchemeForClusterAddressesWhenSslEnabled() throws Exception {
RedisProperties properties = new RedisProperties();
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(List.of("redis-cluster-1:6379"));
properties.setCluster(cluster);
properties.getSsl().setEnabled(true);
Config config = RedissonConfig.createConfig(properties);
ClusterServersConfig clusterConfig = clusterConfig(config);
assertThat(clusterConfig.getNodeAddresses()).containsExactly("rediss://redis-cluster-1:6379");
}
@Test
void createConfig_prefersSentinelWhenSentinelAndClusterAreBothConfigured() {
RedisProperties properties = new RedisProperties();
RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel();
sentinel.setMaster("mymaster");
sentinel.setNodes(List.of("redis-sentinel-1:26379"));
properties.setSentinel(sentinel);
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(List.of("redis-cluster-1:6379"));
properties.setCluster(cluster);
Config config = RedissonConfig.createConfig(properties);
assertThat(config.isSentinelConfig()).isTrue();
assertThat(config.isClusterConfig()).isFalse();
}
@Test
void createConfig_keepsSentinelMembershipCheckEnabledByDefault() throws Exception {
RedisProperties properties = new RedisProperties();
@ -171,4 +243,10 @@ class RedissonConfigTest {
method.setAccessible(true);
return (SentinelServersConfig) method.invoke(config);
}
private ClusterServersConfig clusterConfig(Config config) throws Exception {
Method method = Config.class.getDeclaredMethod("getClusterServersConfig");
method.setAccessible(true);
return (ClusterServersConfig) method.invoke(config);
}
}