diff --git a/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/index.ts b/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/index.ts index 7fe79fa72..a521132a2 100644 --- a/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/index.ts @@ -6,3 +6,4 @@ export * from './plugin-deploy-to-live.js' export * from './plugin-deploy-to-dcdn.js' export * from './plugin-deploy-to-vod.js' export * from './plugin-deploy-to-tos.js' +export * from './plugin-deploy-to-vke.js' diff --git a/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/plugin-deploy-to-vke.ts b/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/plugin-deploy-to-vke.ts new file mode 100644 index 000000000..680066cbc --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/plugin-deploy-to-vke.ts @@ -0,0 +1,356 @@ +import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline"; +import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib"; +import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert"; +import { VolcengineAccess } from "../access.js"; +import { VolcengineClient } from "../ve-client.js"; +import { utils } from "@certd/basic"; + +const regionOptions = [ + { label: "北京", value: "cn-beijing" }, + { label: "上海", value: "cn-shanghai" }, + { label: "广州", value: "cn-guangzhou" }, + { label: "香港", value: "cn-hongkong" }, + { label: "柔佛", value: "ap-southeast-1" }, + { label: "雅加达", value: "ap-southeast-3" } +]; + +@IsTaskPlugin({ + name: "VolcengineDeployToVKE", + title: "火山引擎-替换VKE证书", + icon: "svg:icon-volcengine", + group: pluginGroups.volcengine.key, + desc: "替换火山引擎VKE集群中的TLS Secret证书", + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed + } + } +}) +export class VolcengineDeployToVKE extends AbstractTaskPlugin { + @TaskInput({ + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "output-selector", + from: [...CertApplyPluginNames] + }, + required: true + }) + cert!: CertInfo; + + @TaskInput(createCertDomainGetterInputDefine({ props: { required: false } })) + certDomains!: string[]; + + @TaskInput({ + title: "Access授权", + helper: "火山引擎AccessKeyId、AccessKeySecret", + component: { + name: "access-selector", + type: "volcengine" + }, + required: true + }) + accessId!: string; + + @TaskInput({ + title: "Region", + helper: "VKE集群所在地域", + component: { + name: "a-select", + options: regionOptions + }, + value: "cn-beijing", + required: true + }) + regionId!: string; + + @TaskInput( + createRemoteSelectInputDefine({ + title: "VKE集群", + helper: "选择要替换证书的VKE集群,也可以手动输入集群ID", + action: VolcengineDeployToVKE.prototype.onGetClusterList.name, + watches: ["accessId", "regionId"], + required: true + }) + ) + clusterId!: string; + + @TaskInput({ + title: "Kubeconfig类型", + helper: "Public需要集群API Server已开启公网访问;Private需要Certd能访问集群私网地址", + component: { + name: "a-select", + options: [ + { label: "公网", value: "Public" }, + { label: "私网", value: "Private" }, + { label: "集群内", value: "TargetCluster" } + ] + }, + value: "Public", + required: true + }) + kubeconfigType!: "Public" | "Private" | "TargetCluster"; + + @TaskInput({ + title: "命名空间", + value: "default", + component: { + placeholder: "命名空间" + }, + required: true + }) + namespace!: string; + + @TaskInput({ + title: "替换方式", + helper: "按Ingress会自动读取spec.tls[].secretName;按Secret需要手动填写Secret名称", + component: { + name: "a-select", + options: [ + { label: "按Ingress替换", value: "ingress" }, + { label: "按Secret替换", value: "secret" } + ] + }, + value: "ingress", + required: true + }) + targetType!: "ingress" | "secret"; + + @TaskInput({ + title: "IngressName", + required: true, + helper: "根据Ingress名称查找TLS Secret并替换", + mergeScript: ` + return { + show: ctx.compute(({form}) => form.targetType === 'ingress'), + required: ctx.compute(({form}) => form.targetType === 'ingress') + } + ` + }) + ingressName!: string; + + @TaskInput({ + title: "Secret名称", + required: true, + helper: "存储TLS证书的Secret名称,可填写多个", + component: { + name: "a-select", + vModel: "value", + mode: "tags", + open: false + }, + mergeScript: ` + return { + show: ctx.compute(({form}) => form.targetType === 'secret'), + required: ctx.compute(({form}) => form.targetType === 'secret') + } + ` + }) + secretName!: string | string[]; + + @TaskInput({ + title: "Secret自动创建", + helper: "如果Secret不存在,则创建kubernetes.io/tls类型Secret", + value: false, + component: { + name: "a-switch", + vModel: "checked" + } + }) + createOnNotFound!: boolean; + + @TaskInput({ + title: "忽略证书校验", + helper: "连接Kubernetes API Server时跳过TLS校验", + value: false, + component: { + name: "a-switch", + vModel: "checked" + } + }) + skipTLSVerify!: boolean; + + K8sClient: any; + + async onInstance() { + const sdk = await import("@certd/lib-k8s"); + this.K8sClient = sdk.K8sClient; + } + + async execute(): Promise { + this.logger.info("开始替换火山引擎VKE证书"); + const access = await this.getAccess(this.accessId); + const vkeService = await this.getVkeService(access); + const kubeconfigId = await this.createKubeconfig(vkeService); + + try { + const kubeconfig = await this.getKubeconfig(vkeService, kubeconfigId); + const k8sClient = new this.K8sClient({ + kubeConfigStr: kubeconfig, + logger: this.logger, + skipTLSVerify: this.skipTLSVerify + }); + const secretNames = await this.getTargetSecretNames(k8sClient); + await this.patchCertSecret({ cert: this.cert, k8sClient, secretNames }); + } catch (e) { + if (e.response?.body) { + throw new Error(JSON.stringify(e.response.body)); + } + throw e; + } finally { + await this.deleteKubeconfig(vkeService, kubeconfigId); + } + + await utils.sleep(5000); + this.logger.info("VKE证书替换完成"); + } + + private async getVkeService(access: VolcengineAccess) { + const client = new VolcengineClient({ + logger: this.logger, + access, + http: this.http + }); + return await client.getVkeService({ region: this.regionId }); + } + + private async createKubeconfig(vkeService: any) { + const res = await vkeService.request({ + action: "CreateKubeconfig", + method: "POST", + body: { + ClusterId: this.clusterId, + Type: this.kubeconfigType, + ValidDuration: 3600 + } + }); + const kubeconfigId = res.Result?.Id || res.Id; + if (!kubeconfigId) { + throw new Error(`生成VKE Kubeconfig失败:${JSON.stringify(res)}`); + } + this.logger.info(`已生成临时Kubeconfig:${kubeconfigId}`); + return kubeconfigId; + } + + private async getKubeconfig(vkeService: any, kubeconfigId: string) { + const res = await vkeService.request({ + action: "ListKubeconfigs", + method: "POST", + body: { + Filter: { + ClusterIds: [this.clusterId], + Ids: [kubeconfigId], + Types: [this.kubeconfigType] + }, + PageNumber: 1, + PageSize: 10 + } + }); + const items = res.Result?.Items || res.Items || []; + const item = items.find((it: any) => it.Id === kubeconfigId) || items[0]; + const kubeconfig = item?.Kubeconfig; + if (!kubeconfig) { + throw new Error(`获取VKE Kubeconfig失败:${JSON.stringify(res)}`); + } + return kubeconfig; + } + + private async deleteKubeconfig(vkeService: any, kubeconfigId?: string) { + if (!kubeconfigId) { + return; + } + try { + await vkeService.request({ + action: "DeleteKubeconfigs", + method: "POST", + body: { + ClusterId: this.clusterId, + Ids: [kubeconfigId] + } + }); + this.logger.info(`已删除临时Kubeconfig:${kubeconfigId}`); + } catch (e) { + this.logger.warn(`删除临时Kubeconfig失败:${e.message || e}`); + } + } + + private async getTargetSecretNames(k8sClient: any) { + if (this.targetType === "secret") { + if (typeof this.secretName === "string") { + return [this.secretName]; + } + return this.secretName || []; + } + + const ingressList = await k8sClient.getIngressList({ + namespace: this.namespace + }); + const ingress = ingressList.items.find((item: any) => item.metadata.name === this.ingressName); + if (!ingress) { + throw new Error(`Ingress不存在:${this.ingressName}`); + } + const secretNames = ingress.spec?.tls?.map((tls: any) => tls.secretName).filter(Boolean) || []; + if (secretNames.length === 0) { + throw new Error(`Ingress:${this.ingressName} 未找到TLS Secret`); + } + return secretNames; + } + + private async patchCertSecret(options: { cert: CertInfo; k8sClient: any; secretNames: string[] }) { + const { cert, k8sClient, secretNames } = options; + if (!secretNames || secretNames.length === 0) { + throw new Error("Secret名称不能为空"); + } + + const body: any = { + data: { + "tls.crt": Buffer.from(cert.crt).toString("base64"), + "tls.key": Buffer.from(cert.key).toString("base64") + }, + metadata: { + labels: { + certd: this.appendTimeSuffix("certd") + } + } + }; + + for (const secretName of secretNames) { + body.metadata.name = secretName; + this.logger.info(`开始更新VKE Secret:${secretName}`); + await k8sClient.patchSecret({ + namespace: this.namespace, + secretName, + body, + createOnNotFound: this.createOnNotFound + }); + this.logger.info(`VKE Secret已更新:${secretName}`); + } + + if (this.targetType === "ingress" && this.ingressName) { + await k8sClient.restartIngress(this.namespace, [this.ingressName], { certd: this.appendTimeSuffix("certd") }); + } + } + + async onGetClusterList() { + if (!this.accessId) { + throw new Error("请选择Access授权"); + } + const access = await this.getAccess(this.accessId); + const service = await this.getVkeService(access); + const res = await service.request({ + action: "ListClusters", + method: "POST", + body: { + PageNumber: 1, + PageSize: 100 + } + }); + const list = res.Result?.Items || res.Items || []; + return list.map((item: any) => ({ + label: `${item.Name || item.Id}<${item.Id}>`, + value: item.Id + })); + } +} + +new VolcengineDeployToVKE(); diff --git a/packages/ui/certd-server/src/plugins/plugin-volcengine/ve-client.ts b/packages/ui/certd-server/src/plugins/plugin-volcengine/ve-client.ts index e63345baf..09a2827dc 100644 --- a/packages/ui/certd-server/src/plugins/plugin-volcengine/ve-client.ts +++ b/packages/ui/certd-server/src/plugins/plugin-volcengine/ve-client.ts @@ -112,6 +112,20 @@ export class VolcengineClient { return service; } + async getVkeService(opts: { region?: string }) { + const CommonService = await this.getServiceCls(); + + const service = new CommonService({ + serviceName: "vke", + defaultVersion: "2022-05-12" + }); + service.setAccessKeyId(this.opts.access.accessKeyId); + service.setSecretKey(this.opts.access.secretAccessKey); + service.setRegion(opts.region); + + return service; + } + async getDCDNService( opts?: { }) { const CommonService = await this.getServiceCls();