refactor: pipeline edit view

pull/9/head^2
xiaojunnuo 2022-10-26 09:02:47 +08:00
parent af919c2f6e
commit 370a28c10e
48 changed files with 1606 additions and 3 deletions

@ -1 +1 @@
Subproject commit 23f1e36aa82d5d7837033a555c5ea04614cfcbcc
Subproject commit a78bb43f6b65877b4f0aad25995d7cbf3215a3bc

View File

@ -0,0 +1,21 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"prettier"
],
"env": {
"mocha": true
},
"rules": {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-ts-ignore": "off",
"@typescript-eslint/no-explicit-any": "off",
"no-unused-expressions": "off",
"max-len": [0, 160, 2, { "ignoreUrls": true }]
}
}

26
packages/core/pipeline/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
test/user.secret.ts

View File

@ -0,0 +1,5 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "ts-node/register"
}

View File

@ -0,0 +1,3 @@
{
"printWidth": 160
}

View File

@ -0,0 +1,16 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).

View File

@ -0,0 +1,44 @@
{
"name": "@certd/pipeline",
"private": true,
"version": "0.3.0",
"main": "./dist/pipeline.umd.js",
"module": "./dist/fast-crud.mjs",
"types": "./dist/es/index.d.ts",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@certd/acme-client": "^0.3.0",
"@fast-crud/fast-crud": "^1.5.0",
"@types/lodash": "^4.14.186",
"dayjs": "^1.11.6",
"lodash": "^4.17.21",
"node-forge": "^0.10.0"
},
"devDependencies": {
"@alicloud/cs20151215": "^3.0.3",
"@alicloud/openapi-client": "^0.4.0",
"@alicloud/pop-core": "^1.7.10",
"@midwayjs/core": "^3.0.0",
"@midwayjs/decorator": "^3.0.0",
"@types/chai": "^4.3.3",
"@types/mocha": "^10.0.0",
"@types/node-forge": "^1.3.0",
"@typescript-eslint/eslint-plugin": "^5.38.1",
"@typescript-eslint/parser": "^5.38.1",
"chai": "^4.3.6",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"log4js": "^6.3.0",
"mocha": "^10.1.0",
"ts-node": "^10.9.1",
"typescript": "^4.6.4",
"vite": "^3.1.0"
}
}

View File

@ -0,0 +1,3 @@
import { AbstractRegistrable } from "../registry";
export abstract class AbstractAccess extends AbstractRegistrable {}

View File

@ -0,0 +1,5 @@
import { AbstractAccess } from "./abstract-access";
export interface IAccessService {
getById(id: any): AbstractAccess;
}

View File

@ -0,0 +1,13 @@
import { Registrable } from "../registry";
import { FormItemProps } from "@fast-crud/fast-crud";
export type AccessDefine = Registrable & {
input: {
[key: string]: FormItemProps;
};
};
export function IsAccess(define: AccessDefine) {
return function (target: any) {
target.define = define;
};
}

View File

@ -0,0 +1,21 @@
import { IsAccess } from "../api";
import { AbstractAccess } from "../abstract-access";
@IsAccess({
name: "aliyun",
title: "阿里云授权",
desc: "",
input: {
accessKeyId: {
component: {
placeholder: "accessKeyId",
},
//required: true,
//rules: [{ required: true, message: "必填项" }],
},
},
})
export class AliyunAccess extends AbstractAccess {
accessKeyId = "";
accessKeySecret = "";
}

View File

@ -0,0 +1 @@
export * from "./aliyun-access";

View File

@ -0,0 +1,3 @@
export * from "./api";
export * from "./impl";
export * from "./abstract-access";

View File

@ -0,0 +1,41 @@
import { IStorage } from "./storage";
export interface IContext {
get(key: string): Promise<any>;
set(key: string, value: any): Promise<void>;
}
export class ContextFactory {
storage: IStorage;
constructor(storage: IStorage) {
this.storage = storage;
}
getContext(scope: string, namespace: string): IContext {
return new StorageContext(scope, namespace, this.storage);
}
}
export class StorageContext implements IContext {
storage: IStorage;
namespace: string;
scope: string;
constructor(scope: string, namespace: string, storage: IStorage) {
this.storage = storage;
this.scope = scope;
this.namespace = namespace;
}
async get(key: string): Promise<any> {
const str = await this.storage.get(this.scope, this.namespace, key);
if (str) {
const store = JSON.parse(str);
return store.value;
}
return null;
}
async set(key: string, value: any) {
await this.storage.set(this.scope, this.namespace, key, JSON.stringify({ value }));
}
}

View File

