perf: 手动上传证书部署流水线

This commit is contained in:
xiaojunnuo
2025-03-22 02:06:02 +08:00
parent fedf90ea78
commit fbb66f3c43
25 changed files with 329 additions and 511 deletions
+1
View File
@@ -67,6 +67,7 @@
"lucide-vue-next": "^0.477.0",
"mitt": "^3.0.1",
"nanoid": "^4.0.0",
"node-forge": "^1.3.1",
"nprogress": "^0.2.0",
"object-assign": "^4.1.1",
"pinia": "2.1.7",
@@ -5,8 +5,8 @@ import OutputSelector from "/@/components/plugins/common/output-selector/index.v
import DnsProviderSelector from "/@/components/plugins/cert/dns-provider-selector/index.vue";
import DomainsVerifyPlanEditor from "/@/components/plugins/cert/domains-verify-plan-editor/index.vue";
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
import CertInfoUpdater from "/@/views/certd/monitor/cert/updater/index.vue";
import InputPassword from "./common/input-password.vue";
import CertInfoUpdater from "/@/views/certd/pipeline/cert-upload/index.vue";
import ApiTest from "./common/api-test.vue";
export * from "./cert/index.js";
export default {
@@ -109,24 +109,23 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
copy: { show: false },
edit: { show: false },
upload: {
show: compute(({ row }) => {
return row.fromType === "upload";
}),
order: 4,
title: "更新证书",
type: "link",
icon: "ion:upload",
async click({ row }) {
await openUpdateCertDialog({
id: row.id,
});
},
},
remove: {
order: 10,
show: false,
},
download: {
order: 9,
title: "下载证书",
type: "link",
icon: "ant-design:download-outlined",
async click({ row }) {
if (!row.certFile) {
notification.error({ message: "证书还未生成,请先运行流水线" });
return;
}
window.open("/api/monitor/cert/download?id=" + row.id);
},
},
},
},
columns: {
@@ -3,7 +3,7 @@
<template #header>
<div class="title">
证书仓库
<span class="sub">从流水线生成的证书后续将支持手动上传证书并部署</span>
<span class="sub">从流水线生成的证书</span>
</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding"> </fs-crud>
@@ -11,12 +11,12 @@
</template>
<script lang="ts" setup>
import { defineComponent, onActivated, onMounted } from "vue";
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import { createApi } from "./api";
defineOptions({
name: "CertStore"
name: "CertStore",
});
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: {} });
@@ -8,7 +8,7 @@ export async function GetList(query: any) {
return await request({
url: apiPrefix + "/page",
method: "post",
data: query
data: query,
});
}
@@ -16,7 +16,7 @@ export async function AddObj(obj: any) {
return await request({
url: apiPrefix + "/add",
method: "post",
data: obj
data: obj,
});
}
@@ -24,7 +24,7 @@ export async function UpdateObj(obj: any) {
return await request({
url: apiPrefix + "/update",
method: "post",
data: obj
data: obj,
});
}
@@ -32,7 +32,7 @@ export async function DelObj(id: any) {
return await request({
url: apiPrefix + "/delete",
method: "post",
params: { id }
params: { id },
});
}
@@ -40,7 +40,7 @@ export async function GetObj(id: any) {
return await request({
url: apiPrefix + "/info",
method: "post",
params: { id }
params: { id },
});
}
@@ -48,7 +48,7 @@ export async function GetDetail(id: any) {
return await request({
url: apiPrefix + "/detail",
method: "post",
params: { id }
params: { id },
});
}
@@ -56,7 +56,7 @@ export async function Save(pipelineEntity: any) {
return await request({
url: apiPrefix + "/save",
method: "post",
data: pipelineEntity
data: pipelineEntity,
});
}
@@ -64,7 +64,7 @@ export async function Trigger(id: any, stepId?: string) {
return await request({
url: apiPrefix + "/trigger",
method: "post",
params: { id, stepId }
params: { id, stepId },
});
}
@@ -72,7 +72,7 @@ export async function Cancel(historyId: any) {
return await request({
url: apiPrefix + "/cancel",
method: "post",
params: { historyId }
params: { historyId },
});
}
@@ -80,7 +80,7 @@ export async function BatchUpdateGroup(pipelineIds: number[], groupId: number):
return await request({
url: apiPrefix + "/batchUpdateGroup",
method: "post",
data: { ids: pipelineIds, groupId }
data: { ids: pipelineIds, groupId },
});
}
@@ -88,7 +88,7 @@ export async function BatchDelete(pipelineIds: number[]): Promise<CertInfo> {
return await request({
url: apiPrefix + "/batchDelete",
method: "post",
data: { ids: pipelineIds }
data: { ids: pipelineIds },
});
}
@@ -96,14 +96,14 @@ export async function GetFiles(pipelineId: number) {
return await request({
url: historyApiPrefix + "/files",
method: "post",
params: { pipelineId }
params: { pipelineId },
});
}
export async function GetCount() {
return await request({
url: apiPrefix + "/count",
method: "post"
method: "post",
});
}
@@ -119,6 +119,6 @@ export async function GetCert(pipelineId: number): Promise<CertInfo> {
return await request({
url: certApiPrefix + "/get",
method: "post",
params: { id: pipelineId }
params: { id: pipelineId },
});
}
@@ -1,24 +1,23 @@
<template>
<div class="cert-info-updater w-full flex items-center">
<div class="flex-o">
<fs-values-format :model-value="modelValue" :dict="certInfoDict" />
<a-tag>{{ domain }}</a-tag>
<fs-button type="primary" size="small" class="ml-1" icon="ion:upload" text="更新证书" @click="onUploadClick" />
</div>
</div>
</template>
<script lang="tsx" setup>
import { inject } from "vue";
import { dict } from "@fast-crud/fast-crud";
import { certInfoApi } from "../api";
import { useCertUpload } from "/@/views/certd/pipeline/cert-upload/use";
import { computed, inject } from "vue";
import { useCertUpload } from "./use";
import { getAllDomainsFromCrt } from "/@/views/certd/pipeline/utils";
defineOptions({
name: "CertInfoUpdater",
});
const props = defineProps<{
modelValue?: number | string;
modelValue?: { crt: string; key: string };
type?: string;
placeholder?: string;
size?: string;
@@ -26,33 +25,28 @@ const props = defineProps<{
}>();
const emit = defineEmits(["updated", "update:modelValue"]);
const certInfoDict = dict({
value: "id",
label: "domain",
getNodesByValues: async (values: any[]) => {
const res = await certInfoApi.GetOptionsByIds(values);
if (res.length > 0) {
emit("updated", {
domains: res[0].domains,
});
}
return res;
},
const { openUpdateCertDialog } = useCertUpload();
const domain = computed(() => {
if (!props.modelValue?.crt) {
return "";
}
const domains = getAllDomainsFromCrt(props.modelValue?.crt);
return domains[0];
});
const { openUpdateCertDialog } = useCertUpload();
function onUpdated(res: any) {
if (!props.modelValue) {
emit("update:modelValue", res.id);
}
emit("updated", res);
function onUpdated(res: { uploadCert: any }) {
debugger;
emit("update:modelValue", res.uploadCert);
const domains = getAllDomainsFromCrt(res.uploadCert.crt);
emit("updated", { domains });
}
const pipeline: any = inject("pipeline");
function onUploadClick() {
debugger;
openUpdateCertDialog({
id: props.modelValue,
pipelineId: pipeline.id,
onSubmit: onUpdated,
});
}
@@ -1,23 +1,62 @@
import { compute, useFormWrapper } from "@fast-crud/fast-crud";
import NotificationSelector from "/@/views/certd/notification/notification-selector/index.vue";
import * as api from "./api";
import { omit, cloneDeep, set } from "lodash-es";
import { cloneDeep, omit } from "lodash-es";
import { useReference } from "/@/use/use-refrence";
import { ref } from "vue";
import * as pluginApi from "../api.plugin";
import { checkPipelineLimit } from "/@/views/certd/pipeline/utils";
import { notification } from "ant-design-vue";
import * as api from "../api";
import { checkPipelineLimit, getAllDomainsFromCrt } from "/@/views/certd/pipeline/utils";
import { useRouter } from "vue-router";
import { nanoid } from "nanoid";
export function useCertUpload() {
const { openCrudFormDialog } = useFormWrapper();
const router = useRouter();
const certInputs = {
"uploadCert.crt": {
title: "证书",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN CERTIFICATE-----\n...\n...\n-----END CERTIFICATE-----",
},
},
helper: "选择pem格式证书文件,或者粘贴到此",
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
order: -9999,
},
},
"uploadCert.key": {
title: "证书私钥",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN PRIVATE KEY-----\n...\n...\n-----END PRIVATE KEY----- ",
},
},
helper: "选择pem格式证书私钥文件,或者粘贴到此",
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
order: -9999,
},
},
};
async function buildUploadCertPluginInputs(getFormData: any) {
const plugin: any = await pluginApi.GetPluginDefine("CertApplyUpload");
const inputs: any = {};
for (const inputKey in plugin.input) {
if (inputKey === "certInfoId" || inputKey === "domains") {
if (inputKey === "uploadCert" || inputKey === "domains") {
continue;
}
const inputDefine = cloneDeep(plugin.input[inputKey]);
@@ -66,42 +105,7 @@ export function useCertUpload() {
return {
crudOptions: {
columns: {
"cert.crt": {
title: "证书",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN CERTIFICATE-----\n...\n...\n-----END CERTIFICATE-----",
},
},
helper: "选择pem格式证书文件,或者粘贴到此",
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
order: -9999,
},
},
"cert.key": {
title: "证书私钥",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN PRIVATE KEY-----\n...\n...\n-----END PRIVATE KEY----- ",
},
},
helper: "选择pem格式证书私钥文件,或者粘贴到此",
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
order: -9999,
},
},
...cloneDeep(certInputs),
...inputs,
notification: {
title: "失败通知",
@@ -128,6 +132,9 @@ export function useCertUpload() {
saveRemind: false,
},
async doSubmit({ form }: any) {
const cert = form.uploadCert;
const domains = getAllDomainsFromCrt(cert.crt);
const notifications = [];
if (form.notification != null) {
notifications.push({
@@ -138,18 +145,54 @@ export function useCertUpload() {
});
}
const req = {
id: form.id,
cert: form.cert,
pipeline: {
input: omit(form, ["id", "cert", "notification", "notificationTarget"]),
notifications,
},
const pipelineTitle = domains[0] + "上传证书部署";
const input = omit(form, ["id", "cert", "notification", "notificationTarget"]);
const pipeline = {
title: pipelineTitle,
runnableType: "pipeline",
stages: [
{
id: nanoid(10),
title: "上传证书解析阶段",
maxTaskCount: 1,
runnableType: "stage",
tasks: [
{
id: nanoid(10),
title: "上传证书解析转换",
runnableType: "task",
steps: [
{
id: nanoid(10),
title: "上传证书解析转换",
runnableType: "step",
input: {
cert: cert,
domains: domains,
...input,
},
strategy: {
runStrategy: 0, // 正常执行
},
type: "CertApplyUpload",
},
],
},
],
},
],
notifications,
};
const res = await api.UploadCert(req);
const id = await api.Save({
title: pipeline.title,
content: JSON.stringify(pipeline),
keepHistoryCount: 30,
type: "cert_upload",
});
router.push({
path: "/certd/pipeline/detail",
query: { id: res.pipelineId, editMode: "true" },
query: { id: id, editMode: "true" },
});
},
},
@@ -161,61 +204,22 @@ export function useCertUpload() {
wrapperRef.value = wrapper;
}
async function openUpdateCertDialog(opts: { id?: any; onSubmit?: any; pipelineId?: any }) {
async function openUpdateCertDialog(opts: { onSubmit?: any }) {
function createCrudOptions() {
return {
crudOptions: {
columns: {
"cert.crt": {
title: "证书",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN CERTIFICATE-----\n...\n...\n-----END CERTIFICATE-----",
},
},
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
},
},
"cert.key": {
title: "私钥",
type: "textarea",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN PRIVATE KEY-----\n...\n...\n-----END PRIVATE KEY----- ",
},
},
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
},
},
...cloneDeep(certInputs),
},
form: {
wrapper: {
title: "更新证书",
title: "手动上传证书",
saveRemind: false,
},
async afterSubmit() {
notification.success({ message: "更新成功" });
},
async afterSubmit() {},
async doSubmit({ form }: any) {
const req = {
id: opts.id,
pipelineId: opts.pipelineId,
cert: form.cert,
};
const res = await api.UploadCert(req);
if (opts.onSubmit) {
await opts.onSubmit(res);
await opts.onSubmit(form);
}
},
},
@@ -1,4 +1,4 @@
import { checkPipelineLimit } from "/@/views/certd/pipeline/utils";
import { checkPipelineLimit, readCertDetail } from "/@/views/certd/pipeline/utils";
import { omit } from "lodash-es";
import * as api from "/@/views/certd/pipeline/api";
import { message } from "ant-design-vue";
@@ -52,6 +52,7 @@ export function useCertd(certdFormRef: any) {
await checkPipelineLimit();
certdFormRef.value.open(async ({ form }: any) => {
const certDetail = readCertDetail(form.cert.crt);
// 添加certd pipeline
const triggers = [];
if (form.triggerCron) {
@@ -22,6 +22,7 @@ const props = defineProps<{
form: any;
input: any;
pluginName: string;
stepId: string;
}>();
const attrs = useAttrs();
@@ -85,7 +86,7 @@ const doPluginFormSubmit = async (formData: any) => {
if (res.input) {
const { save, findStep } = getPipelineScope();
const step = findStep(res.input);
const step = findStep(props.stepId);
if (step) {
// 数组覆盖合并
mergeWith(step.input, res.input, (objValue, srcValue) => {
@@ -41,6 +41,7 @@ async function init() {
...shortcut,
pluginName: stepType,
input: step.input,
stepId: step.id,
});
}
}
@@ -104,7 +104,7 @@
</template>
<span class="flex-o w-100">
<span class="ellipsis flex-1 task-title" :class="{ 'in-edit': editMode, deleted: task.disabled }">{{ task.title }}</span>
<pi-status-show :status="task.status?.result"></pi-status-show>
<pi-status-show v-if="!editMode" :status="task.status?.result"></pi-status-show>
</span>
</a-popover>
</a-button>
@@ -273,6 +273,7 @@ import { FsIcon } from "@fast-crud/fast-crud";
import { useSettingStore } from "/@/store/modules/settings";
import { useUserStore } from "/@/store/modules/user";
import TaskShortcuts from "./component/shortcut/task-shortcuts.vue";
import { eachSteps, findStep } from "../utils";
export default defineComponent({
name: "PipelineEdit",
// eslint-disable-next-line vue/no-unused-components
@@ -648,22 +649,6 @@ export default defineComponent({
errors.push(error);
}
function eachSteps(pp: any, callback: any) {
if (pp.stages) {
for (const stage of pp.stages) {
if (stage.tasks) {
for (const task of stage.tasks) {
if (task.steps) {
for (const step of task.steps) {
callback(step, task, stage);
}
}
}
}
}
}
}
function doValidate() {
validateErrors.value = {};
@@ -748,15 +733,8 @@ export default defineComponent({
toggleEditMode(false);
};
function findStep(id: string) {
let found = null;
const pp = pipeline.value;
eachSteps(pp, (step: any, task: any, stage: any) => {
if (step.id === id) {
found = step;
}
});
return found;
function fundStepFromPipeline(id: string) {
return findStep(pipeline.value, id);
}
return {
@@ -766,7 +744,7 @@ export default defineComponent({
cancel,
saveLoading,
hasValidateError,
findStep,
findStep: fundStepFromPipeline,
};
}
@@ -2,7 +2,8 @@ import { forEach } from "lodash-es";
import { mySuiteApi } from "/@/views/certd/suite/mine/api";
import { notification } from "ant-design-vue";
import { useSettingStore } from "/@/store/modules/settings";
//@ts-ignore
import forge from "node-forge";
export function eachStages(list: any[], exec: (item: any, runnableType: string) => void, runnableType: string = "stage") {
if (!list || list.length <= 0) {
return;
@@ -17,6 +18,42 @@ export function eachStages(list: any[], exec: (item: any, runnableType: string)
});
}
export function eachSteps(pipeline: any, callback: any) {
const pp = pipeline;
if (pp.stages) {
for (const stage of pp.stages) {
if (stage.tasks) {
for (const task of stage.tasks) {
if (task.steps) {
for (const step of task.steps) {
callback(step, task, stage);
}
}
}
}
}
}
}
export function findStep(pipeline: any, id: string) {
const pp = pipeline;
if (pp.stages) {
for (const stage of pp.stages) {
if (stage.tasks) {
for (const task of stage.tasks) {
if (task.steps) {
for (const step of task.steps) {
if (step.id === id) {
return step;
}
}
}
}
}
}
}
}
export async function checkPipelineLimit() {
const settingsStore = useSettingStore();
if (settingsStore.isComm && settingsStore.suiteSetting.enabled) {
@@ -32,3 +69,34 @@ export async function checkPipelineLimit() {
}
}
}
export function readCertDetail(crt: string) {
const detail = forge.pki.certificateFromPem(crt);
const expires = detail.notAfter;
return { detail, expires };
}
export function getAllDomainsFromCrt(crt: string) {
const { detail } = readCertDetail(crt);
const domains = [];
// 1. 提取SAN中的DNS名称
const sanExtension = detail.extensions.find((ext: any) => ext.name === "subjectAltName");
if (sanExtension) {
sanExtension.altNames.forEach((altName: any) => {
if (altName.type === 2) {
// type=2 表示DNS名称
domains.push(altName.value);
}
});
}
// 2. 如果没有SAN,回退到CN(通用名称)
if (domains.length === 0) {
const cnAttr = detail.subject.attributes.find((attr: any) => attr.name === "commonName");
if (cnAttr) {
domains.push(cnAttr.value);
}
}
return domains;
}