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

pull/361/head
xiaojunnuo 2025-03-22 02:06:02 +08:00
parent fedf90ea78
commit fbb66f3c43
25 changed files with 329 additions and 511 deletions

View File

@ -4,6 +4,7 @@ import type { CertInfo } from "./acme.js";
import { CertReader } from "./cert-reader.js"; import { CertReader } from "./cert-reader.js";
import JSZip from "jszip"; import JSZip from "jszip";
import { CertConverter } from "./convert.js"; import { CertConverter } from "./convert.js";
export const EVENT_CERT_APPLY_SUCCESS = "CertApply.success";
export abstract class CertApplyBaseConvertPlugin extends AbstractTaskPlugin { export abstract class CertApplyBaseConvertPlugin extends AbstractTaskPlugin {
@TaskInput({ @TaskInput({
@ -76,6 +77,16 @@ export abstract class CertApplyBaseConvertPlugin extends AbstractTaskPlugin {
abstract onInit(): Promise<void>; abstract onInit(): Promise<void>;
//必须output之后执行
async emitCertApplySuccess() {
const emitter = this.ctx.emitter;
const value = {
cert: this.cert,
file: this._result.files[0].path,
};
await emitter.emit(EVENT_CERT_APPLY_SUCCESS, value);
}
async output(certReader: CertReader, isNew: boolean) { async output(certReader: CertReader, isNew: boolean) {
const cert: CertInfo = certReader.toCertInfo(); const cert: CertInfo = certReader.toCertInfo();
this.cert = cert; this.cert = cert;

View File

@ -1,15 +1,9 @@
import { NotificationBody, Step, TaskEmitter, TaskInput } from "@certd/pipeline"; import { NotificationBody, Step, TaskInput } from "@certd/pipeline";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { CertReader } from "./cert-reader.js"; import { CertReader } from "./cert-reader.js";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { CertApplyBaseConvertPlugin } from "./base-convert.js"; import { CertApplyBaseConvertPlugin } from "./base-convert.js";
export const EVENT_CERT_APPLY_SUCCESS = "CertApply.success";
export async function emitCertApplySuccess(emitter: TaskEmitter, cert: CertReader) {
await emitter.emit(EVENT_CERT_APPLY_SUCCESS, cert);
}
export abstract class CertApplyBasePlugin extends CertApplyBaseConvertPlugin { export abstract class CertApplyBasePlugin extends CertApplyBaseConvertPlugin {
@TaskInput({ @TaskInput({
title: "邮箱", title: "邮箱",
@ -75,7 +69,7 @@ export abstract class CertApplyBasePlugin extends CertApplyBaseConvertPlugin {
if (cert != null) { if (cert != null) {
await this.output(cert, true); await this.output(cert, true);
await emitCertApplySuccess(this.ctx.emitter, cert); await this.emitCertApplySuccess();
//清空后续任务的状态,让后续任务能够重新执行 //清空后续任务的状态,让后续任务能够重新执行
this.clearLastStatus(); this.clearLastStatus();

View File

@ -1,5 +0,0 @@
import { CertInfo } from "../acme";
export interface ICertApplyUploadService {
getCertInfo: (opts: { certId: number; userId: number }) => Promise<any>;
updateCert: (opts: { certId: number; cert: CertInfo; userId: number }) => Promise<any>;
}

View File

@ -2,9 +2,8 @@ import { IsTaskPlugin, pluginGroups, RunStrategy, Step, TaskInput, TaskOutput }
import type { CertInfo } from "../acme.js"; import type { CertInfo } from "../acme.js";
import { CertReader } from "../cert-reader.js"; import { CertReader } from "../cert-reader.js";
import { CertApplyBaseConvertPlugin } from "../base-convert.js"; import { CertApplyBaseConvertPlugin } from "../base-convert.js";
export * from "./d.js";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ICertApplyUploadService } from "./d";
export { CertReader }; export { CertReader };
export type { CertInfo }; export type { CertInfo };
@IsTaskPlugin({ @IsTaskPlugin({
@ -84,7 +83,7 @@ export class CertApplyUploadPlugin extends CertApplyBaseConvertPlugin {
} }
`, `,
}) })
certInfoId!: string; uploadCert!: CertInfo;
@TaskOutput({ @TaskOutput({
title: "证书MD5", title: "证书MD5",
@ -100,14 +99,7 @@ export class CertApplyUploadPlugin extends CertApplyBaseConvertPlugin {
async onInit(): Promise<void> {} async onInit(): Promise<void> {}
async getCertFromStore() { async getCertFromStore() {
const certApplyUploadService: ICertApplyUploadService = await this.ctx.serviceGetter.get("CertApplyUploadService"); const certReader = new CertReader(this.uploadCert);
const certInfo = await certApplyUploadService.getCertInfo({
certId: Number(this.certInfoId),
userId: this.pipeline.userId,
});
const certReader = new CertReader(certInfo);
if (!certReader.expires && certReader.expires < new Date().getTime()) { if (!certReader.expires && certReader.expires < new Date().getTime()) {
throw new Error("证书已过期,停止部署,请重新上传证书"); throw new Error("证书已过期,停止部署,请重新上传证书");
} }
@ -121,39 +113,43 @@ export class CertApplyUploadPlugin extends CertApplyBaseConvertPlugin {
const leftDays = dayjs(certReader.expires).diff(dayjs(), "day"); const leftDays = dayjs(certReader.expires).diff(dayjs(), "day");
this.logger.info(`证书过期时间${dayjs(certReader.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${leftDays}`); this.logger.info(`证书过期时间${dayjs(certReader.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${leftDays}`);
const lastCrtMd5 = this.lastStatus?.status?.output?.certMd5;
this.logger.info("证书MD5", crtMd5); if (!this.ctx.inputChanged) {
this.logger.info("上次证书MD5", lastCrtMd5); this.logger.info("输入参数无变化");
if (lastCrtMd5 === crtMd5) { const lastCrtMd5 = this.lastStatus?.status?.output?.certMd5;
this.logger.info("证书无变化,跳过"); this.logger.info("证书MD5", crtMd5);
//输出证书MD5 this.logger.info("上次证书MD5", lastCrtMd5);
this.certMd5 = crtMd5; if (lastCrtMd5 === crtMd5) {
await this.output(certReader, false); this.logger.info("证书无变化,跳过");
return "skip"; //输出证书MD5
this.certMd5 = crtMd5;
await this.output(certReader, false);
return "skip";
}
this.logger.info("证书有变化,重新部署");
} else {
this.logger.info("输入参数有变化,重新部署");
} }
this.logger.info("证书有变化,重新部署");
this.clearLastStatus(); this.clearLastStatus();
//输出证书MD5 //输出证书MD5
this.certMd5 = crtMd5; this.certMd5 = crtMd5;
await this.output(certReader, true); await this.output(certReader, true);
//必须output之后执行
await this.emitCertApplySuccess();
return; return;
} }
async onCertUpdate(data: any) { async onCertUpdate(data: any) {
const certApplyUploadService = await this.ctx.serviceGetter.get("CertApplyUploadService"); const certReader = new CertReader(data);
const res = await certApplyUploadService.updateCert({
certId: this.certInfoId,
userId: this.ctx.user.id,
cert: {
crt: data.crt,
key: data.key,
},
});
return { return {
input: { input: {
domains: res.domains, uploadCert: {
crt: data.crt,
key: data.key,
},
domains: certReader.getAllDomains(),
}, },
}; };
} }

View File

@ -1,3 +1,5 @@
export { EVENT_CERT_APPLY_SUCCESS } from "./cert-plugin/base-convert.js";
export * from "./cert-plugin/index.js"; export * from "./cert-plugin/index.js";
export * from "./cert-plugin/lego/index.js"; export * from "./cert-plugin/lego/index.js";
export * from "./cert-plugin/custom/index.js"; export * from "./cert-plugin/custom/index.js";

View File

@ -67,6 +67,7 @@
"lucide-vue-next": "^0.477.0", "lucide-vue-next": "^0.477.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"node-forge": "^1.3.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"pinia": "2.1.7", "pinia": "2.1.7",

View File

@ -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 DnsProviderSelector from "/@/components/plugins/cert/dns-provider-selector/index.vue";
import DomainsVerifyPlanEditor from "/@/components/plugins/cert/domains-verify-plan-editor/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 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 InputPassword from "./common/input-password.vue";
import CertInfoUpdater from "/@/views/certd/pipeline/cert-upload/index.vue";
import ApiTest from "./common/api-test.vue"; import ApiTest from "./common/api-test.vue";
export * from "./cert/index.js"; export * from "./cert/index.js";
export default { export default {

View File

@ -109,24 +109,23 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
copy: { show: false }, copy: { show: false },
edit: { 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: { remove: {
order: 10, order: 10,
show: false, 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: { columns: {

View File

@ -3,7 +3,7 @@
<template #header> <template #header>
<div class="title"> <div class="title">
证书仓库 证书仓库
<span class="sub">从流水线生成的证书后续将支持手动上传证书并部署</span> <span class="sub">从流水线生成的证书</span>
</div> </div>
</template> </template>
<fs-crud ref="crudRef" v-bind="crudBinding"> </fs-crud> <fs-crud ref="crudRef" v-bind="crudBinding"> </fs-crud>
@ -11,12 +11,12 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineComponent, onActivated, onMounted } from "vue"; import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud"; import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud"; import createCrudOptions from "./crud";
import { createApi } from "./api";
defineOptions({ defineOptions({
name: "CertStore" name: "CertStore",
}); });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: {} }); const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: {} });

View File

@ -8,7 +8,7 @@ export async function GetList(query: any) {
return await request({ return await request({
url: apiPrefix + "/page", url: apiPrefix + "/page",
method: "post", method: "post",
data: query data: query,
}); });
} }
@ -16,7 +16,7 @@ export async function AddObj(obj: any) {
return await request({ return await request({
url: apiPrefix + "/add", url: apiPrefix + "/add",
method: "post", method: "post",
data: obj data: obj,
}); });
} }
@ -24,7 +24,7 @@ export async function UpdateObj(obj: any) {
return await request({ return await request({
url: apiPrefix + "/update", url: apiPrefix + "/update",
method: "post", method: "post",
data: obj data: obj,
}); });
} }
@ -32,7 +32,7 @@ export async function DelObj(id: any) {
return await request({ return await request({
url: apiPrefix + "/delete", url: apiPrefix + "/delete",
method: "post", method: "post",
params: { id } params: { id },
}); });
} }
@ -40,7 +40,7 @@ export async function GetObj(id: any) {
return await request({ return await request({
url: apiPrefix + "/info", url: apiPrefix + "/info",
method: "post", method: "post",
params: { id } params: { id },
}); });
} }
@ -48,7 +48,7 @@ export async function GetDetail(id: any) {
return await request({ return await request({
url: apiPrefix + "/detail", url: apiPrefix + "/detail",
method: "post", method: "post",
params: { id } params: { id },
}); });
} }
@ -56,7 +56,7 @@ export async function Save(pipelineEntity: any) {
return await request({ return await request({
url: apiPrefix + "/save", url: apiPrefix + "/save",
method: "post", method: "post",
data: pipelineEntity data: pipelineEntity,
}); });
} }
@ -64,7 +64,7 @@ export async function Trigger(id: any, stepId?: string) {
return await request({ return await request({
url: apiPrefix + "/trigger", url: apiPrefix + "/trigger",
method: "post", method: "post",
params: { id, stepId } params: { id, stepId },
}); });
} }
@ -72,7 +72,7 @@ export async function Cancel(historyId: any) {
return await request({ return await request({
url: apiPrefix + "/cancel", url: apiPrefix + "/cancel",
method: "post", method: "post",
params: { historyId } params: { historyId },
}); });
} }
@ -80,7 +80,7 @@ export async function BatchUpdateGroup(pipelineIds: number[], groupId: number):
return await request({ return await request({
url: apiPrefix + "/batchUpdateGroup", url: apiPrefix + "/batchUpdateGroup",
method: "post", 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({ return await request({
url: apiPrefix + "/batchDelete", url: apiPrefix + "/batchDelete",
method: "post", method: "post",
data: { ids: pipelineIds } data: { ids: pipelineIds },
}); });
} }
@ -96,14 +96,14 @@ export async function GetFiles(pipelineId: number) {
return await request({ return await request({
url: historyApiPrefix + "/files", url: historyApiPrefix + "/files",
method: "post", method: "post",
params: { pipelineId } params: { pipelineId },
}); });
} }
export async function GetCount() { export async function GetCount() {
return await request({ return await request({
url: apiPrefix + "/count", url: apiPrefix + "/count",
method: "post" method: "post",
}); });
} }
@ -119,6 +119,6 @@ export async function GetCert(pipelineId: number): Promise<CertInfo> {
return await request({ return await request({
url: certApiPrefix + "/get", url: certApiPrefix + "/get",
method: "post", method: "post",
params: { id: pipelineId } params: { id: pipelineId },
}); });
} }

View File

@ -1,24 +1,23 @@
<template> <template>
<div class="cert-info-updater w-full flex items-center"> <div class="cert-info-updater w-full flex items-center">
<div class="flex-o"> <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" /> <fs-button type="primary" size="small" class="ml-1" icon="ion:upload" text="更新证书" @click="onUploadClick" />
</div> </div>
</div> </div>
</template> </template>
<script lang="tsx" setup> <script lang="tsx" setup>
import { inject } from "vue"; import { computed, inject } from "vue";
import { dict } from "@fast-crud/fast-crud"; import { useCertUpload } from "./use";
import { certInfoApi } from "../api"; import { getAllDomainsFromCrt } from "/@/views/certd/pipeline/utils";
import { useCertUpload } from "/@/views/certd/pipeline/cert-upload/use";
defineOptions({ defineOptions({
name: "CertInfoUpdater", name: "CertInfoUpdater",
}); });
const props = defineProps<{ const props = defineProps<{
modelValue?: number | string; modelValue?: { crt: string; key: string };
type?: string; type?: string;
placeholder?: string; placeholder?: string;
size?: string; size?: string;
@ -26,33 +25,28 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits(["updated", "update:modelValue"]); const emit = defineEmits(["updated", "update:modelValue"]);
const certInfoDict = dict({ const { openUpdateCertDialog } = useCertUpload();
value: "id",
label: "domain", const domain = computed(() => {
getNodesByValues: async (values: any[]) => { if (!props.modelValue?.crt) {
const res = await certInfoApi.GetOptionsByIds(values); return "";
if (res.length > 0) { }
emit("updated", { const domains = getAllDomainsFromCrt(props.modelValue?.crt);
domains: res[0].domains,
}); return domains[0];
}
return res;
},
}); });
const { openUpdateCertDialog } = useCertUpload(); function onUpdated(res: { uploadCert: any }) {
function onUpdated(res: any) { debugger;
if (!props.modelValue) { emit("update:modelValue", res.uploadCert);
emit("update:modelValue", res.id); const domains = getAllDomainsFromCrt(res.uploadCert.crt);
} emit("updated", { domains });
emit("updated", res);
} }
const pipeline: any = inject("pipeline"); const pipeline: any = inject("pipeline");
function onUploadClick() { function onUploadClick() {
debugger;
openUpdateCertDialog({ openUpdateCertDialog({
id: props.modelValue,
pipelineId: pipeline.id,
onSubmit: onUpdated, onSubmit: onUpdated,
}); });
} }

View File

@ -1,23 +1,62 @@
import { compute, useFormWrapper } from "@fast-crud/fast-crud"; import { compute, useFormWrapper } from "@fast-crud/fast-crud";
import NotificationSelector from "/@/views/certd/notification/notification-selector/index.vue"; import NotificationSelector from "/@/views/certd/notification/notification-selector/index.vue";
import * as api from "./api"; import { cloneDeep, omit } from "lodash-es";
import { omit, cloneDeep, set } from "lodash-es";
import { useReference } from "/@/use/use-refrence"; import { useReference } from "/@/use/use-refrence";
import { ref } from "vue"; import { ref } from "vue";
import * as pluginApi from "../api.plugin"; import * as pluginApi from "../api.plugin";
import { checkPipelineLimit } from "/@/views/certd/pipeline/utils"; import * as api from "../api";
import { notification } from "ant-design-vue"; import { checkPipelineLimit, getAllDomainsFromCrt } from "/@/views/certd/pipeline/utils";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { nanoid } from "nanoid";
export function useCertUpload() { export function useCertUpload() {
const { openCrudFormDialog } = useFormWrapper(); const { openCrudFormDialog } = useFormWrapper();
const router = useRouter(); 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) { async function buildUploadCertPluginInputs(getFormData: any) {
const plugin: any = await pluginApi.GetPluginDefine("CertApplyUpload"); const plugin: any = await pluginApi.GetPluginDefine("CertApplyUpload");
const inputs: any = {}; const inputs: any = {};
for (const inputKey in plugin.input) { for (const inputKey in plugin.input) {
if (inputKey === "certInfoId" || inputKey === "domains") { if (inputKey === "uploadCert" || inputKey === "domains") {
continue; continue;
} }
const inputDefine = cloneDeep(plugin.input[inputKey]); const inputDefine = cloneDeep(plugin.input[inputKey]);
@ -66,42 +105,7 @@ export function useCertUpload() {
return { return {
crudOptions: { crudOptions: {
columns: { columns: {
"cert.crt": { ...cloneDeep(certInputs),
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,
},
},
...inputs, ...inputs,
notification: { notification: {
title: "失败通知", title: "失败通知",
@ -128,6 +132,9 @@ export function useCertUpload() {
saveRemind: false, saveRemind: false,
}, },
async doSubmit({ form }: any) { async doSubmit({ form }: any) {
const cert = form.uploadCert;
const domains = getAllDomainsFromCrt(cert.crt);
const notifications = []; const notifications = [];
if (form.notification != null) { if (form.notification != null) {
notifications.push({ notifications.push({
@ -138,18 +145,54 @@ export function useCertUpload() {
}); });
} }
const req = { const pipelineTitle = domains[0] + "上传证书部署";
id: form.id, const input = omit(form, ["id", "cert", "notification", "notificationTarget"]);
cert: form.cert, const pipeline = {
pipeline: { title: pipelineTitle,
input: omit(form, ["id", "cert", "notification", "notificationTarget"]), runnableType: "pipeline",
notifications, 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({ router.push({
path: "/certd/pipeline/detail", 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; wrapperRef.value = wrapper;
} }
async function openUpdateCertDialog(opts: { id?: any; onSubmit?: any; pipelineId?: any }) { async function openUpdateCertDialog(opts: { onSubmit?: any }) {
function createCrudOptions() { function createCrudOptions() {
return { return {
crudOptions: { crudOptions: {
columns: { columns: {
"cert.crt": { ...cloneDeep(certInputs),
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 },
},
},
}, },
form: { form: {
wrapper: { wrapper: {
title: "更新证书", title: "手动上传证书",
saveRemind: false, saveRemind: false,
}, },
async afterSubmit() { async afterSubmit() {},
notification.success({ message: "更新成功" });
},
async doSubmit({ form }: any) { async doSubmit({ form }: any) {
const req = {
id: opts.id,
pipelineId: opts.pipelineId,
cert: form.cert,
};
const res = await api.UploadCert(req);
if (opts.onSubmit) { if (opts.onSubmit) {
await opts.onSubmit(res); await opts.onSubmit(form);
} }
}, },
}, },

View File

@ -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 { omit } from "lodash-es";
import * as api from "/@/views/certd/pipeline/api"; import * as api from "/@/views/certd/pipeline/api";
import { message } from "ant-design-vue"; import { message } from "ant-design-vue";
@ -52,6 +52,7 @@ export function useCertd(certdFormRef: any) {
await checkPipelineLimit(); await checkPipelineLimit();
certdFormRef.value.open(async ({ form }: any) => { certdFormRef.value.open(async ({ form }: any) => {
const certDetail = readCertDetail(form.cert.crt);
// 添加certd pipeline // 添加certd pipeline
const triggers = []; const triggers = [];
if (form.triggerCron) { if (form.triggerCron) {

View File

@ -22,6 +22,7 @@ const props = defineProps<{
form: any; form: any;
input: any; input: any;
pluginName: string; pluginName: string;
stepId: string;
}>(); }>();
const attrs = useAttrs(); const attrs = useAttrs();
@ -85,7 +86,7 @@ const doPluginFormSubmit = async (formData: any) => {
if (res.input) { if (res.input) {
const { save, findStep } = getPipelineScope(); const { save, findStep } = getPipelineScope();
const step = findStep(res.input); const step = findStep(props.stepId);
if (step) { if (step) {
// //
mergeWith(step.input, res.input, (objValue, srcValue) => { mergeWith(step.input, res.input, (objValue, srcValue) => {

View File

@ -41,6 +41,7 @@ async function init() {
...shortcut, ...shortcut,
pluginName: stepType, pluginName: stepType,
input: step.input, input: step.input,
stepId: step.id,
}); });
} }
} }

View File

@ -104,7 +104,7 @@
</template> </template>
<span class="flex-o w-100"> <span class="flex-o w-100">
<span class="ellipsis flex-1 task-title" :class="{ 'in-edit': editMode, deleted: task.disabled }">{{ task.title }}</span> <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> </span>
</a-popover> </a-popover>
</a-button> </a-button>
@ -273,6 +273,7 @@ import { FsIcon } from "@fast-crud/fast-crud";
import { useSettingStore } from "/@/store/modules/settings"; import { useSettingStore } from "/@/store/modules/settings";
import { useUserStore } from "/@/store/modules/user"; import { useUserStore } from "/@/store/modules/user";
import TaskShortcuts from "./component/shortcut/task-shortcuts.vue"; import TaskShortcuts from "./component/shortcut/task-shortcuts.vue";
import { eachSteps, findStep } from "../utils";
export default defineComponent({ export default defineComponent({
name: "PipelineEdit", name: "PipelineEdit",
// eslint-disable-next-line vue/no-unused-components // eslint-disable-next-line vue/no-unused-components
@ -648,22 +649,6 @@ export default defineComponent({
errors.push(error); 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() { function doValidate() {
validateErrors.value = {}; validateErrors.value = {};
@ -748,15 +733,8 @@ export default defineComponent({
toggleEditMode(false); toggleEditMode(false);
}; };
function findStep(id: string) { function fundStepFromPipeline(id: string) {
let found = null; return findStep(pipeline.value, id);
const pp = pipeline.value;
eachSteps(pp, (step: any, task: any, stage: any) => {
if (step.id === id) {
found = step;
}
});
return found;
} }
return { return {
@ -766,7 +744,7 @@ export default defineComponent({
cancel, cancel,
saveLoading, saveLoading,
hasValidateError, hasValidateError,
findStep, findStep: fundStepFromPipeline,
}; };
} }

View File

@ -2,7 +2,8 @@ import { forEach } from "lodash-es";
import { mySuiteApi } from "/@/views/certd/suite/mine/api"; import { mySuiteApi } from "/@/views/certd/suite/mine/api";
import { notification } from "ant-design-vue"; import { notification } from "ant-design-vue";
import { useSettingStore } from "/@/store/modules/settings"; 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") { export function eachStages(list: any[], exec: (item: any, runnableType: string) => void, runnableType: string = "stage") {
if (!list || list.length <= 0) { if (!list || list.length <= 0) {
return; 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() { export async function checkPipelineLimit() {
const settingsStore = useSettingStore(); const settingsStore = useSettingStore();
if (settingsStore.isComm && settingsStore.suiteSetting.enabled) { 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;
}

View File

@ -1,11 +1,11 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core'; import { ALL, Body, Controller, Get, Inject, Post, Provide, Query } from "@midwayjs/core";
import { Constants, CrudController } from '@certd/lib-server'; import { CommonException, Constants, CrudController } from "@certd/lib-server";
import { AuthService } from '../../../modules/sys/authority/service/auth-service.js'; import { AuthService } from "../../../modules/sys/authority/service/auth-service.js";
import { CertInfoService } from '../../../modules/monitor/index.js'; import { CertInfoService } from "../../../modules/monitor/index.js";
import { PipelineService } from '../../../modules/pipeline/service/pipeline-service.js'; import { PipelineService } from "../../../modules/pipeline/service/pipeline-service.js";
import { SelectQueryBuilder } from "typeorm"; import { SelectQueryBuilder } from "typeorm";
import { CertUploadService } from "../../../modules/monitor/service/cert-upload-service.js"; import { logger } from "@certd/basic";
import { CertInfo } from "@certd/plugin-cert"; import fs from "fs";
/** /**
*/ */
@ -17,8 +17,6 @@ export class CertInfoController extends CrudController<CertInfoService> {
@Inject() @Inject()
authService: AuthService; authService: AuthService;
@Inject() @Inject()
certUploadService: CertUploadService;
@Inject()
pipelineService: PipelineService; pipelineService: PipelineService;
getService(): CertInfoService { getService(): CertInfoService {
@ -131,26 +129,28 @@ export class CertInfoController extends CrudController<CertInfoService> {
return this.ok(certInfo); return this.ok(certInfo);
} }
@Post('/upload', { summary: Constants.per.authOnly }) @Get('/download', { summary: Constants.per.authOnly })
async upload(@Body(ALL) body: {cert: CertInfo, pipeline: any, id?: number}) { async download(@Query('id') id: number) {
if (body.id) { const certInfo = await this.service.info(id)
//修改 if (certInfo == null) {
await this.service.checkUserId(body.id, this.getUserId()); throw new CommonException('file not found');
await this.certUploadService.updateCert({
id: body.id,
userId: this.getUserId(),
cert: body.cert,
});
return this.ok();
}else{
//添加
const res = await this.certUploadService.createUploadCertPipeline({
userId: this.getUserId(),
cert: body.cert,
pipeline: body.pipeline,
});
return this.ok(res)
} }
if (certInfo.userId !== this.getUserId()) {
throw new CommonException('file not found');
}
// koa send file
// 下载文件的名称
// const filename = file.filename;
// 要下载的文件的完整路径
const path = certInfo.certFile;
if (!path) {
throw new CommonException('file not found');
}
logger.info(`download:${path}`);
// 以流的形式下载文件
this.ctx.attachment(path);
this.ctx.set('Content-Type', 'application/octet-stream');
return fs.createReadStream(path);
} }
} }

View File

@ -13,7 +13,6 @@ import {
import {EmailService} from '../../../modules/basic/service/email-service.js'; import {EmailService} from '../../../modules/basic/service/email-service.js';
import {http, HttpRequestConfig, logger, mergeUtils, utils} from '@certd/basic'; import {http, HttpRequestConfig, logger, mergeUtils, utils} from '@certd/basic';
import {NotificationService} from '../../../modules/pipeline/service/notification-service.js'; import {NotificationService} from '../../../modules/pipeline/service/notification-service.js';
import {CertApplyUploadService} from "../../../modules/pipeline/service/cert-apply-upload-service.js";
@Provide() @Provide()
@Controller('/api/pi/handle') @Controller('/api/pi/handle')
@ -24,8 +23,6 @@ export class HandleController extends BaseController {
@Inject() @Inject()
emailService: EmailService; emailService: EmailService;
@Inject()
certApplyUploadService: CertApplyUploadService;
@Inject() @Inject()
notificationService: NotificationService; notificationService: NotificationService;
@ -97,7 +94,6 @@ export class HandleController extends BaseController {
}; };
const serviceContainer:any = { const serviceContainer:any = {
CertApplyUploadService:this.certApplyUploadService
} }
const serviceGetter = { const serviceGetter = {
get:(name: string) => { get:(name: string) => {

View File

@ -1,8 +1,8 @@
import { Autoload, Init, Inject, Scope, ScopeEnum } from '@midwayjs/core'; import { Autoload, Init, Inject, Scope, ScopeEnum } from "@midwayjs/core";
import { CertInfoService } from '../monitor/index.js'; import { CertInfoService } from "../monitor/index.js";
import { pipelineEmitter } from '@certd/pipeline'; import { pipelineEmitter } from "@certd/pipeline";
import { CertReader, EVENT_CERT_APPLY_SUCCESS } from '@certd/plugin-cert'; import { CertInfo, EVENT_CERT_APPLY_SUCCESS } from "@certd/plugin-cert";
import { PipelineEvent } from '@certd/pipeline/dist/service/emit.js'; import { PipelineEvent } from "@certd/pipeline/dist/service/emit.js";
@Autoload() @Autoload()
@Scope(ScopeEnum.Request, { allowDowngrade: true }) @Scope(ScopeEnum.Request, { allowDowngrade: true })
@ -15,8 +15,8 @@ export class AutoEPipelineEmitterRegister {
await this.onCertApplySuccess(); await this.onCertApplySuccess();
} }
async onCertApplySuccess() { async onCertApplySuccess() {
pipelineEmitter.on(EVENT_CERT_APPLY_SUCCESS, async (event: PipelineEvent<CertReader>) => { pipelineEmitter.on(EVENT_CERT_APPLY_SUCCESS, async (event: PipelineEvent<{cert:CertInfo,file:string}>) => {
await this.certInfoService.updateCertByPipelineId(event.pipeline.id, event.event); await this.certInfoService.updateCertByPipelineId(event.pipeline.id, event.event.cert, event.event.file);
}); });
} }
} }

View File

@ -11,6 +11,7 @@ export type UploadCertReq = {
certReader: CertReader; certReader: CertReader;
fromType?: string; fromType?: string;
userId?: number; userId?: number;
file?:any
}; };
@ -38,7 +39,7 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
}); });
} }
async updateDomains(pipelineId: number, userId: number, domains: string[]) { async updateDomains(pipelineId: number, userId: number, domains: string[],fromType?:string) {
const found = await this.repository.findOne({ const found = await this.repository.findOne({
where: { where: {
pipelineId, pipelineId,
@ -53,6 +54,7 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
//create //create
bean.pipelineId = pipelineId; bean.pipelineId = pipelineId;
bean.userId = userId; bean.userId = userId;
bean.fromType = fromType
if (!domains || domains.length === 0) { if (!domains || domains.length === 0) {
return; return;
} }
@ -133,7 +135,7 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
return certReader.toCertInfo(); return certReader.toCertInfo();
} }
async updateCertByPipelineId(pipelineId: number, certReader: CertReader, fromType = 'pipeline') { async updateCertByPipelineId(pipelineId: number, cert: CertInfo,file?:string,fromType = 'pipeline') {
const found = await this.repository.findOne({ const found = await this.repository.findOne({
where: { where: {
pipelineId, pipelineId,
@ -141,8 +143,9 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
}); });
const bean = await this.updateCert({ const bean = await this.updateCert({
id: found?.id, id: found?.id,
certReader, certReader: new CertReader(cert),
fromType, fromType,
file
}); });
return bean; return bean;
} }
@ -165,6 +168,9 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
bean.expiresTime = certReader.expires; bean.expiresTime = certReader.expires;
bean.certProvider = certReader.detail.issuer.commonName; bean.certProvider = certReader.detail.issuer.commonName;
bean.userId = userId bean.userId = userId
if(req.file){
bean.certFile = req.file
}
await this.addOrUpdate(bean); await this.addOrUpdate(bean);
return bean; return bean;
} }

View File

@ -1,203 +0,0 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { BaseService, CommonException } from "@certd/lib-server";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { EntityManager, Repository } from "typeorm";
import { CertInfoEntity } from "../entity/cert-info.js";
import { CertInfo, CertReader } from "@certd/plugin-cert";
import { PipelineService } from "../../pipeline/service/pipeline-service.js";
import { CertInfoService } from "./cert-info-service.js";
import { PipelineEntity } from "../../pipeline/entity/pipeline.js";
import { nanoid } from "nanoid";
export type UploadCertReq = {
id?: number;
certReader: CertReader;
fromType?: string;
userId?: number;
pipelineId?: number;
};
export type UpdateCertReq = {
id: number;
cert: CertInfo;
userId?: number;
};
export type CreateUploadPipelineReq = {
cert: CertInfo;
userId: number;
pipelineId?: number;
pipeline?:{
input?:any;
notifications?:any[]
}
};
@Provide("CertUploadService")
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class CertUploadService extends BaseService<CertInfoEntity> {
@InjectEntityModel(CertInfoEntity)
repository: Repository<CertInfoEntity>;
@Inject()
pipelineService: PipelineService;
@Inject()
certInfoService: CertInfoService;
//@ts-ignore
getRepository() {
return this.repository;
}
/**
* 线
* @param req
*/
async updateCert(req: UpdateCertReq) {
const certInfoEntity = await this.certInfoService.info(req.id);
if (!certInfoEntity) {
throw new CommonException("cert not found");
}
if(certInfoEntity.fromType !== 'upload') {
throw new CommonException("cert can't be custom upload");
}
await this.uploadCert(this.repository.manager,{
id: req.id,
fromType: 'upload',
userId: req.userId,
certReader: new CertReader(req.cert)
})
return {
id: certInfoEntity.id,
domains: certInfoEntity.domains.split(','),
pipelineId: certInfoEntity.pipelineId,
fromType: certInfoEntity.fromType,
updateTime: certInfoEntity.updateTime,
}
}
async createUploadCertPipeline(body:CreateUploadPipelineReq) {
const { userId, cert } = body;
if (!cert) {
throw new CommonException("cert can't be empty");
}
const certReader = new CertReader(cert)
return await this.transaction(async (tx:EntityManager)=>{
let pipelineId = body.pipelineId;
const newCertInfo = await this.uploadCert(tx,{
certReader: certReader,
fromType: 'upload',
userId,
pipelineId
});
if(!pipelineId){
const pipelineTitle = certReader.getAllDomains()[0] +"上传证书自动部署";
const notifications = body.pipeline?.notifications ||[];
if(notifications.length === 0){
notifications.push({
type: "custom",
when: ["error", "turnToSuccess", "success"],
notificationId: 0,
title: "默认通知",
});
}
let 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: {
certInfoId: newCertInfo.id,
domains: newCertInfo.domains.split(','),
...body.pipeline?.input
},
strategy: {
runStrategy: 0, // 正常执行
},
type: "CertApplyUpload",
},
],
},
],
},
],
triggers:[],
notifications,
}
const newPipeline = await tx.getRepository(PipelineEntity).save({
userId,
title: pipelineTitle,
type:"cert_upload",
content: JSON.stringify(pipeline),
keepHistory:20,
})
newCertInfo.pipelineId = newPipeline.id;
await tx.getRepository(CertInfoEntity).save({
id: newCertInfo.id,
pipelineId: newPipeline.id
});
pipelineId = newPipeline.id;
}
return {
id:newCertInfo.id,
pipelineId: pipelineId,
domains: newCertInfo.domains.split(','),
fromType: newCertInfo.fromType,
updateTime: newCertInfo.updateTime,
}
})
}
private async uploadCert(tx:EntityManager,req: UploadCertReq) {
const bean = new CertInfoEntity();
const { id, fromType,userId, certReader } = req;
if (id) {
bean.id = id;
} else {
bean.fromType = fromType;
bean.userId = userId
}
const certInfo = certReader.toCertInfo();
bean.certInfo = JSON.stringify(certInfo);
bean.applyTime = new Date().getTime();
const domains = certReader.detail.domains.altNames;
bean.domains = domains.join(',');
bean.domain = domains[0];
bean.domainCount = domains.length;
bean.expiresTime = certReader.expires;
bean.certProvider = certReader.detail.issuer.commonName;
if (req.pipelineId){
bean.pipelineId = req.pipelineId;
}
await tx.getRepository(CertInfoEntity).save(bean);
return bean;
}
}

View File

@ -1,28 +0,0 @@
import {ICertApplyUploadService} from "@certd/plugin-cert";
import {IMidwayContext, Inject, Provide} from "@midwayjs/core";
import {CertInfoService} from "../../monitor/index.js";
import {CertUploadService} from "../../monitor/service/cert-upload-service.js";
@Provide("CertApplyUploadService")
export class CertApplyUploadService implements ICertApplyUploadService {
@Inject()
ctx : IMidwayContext
async getCertInfo(opts: { certId: number; userId: number; }) {
const certInfoService = this.ctx.getApp().getApplicationContext().get<CertInfoService>("CertInfoService")
const { certId, userId } = opts;
return await certInfoService.getCertInfo({
certId,
userId: Number(userId),
});
};
async updateCert(opts: { certId: any; userId: any; cert: any; }){
const certUploadService = this.ctx.getApp().getApplicationContext().get<CertUploadService>("CertUploadService")
return await certUploadService.updateCert({
id:opts.certId,
userId:opts.userId,
cert:opts.cert
});
}
}

View File

@ -36,7 +36,6 @@ import { NotificationService } from "./notification-service.js";
import { NotificationGetter } from "./notification-getter.js"; import { NotificationGetter } from "./notification-getter.js";
import { UserSuiteEntity, UserSuiteService } from "@certd/commercial-core"; import { UserSuiteEntity, UserSuiteService } from "@certd/commercial-core";
import { CertInfoService } from "../../monitor/service/cert-info-service.js"; import { CertInfoService } from "../../monitor/service/cert-info-service.js";
import {CertApplyUploadService} from "./cert-apply-upload-service.js";
const runningTasks: Map<string | number, Executor> = new Map(); const runningTasks: Map<string | number, Executor> = new Map();
@ -93,8 +92,6 @@ export class PipelineService extends BaseService<PipelineEntity> {
@Inject() @Inject()
certInfoService: CertInfoService; certInfoService: CertInfoService;
@Inject()
certApplyUploadService: CertApplyUploadService;
//@ts-ignore //@ts-ignore
getRepository() { getRepository() {
return this.repository; return this.repository;
@ -196,10 +193,11 @@ export class PipelineService extends BaseService<PipelineEntity> {
await this.registerTriggerById(bean.id); await this.registerTriggerById(bean.id);
//保存域名信息到certInfo表 //保存域名信息到certInfo表
if(bean.from !== 'cert_upload'){ let fromType = 'pipeline';
await this.certInfoService.updateDomains(pipeline.id, pipeline.userId || bean.userId, domains); if(bean.type === 'cert_upload') {
fromType = 'upload';
} }
await this.certInfoService.updateDomains(pipeline.id, pipeline.userId || bean.userId, domains,fromType);
return bean; return bean;
} }
@ -483,9 +481,7 @@ export class PipelineService extends BaseService<PipelineEntity> {
const siteInfo = await this.sysSettingsService.getSetting<SysSiteInfo>(SysSiteInfo); const siteInfo = await this.sysSettingsService.getSetting<SysSiteInfo>(SysSiteInfo);
sysInfo.title = siteInfo.title; sysInfo.title = siteInfo.title;
} }
const serviceContainer = { const serviceContainer = {}
CertApplyUploadService: this.certApplyUploadService
}
const serviceGetter = { const serviceGetter = {
get:(name: string) => { get:(name: string) => {
return serviceContainer[name] return serviceContainer[name]

View File

@ -1072,6 +1072,9 @@ importers:
cropperjs: cropperjs:
specifier: ^1.6.1 specifier: ^1.6.1
version: 1.6.2 version: 1.6.2
crypto-js:
specifier: ^4.2.0
version: 4.2.0
cssnano: cssnano:
specifier: ^7.0.6 specifier: ^7.0.6
version: 7.0.6(postcss@8.5.3) version: 7.0.6(postcss@8.5.3)
@ -1102,6 +1105,9 @@ importers:
nanoid: nanoid:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.2 version: 4.0.2
node-forge:
specifier: ^1.3.1
version: 1.3.1
nprogress: nprogress:
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0