@ -0,0 +1,101 @@
import { ConcurrencyStrategy, Pipeline, Runnable, Stage, Step, Task } from "../d.ts/pipeline";
import _ from "lodash";
import { RunHistory } from "./run-history";
import { pluginRegistry, TaskPlugin } from "../plugin";
import { IAccessService } from "../access/access-service";
import { ContextFactory, StorageContext } from "./context";
import { IStorage, MemoryStorage } from "./storage";
import { logger } from "../utils/util.log";
import { use } from "chai";
export class Executor {
userId: any;
pipeline: Pipeline;
runtime: RunHistory = new RunHistory();
lastSuccessHistory: RunHistory;
accessService: IAccessService;
contextFactory: ContextFactory;
onChanged: (history: RunHistory) => void;
constructor(options: { userId: any; pipeline: Pipeline; storage: IStorage; onChanged: (history: RunHistory) => void; lastSuccessHistory?: RunHistory; accessService: IAccessService }) {
this.pipeline = options.pipeline;
this.lastSuccessHistory = options.lastSuccessHistory ?? new RunHistory();
this.onChanged = options.onChanged;
this.accessService = options.accessService;
this.userId = options.userId;
this.contextFactory = new ContextFactory(options.storage);
}
async run() {
await this.runWithHistory(this.pipeline, async () => {
return await this.runStages();
});
}
async runWithHistory(runnable: Runnable, run: () => Promise<any>) {
this.runtime.start(runnable);
this.onChanged(this.runtime);
try {
await run();
this.runtime.success(runnable);
this.onChanged(this.runtime);
} catch (e: any) {
logger.error(e);
this.runtime.error(runnable, e);
this.onChanged(this.runtime);
}
}
private async runStages() {
for (const stage of this.pipeline.stages) {
await this.runWithHistory(stage, async () => {
return await this.runStage(stage);
});
}
}
async runStage(stage: Stage) {
const runnerList = [];
for (const task of stage.tasks) {
const runner = this.runWithHistory(task, async () => {
return await this.runTask(task);
});
runnerList.push(runner);
}
if (stage.concurrency === ConcurrencyStrategy.Parallel) {
await Promise.all(runnerList);
} else {
for (const runner of runnerList) {
await runner;
}
}
}
private async runTask(task: Task) {
for (const step of task.steps) {
await this.runWithHistory(step, async () => {
return await this.runStep(step);
});
}
}
private async runStep(step: Step) {
//执行任务
const taskPlugin: TaskPlugin = await this.getPlugin(step.type);
const res = await taskPlugin.execute(step.input);
_.merge(this.runtime.context, res);
}
private async getPlugin(type: string): Promise<TaskPlugin> {
const pluginClass = pluginRegistry.get(type);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const plugin = new pluginClass();
await plugin.doInit({
accessService: this.accessService,
pipelineContext: this.contextFactory.getContext("pipeline", this.pipeline.id),
userContext: this.contextFactory.getContext("user", this.userId),
});
return plugin;
}
}

View File

@ -0,0 +1,2 @@
export * from "./executor";
export * from "./run-history";

View File

@ -0,0 +1,62 @@
import { Context, HistoryResult, Log, Runnable } from "../d.ts/pipeline";
import _ from "lodash";
export class RunHistory {
id: any;
logs: Log[] = [];
context: Context = {};
results: {
[key: string]: HistoryResult;
} = {};
start(runnable: Runnable) {
const status = "ing";
const now = new Date().getTime();
_.merge(runnable, { status, lastTime: now });
this.results[runnable.id] = {
startTime: new Date().getTime(),
title: runnable.title,
status,
};
this.log(runnable, `${runnable.title}<${runnable.id}> 开始执行`);
}
success(runnable: Runnable, result?: any) {
const status = "success";
const now = new Date().getTime();
_.merge(runnable, { status, lastTime: now });
_.merge(this.results[runnable.id], { status, endTime: now }, result);
this.log(
runnable,
`${this.results[runnable.id].title}<${runnable.id}> 执行成功`
);
}
error(runnable: Runnable, e: Error) {
const status = "error";
const now = new Date().getTime();
_.merge(runnable, { status, lastTime: now });
_.merge(this.results[runnable.id], {
status,
endTime: now,
errorMessage: e.message,
});
this.log(
runnable,
`${this.results[runnable.id].title}<${runnable.id}> 执行异常:${
e.message
}`,
status
);
}
log(runnable: Runnable, text: string, level = "info") {
this.logs.push({
time: new Date().getTime(),
level,
title: runnable.title,
text,
});
}
}

View File

