From 3db3c9685fdbc17ea0617f12c1bc6ea51de2a772 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:36:06 +0800 Subject: [PATCH] feat(redis): complete cluster connection support Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.draft | 19 +++ .env.release.example | 22 +++ .github/workflows/pr-helm-chart.yml | 6 + Makefile | 6 +- REDIS-CLUSTER-SUPPORT.md | 116 --------------- charts/skillhub/README.md | 46 ++++++ charts/skillhub/templates/_helpers.tpl | 15 +- .../skillhub/templates/server-deployment.yaml | 32 ++++- charts/skillhub/templates/validate.yaml | 23 ++- .../skillhub/tests/configuration-contracts.sh | 67 +++++++++ charts/skillhub/values.schema.json | 30 +++- charts/skillhub/values.yaml | 11 ++ compose.release.yml | 18 ++- deploy/k8s/README.md | 46 +++++- deploy/k8s/base/backend-deployment.yaml | 78 ++++++++++ deploy/k8s/base/configmap.yaml | 15 ++ deploy/k8s/base/secret.yaml.example | 5 + docs/09-deployment.md | 53 ++++++- scripts/redis-cluster-integration-test.sh | 21 +++ scripts/tests/validate-release-config-test.sh | 64 +++++++++ scripts/validate-release-config.sh | 74 ++++++++++ scripts/verify-redis-config.sh | 76 ---------- .../iflytek/skillhub/config/RedisConfig.java | 119 --------------- .../skillhub/config/RedissonConfig.java | 27 ++++ .../src/main/resources/REDIS-CONFIG-GUIDE.md | 82 ----------- .../resources/application-cluster-example.yml | 23 --- .../src/main/resources/application-local.yml | 3 - .../src/main/resources/application.yml | 4 - .../config/RedisClusterIntegrationTest.java | 136 ++++++++++++++++++ .../skillhub/config/RedisConfigTest.java | 40 ------ .../RedisConnectionConfigurationTest.java | 83 +++++++++++ .../skillhub/config/RedissonConfigTest.java | 78 ++++++++++ 32 files changed, 961 insertions(+), 477 deletions(-) delete mode 100644 REDIS-CLUSTER-SUPPORT.md create mode 100755 scripts/redis-cluster-integration-test.sh delete mode 100644 scripts/verify-redis-config.sh delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisConfig.java delete mode 100644 server/skillhub-app/src/main/resources/REDIS-CONFIG-GUIDE.md delete mode 100644 server/skillhub-app/src/main/resources/application-cluster-example.yml create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConfigTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConnectionConfigurationTest.java diff --git a/.env.release.draft b/.env.release.draft index 417aab30..c994b08e 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -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 diff --git a/.env.release.example b/.env.release.example index d038d6e1..59387e0f 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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 diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 0f26fb97..20359be3 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -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: >- diff --git a/Makefile b/Makefile index c45bb9ee..af9ed846 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/REDIS-CLUSTER-SUPPORT.md b/REDIS-CLUSTER-SUPPORT.md deleted file mode 100644 index f30ba7cf..00000000 --- a/REDIS-CLUSTER-SUPPORT.md +++ /dev/null @@ -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 diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 29c41818..4d071b88 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -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。 + ### 存储配置 | 参数 | 描述 | 默认值 | diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index 2f4b9321..460f67f7 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -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 -}} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index b1bd6393..149cb203 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -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: diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index 443b880f..db34c848 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -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 -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index 17b77853..94df9733 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -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 diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 6e6497ad..19be6440 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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 } + } } } }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index e89a7d4c..059ff73c 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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 配置 diff --git a/compose.release.yml b/compose.release.yml index 69c07496..73318768 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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:-} diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index a58d031d..35b938a3 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -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、节点和独立 ACL;Sentinel 配置优先于 +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 密钥 | 否 | diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 8f09feae..52ddc185 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -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 diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index 590052e2..06dbbaa5 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -8,6 +8,21 @@ data: # 使用内置 Redis(overlays/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 diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index 5ff967cc..c2b93d45 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -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 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 48c5d9d0..bcf36289 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -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` 分支最新构建 diff --git a/scripts/redis-cluster-integration-test.sh b/scripts/redis-cluster-integration-test.sh new file mode 100755 index 00000000..a8b9f67b --- /dev/null +++ b/scripts/redis-cluster-integration-test.sh @@ -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 diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index d94ed62c..bee780fb 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -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 diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 27e9d042..7c7940a3 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -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 diff --git a/scripts/verify-redis-config.sh b/scripts/verify-redis-config.sh deleted file mode 100644 index 3480e551..00000000 --- a/scripts/verify-redis-config.sh +++ /dev/null @@ -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" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisConfig.java deleted file mode 100644 index caf82750..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisConfig.java +++ /dev/null @@ -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 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 redisTemplate(LettuceConnectionFactory connectionFactory) { - RedisTemplate 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; - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java index 9f4de0b3..6feada82 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java @@ -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())) { diff --git a/server/skillhub-app/src/main/resources/REDIS-CONFIG-GUIDE.md b/server/skillhub-app/src/main/resources/REDIS-CONFIG-GUIDE.md deleted file mode 100644 index 0bf08e66..00000000 --- a/server/skillhub-app/src/main/resources/REDIS-CONFIG-GUIDE.md +++ /dev/null @@ -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 diff --git a/server/skillhub-app/src/main/resources/application-cluster-example.yml b/server/skillhub-app/src/main/resources/application-cluster-example.yml deleted file mode 100644 index ba8a0bef..00000000 --- a/server/skillhub-app/src/main/resources/application-cluster-example.yml +++ /dev/null @@ -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 diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 3c6ac3ae..0e390aa5 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -11,11 +11,8 @@ spring: password: skillhub_dev data: redis: - mode: standalone host: localhost port: 6379 - password: - database: 0 session: store-type: redis security: diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index d1c90f6a..421e27f6 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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: diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java new file mode 100644 index 00000000..222cf39a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java @@ -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 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 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 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.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 stream = redissonClient.getStream(keyPrefix + ":stream", StringCodec.INSTANCE); + try { + stream.add(entry("status", "ok")); + assertThat(stream.size()).isEqualTo(1); + } finally { + stream.delete(); + } + } + + private List clusterNodes() { + return Arrays.stream(System.getenv("REDIS_CLUSTER_TEST_NODES").split(",")) + .map(String::trim) + .filter(node -> !node.isEmpty()) + .toList(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private SessionRepository sessionRepository(RedisIndexedSessionRepository repository) { + return (SessionRepository) repository; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConfigTest.java deleted file mode 100644 index 2ce8709d..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConfigTest.java +++ /dev/null @@ -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 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); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConnectionConfigurationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConnectionConfigurationTest.java new file mode 100644 index 00000000..82757f20 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisConnectionConfigurationTest.java @@ -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"); + }); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java index 9f290ede..1e96d898 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java @@ -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); + } }