6181 workflows create a custom code executor (#6235)

Closes #6181

## Testing
- download Altair graphql dev tool https://altairgraphql.dev/#download
- create a file locally `test.ts` containing:
```
export const handler = async (event: object, context: object) => {
  return { test: 'toto', data: event['data'] };
}
```
- play those requests in Altair:
mutation UpsertFunction($file: Upload!) {
  upsertFunction(name: "toto", file: $file)
}

mutation ExecFunction {
  executeFunction(name:"toto", payload: {data: "titi"})
}
- it will run the local driver, add those env variable to test with
lambda driver
```
CUSTOM_CODE_ENGINE_DRIVER_TYPE=lambda
LAMBDA_REGION=eu-west-2
LAMBDA_ROLE=<ASK_ME>
```
This commit is contained in:
martmull
2024-07-17 17:53:01 +02:00
committed by GitHub
parent e6f6069fe7
commit 47ddc7be83
37 changed files with 2382 additions and 16 deletions

View File

@ -0,0 +1,20 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
@ArgsType()
export class ExecuteServerlessFunctionInput {
@Field({ description: 'Name of the serverless function to execute' })
@IsNotEmpty()
@IsString()
name: string;
@Field(() => graphqlTypeJson, {
description: 'Payload in JSON format',
nullable: true,
})
@IsObject()
@IsOptional()
payload?: JSON;
}

View File

@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsObject } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
@ObjectType('ServerlessFunctionExecutionResult')
export class ServerlessFunctionExecutionResultDTO {
@IsObject()
@Field(() => graphqlTypeJson, {
description: 'Execution result in JSON format',
})
result: JSON;
}

View File

@ -0,0 +1,70 @@
import {
Field,
HideField,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import {
Authorize,
IDField,
QueryOptions,
} from '@ptc-org/nestjs-query-graphql';
import {
IsDateString,
IsEnum,
IsNotEmpty,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ServerlessFunctionSyncStatus } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
registerEnumType(ServerlessFunctionSyncStatus, {
name: 'ServerlessFunctionSyncStatus',
description: 'SyncStatus of the serverlessFunction',
});
@ObjectType('serverlessFunction')
@Authorize({
authorize: (context: any) => ({
workspaceId: { eq: context?.req?.user?.workspace?.id },
}),
})
@QueryOptions({
defaultResultSize: 10,
maxResultsSize: 1000,
})
export class ServerlessFunctionDto {
@IsUUID()
@IsNotEmpty()
@IDField(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsNotEmpty()
@Field()
sourceCodeHash: string;
@IsEnum(ServerlessFunctionSyncStatus)
@IsNotEmpty()
@Field(() => ServerlessFunctionSyncStatus)
syncStatus: ServerlessFunctionSyncStatus;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}

View File

@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
export enum ServerlessFunctionSyncStatus {
NOT_READY = 'NOT_READY',
READY = 'READY',
}
@Entity('serverlessFunction')
@Unique('IndexOnNameAndWorkspaceIdUnique', ['name', 'workspaceId'])
export class ServerlessFunctionEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
sourceCodeHash: string;
@Column({
nullable: false,
default: ServerlessFunctionSyncStatus.NOT_READY,
type: 'enum',
enum: ServerlessFunctionSyncStatus,
})
syncStatus: ServerlessFunctionSyncStatus;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,14 @@
import { CustomException } from 'src/utils/custom-exception';
export class ServerlessFunctionException extends CustomException {
code: ServerlessFunctionExceptionCode;
constructor(message: string, code: ServerlessFunctionExceptionCode) {
super(message, code);
}
}
export enum ServerlessFunctionExceptionCode {
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST',
SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY',
}

View File

@ -0,0 +1,50 @@
import { Module } from '@nestjs/common';
import {
NestjsQueryGraphQLModule,
PagingStrategies,
} from '@ptc-org/nestjs-query-graphql';
import { SortDirection } from '@ptc-org/nestjs-query-core';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ServerlessModule } from 'src/engine/integrations/serverless/serverless.module';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverless-function/serverless-function.resolver';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
FileUploadModule,
NestjsQueryTypeOrmModule.forFeature(
[ServerlessFunctionEntity],
'metadata',
),
],
services: [ServerlessFunctionService],
resolvers: [
{
EntityClass: ServerlessFunctionEntity,
DTOClass: ServerlessFunctionDto,
ServiceClass: ServerlessFunctionService,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: { disabled: true },
update: { disabled: true },
delete: { disabled: true },
guards: [JwtAuthGuard],
},
],
}),
ServerlessModule,
],
providers: [ServerlessFunctionService, ServerlessFunctionResolver],
exports: [ServerlessFunctionService],
})
export class ServerlessFunctionModule {}