@ -0,0 +1,86 @@
import fs from "fs";
import path from "path";
export interface IStorage {
get(scope: string, namespace: string, key: string): Promise<string | null>;
set(scope: string, namespace: string, key: string, value: string): Promise<void>;
}
export class FileStorage implements IStorage {
/**
* user / pipeline / runtime / task
*/
scope: any;
namespace: any;
root: string;
constructor(rootDir?: string) {
if (rootDir == null) {
const userHome = process.env.HOME || process.env.USERPROFILE;
rootDir = userHome + "/.certd/storage/";
}
this.root = rootDir;
if (!fs.existsSync(this.root)) {
fs.mkdirSync(this.root, { recursive: true });
}
}
writeFile(filePath: string, value: string) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, value);
return filePath;
}
readFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return null;
}
return fs.readFileSync(filePath).toString();
}
async get(scope: string, namespace: string, key: string): Promise<string | null> {
const path = `${this.root}/${this.scope}/${namespace}/${key}`;
return this.readFile(path);
}
async set(scope: string, namespace: string, key: string, value: string): Promise<void> {
const path = this.buildPath(namespace, key);
this.writeFile(path, value);
}
private buildPath(namespace: string, key: string) {
return `${this.root}/${this.scope}/${namespace}/${key}`;
}
}
export class MemoryStorage implements IStorage {
/**
* user / pipeline / runtime / task
*/
scope: any;
namespace: any;
context: {
[scope: string]: {
[key: string]: any;
};
} = {};
async get(scope: string, namespace: string, key: string): Promise<string | null> {
const context = this.context[scope];
if (context == null) {
return null;
}
return context[namespace + "." + key];
}
async set(scope: string, namespace: string, key: string, value: string): Promise<void> {
let context = this.context[scope];
if (context == null) {
context = context[scope];
}
context[namespace + "." + key] = value;
}
}

View File

@ -0,0 +1 @@
export * from "./pipeline";

View File

@ -0,0 +1,95 @@
export enum RunStrategy {
AlwaysRun,
SkipWhenSucceed,
}
export enum ConcurrencyStrategy {
Serial,
Parallel,
}
export enum NextStrategy {
AllSuccess,
OneSuccess,
}
export enum HandlerType {
//清空后续任务的状态
ClearFollowStatus,
SendEmail,
}
export type EventHandler = {
type: HandlerType;
params: {
[key: string]: any;
};
};
export type RunnableStrategy = {
runStrategy: RunStrategy;
onSuccess: EventHandler[];
onError: EventHandler[];
};
export type Step = Runnable & {
type: string; //插件类型
input: {
[key: string]: any;
};
};
export type Task = Runnable & {
steps: Step[];
};
export type Stage = Runnable & {
tasks: Task[];
concurrency: ConcurrencyStrategy;
next: NextStrategy;
};
export type Trigger = {
id: string;
title: string;
cron: string;
};
export type Runnable = {
id: string;
title: string;
status?: string;
lastTime?: number;
strategy?: RunnableStrategy;
};
export type Pipeline = Runnable & {
version: number;
stages: Stage[];
triggers: Trigger[];
};
export type Context = {
[key: string]: any;
};
export type Log = {
title: string;
time: number;
level: string;
text: string;
};
export type HistoryResult = {
title: string;
/**
*
*/
status: string;
startTime: number;
endTime?: number;
/**
*
*/
result?: string;
errorMessage?: string;
};

View File

@ -0,0 +1,29 @@
import { AbstractRegistrable } from "../registry";
import {
CreateRecordOptions,
IDnsProvider,
DnsProviderDefine,
RemoveRecordOptions,
} from "./api";
import { AbstractAccess } from "../access";
export abstract class AbstractDnsProvider
extends AbstractRegistrable
implements IDnsProvider
{
static define: DnsProviderDefine;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
access: AbstractAccess;
doInit(options: { access: AbstractAccess }) {
this.access = options.access;
this.onInit();
}
protected abstract onInit(): void;
abstract createRecord(options: CreateRecordOptions): Promise<any>;
abstract removeRecord(options: RemoveRecordOptions): Promise<any>;
}

View File

@ -0,0 +1,28 @@
import { Registrable } from "../registry";
import { dnsProviderRegistry } from "./registry";
export type DnsProviderDefine = Registrable & {
accessType: string;
};
export type CreateRecordOptions = {
fullRecord: string;
type: string;
value: any;
};
export type RemoveRecordOptions = CreateRecordOptions & {
record: any;
};
export interface IDnsProvider {
createRecord(options: CreateRecordOptions): Promise<any>;
removeRecord(options: RemoveRecordOptions): Promise<any>;
}
export function IsDnsProvider(define: DnsProviderDefine) {
return function (target: any) {
target.define = define;
dnsProviderRegistry.install(target);
};
}

View File

@ -0,0 +1,3 @@
import "./providers";
export * from "./api";
export * from "./registry";

View File

