perf(volcengine-vke): 火山VKE集群证书支持两种类型的证书保密字典

This commit is contained in:
xiaojunnuo
2026-06-06 02:12:47 +08:00
parent 1175e1164b
commit 77b8024453
4 changed files with 217 additions and 34 deletions
@@ -6,7 +6,7 @@ name: VolcengineDeployToVKE
title: 火山引擎-替换VKE证书 title: 火山引擎-替换VKE证书
icon: svg:icon-volcengine icon: svg:icon-volcengine
group: volcengine group: volcengine
desc: 替换火山引擎VKE集群中的TLS Secret证书 desc: 将证书上传至火山引擎证书中心后,通过 cert_center 方式替换VKE集群中的Secret
input: input:
cert: cert:
title: 域名证书 title: 域名证书
@@ -15,6 +15,7 @@ input:
name: output-selector name: output-selector
from: from:
- ':cert:' - ':cert:'
- 'VolcengineUploadToCertCenter'
required: true required: true
order: 0 order: 0
certDomains: certDomains:
@@ -140,23 +141,38 @@ input:
secretName: secretName:
title: Secret名称 title: Secret名称
required: true required: true
helper: 存储TLS证书的Secret名称,可填写多个 helper: 选择要替换的Secret,可多选
component: component:
name: a-select name: remote-select
vModel: value vModel: value
mode: tags mode: tags
open: false type: plugin
action: onGetSecretList
search: false
pager: false
single: false
watches:
- certDomains
- accessId
- regionId
- clusterId
- kubeconfigType
- namespace
mergeScript: |2- mergeScript: |2-
return { return {
show: ctx.compute(({form}) => form.targetType === 'secret'), show: ctx.compute(({form}) => form.targetType === 'secret'),
required: ctx.compute(({form}) => form.targetType === 'secret') required: ctx.compute(({form}) => form.targetType === 'secret'),
component: {
form: ctx.compute(({form}) => form)
}
} }
order: 0 order: 0
createOnNotFound: createOnNotFound:
title: Secret自动创建 title: Secret自动创建
helper: 如果Secret不存在,则创建kubernetes.io/tls类型Secret helper: 如果Secret不存在,则创建Opaque类型Secret
value: false value: false
component: component:
name: a-switch name: a-switch
@@ -3,6 +3,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { VolcengineDeployToVKE } from "./plugin-deploy-to-vke.js"; import { VolcengineDeployToVKE } from "./plugin-deploy-to-vke.js";
import { CertInfo } from "@certd/plugin-cert";
describe("VolcengineDeployToVKE", () => { describe("VolcengineDeployToVKE", () => {
it("uses a single-select cluster field", () => { it("uses a single-select cluster field", () => {
@@ -86,4 +87,65 @@ describe("VolcengineDeployToVKE", () => {
/当前命名空间可用Ingress:app-web,api-web/ /当前命名空间可用Ingress:app-web,api-web/
); );
}); });
it("creates Secret with cert_center format for new non-tls secrets", async () => {
const plugin = new VolcengineDeployToVKE();
plugin.namespace = "default";
plugin.targetType = "secret";
plugin.secretName = "test-tls";
plugin.createOnNotFound = true;
plugin.logger = { info: () => undefined } as any;
plugin.appendTimeSuffix = (s: string) => s + "-test";
let secretBody: any;
await (plugin as any).patchCertSecret({
certId: "cert-abc123",
k8sClient: {
patchSecret: async (opts: any) => {
secretBody = opts.body;
return {};
},
client: {
readNamespacedSecret: async () => {
throw Object.assign(new Error("Not Found"), { response: { body: { code: 404 } } });
},
},
},
secretNames: ["test-tls"],
});
assert.equal(secretBody.type, "Opaque");
assert.equal(secretBody.data["cert_id"], Buffer.from("cert-abc123").toString("base64"));
assert.equal(secretBody.data["cert_source"], Buffer.from("cert_center").toString("base64"));
});
it("uses tls.crt/tls.key format for kubernetes.io/tls secrets", async () => {
const plugin = new VolcengineDeployToVKE();
plugin.namespace = "default";
plugin.targetType = "secret";
plugin.secretName = "test-tls";
plugin.logger = { info: () => undefined } as any;
plugin.appendTimeSuffix = (s: string) => s + "-test";
plugin.cert = { crt: "MY_CRT", key: "MY_KEY" } as CertInfo;
let secretBody: any;
await (plugin as any).patchCertSecret({
certId: "cert-abc123",
k8sClient: {
patchSecret: async (opts: any) => {
secretBody = opts.body;
return {};
},
client: {
readNamespacedSecret: async () => ({
body: { type: "kubernetes.io/tls" },
}),
},
},
secretNames: ["test-tls"],
});
assert.equal(secretBody.data["tls.crt"], Buffer.from("MY_CRT").toString("base64"));
assert.equal(secretBody.data["tls.key"], Buffer.from("MY_KEY").toString("base64"));
});
}); });
@@ -34,11 +34,11 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
helper: "请选择前置任务输出的域名证书", helper: "请选择前置任务输出的域名证书",
component: { component: {
name: "output-selector", name: "output-selector",
from: [...CertApplyPluginNames], from: [...CertApplyPluginNames, "VolcengineUploadToCertCenter"],
}, },
required: true, required: true,
}) })
cert!: CertInfo; cert!: CertInfo | string;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } })) @TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[]; certDomains!: string[];
@@ -109,11 +109,11 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
component: { component: {
name: "a-select", name: "a-select",
options: [ options: [
{ label: "按Ingress替换", value: "ingress" },
{ label: "按Secret替换", value: "secret" }, { label: "按Secret替换", value: "secret" },
{ label: "按Ingress替换", value: "ingress" },
], ],
}, },
value: "ingress", value: "secret",
required: true, required: true,
}) })
targetType!: "ingress" | "secret"; targetType!: "ingress" | "secret";
@@ -134,17 +134,25 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
@TaskInput({ @TaskInput({
title: "Secret名称", title: "Secret名称",
required: true, required: true,
helper: "存储TLS证书的Secret名称,可填写多个", helper: "选择要替换的Secret,可多选",
component: { component: {
name: "a-select", name: "remote-select",
vModel: "value", vModel: "value",
mode: "tags", mode: "tags",
open: false, type: "plugin",
action: "onGetSecretList",
search: false,
pager: false,
single: false,
watches: ["certDomains", "accessId", "regionId", "clusterId", "kubeconfigType", "namespace"],
}, },
mergeScript: ` mergeScript: `
return { return {
show: ctx.compute(({form}) => form.targetType === 'secret'), show: ctx.compute(({form}) => form.targetType === 'secret'),
required: ctx.compute(({form}) => form.targetType === 'secret') required: ctx.compute(({form}) => form.targetType === 'secret'),
component: {
form: ctx.compute(({form}) => form)
}
} }
`, `,
}) })
@@ -152,7 +160,7 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
@TaskInput({ @TaskInput({
title: "Secret自动创建", title: "Secret自动创建",
helper: "如果Secret不存在,则创建kubernetes.io/tls类型Secret", helper: "如果Secret不存在,则创建Opaque类型Secret",
value: false, value: false,
component: { component: {
name: "a-switch", name: "a-switch",
@@ -183,6 +191,23 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
this.logger.info("开始替换火山引擎VKE证书"); this.logger.info("开始替换火山引擎VKE证书");
const access = await this.getAccess<VolcengineAccess>(this.accessId); const access = await this.getAccess<VolcengineAccess>(this.accessId);
const vkeService = await this.getVkeService(access); const vkeService = await this.getVkeService(access);
// 上传证书到证书中心
let certId: string;
if (typeof this.cert !== "string") {
const certInfo = this.cert as CertInfo;
this.logger.info("开始上传证书到证书中心");
const certService = await this.getCertService(access);
certId = await certService.ImportCertificate({
certName: this.appendTimeSuffix("certd"),
cert: certInfo,
});
this.logger.info("上传证书到证书中心成功:" + certId);
} else {
certId = this.cert;
this.logger.info("使用已有证书中心ID:" + certId);
}
const kubeconfigId = await this.createKubeconfig(vkeService); const kubeconfigId = await this.createKubeconfig(vkeService);
try { try {
@@ -193,7 +218,7 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
skipTLSVerify: this.skipTLSVerify, skipTLSVerify: this.skipTLSVerify,
}); });
const secretNames = await this.getTargetSecretNames(k8sClient); const secretNames = await this.getTargetSecretNames(k8sClient);
await this.patchCertSecret({ cert: this.cert, k8sClient, secretNames }); await this.patchCertSecret({ certId, k8sClient, secretNames });
} catch (e) { } catch (e) {
if (e.response?.body) { if (e.response?.body) {
throw new Error(this.formatK8sError(e.response.body)); throw new Error(this.formatK8sError(e.response.body));
@@ -207,6 +232,15 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
this.logger.info("VKE证书替换完成"); this.logger.info("VKE证书替换完成");
} }
private async getCertService(access: VolcengineAccess) {
const client = new VolcengineClient({
logger: this.logger,
access,
http: this.http,
});
return await client.getCertCenterService();
}
private async getVkeService(access: VolcengineAccess) { private async getVkeService(access: VolcengineAccess) {
const client = new VolcengineClient({ const client = new VolcengineClient({
logger: this.logger, logger: this.logger,
@@ -295,6 +329,11 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
} }
private formatK8sError(body: any) { private formatK8sError(body: any) {
if (body?.code === 422 && body?.message?.includes("field is immutable")) {
const secretName = body.details?.name || "未知";
return `Secret类型不可变:Secret ${secretName} 已是kubernetes.io/tls类型,type字段不可修改。\n请删除该Secret后重试,或选择正确的Secret。\n原始错误:${JSON.stringify(body)}`;
}
if (body?.code !== 403 || body?.reason !== "Forbidden") { if (body?.code !== 403 || body?.reason !== "Forbidden") {
return JSON.stringify(body); return JSON.stringify(body);
} }
@@ -336,34 +375,71 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
return secretNames; return secretNames;
} }
private async patchCertSecret(options: { cert: CertInfo; k8sClient: any; secretNames: string[] }) { private async patchCertSecret(options: { certId: string; k8sClient: any; secretNames: string[] }) {
const { cert, k8sClient, secretNames } = options; const { certId, k8sClient, secretNames } = options;
if (!secretNames || secretNames.length === 0) { if (!secretNames || secretNames.length === 0) {
throw new Error("Secret名称不能为空"); 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) { for (const secretName of secretNames) {
let useTlsFormat = false;
try {
const res = await k8sClient.client.readNamespacedSecret(secretName, this.namespace);
useTlsFormat = res.body?.type === "kubernetes.io/tls";
} catch (e) {
// Secret 不存在,将走创建逻辑
}
let body: any;
if (useTlsFormat) {
let crt: string;
let key: string;
if (typeof this.cert === "string") {
const access = await this.getAccess<VolcengineAccess>(this.accessId);
const certService = await this.getCertService(access);
const detail = await certService.GetCertificateDetail(this.cert);
crt = detail.CertificateChain || "";
key = detail.PrivateKey || "";
this.logger.info("从证书中心获取证书详情成功");
} else {
crt = this.cert.crt;
key = this.cert.key;
}
body = {
data: {
"tls.crt": Buffer.from(crt).toString("base64"),
"tls.key": Buffer.from(key).toString("base64"),
},
metadata: {
labels: {
certd: this.appendTimeSuffix("certd"),
},
},
};
} else {
body = {
type: "Opaque",
data: {
cert_id: Buffer.from(certId).toString("base64"),
cert_source: Buffer.from("cert_center").toString("base64"),
},
metadata: {
labels: {
certd: this.appendTimeSuffix("certd"),
},
},
};
}
body.metadata.name = secretName; body.metadata.name = secretName;
this.logger.info(`开始更新VKE Secret:${secretName}`); this.logger.info("开始更新VKE Secret:" + secretName);
await k8sClient.patchSecret({ await k8sClient.patchSecret({
namespace: this.namespace, namespace: this.namespace,
secretName, secretName,
body, body,
createOnNotFound: this.createOnNotFound, createOnNotFound: this.createOnNotFound,
}); });
this.logger.info(`VKE Secret已更新:${secretName}`); this.logger.info("VKE Secret已更新:" + secretName);
} }
if (this.targetType === "ingress" && this.ingressName) { if (this.targetType === "ingress" && this.ingressName) {
@@ -391,6 +467,35 @@ export class VolcengineDeployToVKE extends AbstractPlusTaskPlugin {
value: item.Id, value: item.Id,
})); }));
} }
async onGetSecretList() {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
if (!this.clusterId) {
throw new Error("请选择VKE集群");
}
const access = await this.getAccess<VolcengineAccess>(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 res = await k8sClient.getSecrets({ namespace: this.namespace || "default" });
const list = res.body?.items || res.items || [];
return list.map((item: any) => ({
label: item.metadata.name,
value: item.metadata.name,
}));
} finally {
await this.deleteKubeconfig(vkeService, kubeconfigId);
}
}
} }
new VolcengineDeployToVKE(); new VolcengineDeployToVKE();
@@ -1,4 +1,4 @@
import { VolcengineAccess } from "./access.js"; import { VolcengineAccess } from "./access.js";
import { HttpClient, ILogger } from "@certd/basic"; import { HttpClient, ILogger } from "@certd/basic";
export type VolcengineOpts = { export type VolcengineOpts = {