集群初始化重构:Helm SDK 替代 Embed Manifest¶
背景¶
当前集群初始化 Step 4「部署 Operator Manifest」通过 go:embed manifests/install.yaml 嵌入 Operator 安装清单,再用 K8s dynamic client 逐一 Apply。
问题:
1. install.yaml 是 placeholder(仅含 1 个 Namespace),完整清单有 33438 行、39 个对象(14 CRD + 15 ClusterRole + Deployment + RBAC + Webhook 等),跨项目同步维护成本极高
2. Operator 版本与 TFRSManager 构建强耦合 — 升级 Operator 必须重新构建 Manager 镜像
3. Step 2(创建 Namespace)和 Step 3(创建 ImagePullSecret)的工作与 Helm chart 重复 — chart 已内置 --create-namespace 和 imageCredentials 模板
tfrs-operator 已提供 Helm chart(OCI 仓库 oci://helm.cnb.cool/turingfocus/tfrs-operator/tfrs-operator),本方案改用 Helm Go SDK 在代码中执行 helm install,解耦版本依赖并消除重复逻辑。
涉及文件¶
| 文件 | 变更类型 | 说明 |
|---|---|---|
internal/shared/config/config.go |
修改 | ClusterInitConfig 新增 Helm 配置字段 |
internal/admin/service/cluster_init_executor.go |
重写 | 替换 Step 2-4 实现 |
internal/admin/service/cluster_init_manifest.go |
删除 | 不再需要 embed 和 YAML 解析 |
internal/admin/service/manifests/install.yaml |
删除 | 不再需要 |
go.mod |
修改 | 新增 helm.sh/helm/v3 依赖 |
新的初始化流程¶
步骤编排(7 步 → 5 步)¶
Phase 1: 前置检查
Step 1: 检查集群连通性 ← 不变
Phase 2: 基础设施部署(原 Step 2-4 合并 + 新增)
Step 2: Helm Install Operator ← 新:替代原 Step 2+3+4
Step 3: 创建租户镜像凭证命名空间 ← 新:为 TFRTenant 准备 image-credentials
Phase 3: 业务资源
Step 4: 创建 TFRCluster CR ← 原 Step 6,不变
Step 5: 验证 TFRCluster ← 原 Step 7,不变
为什么从 7 步缩减到 5 步?
原 Step 2(创建 Namespace)、Step 3(创建 ImagePullSecret)、Step 4(Apply Manifest)、Step 5(等待 Ready)中,前三步的工作被 Helm chart 完全覆盖。 Helm install 本身是同步阻塞的(chart 不含 hooks),install 返回即表示所有资源已提交。 等待 Operator Ready 仍然需要,但合并进 Step 2 作为 install 后的 readiness 轮询。
Step 2 详细设计:Helm Install Operator¶
helm install tfrs-operator \
oci://helm.cnb.cool/turingfocus/tfrs-operator/tfrs-operator \
--version <OPERATOR_CHART_VERSION> \
--namespace tfrs-operator-system \
--create-namespace \
--set image.tag=<OPERATOR_IMAGE_TAG> \
--set 'imageCredentials[0].name=cnb-registry-secret' \
--set 'imageCredentials[0].registry=docker.cnb.cool' \
--set 'imageCredentials[0].username=<from Infisical>' \
--set 'imageCredentials[0].password=<from Infisical>' \
--wait \
--timeout 5m
等效 Go 代码见「实现细节」一节。
Step 3 详细设计:创建租户镜像凭证命名空间¶
Operator 部署完成后,还需要为 TFRTenant 创建镜像拉取凭证(存放于独立命名空间,由 Operator 复制到各租户 namespace)。
这一步复用当前 ensureImagePullSecret 的逻辑,但目标 namespace 改为 tfr-image-credentials(由 CreateClusterRequest.TenantConfig.ImagePullSecrets[].SourceNamespace 指定)。
func (e *ClusterInitExecutor) ensureTenantImageCredentials(ctx context.Context, k8sClient *client.K8sClient) error {
// 1. 确保 tfr-image-credentials namespace 存在
e.ensureNamespace(ctx, k8sClient, "tfr-image-credentials")
// 2. 创建/更新 cnb-registry-secret(复用 Infisical 凭证)
// 与当前 ensureImagePullSecret 相同,仅 namespace 不同
}
配置变更¶
ClusterInitConfig 新增字段¶
type ClusterInitConfig struct {
// --- 保留字段 ---
OperatorNamespace string // 默认 "tfrs-operator-system"
OperatorImage string // 默认镜像(仅作为 image.tag 的 fallback)
VMRemoteWriteURL string
VMRemoteWriteUser string
InfisicalSecretPath string // "/metrics/"
InfisicalSecretName string // "REMOTE_WRITE_PASSWORD"
InfisicalRegistryPath string // "/registry/"
DefaultClusterType string
OperatorReadyTimeout time.Duration // Helm --timeout 使用此值
TFRClusterReadyTimeout time.Duration
MgmtNamespace string
MgmtStorageClass string
MgmtDefaultStorageSize string
// --- 新增字段 ---
OperatorHelmRepoURL string // OCI 仓库地址,默认 "oci://helm.cnb.cool/turingfocus/tfrs-operator/tfrs-operator"
OperatorChartVersion string // Chart 版本,默认 "0.5.0-dev7"(从 Chart.yaml 当前版本)
OperatorImageTag string // 镜像 tag,默认从 OperatorImage 解析;覆盖 chart appVersion
OperatorReleaseName string // Helm release 名称,默认 "tfrs-operator"
HelmStorageDriver string // Helm 存储驱动,默认 "secret"(可选 "configmap")
}
环境变量映射¶
| 环境变量 | 字段 | 默认值 |
|---|---|---|
OPERATOR_HELM_REPO_URL |
OperatorHelmRepoURL | oci://helm.cnb.cool/turingfocus/tfrs-operator/tfrs-operator |
OPERATOR_CHART_VERSION |
OperatorChartVersion | 0.5.0-dev7 |
OPERATOR_IMAGE_TAG |
OperatorImageTag | 从 CLUSTER_INIT_OPERATOR_IMAGE 解析 tag 部分 |
OPERATOR_RELEASE_NAME |
OperatorReleaseName | tfrs-operator |
HELM_STORAGE_DRIVER |
HelmStorageDriver | secret |
实现细节¶
1. 依赖引入¶
Helm v3 SDK 与当前 k8s.io 版本(v0.33.4)兼容。注意 Helm SDK 会引入较多间接依赖,但不增加运行时外部依赖(不需要 helm CLI 二进制)。
2. Helm 操作封装¶
新建文件 internal/admin/service/cluster_init_helm.go:
package service
import (
"context"
"fmt"
"time"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/registry"
"k8s.io/client-go/rest"
)
// HelmInstaller 封装 Helm SDK 操作
type HelmInstaller struct {
config *ClusterInitConfig
restConfig *rest.Config
}
func NewHelmInstaller(config *ClusterInitConfig, restConfig *rest.Config) *HelmInstaller {
return &HelmInstaller{config: config, restConfig: restConfig}
}
// InstallOrUpgradeOperator 安装或升级 tfrs-operator
//
// 幂等:首次调用执行 install,已存在时执行 upgrade。
// 使用 OCI 仓库拉取 chart,无需本地文件。
func (h *HelmInstaller) InstallOrUpgradeOperator(ctx context.Context, username, password string) error {
namespace := h.config.OperatorNamespace
// 1. 构建 Helm action configuration
// 关键:使用目标集群的 rest.Config,而非本地 kubeconfig
actionConfig := new(action.Configuration)
err := actionConfig.Init(
newRESTClientGetter(h.restConfig, namespace),
namespace,
h.config.HelmStorageDriver, // "secret"
func(format string, v ...interface{}) {
// Helm 日志接入 zap logger
},
)
if err != nil {
return fmt.Errorf("初始化 Helm action 失败: %w", err)
}
// 2. 从 OCI 仓库拉取 chart
registryClient, err := registry.NewClient()
if err != nil {
return fmt.Errorf("创建 OCI registry client 失败: %w", err)
}
actionConfig.RegistryClient = registryClient
chartRef := h.config.OperatorHelmRepoURL
chartVersion := h.config.OperatorChartVersion
// 3. 构建 values
vals := map[string]interface{}{
"image": map[string]interface{}{
"tag": h.config.OperatorImageTag,
},
"imageCredentials": []map[string]interface{}{
{
"name": "cnb-registry-secret",
"registry": "docker.cnb.cool",
"username": username,
"password": password,
},
},
}
// 4. 检查 release 是否已存在 → 决定 install 还是 upgrade
histClient := action.NewHistory(actionConfig)
histClient.Max = 1
_, err = histClient.Run(h.config.OperatorReleaseName)
if err != nil {
// Release 不存在 → install
return h.install(actionConfig, chartRef, chartVersion, namespace, vals)
}
// Release 已存在 → upgrade
return h.upgrade(actionConfig, chartRef, chartVersion, namespace, vals)
}
func (h *HelmInstaller) install(actionConfig *action.Configuration, chartRef, version, namespace string, vals map[string]interface{}) error {
client := action.NewInstall(actionConfig)
client.ReleaseName = h.config.OperatorReleaseName
client.Namespace = namespace
client.CreateNamespace = true
client.Wait = true
client.Timeout = h.config.OperatorReadyTimeout // 5 分钟
client.Version = version
// Pull + Load chart
cp, err := client.ChartPathOptions.LocateChart(chartRef, cli.New())
if err != nil {
return fmt.Errorf("定位 chart 失败: %w", err)
}
chartObj, err := loader.Load(cp)
if err != nil {
return fmt.Errorf("加载 chart 失败: %w", err)
}
_, err = client.RunWithContext(ctx, chartObj, vals)
if err != nil {
return fmt.Errorf("Helm install 失败: %w", err)
}
return nil
}
func (h *HelmInstaller) upgrade(actionConfig *action.Configuration, chartRef, version, namespace string, vals map[string]interface{}) error {
client := action.NewUpgrade(actionConfig)
client.Namespace = namespace
client.Wait = true
client.Timeout = h.config.OperatorReadyTimeout
client.Version = version
cp, err := client.ChartPathOptions.LocateChart(chartRef, cli.New())
if err != nil {
return fmt.Errorf("定位 chart 失败: %w", err)
}
chartObj, err := loader.Load(cp)
if err != nil {
return fmt.Errorf("加载 chart 失败: %w", err)
}
_, err = client.RunWithContext(ctx, h.config.OperatorReleaseName, chartObj, vals)
if err != nil {
return fmt.Errorf("Helm upgrade 失败: %w", err)
}
return nil
}
3. RESTClientGetter 适配¶
Helm SDK 需要 genericclioptions.RESTClientGetter 接口。当前场景中 kubeconfig 是从 Infisical 动态获取的 []byte,不是本地文件。需要实现一个基于 rest.Config 的适配器:
// internal/admin/service/cluster_init_helm_restclient.go
package service
import (
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
)
// restConfigGetter 实现 genericclioptions.RESTClientGetter
// 用于将已有的 rest.Config 传递给 Helm SDK
type restConfigGetter struct {
restConfig *rest.Config
namespace string
}
func newRESTClientGetter(config *rest.Config, namespace string) *restConfigGetter {
return &restConfigGetter{restConfig: config, namespace: namespace}
}
func (r *restConfigGetter) ToRESTConfig() (*rest.Config, error) {
return r.restConfig, nil
}
func (r *restConfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
config, _ := r.ToRESTConfig()
dc, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, err
}
return memory.NewMemCacheClient(dc), nil
}
func (r *restConfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
dc, err := r.ToDiscoveryClient()
if err != nil {
return nil, err
}
mapper := restmapper.NewDeferredDiscoveryRESTMapper(dc)
return mapper, nil
}
func (r *restConfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return clientcmd.NewDefaultClientConfig(
clientcmd.Config{}, &clientcmd.ConfigOverrides{
Context: clientcmd.Context{Namespace: r.namespace},
},
)
}
4. 重写 Execute 方法¶
func (e *ClusterInitExecutor) Execute(ctx context.Context, task *models.Task, progressCallback func(taskID uint, event *task_manager.ProgressEvent) error) (interface{}, error) {
// ... 参数解析同现有代码 ...
const totalSteps = 5
// Step 1: 检查集群连通性(不变)
emitStep(1, "检查集群连通性", "running")
k8sClient, err := e.getK8sClient(ctx, params.ClusterID)
// ... 同现有代码 ...
// Step 2: Helm Install Operator(新)
emitStep(2, "部署 Operator", "running")
username, err := e.infisicalClient.GetSecret("CNB_REGISTRY_USERNAME", e.initConfig.InfisicalRegistryPath)
if err != nil { /* ... */ }
password, err := e.infisicalClient.GetSecret("CNB_REGISTRY_PASSWORD", e.initConfig.InfisicalRegistryPath)
if err != nil { /* ... */ }
helmInstaller := NewHelmInstaller(e.initConfig, k8sClient.RestConfig)
err = helmInstaller.InstallOrUpgradeOperator(ctx, username, password)
if err != nil { /* ... */ }
emitStep(2, "部署 Operator", "completed")
// 注意:Helm --wait 已确保 Deployment ready,无需额外轮询
// Step 3: 创建租户镜像凭证(新)
emitStep(3, "创建租户镜像凭证", "running")
err = e.ensureTenantImageCredentials(ctx, k8sClient, username, password)
if err != nil { /* ... */ }
emitStep(3, "创建租户镜像凭证", "completed")
// Step 4: 创建 TFRCluster CR(不变)
emitStep(4, "创建 TFRCluster CR", "running")
err = e.createTFRCluster(ctx, k8sClient, params.ClusterID, e.initConfig.OperatorNamespace, params.SpecOverride)
// ...
// Step 5: 验证 TFRCluster(不变)
emitStep(5, "验证 TFRCluster", "running")
err = e.verifyTFRCluster(ctx, k8sClient, params.ClusterID, e.initConfig.OperatorNamespace)
// ...
return result, nil
}
5. 前端适配¶
clusterInitTotalSteps 从 7 改为 5,前端初始化进度组件需要同步调整步骤名称:
| 步骤 | 旧名称 | 新名称 |
|---|---|---|
| 1 | 检查集群连通性 | 检查集群连通性 |
| 2 | 创建 Operator 命名空间 | 部署 Operator |
| 3 | 创建镜像拉取凭证 | 创建租户镜像凭证 |
| 4 | 部署 Operator Manifest | 创建 TFRCluster CR |
| 5 | 等待 Operator Ready | 验证 TFRCluster |
| 6 | 创建 TFRCluster CR | (删除) |
| 7 | 验证 TFRCluster | (删除) |
前端步骤名称从后端 streaming 事件的 name 字段获取,理论上自适应。但进度百分比计算(step / totalSteps * 100)需要确认前端是否硬编码了 7。
可删除的代码¶
| 文件 / 代码 | 说明 |
|---|---|
manifests/install.yaml |
整个文件删除 |
cluster_init_manifest.go |
整个文件删除(ParseMultiDocYAML、ReplaceOperatorImage、replaceContainerImage) |
cluster_init_executor.go 中 ensureNamespace |
Helm --create-namespace 已覆盖 |
cluster_init_executor.go 中 deployOperatorManifest |
Helm install 已覆盖 |
cluster_init_executor.go 中 waitForOperatorReady |
Helm --wait 已覆盖 |
cluster_init_executor.go 中 ensureImagePullSecret(operator namespace 用途) |
Helm imageCredentials 已覆盖 |
保留 ensureImagePullSecret 的逻辑但重命名为 ensureTenantImageCredentials,目标 namespace 改为 tfr-image-credentials。
升级 Operator 的新流程¶
重构后,升级集群中的 Operator 版本只需:
- 修改环境变量
OPERATOR_CHART_VERSION和OPERATOR_IMAGE_TAG - 重启 TFRSManager(或通过 API 触发重新初始化)
- 对已有集群:可新增
reinitializeAPI 端点,仅执行 Step 2(Helm upgrade)
无需重新构建 TFRSManager 镜像。
错误处理与回滚¶
Helm 安装失败¶
action.Install 默认 --atomic=false。如果安装过程中某个资源失败:
- Helm 会将 release 标记为 failed
- 下次执行 InstallOrUpgradeOperator 时,history 查询返回 failed release
- 此时需要先 helm uninstall 再 install,或使用 upgrade --install 模式
建议:设置 client.Atomic = true,安装失败时自动回滚清理,保证幂等性。
网络问题¶
OCI 仓库不可达时,LocateChart 会失败。错误信息应包含仓库 URL 和版本号,方便排查。
超时¶
Helm --wait --timeout 5m 会在超时后返回错误。结合 --atomic,超时也会触发回滚。
测试计划¶
单元测试¶
HelmInstaller的 values 构建逻辑(mock registry client)restConfigGetter接口实现的正确性- 配置字段默认值和环境变量解析
集成测试(beta 环境)¶
- 删除现有集群
- 确认 Infisical
/registry/凭据就绪 - 创建新集群,观察 5 步初始化流程
- 验证 K8s 集群中:
tfrs-operator-systemnamespace 存在tfrs-operator-controller-managerDeployment Readycnb-registry-secretSecret 存在(operator namespace)tfr-image-credentialsnamespace 中cnb-registry-secret存在- TFRCluster CR 已创建
- 删除集群后重新创建,验证 Helm upgrade 幂等性
时间线建议¶
| 阶段 | 工作 | 预估 |
|---|---|---|
| P0 | 引入 Helm SDK + HelmInstaller + RESTClientGetter | 核心实现 |
| P0 | 重写 Execute 方法(5 步流程) | 核心实现 |
| P0 | 删除 manifest 相关代码 | 清理 |
| P0 | 配置字段新增 + 环境变量 | 配置 |
| P1 | 前端步骤名称适配(如有硬编码) | 联调 |
| P1 | beta 环境集成测试 | 验收 |
| P2 | 新增 reinitialize API(仅重新执行 Helm upgrade) |
增强 |