Compare commits

...

6 Commits

Author SHA1 Message Date
xiaojunnuo b8a64a6b5b perf(plugin-volcengine): 支持火山引擎VKE部署插件
- 改进Kubernetes错误处理,添加RBAC权限不足的友好提示
- 添加集群ID验证和kubeconfig解码验证
- 优化Ingress不存在时的错误提示,显示可用选项
- 移除未使用的TargetCluster选项
- 修复kubeconfig请求中集群ID未验证的问题
2026-05-08 00:25:00 +08:00
xiaojunnuo 25ad1e6f86 perf(设置): 添加首页启用开关配置
在系统设置中添加首页启用开关配置项,包括前端路由守卫检查、多语言支持和表单配置,以控制是否显示首页
2026-05-07 23:15:50 +08:00
xiaojunnuo 6b6f1604e9 style(ui): 修复批量操作区域样式溢出问题
为批量操作区域添加 overflow-x-auto 和 whitespace-nowrap 样式,防止文本溢出
2026-05-07 23:01:42 +08:00
xiaojunnuo 63be1c1cbd perf(证书流水线): 添加批量更新证书申请参数功能
实现批量更新证书申请参数功能,包括前端界面和后端处理逻辑
- 添加批量修改证书申请参数的按钮和对话框
- 实现后端批量更新证书申请参数的接口和服务
- 添加相关测试用例验证功能正确性
2026-05-07 22:54:29 +08:00
xiaojunnuo b75c625ddc fix(aliyun-access): 添加阿里云密钥校验失败的错误处理
在测试阿里云访问密钥时,增加对STS错误响应的处理逻辑
添加相关测试用例验证错误处理和正常情况
2026-05-07 22:18:34 +08:00
xiaojunnuo 7083e7aff7 test(tld-client): 添加对rdap.ss回退机制的测试
验证当rdap和whoiser都失败时,TldClient能正确委托RdapSsClient处理
2026-05-07 22:03:33 +08:00
19 changed files with 610 additions and 14 deletions
@@ -32,6 +32,7 @@ export class SysPublicSettings extends BaseSettings {
customFooter?: string;
robots?: boolean = true;
aiChatEnabled = true;
homePageEnabled = true;
//验证码是否开启
@@ -271,4 +272,3 @@ export class SysSafeSetting extends BaseSettings {
autoHiddenTimes: 5,
};
}
@@ -16,6 +16,7 @@ export default {
showRunStrategy: "Show RunStrategy",
showRunStrategyHelper: "Allow modify the run strategy of the task",
homePageEnabled: "Enable Home Page",
captchaEnabled: "Enable Login Captcha",
captchaHelper: "Whether to enable captcha verification for login",
@@ -18,6 +18,7 @@ export default {
showRunStrategy: "显示运行策略选择",
showRunStrategyHelper: "任务设置中是否允许选择运行策略",
homePageEnabled: "启用首页",
captchaEnabled: "启用登录验证码",
captchaHelper: "登录时是否启用验证码",
@@ -47,6 +47,13 @@ export function setupCommonGuard(router: Router) {
const settingStore = useSettingStore();
await settingStore.initOnce();
if (to.path === "/" && settingStore.sysPublic?.homePageEnabled === false) {
return {
path: DEFAULT_HOME_PATH,
replace: true,
};
}
to.meta.loaded = loadedPaths.has(to.path);
// 页面加载进度条
@@ -48,6 +48,7 @@ export type SysPublicSetting = {
customFooter?: string;
robots?: boolean;
aiChatEnabled?: boolean;
homePageEnabled?: boolean;
showRunStrategy?: boolean;
@@ -81,6 +81,7 @@ export const useSettingStore = defineStore({
registerEnabled: false,
managerOtherUserPipeline: false,
icpNo: env.ICP_NO || "",
homePageEnabled: true,
},
installInfo: {
siteId: "",
@@ -110,6 +110,14 @@ export async function BatchUpdateNotificaiton(pipelineIds: number[], notificatio
});
}
export async function BatchUpdateCertApplyOptions(pipelineIds: number[], options: any): Promise<void> {
return await request({
url: apiPrefix + "/batchUpdateCertApplyOptions",
method: "post",
data: { ids: pipelineIds, options },
});
}
export async function BatchUpdateProject(pipelineIds: number[], toProjectId: number): Promise<void> {
return await request({
url: apiPrefix + "/batchTransfer",
@@ -0,0 +1,106 @@
<template>
<fs-button icon="ph:certificate" class="need-plus" type="link" text="修改证书申请参数" @click="openFormDialog"></fs-button>
</template>
<script setup lang="ts">
import { useFormWrapper } from "@fast-crud/fast-crud";
import { cloneDeep } from "lodash-es";
import * as api from "../api";
import { useSettingStore } from "/@/store/settings";
import { usePluginStore } from "/@/store/plugin";
import { useReference } from "/@/use/use-refrence";
const props = defineProps<{
selectedRowKeys: any[];
}>();
const emit = defineEmits<{
change: any;
}>();
const batchUpdateFields = ["renewDays", "privateKeyType"];
function hasFormValue(form: any, field: string) {
return form[field] != null && form[field] !== "";
}
function buildBatchUpdateOptions(form: any) {
const options: any = {};
for (const field of batchUpdateFields) {
if (hasFormValue(form, field)) {
options[field] = form[field];
}
}
return options;
}
async function batchUpdateRequest(form: any) {
const options = buildBatchUpdateOptions(form);
await api.BatchUpdateCertApplyOptions(props.selectedRowKeys, options);
emit("change");
}
const { openCrudFormDialog } = useFormWrapper();
const settingStore = useSettingStore();
const pluginStore = usePluginStore();
function createInputColumn(inputDefine: any) {
const form = cloneDeep(inputDefine);
useReference(form);
delete form.value;
delete form.rules;
form.required = false;
if (form.component) {
form.component.allowClear = true;
}
return {
title: inputDefine.title,
form,
};
}
function createColumns(inputDefines: any) {
const columns: any = {};
for (const field of batchUpdateFields) {
columns[field] = createInputColumn(inputDefines[field]);
}
return columns;
}
function hasAnyBatchUpdateValue(form: any) {
return batchUpdateFields.some(field => hasFormValue(form, field));
}
async function openFormDialog() {
settingStore.checkPlus();
const certApplyPlugin: any = await pluginStore.getPluginDefine("CertApply");
const certApplyInput = certApplyPlugin?.input || {};
const crudOptions: any = {
columns: createColumns(certApplyInput),
form: {
mode: "edit",
//@ts-ignore
async doSubmit({ form }) {
if (!hasAnyBatchUpdateValue(form)) {
throw new Error("请至少选择一个要修改的参数");
}
await batchUpdateRequest(form);
},
col: {
span: 22,
},
labelCol: {
style: {
width: "120px",
},
},
wrapper: {
title: "批量修改证书申请参数",
width: 620,
},
},
} as any;
await openCrudFormDialog({ crudOptions });
}
</script>
@@ -35,11 +35,12 @@
</a-dropdown>
</template>
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
<div class="batch-actions-inner">
<span>{{ t("certd.selectedCount", { count: selectedRowKeys.length }) }}</span>
<div class="batch-actions-inner overflow-x-auto">
<span class="mr-2 inline-flex whitespace-nowrap">{{ t("certd.selectedCount", { count: selectedRowKeys.length }) }}</span>
<fs-button v-if="hasActionPermission('write')" icon="ion:trash-outline" class="color-red" type="link" :text="t('certd.batchDelete')" @click="batchDelete"></fs-button>
<batch-rerun :selected-row-keys="selectedRowKeys" @change="batchFinished"></batch-rerun>
<change-group v-if="hasActionPermission('write')" :selected-row-keys="selectedRowKeys" @change="batchFinished"></change-group>
<change-cert-apply-options v-if="hasActionPermission('write')" :selected-row-keys="selectedRowKeys" @change="batchFinished"></change-cert-apply-options>
<change-notification v-if="hasActionPermission('write')" :selected-row-keys="selectedRowKeys" @change="batchFinished"></change-notification>
<change-trigger v-if="hasActionPermission('write')" :selected-row-keys="selectedRowKeys" @change="batchFinished"></change-trigger>
<change-project v-if="hasActionPermission('write') && settingStore.isEnterprise" :selected-row-keys="selectedRowKeys" @change="batchFinished"></change-project>
@@ -57,6 +58,7 @@ import { computed, onActivated, onMounted, provide, ref } from "vue";
import { dict, useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import ChangeGroup from "./components/change-group.vue";
import ChangeCertApplyOptions from "./components/change-cert-apply-options.vue";
import ChangeTrigger from "./components/change-trigger.vue";
import ChangeProject from "./components/change-project.vue";
@@ -14,6 +14,9 @@
<a-form-item :label="t('certd.allowCrawlers')" :name="['public', 'robots']">
<a-switch v-model:checked="formState.public.robots" />
</a-form-item>
<a-form-item :label="t('certd.sys.setting.homePageEnabled')" :name="['public', 'homePageEnabled']">
<a-switch v-model:checked="formState.public.homePageEnabled" />
</a-form-item>
<a-form-item :label="t('certd.enableCommonCnameService')" :name="['private', 'commonCnameEnabled']">
<a-switch v-model:checked="formState.private.commonCnameEnabled" />
@@ -62,6 +65,7 @@ const formState = reactive<Partial<SysSettings>>({
public: {
icpNo: "",
mpsNo: "",
homePageEnabled: true,
},
private: {},
});
@@ -349,6 +349,14 @@ export class PipelineController extends CrudController<PipelineService> {
return this.ok({});
}
@Post('/batchUpdateCertApplyOptions', { description: Constants.per.authOnly, summary: "批量更新证书申请任务配置" })
async batchUpdateCertApplyOptions(@Body('ids') ids: number[], @Body('options') options: any) {
await this.checkPermissionCall(async ({userId,projectId})=>{
await this.service.batchUpdateCertApplyOptions(ids, options, userId,projectId);
})
return this.ok({});
}
@Post('/batchRerun', { description: Constants.per.authOnly, summary: "批量重新运行流水线" })
async batchRerun(@Body('ids') ids: number[], @Body('force') force: boolean) {
await this.checkPermissionCall(async ({userId,projectId})=>{
@@ -33,6 +33,32 @@ describe("TldClient", () => {
assert.equal(result.expirationDate, 1795104000000);
});
it("delegates rdap.ss fallback to RdapSsClient", async () => {
const client = new TldClient() as any;
const queriedDomains: string[] = [];
client.init = async () => {};
client.getDomainExpirationByRdap = async () => {
throw new Error("rdap failed");
};
client.getDomainExpirationByWhoiser = async () => {
throw new Error("whoiser failed");
};
client.rdapSsClient = {
getDomainInfo: async (domain: string) => {
queriedDomains.push(domain);
return { expirationDate: 1795104000000, registrationDate: 995040000000 };
},
};
const result = await client.getDomainExpirationDate("google.com.hk");
assert.deepEqual(queriedDomains, ["google.com.hk"]);
assert.deepEqual(result, { expirationDate: 1795104000000, registrationDate: 995040000000 });
});
});
describe("RdapSsClient", () => {
it("queries rdap.ss and parses HK whois date fields", async () => {
const originalRequest = http.request;
let requestedConfig: any;
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { updateCertApplyStepInputs } from "./pipeline-batch-update.js";
describe("pipeline batch update", () => {
it("updates only cert apply step inputs", () => {
const pipeline: any = {
stages: [
{
tasks: [
{
steps: [
{
type: "CertApply",
input: {
renewDays: 20,
privateKeyType: "rsa_2048",
domains: ["example.com"],
},
},
{
type: "DeployToHost",
input: {
renewDays: 1,
privateKeyType: "rsa_1024",
},
},
],
},
],
},
],
};
const count = updateCertApplyStepInputs(pipeline, {
renewDays: 10,
privateKeyType: "ec_256",
});
assert.equal(count, 1);
assert.deepEqual(pipeline.stages[0].tasks[0].steps[0].input, {
renewDays: 10,
privateKeyType: "ec_256",
domains: ["example.com"],
});
assert.deepEqual(pipeline.stages[0].tasks[0].steps[1].input, {
renewDays: 1,
privateKeyType: "rsa_1024",
});
});
it("does not overwrite fields omitted from the patch", () => {
const pipeline: any = {
stages: [
{
tasks: [
{
steps: [
{
type: "CertApply",
input: {
renewDays: 20,
privateKeyType: "ec_256",
},
},
],
},
],
},
],
};
updateCertApplyStepInputs(pipeline, {
renewDays: 15,
});
assert.deepEqual(pipeline.stages[0].tasks[0].steps[0].input, {
renewDays: 15,
privateKeyType: "ec_256",
});
});
it("updates uploaded cert pipelines only for fields defined by the plugin", () => {
const pipeline: any = {
stages: [
{
tasks: [
{
steps: [
{
type: "CertApplyUpload",
input: {
renewDays: 20,
},
},
{
type: "CertApply",
input: {
renewDays: 20,
},
},
],
},
],
},
],
};
const inputDefines: Record<string, Record<string, unknown>> = {
CertApplyUpload: {
renewDays: {},
},
CertApply: {
renewDays: {},
privateKeyType: {},
},
};
assert.equal(updateCertApplyStepInputs(pipeline, {}, stepType => inputDefines[stepType]), 0);
assert.equal(
updateCertApplyStepInputs(
pipeline,
{
renewDays: 12,
privateKeyType: "ec_256",
},
stepType => inputDefines[stepType]
),
2
);
assert.deepEqual(pipeline.stages[0].tasks[0].steps[0].input, {
renewDays: 12,
});
assert.deepEqual(pipeline.stages[0].tasks[0].steps[1].input, {
renewDays: 12,
privateKeyType: "ec_256",
});
});
it("skips lego cert apply steps", () => {
const pipeline: any = {
stages: [
{
tasks: [
{
steps: [
{
type: "CertApplyLego",
input: {
renewDays: 20,
privateKeyType: "ec256",
},
},
],
},
],
},
],
};
assert.equal(updateCertApplyStepInputs(pipeline, { renewDays: 12, privateKeyType: "ec_256" }), 0);
assert.deepEqual(pipeline.stages[0].tasks[0].steps[0].input, {
renewDays: 20,
privateKeyType: "ec256",
});
});
});
@@ -0,0 +1,53 @@
export type CertApplyStepInputPatch = {
renewDays?: number;
privateKeyType?: string;
};
export type GetStepInputDefine = (stepType: string) => Record<string, unknown> | undefined;
function isCertApplyStep(step: any) {
return typeof step?.type === "string" && step.type !== "CertApplyLego" && step.type.startsWith("CertApply");
}
function hasPatchValue(patch: CertApplyStepInputPatch, key: keyof CertApplyStepInputPatch) {
return Object.prototype.hasOwnProperty.call(patch, key) && patch[key] !== undefined;
}
function hasInputDefine(inputDefine: Record<string, unknown> | undefined, key: keyof CertApplyStepInputPatch) {
return inputDefine == null || Object.prototype.hasOwnProperty.call(inputDefine, key);
}
function applyPatchFields(target: Record<string, unknown>, patch: CertApplyStepInputPatch, inputDefine: Record<string, unknown> | undefined, fields: (keyof CertApplyStepInputPatch)[]) {
let changed = false;
for (const field of fields) {
if (!hasPatchValue(patch, field) || !hasInputDefine(inputDefine, field)) {
continue;
}
target[field] = patch[field];
changed = true;
}
return changed;
}
export function updateCertApplyStepInputs(pipeline: any, patch: CertApplyStepInputPatch, getStepInputDefine?: GetStepInputDefine) {
const fields: (keyof CertApplyStepInputPatch)[] = ["renewDays", "privateKeyType"];
let count = 0;
for (const stage of pipeline?.stages || []) {
for (const task of stage?.tasks || []) {
for (const step of task?.steps || []) {
if (!isCertApplyStep(step)) {
continue;
}
const inputDefine = getStepInputDefine?.(step.type);
if (step.input == null) {
step.input = {};
}
if (!applyPatchFields(step.input, patch, inputDefine, fields)) {
continue;
}
count++;
}
}
}
return count;
}
@@ -52,6 +52,7 @@ import { set } from "lodash-es";
import { executorQueue } from "@certd/lib-server";
import parser from "cron-parser";
import { ProjectService } from "../../sys/enterprise/service/project-service.js";
import { CertApplyStepInputPatch, updateCertApplyStepInputs } from "./pipeline-batch-update.js";
const runningTasks: Map<string | number, Executor> = new Map();
@@ -1211,6 +1212,37 @@ export class PipelineService extends BaseService<PipelineEntity> {
}
}
async batchUpdateCertApplyOptions(ids: number[], options: CertApplyStepInputPatch, userId: any, projectId?: number) {
if (!isPlus()) {
throw new NeedVIPException("此功能需要升级Certd专业版");
}
const query: any = {}
if (userId && userId > 0) {
query.userId = userId;
}
if (projectId) {
query.projectId = projectId;
}
const list = await this.find({
where: {
id: In(ids),
...query
}
});
for (const item of list) {
const pipeline = JSON.parse(item.content);
const updatedCount = updateCertApplyStepInputs(pipeline, options, stepType => {
const pluginDefine: any = pluginRegistry.getDefine(stepType);
return pluginDefine?.input;
});
if (updatedCount === 0) {
continue;
}
await this.doUpdatePipelineJson(item, pipeline);
}
}
async batchRerun(ids: number[], force: boolean, userId: any, projectId?: number) {
if (!isPlus()) {
throw new NeedVIPException("此功能需要升级Certd专业版");
@@ -0,0 +1,41 @@
/// <reference types="mocha" />
import assert from "node:assert/strict";
import { AliyunAccess } from "./aliyun-access.js";
function createAccess(result: Record<string, unknown>) {
const access = new AliyunAccess();
access.ctx = {
logger: {
log() {},
},
} as any;
access.getStsClient = async () =>
({
getCallerIdentity: async () => result,
}) as any;
return access;
}
describe("AliyunAccess", () => {
it("rejects STS error responses when testing access keys", async () => {
const access = createAccess({
Code: "InvalidAccessKeyId.NotFound",
Message: "Specified access key is not found.",
RequestId: "request-id",
});
await assert.rejects(() => access.onTestRequest(), /InvalidAccessKeyId\.NotFound/);
});
it("returns ok for valid STS identity responses", async () => {
const access = createAccess({
AccountId: "123456789",
Arn: "acs:ram::123456789:user/test",
UserId: "test-user",
});
assert.equal(await access.onTestRequest(), "ok");
});
});
@@ -41,7 +41,7 @@ export class AliyunAccess extends BaseAccess {
async onTestRequest() {
await this.getCallerIdentity();
return "ok"
return "ok";
}
@@ -64,6 +64,11 @@ export class AliyunAccess extends BaseAccess {
const sts = await this.getStsClient();
// 调用 GetCallerIdentity 接口
const result = await sts.getCallerIdentity();
if (result.Code || !result.AccountId) {
const message = result.Message || "阿里云密钥校验失败";
const code = result.Code ? `[${result.Code}] ` : "";
throw new Error(`${code}${message}`);
}
this.ctx.logger.log("✅ 密钥有效!");
this.ctx.logger.log(` 账户ID: ${result.AccountId}`);
@@ -0,0 +1,98 @@
/// <reference types="mocha" />
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/
);
});
});
@@ -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;
}