diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml new file mode 100644 index 00000000..0f26fb97 --- /dev/null +++ b/.github/workflows/pr-helm-chart.yml @@ -0,0 +1,217 @@ +name: PR Helm Chart + +on: + pull_request: + paths: + - charts/skillhub/** + - .github/workflows/pr-helm-chart.yml + - .github/workflows/publish-chart.yml + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + +concurrency: + group: pr-helm-chart-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: Lint Chart + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: charts/skillhub + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.19.0 + + - name: Build dependencies + run: helm dependency build . + + - name: Lint chart + run: helm lint --strict . -f tests/test-values.yaml + + - name: Validate configuration contracts + run: bash tests/configuration-contracts.sh + + - name: Validate chart metadata + run: | + CHART_VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}') + APP_VERSION=$(helm show chart . | grep '^appVersion:' | awk '{print $2}') + echo "Chart version: $CHART_VERSION" + echo "App version: $APP_VERSION" + if [ -z "$CHART_VERSION" ]; then + echo "ERROR: Chart version is empty" + exit 1 + fi + + template: + name: Template Validation + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: charts/skillhub + + strategy: + fail-fast: false + matrix: + scenario: + - name: bitnami-default + description: Bitnami 默认配置 + args: "" + - name: external-db-redis + description: 外部 PostgreSQL + Redis + args: >- + --set postgresql.enabled=false + --set redis.enabled=false + --set externalDatabase.host=postgres.example.com + --set externalDatabase.password=secret + --set externalRedis.host=redis.example.com + --set externalRedis.password=secret + - name: postgresql-replication + description: PostgreSQL 主从 + Redis 主从 + args: >- + --set postgresql.architecture=replication + --set redis.architecture=replication + - name: redis-sentinel + description: Redis 哨兵模式 + args: >- + --set redis.architecture=replication + --set redis.sentinel.enabled=true + - name: ingress-tls-certmanager + description: Ingress + TLS + cert-manager + args: >- + --set ingress.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 存储 + args: >- + --set s3.enabled=true + --set s3.bucket=test-bucket + --set s3.endpoint=https://s3.amazonaws.com + --set s3.region=us-east-1 + - name: external-secret + description: 外部 Secret + args: >- + --set existingSecret=my-custom-secret + - name: scanner-disabled + description: 禁用 Scanner + args: >- + --set scanner.enabled=false + - name: hpa-pdb + description: HPA + PDB + args: >- + --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 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.19.0 + + - name: Build dependencies + run: helm dependency build . + + - name: Render template - ${{ matrix.scenario.name }} + run: | + echo "## ${{ matrix.scenario.description }}" + helm template test-release . -f tests/test-values.yaml ${{ matrix.scenario.args }} > rendered.yaml + echo "✅ Template rendered successfully" + + - name: Validate resources + run: | + RESOURCES=$(grep -c '^kind:' rendered.yaml || true) + echo "Rendered $RESOURCES resources for ${{ matrix.scenario.name }}" + if [ "$RESOURCES" -eq 0 ]; then + echo "ERROR: No resources rendered for ${{ matrix.scenario.name }}" + exit 1 + fi + + - name: Validate default dependency wiring + if: ${{ matrix.scenario.name == 'bitnami-default' }} + run: | + 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 + 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@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e # v0.8.0 + with: + entrypoint: '/kubeconform' + args: "-strict -summary -output text -schema-location default -schema-location https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json charts/skillhub/rendered.yaml" + + install-upgrade: + name: Install and Upgrade Smoke (${{ matrix.scenario }}) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + scenario: + - default + - sentinel + - s3 + - ingress-tls + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.19.0 + + - name: Create Kubernetes cluster + uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + with: + cluster_name: skillhub-helm-smoke + wait: 120s + + - name: Run install and upgrade smoke + env: + HELM_SMOKE_SCENARIO: ${{ matrix.scenario }} + run: bash charts/skillhub/tests/install-upgrade-smoke.sh diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index 082ce102..6b46fde7 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -12,7 +12,9 @@ on: - 'web/nginx.conf.template' - '.github/workflows/pr-cli.yml' - '.github/workflows/pr-e2e.yml' + - '.github/workflows/pr-helm-chart.yml' - '.github/workflows/pr-tests.yml' + - '.github/workflows/publish-chart.yml' - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' - '**/*.py' diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml new file mode 100644 index 00000000..5264d84c --- /dev/null +++ b/.github/workflows/publish-chart.yml @@ -0,0 +1,86 @@ +name: Publish Helm Chart + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: Chart and application version (for example, 0.2.14) + required: true + type: string + +concurrency: + group: publish-chart-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +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: + working-directory: charts/skillhub + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.19.0 + + - name: Verify dependencies + run: helm dependency build . + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Parse version from tag + id: ver + run: | + 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 + 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" + + - name: Lint chart + run: helm lint . -f tests/test-values.yaml + + - name: Package and push + run: | + helm package . \ + --version "${{ steps.ver.outputs.version }}" \ + --app-version "${{ steps.ver.outputs.version }}" \ + --destination /tmp/helm-charts + helm push /tmp/helm-charts/skillhub-${{ steps.ver.outputs.version }}.tgz \ + oci://ghcr.io/${{ github.repository_owner }}/charts + + - name: Upload chart artifact + uses: actions/upload-artifact@v4 + with: + name: skillhub-${{ steps.ver.outputs.version }}.tgz + path: /tmp/helm-charts/skillhub-${{ steps.ver.outputs.version }}.tgz + retention-days: 90 diff --git a/.gitignore b/.gitignore index 3a6c5f5b..76a972db 100644 --- a/.gitignore +++ b/.gitignore @@ -84,5 +84,8 @@ docs/superpowers/ # Local workspace metadata CLAUDE.md +# Helm chart dependencies +charts/skillhub/charts/*.tgz + # Local config file .mcp.json diff --git a/README.md b/README.md index 4c36db58..ff928fcf 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,20 @@ Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s): - `services.yaml` - `ingress.yaml` +For a configurable deployment with bundled PostgreSQL and Redis dependencies, +use the Helm chart under [`charts/skillhub/`](./charts/skillhub): + +```bash +helm dependency build ./charts/skillhub +helm upgrade --install skillhub ./charts/skillhub \ + --namespace skillhub \ + --create-namespace \ + -f values-production.yaml +``` + +See the [Helm chart guide](./charts/skillhub/README.md) for required secrets, +Ingress/TLS, external data services, storage migration, and upgrade constraints. + Apply them after creating your own secret: ```bash diff --git a/README_zh.md b/README_zh.md index 7923daae..694bdc27 100644 --- a/README_zh.md +++ b/README_zh.md @@ -228,8 +228,10 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u # 应用 Kubernetes 清单 kubectl apply -f deploy/k8s/ -# 或使用 Helm(即将推出) -helm install skillhub ./deploy/helm +# 或使用 Helm Chart +helm dependency build ./charts/skillhub +helm upgrade --install skillhub ./charts/skillhub -n skillhub --create-namespace \ + -f values-production.yaml ``` ### 环境变量 @@ -319,7 +321,7 @@ SkillHub 采用清晰的分层架构: ### 基础设施 - **容器化**:Docker & Docker Compose - **监控**:Prometheus + Grafana -- **部署**:Kubernetes 清单 +- **部署**:Kubernetes 清单与 Helm Chart - **CI/CD**:GitHub Actions ## 路线图 @@ -332,7 +334,7 @@ SkillHub 采用清晰的分层架构: - [x] API 令牌管理 - [x] 账户合并 - [x] 国际化支持 -- [ ] Helm Chart 部署 +- [x] Helm Chart 部署 - [ ] 高级搜索过滤器 - [ ] 技能依赖管理 - [ ] Webhook 集成 diff --git a/charts/skillhub/.helmignore b/charts/skillhub/.helmignore new file mode 100644 index 00000000..47499d20 --- /dev/null +++ b/charts/skillhub/.helmignore @@ -0,0 +1,24 @@ +# OS files +.DS_Store +Thumbs.db + +# Editors / IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# Local tooling +.claude/ +CLAUDE.md + +# Git +.git/ +.gitignore +.gitattributes + +# CI +.github/ + +# Source-only contract tests +tests/ diff --git a/charts/skillhub/Chart.lock b/charts/skillhub/Chart.lock new file mode 100644 index 00000000..fc2e72e3 --- /dev/null +++ b/charts/skillhub/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: postgresql + repository: oci://registry-1.docker.io/bitnamicharts + version: 18.6.10 +- name: redis + repository: oci://registry-1.docker.io/bitnamicharts + version: 25.5.3 +digest: sha256:20336709650cc49c81b8b4afdac0efeeea00cb88ff87820be9272ef5a7d545cc +generated: "2026-05-31T08:35:16.614393+08:00" diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml new file mode 100644 index 00000000..2bf90a60 --- /dev/null +++ b/charts/skillhub/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v2 +name: skillhub +description: Self-hosted, open-source agent skill registry for enterprises. +type: application +version: 0.1.0 +appVersion: 0.2.14 +keywords: + - skillhub + - 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 + +dependencies: + # PostgreSQL - Bitnami 官方 chart,支持 HA、备份、监控 + - name: postgresql + version: "18.6.10" + repository: "oci://registry-1.docker.io/bitnamicharts" + condition: postgresql.enabled + + # Redis - Bitnami 官方 chart,支持集群模式、哨兵模式 + - name: redis + version: "25.5.3" + repository: "oci://registry-1.docker.io/bitnamicharts" + condition: redis.enabled diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md new file mode 100644 index 00000000..29c41818 --- /dev/null +++ b/charts/skillhub/README.md @@ -0,0 +1,475 @@ +# SkillHub Helm Chart + +企业级 AI 技能中心私有化部署方案,基于 Kubernetes 和 Helm。 + +## 特性 + +- **微服务架构**:Server(Spring Boot)、Web(Nginx)、Scanner 分离部署 +- **高可用**:支持 HPA 自动扩缩容、PDB Pod 中断预算 +- **数据层**:使用 Bitnami PostgreSQL/Redis,支持主从复制、哨兵模式 +- **安全**:TLS 证书管理、Secret 密码保护;Bitnami 数据组件默认提供 NetworkPolicy +- **可观测性**:内置 Prometheus metrics exporter + +## 快速开始 + +### 前置要求 + +- Kubernetes 1.24+ +- Helm 3.8+ +- kubectl configured + +### 安装 + +先创建受保护的 `values-production.yaml`。以下值必须替换为实际随机强密码: + +```yaml +secrets: + allowAutoGenerated: false + bootstrapAdminPassword: "<固定管理员密码>" + downloadAnonCookieSecret: "<至少32字符的固定随机值>" + +postgresql: + auth: + postgresPassword: "<固定PostgreSQL管理员密码>" + password: "<固定skillhub用户密码>" + +redis: + auth: + password: "<固定Redis密码>" +``` + +```bash +helm dependency build ./charts/skillhub +kubectl create namespace skillhub + +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -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 示例中的数组结构。 + +合并或发布前,可在一个空的测试 Kubernetes 集群中运行可重复的安装/升级 smoke: + +```bash +for scenario in default sentinel s3 ingress-tls; do + HELM_SMOKE_SCENARIO="$scenario" \ + bash charts/skillhub/tests/install-upgrade-smoke.sh +done +``` + +脚本验证 `install -> Ready -> HTTP health -> upgrade -> Ready`,并确认 Secret 数据、 +PVC UID 与绑定 PV 在升级前后保持不变。四个场景分别覆盖默认依赖、Redis +Sentinel、实际 MinIO S3 连接,以及由 Kubernetes API 接受的 TLS Ingress 路由。 +默认清理自己创建的 namespace;设置 `KEEP_HELM_SMOKE=true` 可保留现场用于排查。 + +### 高可用模式 + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set postgresql.architecture=replication \ + --set postgresql.auth.replicationPassword=your-replication-password \ + --set redis.architecture=replication +``` + +### 外部数据库模式 + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set postgresql.enabled=false \ + --set redis.enabled=false \ + --set externalDatabase.host=postgres.example.com \ + --set externalDatabase.port=5432 \ + --set externalDatabase.database=skillhub \ + --set externalDatabase.username=skillhub \ + --set externalDatabase.password=your-db-password \ + --set externalRedis.host=redis.example.com \ + --set externalRedis.port=6379 \ + --set externalRedis.password=your-redis-password +``` + +### 使用 existingSecret + +通过 `existingSecret` 引用已存在的 Secret 对象,避免在 values 中明文写入密码。 +内置 PostgreSQL/Redis 使用各自的 Bitnami Secret,不需要复制到该 Secret。 + +| Key | 必填 | 说明 | +|-----|------|------| +| `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 模型名称 | +| `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 \ + -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 或密钥注入插件中。 + +内置 PostgreSQL、Redis、Sentinel 及 metrics exporter 镜像默认使用不可变的 +多架构 manifest digest,避免 Bitnami 子 Chart 的 `latest` 默认值造成不可复现的 +安装和回滚,同时保留 amd64/arm64 支持。覆盖私有镜像仓库或 tag 时,必须同时把 +对应的 `image.digest` 设为空,或改成私有仓库中该镜像的真实 digest;digest +非空时会优先于 tag。 + +## 配置参考 + +### 副本数配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `server.replicaCount` | Server 副本数 | `1` | +| `web.replicaCount` | Web 副本数 | `1` | +| `scanner.replicaCount` | Scanner 副本数 | `1` | + +```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。 + +### 服务配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `server.service.type` | Server Service 类型 | `ClusterIP` | +| `server.service.port` | Server 端口 | `8080` | +| `web.service.type` | Web Service 类型 | `ClusterIP` | +| `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.14 + 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 + digest: "" + metrics: + image: + registry: registry.example.com + repository: library/postgres-exporter + tag: 0.20.1 + digest: "" + +redis: + image: + registry: registry.example.com + repository: library/redis + tag: 8.8.0 + digest: "" + sentinel: + image: + registry: registry.example.com + repository: library/redis-sentinel + tag: 8.8.0 + digest: "" + metrics: + image: + registry: registry.example.com + repository: library/redis-exporter + tag: 1.86.0 + digest: "" +``` + +`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='<密码>' +``` + +### 数据库配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `postgresql.enabled` | 启用内置 PostgreSQL | `true` | +| `postgresql.architecture` | 架构模式 | `standalone` | +| `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。节点地址由副本数自动生成,不需要手动配置。由于 Bitnami Sentinel +上报的 Pod 地址可能与客户端连接的 Headless Service FQDN 不同,Chart 仅在该 +内置模式下关闭 Redisson 的 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`。外部 Sentinel 默认保留 Redisson 地址一致性检查; +只有已确认服务发现会改写节点地址时,才通过 `server.extraEnv` 显式设置 +`SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=false`: + +```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"]' +``` + +确需关闭检查时,在 values 文件中显式记录该兼容例外: + +```yaml +server: + extraEnv: + - name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST + value: "false" +``` + +### 存储配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `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 +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml +``` + +### S3 对象存储 + +`s3.enabled=true` 时,不创建 PVC,应用使用 S3 作为存储后端。 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `s3.enabled` | 启用 S3 | `false` | +| `s3.bucket` | Bucket 名称 | `skillhub-storage` | +| `s3.endpoint` | S3 端点,非空时必须是绝对 HTTP(S) URL | `""` | +| `s3.publicEndpoint` | S3 公网访问端点,非空时必须是绝对 HTTP(S) URL | `""` | +| `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 | `""` | + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set s3.enabled=true \ + --set s3.bucket=your-bucket \ + --set s3.endpoint=https://s3.amazonaws.com \ + --set s3.region=us-east-1 \ + --set s3.accessKey=your-access-key \ + --set s3.secretKey=your-secret-key +``` + +### Ingress + TLS + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set ingress.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 publicBaseUrl=https://skills.example.com \ + --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`、`/oauth2`、 +`/login/oauth2` 和 `/.well-known` 直接转发给 Server,确保 TLS 终止后的 OAuth +回调协议保持正确;`hosts[].paths` 中的其他路径转发给 Web,因此上述四个前缀 +均为保留路径。`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.storage.accessMode=ReadWriteMany +``` + +每个 HPA 至少需要一个非零 CPU 或内存利用率目标。本地存储的 Server HPA 同样 +要求 RWX;也可以启用 S3 来避免共享 PVC。 + +## 发布 + +`.github/workflows/publish-chart.yml` 在 GitHub Release 发布后或手动 +`workflow_dispatch` 时运行。Release tag 必须使用 `vX.Y.Z`、`chart-vX.Y.Z` 或 +`helm-vX.Y.Z`;手动运行时显式输入 `X.Y.Z`。工作流按该版本打包 Chart,并推送到 +`oci://ghcr.io/iflytek/charts`,同时保留构建 artifact。 + +## 卸载 + +```bash +helm -n skillhub uninstall skillhub +``` + +Server 数据 PVC 带有 `helm.sh/resource-policy: keep`,卸载 release 后仍会保留, +需要确认数据不再使用后手动删除。 + +## 依赖 + +| 依赖 | 版本 | +|------|------| +| postgresql | 18.6.10 | +| redis | 25.5.3 | diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl new file mode 100644 index 00000000..2f4b9321 --- /dev/null +++ b/charts/skillhub/templates/_helpers.tpl @@ -0,0 +1,255 @@ +{{- /* +SkillHub Helm Chart 模板辅助函数 +*/}} + +{{- /* 名称 */}} +{{- define "skillhub.name" -}} +{{- default "skillhub" .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- /* 完整名称 */}} +{{- define "skillhub.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default "skillhub" .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- /* Chart 标签 */}} +{{- define "skillhub.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- /* 通用标签 */}} +{{- define "skillhub.labels" -}} +helm.sh/chart: {{ include "skillhub.chart" . }} +{{ include "skillhub.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: skillhub +{{- end }} + +{{- /* 选择器标签 */}} +{{- define "skillhub.selectorLabels" -}} +app.kubernetes.io/name: {{ include "skillhub.name" . }} +{{- end }} + +{{- /* 组件标签 */}} +{{- define "skillhub.server.labels" -}} +{{ include "skillhub.labels" . }} +app.kubernetes.io/component: server +{{- end }} +{{- define "skillhub.server.selectorLabels" -}} +{{ include "skillhub.selectorLabels" . }} +app.kubernetes.io/component: server +{{- end }} + +{{- define "skillhub.web.labels" -}} +{{ include "skillhub.labels" . }} +app.kubernetes.io/component: web +{{- end }} +{{- define "skillhub.web.selectorLabels" -}} +{{ include "skillhub.selectorLabels" . }} +app.kubernetes.io/component: web +{{- end }} + +{{- define "skillhub.scanner.labels" -}} +{{ include "skillhub.labels" . }} +app.kubernetes.io/component: scanner +{{- end }} +{{- define "skillhub.scanner.selectorLabels" -}} +{{ include "skillhub.selectorLabels" . }} +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 := include "skillhub.postgresql.fullname" . -}} +{{- if eq .Values.postgresql.architecture "replication" -}} +{{- printf "%s-primary" $prefix -}} +{{- else -}} +{{- $prefix -}} +{{- end -}} +{{- else -}} +{{- .Values.externalDatabase.host -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL Port */}} +{{- define "skillhub.postgresql.port" -}} +{{- if .Values.postgresql.enabled -}} +{{- print "5432" -}} +{{- else -}} +{{- .Values.externalDatabase.port | default 5432 | int -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL Database */}} +{{- define "skillhub.postgresql.database" -}} +{{- if .Values.postgresql.enabled -}} +{{- .Values.postgresql.auth.database -}} +{{- else -}} +{{- .Values.externalDatabase.database -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL Username */}} +{{- define "skillhub.postgresql.username" -}} +{{- if .Values.postgresql.enabled -}} +{{- .Values.postgresql.auth.username -}} +{{- else -}} +{{- .Values.externalDatabase.username -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL Secret Name */}} +{{- define "skillhub.postgresql.secretName" -}} +{{- if .Values.postgresql.enabled -}} +{{- .Values.postgresql.auth.existingSecret | default (include "skillhub.postgresql.fullname" .) -}} +{{- else -}} +{{- include "skillhub.secretName" . -}} +{{- end -}} +{{- end }} + +{{- /* 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 */}} +{{- define "skillhub.jdbcUrl" -}} +{{- if .Values.postgresql.enabled -}} +{{- printf "jdbc:postgresql://%s:5432/%s" (include "skillhub.postgresql.host" .) .Values.postgresql.auth.database -}} +{{- else -}} +{{- if .Values.externalDatabase.jdbcUrl -}} +{{- .Values.externalDatabase.jdbcUrl -}} +{{- else -}} +{{- printf "jdbc:postgresql://%s:%d/%s" .Values.externalDatabase.host (.Values.externalDatabase.port | default 5432 | int) .Values.externalDatabase.database -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{- /* Redis Sentinel 节点列表(Redisson 需要具体 pod FQDN,格式: {pod}.{headless-svc}.{ns}.svc.cluster.local) */}} +{{- define "skillhub.redis.sentinel.nodes" -}} +{{- $fullname := include "skillhub.redis.fullname" . -}} +{{- $prefix := printf "%s-node" $fullname -}} +{{- $headless := printf "%s-headless" $fullname -}} +{{- /* 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:%v" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} +{{- end }} + +{{- /* Redis Host */}} +{{- define "skillhub.redis.host" -}} +{{- if .Values.redis.enabled -}} +{{- if .Values.redis.sentinel.enabled -}} +{{- include "skillhub.redis.fullname" . -}} +{{- else -}} +{{- printf "%s-master" (include "skillhub.redis.fullname" .) -}} +{{- end -}} +{{- else -}} +{{- .Values.externalRedis.host -}} +{{- end -}} +{{- end }} + +{{- /* Redis Port */}} +{{- define "skillhub.redis.port" -}} +{{- if .Values.redis.enabled -}} +{{- if .Values.redis.sentinel.enabled -}} +{{- .Values.redis.sentinel.service.ports.sentinel | default 26379 -}} +{{- else -}} +{{- 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 */}} +{{- define "skillhub.redis.secretName" -}} +{{- if .Values.redis.enabled -}} +{{- .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" .)) }} +{{- end }} + +{{- /* PostgreSQL Service 名称(用于 server initContainer 等待) */}} +{{- define "skillhub.postgresql.serviceName" -}} +{{- if .Values.postgresql.enabled -}} +{{- include "skillhub.postgresql.host" . -}} +{{- else -}} +{{- .Values.externalDatabase.host -}} +{{- end -}} +{{- end }} + +{{- /* Redis Service 名称(用于 server initContainer 等待) */}} +{{- define "skillhub.redis.serviceName" -}} +{{- 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 new file mode 100644 index 00000000..e5675edc --- /dev/null +++ b/charts/skillhub/templates/certificate.yaml @@ -0,0 +1,24 @@ +{{- 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: {{ $tls.secretName }}-cert + labels: + {{- include "skillhub.labels" $ | nindent 4 }} +spec: + secretName: {{ $tls.secretName }} + duration: 2160h + renewBefore: 360h + dnsNames: + {{- range $tls.hosts }} + - {{ . | quote }} + {{- end }} + issuerRef: + 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 new file mode 100644 index 00000000..78e9e016 --- /dev/null +++ b/charts/skillhub/templates/configmap.yaml @@ -0,0 +1,57 @@ +{{- /* +SkillHub 应用 ConfigMap +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "skillhub.fullname" . }}-config + labels: + {{- include "skillhub.labels" . | nindent 4 }} +data: + # Redis 配置 + redis-host: {{ include "skillhub.redis.host" . | quote }} + redis-port: {{ include "skillhub.redis.port" . | quote }} + + # 存储路径 + storage-base-path: "/var/lib/skillhub/storage" + + # 存储提供者: local | s3 + skillhub-storage-provider: {{ if .Values.s3.enabled }}"s3"{{ else }}"local"{{ end }} + + {{- if .Values.s3.enabled }} + # S3 配置 + 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 }} + s3-presign-expiry: {{ .Values.s3.presignExpiry | quote }} + {{- end }} + + # 技能扫描器 + skill-scanner-enabled: {{ .Values.scanner.enabled | quote }} + 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 | 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: {{ or .Values.session.cookieSecure (not (empty .Values.ingress.tls)) .Values.ingress.certManager.enabled | quote }} + + # Public URL and authentication + public-base-url: {{ .Values.publicBaseUrl | 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 new file mode 100644 index 00000000..fd2d0600 --- /dev/null +++ b/charts/skillhub/templates/hpa.yaml @@ -0,0 +1,40 @@ +{{- 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 }} +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "skillhub.fullname" $ }}-{{ $name }} + labels: + {{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "skillhub.fullname" $ }}-{{ $name }} + minReplicas: {{ $component.autoscaling.minReplicas }} + maxReplicas: {{ $component.autoscaling.maxReplicas }} + metrics: + {{- if $component.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $component.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if $component.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ $component.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/skillhub/templates/ingress.yaml b/charts/skillhub/templates/ingress.yaml new file mode 100644 index 00000000..dd5f28d2 --- /dev/null +++ b/charts/skillhub/templates/ingress.yaml @@ -0,0 +1,64 @@ +{{- if .Values.ingress.enabled }} +{{- $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: + {{- toYaml .Values.ingress.annotations | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range $host := $hosts }} + - host: {{ $host.host | quote }} + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" $ }}-server + port: + number: {{ $.Values.server.service.port }} + - path: /oauth2 + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" $ }}-server + port: + number: {{ $.Values.server.service.port }} + - path: /login/oauth2 + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" $ }}-server + port: + number: {{ $.Values.server.service.port }} + - path: /.well-known + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" $ }}-server + port: + number: {{ $.Values.server.service.port }} + {{- range $path := $host.paths }} + - path: {{ $path.path | quote }} + pathType: {{ $path.pathType }} + backend: + service: + name: {{ include "skillhub.fullname" $ }}-web + port: + number: {{ $.Values.web.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/skillhub/templates/pdb.yaml b/charts/skillhub/templates/pdb.yaml new file mode 100644 index 00000000..b8869158 --- /dev/null +++ b/charts/skillhub/templates/pdb.yaml @@ -0,0 +1,21 @@ +{{- range $name := list "server" "web" "scanner" }} +{{- $component := index $.Values $name }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.podDisruptionBudget.enabled }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "skillhub.fullname" $ }}-{{ $name }} + labels: + {{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }} +spec: + selector: + matchLabels: + {{- include (printf "skillhub.%s.selectorLabels" $name) $ | nindent 6 }} + minAvailable: {{ $component.podDisruptionBudget.minAvailable }} +{{- end }} +{{- end }} diff --git a/charts/skillhub/templates/pvc.yaml b/charts/skillhub/templates/pvc.yaml new file mode 100644 index 00000000..cdc48ad3 --- /dev/null +++ b/charts/skillhub/templates/pvc.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.server.enabled (not .Values.s3.enabled) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "skillhub.fullname" . }}-server-data + labels: + {{- include "skillhub.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +spec: + {{- $accessMode := .Values.server.storage.accessMode }} + {{- if not $accessMode }} + {{- $accessMode = "ReadWriteOnce" }} + {{- end }} + accessModes: + - {{ $accessMode }} + {{- if .Values.server.storage.storageClassName }} + storageClassName: {{ .Values.server.storage.storageClassName | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.server.storage.size }} +{{- end }} diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml new file mode 100644 index 00000000..b511465e --- /dev/null +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -0,0 +1,77 @@ +{{- if .Values.scanner.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "skillhub.fullname" . }}-scanner + labels: + {{- include "skillhub.scanner.labels" . | nindent 4 }} +spec: + {{- if not .Values.scanner.autoscaling.enabled }} + replicas: {{ .Values.scanner.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "skillhub.scanner.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "skillhub.scanner.selectorLabels" . | nindent 8 }} + annotations: + checksum/config: {{ toYaml (dict "scanner" .Values.scanner "secrets" .Values.secrets "existingSecret" .Values.existingSecret) | sha256sum }} + {{- range $key, $val := .Values.scanner.podAnnotations }} + {{ $key }}: {{ $val }} + {{- end }} + spec: + {{- $secrets := .Values.scanner.imagePullSecrets }} + {{- if $secrets }} + imagePullSecrets: + {{- toYaml $secrets | nindent 8 }} + {{- end }} + containers: + - name: scanner + image: {{ .Values.scanner.image.registry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.scanner.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + ports: + - containerPort: {{ .Values.scanner.service.port }} + name: http + env: + - name: SKILL_SCANNER_LLM_API_KEY + valueFrom: + secretKeyRef: + 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: + name: {{ include "skillhub.secretName" . }} + key: skill-scanner-llm-model + optional: true + {{- with .Values.scanner.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.scanner.resources | nindent 12 }} + readinessProbe: + {{- toYaml .Values.scanner.probes.readiness | nindent 12 }} + livenessProbe: + {{- toYaml .Values.scanner.probes.liveness | nindent 12 }} + {{- with .Values.scanner.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.scanner.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.scanner.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml new file mode 100644 index 00000000..8e28c911 --- /dev/null +++ b/charts/skillhub/templates/secret.yaml @@ -0,0 +1,80 @@ +{{- /* +SkillHub 应用 Secret +- 内置 PostgreSQL/Redis:密码由 Bitnami 管理,从对应 Secret 读取 +- 外部 PostgreSQL/Redis:密码从 values 或 existingSecret 读取 +*/}} +{{- if not .Values.existingSecret }} +{{- $secretName := include "skillhub.secretName" . }} +{{- $appSecret := (lookup "v1" "Secret" $.Release.Namespace $secretName) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + labels: + {{- include "skillhub.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if not .Values.postgresql.enabled }} + # 外部数据库密码;内置 PostgreSQL 直接引用 Bitnami Secret + spring-datasource-password: {{ .Values.externalDatabase.password | quote }} + {{- end }} + + {{- if not .Values.redis.enabled }} + # 外部 Redis 密码;内置 Redis 直接引用 Bitnami Secret + redis-password: {{ .Values.externalRedis.password | default "" | quote }} + {{- end }} + + {{- 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 管理员密码 + # 优先级: secrets.bootstrapAdminPassword → bootstrapAdmin.password → 集群已有 Secret → 随机生成 + {{- $baPwd := .Values.secrets.bootstrapAdminPassword | default .Values.bootstrapAdmin.password | default "" }} + {{- if not $baPwd }} + {{- if $appSecret }} + {{- $baPwd = index $appSecret.data "bootstrap-admin-password" | default "" | b64dec }} + {{- end }} + {{- if not $baPwd }} + {{- $baPwd = randAlphaNum 16 }} + {{- 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 }} + {{- end }} + {{- if .Values.secrets.oauth2GithubClientSecret }} + oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | quote }} + {{- end }} + + # Scanner LLM 配置 (optional) + {{- 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 }} + skillhub-storage-s3-access-key: {{ .Values.s3.accessKey | quote }} + {{- end }} + {{- if .Values.s3.secretKey }} + 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 new file mode 100644 index 00000000..b1bd6393 --- /dev/null +++ b/charts/skillhub/templates/server-deployment.yaml @@ -0,0 +1,370 @@ +{{- if .Values.server.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "skillhub.fullname" . }}-server + labels: + {{- include "skillhub.server.labels" . | nindent 4 }} +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 }} + template: + metadata: + labels: + {{- include "skillhub.server.selectorLabels" . | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- range $key, $val := .Values.server.podAnnotations }} + {{ $key }}: {{ $val }} + {{- end }} + spec: + {{- with .Values.server.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- $secrets := .Values.server.imagePullSecrets }} + {{- if $secrets }} + imagePullSecrets: + {{- toYaml $secrets | nindent 8 }} + {{- end }} + initContainers: + - name: wait-for-dependencies + 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" . | quote }} + - name: DB_PORT + value: {{ include "skillhub.postgresql.port" . | quote }} + - name: REDIS_HOST + value: {{ include "skillhub.redis.serviceName" . | quote }} + - name: REDIS_PORT + value: {{ include "skillhub.redis.port" . | quote }} + command: + - sh + - -c + - | + echo "Waiting for PostgreSQL at ${DB_HOST}:${DB_PORT}..." + until nc -z -w 2 "${DB_HOST}" "${DB_PORT}"; do sleep 2; done + echo "PostgreSQL is ready!" + echo "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..." + until nc -z -w 2 "${REDIS_HOST}" "${REDIS_PORT}"; do sleep 2; done + echo "Redis is ready!" + containers: + - name: server + image: {{ .Values.server.image.registry | default .Values.images.registry }}/skillhub-server:{{ .Values.server.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + ports: + - containerPort: {{ .Values.server.service.port }} + name: http + env: + - name: SPRING_PROFILES_ACTIVE + {{- $profiles := .Values.springProfilesActive }} + {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} + {{- $profiles = printf "%s,redis-sentinel" $profiles }} + {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + {{- $profiles = printf "%s,redis-sentinel" $profiles }} + {{- end }} + value: {{ $profiles | quote }} + + # Database + - name: SPRING_DATASOURCE_URL + value: {{ include "skillhub.jdbcUrl" . | quote }} + - name: 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 }} + - 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" . | quote }} + # Bitnami Sentinel pods advertise pod-local addresses that can differ from + # the headless-service FQDNs used by clients inside Kubernetes. + - name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST + value: "false" + {{- 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 | quote }} + {{- else }} + - name: SPRING_DATA_REDIS_HOST + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: redis-host + - name: SPRING_DATA_REDIS_PORT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: redis-port + {{- 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: + {{- if .Values.redis.enabled }} + name: {{ include "skillhub.redis.secretName" . }} + key: {{ include "skillhub.redis.passwordKey" . }} + {{- else }} + 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" . }} + {{- end }} + key: {{ if .Values.redis.enabled }}{{ include "skillhub.redis.passwordKey" . }}{{ else }}redis-password{{ end }} + optional: true + {{- end }} + + # Storage + - name: STORAGE_BASE_PATH + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: storage-base-path + - name: SKILLHUB_STORAGE_PROVIDER + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skillhub-storage-provider + + {{- if .Values.s3.enabled }} + - name: SKILLHUB_STORAGE_S3_BUCKET + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-bucket + - name: SKILLHUB_STORAGE_S3_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-endpoint + - 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_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: skillhub-storage-s3-access-key + optional: true + - name: SKILLHUB_STORAGE_S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: skillhub-storage-s3-secret-key + optional: true + {{- end }} + + # Scanner + - name: SKILLHUB_SECURITY_SCANNER_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skill-scanner-enabled + - name: SKILLHUB_SECURITY_SCANNER_URL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skill-scanner-url + - name: SKILLHUB_SECURITY_SCANNER_MODE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skill-scanner-mode + + # Session + - name: SESSION_COOKIE_SECURE + valueFrom: + configMapKeyRef: + 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: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-enabled + - name: BOOTSTRAP_ADMIN_USER_ID + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-user-id + - name: BOOTSTRAP_ADMIN_USERNAME + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-username + - name: BOOTSTRAP_ADMIN_DISPLAY_NAME + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-display-name + - name: BOOTSTRAP_ADMIN_EMAIL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-email + - name: BOOTSTRAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: bootstrap-admin-password + optional: true + + # OAuth2 GitHub (optional) + - name: OAUTH2_GITHUB_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: oauth2-github-client-id + optional: true + - name: OAUTH2_GITHUB_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: oauth2-github-client-secret + optional: true + + {{- if .Values.server.javaOpts }} + - name: JAVA_OPTS + value: {{ .Values.server.javaOpts }} + {{- end }} + + {{- with .Values.server.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + + {{- if and .Values.server.enabled (not .Values.s3.enabled) }} + volumeMounts: + - name: skillhub-storage + mountPath: /var/lib/skillhub/storage + {{- end }} + + resources: + {{- toYaml .Values.server.resources | nindent 12 }} + + startupProbe: + {{- toYaml .Values.server.probes.startup | nindent 12 }} + readinessProbe: + {{- toYaml .Values.server.probes.readiness | nindent 12 }} + livenessProbe: + {{- toYaml .Values.server.probes.liveness | nindent 12 }} + + {{- if and .Values.server.enabled (not .Values.s3.enabled) }} + volumes: + - name: skillhub-storage + persistentVolumeClaim: + claimName: {{ include "skillhub.fullname" . }}-server-data + {{- end }} + {{- with .Values.server.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.server.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.server.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + +{{- end }} diff --git a/charts/skillhub/templates/services.yaml b/charts/skillhub/templates/services.yaml new file mode 100644 index 00000000..94c73e34 --- /dev/null +++ b/charts/skillhub/templates/services.yaml @@ -0,0 +1,61 @@ +{{- /* +SkillHub Service 资源 +- server/web: 使用组件自己的 service.type 配置(共享同一模板) +- scanner: 固定 ClusterIP(仅供内部调用) +*/}} + +{{- range $name := list "server" "web" }} +{{- $component := index $.Values $name }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.service.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "skillhub.fullname" $ }}-{{ $name }} + labels: + {{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }} +spec: + type: {{ $component.service.type }} + {{- if eq $component.service.type "LoadBalancer" }} + {{- if $component.service.loadBalancerIP }} + loadBalancerIP: {{ $component.service.loadBalancerIP }} + {{- end }} + {{- if $component.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml $component.service.loadBalancerSourceRanges | nindent 4 }} + {{- end }} + {{- end }} + ports: + - name: http + port: {{ $component.service.port }} + targetPort: http + {{- if and (eq $component.service.type "NodePort") $component.service.nodePort }} + nodePort: {{ $component.service.nodePort }} + {{- end }} + selector: + {{- include (printf "skillhub.%s.selectorLabels" $name) $ | nindent 4 }} +{{- end }} +{{- end }} + +{{- /* Scanner Service(固定 ClusterIP) */}} +{{- if and .Values.scanner.enabled .Values.scanner.service }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "skillhub.fullname" . }}-scanner + labels: + {{- include "skillhub.scanner.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.scanner.service.port }} + targetPort: http + selector: + {{- include "skillhub.scanner.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml new file mode 100644 index 00000000..443b880f --- /dev/null +++ b/charts/skillhub/templates/validate.yaml @@ -0,0 +1,95 @@ +{{- /* Cross-field validation that JSON Schema cannot express reliably. */ -}} +{{- $absoluteHttpUrlPattern := "^https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#][^[:space:]]*)?$" -}} +{{- 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|oauth2|login/oauth2|\\.well-known)(/|$)" $path.path -}} +{{- fail "ingress.hosts[].paths reserves /api, /oauth2, /login/oauth2 and /.well-known 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 and .Values.s3.endpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.endpoint)) -}} +{{- fail "s3.endpoint must be an absolute HTTP(S) URL" -}} +{{- end -}} +{{- if and .Values.s3.publicEndpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.publicEndpoint)) -}} +{{- fail "s3.publicEndpoint must be an absolute HTTP(S) URL" -}} +{{- 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/templates/web-deployment.yaml b/charts/skillhub/templates/web-deployment.yaml new file mode 100644 index 00000000..bdbb3e75 --- /dev/null +++ b/charts/skillhub/templates/web-deployment.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "skillhub.fullname" . }}-web + labels: + {{- include "skillhub.web.labels" . | nindent 4 }} +spec: + {{- if not .Values.web.autoscaling.enabled }} + replicas: {{ .Values.web.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "skillhub.web.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "skillhub.web.selectorLabels" . | nindent 8 }} + annotations: + checksum/config: {{ toYaml (dict "web" .Values.web "publicBaseUrl" .Values.publicBaseUrl "auth" .Values.auth) | sha256sum }} + {{- range $key, $val := .Values.web.podAnnotations }} + {{ $key }}: {{ $val }} + {{- end }} + spec: + {{- $secrets := .Values.web.imagePullSecrets }} + {{- if $secrets }} + imagePullSecrets: + {{- toYaml $secrets | nindent 8 }} + {{- end }} + containers: + - name: web + image: {{ .Values.web.image.registry | default .Values.images.registry }}/skillhub-web:{{ .Values.web.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + 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 }} + ports: + - containerPort: {{ .Values.web.service.port }} + name: http + resources: + {{- toYaml .Values.web.resources | nindent 12 }} + readinessProbe: + {{- toYaml .Values.web.probes.readiness | nindent 12 }} + livenessProbe: + {{- toYaml .Values.web.probes.liveness | nindent 12 }} + {{- with .Values.web.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.web.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.web.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh new file mode 100755 index 00000000..17b77853 --- /dev/null +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -0,0 +1,254 @@ +#!/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" +grep -Fq 'bitnami/postgresql@sha256:db2312d9b243afa8c3b3f5496e478d17d0dff9791d06f3b93b9567abd86ae92f' "$TMP_DIR/default.yaml" +grep -Fq 'bitnami/postgres-exporter@sha256:53ab72a1b940d7637e91619f1000da9ebef14bc7dad74321a78731d65c79f55b' "$TMP_DIR/default.yaml" +grep -Fq 'bitnami/redis@sha256:08863c2c3f4e051fb6139b38fa223e9c13be5033326a59bead182860d899bf98' "$TMP_DIR/default.yaml" +grep -Fq 'bitnami/redis-exporter@sha256:fb1dae6add1e1104989d086d9407f7d65f58968550aa5fddea20637a758c0773' "$TMP_DIR/default.yaml" +if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/default.yaml"; then + fail "default workloads must not use mutable latest image tags" +fi +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 + --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 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 \ + --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" +grep -A1 -F 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/sentinel.yaml" \ + | grep -Fq 'value: "false"' +render sentinel-full "$CHART_DIR" \ + --set redis.architecture=replication \ + --set redis.sentinel.enabled=true >"$TMP_DIR/sentinel-full.yaml" +grep -Fq 'bitnami/redis-sentinel@sha256:ae75dd69c192a632bdeb21baa6721080be5b12347e52add922036398b47631da' "$TMP_DIR/sentinel-full.yaml" +if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/sentinel-full.yaml"; then + fail "Sentinel workloads must not use mutable latest image tags" +fi + +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" +if grep -Fq 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/external-sentinel.yaml"; then + fail "external Sentinel must preserve Redisson address consistency checks by default" +fi + +render 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 tls "$CHART_DIR" \ + --set ingress.enabled=true \ + --set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \ + --show-only templates/ingress.yaml >"$TMP_DIR/tls-ingress.yaml" +for server_path in /api /oauth2 /login/oauth2 /.well-known; do + grep -Fq -- "- path: $server_path" "$TMP_DIR/tls-ingress.yaml" +done +if [[ $(grep -Fc 'name: tls-skillhub-server' "$TMP_DIR/tls-ingress.yaml") -ne 4 ]]; then + fail "API and OAuth ingress paths must route directly to the SkillHub server" +fi + +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" +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 \ + --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 reserved-oauth-ingress-path \ + --set ingress.enabled=true \ + --set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/oauth2","pathType":"Prefix"}]}]' +assert_rejected reserved-oauth-ingress-child-path \ + --set ingress.enabled=true \ + --set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/login/oauth2/code/github","pathType":"Prefix"}]}]' +assert_rejected invalid-s3-endpoint --set s3.endpoint=s3.amazonaws.com +assert_rejected invalid-s3-public-endpoint --set s3.publicEndpoint=cdn.example.com +assert_rejected invalid-s3-empty-authority --set-string 's3.endpoint=https://?' +assert_rejected invalid-s3-whitespace-authority --set-string 's3.publicEndpoint=https:// ' +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/install-upgrade-smoke.sh b/charts/skillhub/tests/install-upgrade-smoke.sh new file mode 100755 index 00000000..fd461d56 --- /dev/null +++ b/charts/skillhub/tests/install-upgrade-smoke.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +set -euo pipefail + +CHART_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TEST_VALUES="$CHART_DIR/tests/test-values.yaml" +SCENARIO=${HELM_SMOKE_SCENARIO:-default} +NAMESPACE=${HELM_SMOKE_NAMESPACE:-skillhub-helm-smoke-$SCENARIO} +RELEASE=${HELM_SMOKE_RELEASE:-skillhub-smoke} +TIMEOUT=${HELM_SMOKE_TIMEOUT:-15m} +KEEP_ENVIRONMENT=${KEEP_HELM_SMOKE:-false} +TMP_DIR=$(mktemp -d) +PORT_FORWARD_PID="" +OWNS_NAMESPACE=false +HELM_SCENARIO_ARGS=() + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +for command in helm kubectl curl jq sha256sum; do + command -v "$command" >/dev/null 2>&1 || fail "$command is required" +done + +if kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then + fail "namespace $NAMESPACE already exists; choose an unused HELM_SMOKE_NAMESPACE" +fi + +stop_port_forward() { + if [[ -n "$PORT_FORWARD_PID" ]]; then + kill "$PORT_FORWARD_PID" >/dev/null 2>&1 || true + wait "$PORT_FORWARD_PID" >/dev/null 2>&1 || true + PORT_FORWARD_PID="" + fi +} + +cleanup() { + local exit_code=$? + trap - EXIT + stop_port_forward + + if (( exit_code != 0 )) && kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then + echo "Helm smoke failed; collecting non-secret diagnostics" >&2 + helm status "$RELEASE" --namespace "$NAMESPACE" >&2 || true + kubectl get pods,pvc,deployments,statefulsets --namespace "$NAMESPACE" -o wide >&2 || true + kubectl get events --namespace "$NAMESPACE" --sort-by=.lastTimestamp >&2 || true + fi + + if [[ "$KEEP_ENVIRONMENT" != "true" && "$OWNS_NAMESPACE" == "true" ]]; then + helm uninstall "$RELEASE" --namespace "$NAMESPACE" --wait >/dev/null 2>&1 || true + kubectl delete namespace "$NAMESPACE" --wait --timeout=5m >/dev/null 2>&1 || true + fi + + rm -rf "$TMP_DIR" + exit "$exit_code" +} +trap cleanup EXIT + +setup_scenario() { + case "$SCENARIO" in + default) + ;; + sentinel) + HELM_SCENARIO_ARGS+=( + --set redis.architecture=replication + --set redis.sentinel.enabled=true + ) + ;; + s3) + kubectl apply --namespace "$NAMESPACE" -f - <<'YAML' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio +spec: + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + containers: + - name: minio + image: docker.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e + args: + - server + - /data + env: + - name: MINIO_ROOT_USER + value: smoke-access-key + - name: MINIO_ROOT_PASSWORD + value: smoke-secret-key + ports: + - name: api + containerPort: 9000 + readinessProbe: + httpGet: + path: /minio/health/ready + port: api + periodSeconds: 2 +--- +apiVersion: v1 +kind: Service +metadata: + name: minio +spec: + selector: + app: minio + ports: + - name: api + port: 9000 + targetPort: api +YAML + kubectl rollout status deployment/minio \ + --namespace "$NAMESPACE" \ + --timeout=5m + HELM_SCENARIO_ARGS+=( + --set s3.enabled=true + --set-string s3.endpoint=http://minio:9000 + --set-string s3.accessKey=smoke-access-key + --set-string s3.secretKey=smoke-secret-key + --set s3.autoCreateBucket=true + ) + ;; + ingress-tls) + command -v openssl >/dev/null 2>&1 || fail "openssl is required for ingress-tls" + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$TMP_DIR/tls.key" \ + -out "$TMP_DIR/tls.crt" \ + -days 1 \ + -subj /CN=skillhub-smoke.local \ + -addext subjectAltName=DNS:skillhub-smoke.local >/dev/null 2>&1 + kubectl create secret tls skillhub-smoke-tls \ + --namespace "$NAMESPACE" \ + --cert "$TMP_DIR/tls.crt" \ + --key "$TMP_DIR/tls.key" + HELM_SCENARIO_ARGS+=( + --set ingress.enabled=true + --set-json 'ingress.hosts=[{"host":"skillhub-smoke.local","paths":[{"path":"/","pathType":"Prefix"}]}]' + --set-json 'ingress.tls=[{"hosts":["skillhub-smoke.local"],"secretName":"skillhub-smoke-tls"}]' + ) + ;; + *) + fail "unknown HELM_SMOKE_SCENARIO: $SCENARIO" + ;; + esac +} + +assert_scenario_contract() { + case "$SCENARIO" in + default) + ;; + sentinel) + kubectl get deployment "$RELEASE-server" --namespace "$NAMESPACE" -o json \ + | jq -e ' + [.spec.template.spec.containers[] + | select(.name == "server") + | .env[] + | select(.name == "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST") + | .value] == ["false"] + ' >/dev/null \ + || fail "Sentinel scenario did not apply the Kubernetes-only address-check override" + ;; + s3) + local storage_provider + storage_provider=$(kubectl get configmap "$RELEASE-config" \ + --namespace "$NAMESPACE" -o json | jq -r '.data["skillhub-storage-provider"]') + [[ "$storage_provider" == "s3" ]] || fail "S3 scenario did not configure S3 storage" + ;; + ingress-tls) + kubectl get ingress "$RELEASE" --namespace "$NAMESPACE" -o json \ + | jq -e --arg server "$RELEASE-server" ' + .spec.tls[0].secretName == "skillhub-smoke-tls" + and ( + [.spec.rules[].http.paths[] + | select( + .path == "/api" + or .path == "/oauth2" + or .path == "/login/oauth2" + or .path == "/.well-known" + ) + | .backend.service.name] + | length == 4 and all(. == $server) + ) + ' >/dev/null \ + || fail "TLS Ingress does not route every reserved path directly to the server" + local cookie_secure + cookie_secure=$(kubectl get configmap "$RELEASE-config" \ + --namespace "$NAMESPACE" -o json | jq -r '.data["session-cookie-secure"]') + [[ "$cookie_secure" == "true" ]] || fail "TLS Ingress did not enable secure session cookies" + ;; + esac +} + +probe_service() { + local service=$1 + local service_port=$2 + local local_port=$3 + local path=$4 + local expected_status=${5:-200} + local log_file="$TMP_DIR/${service}.port-forward.log" + local status + + stop_port_forward + kubectl port-forward \ + --namespace "$NAMESPACE" \ + "service/$service" \ + "$local_port:$service_port" >"$log_file" 2>&1 & + PORT_FORWARD_PID=$! + + for _ in $(seq 1 60); do + status=$(curl --silent --output /dev/null --write-out '%{http_code}' \ + "http://127.0.0.1:$local_port$path" 2>/dev/null || true) + if [[ "$status" == "$expected_status" ]]; then + stop_port_forward + return 0 + fi + if ! kill -0 "$PORT_FORWARD_PID" >/dev/null 2>&1; then + break + fi + sleep 1 + done + + cat "$log_file" >&2 + fail "$service$path did not return HTTP $expected_status" +} + +snapshot_secrets() { + local output=$1 + : >"$output" + for secret in "$RELEASE-secret" "$RELEASE-postgresql" "$RELEASE-redis"; do + printf '%s ' "$secret" >>"$output" + kubectl get secret "$secret" --namespace "$NAMESPACE" -o json \ + | jq -cS '.data' \ + | sha256sum \ + | awk '{print $1}' >>"$output" + done +} + +snapshot_pvcs() { + local output=$1 + kubectl get pvc --namespace "$NAMESPACE" -o json \ + | jq -r '.items[] | [.metadata.name, .metadata.uid, .spec.volumeName] | @tsv' \ + | sort >"$output" + [[ -s "$output" ]] || fail "Helm install did not create any PVCs" +} + +assert_ready_and_healthy() { + kubectl wait pod \ + --namespace "$NAMESPACE" \ + --all \ + --for=condition=Ready \ + --timeout="$TIMEOUT" + + probe_service "$RELEASE-server" 8080 18081 /actuator/health + probe_service "$RELEASE-web" 80 18080 /nginx-health + probe_service "$RELEASE-web" 80 18080 /api/v1/auth/me 401 + probe_service "$RELEASE-scanner" 8000 18082 /health + + local restarts + restarts=$(kubectl get pods --namespace "$NAMESPACE" -o json \ + | jq '[.items[].status.containerStatuses[]?.restartCount] | add // 0') + [[ "$restarts" == "0" ]] || fail "workloads restarted $restarts time(s)" +} + +helm dependency build "$CHART_DIR" +kubectl create namespace "$NAMESPACE" +OWNS_NAMESPACE=true +setup_scenario + +helm install "$RELEASE" "$CHART_DIR" \ + --namespace "$NAMESPACE" \ + --values "$TEST_VALUES" \ + --set-string fullnameOverride="$RELEASE" \ + --set-string publicBaseUrl=http://skillhub-smoke.local \ + "${HELM_SCENARIO_ARGS[@]}" \ + --wait \ + --timeout "$TIMEOUT" + +assert_ready_and_healthy +assert_scenario_contract +snapshot_secrets "$TMP_DIR/secrets-before" +snapshot_pvcs "$TMP_DIR/pvcs-before" +revision_before=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \ + | jq -r '.[-1].revision') + +helm upgrade "$RELEASE" "$CHART_DIR" \ + --namespace "$NAMESPACE" \ + --reuse-values \ + --set-string publicBaseUrl=https://skillhub-smoke.local \ + --set-string server.podAnnotations.helm-smoke-revision=revision-2 \ + --wait \ + --timeout "$TIMEOUT" + +assert_ready_and_healthy +assert_scenario_contract +snapshot_secrets "$TMP_DIR/secrets-after" +snapshot_pvcs "$TMP_DIR/pvcs-after" +revision_after=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \ + | jq -r '.[-1].revision') + +(( revision_after == revision_before + 1 )) \ + || fail "Helm revision did not advance exactly once" +cmp "$TMP_DIR/secrets-before" "$TMP_DIR/secrets-after" \ + || fail "application or dependency Secret data changed during upgrade" +cmp "$TMP_DIR/pvcs-before" "$TMP_DIR/pvcs-after" \ + || fail "PVC identity or bound volume changed during upgrade" + +public_base_url=$(kubectl get configmap "$RELEASE-config" \ + --namespace "$NAMESPACE" \ + -o json | jq -r '.data["public-base-url"]') +[[ "$public_base_url" == "https://skillhub-smoke.local" ]] \ + || fail "publicBaseUrl was not applied by the upgrade" + +echo "Helm install/upgrade smoke passed for scenario: $SCENARIO" 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..6e6497ad --- /dev/null +++ b/charts/skillhub/values.schema.json @@ -0,0 +1,462 @@ +{ + "$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", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" }, + "publicEndpoint": { "type": "string", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" }, + "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", "podSecurityContext", "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" } + } + }, + "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" } }, + "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 new file mode 100644 index 00000000..e89a7d4c --- /dev/null +++ b/charts/skillhub/values.yaml @@ -0,0 +1,460 @@ +# ============================================================================ +# SkillHub Helm Chart 全局配置 +# ============================================================================ + +# ============================================================================ +# 镜像配置 +# ============================================================================ +images: + registry: ghcr.io/iflytek + tag: "" + pullPolicy: IfNotPresent + +nameOverride: "" +fullnameOverride: "" + +# 浏览器、CLI 和 OAuth 回调访问的公开地址(不带末尾斜杠) +publicBaseUrl: "" +deviceAuthVerificationUri: "" + +auth: + direct: + enabled: true + provider: local + +builtinSkills: + enabled: true + +# ============================================================================ +# Ingress 配置 +# ============================================================================ +ingress: + enabled: false + className: nginx + hosts: + - host: skills.example.com + paths: + - path: / + pathType: Prefix + annotations: {} + tls: [] + certManager: + enabled: false + issuerName: letsencrypt-prod + issuerKind: ClusterIssuer + +# ============================================================================ +# S3 对象存储配置 +# ============================================================================ +s3: + enabled: false + bucket: skillhub-storage + endpoint: "" + publicEndpoint: "" + region: us-east-1 + forcePathStyle: true + disableChunkedEncoding: false + autoCreateBucket: false + presignExpiry: PT10M + accessKey: "" + secretKey: "" + +# ============================================================================ +# Session 配置 +# ============================================================================ +session: + cookieSecure: false + +# ============================================================================ +# Bootstrap 管理员 +# ============================================================================ +bootstrapAdmin: + enabled: true + userId: docker-admin + username: admin + displayName: "Platform Admin" + email: admin@example.com + password: "" + +# ============================================================================ +# Spring Profiles +# ============================================================================ +springProfilesActive: docker + +# ============================================================================ +# Secret 配置 +# ============================================================================ +existingSecret: "" + +secrets: + # 默认禁止随机 Secret;仅在非 GitOps 临时环境中按需启用 + allowAutoGenerated: false + bootstrapAdminPassword: "" + downloadAnonCookieSecret: "" + oauth2GithubClientId: "" + oauth2GithubClientSecret: "" + scannerLlmApiKey: "" + scannerLlmBaseUrl: "" + scannerLlmModel: "" + +# ============================================================================ +# PostgreSQL 配置(Bitnami) +# ============================================================================ +postgresql: + enabled: true + + architecture: standalone + + # Bitnami's current chart defaults to a mutable latest tag. Pin the image + # digest so installs and rollbacks remain reproducible. + image: + digest: "sha256:db2312d9b243afa8c3b3f5496e478d17d0dff9791d06f3b93b9567abd86ae92f" + + auth: + postgresPassword: "" + database: skillhub + username: skillhub + password: "" + + primary: + persistence: + enabled: true + size: 10Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + extraEnvVars: + - name: POSTGRESQL_MAX_CONNECTIONS + value: "500" + podAnnotations: {} + podSecurityContext: + enabled: true + fsGroup: 1001 + containerSecurityContext: + enabled: true + runAsUser: 1001 + livenessProbe: + enabled: true + initialDelaySeconds: 30 + periodSeconds: 20 + readinessProbe: + enabled: true + initialDelaySeconds: 5 + periodSeconds: 10 + + readReplicas: + persistence: + enabled: true + size: 10Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 100m + memory: 256Mi + 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 + image: + digest: "sha256:53ab72a1b940d7637e91619f1000da9ebef14bc7dad74321a78731d65c79f55b" + serviceMonitor: + enabled: false + +externalDatabase: + host: "" + port: 5432 + database: skillhub + username: skillhub + password: "" + jdbcUrl: "" + +# ============================================================================ +# Redis 配置(Bitnami) +# ============================================================================ +redis: + enabled: true + + architecture: standalone + + # Keep the bundled Redis runtime immutable for repeatable upgrades. + image: + digest: "sha256:08863c2c3f4e051fb6139b38fa223e9c13be5033326a59bead182860d899bf98" + + auth: + enabled: true + password: "" + + master: + persistence: + enabled: true + size: 5Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 512Mi + podAnnotations: {} + podSecurityContext: + enabled: true + fsGroup: 1001 + containerSecurityContext: + enabled: true + runAsUser: 1001 + + replica: + persistence: + enabled: true + size: 5Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 512Mi + + sentinel: + enabled: false + masterSet: mymaster + image: + digest: "sha256:ae75dd69c192a632bdeb21baa6721080be5b12347e52add922036398b47631da" + service: + ports: + sentinel: 26379 + containerPorts: + sentinel: 26379 + + metrics: + enabled: true + image: + digest: "sha256:fb1dae6add1e1104989d086d9407f7d65f58968550aa5fddea20637a758c0773" + serviceMonitor: + enabled: false + +externalRedis: + host: "" + port: 6379 + password: "" + sentinel: + enabled: false + masterSet: mymaster + nodes: [] + password: "" + +# ============================================================================ +# Server 配置 +# ============================================================================ +server: + enabled: true + replicaCount: 1 + + image: + registry: "" + tag: "" + + dependencyWait: + image: + registry: docker.io + repository: library/busybox + tag: "1.37" + pullPolicy: IfNotPresent + + service: + enabled: true + type: ClusterIP + port: 8080 + nodePort: "" + loadBalancerIP: "" + loadBalancerSourceRanges: [] + + storage: + # 访问模式:留空时自动判断(单副本 RWO,多副本 RWX),或手动指定 + accessMode: "" + size: 10Gi + storageClassName: "" + + # PVC 挂载会覆盖镜像内目录权限;使用镜像中 app 用户的组 ID 使本地存储可写 + podSecurityContext: + fsGroup: 101 + fsGroupChangePolicy: OnRootMismatch + + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1000m + memory: 1Gi + + javaOpts: "" + extraEnv: [] + podAnnotations: {} + imagePullSecrets: [] + nodeSelector: {} + tolerations: [] + affinity: {} + + probes: + startup: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 30 + readiness: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + liveness: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 30 + periodSeconds: 15 + + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + podDisruptionBudget: + enabled: false + minAvailable: 1 + +# ============================================================================ +# Web 配置 +# ============================================================================ +web: + replicaCount: 1 + image: + registry: "" + tag: "" + + service: + enabled: true + type: ClusterIP + port: 80 + nodePort: "" + loadBalancerIP: "" + loadBalancerSourceRanges: [] + + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + + extraEnv: [] + podAnnotations: {} + imagePullSecrets: [] + nodeSelector: {} + tolerations: [] + affinity: {} + + probes: + readiness: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + liveness: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + podDisruptionBudget: + enabled: false + minAvailable: 1 + +# ============================================================================ +# Scanner 配置 +# ============================================================================ +scanner: + enabled: true + replicaCount: 1 + image: + registry: "" + tag: "" + + service: + port: 8000 + + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + extraEnv: [] + podAnnotations: {} + imagePullSecrets: [] + nodeSelector: {} + tolerations: [] + affinity: {} + + probes: + readiness: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + liveness: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + podDisruptionBudget: + enabled: false + minAvailable: 1 diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index ec1f70ce..a13c7c62 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -24,6 +24,7 @@ assert_pr_workflow_hardened() { assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml" assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-helm-chart.yml" assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml" assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW" assert_pr_workflow_hardened "$SECURITY_WORKFLOW" @@ -54,8 +55,12 @@ grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR CLI workflow changes" grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR E2E workflow changes" +grep -Fq '.github/workflows/pr-helm-chart.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR Helm Chart workflow changes" grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR Tests workflow changes" +grep -Fq '.github/workflows/publish-chart.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when Chart publish workflow changes" grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when Python source changes" grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \ diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java index 1e15eace..9f4de0b3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java @@ -5,6 +5,7 @@ import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.redisson.config.SentinelServersConfig; import org.redisson.config.SingleServerConfig; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.data.redis.RedisProperties; import org.springframework.context.annotation.Bean; @@ -18,14 +19,20 @@ import java.util.List; public class RedissonConfig { @Bean(destroyMethod = "shutdown") - public RedissonClient redissonClient(RedisProperties redisProperties) { - return Redisson.create(createConfig(redisProperties)); + public RedissonClient redissonClient( + RedisProperties redisProperties, + @Value("${skillhub.redis.sentinel.check-sentinels-list:true}") boolean checkSentinelsList) { + return Redisson.create(createConfig(redisProperties, checkSentinelsList)); } static Config createConfig(RedisProperties redisProperties) { + return createConfig(redisProperties, true); + } + + static Config createConfig(RedisProperties redisProperties, boolean checkSentinelsList) { Config config = new Config(); if (hasSentinelConfiguration(redisProperties)) { - configureSentinelServers(config, redisProperties); + configureSentinelServers(config, redisProperties, checkSentinelsList); return config; } @@ -38,10 +45,14 @@ public class RedissonConfig { return config; } - private static void configureSentinelServers(Config config, RedisProperties redisProperties) { + private static void configureSentinelServers( + Config config, + RedisProperties redisProperties, + boolean checkSentinelsList) { SentinelServersConfig sentinelServersConfig = config.useSentinelServers() .setMasterName(redisProperties.getSentinel().getMaster()) - .setDatabase(redisProperties.getDatabase()); + .setDatabase(redisProperties.getDatabase()) + .setCheckSentinelsList(checkSentinelsList); List nodes = redisProperties.getSentinel().getNodes(); nodes.stream() .map(String::trim) @@ -50,6 +61,9 @@ public class RedissonConfig { .forEach(sentinelServersConfig::addSentinelAddress); applySharedSettings(sentinelServersConfig, redisProperties); + if (StringUtils.hasText(redisProperties.getSentinel().getPassword())) { + sentinelServersConfig.setSentinelPassword(redisProperties.getSentinel().getPassword()); + } } private static boolean hasSentinelConfiguration(RedisProperties redisProperties) { diff --git a/server/skillhub-app/src/main/resources/application-redis-sentinel.yml b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml new file mode 100644 index 00000000..e5caff3e --- /dev/null +++ b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml @@ -0,0 +1,8 @@ +spring: + data: + redis: + 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:}} + password: ${SPRING_DATA_REDIS_SENTINEL_PASSWORD:${REDIS_SENTINEL_PASSWORD:}} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 286ff05f..7d5133c2 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -95,6 +95,9 @@ spring: skillhub: builtin-skills: enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} + redis: + sentinel: + check-sentinels-list: ${SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} 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); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java index 4ebb502d..9f290ede 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java @@ -105,6 +105,67 @@ class RedissonConfigTest { assertThat(sentinelConfig.getSentinelAddresses()).containsExactly("rediss://redis-sentinel-1:26379"); } + @Test + void createConfig_appliesSentinelPasswordWhenSet() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + sentinel.setPassword("sentinel-secret"); + properties.setSentinel(sentinel); + properties.setPassword("master-secret"); + + Config config = RedissonConfig.createConfig(properties); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + + assertThat(sentinelConfig.getSentinelPassword()).isEqualTo("sentinel-secret"); + assertThat(sentinelConfig.getPassword()).isEqualTo("master-secret"); + } + + @Test + void createConfig_keepsSentinelMembershipCheckEnabledByDefault() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + properties.setSentinel(sentinel); + + Config config = RedissonConfig.createConfig(properties); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + + assertThat(sentinelConfig.isCheckSentinelsList()).isTrue(); + } + + @Test + void createConfig_canDisableSentinelMembershipCheckForKubernetes() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + properties.setSentinel(sentinel); + + Config config = RedissonConfig.createConfig(properties, false); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + + assertThat(sentinelConfig.isCheckSentinelsList()).isFalse(); + } + + @Test + void createConfig_doesNotSetSentinelPasswordWhenEmpty() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + properties.setSentinel(sentinel); + properties.setPassword("master-secret"); + + Config config = RedissonConfig.createConfig(properties); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + + assertThat(sentinelConfig.getPassword()).isEqualTo("master-secret"); + assertThat(sentinelConfig.getSentinelPassword()).isNull(); + } + private SentinelServersConfig sentinelConfig(Config config) throws Exception { Method method = Config.class.getDeclaredMethod("getSentinelServersConfig"); method.setAccessible(true);