@ -0,0 +1,145 @@
import { AbstractDnsProvider } from "../abstract-dns-provider";
import Core from "@alicloud/pop-core";
import _ from "lodash";
import {
CreateRecordOptions,
IDnsProvider,
IsDnsProvider,
RemoveRecordOptions,
} from "../api";
@IsDnsProvider({
name: "aliyun",
title: "阿里云",
desc: "阿里云DNS解析提供商",
accessType: "aliyun",
})
export class AliyunDnsProvider
extends AbstractDnsProvider
implements IDnsProvider
{
client: any;
constructor() {
super();
}
async onInit() {
const access: any = this.access;
this.client = new Core({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
endpoint: "https://alidns.aliyuncs.com",
apiVersion: "2015-01-09",
});
}
async getDomainList() {
const params = {
RegionId: "cn-hangzhou",
};
const requestOption = {
method: "POST",
};
const ret = await this.client.request(
"DescribeDomains",
params,
requestOption
);
return ret.Domains.Domain;
}
async matchDomain(dnsRecord: string) {
const list = await this.getDomainList();
let domain = null;
for (const item of list) {
if (_.endsWith(dnsRecord, item.DomainName)) {
domain = item.DomainName;
break;
}
}
if (!domain) {
throw new Error("can not find Domain ," + dnsRecord);
}
return domain;
}
async getRecords(domain: string, rr: string, value: string) {
const params: any = {
RegionId: "cn-hangzhou",
DomainName: domain,
RRKeyWord: rr,
ValueKeyWord: undefined,
};
if (value) {
params.ValueKeyWord = value;
}
const requestOption = {
method: "POST",
};
const ret = await this.client.request(
"DescribeDomainRecords",
params,
requestOption
);
return ret.DomainRecords.Record;
}
async createRecord(options: CreateRecordOptions): Promise<any> {
const { fullRecord, value, type } = options;
this.logger.info("添加域名解析:", fullRecord, value);
const domain = await this.matchDomain(fullRecord);
const rr = fullRecord.replace("." + domain, "");
const params = {
RegionId: "cn-hangzhou",
DomainName: domain,
RR: rr,
Type: type,
Value: value,
// Line: 'oversea' // 海外
};
const requestOption = {
method: "POST",
};
try {
const ret = await this.client.request(
"AddDomainRecord",
params,
requestOption
);
this.logger.info("添加域名解析成功:", value, value, ret.RecordId);
return ret.RecordId;
} catch (e: any) {
if (e.code === "DomainRecordDuplicate") {
return;
}
this.logger.info("添加域名解析出错", e);
throw e;
}
}
async removeRecord(options: RemoveRecordOptions): Promise<any> {
const { fullRecord, value, type, record } = options;
const params = {
RegionId: "cn-hangzhou",
RecordId: record,
};
const requestOption = {
method: "POST",
};
const ret = await this.client.request(
"DeleteDomainRecord",
params,
requestOption
);
this.logger.info("删除域名解析成功:", fullRecord, value, ret.RecordId);
return ret.RecordId;
}
}

View File

@ -0,0 +1 @@
import "./aliyun-dns-provider";

View File

@ -0,0 +1,4 @@
import { Registry } from "../registry";
import { AbstractDnsProvider } from "./abstract-dns-provider";
export const dnsProviderRegistry = new Registry<typeof AbstractDnsProvider>();

View File

@ -0,0 +1,6 @@
export * from "./core";
export * from "./d.ts";
export * from "./access";
export * from "./registry";
export * from "./dns-provider";
export * from "./plugin";

View File

@ -0,0 +1,28 @@
import { AbstractRegistrable } from "../registry";
import { PluginDefine } from "./api";
import { Logger } from "log4js";
import { logger } from "../utils/util.log";
import { IAccessService } from "../access/access-service";
import { IContext } from "../core/context";
export abstract class AbstractPlugin extends AbstractRegistrable {
static define: PluginDefine;
logger: Logger = logger;
// @ts-ignore
accessService: IAccessService;
// @ts-ignore
pipelineContext: IContext;
// @ts-ignore
userContext: IContext;
async doInit(options: { accessService: IAccessService; pipelineContext: IContext; userContext: IContext }) {
this.accessService = options.accessService;
this.pipelineContext = options.pipelineContext;
this.userContext = options.userContext;
await this.onInit();
}
protected async onInit(): Promise<void> {
//
}
}

View File

@ -0,0 +1,60 @@
import { FormItemProps } from "@fast-crud/fast-crud";
import { Registrable } from "../registry";
import { pluginRegistry } from "./registry";
export type TaskInput = {
[key: string]: any;
};
export type TaskOutput = {
[key: string]: any;
};
export enum ContextScope {
global,
pipeline,
runtime,
}
export type Storage = {
scope: ContextScope;
path: string;
};
export type TaskOutputDefine = {
title: string;
key: string;
value?: any;
storage?: Storage;
};
export type TaskInputDefine = FormItemProps;
export type PluginDefine = Registrable & {
input: {
[key: string]: TaskInputDefine;
};
output: {
[key: string]: TaskOutputDefine;
};
};
export interface TaskPlugin {
execute(input: TaskInput): Promise<TaskOutput>;
}
export type OutputVO = {
key: string;
title: string;
value: any;
};
export function IsTask(define: (() => PluginDefine) | PluginDefine) {
return function (target: any) {
if (define instanceof Function) {
target.define = define();
} else {
target.define = define;
}
pluginRegistry.install(target);
};
}

View File

@ -0,0 +1,3 @@
import "./plugins";
export * from "./api";
export * from "./registry";

View File

@ -0,0 +1,198 @@
// @ts-ignore
import acme, { Authorization } from "@certd/acme-client";
import _ from "lodash";
import { logger } from "../../../utils/util.log";
import { AbstractDnsProvider } from "../../../dns-provider/abstract-dns-provider";
import { IContext } from "../../../core/context";
import { IDnsProvider } from "../../../dns-provider";
import { Challenge } from "@certd/acme-client/types/rfc8555";
export class AcmeService {
userContext: IContext;
constructor(options: { userContext: IContext }) {
this.userContext = options.userContext;
acme.setLogger((text: string) => {
logger.info(text);
});
}
async getAccountConfig(email: string) {
return (await this.userContext.get(this.buildAccountKey(email))) || {};
}
buildAccountKey(email: string) {
return "acme.config." + email;
}
async saveAccountConfig(email: string, conf: any) {
await this.userContext.set(this.buildAccountKey(email), conf);
}
async getAcmeClient(email: string, isTest = false): Promise<acme.Client> {
const conf = await this.getAccountConfig(email);
if (conf.key == null) {
conf.key = await this.createNewKey();
await this.saveAccountConfig(email, conf);
}
if (isTest == null) {
isTest = process.env.CERTD_MODE === "test";
}
const client = new acme.Client({
directoryUrl: isTest ? acme.directory.letsencrypt.staging : acme.directory.letsencrypt.production,
accountKey: conf.key,
accountUrl: conf.accountUrl,
backoffAttempts: 20,
backoffMin: 5000,
backoffMax: 10000,
});
if (conf.accountUrl == null) {
const accountPayload = {
termsOfServiceAgreed: true,
contact: [`mailto:${email}`],
};
await client.createAccount(accountPayload);
conf.accountUrl = client.getAccountUrl();
await this.saveAccountConfig(email, conf);
}
return client;
}
async createNewKey() {
const key = await acme.forge.createPrivateKey();
return key.toString();
}
async challengeCreateFn(authz: any, challenge: any, keyAuthorization: string, dnsProvider: IDnsProvider) {
logger.info("Triggered challengeCreateFn()");
/* http-01 */
if (challenge.type === "http-01") {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
const fileContents = keyAuthorization;
logger.info(`Creating challenge response for ${authz.identifier.value} at path: ${filePath}`);
/* Replace this */
logger.info(`Would write "${fileContents}" to path "${filePath}"`);
// await fs.writeFileAsync(filePath, fileContents);
} else if (challenge.type === "dns-01") {
/* dns-01 */
const dnsRecord = `_acme-challenge.${authz.identifier.value}`;
const recordValue = keyAuthorization;
logger.info(`Creating TXT record for ${authz.identifier.value}: ${dnsRecord}`);
/* Replace this */
logger.info(`Would create TXT record "${dnsRecord}" with value "${recordValue}"`);
return await dnsProvider.createRecord({
fullRecord: dnsRecord,
type: "TXT",
value: recordValue,
});
}
}
/**
* Function used to remove an ACME challenge response
*
* @param {object} authz Authorization object
* @param {object} challenge Selected challenge
* @param {string} keyAuthorization Authorization key
* @param recordItem challengeCreateFn create record item
* @param dnsProvider dnsProvider
* @returns {Promise}
*/
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordItem: any, dnsProvider: IDnsProvider) {
logger.info("Triggered challengeRemoveFn()");
/* http-01 */
if (challenge.type === "http-01") {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
logger.info(`Removing challenge response for ${authz.identifier.value} at path: ${filePath}`);
/* Replace this */
logger.info(`Would remove file on path "${filePath}"`);
// await fs.unlinkAsync(filePath);
} else if (challenge.type === "dns-01") {
const dnsRecord = `_acme-challenge.${authz.identifier.value}`;
const recordValue = keyAuthorization;
logger.info(`Removing TXT record for ${authz.identifier.value}: ${dnsRecord}`);
/* Replace this */
logger.info(`Would remove TXT record "${dnsRecord}" with value "${recordValue}"`);
await dnsProvider.removeRecord({
fullRecord: dnsRecord,
type: "TXT",
value: keyAuthorization,
record: recordItem,
});
}
}
async order(options: { email: string; domains: string | string[]; dnsProvider: AbstractDnsProvider; csrInfo: any; isTest?: boolean }) {
const { email, isTest, domains, csrInfo, dnsProvider } = options;
const client: acme.Client = await this.getAcmeClient(email, isTest);
/* Create CSR */
const { commonName, altNames } = this.buildCommonNameByDomains(domains);
const [key, csr] = await acme.forge.createCsr({
commonName,
...csrInfo,
altNames,
});
if (dnsProvider == null) {
throw new Error("dnsProvider 不能为空");
}
/* 自动申请证书 */
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
challengePriority: ["dns-01"],
challengeCreateFn: async (authz: Authorization, challenge: Challenge, keyAuthorization: string): Promise<any> => {
return await this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider);
},
challengeRemoveFn: async (authz: Authorization, challenge: Challenge, keyAuthorization: string, recordItem: any): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordItem, dnsProvider);
},
});
const cert = {
crt: crt.toString(),
key: key.toString(),
csr: csr.toString(),
};
/* Done */
logger.debug(`CSR:\n${cert.csr}`);
logger.debug(`Certificate:\n${cert.crt}`);
logger.info("证书申请成功");
return cert;
}
buildCommonNameByDomains(domains: string | string[]): {
commonName: string;
altNames: string[] | undefined;
} {
if (typeof domains === "string") {
domains = domains.split(",");
}
if (domains.length === 0) {
throw new Error("domain can not be empty");
}
const commonName = domains[0];
let altNames: undefined | string[] = undefined;
if (domains.length > 1) {
altNames = _.slice(domains, 1);
}
return {
commonName,
altNames,
};
}
}

