6654 serverless functions add a deploy button disable deploy when autosave (#6715)

- improvements on serverless function behavior (autosave performances,
deploy on execution only)
- add versioning to serverless functions
- add a publish endpoint to create a new version of a serverless
function
  - add deploy and reset to lastVersion button in the settings section:
<img width="736" alt="image"
src="https://github.com/user-attachments/assets/2001f8d2-07a4-4f79-84dd-ec74b6f301d3">
This commit is contained in:
martmull
2024-08-23 12:06:03 +02:00
committed by GitHub
parent 7ca091faa5
commit 6f9aa1e870
42 changed files with 850 additions and 269 deletions

View File

@ -1,11 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { ArgsType, Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsObject, IsOptional, IsUUID } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ArgsType()
@InputType()
export class ExecuteServerlessFunctionInput {
@Field(() => UUIDScalarType, {
description: 'Id of the serverless function to execute',
@ -21,4 +21,11 @@ export class ExecuteServerlessFunctionInput {
@IsObject()
@IsOptional()
payload?: JSON;
@Field(() => String, {
nullable: false,
description: 'Version of the serverless function to execute',
defaultValue: 'latest',
})
version: string;
}

View File

@ -0,0 +1,16 @@
import { Field, ID, InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
@InputType()
export class GetServerlessFunctionSourceCodeInput {
@IDField(() => ID, { description: 'The id of the function.' })
id!: string;
@Field(() => String, {
nullable: false,
description: 'The version of the function',
defaultValue: 'draft',
})
version: string;
}

View File

@ -0,0 +1,9 @@
import { ID, InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
@InputType()
export class PublishServerlessFunctionInput {
@IDField(() => ID, { description: 'The id of the function.' })
id!: string;
}

View File

@ -14,6 +14,7 @@ import {
IsDateString,
IsEnum,
IsNotEmpty,
IsNumber,
IsString,
IsUUID,
} from 'class-validator';
@ -59,12 +60,11 @@ export class ServerlessFunctionDTO {
@IsString()
@IsNotEmpty()
@Field()
sourceCodeFullPath: string;
runtime: string;
@IsString()
@IsNotEmpty()
@Field()
runtime: string;
@Field({ nullable: true })
latestVersion: string;
@IsEnum(ServerlessFunctionSyncStatus)
@IsNotEmpty()

View File

@ -28,11 +28,11 @@ export class ServerlessFunctionEntity {
@Column({ nullable: true })
description: string;
@Column({ nullable: false })
sourceCodeHash: string;
@Column({ nullable: true })
latestVersion: string;
@Column({ nullable: false })
sourceCodeFullPath: string;
sourceCodeHash: string;
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 })
runtime: ServerlessFunctionRuntime;

View File

@ -9,6 +9,7 @@ export class ServerlessFunctionException extends CustomException {
export enum ServerlessFunctionExceptionCode {
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
SERVERLESS_FUNCTION_VERSION_NOT_FOUND = 'SERVERLESS_FUNCTION_VERSION_NOT_FOUND',
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST',
SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY',

View File

@ -1,57 +0,0 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
@Injectable()
export class ServerlessFunctionInterceptor implements NestInterceptor {
constructor(private readonly fileService: FileService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map(async (data) => {
if (data.edges && Array.isArray(data.edges)) {
return {
...data,
edges: Promise.all(
data.edges.map((item) => ({
...item,
node: this.processItem(item.node),
})),
),
};
} else {
return this.processItem(data);
}
}),
);
}
private async processItem(item: any): Promise<any> {
if (item && item.sourceCodeFullPath) {
const workspaceId = item.workspace?.id || item.workspaceId;
if (!workspaceId) {
return item;
}
const signedPayload = await this.fileService.encodeFileToken({
serverlessFunctionId: item.id,
workspace_id: workspaceId,
});
return {
...item,
sourceCodeFullPath: `${item.sourceCodeFullPath}?token=${signedPayload}`,
};
}
return item;
}
}

View File

@ -15,7 +15,6 @@ import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ServerlessModule } from 'src/engine/integrations/serverless/serverless.module';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ServerlessFunctionInterceptor } from 'src/engine/metadata-modules/serverless-function/serverless-function.interceptor';
import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverless-function/serverless-function.resolver';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@ -45,7 +44,6 @@ import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverles
update: { disabled: true },
delete: { disabled: true },
guards: [JwtAuthGuard],
interceptors: [ServerlessFunctionInterceptor],
},
],
}),

View File

@ -1,5 +1,5 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
@ -21,9 +21,10 @@ import {
ServerlessFunctionException,
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { ServerlessFunctionInterceptor } from 'src/engine/metadata-modules/serverless-function/serverless-function.interceptor';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
import { GetServerlessFunctionSourceCodeInput } from 'src/engine/metadata-modules/serverless-function/dtos/get-serverless-function-source-code.input';
import { PublishServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/publish-serverless-function.input';
@UseGuards(JwtAuthGuard)
@Resolver()
@ -50,6 +51,24 @@ export class ServerlessFunctionResolver {
}
}
@Query(() => String)
async getServerlessFunctionSourceCode(
@Args('input') input: GetServerlessFunctionSourceCodeInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
await this.checkFeatureFlag(workspaceId);
return await this.serverlessFunctionService.getServerlessFunctionSourceCode(
workspaceId,
input.id,
input.version,
);
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => ServerlessFunctionDTO)
async deleteOneServerlessFunction(
@Args('input') input: DeleteServerlessFunctionInput,
@ -67,7 +86,6 @@ export class ServerlessFunctionResolver {
}
}
@UseInterceptors(ServerlessFunctionInterceptor)
@Mutation(() => ServerlessFunctionDTO)
async updateOneServerlessFunction(
@Args('input')
@ -86,7 +104,6 @@ export class ServerlessFunctionResolver {
}
}
@UseInterceptors(ServerlessFunctionInterceptor)
@Mutation(() => ServerlessFunctionDTO)
async createOneServerlessFunction(
@Args('input')
@ -109,7 +126,6 @@ export class ServerlessFunctionResolver {
}
}
@UseInterceptors(ServerlessFunctionInterceptor)
@Mutation(() => ServerlessFunctionDTO)
async createOneServerlessFunctionFromFile(
@Args({ name: 'file', type: () => GraphQLUpload })
@ -133,17 +149,36 @@ export class ServerlessFunctionResolver {
@Mutation(() => ServerlessFunctionExecutionResultDTO)
async executeOneServerlessFunction(
@Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput,
@Args('input') input: ExecuteServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
await this.checkFeatureFlag(workspaceId);
const { id, payload } = executeServerlessFunctionInput;
const { id, payload, version } = input;
return await this.serverlessFunctionService.executeOne(
return await this.serverlessFunctionService.executeOneServerlessFunction(
id,
workspaceId,
payload,
version,
);
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => ServerlessFunctionDTO)
async publishServerlessFunction(
@Args('input') input: PublishServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
await this.checkFeatureFlag(workspaceId);
const { id } = input;
return await this.serverlessFunctionService.publishOneServerlessFunction(
id,
workspaceId,
);
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);

View File

@ -1,15 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FileUpload } from 'graphql-upload';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { ServerlessExecuteResult } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
import { FileStorageExceptionCode } from 'src/engine/integrations/file-storage/interfaces/file-storage-exception';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content';
@ -26,6 +23,8 @@ import {
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
import { isDefined } from 'src/utils/is-defined';
import { getServerlessFolder } from 'src/engine/integrations/serverless/utils/serverless-get-folder.utils';
@Injectable()
export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFunctionEntity> {
@ -38,10 +37,54 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
super(serverlessFunctionRepository);
}
async executeOne(
async getServerlessFunctionSourceCode(
workspaceId: string,
id: string,
version: string,
) {
try {
const serverlessFunction =
await this.serverlessFunctionRepository.findOne({
where: {
id,
workspaceId,
},
});
if (!serverlessFunction) {
throw new ServerlessFunctionException(
`Function does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
const folderPath = getServerlessFolder({
serverlessFunction,
version,
});
const fileStream = await this.fileStorageService.read({
folderPath,
filename: SOURCE_FILE_NAME,
});
return await readFileContent(fileStream);
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
throw new ServerlessFunctionException(
`Function Version '${version}' does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
throw error;
}
}
async executeOneServerlessFunction(
id: string,
workspaceId: string,
payload: object | undefined = undefined,
version = 'latest',
): Promise<ServerlessExecuteResult> {
const functionToExecute = await this.serverlessFunctionRepository.findOne({
where: {
@ -60,13 +103,73 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
if (
functionToExecute.syncStatus === ServerlessFunctionSyncStatus.NOT_READY
) {
await this.serverlessService.build(functionToExecute, version);
await super.updateOne(functionToExecute.id, {
syncStatus: ServerlessFunctionSyncStatus.READY,
});
}
return this.serverlessService.execute(functionToExecute, payload, version);
}
async publishOneServerlessFunction(id: string, workspaceId: string) {
const existingServerlessFunction =
await this.serverlessFunctionRepository.findOne({
where: { id, workspaceId },
});
if (!existingServerlessFunction) {
throw new ServerlessFunctionException(
`Function is not ready to be executed`,
`Function does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
return this.serverlessService.execute(functionToExecute, payload);
if (isDefined(existingServerlessFunction.latestVersion)) {
const latestCode = await this.getServerlessFunctionSourceCode(
workspaceId,
id,
'latest',
);
const draftCode = await this.getServerlessFunctionSourceCode(
workspaceId,
id,
'draft',
);
if (
serverlessFunctionCreateHash(latestCode) ===
serverlessFunctionCreateHash(draftCode)
) {
throw new Error(
'Cannot publish a new version when code has not changed',
);
}
}
const newVersion = await this.serverlessService.publish(
existingServerlessFunction,
);
const draftFolderPath = getServerlessFolder({
serverlessFunction: existingServerlessFunction,
version: 'draft',
});
const newFolderPath = getServerlessFolder({
serverlessFunction: existingServerlessFunction,
version: newVersion,
});
await this.fileStorageService.copy({
from: { folderPath: draftFolderPath },
to: { folderPath: newFolderPath },
});
await super.updateOne(existingServerlessFunction.id, {
latestVersion: newVersion,
});
return await this.findById(existingServerlessFunction.id);
}
async deleteOneServerlessFunction(id: string, workspaceId: string) {
@ -86,6 +189,12 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
await this.serverlessService.delete(existingServerlessFunction);
await this.fileStorageService.delete({
folderPath: getServerlessFolder({
serverlessFunction: existingServerlessFunction,
}),
});
return existingServerlessFunction;
}
@ -105,34 +214,23 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
);
}
const codeHasChanged =
serverlessFunctionCreateHash(serverlessFunctionInput.code) !==
existingServerlessFunction.sourceCodeHash;
await super.updateOne(existingServerlessFunction.id, {
name: serverlessFunctionInput.name,
description: serverlessFunctionInput.description,
sourceCodeHash: serverlessFunctionCreateHash(
serverlessFunctionInput.code,
),
syncStatus: ServerlessFunctionSyncStatus.NOT_READY,
});
if (codeHasChanged) {
const fileFolder = join(
'workspace-' + workspaceId,
FileFolder.ServerlessFunction,
existingServerlessFunction.id,
);
const fileFolder = getServerlessFolder({
serverlessFunction: existingServerlessFunction,
version: 'draft',
});
await this.fileStorageService.write({
file: serverlessFunctionInput.code,
name: SOURCE_FILE_NAME,
mimeType: undefined,
folder: fileFolder,
});
await this.serverlessService.build(existingServerlessFunction);
}
await this.fileStorageService.write({
file: serverlessFunctionInput.code,
name: SOURCE_FILE_NAME,
mimeType: undefined,
folder: fileFolder,
});
return await this.findById(existingServerlessFunction.id);
}
@ -162,41 +260,24 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
typescriptCode = await readFileContent(code.createReadStream());
}
const serverlessFunctionId = v4();
const fileFolderWithoutWorkspace = join(
FileFolder.ServerlessFunction,
serverlessFunctionId,
);
const fileFolder = join(
'workspace-' + workspaceId,
fileFolderWithoutWorkspace,
);
const sourceCodeFullPath =
fileFolderWithoutWorkspace + '/' + SOURCE_FILE_NAME;
const serverlessFunction = await super.createOne({
const createdServerlessFunction = await super.createOne({
...serverlessFunctionInput,
id: serverlessFunctionId,
workspaceId,
sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
sourceCodeFullPath,
});
const draftFileFolder = getServerlessFolder({
serverlessFunction: createdServerlessFunction,
version: 'draft',
});
await this.fileStorageService.write({
file: typescriptCode,
name: SOURCE_FILE_NAME,
mimeType: undefined,
folder: fileFolder,
folder: draftFileFolder,
});
await this.serverlessService.build(serverlessFunction);
await super.updateOne(serverlessFunctionId, {
syncStatus: ServerlessFunctionSyncStatus.READY,
});
return await this.findById(serverlessFunctionId);
return await this.findById(createdServerlessFunction.id);
}
}

View File

@ -13,6 +13,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => {
if (error instanceof ServerlessFunctionException) {
switch (error.code) {
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND:
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_VERSION_NOT_FOUND:
throw new NotFoundError(error.message);
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST:
throw new ConflictError(error.message);