diff --git a/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/plugin-deploy-to-vke.test.ts b/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/plugin-deploy-to-vke.test.ts new file mode 100644 index 000000000..3e5c5f382 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-volcengine/plugins/plugin-deploy-to-vke.test.ts @@ -0,0 +1,98 @@ +/// + +import assert from "node:assert/strict"; + +import { VolcengineDeployToVKE } from "./plugin-deploy-to-vke.js"; + +describe("VolcengineDeployToVKE", () => { + it("uses a single-select cluster field", () => { + const clusterInput = (VolcengineDeployToVKE as any).define.input.clusterId; + + assert.equal(clusterInput.component.multi, false); + assert.equal(clusterInput.component.mode, "default"); + }); + + it("sends the configured string ClusterId", async () => { + const plugin = new VolcengineDeployToVKE(); + plugin.clusterId = "cc1234567890123456789"; + plugin.kubeconfigType = "Public"; + plugin.logger = { info: () => undefined } as any; + + let requestBody: any; + const kubeconfigId = await (plugin as any).createKubeconfig({ + request: async (req: any) => { + requestBody = req.body; + return { Result: { Id: "kc-123" } }; + } + }); + + assert.equal(kubeconfigId, "kc-123"); + assert.equal(requestBody.ClusterId, "cc1234567890123456789"); + }); + + it("decodes the base64 kubeconfig returned by VKE", async () => { + const plugin = new VolcengineDeployToVKE(); + plugin.clusterId = "cc1234567890123456789"; + plugin.kubeconfigType = "Public"; + + const kubeconfig = [ + "apiVersion: v1", + "clusters:", + "- cluster:", + " server: https://example.com", + " name: vke", + "contexts: []", + "current-context: vke" + ].join("\n"); + + const result = await (plugin as any).getKubeconfig( + { + request: async () => ({ + Result: { + Items: [{ Id: "kc-123", Kubeconfig: Buffer.from(kubeconfig).toString("base64") }] + } + }) + }, + "kc-123" + ); + + assert.equal(result, kubeconfig); + }); + + it("formats Kubernetes forbidden errors with VKE RBAC guidance", () => { + const plugin = new VolcengineDeployToVKE(); + plugin.clusterId = "cc1234567890123456789"; + plugin.namespace = "default"; + + const message = (plugin as any).formatK8sError({ + status: "Failure", + message: + 'secrets "aaaa" is forbidden: User "2100656669-kd7ubde6lsvqbdgsa40t0" cannot get resource "secrets" in API group "" in the namespace "default"', + reason: "Forbidden", + details: { name: "aaaa", kind: "secrets" }, + code: 403 + }); + + assert.match(message, /VKE集群RBAC权限不足/); + assert.match(message, /用户ID:2100656669/); + assert.match(message, /命名空间:default/); + assert.match(message, /secrets get\/create\/update\/patch/); + }); + + it("explains missing ingress names with available choices", async () => { + const plugin = new VolcengineDeployToVKE(); + plugin.targetType = "ingress"; + plugin.namespace = "default"; + plugin.ingressName = "ingress-nginx-controller"; + + await assert.rejects( + () => + (plugin as any).getTargetSecretNames({ + getIngressList: async () => ({ + items: [{ metadata: { name: "app-web" } }, { metadata: { name: "api-web" } }] + }) + }), + /当前命名空间可用Ingress:app-web,api-web/ + ); + }); +}); 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 index 680066cbc..c7fcd7e2b 100644 --- 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 @@ -70,6 +70,7 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { helper: "选择要替换证书的VKE集群,也可以手动输入集群ID", action: VolcengineDeployToVKE.prototype.onGetClusterList.name, watches: ["accessId", "regionId"], + multi: false, required: true }) ) @@ -82,14 +83,13 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { name: "a-select", options: [ { label: "公网", value: "Public" }, - { label: "私网", value: "Private" }, - { label: "集群内", value: "TargetCluster" } + { label: "私网", value: "Private" } ] }, value: "Public", required: true }) - kubeconfigType!: "Public" | "Private" | "TargetCluster"; + kubeconfigType!: "Public" | "Private"; @TaskInput({ title: "命名空间", @@ -194,7 +194,7 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { await this.patchCertSecret({ cert: this.cert, k8sClient, secretNames }); } catch (e) { if (e.response?.body) { - throw new Error(JSON.stringify(e.response.body)); + throw new Error(this.formatK8sError(e.response.body)); } throw e; } finally { @@ -215,11 +215,12 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { } private async createKubeconfig(vkeService: any) { + const clusterId = this.getClusterId(); const res = await vkeService.request({ action: "CreateKubeconfig", method: "POST", body: { - ClusterId: this.clusterId, + ClusterId: clusterId, Type: this.kubeconfigType, ValidDuration: 3600 } @@ -233,12 +234,13 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { } private async getKubeconfig(vkeService: any, kubeconfigId: string) { + const clusterId = this.getClusterId(); const res = await vkeService.request({ action: "ListKubeconfigs", method: "POST", body: { Filter: { - ClusterIds: [this.clusterId], + ClusterIds: [clusterId], Ids: [kubeconfigId], Types: [this.kubeconfigType] }, @@ -252,19 +254,20 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { if (!kubeconfig) { throw new Error(`获取VKE Kubeconfig失败:${JSON.stringify(res)}`); } - return kubeconfig; + return this.decodeKubeconfig(kubeconfig); } private async deleteKubeconfig(vkeService: any, kubeconfigId?: string) { if (!kubeconfigId) { return; } + const clusterId = this.getClusterId(); try { await vkeService.request({ action: "DeleteKubeconfigs", method: "POST", body: { - ClusterId: this.clusterId, + ClusterId: clusterId, Ids: [kubeconfigId] } }); @@ -274,6 +277,35 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { } } + private getClusterId() { + if (!this.clusterId) { + throw new Error("VKE集群ID不能为空"); + } + return this.clusterId; + } + + private decodeKubeconfig(kubeconfig: string) { + const decoded = Buffer.from(kubeconfig, "base64").toString("utf8"); + if (!decoded.includes("apiVersion:") || !decoded.includes("clusters:")) { + throw new Error("解析VKE Kubeconfig失败:接口返回的Kubeconfig不是有效的BASE64编码"); + } + return decoded; + } + + private formatK8sError(body: any) { + if (body?.code !== 403 || body?.reason !== "Forbidden") { + return JSON.stringify(body); + } + + const message = body.message || ""; + const user = message.match(/User "([^"]+)"/)?.[1]; + const granteeId = user?.split("-")[0]; + const resource = message.match(/resource "([^"]+)"/)?.[1] || body.details?.kind || "目标资源"; + const namespace = message.match(/namespace "([^"]+)"/)?.[1] || this.namespace; + const userText = granteeId ? `,用户ID:${granteeId}` : ""; + return `VKE集群RBAC权限不足:当前火山引擎授权${userText}在集群:${this.getClusterId()} 的命名空间:${namespace} 没有操作 ${resource} 的权限。请在火山引擎 VKE 集群权限管理中,为该用户授予此命名空间的管理员权限,或授予包含 secrets get/create/update/patch 的自定义角色。原始错误:${JSON.stringify(body)}`; + } + private async getTargetSecretNames(k8sClient: any) { if (this.targetType === "secret") { if (typeof this.secretName === "string") { @@ -287,11 +319,15 @@ export class VolcengineDeployToVKE extends AbstractTaskPlugin { }); const ingress = ingressList.items.find((item: any) => item.metadata.name === this.ingressName); if (!ingress) { - throw new Error(`Ingress不存在:${this.ingressName}`); + const ingressNames = ingressList.items.map((item: any) => item.metadata.name).filter(Boolean); + const availableText = ingressNames.length > 0 ? ingressNames.join(",") : "无"; + throw new Error( + `Ingress不存在:${this.ingressName}(命名空间:${this.namespace})。当前命名空间可用Ingress:${availableText}。请填写业务Ingress资源名称,不是 ingress-nginx-controller 这类控制器Service/Deployment名称;如果只想更新指定Secret,请将替换方式改为“按Secret替换”。` + ); } const secretNames = ingress.spec?.tls?.map((tls: any) => tls.secretName).filter(Boolean) || []; if (secretNames.length === 0) { - throw new Error(`Ingress:${this.ingressName} 未找到TLS Secret`); + throw new Error(`Ingress:${this.ingressName}(命名空间:${this.namespace})未找到spec.tls[].secretName,请确认该Ingress已配置TLS,或改用“按Secret替换”。`); } return secretNames; }