# 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 使用
`<publicBaseUrl>/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 Cluster 由云服务或运维系统提供，Chart 只负责注入连接配置，不创建
Cluster，也不将其计入上述内置架构运行时验证范围。应用侧应另行验证 Spring
Data、Spring Session 与 Redisson Stream 链路。

### 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"
```

### 外部 Redis Cluster

Chart 不创建内置 Redis Cluster。生产环境的 Cluster 由云 Redis 或独立运维系统
提供，SkillHub 通过标准 Spring Boot 配置连接。至少配置一个 seed 节点，且
Cluster 通告的所有节点地址都必须能从 Server Pod 访问：

```yaml
redis:
  enabled: false

externalRedis:
  username: skillhub
  tls:
    enabled: true
  connectTimeout: 5s
  timeout: 3s
  clientName: skillhub-server
  cluster:
    enabled: true
    nodes:
      - redis-0.example.com:6379
      - redis-1.example.com:6379
      - redis-2.example.com:6379
    maxRedirects: 5
```

Redis Cluster 只支持数据库 `0`。`maxRedirects` 交给 Spring Data/Lettuce 处理；
Redisson 使用同一节点、ACL、TLS 与超时配置并自行处理 Cluster 路由。密码建议
通过 `existingSecret` 的 `redis-password` 提供：

```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
  -f values-production.yaml \
  --set redis.enabled=false \
  --set existingSecret=skillhub-production-secret \
  --set externalRedis.cluster.enabled=true \
  --set-json 'externalRedis.cluster.nodes=["redis-0.example.com:6379","redis-1.example.com:6379","redis-2.example.com:6379"]'
```

`externalRedis.sentinel.enabled` 与 `externalRedis.cluster.enabled` 互斥；内置
`redis.enabled=true` 时也不能启用外部 Cluster。

### 存储配置

| 参数 | 描述 | 默认值 |
|------|------|--------|
| `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 |
