跳转至

集群初始化重构: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-namespaceimageCredentials 模板

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. 依赖引入

go get helm.sh/helm/v3@v3.17.3

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.goensureNamespace Helm --create-namespace 已覆盖
cluster_init_executor.godeployOperatorManifest Helm install 已覆盖
cluster_init_executor.gowaitForOperatorReady Helm --wait 已覆盖
cluster_init_executor.goensureImagePullSecret(operator namespace 用途) Helm imageCredentials 已覆盖

保留 ensureImagePullSecret 的逻辑但重命名为 ensureTenantImageCredentials,目标 namespace 改为 tfr-image-credentials

升级 Operator 的新流程

重构后,升级集群中的 Operator 版本只需:

  1. 修改环境变量 OPERATOR_CHART_VERSIONOPERATOR_IMAGE_TAG
  2. 重启 TFRSManager(或通过 API 触发重新初始化)
  3. 对已有集群:可新增 reinitialize API 端点,仅执行 Step 2(Helm upgrade)

无需重新构建 TFRSManager 镜像。

错误处理与回滚

Helm 安装失败

action.Install 默认 --atomic=false。如果安装过程中某个资源失败: - Helm 会将 release 标记为 failed - 下次执行 InstallOrUpgradeOperator 时,history 查询返回 failed release - 此时需要先 helm uninstallinstall,或使用 upgrade --install 模式

建议:设置 client.Atomic = true,安装失败时自动回滚清理,保证幂等性。

网络问题

OCI 仓库不可达时,LocateChart 会失败。错误信息应包含仓库 URL 和版本号,方便排查。

超时

Helm --wait --timeout 5m 会在超时后返回错误。结合 --atomic,超时也会触发回滚。

测试计划

单元测试

  • HelmInstaller 的 values 构建逻辑(mock registry client)
  • restConfigGetter 接口实现的正确性
  • 配置字段默认值和环境变量解析

集成测试(beta 环境)

  1. 删除现有集群
  2. 确认 Infisical /registry/ 凭据就绪
  3. 创建新集群,观察 5 步初始化流程
  4. 验证 K8s 集群中:
  5. tfrs-operator-system namespace 存在
  6. tfrs-operator-controller-manager Deployment Ready
  7. cnb-registry-secret Secret 存在(operator namespace)
  8. tfr-image-credentials namespace 中 cnb-registry-secret 存在
  9. TFRCluster CR 已创建
  10. 删除集群后重新创建,验证 Helm upgrade 幂等性

时间线建议

阶段 工作 预估
P0 引入 Helm SDK + HelmInstaller + RESTClientGetter 核心实现
P0 重写 Execute 方法(5 步流程) 核心实现
P0 删除 manifest 相关代码 清理
P0 配置字段新增 + 环境变量 配置
P1 前端步骤名称适配(如有硬编码) 联调
P1 beta 环境集成测试 验收
P2 新增 reinitialize API(仅重新执行 Helm upgrade) 增强