View File

@ -0,0 +1,59 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { ServerlessFunctionExecutionResultDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result-d-t.o';
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
@UseGuards(JwtAuthGuard)
@Resolver()
export class ServerlessFunctionResolver {
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
) {}
@Mutation(() => ServerlessFunctionDto)
async createOneServerlessFunction(
@Args({ name: 'file', type: () => GraphQLUpload })
file: FileUpload,
@Args('name', { type: () => String }) name: string,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
return await this.serverlessFunctionService.createOne(
name,
workspaceId,
file,
);
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => ServerlessFunctionExecutionResultDTO)
async executeOneServerlessFunction(
@Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
const { name, payload } = executeServerlessFunctionInput;
return {
result: await this.serverlessFunctionService.executeOne(
name,
workspaceId,
payload,
),
};
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
}
}

View File

@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { FileUpload } from 'graphql-upload';
import { Repository } from 'typeorm';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { ServerlessService } from 'src/engine/integrations/serverless/serverless.service';
import {
ServerlessFunctionEntity,
ServerlessFunctionSyncStatus,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import {
ServerlessFunctionException,
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { SOURCE_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/source-file-name';
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
@Injectable()
export class ServerlessFunctionService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly serverlessService: ServerlessService,
@InjectRepository(ServerlessFunctionEntity, 'metadata')
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
) {}
async executeOne(
name: string,
workspaceId: string,
payload: object | undefined = undefined,
) {
const functionToExecute = await this.serverlessFunctionRepository.findOne({
where: {
name,
workspaceId,
},
});
if (!functionToExecute) {
throw new ServerlessFunctionException(
`Function does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
if (
functionToExecute.syncStatus === ServerlessFunctionSyncStatus.NOT_READY
) {
throw new ServerlessFunctionException(
`Function is not ready to be executed`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
return this.serverlessService.execute(functionToExecute, payload);
}
async createOne(
name: string,
workspaceId: string,
{ createReadStream, mimetype }: FileUpload,
) {
const existingServerlessFunction =
await this.serverlessFunctionRepository.findOne({
where: { name, workspaceId },
});
if (existingServerlessFunction) {
throw new ServerlessFunctionException(
`Function already exists`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST,
);
}
const typescriptCode = await readFileContent(createReadStream());
const serverlessFunction = await this.serverlessFunctionRepository.save({
name,
workspaceId,
sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
});
const fileFolder = join(
FileFolder.ServerlessFunction,
workspaceId,
serverlessFunction.id,
);
await this.fileStorageService.write({
file: typescriptCode,
name: SOURCE_FILE_NAME,
mimeType: mimetype,
folder: fileFolder,
});
await this.serverlessService.build(serverlessFunction);
await this.serverlessFunctionRepository.update(serverlessFunction.id, {
syncStatus: ServerlessFunctionSyncStatus.READY,
});
return await this.serverlessFunctionRepository.findOneByOrFail({
id: serverlessFunction.id,
});
}
}

View File

@ -0,0 +1,8 @@
import { createHash } from 'crypto';
export const serverlessFunctionCreateHash = (fileContent: string) => {
return createHash('sha512')
.update(fileContent)
.digest('hex')
.substring(0, 32);
};

View File

@ -0,0 +1,26 @@
import {
ServerlessFunctionException,
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import {
ConflictError,
ForbiddenError,
InternalServerError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => {
if (error instanceof ServerlessFunctionException) {
switch (error.code) {
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND:
throw new NotFoundError(error.message);
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST:
throw new ConflictError(error.message);
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_READY:
throw new ForbiddenError(error.message);
default:
throw new InternalServerError(error.message);
}
}
throw error;
};