mirror of https://github.com/certd/certd
perf: 插件支持导入导出
parent
d66de26de4
commit
cf8abb4528
|
@ -1,7 +1,4 @@
|
|||
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
|
||||
import { ConnectConfig } from "ssh2";
|
||||
import { SshClient } from "./ssh.js";
|
||||
|
||||
@IsAccess({
|
||||
name: "ssh",
|
||||
title: "主机登录授权",
|
||||
|
@ -9,7 +6,7 @@ import { SshClient } from "./ssh.js";
|
|||
icon: "clarity:host-line",
|
||||
input: {},
|
||||
})
|
||||
export class SshAccess extends BaseAccess implements ConnectConfig {
|
||||
export class SshAccess extends BaseAccess {
|
||||
@AccessInput({
|
||||
title: "主机地址",
|
||||
component: {
|
||||
|
@ -125,6 +122,7 @@ export class SshAccess extends BaseAccess implements ConnectConfig {
|
|||
testRequest = true;
|
||||
|
||||
async onTestRequest() {
|
||||
const { SshClient } = await import("./ssh.js");
|
||||
const client = new SshClient(this.ctx.logger);
|
||||
|
||||
await client.exec({
|
||||
|
|
|
@ -1,22 +1,33 @@
|
|||
// @ts-ignore
|
||||
import ssh2, { ConnectConfig, ExecOptions } from "ssh2";
|
||||
|
||||
import ssh2Constants from "ssh2/lib/protocol/constants.js";
|
||||
import path from "path";
|
||||
import * as _ from "lodash-es";
|
||||
import { isArray } from "lodash-es";
|
||||
import { ILogger } from "@certd/basic";
|
||||
import { SshAccess } from "./ssh-access.js";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { SocksClient } from "socks";
|
||||
import { SocksProxy, SocksProxyType } from "socks/typings/common/constants.js";
|
||||
|
||||
import fs from "fs";
|
||||
import { SocksProxyType } from "socks/typings/common/constants";
|
||||
|
||||
export type TransportItem = { localPath: string; remotePath: string };
|
||||
export interface SocksProxy {
|
||||
ipaddress?: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
type: any;
|
||||
userId?: string;
|
||||
password?: string;
|
||||
custom_auth_method?: number;
|
||||
custom_auth_request_handler?: () => Promise<Buffer>;
|
||||
custom_auth_response_size?: number;
|
||||
custom_auth_response_handler?: (data: Buffer) => Promise<boolean>;
|
||||
}
|
||||
export type SshConnectConfig = {
|
||||
sock?: any;
|
||||
};
|
||||
|
||||
export class AsyncSsh2Client {
|
||||
conn: ssh2.Client;
|
||||
conn: any;
|
||||
logger: ILogger;
|
||||
connConf: SshAccess & ssh2.ConnectConfig;
|
||||
connConf: SshAccess & SshConnectConfig;
|
||||
windows = false;
|
||||
encoding: string;
|
||||
constructor(connConf: SshAccess, logger: ILogger) {
|
||||
|
@ -40,7 +51,10 @@ export class AsyncSsh2Client {
|
|||
if (typeof this.connConf.port === "string") {
|
||||
this.connConf.port = parseInt(this.connConf.port);
|
||||
}
|
||||
const proxyOption: SocksProxy = this.parseSocksProxyFromUri(this.connConf.socksProxy);
|
||||
|
||||
const { SocksClient } = await import("socks");
|
||||
|
||||
const proxyOption = this.parseSocksProxyFromUri(this.connConf.socksProxy);
|
||||
const info = await SocksClient.createConnection({
|
||||
proxy: proxyOption,
|
||||
command: "connect",
|
||||
|
@ -53,10 +67,12 @@ export class AsyncSsh2Client {
|
|||
this.connConf.sock = info.socket;
|
||||
}
|
||||
|
||||
const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants;
|
||||
const ssh2 = await import("ssh2");
|
||||
const ssh2Constants = await import("ssh2/lib/protocol/constants.js");
|
||||
const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants.default;
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const conn = new ssh2.Client();
|
||||
const conn = new ssh2.default.Client();
|
||||
conn
|
||||
.on("error", (err: any) => {
|
||||
this.logger.error("连接失败", err);
|
||||
|
@ -197,6 +213,8 @@ export class AsyncSsh2Client {
|
|||
}
|
||||
|
||||
async shell(script: string | string[]): Promise<string> {
|
||||
const stripAnsiModule = await import("strip-ansi");
|
||||
const stripAnsi = stripAnsiModule.default;
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
this.logger.info(`执行shell脚本:[${this.connConf.host}][shell]: ` + script);
|
||||
this.conn.shell((err: Error, stream: any) => {
|
||||
|
@ -449,7 +467,7 @@ export class SshClient {
|
|||
script = script.join(" && ");
|
||||
} else {
|
||||
const newLine = isLinux ? "\n" : "\r\n";
|
||||
if (_.isArray(script)) {
|
||||
if (isArray(script)) {
|
||||
script = script as Array<string>;
|
||||
script = script.join(newLine);
|
||||
}
|
||||
|
@ -465,7 +483,7 @@ export class SshClient {
|
|||
async shell(options: { connectConf: SshAccess; script: string | Array<string> }): Promise<string> {
|
||||
let { script } = options;
|
||||
const { connectConf } = options;
|
||||
if (_.isArray(script)) {
|
||||
if (isArray(script)) {
|
||||
script = script as Array<string>;
|
||||
if (connectConf.windows) {
|
||||
script = script.join("\r\n");
|
||||
|
|
|
@ -66,6 +66,22 @@ export async function SetDisabled(data: { id?: number; name?: string; type?: str
|
|||
});
|
||||
}
|
||||
|
||||
export async function ExportPlugin(id: number) {
|
||||
return await request({
|
||||
url: apiPrefix + "/export",
|
||||
method: "post",
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function ImportPlugin(body: any) {
|
||||
return await request({
|
||||
url: apiPrefix + "/import",
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export type PluginConfigBean = {
|
||||
name: string;
|
||||
disabled: boolean;
|
||||
|
|
|
@ -2,8 +2,8 @@ import * as api from "./api";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import { Ref, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
|
||||
import { Modal } from "ant-design-vue";
|
||||
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, useFormWrapper, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
|
||||
import { Modal, notification } from "ant-design-vue";
|
||||
//@ts-ignore
|
||||
import yaml from "js-yaml";
|
||||
|
||||
|
@ -36,7 +36,74 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||
|
||||
const selectedRowKeys: Ref<any[]> = ref([]);
|
||||
context.selectedRowKeys = selectedRowKeys;
|
||||
const { openCrudFormDialog } = useFormWrapper();
|
||||
|
||||
async function openImportDialog() {
|
||||
function createCrudOptions() {
|
||||
return {
|
||||
crudOptions: {
|
||||
columns: {
|
||||
content: {
|
||||
title: "插件文件",
|
||||
type: "text",
|
||||
form: {
|
||||
component: {
|
||||
name: "pem-input",
|
||||
vModel: "modelValue",
|
||||
textarea: {
|
||||
rows: 8,
|
||||
},
|
||||
},
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
helper: "选择插件文件",
|
||||
},
|
||||
},
|
||||
override: {
|
||||
title: "同名覆盖",
|
||||
type: "dict-switch",
|
||||
dict: dict({
|
||||
data: [
|
||||
{
|
||||
value: true,
|
||||
label: "覆盖",
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
label: "不覆盖",
|
||||
},
|
||||
],
|
||||
}),
|
||||
form: {
|
||||
value: false,
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
helper: "如果已有相同名称插件,直接覆盖",
|
||||
},
|
||||
},
|
||||
},
|
||||
form: {
|
||||
wrapper: {
|
||||
title: "导入插件",
|
||||
saveRemind: false,
|
||||
},
|
||||
afterSubmit() {
|
||||
notification.success({ message: "操作成功" });
|
||||
},
|
||||
async doSubmit({ form }: any) {
|
||||
return await api.ImportPlugin({
|
||||
...form,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const { crudOptions } = createCrudOptions();
|
||||
await openCrudFormDialog({ crudOptions });
|
||||
}
|
||||
return {
|
||||
crudOptions: {
|
||||
settings: {
|
||||
|
@ -65,8 +132,18 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||
buttons: {
|
||||
add: {
|
||||
show: true,
|
||||
icon: "ion:ios-add-circle-outline",
|
||||
text: "自定义插件",
|
||||
},
|
||||
import: {
|
||||
show: true,
|
||||
icon: "ion:cloud-upload-outline",
|
||||
text: "导入",
|
||||
type: "primary",
|
||||
async click() {
|
||||
await openImportDialog();
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rowHandle: {
|
||||
|
@ -85,10 +162,33 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||
}),
|
||||
},
|
||||
remove: {
|
||||
order: 999,
|
||||
show: compute(({ row }) => {
|
||||
return row.type === "custom";
|
||||
}),
|
||||
},
|
||||
export: {
|
||||
text: null,
|
||||
icon: "ion:cloud-download-outline",
|
||||
title: "导出",
|
||||
type: "link",
|
||||
show: compute(({ row }) => {
|
||||
return row.type === "custom";
|
||||
}),
|
||||
async click({ row }) {
|
||||
//将文本内容,作为文件下载
|
||||
const content = await api.ExportPlugin(row.id);
|
||||
if (content) {
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${row.name}.yaml`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
table: {
|
||||
|
@ -182,8 +282,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||
},
|
||||
form: {
|
||||
show: true,
|
||||
helper: "必须为英文,驼峰命名,类型作为前缀\n例如AliyunDeployToCDN\n插件一旦被使用,不要修改名称",
|
||||
rules: [{ required: true }],
|
||||
helper: "必须为英文或数字,驼峰命名,类型作为前缀\n例如AliyunDeployToCDN\n插件一旦被使用,不要修改名称",
|
||||
rules: [
|
||||
{ required: true },
|
||||
{
|
||||
type: "regexp",
|
||||
pattern: /^[a-zA-Z][a-zA-Z0-9]+$/,
|
||||
message: "必须为英文或数字,驼峰命名,类型作为前缀",
|
||||
},
|
||||
],
|
||||
},
|
||||
column: {
|
||||
width: 250,
|
||||
|
@ -205,7 +312,14 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||
form: {
|
||||
show: true,
|
||||
helper: "上传到插件商店时,将作为插件名称前缀,例如:greper/pluginName",
|
||||
rules: [{ required: true }],
|
||||
rules: [
|
||||
{ required: true },
|
||||
{
|
||||
type: "regexp",
|
||||
pattern: /^[a-zA-Z][a-zA-Z0-9]+$/,
|
||||
message: "必须为英文字母或数字",
|
||||
},
|
||||
],
|
||||
},
|
||||
column: {
|
||||
width: 200,
|
||||
|
|
|
@ -26,6 +26,11 @@ process.on('uncaughtException', error => {
|
|||
});
|
||||
|
||||
@Configuration({
|
||||
// detectorOptions: {
|
||||
// ignore: [
|
||||
// '**/plugins/**'
|
||||
// ]
|
||||
// },
|
||||
imports: [
|
||||
koa,
|
||||
orm,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core';
|
||||
import { merge } from 'lodash-es';
|
||||
import { CrudController } from '@certd/lib-server';
|
||||
import { PluginService } from '../../../modules/plugin/service/plugin-service.js';
|
||||
import { PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js";
|
||||
import { CommPluginConfig, PluginConfigService } from '../../../modules/plugin/service/plugin-config-service.js';
|
||||
/**
|
||||
* 插件
|
||||
|
@ -82,4 +82,17 @@ export class PluginController extends CrudController<PluginService> {
|
|||
const res = await this.pluginConfigService.saveCommPluginConfig(body);
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
|
||||
@Post('/import', { summary: 'sys:settings:edit' })
|
||||
async import(@Body(ALL) body: PluginImportReq) {
|
||||
const res = await this.service.importPlugin(body);
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
@Post('/export', { summary: 'sys:settings:edit' })
|
||||
async export(@Body('id') id: number) {
|
||||
const res = await this.service.exportPlugin(id);
|
||||
return this.ok(res);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ export type PluginFindReq = {
|
|||
name?: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class PluginConfigService {
|
||||
|
|
|
@ -12,6 +12,10 @@ import { logger } from "@certd/basic";
|
|||
import yaml from "js-yaml";
|
||||
import { getDefaultAccessPlugin, getDefaultDeployPlugin, getDefaultDnsPlugin } from "./default-plugin.js";
|
||||
|
||||
export type PluginImportReq = {
|
||||
content: string,
|
||||
override?: boolean;
|
||||
};
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
|
@ -41,7 +45,7 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||
const builtInList = await this.getBuiltInEntityList();
|
||||
|
||||
//获取分页数据
|
||||
const data = builtInList.slice(offset, offset + limit);
|
||||
const data = builtInList.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
records: data,
|
||||
|
@ -53,7 +57,7 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||
|
||||
async getEnabledBuildInGroup(isSimple = false) {
|
||||
const groups = this.builtInPluginService.getGroups();
|
||||
if(isSimple){
|
||||
if (isSimple) {
|
||||
for (const key in groups) {
|
||||
const group = groups[key];
|
||||
group.plugins.forEach(item => {
|
||||
|
@ -97,8 +101,8 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||
});
|
||||
const disabledNames = list.map(it => it.name);
|
||||
|
||||
return builtInList.filter(it =>{
|
||||
return !disabledNames.includes(it.name)
|
||||
return builtInList.filter(it => {
|
||||
return !disabledNames.includes(it.name);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -168,33 +172,48 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||
name: param.name,
|
||||
author: param.author
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (old) {
|
||||
throw new Error(`插件${param.author}/${param.name}已存在`);
|
||||
}
|
||||
|
||||
let plugin:any = {}
|
||||
let plugin: any = {};
|
||||
if (param.pluginType === "access") {
|
||||
plugin = getDefaultAccessPlugin()
|
||||
delete param.group
|
||||
}else if (param.pluginType === "deploy") {
|
||||
plugin = getDefaultDeployPlugin()
|
||||
}else if (param.pluginType === "dnsProvider") {
|
||||
plugin = getDefaultDnsPlugin()
|
||||
delete param.group
|
||||
}else{
|
||||
plugin = getDefaultAccessPlugin();
|
||||
delete param.group;
|
||||
} else if (param.pluginType === "deploy") {
|
||||
plugin = getDefaultDeployPlugin();
|
||||
} else if (param.pluginType === "dnsProvider") {
|
||||
plugin = getDefaultDnsPlugin();
|
||||
delete param.group;
|
||||
} else {
|
||||
throw new Error(`插件类型${param.pluginType}不支持`);
|
||||
}
|
||||
|
||||
return await super.add({
|
||||
return await super.add({
|
||||
...param,
|
||||
...plugin
|
||||
});
|
||||
}
|
||||
|
||||
async update(param: any) {
|
||||
const old = await this.repository.findOne({
|
||||
where: {
|
||||
name: param.name,
|
||||
author: param.author
|
||||
}
|
||||
});
|
||||
|
||||
if (old && old.id !== param.id) {
|
||||
throw new Error(`插件${param.author}/${param.name}已存在`);
|
||||
}
|
||||
|
||||
return await super.update(param);
|
||||
}
|
||||
|
||||
async compile(code: string) {
|
||||
const ts = await import("typescript")
|
||||
const ts = await import("typescript");
|
||||
return ts.transpileModule(code, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext }
|
||||
}).outputText;
|
||||
|
@ -220,16 +239,16 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||
if (info && info.length > 0) {
|
||||
const plugin = info[0];
|
||||
|
||||
try{
|
||||
try {
|
||||
const AsyncFunction = Object.getPrototypeOf(async () => {
|
||||
}).constructor;
|
||||
// const script = await this.compile(plugin.content);
|
||||
const script = plugin.content
|
||||
const script = plugin.content;
|
||||
const getPluginClass = new AsyncFunction(script);
|
||||
return await getPluginClass({ logger: logger });
|
||||
}catch (e) {
|
||||
logger.error("编译插件失败:",e)
|
||||
throw e
|
||||
} catch (e) {
|
||||
logger.error("编译插件失败:", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -284,4 +303,80 @@ export class PluginService extends BaseService<PluginEntity> {
|
|||
});
|
||||
}
|
||||
|
||||
async exportPlugin(id: number) {
|
||||
const info = await this.info(id);
|
||||
if (!info) {
|
||||
throw new Error("插件不存在");
|
||||
}
|
||||
const metadata = yaml.load(info.metadata || "");
|
||||
const extra = yaml.load(info.extra || "");
|
||||
const content = info.content;
|
||||
delete info.metadata;
|
||||
delete info.extra;
|
||||
delete info.content;
|
||||
delete info.id;
|
||||
delete info.createTime;
|
||||
delete info.updateTime;
|
||||
const plugin = {
|
||||
...info,
|
||||
...metadata,
|
||||
...extra,
|
||||
content
|
||||
};
|
||||
|
||||
return yaml.dump(plugin) as string;
|
||||
}
|
||||
|
||||
async importPlugin(req: PluginImportReq) {
|
||||
|
||||
const loaded = yaml.load(req.content);
|
||||
if (!loaded) {
|
||||
throw new Error("插件内容不能为空");
|
||||
}
|
||||
delete loaded.id
|
||||
|
||||
const old = await this.repository.findOne({
|
||||
where: {
|
||||
name: loaded.name,
|
||||
author: loaded.author
|
||||
}
|
||||
});
|
||||
|
||||
const metadata = {
|
||||
input: loaded.input,
|
||||
output: loaded.output
|
||||
};
|
||||
const extra = {
|
||||
dependPlugins: loaded.dependPlugins,
|
||||
default: loaded.default,
|
||||
showRunStrategy: loaded.showRunStrategy
|
||||
};
|
||||
|
||||
const pluginEntity = {
|
||||
...loaded,
|
||||
metadata: yaml.dump(metadata),
|
||||
extra: yaml.dump(extra),
|
||||
content: req.content,
|
||||
disabled: false
|
||||
};
|
||||
if (!pluginEntity.pluginType) {
|
||||
throw new Error(`插件类型不能为空`);
|
||||
}
|
||||
|
||||
if (old) {
|
||||
if (!req.override) {
|
||||
throw new Error(`插件${loaded.author}/${loaded.name}已存在`);
|
||||
}
|
||||
//update
|
||||
pluginEntity.id = old.id;
|
||||
await this.update(pluginEntity);
|
||||
} else {
|
||||
//add
|
||||
const { id } = await this.add(pluginEntity);
|
||||
pluginEntity.id = id;
|
||||
}
|
||||
return {
|
||||
id: pluginEntity.id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue