mirror of
https://github.com/certd/certd.git
synced 2026-04-24 04:17:25 +08:00
refactor: pipeline edit view
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { AbstractRegistrable } from "../registry";
|
||||
|
||||
export abstract class AbstractAccess extends AbstractRegistrable {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AbstractAccess } from "./abstract-access";
|
||||
|
||||
export interface IAccessService {
|
||||
getById(id: any): AbstractAccess;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./aliyun-access";
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./impl";
|
||||
export * from "./abstract-access";
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./executor";
|
||||
export * from "./run-history";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pipeline";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "./providers";
|
||||
export * from "./api";
|
||||
export * from "./registry";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "./aliyun-dns-provider";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Registry } from "../registry";
|
||||
import { AbstractDnsProvider } from "./abstract-dns-provider";
|
||||
|
||||
export const dnsProviderRegistry = new Registry<typeof AbstractDnsProvider>();
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./core";
|
||||
export * from "./d.ts";
|
||||
export * from "./access";
|
||||
export * from "./registry";
|
||||
export * from "./dns-provider";
|
||||
export * from "./plugin";
|
||||
@@ -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> {
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "./plugins";
|
||||
export * from "./api";
|
||||
export * from "./registry";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./cert-plugin/index";
|
||||
export * from "./echo-plugin";
|
||||
export * from "./deploy-to-cdn/index";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Registry } from "../registry";
|
||||
import { AbstractPlugin } from "./abstract-plugin";
|
||||
|
||||
export const pluginRegistry = new Registry<typeof AbstractPlugin>();
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./registry";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user