From 3b3905be63d550dcbce044f5bf86672b670835f8 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Tue, 14 Jul 2026 14:42:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=20Bitnami?= =?UTF-8?q?=20=E4=BE=9D=E8=B5=96=E8=BF=9E=E7=BA=BF=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=BA=94=E7=94=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于当前 SkillHub 运行时契约和 Bitnami 依赖命名,更新原贡献者提交的 Helm Chart 配置。 - 将 Server 正确连接到实际的 PostgreSQL 和 Redis Service 与 Secret - 支持依赖组件的 existingSecret 名称和自定义密码 key,避免安装时 lookup - 同步 S3、匿名下载、Scanner LLM、公开地址、设备认证和直接认证配置 - 将应用版本和 Chart 版本对齐当前发布版本 - 收紧 Chart 发布触发条件和手动版本选择逻辑 - 增加依赖 Service、Secret 和密码 key 的 CI 语义断言 已通过 Helm lint、九组渲染场景、kubeconform、工作流安全检查和后端应用测试套件。 Signed-off-by: lhb6540 --- .github/workflows/pr-helm-chart.yml | 29 +++++- .github/workflows/publish-chart.yml | 29 +++++- charts/skillhub/Chart.yaml | 4 +- charts/skillhub/README.md | 25 +++-- charts/skillhub/templates/_helpers.tpl | 53 ++++++++-- charts/skillhub/templates/configmap.yaml | 12 +++ .../templates/scanner-deployment.yaml | 8 +- charts/skillhub/templates/secret.yaml | 57 +++++------ .../skillhub/templates/server-deployment.yaml | 96 +++++++++++++++---- charts/skillhub/templates/web-deployment.yaml | 17 +++- charts/skillhub/values.yaml | 20 +++- 11 files changed, 267 insertions(+), 83 deletions(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index b1c38d37..37787c14 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - charts/skillhub/** + - .github/workflows/pr-helm-chart.yml + - .github/workflows/publish-chart.yml types: - opened - synchronize @@ -30,11 +32,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@v4 with: - version: latest + version: v3.19.0 - name: Build dependencies run: helm dependency build . @@ -121,11 +125,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@v4 with: - version: latest + version: v3.19.0 - name: Build dependencies run: helm dependency build . @@ -145,6 +151,25 @@ jobs: exit 1 fi + - name: Validate default dependency wiring + if: ${{ matrix.scenario.name == 'bitnami-default' }} + run: | + helm template test-release . --show-only templates/server-deployment.yaml > server.yaml + grep -Fq 'value: test-release-postgresql' server.yaml + grep -Fq 'value: test-release-redis-master' server.yaml + grep -Fq 'name: test-release-postgresql' server.yaml + grep -Fq 'name: test-release-redis' server.yaml + grep -Fq 'key: password' server.yaml + grep -Fq 'key: redis-password' server.yaml + if grep -Fq 'test-release-skillhub-postgresql' server.yaml; then + echo 'ERROR: Server references a non-existent PostgreSQL service' + exit 1 + fi + if grep -Fq 'test-release-skillhub-redis' server.yaml; then + echo 'ERROR: Server references a non-existent Redis service' + exit 1 + fi + - name: Schema validation (kubeconform) uses: docker://ghcr.io/yannh/kubeconform:latest with: diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml index 1e4994ae..13fdf039 100644 --- a/.github/workflows/publish-chart.yml +++ b/.github/workflows/publish-chart.yml @@ -4,6 +4,11 @@ on: release: types: [published] workflow_dispatch: + inputs: + version: + description: Chart and application version (for example, 0.2.13) + required: true + type: string concurrency: group: publish-chart-${{ github.ref }} @@ -15,6 +20,11 @@ permissions: jobs: release: + if: >- + github.event_name == 'workflow_dispatch' || + startsWith(github.ref_name, 'v') || + startsWith(github.ref_name, 'chart-v') || + startsWith(github.ref_name, 'helm-v') runs-on: ubuntu-latest defaults: run: @@ -23,11 +33,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@v4 with: - version: latest + version: v3.19.0 - name: Verify dependencies run: helm dependency build . @@ -38,12 +50,19 @@ jobs: - name: Parse version from tag id: ver run: | - REF="${{ github.ref_name }}" - # 兼容 v0.2.9、chart-v0.2.9、helm-v0.2.9 三种标签格式 - if [[ "$REF" =~ ^(helm|chart)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VER="${{ inputs.version }}" + elif [[ "${{ github.ref_name }}" =~ ^(helm|chart)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then VER="${BASH_REMATCH[2]}" + elif [[ "${{ github.ref_name }}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VER="${BASH_REMATCH[1]}" else - VER="${REF#v}" + echo "ERROR: Unsupported release tag: ${{ github.ref_name }}" + exit 1 + fi + if [[ ! "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: Version must use MAJOR.MINOR.PATCH format: $VER" + exit 1 fi echo "version=$VER" >> "$GITHUB_OUTPUT" diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml index ce981b6a..60ba84c2 100644 --- a/charts/skillhub/Chart.yaml +++ b/charts/skillhub/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: skillhub description: Self-hosted, open-source agent skill registry for enterprises. type: application -version: 0.3.0 -appVersion: 0.3.0 +version: 0.1.0 +appVersion: 0.2.13 keywords: - skillhub - ai diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 19fcefc3..47f8339b 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -24,7 +24,8 @@ kubectl create namespace skillhub helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password + --set bootstrapAdmin.password=your-secure-password \ + --set publicBaseUrl=https://skills.example.com ``` ### 高可用模式 @@ -55,22 +56,23 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ### 使用 existingSecret -通过 `existingSecret` 引用已存在的 Secret 对象,避免在 values 中明文写入密码。该 Secret 必须包含以下 key: +通过 `existingSecret` 引用已存在的 Secret 对象,避免在 values 中明文写入密码。 +内置 PostgreSQL/Redis 使用各自的 Bitnami Secret,不需要复制到该 Secret。 | Key | 必填 | 说明 | |-----|------|------| -| `spring-datasource-url` | 是 | JDBC 连接 URL | -| `spring-datasource-username` | 是 | 数据库用户名 | -| `spring-datasource-password` | 是 | 数据库密码 | -| `redis-password` | 是 | Redis 密码 | -| `redis-sentinel-password` | 否 | Redis Sentinel 密码(sentinel 模式) | +| `spring-datasource-password` | 使用外部 PostgreSQL 时 | 数据库密码 | +| `redis-password` | 使用外部 Redis 时 | Redis 密码 | +| `redis-sentinel-password` | 使用外部 Sentinel 时 | Redis Sentinel 密码 | | `bootstrap-admin-password` | 是 | 初始管理员密码 | +| `skillhub-download-anon-cookie-secret` | 是 | 至少 32 字符的匿名下载 Cookie 签名密钥 | | `oauth2-github-client-id` | 否 | GitHub OAuth2 Client ID | | `oauth2-github-client-secret` | 否 | GitHub OAuth2 Client Secret | | `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key | +| `skill-scanner-llm-base-url` | 否 | Scanner 自定义 LLM API 地址 | | `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 | -| `s3-access-key` | 否 | S3 Access Key | -| `s3-secret-key` | 否 | S3 Secret Key | +| `skillhub-storage-s3-access-key` | 否 | S3 Access Key | +| `skillhub-storage-s3-secret-key` | 否 | S3 Secret Key | ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ @@ -137,7 +139,11 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `s3.enabled` | 启用 S3 | `false` | | `s3.bucket` | Bucket 名称 | `skillhub-storage` | | `s3.endpoint` | S3 端点 | `""` | +| `s3.publicEndpoint` | S3 公网访问端点 | `""` | | `s3.region` | 区域 | `us-east-1` | +| `s3.forcePathStyle` | 强制 path-style 访问 | `true` | +| `s3.disableChunkedEncoding` | 禁用 aws-chunked 编码 | `false` | +| `s3.autoCreateBucket` | 自动创建 Bucket | `false` | | `s3.accessKey` | Access Key | `""` | | `s3.secretKey` | Secret Key | `""` | @@ -158,6 +164,7 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ --set ingress.enabled=true \ --set ingress.host=skills.example.com \ + --set publicBaseUrl=https://skills.example.com \ --set ingress.tls.enabled=true \ --set ingress.certManager.enabled=true ``` diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index 7315f3b4..4331d770 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -70,10 +70,38 @@ app.kubernetes.io/component: scanner app.kubernetes.io/component: scanner {{- end }} +{{- /* Bitnami PostgreSQL subchart 完整名称 */}} +{{- define "skillhub.postgresql.fullname" -}} +{{- if .Values.postgresql.fullnameOverride -}} +{{- .Values.postgresql.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default "postgresql" .Values.postgresql.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{- /* Bitnami Redis subchart 完整名称 */}} +{{- define "skillhub.redis.fullname" -}} +{{- if .Values.redis.fullnameOverride -}} +{{- .Values.redis.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default "redis" .Values.redis.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end }} + {{- /* PostgreSQL Host */}} {{- define "skillhub.postgresql.host" -}} {{- if .Values.postgresql.enabled -}} -{{- $prefix := printf "%s-postgresql" (include "skillhub.fullname" .) -}} +{{- $prefix := include "skillhub.postgresql.fullname" . -}} {{- if eq .Values.postgresql.architecture "replication" -}} {{- printf "%s-primary" $prefix -}} {{- else -}} @@ -114,12 +142,17 @@ app.kubernetes.io/component: scanner {{- /* PostgreSQL Secret Name */}} {{- define "skillhub.postgresql.secretName" -}} {{- if .Values.postgresql.enabled -}} -{{- printf "%s-postgresql" (include "skillhub.fullname" .) -}} +{{- .Values.postgresql.auth.existingSecret | default (include "skillhub.postgresql.fullname" .) -}} {{- else -}} {{- include "skillhub.secretName" . -}} {{- end -}} {{- end }} +{{- /* PostgreSQL 应用用户密码 Secret key */}} +{{- define "skillhub.postgresql.passwordKey" -}} +{{- .Values.postgresql.auth.secretKeys.userPasswordKey | default "password" -}} +{{- end }} + {{- /* PostgreSQL JDBC URL */}} {{- define "skillhub.jdbcUrl" -}} {{- if .Values.postgresql.enabled -}} @@ -135,8 +168,9 @@ app.kubernetes.io/component: scanner {{- /* Redis Sentinel 节点列表(Redisson 需要具体 pod FQDN,格式: {pod}.{headless-svc}.{ns}.svc.cluster.local) */}} {{- define "skillhub.redis.sentinel.nodes" -}} -{{- $prefix := printf "%s-redis-node" (include "skillhub.fullname" .) -}} -{{- $headless := printf "%s-redis-headless" (include "skillhub.fullname" .) -}} +{{- $fullname := include "skillhub.redis.fullname" . -}} +{{- $prefix := printf "%s-node" $fullname -}} +{{- $headless := printf "%s-headless" $fullname -}} {{- $port := include "skillhub.redis.port" . -}} {{- $replicas := .Values.redis.replica.replicaCount | default 3 | int -}} {{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%s" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} @@ -146,9 +180,9 @@ app.kubernetes.io/component: scanner {{- define "skillhub.redis.host" -}} {{- if .Values.redis.enabled -}} {{- if .Values.redis.sentinel.enabled -}} -{{- printf "%s-redis" (include "skillhub.fullname" .) -}} +{{- include "skillhub.redis.fullname" . -}} {{- else -}} -{{- printf "%s-redis-master" (include "skillhub.fullname" .) -}} +{{- printf "%s-master" (include "skillhub.redis.fullname" .) -}} {{- end -}} {{- else -}} {{- .Values.externalRedis.host -}} @@ -171,12 +205,17 @@ app.kubernetes.io/component: scanner {{- /* Redis Password Secret Name */}} {{- define "skillhub.redis.secretName" -}} {{- if .Values.redis.enabled -}} -{{- printf "%s-redis" (include "skillhub.fullname" .) -}} +{{- .Values.redis.auth.existingSecret | default (include "skillhub.redis.fullname" .) -}} {{- else -}} {{- include "skillhub.secretName" . -}} {{- end -}} {{- end }} +{{- /* Redis 密码 Secret key */}} +{{- define "skillhub.redis.passwordKey" -}} +{{- .Values.redis.auth.existingSecretPasswordKey | default "redis-password" -}} +{{- end }} + {{- /* Secret 名称 */}} {{- define "skillhub.secretName" -}} {{- .Values.existingSecret | default (printf "%s-secret" (include "skillhub.fullname" .)) }} diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index aee9b16c..66ca9f32 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -22,7 +22,12 @@ data: # S3 配置 s3-bucket: {{ .Values.s3.bucket }} s3-endpoint: {{ .Values.s3.endpoint }} + s3-public-endpoint: {{ .Values.s3.publicEndpoint }} s3-region: {{ .Values.s3.region }} + s3-force-path-style: {{ .Values.s3.forcePathStyle | quote }} + s3-disable-chunked-encoding: {{ .Values.s3.disableChunkedEncoding | quote }} + s3-auto-create-bucket: {{ .Values.s3.autoCreateBucket | quote }} + s3-presign-expiry: {{ .Values.s3.presignExpiry | quote }} {{- end }} # 技能扫描器 @@ -39,3 +44,10 @@ data: # Session session-cookie-secure: {{ .Values.session.cookieSecure | quote }} + + # Public URL and authentication + public-base-url: {{ .Values.publicBaseUrl | quote }} + device-auth-verification-uri: {{ .Values.deviceAuthVerificationUri | quote }} + auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }} + auth-direct-provider: {{ .Values.auth.direct.provider | quote }} + builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }} diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml index 5e466c39..b511465e 100644 --- a/charts/skillhub/templates/scanner-deployment.yaml +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -17,7 +17,7 @@ spec: labels: {{- include "skillhub.scanner.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "scanner" .Values.scanner) | sha256sum }} + checksum/config: {{ toYaml (dict "scanner" .Values.scanner "secrets" .Values.secrets "existingSecret" .Values.existingSecret) | sha256sum }} {{- range $key, $val := .Values.scanner.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} @@ -41,6 +41,12 @@ spec: name: {{ include "skillhub.secretName" . }} key: skill-scanner-llm-api-key optional: true + - name: SKILL_SCANNER_LLM_BASE_URL + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: skill-scanner-llm-base-url + optional: true - name: SKILL_SCANNER_LLM_MODEL valueFrom: secretKeyRef: diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 648fd9e7..8e28c911 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -5,10 +5,6 @@ SkillHub 应用 Secret */}} {{- if not .Values.existingSecret }} {{- $secretName := include "skillhub.secretName" . }} -{{- $postgresSecretName := include "skillhub.postgresql.secretName" . }} -{{- $redisSecretName := include "skillhub.redis.secretName" . }} -{{- $postgresSecret := (lookup "v1" "Secret" $.Release.Namespace $postgresSecretName) }} -{{- $redisSecret := (lookup "v1" "Secret" $.Release.Namespace $redisSecretName) }} {{- $appSecret := (lookup "v1" "Secret" $.Release.Namespace $secretName) }} apiVersion: v1 kind: Secret @@ -18,41 +14,18 @@ metadata: {{- include "skillhub.labels" . | nindent 4 }} type: Opaque stringData: - # 数据库连接 URL - spring-datasource-url: {{ include "skillhub.jdbcUrl" . | quote }} - spring-datasource-username: {{ include "skillhub.postgresql.username" . | quote }} - - # 数据库密码 - # 优先级: lookup PG Secret → externalDatabase.password → postgresql.auth.password - {{- if and $postgresSecret (index $postgresSecret.data "password") }} - spring-datasource-password: {{ index $postgresSecret.data "password" | b64dec | quote }} - {{- else if not .Values.postgresql.enabled }} + {{- if not .Values.postgresql.enabled }} + # 外部数据库密码;内置 PostgreSQL 直接引用 Bitnami Secret spring-datasource-password: {{ .Values.externalDatabase.password | quote }} - {{- else }} - spring-datasource-password: {{ .Values.secrets.springDatasourcePassword | default .Values.postgresql.auth.password | quote }} {{- end }} - # Redis 密码 - # 优先级: lookup Redis Secret → externalRedis.password → redis.auth.password - {{- if $redisSecret }} - {{- if index $redisSecret.data "redis-password" }} - redis-password: {{ index $redisSecret.data "redis-password" | b64dec | quote }} - {{- end }} - {{- else if not .Values.redis.enabled }} + {{- if not .Values.redis.enabled }} + # 外部 Redis 密码;内置 Redis 直接引用 Bitnami Secret redis-password: {{ .Values.externalRedis.password | default "" | quote }} - {{- else if .Values.redis.auth.password }} - redis-password: {{ .Values.redis.auth.password | quote }} {{- end }} - # Redis Sentinel 密码(仅 sentinel 模式下生效) - # 优先级: lookup Bitnami Secret → sentinelPassword → auth.password → externalRedis.password - {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} - {{- if and $redisSecret (index $redisSecret.data "redis-sentinel-password") }} - redis-sentinel-password: {{ index $redisSecret.data "redis-sentinel-password" | b64dec | quote }} - {{- else }} - redis-sentinel-password: {{ .Values.redis.auth.sentinelPassword | default .Values.redis.auth.password | quote }} - {{- end }} - {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + {{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + # 外部 Sentinel 可使用独立密码 redis-sentinel-password: {{ .Values.externalRedis.sentinel.password | default .Values.externalRedis.password | default "" | quote }} {{- end }} # Bootstrap 管理员密码 @@ -67,6 +40,17 @@ stringData: {{- end }} {{- end }} bootstrap-admin-password: {{ $baPwd | quote }} + + # 匿名下载限流 Cookie 签名密钥 + {{- $downloadSecret := .Values.secrets.downloadAnonCookieSecret | default "" }} + {{- if and (not $downloadSecret) $appSecret }} + {{- $downloadSecret = index $appSecret.data "skillhub-download-anon-cookie-secret" | default "" | b64dec }} + {{- end }} + {{- if not $downloadSecret }} + {{- $downloadSecret = randAlphaNum 48 }} + {{- end }} + skillhub-download-anon-cookie-secret: {{ $downloadSecret | quote }} + # OAuth2 GitHub (optional) {{- if .Values.secrets.oauth2GithubClientId }} oauth2-github-client-id: {{ .Values.secrets.oauth2GithubClientId | quote }} @@ -79,15 +63,18 @@ stringData: {{- if .Values.secrets.scannerLlmApiKey }} skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }} {{- end }} + {{- if .Values.secrets.scannerLlmBaseUrl }} + skill-scanner-llm-base-url: {{ .Values.secrets.scannerLlmBaseUrl | quote }} + {{- end }} {{- if .Values.secrets.scannerLlmModel }} skill-scanner-llm-model: {{ .Values.secrets.scannerLlmModel | quote }} {{- end }} # S3 配置 (optional) {{- if .Values.s3.accessKey }} - s3-access-key: {{ .Values.s3.accessKey | quote }} + skillhub-storage-s3-access-key: {{ .Values.s3.accessKey | quote }} {{- end }} {{- if .Values.s3.secretKey }} - s3-secret-key: {{ .Values.s3.secretKey | quote }} + skillhub-storage-s3-secret-key: {{ .Values.s3.secretKey | quote }} {{- end }} {{- end }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 2363efe2..26345f3d 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -69,20 +69,19 @@ spec: # Database - name: SPRING_DATASOURCE_URL - valueFrom: - secretKeyRef: - name: {{ include "skillhub.secretName" . }} - key: spring-datasource-url + value: {{ include "skillhub.jdbcUrl" . | quote }} - name: SPRING_DATASOURCE_USERNAME - valueFrom: - secretKeyRef: - name: {{ include "skillhub.secretName" . }} - key: spring-datasource-username + value: {{ include "skillhub.postgresql.username" . | quote }} - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: + {{- if .Values.postgresql.enabled }} + name: {{ include "skillhub.postgresql.secretName" . }} + key: {{ include "skillhub.postgresql.passwordKey" . }} + {{- else }} name: {{ include "skillhub.secretName" . }} key: spring-datasource-password + {{- end }} # Redis {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} @@ -112,19 +111,24 @@ spec: - name: SPRING_DATA_REDIS_SENTINEL_PASSWORD valueFrom: secretKeyRef: - name: {{ include "skillhub.secretName" . }} - {{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} - key: redis-sentinel-password + {{- if .Values.redis.enabled }} + name: {{ include "skillhub.redis.secretName" . }} + key: {{ include "skillhub.redis.passwordKey" . }} {{- else }} - key: redis-password + name: {{ include "skillhub.secretName" . }} + key: redis-sentinel-password {{- end }} optional: true {{- else if or .Values.redis.enabled .Values.externalRedis.password }} - name: SPRING_DATA_REDIS_PASSWORD valueFrom: secretKeyRef: + {{- if .Values.redis.enabled }} + name: {{ include "skillhub.redis.secretName" . }} + {{- else }} name: {{ include "skillhub.secretName" . }} - key: redis-password + {{- end }} + key: {{ if .Values.redis.enabled }}{{ include "skillhub.redis.passwordKey" . }}{{ else }}redis-password{{ end }} optional: true {{- end }} @@ -141,32 +145,57 @@ spec: key: skillhub-storage-provider {{- if .Values.s3.enabled }} - - name: SKILLHUB_S3_BUCKET + - name: SKILLHUB_STORAGE_S3_BUCKET valueFrom: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: s3-bucket - - name: SKILLHUB_S3_ENDPOINT + - name: SKILLHUB_STORAGE_S3_ENDPOINT valueFrom: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: s3-endpoint - - name: SKILLHUB_S3_REGION + - name: SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-public-endpoint + - name: SKILLHUB_STORAGE_S3_REGION valueFrom: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: s3-region - - name: SKILLHUB_S3_ACCESS_KEY + - name: SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-force-path-style + - name: SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-disable-chunked-encoding + - name: SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-auto-create-bucket + - name: SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-presign-expiry + - name: SKILLHUB_STORAGE_S3_ACCESS_KEY valueFrom: secretKeyRef: name: {{ include "skillhub.secretName" . }} - key: s3-access-key + key: skillhub-storage-s3-access-key optional: true - - name: SKILLHUB_S3_SECRET_KEY + - name: SKILLHUB_STORAGE_S3_SECRET_KEY valueFrom: secretKeyRef: name: {{ include "skillhub.secretName" . }} - key: s3-secret-key + key: skillhub-storage-s3-secret-key optional: true {{- end }} @@ -194,6 +223,33 @@ spec: name: {{ include "skillhub.fullname" . }}-config key: session-cookie-secure + # Public URL and authentication + - name: SKILLHUB_PUBLIC_BASE_URL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: public-base-url + - name: DEVICE_AUTH_VERIFICATION_URI + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: device-auth-verification-uri + - name: SKILLHUB_AUTH_DIRECT_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-direct-enabled + - name: SKILLHUB_BUILTIN_SKILLS_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: builtin-skills-enabled + - name: SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: skillhub-download-anon-cookie-secret + # Bootstrap Admin - name: BOOTSTRAP_ADMIN_ENABLED valueFrom: diff --git a/charts/skillhub/templates/web-deployment.yaml b/charts/skillhub/templates/web-deployment.yaml index 7392a398..bdbb3e75 100644 --- a/charts/skillhub/templates/web-deployment.yaml +++ b/charts/skillhub/templates/web-deployment.yaml @@ -16,7 +16,7 @@ spec: labels: {{- include "skillhub.web.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "web" .Values.web) | sha256sum }} + checksum/config: {{ toYaml (dict "web" .Values.web "publicBaseUrl" .Values.publicBaseUrl "auth" .Values.auth) | sha256sum }} {{- range $key, $val := .Values.web.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} @@ -33,6 +33,21 @@ spec: env: - name: SKILLHUB_API_UPSTREAM value: http://{{ include "skillhub.fullname" . }}-server:{{ .Values.server.service.port }} + - name: SKILLHUB_PUBLIC_BASE_URL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: public-base-url + - name: SKILLHUB_WEB_AUTH_DIRECT_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-direct-enabled + - name: SKILLHUB_WEB_AUTH_DIRECT_PROVIDER + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-direct-provider {{- with .Values.web.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 3fb16248..42f8ea3b 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -13,6 +13,18 @@ images: nameOverride: "" fullnameOverride: "" +# 浏览器、CLI 和 OAuth 回调访问的公开地址(不带末尾斜杠) +publicBaseUrl: "" +deviceAuthVerificationUri: "" + +auth: + direct: + enabled: true + provider: local + +builtinSkills: + enabled: true + # ============================================================================ # Ingress 配置 # ============================================================================ @@ -37,7 +49,12 @@ s3: enabled: false bucket: skillhub-storage endpoint: "" + publicEndpoint: "" region: us-east-1 + forcePathStyle: true + disableChunkedEncoding: false + autoCreateBucket: false + presignExpiry: PT10M accessKey: "" secretKey: "" @@ -69,11 +86,12 @@ springProfilesActive: docker existingSecret: "" secrets: - springDatasourcePassword: "" bootstrapAdminPassword: "" + downloadAnonCookieSecret: "" oauth2GithubClientId: "" oauth2GithubClientSecret: "" scannerLlmApiKey: "" + scannerLlmBaseUrl: "" scannerLlmModel: "" # ============================================================================ From 5d379dcaafea189b6761f3b2e62c90afbd802b42 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Wed, 15 Jul 2026 09:30:08 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=90=AF=E5=81=9C=E4=B8=8E=E4=BE=9D=E8=B5=96=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=B9=B6=E5=A2=9E=E5=8A=A0=20values=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在配置进入 Kubernetes 前完成父 Chart 校验,并补齐剩余的依赖配置契约。 - 让 Service、HPA 和 PDB 正确遵循组件启用状态 - 校验 Ingress、自动扩缩容、外部依赖和共享存储的组合配置 - 增加 values.schema.json 和 Helm 配置契约测试并接入 CI - 统一 PostgreSQL Primary 与 Read Replica 的 max_connections 配置 - 修正 Redis Sentinel 节点、依赖等待和独立密码配置 - 允许覆盖依赖等待容器镜像,支持完整私有镜像仓库部署 - 兼容现代与旧式 IngressClass,并支持多域名 TLS 和证书 SAN - 增加 GitOps 稳定 Secret 模式,阻止离线渲染产生随机凭据漂移 - 引用用户可控的 ConfigMap 字符串,并推导 TLS 安全 Cookie 和设备认证默认值 - 补充 Sentinel、RWX 存储、TLS Cookie、PVC 保留、私有镜像和 GitOps 文档 - 增加 Redis 数据密码与 Sentinel 密码分离的应用配置测试 已通过 Helm 严格 lint、渲染场景、配置契约测试、kubeconform、后端测试套件和 Sentinel 专项配置测试。 Signed-off-by: lhb6540 --- .github/workflows/pr-helm-chart.yml | 10 +- .github/workflows/publish-chart.yml | 2 +- charts/skillhub/.helmignore | 3 + charts/skillhub/Chart.yaml | 1 + charts/skillhub/README.md | 244 +++++++++- charts/skillhub/templates/_helpers.tpl | 15 +- charts/skillhub/templates/certificate.yaml | 20 +- charts/skillhub/templates/configmap.yaml | 34 +- charts/skillhub/templates/hpa.yaml | 6 +- charts/skillhub/templates/ingress.yaml | 34 +- charts/skillhub/templates/pdb.yaml | 6 +- charts/skillhub/templates/pvc.yaml | 6 +- .../skillhub/templates/server-deployment.yaml | 24 +- charts/skillhub/templates/services.yaml | 6 +- charts/skillhub/templates/validate.yaml | 88 ++++ .../skillhub/tests/configuration-contracts.sh | 174 +++++++ charts/skillhub/tests/test-values.yaml | 14 + charts/skillhub/values.schema.json | 453 ++++++++++++++++++ charts/skillhub/values.yaml | 39 +- .../resources/application-redis-sentinel.yml | 2 +- ...RedisSentinelProfileConfigurationTest.java | 56 +++ 21 files changed, 1156 insertions(+), 81 deletions(-) create mode 100644 charts/skillhub/templates/validate.yaml create mode 100755 charts/skillhub/tests/configuration-contracts.sh create mode 100644 charts/skillhub/tests/test-values.yaml create mode 100644 charts/skillhub/values.schema.json create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 37787c14..bc5d8e03 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -44,7 +44,10 @@ jobs: run: helm dependency build . - name: Lint chart - run: helm lint . + run: helm lint --strict . -f tests/test-values.yaml + + - name: Validate configuration contracts + run: bash tests/configuration-contracts.sh - name: Validate chart metadata run: | @@ -118,6 +121,7 @@ jobs: --set server.autoscaling.enabled=true --set web.autoscaling.enabled=true --set scanner.autoscaling.enabled=true + --set server.storage.accessMode=ReadWriteMany --set server.podDisruptionBudget.enabled=true --set web.podDisruptionBudget.enabled=true --set scanner.podDisruptionBudget.enabled=true @@ -155,8 +159,8 @@ jobs: if: ${{ matrix.scenario.name == 'bitnami-default' }} run: | helm template test-release . --show-only templates/server-deployment.yaml > server.yaml - grep -Fq 'value: test-release-postgresql' server.yaml - grep -Fq 'value: test-release-redis-master' server.yaml + grep -Fq 'value: "test-release-postgresql"' server.yaml + grep -Fq 'value: "test-release-redis-master"' server.yaml grep -Fq 'name: test-release-postgresql' server.yaml grep -Fq 'name: test-release-redis' server.yaml grep -Fq 'key: password' server.yaml diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml index 13fdf039..823524d5 100644 --- a/.github/workflows/publish-chart.yml +++ b/.github/workflows/publish-chart.yml @@ -67,7 +67,7 @@ jobs: echo "version=$VER" >> "$GITHUB_OUTPUT" - name: Lint chart - run: helm lint . + run: helm lint . -f tests/test-values.yaml - name: Package and push run: | diff --git a/charts/skillhub/.helmignore b/charts/skillhub/.helmignore index 55d10a21..47499d20 100644 --- a/charts/skillhub/.helmignore +++ b/charts/skillhub/.helmignore @@ -19,3 +19,6 @@ CLAUDE.md # CI .github/ + +# Source-only contract tests +tests/ diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml index 60ba84c2..3f125dd3 100644 --- a/charts/skillhub/Chart.yaml +++ b/charts/skillhub/Chart.yaml @@ -9,6 +9,7 @@ keywords: - ai - skills home: https://github.com/iflytek/skillhub +icon: https://raw.githubusercontent.com/iflytek/skillhub/main/skillhub-logo.svg sources: - https://github.com/iflytek/skillhub diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 47f8339b..802c233b 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -7,7 +7,7 @@ - **微服务架构**:Server(Spring Boot)、Web(Nginx)、Scanner 分离部署 - **高可用**:支持 HPA 自动扩缩容、PDB Pod 中断预算 - **数据层**:使用 Bitnami PostgreSQL/Redis,支持主从复制、哨兵模式 -- **安全**:TLS 证书管理、Secret 密码保护、NetworkPolicy +- **安全**:TLS 证书管理、Secret 密码保护;Bitnami 数据组件默认提供 NetworkPolicy - **可观测性**:内置 Prometheus metrics exporter ## 快速开始 @@ -20,20 +20,47 @@ ### 安装 +先创建受保护的 `values-production.yaml`。以下值必须替换为实际随机强密码: + +```yaml +secrets: + allowAutoGenerated: false + bootstrapAdminPassword: "<固定管理员密码>" + downloadAnonCookieSecret: "<至少32字符的固定随机值>" + +postgresql: + auth: + postgresPassword: "<固定PostgreSQL管理员密码>" + password: "<固定skillhub用户密码>" + +redis: + auth: + password: "<固定Redis密码>" +``` + ```bash kubectl create namespace skillhub helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set publicBaseUrl=https://skills.example.com ``` +未显式设置 `deviceAuthVerificationUri` 时,Chart 使用 +`/cli/auth`。所有 values 会先经过 `values.schema.json` 和跨字段校验, +无效的组件、Ingress、HPA 与存储组合会在安装前失败。 + +> **Ingress values 迁移:** 当前版本只支持结构化的 `ingress.hosts[]` 和 +> `ingress.tls[]`。旧的 `ingress.host`、`ingress.tls.enabled` 与 +> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。 + ### 高可用模式 ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set postgresql.architecture=replication \ + --set postgresql.auth.replicationPassword=your-replication-password \ --set redis.architecture=replication ``` @@ -41,7 +68,7 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set postgresql.enabled=false \ --set redis.enabled=false \ --set externalDatabase.host=postgres.example.com \ @@ -76,9 +103,37 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set existingSecret=my-custom-secret ``` +### GitOps 稳定 Secret + +Argo CD 等 GitOps 工具使用离线 `helm template`,无法通过 Helm `lookup` 读取集群 +中已有的 Secret。Bitnami 子 Chart 和父 Chart 的空密码会在每次渲染时重新随机 +生成。Chart 默认禁止自动生成并要求提供固定值: + +```yaml +secrets: + allowAutoGenerated: false + bootstrapAdminPassword: "<固定管理员密码>" + downloadAnonCookieSecret: "<至少32字符的固定随机值>" + +postgresql: + auth: + postgresPassword: "<固定PostgreSQL管理员密码>" + password: "<固定skillhub用户密码>" + # replication 架构还必须配置 replicationPassword + +redis: + auth: + password: "<固定Redis密码>" +``` + +也可以为三个组件分别配置 `existingSecret`。`allowAutoGenerated=false` 不会生成 +可预测密码,而是在任何随机密码缺失时终止渲染并指出具体配置项。敏感值应放在 +受保护的 values、External Secrets、Sealed Secrets 或密钥注入插件中。 + ## 配置参考 ### 副本数配置 @@ -92,11 +147,16 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash # 差异化副本配置 helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set server.replicaCount=3 \ + --set server.storage.accessMode=ReadWriteMany \ --set web.replicaCount=2 \ --set scanner.replicaCount=1 ``` +本地存储运行多个 Server 副本时,必须显式设置 `ReadWriteMany`,并使用支持 RWX +的 StorageClass。无法提供 RWX 时应改用 S3。 + ### 服务配置 | 参数 | 描述 | 默认值 | @@ -107,6 +167,80 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `web.service.port` | Web 端口 | `80` | | `scanner.service.port` | Scanner 端口 | `8000` | +### 私有镜像仓库 + +使用私有仓库时,需要分别覆盖 SkillHub 镜像、依赖等待镜像和 Bitnami 子 Chart +镜像。以下示例中的数据库镜像标签均为明确版本,不使用 `latest`: + +```yaml +global: + imagePullSecrets: + - private-registry + security: + allowInsecureImages: true + +images: + registry: registry.example.com/library + tag: v0.2.13 + pullPolicy: IfNotPresent + +server: + dependencyWait: + image: + registry: registry.example.com + repository: library/busybox + tag: "1.37" + pullPolicy: IfNotPresent + imagePullSecrets: + - name: private-registry + +web: + imagePullSecrets: + - name: private-registry + +scanner: + imagePullSecrets: + - name: private-registry + +postgresql: + image: + registry: registry.example.com + repository: library/postgresql + tag: 18.4.0 + metrics: + image: + registry: registry.example.com + repository: library/postgres-exporter + tag: 0.20.1 + +redis: + image: + registry: registry.example.com + repository: library/redis + tag: 8.8.0 + sentinel: + image: + registry: registry.example.com + repository: library/redis-sentinel + tag: 8.8.0 + metrics: + image: + registry: registry.example.com + repository: library/redis-exporter + tag: 1.86.0 +``` + +`global.security.allowInsecureImages` 是 Bitnami 对自定义镜像仓库和镜像名称的校验 +开关,并不表示使用不安全的 HTTP 仓库。先在目标 namespace 创建拉取凭据: + +```bash +kubectl create secret docker-registry private-registry \ + -n skillhub \ + --docker-server=registry.example.com \ + --docker-username='<用户名>' \ + --docker-password='<密码>' +``` + ### 数据库配置 | 参数 | 描述 | 默认值 | @@ -116,18 +250,62 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `redis.enabled` | 启用内置 Redis | `true` | | `redis.architecture` | 架构模式 | `standalone` | +#### 数据库架构支持边界 + +以下内置数据库目标架构已完成独立 namespace 的全新安装和运行时验证: + +| 数据组件 | 已验证架构 | 运行时验证 | +|----------|------------|------------| +| PostgreSQL | standalone | Server 连接、Flyway 和应用启动 | +| PostgreSQL | replication | 1 Primary + 2 Read Replicas,两个副本均处于 recovery,流复制状态为 `streaming` | +| Redis | standalone | Server 读写和应用启动 | +| Redis | replication | 1 Master + 2 Replicas,角色和数据复制正常 | +| Redis | replication + Sentinel | 3 个 Sentinel 节点 master 视图一致,Server 可通过 Sentinel 读写 | + +上述支持表示 Chart 能够全新部署目标架构,并为 SkillHub 配置正确的写节点或 +Sentinel 地址。Chart **不负责数据库架构切换时的数据迁移**,也不承诺仅修改 +`architecture` 或 `sentinel.enabled` 就能保留已有数据。已有数据的 PostgreSQL +standalone → replication、Redis standalone/replication → Sentinel 等切换,必须由 +运维人员在 Chart 之外完成备份、恢复、PVC 复用或其他迁移方案。 + +### Redis Sentinel + +内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和 +Sentinel。节点地址由副本数自动生成,不需要手动配置: + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set redis.architecture=replication \ + --set redis.sentinel.enabled=true +``` + +外部 Sentinel 必须提供至少一个 `host:port` 节点。Redis 数据密码和 Sentinel +密码可以不同;使用 `existingSecret` 时分别对应 `redis-password` 和 +`redis-sentinel-password`: + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set redis.enabled=false \ + --set externalRedis.password=redis-password \ + --set externalRedis.sentinel.enabled=true \ + --set externalRedis.sentinel.password=sentinel-password \ + --set-json 'externalRedis.sentinel.nodes=["sentinel-0.example.com:26379","sentinel-1.example.com:26379"]' +``` + ### 存储配置 | 参数 | 描述 | 默认值 | |------|------|--------| -| `server.storage.accessMode` | 访问模式:ReadWriteOnce(单副本)或 ReadWriteMany(多副本) | `""` | +| `server.storage.accessMode` | 留空时单副本使用 ReadWriteOnce;多副本必须显式使用 ReadWriteMany | `""` | | `server.storage.size` | PVC 大小 | `10Gi` | | `server.storage.storageClassName` | StorageClass | `""` | ```bash # 默认使用本地 PVC helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password + -f values-production.yaml ``` ### S3 对象存储 @@ -149,7 +327,7 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set s3.enabled=true \ --set s3.bucket=your-bucket \ --set s3.endpoint=s3.amazonaws.com \ @@ -162,28 +340,74 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set ingress.enabled=true \ - --set ingress.host=skills.example.com \ + --set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/","pathType":"Prefix"}]}]' \ + --set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \ --set publicBaseUrl=https://skills.example.com \ - --set ingress.tls.enabled=true \ --set ingress.certManager.enabled=true ``` +配置非空 `ingress.tls` 或启用 `ingress.certManager` 时,Chart 会自动将 Session Cookie +标记为 Secure。Ingress 要求 Server 和 Web Service 均保持启用。 + +`ingress.className` 和旧式 `kubernetes.io/ingress.class` annotation 均受支持, +可以任选其一,也可以同时输出。仅使用旧式 annotation 时将 `className` 留空: + +```yaml +ingress: + enabled: true + className: "" + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":6443}]' +``` + +`hosts` 是至少包含一个条目的对象数组。Chart 自动将 `/api` 转发给 Server, +`hosts[].paths` 中的路径转发给 Web,因此 `/api` 是保留路径。`tls` 同样是数组, +可为不同证书分别配置域名;TLS 域名会写入 cert-manager Certificate SAN: + +```yaml +ingress: + hosts: + - host: skills.example.com + paths: + - path: / + pathType: Prefix + - host: skills.internal.example.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: + - skills.example.com + - skills.internal.example.com + secretName: skills-tls +``` + ### 自动扩缩容 ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set server.autoscaling.enabled=true \ --set server.autoscaling.minReplicas=2 \ - --set server.autoscaling.maxReplicas=10 + --set server.autoscaling.maxReplicas=10 \ + --set server.storage.accessMode=ReadWriteMany ``` +每个 HPA 至少需要一个非零 CPU 或内存利用率目标。本地存储的 Server HPA 同样 +要求 RWX;也可以启用 S3 来避免共享 PVC。 + ## 卸载 ```bash helm -n skillhub uninstall skillhub ``` +Server 数据 PVC 带有 `helm.sh/resource-policy: keep`,卸载 release 后仍会保留, +需要确认数据不再使用后手动删除。 + ## 依赖 | 依赖 | 版本 | diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index 4331d770..d287cfeb 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -171,9 +171,10 @@ app.kubernetes.io/component: scanner {{- $fullname := include "skillhub.redis.fullname" . -}} {{- $prefix := printf "%s-node" $fullname -}} {{- $headless := printf "%s-headless" $fullname -}} -{{- $port := include "skillhub.redis.port" . -}} +{{- /* Headless Service DNS resolves directly to pod IPs, so use the container port. */ -}} +{{- $port := .Values.redis.sentinel.containerPorts.sentinel | default 26379 -}} {{- $replicas := .Values.redis.replica.replicaCount | default 3 | int -}} -{{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%s" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} +{{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%v" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} {{- end }} {{- /* Redis Host */}} @@ -198,8 +199,13 @@ app.kubernetes.io/component: scanner {{- print "6379" -}} {{- end -}} {{- else -}} +{{- if .Values.externalRedis.sentinel.enabled -}} +{{- $node := first .Values.externalRedis.sentinel.nodes -}} +{{- last (splitList ":" $node) -}} +{{- else -}} {{- .Values.externalRedis.port | default 6379 | int -}} {{- end -}} +{{- end -}} {{- end }} {{- /* Redis Password Secret Name */}} @@ -235,6 +241,11 @@ app.kubernetes.io/component: scanner {{- if .Values.redis.enabled -}} {{- include "skillhub.redis.host" . -}} {{- else -}} +{{- if .Values.externalRedis.sentinel.enabled -}} +{{- $node := first .Values.externalRedis.sentinel.nodes -}} +{{- first (splitList ":" $node) -}} +{{- else -}} {{- .Values.externalRedis.host -}} {{- end -}} +{{- end -}} {{- end }} diff --git a/charts/skillhub/templates/certificate.yaml b/charts/skillhub/templates/certificate.yaml index bd886df4..e5675edc 100644 --- a/charts/skillhub/templates/certificate.yaml +++ b/charts/skillhub/templates/certificate.yaml @@ -1,18 +1,24 @@ -{{- $secretName := .Values.ingress.tls.secretName | default (printf "%s-tls" (include "skillhub.fullname" .)) }} {{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled }} +{{- range $index, $tls := .Values.ingress.tls }} +{{- if $index }} +--- +{{- end }} apiVersion: cert-manager.io/v1 kind: Certificate metadata: - name: {{ $secretName }}-cert + name: {{ $tls.secretName }}-cert labels: - {{- include "skillhub.labels" . | nindent 4 }} + {{- include "skillhub.labels" $ | nindent 4 }} spec: - secretName: {{ $secretName }} + secretName: {{ $tls.secretName }} duration: 2160h renewBefore: 360h dnsNames: - - {{ .Values.ingress.host }} + {{- range $tls.hosts }} + - {{ . | quote }} + {{- end }} issuerRef: - name: {{ .Values.ingress.certManager.issuerName }} - kind: {{ .Values.ingress.certManager.issuerKind }} + name: {{ $.Values.ingress.certManager.issuerName | quote }} + kind: {{ $.Values.ingress.certManager.issuerKind | quote }} +{{- end }} {{- end }} diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index 66ca9f32..78e9e016 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -9,21 +9,21 @@ metadata: {{- include "skillhub.labels" . | nindent 4 }} data: # Redis 配置 - redis-host: {{ include "skillhub.redis.host" . }} + redis-host: {{ include "skillhub.redis.host" . | quote }} redis-port: {{ include "skillhub.redis.port" . | quote }} # 存储路径 - storage-base-path: /var/lib/skillhub/storage + storage-base-path: "/var/lib/skillhub/storage" # 存储提供者: local | s3 - skillhub-storage-provider: {{ if .Values.s3.enabled }}s3{{ else }}local{{ end }} + skillhub-storage-provider: {{ if .Values.s3.enabled }}"s3"{{ else }}"local"{{ end }} {{- if .Values.s3.enabled }} # S3 配置 - s3-bucket: {{ .Values.s3.bucket }} - s3-endpoint: {{ .Values.s3.endpoint }} - s3-public-endpoint: {{ .Values.s3.publicEndpoint }} - s3-region: {{ .Values.s3.region }} + s3-bucket: {{ .Values.s3.bucket | quote }} + s3-endpoint: {{ .Values.s3.endpoint | quote }} + s3-public-endpoint: {{ .Values.s3.publicEndpoint | quote }} + s3-region: {{ .Values.s3.region | quote }} s3-force-path-style: {{ .Values.s3.forcePathStyle | quote }} s3-disable-chunked-encoding: {{ .Values.s3.disableChunkedEncoding | quote }} s3-auto-create-bucket: {{ .Values.s3.autoCreateBucket | quote }} @@ -32,22 +32,26 @@ data: # 技能扫描器 skill-scanner-enabled: {{ .Values.scanner.enabled | quote }} - skill-scanner-url: http://{{ include "skillhub.fullname" . }}-scanner:{{ .Values.scanner.service.port }} - skill-scanner-mode: upload + skill-scanner-url: {{ printf "http://%s-scanner:%v" (include "skillhub.fullname" .) .Values.scanner.service.port | quote }} + skill-scanner-mode: "upload" # Bootstrap 管理员 bootstrap-admin-enabled: {{ .Values.bootstrapAdmin.enabled | quote }} - bootstrap-admin-user-id: {{ .Values.bootstrapAdmin.userId }} - bootstrap-admin-username: {{ .Values.bootstrapAdmin.username }} - bootstrap-admin-display-name: {{ .Values.bootstrapAdmin.displayName }} - bootstrap-admin-email: {{ .Values.bootstrapAdmin.email }} + bootstrap-admin-user-id: {{ .Values.bootstrapAdmin.userId | quote }} + bootstrap-admin-username: {{ .Values.bootstrapAdmin.username | quote }} + bootstrap-admin-display-name: {{ .Values.bootstrapAdmin.displayName | quote }} + bootstrap-admin-email: {{ .Values.bootstrapAdmin.email | quote }} # Session - session-cookie-secure: {{ .Values.session.cookieSecure | quote }} + session-cookie-secure: {{ or .Values.session.cookieSecure (not (empty .Values.ingress.tls)) .Values.ingress.certManager.enabled | quote }} # Public URL and authentication public-base-url: {{ .Values.publicBaseUrl | quote }} - device-auth-verification-uri: {{ .Values.deviceAuthVerificationUri | quote }} + {{- $deviceAuthVerificationUri := .Values.deviceAuthVerificationUri }} + {{- if and (not $deviceAuthVerificationUri) .Values.publicBaseUrl }} + {{- $deviceAuthVerificationUri = printf "%s/cli/auth" (trimSuffix "/" .Values.publicBaseUrl) }} + {{- end }} + device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }} auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }} auth-direct-provider: {{ .Values.auth.direct.provider | quote }} builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }} diff --git a/charts/skillhub/templates/hpa.yaml b/charts/skillhub/templates/hpa.yaml index 15fca857..fd2d0600 100644 --- a/charts/skillhub/templates/hpa.yaml +++ b/charts/skillhub/templates/hpa.yaml @@ -1,6 +1,10 @@ {{- range $name := list "server" "web" "scanner" }} {{- $component := index $.Values $name }} -{{- if and (default true $component.enabled) $component.autoscaling.enabled }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.autoscaling.enabled }} --- apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler diff --git a/charts/skillhub/templates/ingress.yaml b/charts/skillhub/templates/ingress.yaml index 8c95928c..60512116 100644 --- a/charts/skillhub/templates/ingress.yaml +++ b/charts/skillhub/templates/ingress.yaml @@ -1,39 +1,43 @@ {{- if .Values.ingress.enabled }} -{{- $secretName := .Values.ingress.tls.secretName | default (printf "%s-tls" (include "skillhub.fullname" .)) }} +{{- $hosts := .Values.ingress.hosts }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "skillhub.fullname" . }} labels: {{- include "skillhub.labels" . | nindent 4 }} + {{- if .Values.ingress.annotations }} annotations: - {{- if .Values.ingress.annotations }} {{- toYaml .Values.ingress.annotations | nindent 4 }} - {{- end }} + {{- end }} spec: - ingressClassName: {{ .Values.ingress.className }} - {{- if or .Values.ingress.tls.enabled .Values.ingress.certManager.enabled }} + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- if .Values.ingress.tls }} tls: - - hosts: - - {{ .Values.ingress.host }} - secretName: {{ $secretName }} + {{- toYaml .Values.ingress.tls | nindent 4 }} {{- end }} rules: - - host: {{ .Values.ingress.host }} + {{- range $host := $hosts }} + - host: {{ $host.host | quote }} http: paths: - path: /api pathType: Prefix backend: service: - name: {{ include "skillhub.fullname" . }}-server + name: {{ include "skillhub.fullname" $ }}-server port: - number: {{ .Values.server.service.port }} - - path: / - pathType: Prefix + number: {{ $.Values.server.service.port }} + {{- range $path := $host.paths }} + - path: {{ $path.path | quote }} + pathType: {{ $path.pathType }} backend: service: - name: {{ include "skillhub.fullname" . }}-web + name: {{ include "skillhub.fullname" $ }}-web port: - number: {{ .Values.web.service.port }} + number: {{ $.Values.web.service.port }} + {{- end }} + {{- end }} {{- end }} diff --git a/charts/skillhub/templates/pdb.yaml b/charts/skillhub/templates/pdb.yaml index f5138066..b8869158 100644 --- a/charts/skillhub/templates/pdb.yaml +++ b/charts/skillhub/templates/pdb.yaml @@ -1,6 +1,10 @@ {{- range $name := list "server" "web" "scanner" }} {{- $component := index $.Values $name }} -{{- if and (default true $component.enabled) $component.podDisruptionBudget.enabled }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.podDisruptionBudget.enabled }} --- apiVersion: policy/v1 kind: PodDisruptionBudget diff --git a/charts/skillhub/templates/pvc.yaml b/charts/skillhub/templates/pvc.yaml index 3881cbbe..cdc48ad3 100644 --- a/charts/skillhub/templates/pvc.yaml +++ b/charts/skillhub/templates/pvc.yaml @@ -10,16 +10,12 @@ metadata: spec: {{- $accessMode := .Values.server.storage.accessMode }} {{- if not $accessMode }} - {{- if or (gt (.Values.server.replicaCount | int) 1) (and .Values.server.autoscaling.enabled (gt (.Values.server.autoscaling.maxReplicas | int) 1)) }} - {{- $accessMode = "ReadWriteMany" }} - {{- else }} {{- $accessMode = "ReadWriteOnce" }} {{- end }} - {{- end }} accessModes: - {{ $accessMode }} {{- if .Values.server.storage.storageClassName }} - storageClassName: {{ .Values.server.storage.storageClassName }} + storageClassName: {{ .Values.server.storage.storageClassName | quote }} {{- end }} resources: requests: diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 26345f3d..a39d9d85 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -30,14 +30,15 @@ spec: {{- end }} initContainers: - name: wait-for-dependencies - image: busybox:1.37 + image: {{ printf "%s/%s:%s" .Values.server.dependencyWait.image.registry .Values.server.dependencyWait.image.repository .Values.server.dependencyWait.image.tag | quote }} + imagePullPolicy: {{ .Values.server.dependencyWait.image.pullPolicy }} env: - name: DB_HOST - value: {{ include "skillhub.postgresql.serviceName" . }} + value: {{ include "skillhub.postgresql.serviceName" . | quote }} - name: DB_PORT value: {{ include "skillhub.postgresql.port" . | quote }} - name: REDIS_HOST - value: {{ include "skillhub.redis.serviceName" . }} + value: {{ include "skillhub.redis.serviceName" . | quote }} - name: REDIS_PORT value: {{ include "skillhub.redis.port" . | quote }} command: @@ -65,7 +66,7 @@ spec: {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} {{- $profiles = printf "%s,redis-sentinel" $profiles }} {{- end }} - value: {{ $profiles }} + value: {{ $profiles | quote }} # Database - name: SPRING_DATASOURCE_URL @@ -88,12 +89,12 @@ spec: - name: SPRING_DATA_REDIS_SENTINEL_MASTER value: {{ .Values.redis.sentinel.masterSet | default "mymaster" | quote }} - name: SPRING_DATA_REDIS_SENTINEL_NODES - value: {{ include "skillhub.redis.sentinel.nodes" . }} + value: {{ include "skillhub.redis.sentinel.nodes" . | quote }} {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} - name: SPRING_DATA_REDIS_SENTINEL_MASTER value: {{ .Values.externalRedis.sentinel.masterSet | default "mymaster" | quote }} - name: SPRING_DATA_REDIS_SENTINEL_NODES - value: {{ join "," .Values.externalRedis.sentinel.nodes }} + value: {{ join "," .Values.externalRedis.sentinel.nodes | quote }} {{- else }} - name: SPRING_DATA_REDIS_HOST valueFrom: @@ -108,6 +109,17 @@ spec: {{- 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: + secretKeyRef: + {{- if .Values.redis.enabled }} + name: {{ include "skillhub.redis.secretName" . }} + key: {{ include "skillhub.redis.passwordKey" . }} + {{- else }} + name: {{ include "skillhub.secretName" . }} + key: redis-password + {{- end }} + optional: true - name: SPRING_DATA_REDIS_SENTINEL_PASSWORD valueFrom: secretKeyRef: diff --git a/charts/skillhub/templates/services.yaml b/charts/skillhub/templates/services.yaml index f3b2be89..94c73e34 100644 --- a/charts/skillhub/templates/services.yaml +++ b/charts/skillhub/templates/services.yaml @@ -6,7 +6,11 @@ SkillHub Service 资源 {{- range $name := list "server" "web" }} {{- $component := index $.Values $name }} -{{- if $component.service.enabled }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.service.enabled }} --- apiVersion: v1 kind: Service diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml new file mode 100644 index 00000000..e5a34aa2 --- /dev/null +++ b/charts/skillhub/templates/validate.yaml @@ -0,0 +1,88 @@ +{{- /* Cross-field validation that JSON Schema cannot express reliably. */ -}} +{{- if not .Values.server.enabled -}} +{{- fail "server.enabled=false is unsupported because the bundled web component requires the SkillHub server" -}} +{{- end -}} +{{- if and .Values.auth.direct.enabled (not .Values.auth.direct.provider) -}} +{{- fail "auth.direct.enabled=true requires auth.direct.provider" -}} +{{- end -}} + +{{- if and .Values.ingress.enabled (not .Values.server.service.enabled) -}} +{{- fail "ingress.enabled=true requires server.service.enabled=true" -}} +{{- end -}} +{{- if and .Values.ingress.enabled (not .Values.web.service.enabled) -}} +{{- fail "ingress.enabled=true requires web.service.enabled=true" -}} +{{- end -}} +{{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled (not .Values.ingress.tls) -}} +{{- fail "ingress.certManager.enabled=true requires at least one ingress.tls entry" -}} +{{- end -}} +{{- range $host := .Values.ingress.hosts -}} +{{- range $path := $host.paths -}} +{{- if regexMatch "^/api(?:/|$)" $path.path -}} +{{- fail "ingress.hosts[].paths reserves /api for the SkillHub server" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- range $name := list "server" "web" "scanner" -}} +{{- $component := index $.Values $name -}} +{{- $enabled := true -}} +{{- if hasKey $component "enabled" -}} +{{- $enabled = $component.enabled -}} +{{- end -}} +{{- if and $enabled $component.autoscaling.enabled -}} +{{- if gt ($component.autoscaling.minReplicas | int) ($component.autoscaling.maxReplicas | int) -}} +{{- fail (printf "%s.autoscaling.minReplicas must not exceed maxReplicas" $name) -}} +{{- end -}} +{{- if and (not $component.autoscaling.targetCPUUtilizationPercentage) (not $component.autoscaling.targetMemoryUtilizationPercentage) -}} +{{- fail (printf "%s.autoscaling requires at least one CPU or memory utilization target" $name) -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- $localStorageReplicas := .Values.server.replicaCount | int -}} +{{- if .Values.server.autoscaling.enabled -}} +{{- $localStorageReplicas = .Values.server.autoscaling.maxReplicas | int -}} +{{- end -}} +{{- if and (not .Values.s3.enabled) (gt $localStorageReplicas 1) -}} +{{- if not .Values.server.storage.accessMode -}} +{{- fail "local storage with multiple server replicas requires server.storage.accessMode=ReadWriteMany and an RWX-capable StorageClass; use S3 otherwise" -}} +{{- end -}} +{{- if ne .Values.server.storage.accessMode "ReadWriteMany" -}} +{{- fail "local storage with multiple server replicas requires server.storage.accessMode=ReadWriteMany" -}} +{{- end -}} +{{- end -}} + +{{- 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) -}} +{{- 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 not .Values.secrets.allowAutoGenerated -}} +{{- if not .Values.existingSecret -}} +{{- if not (or .Values.secrets.bootstrapAdminPassword .Values.bootstrapAdmin.password) -}} +{{- fail "secrets.allowAutoGenerated=false requires secrets.bootstrapAdminPassword or bootstrapAdmin.password" -}} +{{- end -}} +{{- if not .Values.secrets.downloadAnonCookieSecret -}} +{{- fail "secrets.allowAutoGenerated=false requires secrets.downloadAnonCookieSecret" -}} +{{- end -}} +{{- end -}} +{{- if and .Values.postgresql.enabled (not .Values.postgresql.auth.existingSecret) -}} +{{- if and .Values.postgresql.auth.enablePostgresUser (not .Values.postgresql.auth.postgresPassword) -}} +{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.postgresPassword or postgresql.auth.existingSecret" -}} +{{- end -}} +{{- if and .Values.postgresql.auth.username (ne .Values.postgresql.auth.username "postgres") (not .Values.postgresql.auth.password) -}} +{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.password or postgresql.auth.existingSecret" -}} +{{- end -}} +{{- if and (eq .Values.postgresql.architecture "replication") (not .Values.postgresql.auth.replicationPassword) -}} +{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.replicationPassword for replication architecture" -}} +{{- end -}} +{{- end -}} +{{- if and .Values.redis.enabled .Values.redis.auth.enabled (not .Values.redis.auth.existingSecret) (not .Values.redis.auth.password) -}} +{{- fail "secrets.allowAutoGenerated=false requires redis.auth.password or redis.auth.existingSecret" -}} +{{- end -}} +{{- end -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh new file mode 100755 index 00000000..e1089174 --- /dev/null +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail + +CHART_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TEST_VALUES="$CHART_DIR/tests/test-values.yaml" +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +render() { + helm template "$@" -f "$TEST_VALUES" +} + +assert_rejected() { + local name=$1 + shift + if render "$name" "$CHART_DIR" "$@" >"$TMP_DIR/$name.yaml" 2>"$TMP_DIR/$name.err"; then + fail "$name should have been rejected" + fi +} + +render verify "$CHART_DIR" >"$TMP_DIR/default.yaml" +grep -Fq 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/default.yaml" +grep -Fq 'value: "verify-postgresql"' "$TMP_DIR/default.yaml" +grep -Fq 'value: "verify-redis-master"' "$TMP_DIR/default.yaml" + +stable_args=( + --set-string secrets.bootstrapAdminPassword=stable-bootstrap-password + --set-string secrets.downloadAnonCookieSecret=stable-download-cookie-secret + --set-string postgresql.auth.postgresPassword=stable-postgres-password + --set-string postgresql.auth.password=stable-user-password + --set-string redis.auth.password=stable-redis-password +) +render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-a.yaml" +render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-b.yaml" +cmp "$TMP_DIR/stable-a.yaml" "$TMP_DIR/stable-b.yaml" + +render private-registry "$CHART_DIR" \ + --set server.dependencyWait.image.registry=registry.example.com \ + --set server.dependencyWait.image.repository=library/busybox \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/private-registry.yaml" +grep -Fq 'image: "registry.example.com/library/busybox:1.37"' "$TMP_DIR/private-registry.yaml" + +render postgresql-replication "$CHART_DIR" \ + --set postgresql.architecture=replication >"$TMP_DIR/postgresql-replication.yaml" +if [[ $(grep -Fc 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/postgresql-replication.yaml") -ne 2 ]]; then + fail "PostgreSQL primary and read replica must use the same max_connections setting" +fi + +render custom "$CHART_DIR" \ + --set postgresql.auth.existingSecret=custom-pg \ + --set postgresql.auth.secretKeys.userPasswordKey=custom-pg-key \ + --set redis.auth.existingSecret=custom-redis \ + --set redis.auth.existingSecretPasswordKey=custom-redis-key \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/custom.yaml" +grep -Fq 'name: custom-pg' "$TMP_DIR/custom.yaml" +grep -Fq 'key: custom-pg-key' "$TMP_DIR/custom.yaml" +grep -Fq 'name: custom-redis' "$TMP_DIR/custom.yaml" +grep -Fq 'key: custom-redis-key' "$TMP_DIR/custom.yaml" + +render sentinel "$CHART_DIR" \ + --set redis.architecture=replication \ + --set redis.sentinel.enabled=true \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/sentinel.yaml" +grep -Fq 'value: "docker,redis-sentinel"' "$TMP_DIR/sentinel.yaml" +grep -Fq 'value: "mymaster"' "$TMP_DIR/sentinel.yaml" +grep -Fq '.svc.cluster.local:26379' "$TMP_DIR/sentinel.yaml" +grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/sentinel.yaml" +grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/sentinel.yaml" + +render external-sentinel "$CHART_DIR" \ + --set postgresql.enabled=false \ + --set externalDatabase.host=db.example.com \ + --set redis.enabled=false \ + --set externalRedis.password=redis-password \ + --set externalRedis.sentinel.enabled=true \ + --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" + +render special "$CHART_DIR" \ + --set-string 'bootstrapAdmin.displayName=Ops: Admin' \ + --show-only templates/configmap.yaml >"$TMP_DIR/special.yaml" +grep -Fq 'bootstrap-admin-display-name: "Ops: Admin"' "$TMP_DIR/special.yaml" + +render device "$CHART_DIR" \ + --set publicBaseUrl=https://skills.example.com \ + --show-only templates/configmap.yaml >"$TMP_DIR/device.yaml" +grep -Fq 'device-auth-verification-uri: "https://skills.example.com/cli/auth"' "$TMP_DIR/device.yaml" + +render tls "$CHART_DIR" \ + --set ingress.enabled=true \ + --set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \ + --show-only templates/configmap.yaml >"$TMP_DIR/tls.yaml" +grep -Fq 'session-cookie-secure: "true"' "$TMP_DIR/tls.yaml" + +render legacy-ingress "$CHART_DIR" \ + --set ingress.enabled=true \ + --set-string ingress.className= \ + --set-json 'ingress.annotations={"kubernetes.io/ingress.class":"alb","alb.ingress.kubernetes.io/listen-ports":"[{\"HTTPS\":6443}]"}' \ + --show-only templates/ingress.yaml >"$TMP_DIR/legacy-ingress.yaml" +grep -Fq 'kubernetes.io/ingress.class: alb' "$TMP_DIR/legacy-ingress.yaml" +grep -Fq 'alb.ingress.kubernetes.io/listen-ports:' "$TMP_DIR/legacy-ingress.yaml" +if grep -Fq 'ingressClassName:' "$TMP_DIR/legacy-ingress.yaml"; then + fail "empty ingress.className must omit spec.ingressClassName" +fi + +render multi-host-ingress "$CHART_DIR" \ + --set ingress.enabled=true \ + --set ingress.certManager.enabled=true \ + --set-json 'ingress.hosts=[{"host":"skills-a.example.com","paths":[{"path":"/","pathType":"Prefix"}]},{"host":"skills-b.example.com","paths":[{"path":"/portal","pathType":"Prefix"}]}]' \ + --set-json 'ingress.tls=[{"hosts":["skills-a.example.com","skills-b.example.com"],"secretName":"skills-tls"}]' \ + --show-only templates/ingress.yaml \ + --show-only templates/certificate.yaml >"$TMP_DIR/multi-host-ingress.yaml" +if [[ $(grep -Fc 'skills-a.example.com' "$TMP_DIR/multi-host-ingress.yaml") -ne 3 ]]; then + fail "first ingress host must be rendered in rule, TLS and Certificate" +fi +if [[ $(grep -Fc 'skills-b.example.com' "$TMP_DIR/multi-host-ingress.yaml") -ne 3 ]]; then + fail "second ingress host must be rendered in rule, TLS and Certificate" +fi + +render scanner-off "$CHART_DIR" \ + --set scanner.enabled=false \ + --set scanner.autoscaling.enabled=true \ + --set scanner.podDisruptionBudget.enabled=true >"$TMP_DIR/scanner-off.yaml" +if awk ' + $1 == "kind:" { kind=$2 } + kind ~ /^(Deployment|Service|HorizontalPodAutoscaler|PodDisruptionBudget)$/ && + $1 == "name:" && $2 == "scanner-off-skillhub-scanner" { found=1 } + END { exit found ? 0 : 1 } +' "$TMP_DIR/scanner-off.yaml"; then + fail "disabled scanner rendered workload resources" +fi + +render multi-rwx "$CHART_DIR" \ + --set server.replicaCount=2 \ + --set server.storage.accessMode=ReadWriteMany >"$TMP_DIR/multi-rwx.yaml" +grep -Fq -- '- ReadWriteMany' "$TMP_DIR/multi-rwx.yaml" + +assert_rejected server-off --set server.enabled=false +assert_rejected direct-auth-without-provider \ + --set auth.direct.enabled=true \ + --set-string auth.direct.provider= +assert_rejected ingress-without-server-service --set ingress.enabled=true --set server.service.enabled=false +assert_rejected ingress-without-web-service --set ingress.enabled=true --set web.service.enabled=false +assert_rejected multi-without-rwx --set server.replicaCount=2 +assert_rejected hpa-without-metrics \ + --set server.autoscaling.enabled=true \ + --set server.autoscaling.targetCPUUtilizationPercentage=0 \ + --set server.autoscaling.targetMemoryUtilizationPercentage=0 +assert_rejected old-postgres-env --set-json 'postgresql.primary.extraEnv=[{"name":"X","value":"Y"}]' +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 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 +assert_rejected empty-ingress-hosts --set-json 'ingress.hosts=[]' +assert_rejected cert-manager-without-tls \ + --set ingress.enabled=true \ + --set ingress.certManager.enabled=true \ + --set-json 'ingress.tls=[]' +if helm template missing-credentials "$CHART_DIR" >"$TMP_DIR/missing-credentials.yaml" 2>"$TMP_DIR/missing-credentials.err"; then + fail "default rendering without stable credentials should have been rejected" +fi + +echo "Helm configuration contract tests passed" diff --git a/charts/skillhub/tests/test-values.yaml b/charts/skillhub/tests/test-values.yaml new file mode 100644 index 00000000..1be1d359 --- /dev/null +++ b/charts/skillhub/tests/test-values.yaml @@ -0,0 +1,14 @@ +# Non-production credentials used only for deterministic chart tests. +secrets: + bootstrapAdminPassword: test-bootstrap-password + downloadAnonCookieSecret: test-download-cookie-secret-at-least-32-chars + +postgresql: + auth: + postgresPassword: test-postgres-password + password: test-postgresql-user-password + replicationPassword: test-postgresql-replication-password + +redis: + auth: + password: test-redis-password diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json new file mode 100644 index 00000000..da736cf9 --- /dev/null +++ b/charts/skillhub/values.schema.json @@ -0,0 +1,453 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "global": { "type": "object" }, + "images": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "tag", "pullPolicy"], + "properties": { + "registry": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "nameOverride": { "$ref": "#/definitions/optionalDnsLabel" }, + "fullnameOverride": { "$ref": "#/definitions/optionalDnsLabel" }, + "publicBaseUrl": { "type": "string" }, + "deviceAuthVerificationUri": { "type": "string" }, + "auth": { + "type": "object", + "additionalProperties": false, + "required": ["direct"], + "properties": { + "direct": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "provider"], + "properties": { + "enabled": { "type": "boolean" }, + "provider": { "type": "string" } + } + } + } + }, + "builtinSkills": { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "type": "boolean" } } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "className", "hosts", "annotations", "tls", "certManager"], + "properties": { + "enabled": { "type": "boolean" }, + "className": { + "oneOf": [ + { "type": "string", "enum": [""] }, + { "$ref": "#/definitions/dnsSubdomain" } + ] + }, + "hosts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["host", "paths"], + "properties": { + "host": { "type": "string", "minLength": 1, "pattern": "^(\\*\\.)?[A-Za-z0-9.-]+$" }, + "paths": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "pathType"], + "properties": { + "path": { "type": "string", "pattern": "^/" }, + "pathType": { "enum": ["Exact", "Prefix", "ImplementationSpecific"] } + } + } + } + } + } + }, + "annotations": { "$ref": "#/definitions/stringMap" }, + "tls": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["hosts", "secretName"], + "properties": { + "hosts": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "pattern": "^(\\*\\.)?[A-Za-z0-9.-]+$" } + }, + "secretName": { "type": "string", "minLength": 1 } + } + } + }, + "certManager": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "issuerName", "issuerKind"], + "properties": { + "enabled": { "type": "boolean" }, + "issuerName": { "type": "string", "minLength": 1 }, + "issuerKind": { "type": "string", "minLength": 1 } + } + } + } + }, + "s3": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "bucket", "endpoint", "publicEndpoint", "region", "forcePathStyle", "disableChunkedEncoding", "autoCreateBucket", "presignExpiry", "accessKey", "secretKey"], + "properties": { + "enabled": { "type": "boolean" }, + "bucket": { "type": "string", "minLength": 1 }, + "endpoint": { "type": "string" }, + "publicEndpoint": { "type": "string" }, + "region": { "type": "string", "minLength": 1 }, + "forcePathStyle": { "type": "boolean" }, + "disableChunkedEncoding": { "type": "boolean" }, + "autoCreateBucket": { "type": "boolean" }, + "presignExpiry": { "type": "string", "pattern": "^P" }, + "accessKey": { "type": "string" }, + "secretKey": { "type": "string" } + } + }, + "session": { + "type": "object", + "additionalProperties": false, + "required": ["cookieSecure"], + "properties": { "cookieSecure": { "type": "boolean" } } + }, + "bootstrapAdmin": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "userId", "username", "displayName", "email", "password"], + "properties": { + "enabled": { "type": "boolean" }, + "userId": { "type": "string", "minLength": 1 }, + "username": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string", "minLength": 1 }, + "email": { "type": "string", "minLength": 1 }, + "password": { "type": "string" } + } + }, + "springProfilesActive": { "type": "string", "minLength": 1 }, + "existingSecret": { "type": "string" }, + "secrets": { + "type": "object", + "additionalProperties": false, + "required": ["allowAutoGenerated"], + "properties": { + "allowAutoGenerated": { "type": "boolean" }, + "bootstrapAdminPassword": { "type": "string" }, + "downloadAnonCookieSecret": { "type": "string" }, + "oauth2GithubClientId": { "type": "string" }, + "oauth2GithubClientSecret": { "type": "string" }, + "scannerLlmApiKey": { "type": "string" }, + "scannerLlmBaseUrl": { "type": "string" }, + "scannerLlmModel": { "type": "string" } + } + }, + "postgresql": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "architecture": { "enum": ["standalone", "replication"] }, + "auth": { "type": "object" }, + "primary": { + "type": "object", + "properties": { "extraEnv": false } + } + } + }, + "externalDatabase": { + "type": "object", + "additionalProperties": false, + "required": ["host", "port", "database", "username", "password", "jdbcUrl"], + "properties": { + "host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "database": { "type": "string", "minLength": 1 }, + "username": { "type": "string", "minLength": 1 }, + "password": { "type": "string" }, + "jdbcUrl": { "type": "string" } + } + }, + "redis": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "architecture": { "enum": ["standalone", "replication"] }, + "auth": { + "type": "object", + "properties": { "sentinelPassword": false } + }, + "sentinel": { + "type": "object", + "properties": { + "nodes": false, + "service": { + "type": "object", + "properties": { "enabled": false } + } + } + } + } + }, + "externalRedis": { + "type": "object", + "additionalProperties": false, + "required": ["host", "port", "password", "sentinel"], + "properties": { + "host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "password": { "type": "string" }, + "sentinel": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "masterSet", "nodes", "password"], + "properties": { + "enabled": { "type": "boolean" }, + "masterSet": { "type": "string", "minLength": 1 }, + "nodes": { + "type": "array", + "items": { "type": "string", "pattern": "^[^:]+:[0-9]+$" } + }, + "password": { "type": "string" } + } + } + } + }, + "server": { "$ref": "#/definitions/serverComponent" }, + "web": { "$ref": "#/definitions/webComponent" }, + "scanner": { "$ref": "#/definitions/scannerComponent" } + }, + "required": ["images", "auth", "builtinSkills", "ingress", "s3", "session", "bootstrapAdmin", "springProfilesActive", "secrets", "postgresql", "externalDatabase", "redis", "externalRedis", "server", "web", "scanner"], + "definitions": { + "dnsLabel": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }, + "dnsSubdomain": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$" + }, + "optionalDnsLabel": { + "type": "string", + "maxLength": 63, + "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }, + "stringMap": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "tag"], + "properties": { + "registry": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "type", "port", "nodePort", "loadBalancerIP", "loadBalancerSourceRanges"], + "properties": { + "enabled": { "type": "boolean" }, + "type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "nodePort": { + "oneOf": [ + { "type": "string", "enum": [""] }, + { "type": "integer", "minimum": 1, "maximum": 65535 } + ] + }, + "loadBalancerIP": { "type": "string" }, + "loadBalancerSourceRanges": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "resources": { "type": "object" }, + "autoscaling": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "minReplicas", "maxReplicas", "targetCPUUtilizationPercentage", "targetMemoryUtilizationPercentage"], + "properties": { + "enabled": { "type": "boolean" }, + "minReplicas": { "type": "integer", "minimum": 1 }, + "maxReplicas": { "type": "integer", "minimum": 1 }, + "targetCPUUtilizationPercentage": { "type": "integer", "minimum": 0 }, + "targetMemoryUtilizationPercentage": { "type": "integer", "minimum": 0 } + } + }, + "pdb": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "minAvailable"], + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string", "pattern": "^[0-9]+%$" } + ] + } + } + }, + "commonPod": { + "type": "object", + "properties": { + "resources": { "$ref": "#/definitions/resources" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { "name": { "type": "string", "minLength": 1 } } + } + }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + }, + "serverComponent": { + "allOf": [ + { "$ref": "#/definitions/commonPod" }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "properties": { + "enabled": { "type": "boolean" }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { "$ref": "#/definitions/image" }, + "dependencyWait": { + "type": "object", + "additionalProperties": false, + "required": ["image"], + "properties": { + "image": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "repository", "tag", "pullPolicy"], + "properties": { + "registry": { "type": "string", "minLength": 1 }, + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string", "minLength": 1 }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + } + } + }, + "service": { "$ref": "#/definitions/service" }, + "storage": { + "type": "object", + "additionalProperties": false, + "required": ["accessMode", "size", "storageClassName"], + "properties": { + "accessMode": { "enum": ["", "ReadWriteOnce", "ReadWriteMany"] }, + "size": { "type": "string", "minLength": 1 }, + "storageClassName": { "type": "string" } + } + }, + "resources": { "$ref": "#/definitions/resources" }, + "javaOpts": { "type": "string" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + } + ] + }, + "webComponent": { + "allOf": [ + { "$ref": "#/definitions/commonPod" }, + { + "type": "object", + "additionalProperties": false, + "required": ["replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { "$ref": "#/definitions/image" }, + "service": { "$ref": "#/definitions/service" }, + "resources": { "$ref": "#/definitions/resources" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + } + ] + }, + "scannerComponent": { + "allOf": [ + { "$ref": "#/definitions/commonPod" }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "properties": { + "enabled": { "type": "boolean" }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { "$ref": "#/definitions/image" }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["port"], + "properties": { "port": { "type": "integer", "minimum": 1, "maximum": 65535 } } + }, + "resources": { "$ref": "#/definitions/resources" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + } + ] + } + } +} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 42f8ea3b..48997726 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -31,12 +31,13 @@ builtinSkills: ingress: enabled: false className: nginx - host: skills.example.com - annotations: - nginx.ingress.kubernetes.io/proxy-body-size: 100m - tls: - enabled: false - secretName: "" + hosts: + - host: skills.example.com + paths: + - path: / + pathType: Prefix + annotations: {} + tls: [] certManager: enabled: false issuerName: letsencrypt-prod @@ -86,6 +87,8 @@ springProfilesActive: docker existingSecret: "" secrets: + # 默认禁止随机 Secret;仅在非 GitOps 临时环境中按需启用 + allowAutoGenerated: false bootstrapAdminPassword: "" downloadAnonCookieSecret: "" oauth2GithubClientId: "" @@ -122,8 +125,8 @@ postgresql: limits: cpu: 500m memory: 1Gi - extraEnv: - - name: POSTGRES_MAX_CONNECTIONS + extraEnvVars: + - name: POSTGRESQL_MAX_CONNECTIONS value: "500" podAnnotations: {} podSecurityContext: @@ -155,6 +158,10 @@ postgresql: limits: cpu: 500m memory: 1Gi + # Hot standbys must not use a lower max_connections than the primary. + extraEnvVars: + - name: POSTGRESQL_MAX_CONNECTIONS + value: "500" metrics: enabled: true @@ -162,7 +169,7 @@ postgresql: enabled: false externalDatabase: - host: postgres.example.com + host: "" port: 5432 database: skillhub username: skillhub @@ -180,7 +187,6 @@ redis: auth: enabled: true password: "" - sentinelPassword: "" master: persistence: @@ -222,11 +228,11 @@ redis: sentinel: enabled: false masterSet: mymaster - nodes: "" service: - enabled: true ports: sentinel: 26379 + containerPorts: + sentinel: 26379 metrics: enabled: true @@ -234,7 +240,7 @@ redis: enabled: false externalRedis: - host: redis.example.com + host: "" port: 6379 password: "" sentinel: @@ -254,6 +260,13 @@ server: registry: "" tag: "" + dependencyWait: + image: + registry: docker.io + repository: library/busybox + tag: "1.37" + pullPolicy: IfNotPresent + service: enabled: true type: ClusterIP diff --git a/server/skillhub-app/src/main/resources/application-redis-sentinel.yml b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml index b203b0cd..e5caff3e 100644 --- a/server/skillhub-app/src/main/resources/application-redis-sentinel.yml +++ b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml @@ -1,7 +1,7 @@ spring: data: redis: - password: ${SPRING_DATA_REDIS_SENTINEL_PASSWORD:${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}}} + password: ${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:${SPRING_DATA_REDIS_SENTINEL_PASSWORD:}}} sentinel: master: ${SPRING_DATA_REDIS_SENTINEL_MASTER:${REDIS_SENTINEL_MASTER:mymaster}} nodes: ${SPRING_DATA_REDIS_SENTINEL_NODES:${REDIS_SENTINEL_NODES:}} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java new file mode 100644 index 00000000..65a446b5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.PropertySourcesPropertyResolver; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RedisSentinelProfileConfigurationTest { + + private final PropertySource sentinelProfile = loadSentinelProfile(); + + @Test + void separateDataAndSentinelPasswordsResolveIndependently() { + assertThat(resolve("spring.data.redis.password", Map.of( + "SPRING_DATA_REDIS_PASSWORD", "data-password", + "SPRING_DATA_REDIS_SENTINEL_PASSWORD", "sentinel-password" + ))).isEqualTo("data-password"); + + assertThat(resolve("spring.data.redis.sentinel.password", Map.of( + "SPRING_DATA_REDIS_PASSWORD", "data-password", + "SPRING_DATA_REDIS_SENTINEL_PASSWORD", "sentinel-password" + ))).isEqualTo("sentinel-password"); + } + + @Test + void sentinelPasswordRemainsADataPasswordFallback() { + assertThat(resolve("spring.data.redis.password", Map.of( + "SPRING_DATA_REDIS_SENTINEL_PASSWORD", "legacy-password" + ))).isEqualTo("legacy-password"); + } + + private String resolve(String propertyName, Map environment) { + MutablePropertySources sources = new MutablePropertySources(); + sources.addFirst(new MapPropertySource("test-environment", environment)); + PropertySourcesPropertyResolver resolver = new PropertySourcesPropertyResolver(sources); + return resolver.resolveRequiredPlaceholders((String) sentinelProfile.getProperty(propertyName)); + } + + private static PropertySource loadSentinelProfile() { + try { + return new YamlPropertySourceLoader() + .load("redis-sentinel", new ClassPathResource("application-redis-sentinel.yml")) + .getFirst(); + } catch (IOException e) { + throw new IllegalStateException("Failed to load Redis Sentinel profile", e); + } + } +} From 9978d82cb155832e196f29810be59377c95ed603 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Thu, 16 Jul 2026 20:04:07 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=20Server=20?= =?UTF-8?q?PVC=20=E6=9D=83=E9=99=90=E4=B8=8E=20RWO=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地 PVC 会覆盖 Server 镜像内预先设置的目录所有者,导致非 root app 用户无法写入技能文件。 - 为 Server Pod 增加可覆盖的 fsGroup,默认匹配 v0.2.13 镜像的 app 组 101 - 本地 ReadWriteOnce 存储自动使用 Recreate,避免滚动升级时新旧 Pod 抢占卷 - ReadWriteMany 与 S3 部署继续使用 RollingUpdate - 补充 values schema、配置契约测试和运维文档 Signed-off-by: lhb6540 --- charts/skillhub/README.md | 11 ++++++++++ .../skillhub/templates/server-deployment.yaml | 10 ++++++++++ .../skillhub/tests/configuration-contracts.sh | 20 +++++++++++++++++++ charts/skillhub/values.schema.json | 11 +++++++++- charts/skillhub/values.yaml | 5 +++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 802c233b..445873d4 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -301,6 +301,17 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `server.storage.accessMode` | 留空时单副本使用 ReadWriteOnce;多副本必须显式使用 ReadWriteMany | `""` | | `server.storage.size` | PVC 大小 | `10Gi` | | `server.storage.storageClassName` | StorageClass | `""` | +| `server.podSecurityContext.fsGroup` | Server 本地存储的可写组 ID,应与镜像内 app 用户组一致 | `101` | +| `server.podSecurityContext.fsGroupChangePolicy` | kubelet 调整 PVC 组权限的策略 | `OnRootMismatch` | + +本地 PVC 会覆盖镜像内预先设置的目录所有者。Chart 默认通过 Pod `fsGroup=101` +使 Server 的非 root `app` 用户可以创建和更新技能文件。使用自定义 Server 镜像且其 +运行组 ID 不同时,必须同步覆盖 `server.podSecurityContext.fsGroup`。 + +使用本地 `ReadWriteOnce` PVC 时,Server Deployment 自动采用 `Recreate`,避免 +滚动升级期间新旧 Pod 同时挂载非共享卷而触发 Multi-Attach。单副本升级会有短暂 +停机;使用支持 RWX 的 `ReadWriteMany` 存储或启用 S3 时,Chart 保留 +`RollingUpdate`。 ```bash # 默认使用本地 PVC diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index a39d9d85..84e7ac9d 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -9,6 +9,12 @@ spec: {{- if not .Values.server.autoscaling.enabled }} replicas: {{ .Values.server.replicaCount }} {{- end }} + strategy: + {{- if and (not .Values.s3.enabled) (ne .Values.server.storage.accessMode "ReadWriteMany") }} + type: Recreate + {{- else }} + type: RollingUpdate + {{- end }} selector: matchLabels: {{- include "skillhub.server.selectorLabels" . | nindent 6 }} @@ -23,6 +29,10 @@ spec: {{ $key }}: {{ $val }} {{- end }} spec: + {{- with .Values.server.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} {{- $secrets := .Values.server.imagePullSecrets }} {{- if $secrets }} imagePullSecrets: diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index e1089174..e3b33b7b 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -27,6 +27,16 @@ render verify "$CHART_DIR" >"$TMP_DIR/default.yaml" grep -Fq 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/default.yaml" grep -Fq 'value: "verify-postgresql"' "$TMP_DIR/default.yaml" grep -Fq 'value: "verify-redis-master"' "$TMP_DIR/default.yaml" +grep -Fq 'fsGroup: 101' "$TMP_DIR/default.yaml" +grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml" +grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml" + +render custom-server-fsgroup "$CHART_DIR" \ + --set server.podSecurityContext.fsGroup=2000 \ + --set server.podSecurityContext.fsGroupChangePolicy=Always \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/custom-server-fsgroup.yaml" +grep -Fq 'fsGroup: 2000' "$TMP_DIR/custom-server-fsgroup.yaml" +grep -Fq 'fsGroupChangePolicy: Always' "$TMP_DIR/custom-server-fsgroup.yaml" stable_args=( --set-string secrets.bootstrapAdminPassword=stable-bootstrap-password @@ -143,6 +153,16 @@ render multi-rwx "$CHART_DIR" \ --set server.replicaCount=2 \ --set server.storage.accessMode=ReadWriteMany >"$TMP_DIR/multi-rwx.yaml" grep -Fq -- '- ReadWriteMany' "$TMP_DIR/multi-rwx.yaml" +grep -Fq 'type: RollingUpdate' "$TMP_DIR/multi-rwx.yaml" + +render s3-rolling "$CHART_DIR" \ + --set s3.enabled=true \ + --set s3.bucket=skillhub \ + --set s3.endpoint=https://s3.example.com \ + --set s3.accessKey=access-key \ + --set s3.secretKey=secret-key \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/s3-rolling.yaml" +grep -Fq 'type: RollingUpdate' "$TMP_DIR/s3-rolling.yaml" assert_rejected server-off --set server.enabled=false assert_rejected direct-auth-without-provider \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index da736cf9..b1c6f3a0 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -344,7 +344,7 @@ { "type": "object", "additionalProperties": false, - "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], "properties": { "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer", "minimum": 1 }, @@ -378,6 +378,15 @@ "storageClassName": { "type": "string" } } }, + "podSecurityContext": { + "type": "object", + "additionalProperties": false, + "required": ["fsGroup", "fsGroupChangePolicy"], + "properties": { + "fsGroup": { "type": "integer", "minimum": 1 }, + "fsGroupChangePolicy": { "enum": ["Always", "OnRootMismatch"] } + } + }, "resources": { "$ref": "#/definitions/resources" }, "javaOpts": { "type": "string" }, "extraEnv": { "type": "array", "items": { "type": "object" } }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 48997726..d70f2406 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -281,6 +281,11 @@ server: size: 10Gi storageClassName: "" + # PVC 挂载会覆盖镜像内目录权限;使用镜像中 app 用户的组 ID 使本地存储可写 + podSecurityContext: + fsGroup: 101 + fsGroupChangePolicy: OnRootMismatch + resources: requests: cpu: 500m From f3dbb57a806db620ae83cd76433b8efa69cb5a02 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Mon, 20 Jul 2026 11:35:44 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=20CI=20?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E4=B8=8E=20PostgreSQL=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=AF=86=E7=A0=81=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Helm CI matrix 与当前 values schema 和确定性凭据策略。 - 所有 CI 渲染加载测试凭据并迁移 Ingress TLS 数组配置 - PostgreSQL 使用 postgres 用户时引用管理员密码 key - 增加内置 Secret 和 existingSecret 的管理员用户契约测试 Signed-off-by: lhb6540 --- .github/workflows/pr-helm-chart.yml | 7 ++++--- charts/skillhub/templates/_helpers.tpl | 6 +++++- .../skillhub/tests/configuration-contracts.sh | 21 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index bc5d8e03..5ec348d3 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -98,7 +98,8 @@ jobs: description: Ingress + TLS + cert-manager args: >- --set ingress.enabled=true - --set ingress.tls.enabled=true + --set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/","pathType":"Prefix"}]}]' + --set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' --set ingress.certManager.enabled=true - name: s3-storage description: S3 存储 @@ -143,7 +144,7 @@ jobs: - name: Render template - ${{ matrix.scenario.name }} run: | echo "## ${{ matrix.scenario.description }}" - helm template test-release . ${{ matrix.scenario.args }} > rendered.yaml + helm template test-release . -f tests/test-values.yaml ${{ matrix.scenario.args }} > rendered.yaml echo "✅ Template rendered successfully" - name: Validate resources @@ -158,7 +159,7 @@ jobs: - name: Validate default dependency wiring if: ${{ matrix.scenario.name == 'bitnami-default' }} run: | - helm template test-release . --show-only templates/server-deployment.yaml > server.yaml + helm template test-release . -f tests/test-values.yaml --show-only templates/server-deployment.yaml > server.yaml grep -Fq 'value: "test-release-postgresql"' server.yaml grep -Fq 'value: "test-release-redis-master"' server.yaml grep -Fq 'name: test-release-postgresql' server.yaml diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index d287cfeb..2f4b9321 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -148,9 +148,13 @@ app.kubernetes.io/component: scanner {{- end -}} {{- end }} -{{- /* PostgreSQL 应用用户密码 Secret key */}} +{{- /* PostgreSQL 密码 Secret key;postgres 使用管理员密码,其他用户使用应用密码 */}} {{- define "skillhub.postgresql.passwordKey" -}} +{{- if eq .Values.postgresql.auth.username "postgres" -}} +{{- .Values.postgresql.auth.secretKeys.adminPasswordKey | default "postgres-password" -}} +{{- else -}} {{- .Values.postgresql.auth.secretKeys.userPasswordKey | default "password" -}} +{{- end -}} {{- end }} {{- /* PostgreSQL JDBC URL */}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index e3b33b7b..acf671a6 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -72,6 +72,27 @@ grep -Fq 'key: custom-pg-key' "$TMP_DIR/custom.yaml" grep -Fq 'name: custom-redis' "$TMP_DIR/custom.yaml" grep -Fq 'key: custom-redis-key' "$TMP_DIR/custom.yaml" +render postgresql-admin "$CHART_DIR" \ + --set postgresql.auth.username=postgres \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/postgresql-admin.yaml" +grep -Fq 'value: "postgres"' "$TMP_DIR/postgresql-admin.yaml" +grep -Fq 'key: postgres-password' "$TMP_DIR/postgresql-admin.yaml" +render postgresql-admin-secret "$CHART_DIR" \ + --set postgresql.auth.username=postgres \ + --show-only charts/postgresql/templates/secrets.yaml >"$TMP_DIR/postgresql-admin-secret.yaml" +grep -Eq '^ postgres-password:' "$TMP_DIR/postgresql-admin-secret.yaml" +if grep -Eq '^ password:' "$TMP_DIR/postgresql-admin-secret.yaml"; then + fail "Bitnami PostgreSQL must not create a custom-user password key for username=postgres" +fi + +render postgresql-admin-existing-secret "$CHART_DIR" \ + --set postgresql.auth.username=postgres \ + --set postgresql.auth.existingSecret=custom-pg-admin \ + --set postgresql.auth.secretKeys.adminPasswordKey=custom-admin-key \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/postgresql-admin-existing-secret.yaml" +grep -Fq 'name: custom-pg-admin' "$TMP_DIR/postgresql-admin-existing-secret.yaml" +grep -Fq 'key: custom-admin-key' "$TMP_DIR/postgresql-admin-existing-secret.yaml" + render sentinel "$CHART_DIR" \ --set redis.architecture=replication \ --set redis.sentinel.enabled=true \