18 Commits

Author SHA1 Message Date
f47a20f942 chore: housework. 2021-10-20 22:51:24 +08:00
6b9f846154 feat: 为 pipeline 添加运行环境,并用于配置文件的发布。 2021-10-20 22:48:08 +08:00
3ba8fc9759 feat: add configuration-center module. 2021-10-16 10:54:35 +08:00
d5f49531e9 feat: register service to api gateway. 2021-09-25 20:38:54 +08:00
38dd05c4be build: 添加 git hook,提交前格式化代码并执行单元测试。 2021-09-19 19:43:09 +08:00
8467a42b3b build: 去除无用的 tsc 配置项 2021-09-19 19:40:06 +08:00
a299958fcb chore: high severity vulnerability 2021-09-17 23:14:42 +08:00
f07e18f71d fix: nest build can not output dist folder. 2021-09-17 22:58:35 +08:00
eb7adc0ef2 build: 更新部分依赖。 2021-09-16 23:58:04 +08:00
122dca689d build: 更新部分依赖。 2021-09-16 23:55:08 +08:00
fcca1508eb fix: 依赖问题导致无法通过类型检查 2021-09-16 23:17:08 +08:00
34cfc71a18 Merge pull request 'feat-built-in-pm2' (#9) from feat-built-in-pm2 into master
Reviewed-on: #9
2021-09-12 19:54:49 +08:00
de2e9fa8c4 chore: 使用自建的包管理仓库 2021-09-12 10:55:39 +08:00
0f1466bf91 feat: built in pm2 2021-09-08 23:04:11 +08:00
ed71d83581 chore: clean code. 2021-07-24 16:57:01 +08:00
574e7ecae7 Merge pull request 'single-deploy-folder' (#8) from single-deploy-folder into master
Reviewed-on: #8
2021-07-22 20:59:55 +08:00
1b469e34f9 feat: 使用单独目录部署。 2021-07-22 20:48:30 +08:00
c86772a5dd bak 2021-07-20 22:30:00 +08:00
54 changed files with 8963 additions and 6359 deletions

2
.gitignore vendored
View File

@ -34,3 +34,5 @@ lerna-debug.log*
!.vscode/extensions.json
/config.yml
tsconfig.build.tsbuildinfo
.eslintcache

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

5
.husky/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
npm test

14495
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,11 +18,12 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"prepare": "husky install"
},
"dependencies": {
"@golevelup/nestjs-rabbitmq": "^1.16.1",
"@nestjs-lib/auth": "^0.2.0",
"@nestjs-lib/auth": "^0.2.1",
"@nestjs/common": "^7.5.1",
"@nestjs/config": "^0.6.2",
"@nestjs/core": "^7.5.1",
@ -36,9 +37,10 @@
"body-parser": "^1.19.0",
"class-transformer": "^0.3.2",
"class-validator": "^0.13.1",
"configuration": "file:../configuration",
"debug": "^4.3.1",
"graphql": "^15.5.0",
"graphql-tools": "^7.0.2",
"graphql-tools": "^8.1.0",
"ioredis": "^4.25.0",
"jose": "^3.14.0",
"js-yaml": "^4.0.0",
@ -48,6 +50,7 @@
"observable-to-async-generator": "^1.0.1-rc",
"pg": "^8.5.1",
"pino-pretty": "^4.7.1",
"pm2": "^5.1.0",
"ramda": "^0.27.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
@ -56,7 +59,7 @@
"typeorm": "^0.2.30"
},
"devDependencies": {
"@nestjs/cli": "^7.5.7",
"@nestjs/cli": "^7.6.0",
"@nestjs/schematics": "^7.1.3",
"@nestjs/testing": "^7.5.1",
"@types/body-parser": "^1.19.0",
@ -73,7 +76,9 @@
"eslint": "^7.12.1",
"eslint-config-prettier": "7.2.0",
"eslint-plugin-prettier": "^3.1.4",
"husky": "^7.0.2",
"jest": "^26.6.3",
"lint-staged": "^11.1.2",
"prettier": "^2.1.2",
"supertest": "^6.0.0",
"ts-jest": "^26.4.3",
@ -101,5 +106,8 @@
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"lint-staged": {
"{src,apps,libs,test}/**/*.ts": "eslint --cache --fix"
}
}

View File

@ -20,6 +20,7 @@ import { LoggerModule } from 'nestjs-pino';
import { EtcdModule } from 'nestjs-etcd';
import pinoPretty from 'pino-pretty';
import { fromPairs, map, pipe, toPairs } from 'ramda';
import { ConfigurationsModule } from './configurations/configurations.module';
@Module({
imports: [
@ -64,7 +65,7 @@ import { fromPairs, map, pipe, toPairs } from 'ramda';
playground: true,
autoSchemaFile: true,
installSubscriptionHandlers: true,
context: ({ req, connection, ...args }) => {
context: ({ req, connection }) => {
return connection ? { req: connection.context } : { req };
},
subscriptions: {
@ -106,6 +107,7 @@ import { fromPairs, map, pipe, toPairs } from 'ramda';
}),
WebhooksModule,
CommonsModule,
ConfigurationsModule,
],
controllers: [AppController],
providers: [AppService, AppResolver],

View File

@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { PasswordConverter } from './services/password-converter';
import { RedisMutexModule } from './redis-mutex/redis-mutex.module';
import { AuthModule } from '@nestjs-lib/auth';
@Global()
@Module({
imports: [RedisMutexModule, AuthModule],
providers: [PasswordConverter],

View File

@ -13,9 +13,9 @@ export class ApplicationException extends Error {
this.error = message.error;
this.message = message.message as any;
} else if (typeof message === 'string') {
super((message as unknown) as any);
super(message as unknown as any);
} else {
super((message as unknown) as any);
super(message as unknown as any);
}
}

View File

@ -19,12 +19,16 @@ export class HttpExceptionFilter implements ExceptionFilter {
case 'graphql': {
const errorName = exception.message;
const extensions: Record<string, any> = {};
const err = exception.getResponse();
const err = exception.getResponse() as any;
if (typeof err === 'string') {
extensions.message = err;
} else {
Object.assign(extensions, (err as any).extension);
extensions.message = (err as any).message;
Object.assign(extensions, err.extension);
if (typeof err.message === 'string') {
extensions.message = err.message;
} else {
extensions.message = err.error;
}
}
extensions.error = errorName;
this.logger.error(extensions);

View File

@ -126,6 +126,7 @@ export class BaseDbService<Entity extends AppBaseEntity> extends TypeormHelper {
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canYouRemoveWithIds(ids: string[]): Promise<void> {
return;
}

View File

@ -0,0 +1,11 @@
import { Configuration } from './entities/configuration.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Module } from '@nestjs/common';
import { ConfigurationsService } from './configurations.service';
import { ConfigurationsResolver } from './configurations.resolver';
@Module({
imports: [TypeOrmModule.forFeature([Configuration])],
providers: [ConfigurationsResolver, ConfigurationsService],
})
export class ConfigurationsModule {}

View File

@ -0,0 +1,30 @@
import { JwtService } from '@nestjs-lib/auth';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigurationsResolver } from './configurations.resolver';
import { ConfigurationsService } from './configurations.service';
describe('ConfigurationsResolver', () => {
let resolver: ConfigurationsResolver;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ConfigurationsResolver,
{
provide: ConfigurationsService,
useValue: {},
},
{
provide: JwtService,
useValue: {},
},
],
}).compile();
resolver = module.get<ConfigurationsResolver>(ConfigurationsResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});

View File

@ -0,0 +1,39 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { GetConfigurationArgs } from './dto/get-configuration.args';
import { SetConfigurationInput } from './dto/set-configuration.input';
import { Resolver, Mutation, Args, Query } from '@nestjs/graphql';
import { ConfigurationsService } from './configurations.service';
import { Configuration } from './entities/configuration.entity';
import { any, pipe, values } from 'ramda';
import { AccountRole, Roles } from '@nestjs-lib/auth';
@Roles(AccountRole.admin, AccountRole.super)
@Resolver(() => Configuration)
export class ConfigurationsResolver {
constructor(private readonly configurationsService: ConfigurationsService) {}
@Mutation(() => Configuration)
setConfiguration(
@Args('setConfigurationInput', { type: () => SetConfigurationInput })
setConfigurationInput: SetConfigurationInput,
) {
return this.configurationsService.setConfiguration(setConfigurationInput);
}
@Query(() => Configuration, { nullable: true })
getConfiguration(
@Args()
getConfigurationArgs: GetConfigurationArgs,
) {
if (
pipe(
values,
any((value) => !value),
)(getConfigurationArgs)
) {
throw new UnprocessableEntityException('Must pass a parameter');
}
return this.configurationsService.findOneByConditions(getConfigurationArgs);
}
}

View File

@ -0,0 +1,49 @@
import { getRepositoryToken } from '@nestjs/typeorm';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigurationsService } from './configurations.service';
import { Configuration } from './entities/configuration.entity';
import { IsNull } from 'typeorm';
import { Etcd3 } from 'etcd3';
describe('ConfigurationsService', () => {
let service: ConfigurationsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ConfigurationsService,
{
provide: getRepositoryToken(Configuration),
useValue: {},
},
{
provide: Etcd3,
useValue: {},
},
],
}).compile();
service = module.get<ConfigurationsService>(ConfigurationsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findOneByConditions', () => {
it('should select by projectId only', async () => {
const entity = new Configuration();
const findOne = jest.fn<any, [any]>(() => Promise.resolve(entity));
service['repository'].findOne = findOne;
await expect(
service.findOneByConditions({ projectId: 'uuid' }),
).resolves.toEqual(entity);
expect(findOne.mock.calls[0][0]).toMatchObject({
projectId: 'uuid',
pipelineId: IsNull(),
});
});
});
});

View File

@ -0,0 +1,55 @@
import { GetConfigurationArgs } from './dto/get-configuration.args';
import { BaseDbService } from './../commons/services/base-db.service';
import { Injectable } from '@nestjs/common';
import { Configuration } from './entities/configuration.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { FindConditions, IsNull, Repository } from 'typeorm';
import { SetConfigurationInput } from './dto/set-configuration.input';
import { pick } from 'ramda';
import { Etcd3 } from 'etcd3';
@Injectable()
export class ConfigurationsService extends BaseDbService<Configuration> {
constructor(
@InjectRepository(Configuration)
configurationRepository: Repository<Configuration>,
private readonly etcd: Etcd3,
) {
super(configurationRepository);
}
async setConfiguration(dto: SetConfigurationInput) {
let entity = await this.repository.findOne(
pick(['pipelineId', 'projectId'], dto),
);
if (!entity) {
entity = this.repository.create(dto);
}
entity = await this.repository.save(entity);
await this.syncToEtcd(entity);
return entity;
}
async findOneByConditions(dto: FindConditions<GetConfigurationArgs>) {
if (dto.projectId && !dto.pipelineId) {
dto.pipelineId = IsNull();
}
return await this.repository.findOne(dto);
}
async syncToEtcd({ pipelineId, id }: { pipelineId?: string; id?: string }) {
const config = await this.repository.findOneOrFail({
where: { pipelineId, id },
relations: ['pipeline', 'project'],
});
await this.etcd
.put(`share/config/${config.id}`)
.value(config.content)
.exec();
await this.etcd
.put(`share/config/${config.pipeline.environment}/${config.project.name}`)
.value(config.content)
.exec();
}
}

View File

@ -0,0 +1,17 @@
import { ArgsType } from '@nestjs/graphql';
import { IsUUID, IsOptional } from 'class-validator';
@ArgsType()
export class GetConfigurationArgs {
@IsUUID()
@IsOptional()
pipelineId?: string;
@IsUUID()
@IsOptional()
projectId?: string;
@IsUUID()
@IsOptional()
id?: string;
}

View File

@ -0,0 +1,26 @@
import { ConfigurationLanguage } from './../enums/configuration-language.enum';
import { IsEnum, IsString, IsUUID, Length, IsOptional } from 'class-validator';
import { InputType } from '@nestjs/graphql';
@InputType()
export class SetConfigurationInput {
@IsOptional()
@IsUUID()
id?: string;
@IsUUID()
pipelineId: string;
@IsUUID()
projectId: string;
@IsString()
content: string;
@IsEnum(ConfigurationLanguage)
language: ConfigurationLanguage;
@Length(0, 100)
@IsOptional()
name = 'Default Configuration';
}

View File

@ -0,0 +1,35 @@
import { Project } from './../../projects/project.entity';
import { ConfigurationLanguage } from './../enums/configuration-language.enum';
import { Pipeline } from './../../pipelines/pipeline.entity';
import { AppBaseEntity } from './../../commons/entities/app-base-entity';
import { ObjectType } from '@nestjs/graphql';
import { Column, Entity, ManyToOne } from 'typeorm';
@Entity()
@ObjectType()
export class Configuration extends AppBaseEntity {
@ManyToOne(() => Pipeline)
pipeline: Pipeline;
@Column({ unique: true, nullable: true })
pipelineId: string;
@ManyToOne(() => Project)
project: Project;
@Column()
projectId: string;
@Column({ comment: 'language defined in type field.' })
content: string;
@Column({ comment: '配置名称' })
name: string;
@Column({
type: 'enum',
enum: ConfigurationLanguage,
comment: 'configuration content language',
})
language: ConfigurationLanguage;
}

View File

@ -0,0 +1,10 @@
import { registerEnumType } from '@nestjs/graphql';
export enum ConfigurationLanguage {
JavaScript = 'JavaScript',
YAML = 'YAML',
}
registerEnumType(ConfigurationLanguage, {
name: 'ConfigurationLanguage',
});

View File

@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './commons/filters/all.exception-filter';
import { SanitizePipe } from './commons/pipes/sanitize.pipe';
import { ServiceRegister } from 'configuration';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
@ -17,6 +18,10 @@ async function bootstrap() {
);
const httpExceptionFilterLogger = await app.resolve(PinoLogger);
app.useGlobalFilters(new HttpExceptionFilter(httpExceptionFilterLogger));
await app.listen(configService.get<number>('http.port'));
const server = await app.listen(configService.get<number>('http.port', 0));
const port = server.address().port;
const register = new ServiceRegister({ etcd: { hosts: 'http://rpi:2379' } });
register.register('fennec/api', `http://localhost:${port}`);
register.register('api.fennec', `http://localhost:${port}`);
}
bootstrap();

View File

@ -1,4 +1,4 @@
import { Field, InputType } from '@nestjs/graphql';
import { InputType } from '@nestjs/graphql';
import { PipelineUnits } from '../enums/pipeline-units.enum';
@InputType()

View File

@ -1,6 +1,6 @@
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import { IsInstance, isInstance, ValidateNested } from 'class-validator';
import { IsInstance, ValidateNested } from 'class-validator';
import { WorkUnit } from './work-unit.model';
@InputType('WorkUnitMetadataInput')

View File

@ -42,9 +42,9 @@ describe('PipelineTaskRunner', () => {
setTimeout(() => {
logger.handleEvent(event);
});
expect(message$.pipe(take(1), timeout(100)).toPromise()).rejects.toMatch(
'timeout',
);
await expect(
message$.pipe(take(1), timeout(100)).toPromise(),
).rejects.toThrow(/timeout/i);
});
it('multiple subscribers', async () => {
const event = new PipelineTaskEvent();
@ -53,13 +53,16 @@ describe('PipelineTaskRunner', () => {
const message2$ = logger.getMessage$('test');
setTimeout(() => {
logger.handleEvent(event);
logger.handleEvent(event);
});
expect(message$.pipe(take(1), timeout(100)).toPromise()).resolves.toEqual(
event,
);
expect(
message2$.pipe(take(1), timeout(100)).toPromise(),
).resolves.toEqual(event);
await Promise.all([
expect(
message$.pipe(take(1), timeout(100)).toPromise(),
).resolves.toEqual(event),
expect(
message2$.pipe(take(1), timeout(100)).toPromise(),
).resolves.toEqual(event),
]);
});
});

View File

@ -13,7 +13,8 @@ import {
@Injectable()
export class PipelineTaskLogger implements OnModuleDestroy {
private readonly messageSubject = new Subject<PipelineTaskEvent>();
private readonly message$: Observable<PipelineTaskEvent> = this.messageSubject.pipe();
private readonly message$: Observable<PipelineTaskEvent> =
this.messageSubject.pipe();
@RabbitSubscribe({
exchange: EXCHANGE_PIPELINE_TASK_FANOUT,

View File

@ -1,3 +1,4 @@
import { DeployByPm2Service } from './runners/deploy-by-pm2/deploy-by-pm2.service';
import { Test, TestingModule } from '@nestjs/testing';
import { ReposService } from '../repos/repos.service';
import { PipelineUnits } from './enums/pipeline-units.enum';
@ -11,7 +12,7 @@ import { WorkUnitMetadata } from './models/work-unit-metadata.model';
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
describe('PipelineTaskRunner', () => {
let runner: PipelineTaskRunner;
let reposService: ReposService;
let deployByPM2Service: DeployByPm2Service;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -36,11 +37,18 @@ describe('PipelineTaskRunner', () => {
provide: AmqpConnection,
useValue: {},
},
{
provide: DeployByPm2Service,
useValue: {
deploy: () => Promise.resolve(),
},
},
],
}).compile();
reposService = module.get(ReposService);
module.get(ReposService);
runner = module.get(PipelineTaskRunner);
deployByPM2Service = module.get(DeployByPm2Service);
});
it('should be defined', () => {
@ -65,7 +73,7 @@ describe('PipelineTaskRunner', () => {
beforeEach(() => {
emitEvent = jest
.spyOn(runner, 'emitEvent')
.mockImplementation((..._) => Promise.resolve());
.mockImplementation(() => Promise.resolve());
});
describe('doTask', () => {
@ -75,10 +83,10 @@ describe('PipelineTaskRunner', () => {
beforeEach(() => {
checkout = jest
.spyOn(runner, 'checkout')
.mockImplementation((..._) => Promise.resolve('/null'));
.mockImplementation(() => Promise.resolve('/null'));
doTaskUnit = jest
.spyOn(runner, 'doTaskUnit')
.mockImplementation((..._) => Promise.resolve());
.mockImplementation(() => Promise.resolve());
});
it('only checkout', async () => {
@ -145,7 +153,7 @@ describe('PipelineTaskRunner', () => {
await runner.doTask(task);
expect(checkout).toBeCalledTimes(1);
expect(doTaskUnit).toBeCalledTimes(2);
expect(doTaskUnit).toBeCalledTimes(1);
expect(emitEvent).toBeCalledTimes(2);
});
@ -171,9 +179,7 @@ describe('PipelineTaskRunner', () => {
doTaskUnit = jest
.spyOn(runner, 'doTaskUnit')
.mockImplementation((..._) =>
Promise.reject(new Error('test error')),
);
.mockImplementation(() => Promise.reject(new Error('test error')));
await runner.doTask(task);
expect(checkout).toBeCalledTimes(1);
@ -189,7 +195,7 @@ describe('PipelineTaskRunner', () => {
it('success', async () => {
const runScript = jest
.spyOn(runner, 'runScript')
.mockImplementation((..._) => Promise.resolve());
.mockImplementation(() => Promise.resolve());
const task = new PipelineTask();
const unit = PipelineUnits.test;
@ -210,9 +216,7 @@ describe('PipelineTaskRunner', () => {
it('failed', async () => {
const runScript = jest
.spyOn(runner, 'runScript')
.mockImplementation((..._) =>
Promise.reject(new Error('test error')),
);
.mockImplementation(() => Promise.reject(new Error('test error')));
const task = new PipelineTask();
const unit = PipelineUnits.test;
@ -230,7 +234,7 @@ describe('PipelineTaskRunner', () => {
describe('runScript', () => {
it('normal', async () => {
const spawn = jest.fn((..._: any[]) => ({
const spawn = jest.fn<any, any>(() => ({
stdout: {
on: () => undefined,
},
@ -256,7 +260,7 @@ describe('PipelineTaskRunner', () => {
});
});
it('failed', async () => {
const spawn = jest.fn((..._: any[]) => ({
const spawn = jest.fn(() => ({
stdout: {
on: () => undefined,
},
@ -291,7 +295,7 @@ describe('PipelineTaskRunner', () => {
}, 10);
}, 10);
});
const spawn = jest.fn((..._: any[]) => ({
const spawn = jest.fn(() => ({
stdout: {
on,
},
@ -304,7 +308,7 @@ describe('PipelineTaskRunner', () => {
}));
let emitSuccessCount = 0;
jest.spyOn(runner, 'emitEvent').mockImplementation((..._: any[]) => {
jest.spyOn(runner, 'emitEvent').mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
emitSuccessCount++;
@ -323,4 +327,25 @@ describe('PipelineTaskRunner', () => {
});
});
});
describe('tryRunDeployScript', () => {
it('should be call deploy with right args', async () => {
const deploy = jest.spyOn(deployByPM2Service, 'deploy');
await expect(
runner['tryRunDeployScript'](
'/test/dir',
'@@DEPLOY ecosystem.config.js',
),
).resolves.toBe(true);
expect(deploy.mock.calls[0][0]).toEqual('/test/dir/ecosystem.config.js');
});
it('should return false', async () => {
await expect(
runner['tryRunDeployScript'](
'/test/dir',
'pm2 start ecosystem.config.js',
),
).resolves.toBe(false);
});
});
});

View File

@ -1,3 +1,4 @@
import { DeployByPm2Service } from './runners/deploy-by-pm2/deploy-by-pm2.service';
import { ReposService } from '../repos/repos.service';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import { PipelineTask } from './pipeline-task.entity';
@ -27,6 +28,9 @@ import {
getSelfInstanceQueueKey,
getSelfInstanceRouteKey,
} from '../commons/utils/rabbit-mq';
import { rm } from 'fs/promises';
import { rename } from 'fs/promises';
import { join } from 'path';
type Spawn = typeof spawn;
@ -41,6 +45,7 @@ export class PipelineTaskRunner {
@Inject('spawn')
private readonly spawn: Spawn,
private readonly amqpConnection: AmqpConnection,
private readonly deployByPM2Service: DeployByPm2Service,
) {}
@RabbitSubscribe({
exchange: 'new-pipeline-task',
@ -110,7 +115,7 @@ export class PipelineTaskRunner {
this.logger.info('running task [%s].', task.id);
try {
const workspaceRoot = await this.checkout(task);
let workspaceRoot = await this.checkout(task);
const units = task.units
.filter((unit) => unit !== PipelineUnits.checkout)
.map(
@ -121,6 +126,22 @@ export class PipelineTaskRunner {
);
this.logger.info({ units }, 'begin run units.');
for (const unit of units) {
if (unit.type === PipelineUnits.deploy) {
const oldRoot = workspaceRoot;
workspaceRoot = this.reposService.getDeployRoot(task);
if (oldRoot !== workspaceRoot) {
await rm(workspaceRoot, { force: true, recursive: true });
await rename(oldRoot, workspaceRoot);
}
await this.emitEvent(
task,
unit.type,
TaskStatuses.success,
`[deploy] change deploy folder content success`,
'stdout',
);
}
await this.doTaskUnit(unit.type, unit.scripts, task, workspaceRoot);
}
await this.emitEvent(
@ -213,6 +234,7 @@ export class PipelineTaskRunner {
'checkout failed.',
'stderr',
);
throw err;
}
}
@ -255,6 +277,9 @@ export class PipelineTaskRunner {
unit: PipelineUnits,
): Promise<void> {
await this.emitEvent(task, unit, TaskStatuses.working, script, 'stdin');
if (await this.tryRunDeployScript(workspaceRoot, script)) {
return;
}
return new Promise((resolve, reject) => {
const sub = this.spawn(script, {
shell: true,
@ -298,4 +323,17 @@ export class PipelineTaskRunner {
});
});
}
private async tryRunDeployScript(workspaceRoot: string, script: string) {
const match = /^@@DEPLOY\s+(\S*)/.exec(script);
if (match) {
await this.deployByPM2Service.deploy(
join(workspaceRoot, match[1]),
workspaceRoot,
);
return true;
} else {
return false;
}
}
}

View File

@ -7,3 +7,5 @@ export const ROUTE_PIPELINE_TASK_DONE = 'pipeline-task-done';
export const QUEUE_PIPELINE_TASK_DONE = 'pipeline-task-done';
export const ROUTE_PIPELINE_TASK_KILL = 'pipeline-task-kill';
export const QUEUE_PIPELINE_TASK_KILL = 'pipeline-task-kill';
export const PM2_TOKEN = Symbol('pm2-token');

View File

@ -16,11 +16,10 @@ import {
} from './pipeline-tasks.constants';
import { PipelineTaskLogger } from './pipeline-task.logger';
import { PipelineTaskFlushService } from './pipeline-task-flush.service';
import { CommonsModule } from '../commons/commons.module';
import { DeployByPm2Service } from './runners/deploy-by-pm2/deploy-by-pm2.service';
@Module({
imports: [
CommonsModule,
TypeOrmModule.forFeature([PipelineTask, Pipeline]),
RedisModule,
ReposModule,
@ -68,6 +67,7 @@ import { CommonsModule } from '../commons/commons.module';
useValue: spawn,
},
PipelineTaskFlushService,
DeployByPm2Service,
],
exports: [PipelineTasksService],
})

View File

@ -1,3 +1,4 @@
import { JwtService } from '@nestjs-lib/auth';
import { Test, TestingModule } from '@nestjs/testing';
import { PipelineTaskLogger } from './pipeline-task.logger';
import { PipelineTasksResolver } from './pipeline-tasks.resolver';
@ -18,6 +19,10 @@ describe('PipelineTasksResolver', () => {
provide: PipelineTaskLogger,
useValue: {},
},
{
provide: JwtService,
useValue: {},
},
],
}).compile();

View File

@ -35,15 +35,6 @@ export class PipelineTasksResolver {
);
}
@Subscription(() => PipelineTask, {
resolve: (value) => {
return value;
},
})
async pipelineTaskChanged(@Args('id') id: string) {
// return await this.service.watchTaskUpdated(id);
}
@Query(() => [PipelineTask])
async listPipelineTaskByPipelineId(@Args('pipelineId') pipelineId: string) {
return await this.service.listTasksByPipelineId(pipelineId);

View File

@ -12,7 +12,6 @@ describe('PipelineTasksService', () => {
let service: PipelineTasksService;
let module: TestingModule;
let taskRepository: Repository<PipelineTask>;
let pipelineRepository: Repository<Pipeline>;
beforeEach(async () => {
module = await Test.createTestingModule({
@ -43,7 +42,6 @@ describe('PipelineTasksService', () => {
service = module.get<PipelineTasksService>(PipelineTasksService);
taskRepository = module.get(getRepositoryToken(PipelineTask));
pipelineRepository = module.get(getRepositoryToken(Pipeline));
jest
.spyOn(taskRepository, 'save')
.mockImplementation(async (data: any) => data);

View File

@ -4,7 +4,6 @@ import { PipelineTask } from './pipeline-task.entity';
import { Repository } from 'typeorm';
import { CreatePipelineTaskInput } from './dtos/create-pipeline-task.input';
import { Pipeline } from '../pipelines/pipeline.entity';
import debug from 'debug';
import { AmqpConnection, RabbitRPC } from '@golevelup/nestjs-rabbitmq';
import {
EXCHANGE_PIPELINE_TASK_TOPIC,
@ -19,8 +18,6 @@ import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { getAppInstanceRouteKey } from '../commons/utils/rabbit-mq';
import { ROUTE_PIPELINE_TASK_KILL } from './pipeline-tasks.constants';
const log = debug('fennec:pipeline-tasks:service');
@Injectable()
export class PipelineTasksService {
constructor(

View File

@ -0,0 +1,117 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getLoggerToken, PinoLogger } from 'nestjs-pino';
import { join } from 'path';
import { DeployByPm2Service } from './deploy-by-pm2.service';
describe('DeployByPm2Service', () => {
let service: DeployByPm2Service;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DeployByPm2Service,
{
provide: getLoggerToken(DeployByPm2Service.name),
useValue: new PinoLogger({
pinoHttp: {
level: 'silent',
},
}),
},
],
}).compile();
service = module.get<DeployByPm2Service>(DeployByPm2Service);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getAppsSn', () => {
it('should return right value', () => {
expect(
service['getAppsSn']([
{ name: 'app' },
{ name: 'app#4' },
{ name: 'app#1' },
]),
).toEqual(4 + 1);
});
it('should return 1 when no match', () => {
expect(
service['getAppsSn']([
{ name: 'bar' },
{ name: 'foo#4' },
{ name: 'foo#1' },
]),
).toEqual(4 + 1);
});
});
describe('filterOldApps', () => {
it('should return right value', () => {
expect(
service['filterOldApps'](
[{ name: 'app' }],
[
{ name: 'app' },
{ name: 'app#4' },
{ name: 'foo#2' },
{ name: 'bar' },
],
),
).toEqual([{ name: 'app' }, { name: 'app#4' }]);
});
it('should return [] when no match', () => {
expect(
service['filterOldApps'](
[{ name: 'app' }],
[{ name: 'foo#2' }, { name: 'bar' }],
),
).toEqual([]);
});
});
describe('replaceAppName', () => {
it('should be replaced with right value', () => {
const getAppsSn = jest
.spyOn(service, 'getAppsSn' as any)
.mockImplementation(() => 1);
const options = [{ name: 'app' }, { name: 'foo' }];
service['replaceAppName'](options, []);
expect(options).toEqual([{ name: 'app#1' }, { name: 'foo#1' }]);
expect(getAppsSn).toBeCalledTimes(1);
expect(getAppsSn.mock.calls[0][0]).toEqual([]);
});
});
describe('deploy', () => {
it('should be success', async () => {
const filterOldApps = jest
.spyOn(service, 'filterOldApps' as any)
.mockImplementation(() => [{ name: 'app#1' }, { name: 'app#2' }]);
const replaceAppName = jest
.spyOn(service, 'replaceAppName' as any)
.mockImplementation((options) => (options[0].name = 'app#2'));
const stopApps = jest
.spyOn(service, 'stopApps' as any)
.mockImplementation(() => Promise.resolve());
await expect(
service['deploy'](
join(
__dirname,
'../../../../test/__mocks__/deploy-service/ecosystem.config.js',
),
join(__dirname, '../../../../test/__mocks__/deploy-service'),
),
).resolves.toBeFalsy();
expect(filterOldApps).toBeCalledTimes(1);
expect(replaceAppName).toBeCalledTimes(1);
expect(stopApps).toBeCalledTimes(1);
stopApps.mockReset();
await service['stopApps']([{ name: 'app#2' }]);
}, 10_000);
});
});

View File

@ -0,0 +1,98 @@
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { Injectable } from '@nestjs/common';
import { promisify } from 'util';
import * as pm2 from 'pm2';
import { Proc, ProcessDescription, StartOptions } from 'pm2';
import { clone, last } from 'ramda';
@Injectable()
export class DeployByPm2Service {
constructor(
@InjectPinoLogger(DeployByPm2Service.name)
private readonly logger: PinoLogger,
) {}
async deploy(filePath: string, workspace: string) {
const baseConfig: { apps: StartOptions[] } = await import(filePath);
const appOptionsList: StartOptions[] = clone(baseConfig.apps);
await promisify<void>(pm2.connect.bind(pm2))();
const allApps = await promisify(pm2.list.bind(pm2))();
try {
if (!Array.isArray(baseConfig.apps)) {
this.logger.error(
'the "apps" in the PM2 ecosystem configuration is not array',
);
throw new Error('apps is not array');
}
const oldApps = this.filterOldApps(appOptionsList, allApps);
this.replaceAppName(appOptionsList, oldApps);
for (const appOptions of appOptionsList) {
const proc = await promisify<StartOptions, Proc>(pm2.start.bind(pm2))({
...appOptions,
cwd: workspace,
});
this.logger.info({ proc }, `start ${appOptions.name}`);
}
await this.stopApps(oldApps);
} catch (err) {
await this.stopApps(appOptionsList);
throw err;
} finally {
pm2.disconnect();
}
}
private async stopApps(apps: ProcessDescription[] | StartOptions[]) {
await Promise.all(
apps.map(async (app: ProcessDescription | StartOptions) => {
let procAtStop: ProcessDescription;
let procAtDelete: ProcessDescription;
try {
const idOrName = 'pm_id' in app ? app.pm_id : app.name;
procAtStop = await promisify(pm2.stop.bind(pm2))(idOrName);
procAtDelete = await promisify(pm2.delete.bind(pm2))(idOrName);
this.logger.info('stop & delete %s success', app.name);
} catch (error) {
this.logger.error(
{ error, procAtStop, procAtDelete },
'stop & delete %s error',
app.name,
);
}
}),
);
}
private replaceAppName(
optionsList: StartOptions[],
oldApps: ProcessDescription[],
) {
const appSn = this.getAppsSn(oldApps);
optionsList.forEach((options) => {
if (!options.name) {
this.logger.error('please give a name for application');
throw new Error('app name is not given');
}
options.name = `${options.name}#${appSn}`;
});
}
private filterOldApps(
optionsList: StartOptions[],
apps: ProcessDescription[],
) {
return apps.filter((app) =>
optionsList.some((options) => app.name.split('#')[0] === options.name),
);
}
private getAppsSn(oldApps: ProcessDescription[]) {
const appsSn: number[] = oldApps.map(
(app) => +(app.name.split('#')?.[1] ?? 0),
);
return (last(appsSn.sort()) ?? 0) + 1;
}
}

View File

@ -1,3 +1,4 @@
import { JwtService } from '@nestjs-lib/auth';
import { Test, TestingModule } from '@nestjs/testing';
import { PipelineTasksService } from '../pipeline-tasks/pipeline-tasks.service';
import { CommitLogsResolver } from './commit-logs.resolver';
@ -18,6 +19,10 @@ describe('CommitLogsResolver', () => {
provide: PipelineTasksService,
useValue: {},
},
{
provide: JwtService,
useValue: {},
},
],
}).compile();

View File

@ -28,4 +28,8 @@ export class CreatePipelineInput {
@ValidateNested()
@IsInstance(WorkUnitMetadata)
workUnitMetadata: WorkUnitMetadata;
@IsString()
@MaxLength(100)
environment: string;
}

View File

@ -12,7 +12,7 @@ export class Pipeline extends AppBaseEntity {
@Column()
projectId: string;
@Column({ comment: 'eg: remotes/origin/master' })
@Column({ comment: 'E.g., remotes/origin/master' })
branch: string;
@Column()
@ -20,4 +20,7 @@ export class Pipeline extends AppBaseEntity {
@Column({ type: 'jsonb' })
workUnitMetadata: WorkUnitMetadata;
@Column()
environment: string;
}

View File

@ -5,14 +5,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Pipeline } from './pipeline.entity';
import { CommitLogsResolver } from './commit-logs.resolver';
import { PipelineTasksModule } from '../pipeline-tasks/pipeline-tasks.module';
import { ReposModule } from '../repos/repos.module';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CommonsModule } from '../commons/commons.module';
@Module({
imports: [
CommonsModule,
TypeOrmModule.forFeature([Pipeline]),
PipelineTasksModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {

View File

@ -1,3 +1,4 @@
import { JwtService } from '@nestjs-lib/auth';
import { Test, TestingModule } from '@nestjs/testing';
import { PipelinesResolver } from './pipelines.resolver';
import { PipelinesService } from './pipelines.service';
@ -13,6 +14,10 @@ describe('PipelinesResolver', () => {
provide: PipelinesService,
useValue: {},
},
{
provide: JwtService,
useValue: {},
},
],
}).compile();

View File

@ -2,13 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing';
import { PipelinesService } from './pipelines.service';
import { Pipeline } from './pipeline.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Project } from '../projects/project.entity';
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
describe('PipelinesService', () => {
let service: PipelinesService;
let repository: Repository<Pipeline>;
let pipeline: Pipeline;
beforeEach(async () => {
@ -40,7 +38,6 @@ describe('PipelinesService', () => {
}).compile();
service = module.get<PipelinesService>(PipelinesService);
repository = module.get(getRepositoryToken(Pipeline));
});
it('should be defined', () => {

View File

@ -19,7 +19,9 @@ import { plainToClass } from 'class-transformer';
@Injectable()
export class PipelinesService extends BaseDbService<Pipeline> {
readonly uniqueFields: Array<Array<keyof Pipeline>> = [['projectId', 'name']];
readonly uniqueFields: Array<Array<keyof Pipeline>> = [
['projectId', 'name', 'environment'],
];
constructor(
@InjectRepository(Pipeline)
readonly repository: Repository<Pipeline>,

View File

@ -1,5 +1,5 @@
import { ObjectType } from '@nestjs/graphql';
import { Entity, Column, DeleteDateColumn } from 'typeorm';
import { Entity, Column } from 'typeorm';
import { AppBaseEntity } from '../commons/entities/app-base-entity';
@ObjectType()

View File

@ -6,11 +6,9 @@ import { Project } from './project.entity';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EXCHANGE_PROJECT_FANOUT } from './projects.constants';
import { CommonsModule } from '../commons/commons.module';
@Module({
imports: [
CommonsModule,
TypeOrmModule.forFeature([Project]),
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],

View File

@ -1,3 +1,4 @@
import { JwtService } from '@nestjs-lib/auth';
import { Test, TestingModule } from '@nestjs/testing';
import { ProjectsResolver } from './projects.resolver';
import { ProjectsService } from './projects.service';
@ -13,6 +14,10 @@ describe('ProjectsResolver', () => {
provide: ProjectsService,
useValue: {},
},
{
provide: JwtService,
useValue: {},
},
],
}).compile();

View File

@ -1,10 +1,5 @@
import { ObjectType, Field } from '@nestjs/graphql';
import {
LogResult,
DefaultLogFields,
BranchSummary,
BranchSummaryBranch,
} from 'simple-git';
import { BranchSummaryBranch } from 'simple-git';
@ObjectType()
export class Branch implements BranchSummaryBranch {

View File

@ -7,14 +7,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { ProjectsModule } from '../projects/projects.module';
import { EXCHANGE_REPO } from './repos.constants';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { CommonsModule } from '../commons/commons.module';
@Module({
imports: [
TypeOrmModule.forFeature([Project]),
ConfigModule,
ProjectsModule,
CommonsModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({

View File

@ -161,7 +161,7 @@ describe('ReposService', () => {
const project = new Project();
const pipeline = new Pipeline();
pipeline.branch = 'test';
const fetch = jest.fn((_: any) => Promise.resolve());
const fetch = jest.fn<any, [any]>(() => Promise.resolve());
pipeline.project = project;
const getGit = jest.spyOn(service, 'getGit').mockImplementation(() =>
Promise.resolve({
@ -182,7 +182,7 @@ describe('ReposService', () => {
const project = new Project();
const pipeline = new Pipeline();
pipeline.branch = 'test';
const fetch = jest.fn((_: any) => Promise.resolve());
const fetch = jest.fn<any, [any]>(() => Promise.resolve());
pipeline.project = project;
const getGit = jest
.spyOn(service, 'getGit')
@ -196,7 +196,7 @@ describe('ReposService', () => {
const project = new Project();
const pipeline = new Pipeline();
pipeline.branch = 'test';
const fetch = jest.fn((_: any) => Promise.reject('error'));
const fetch = jest.fn<any, [any]>(() => Promise.reject('error'));
pipeline.project = project;
const getGit = jest.spyOn(service, 'getGit').mockImplementation(() =>
Promise.resolve({

View File

@ -56,6 +56,14 @@ export class ReposService {
);
}
getDeployRoot(task: PipelineTask) {
return join(
this.configService.get<string>('workspaces.root'),
encodeURIComponent(task.pipeline.project.name),
encodeURIComponent(`deploy-${task.pipeline.name}`),
);
}
async getGit(
project: Project,
workspaceRoot?: string,

View File

@ -1,4 +1,4 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PipelineTasksModule } from '../pipeline-tasks/pipeline-tasks.module';
import { GiteaWebhooksController } from './gitea-webhooks.controller';
@ -10,5 +10,4 @@ import { WebhooksService } from './webhooks.service';
controllers: [GiteaWebhooksController],
providers: [WebhooksService],
})
export class WebhooksModule {
}
export class WebhooksModule {}

View File

@ -0,0 +1,15 @@
module.exports = {
apps: [
{
name: 'app',
script: __dirname + '/index.js',
watch: false,
ignore_watch: ['node_modules'],
log_date_format: 'MM-DD HH:mm:ss.SSS Z',
env: {},
max_restarts: 5,
kill_timeout: 10_000,
wait_ready: true,
},
],
};

View File

@ -0,0 +1,23 @@
import { createServer } from 'http';
var app = createServer(function (req, res) {
res.writeHead(200);
setTimeout(() => {
res.end('hey');
}, 2000);
});
var listener = app.listen(0, function () {
console.log('Listening on port ' + listener.address().port);
setTimeout(() => {
// Here we send the ready signal to PM2
process.send('ready');
}, 5000);
});
process.on('SIGINT', function () {
listener.close();
setTimeout(() => {
process.exit(0);
}, 2000);
});

View File

@ -0,0 +1,10 @@
{
"name": "deploy-service",
"version": "1.0.0",
"description": "For Test",
"main": "index.js",
"type": "module",
"scripts": {},
"author": "Ivan Li",
"license": "ISC"
}

View File

@ -0,0 +1,16 @@
import { createServer } from 'http';
var app = createServer(function (req, res) {
res.writeHead(200);
setTimeout(() => {
res.end('pm2');
}, 2000);
});
var listener = app.listen(33333, function () {
console.log('Listening on port ' + listener.address().port);
setTimeout(() => {
// Here we send the ready signal to PM2
process.send('ready');
}, 5000);
});

View File

@ -11,6 +11,5 @@
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true
}
},
}