View File

@ -0,0 +1,210 @@
import { AbstractPlugin } from "../../abstract-plugin";
import forge from "node-forge";
import { ContextScope, IsTask, TaskInput, TaskOutput, TaskPlugin } from "../../api";
import dayjs from "dayjs";
import { dnsProviderRegistry } from "../../../dns-provider";
import { AbstractDnsProvider } from "../../../dns-provider/abstract-dns-provider";
import { AcmeService } from "./acme";
export type CertInfo = {
crt: string;
key: string;
csr: string;
};
@IsTask(() => {
return {
name: "CertApply",
title: "证书申请",
input: {
domains: {
component: {
name: "a-select",
vModel: "value",
mode: "tags",
},
col: {
span: 24,
},
helper: "请输入域名",
},
email: {
component: {
name: "a-input",
vModel: "value",
},
helper: "请输入邮箱",
},
dnsProviderType: {
component: {
name: "a-select",
},
helper: "请选择dns解析提供商",
},
dnsProviderAccess: {
component: {
name: "access-selector",
},
helper: "请选择dns解析提供商授权",
},
renewDays: {
title: "更新天数",
component: {
name: "a-number",
value: 20,
},
helper: "到期前多少天后更新证书",
},
forceUpdate: {
title: "强制更新",
component: {
name: "a-switch",
vModel: "checked",
value: false,
},
helper: "强制重新申请证书",
},
},
output: {
cert: {
key: "cert",
type: "CertInfo",
title: "证书",
scope: ContextScope.pipeline,
},
},
};
})
export class CertPlugin extends AbstractPlugin implements TaskPlugin {
// @ts-ignore
acme: AcmeService;
constructor() {
super();
}
protected async onInit() {
this.acme = new AcmeService({ userContext: this.userContext });
}
async execute(input: TaskInput): Promise<TaskOutput> {
const oldCert = await this.condition(input);
if (oldCert != null) {
return {
cert: oldCert,
};
}
const cert = await this.doCertApply(input);
return { cert };
}
/**
*
* @param input
*/
async condition(input: TaskInput) {
if (input.forceUpdate) {
return null;
}
let oldCert;
try {
oldCert = await this.readCurrentCert();
} catch (e) {
this.logger.warn("读取cert失败", e);
}
if (oldCert == null) {
this.logger.info("还未申请过,准备申请新证书");
return null;
}
const ret = this.isWillExpire(oldCert.expires, input.renewDays);
if (!ret.isWillExpire) {
this.logger.info(`证书还未过期:过期时间${dayjs(oldCert.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${ret.leftDays}`);
return oldCert;
}
this.logger.info("即将过期,开始更新证书");
return null;
}
async doCertApply(input: TaskInput) {
const email = input["email"];
const domains = input["domains"];
const dnsProviderType = input["dnsProviderType"];
const dnsProviderAccessId = input["dnsProviderAccess"];
const csrInfo = input["csrInfo"];
this.logger.info("开始申请证书,", email, domains);
const dnsProviderClass = dnsProviderRegistry.get(dnsProviderType);
const access = await this.accessService.getById(dnsProviderAccessId);
// @ts-ignore
const dnsProvider: AbstractDnsProvider = new dnsProviderClass();
dnsProvider.doInit({ access });
const cert = await this.acme.order({
email,
domains,
dnsProvider,
csrInfo,
isTest: false,
});
await this.writeCert(cert);
const ret = await this.readCurrentCert();
return {
...ret,
isNew: true,
};
}
formatCert(pem: string) {
pem = pem.replace(/\r/g, "");
pem = pem.replace(/\n\n/g, "\n");
pem = pem.replace(/\n$/g, "");
return pem;
}
async writeCert(cert: { crt: string; key: string; csr: string }) {
const newCert = {
crt: this.formatCert(cert.crt),
key: this.formatCert(cert.key),
csr: this.formatCert(cert.csr),
};
await this.pipelineContext.set("cert", newCert);
}
async readCurrentCert() {
const cert: CertInfo = await this.pipelineContext.get("cert");
if (cert == null) {
return undefined;
}
const { detail, expires } = this.getCrtDetail(cert.crt);
return {
...cert,
detail,
expires: expires.getTime(),
};
}
getCrtDetail(crt: string) {
const pki = forge.pki;
const detail = pki.certificateFromPem(crt.toString());
const expires = detail.validity.notAfter;
return { detail, expires };
}
/**
* 20
* @param expires
* @param maxDays
* @returns {boolean}
*/
isWillExpire(expires: number, maxDays = 20) {
if (expires == null) {
throw new Error("过期时间不能为空");
}
// 检查有效期
const leftDays = dayjs(expires).diff(dayjs(), "day");
return {
isWillExpire: leftDays < maxDays,
leftDays,
};
}
}

View File

@ -0,0 +1,101 @@
import { AbstractPlugin } from "../../abstract-plugin";
import { IsTask, TaskInput, TaskOutput, TaskPlugin } from "../../api";
import dayjs from "dayjs";
import Core from "@alicloud/pop-core";
import RPCClient from "@alicloud/pop-core";
import { AliyunAccess } from "../../../access";
import { CertInfo } from "../cert-plugin";
@IsTask(() => {
return {
name: "DeployCertToAliyunCDN",
title: "部署证书至阿里云CDN",
input: {
domainName: {
title: "cdn加速域名",
component: {
placeholder: "cdn加速域名",
},
required: true,
},
certName: {
title: "证书名称",
component: {
placeholder: "上传后将以此名称作为前缀",
},
},
cert: {
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
},
required: true,
},
accessId: {
title: "Access提供者",
helper: "access授权",
component: {
name: "access-selector",
type: "aliyun",
},
required: true,
},
},
output: {},
};
})
export class DeployCertToAliyunCDN extends AbstractPlugin implements TaskPlugin {
constructor() {
super();
}
async execute(input: TaskInput): Promise<TaskOutput> {
console.log("开始部署证书到阿里云cdn");
const access = this.accessService.getById(input.accessId) as AliyunAccess;
const client = this.getClient(access);
const params = await this.buildParams(input);
await this.doRequest(client, params);
return {};
}
getClient(access: AliyunAccess) {
return new Core({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
endpoint: "https://cdn.aliyuncs.com",
apiVersion: "2018-05-10",
});
}
async buildParams(input: TaskInput) {
const { certName, domainName, cert } = input;
const CertName = certName + "-" + dayjs().format("YYYYMMDDHHmmss");
const newCert = (await this.pipelineContext.get(cert)) as CertInfo;
return {
RegionId: "cn-hangzhou",
DomainName: domainName,
ServerCertificateStatus: "on",
CertName: CertName,
CertType: "upload",
ServerCertificate: newCert.crt,
PrivateKey: newCert.key,
};
}
async doRequest(client: RPCClient, params: any) {
const requestOption = {
method: "POST",
};
const ret: any = await client.request("SetDomainServerCertificate", params, requestOption);
this.checkRet(ret);
this.logger.info("设置cdn证书成功:", ret.RequestId);
}
checkRet(ret: any) {
if (ret.code != null) {
throw new Error("执行失败:", ret.Message);
}
}
}

View File

@ -0,0 +1,26 @@
import { AbstractPlugin } from "../abstract-plugin";
import { IsTask, TaskInput, TaskOutput, TaskPlugin } from "../api";
@IsTask(() => {
return {
name: "EchoPlugin",
title: "测试插件回声",
input: {
cert: {
component: {
name: "output-selector",
},
helper: "输出选择",
},
},
output: {},
};
})
export class EchoPlugin extends AbstractPlugin implements TaskPlugin {
async execute(input: TaskInput): Promise<TaskOutput> {
for (const key in input) {
console.log("input :", key, input[key]);
}
return input;
}
}

View File

@ -0,0 +1,3 @@
export * from "./cert-plugin/index";
export * from "./echo-plugin";
export * from "./deploy-to-cdn/index";

View File

@ -0,0 +1,4 @@
import { Registry } from "../registry";
import { AbstractPlugin } from "./abstract-plugin";
export const pluginRegistry = new Registry<typeof AbstractPlugin>();

View File

@ -0,0 +1 @@
export * from "./registry";

View File

@ -0,0 +1,53 @@
import { Logger } from "log4js";
import { logger } from "../utils/util.log";
export type Registrable = {
name: string;
title: string;
desc?: string;
};
export abstract class AbstractRegistrable {
static define: Registrable;
logger: Logger = logger;
}
export class Registry<T extends typeof AbstractRegistrable> {
storage: {
[key: string]: T;
} = {};
install(target: T) {
if (target == null) {
return;
}
let defineName = target.define.name;
if (defineName == null) {
defineName = target.name;
}
this.register(defineName, target);
}
register(key: string, value: T) {
if (!key || value == null) {
return;
}
this.storage[key] = value;
}
get(name: string) {
if (!name) {
throw new Error("插件名称不能为空");
}
const plugin = this.storage[name];
if (!plugin) {
throw new Error(`插件${name}还未注册`);
}
return plugin;
}
getStorage() {
return this.storage;
}
}

View File

@ -0,0 +1,16 @@
export interface ServiceContext {
get(name: string): any;
register(name: string, service: any): void;
}
export class ServiceContextImpl implements ServiceContext {
register(name: string, service: any): void {}
storage: {
[key: string]: any;
} = {};
get(name: string): any {
return this.storage[name];
}
}
export const serviceContext = new ServiceContextImpl();

View File

@ -0,0 +1,6 @@
import log4js from "log4js";
log4js.configure({
appenders: { std: { type: "stdout" } },
categories: { default: { appenders: ["std"], level: "info" } },
});
export const logger = log4js.getLogger("pipeline");

View File

@ -0,0 +1,10 @@
import { expect } from "chai";
import "mocha";
import { EchoPlugin } from "../src/plugin/plugins";
describe("task_plugin", function () {
it("#taskplugin", function () {
const define = EchoPlugin.define;
new EchoPlugin().execute({ context: {}, props: { test: 111 } });
expect(define.name).eq("EchoPlugin");
});
});

View File

@ -0,0 +1,10 @@
import { IAccessService } from "../../src/access/access-service";
import { AbstractAccess, AliyunAccess } from "../../src";
import { aliyunSecret } from "../user.secret";
export class AccessServiceTest implements IAccessService {
getById(id: any): AbstractAccess {
return {
...aliyunSecret,
} as AliyunAccess;
}
}

View File

@ -0,0 +1,62 @@
import { ConcurrencyStrategy, NextStrategy, Pipeline } from "../../src";
let idIndex = 0;
function generateId() {
idIndex++;
return idIndex + "";
}
export const pipeline: Pipeline = {
version: 1,
id: generateId(),
title: "测试管道",
triggers: [],
stages: [
{
id: generateId(),
title: "证书申请阶段",
concurrency: ConcurrencyStrategy.Serial,
next: NextStrategy.AllSuccess,
tasks: [
{
id: generateId(),
title: "申请证书任务",
steps: [
{
id: generateId(),
title: "申请证书",
type: "CertApply",
input: {
domains: ["*.docmirror.cn"],
email: "xiaojunnuo@qq.com",
dnsProviderType: "aliyun",
accessId: "111",
},
},
],
},
],
},
{
id: generateId(),
title: "证书部署阶段",
concurrency: ConcurrencyStrategy.Serial,
next: NextStrategy.AllSuccess,
tasks: [
{
id: generateId(),
title: "测试输出参数",
steps: [
{
id: generateId(),
title: "输出参数",
type: "EchoPlugin",
input: {
cert: "cert",
},
},
],
},
],
},
],
};

View File

@ -0,0 +1,18 @@
import { expect } from "chai";
import "mocha";
import { Executor, RunHistory } from "../../src";
import { pipeline } from "./pipeline.define";
import { AccessServiceTest } from "./access-service-test";
import { FileStorage } from "../../src/core/storage";
describe("pipeline", function () {
it("#pipeline", async function () {
this.timeout(120000);
function onChanged(history: RunHistory) {
console.log("changed:");
}
const executor = new Executor({ userId: 1, pipeline, onChanged, accessService: new AccessServiceTest(), storage: new FileStorage() });
await executor.run();
// expect(define.name).eq("EchoPlugin");
});
});

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "commonjs",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"experimentalDecorators": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","test/**/*.ts"],
}

View File

@ -0,0 +1,11 @@
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [],
build: {
lib: {
entry: "src/index.ts",
name: "pipeline",
},
},
});

@ -1 +1 @@
Subproject commit 9aa73cf3b85f615018e7b9b903e0cbfe84f05fbb
Subproject commit c9bae7552e25828d9a29d5be71b518b082c71eed

@ -1 +1 @@
Subproject commit 06c64b9bbf0f55f7687cc654cbb5cdcd5debd140
Subproject commit cb01f4fddf910d81b44556c907acea54dc298d5d