feat(serverless): add basic sandbox isolation and flexible driver options (#17176)
## Overview - Add a DISABLED serverless driver to explicitly turn off execution - Clarify self-hosting docs with driver options and recommended usage - Keep integration coverage for serverless function execution (default + external package example) ## Notes - Local driver remains the default for development usage; Lambda or Disabled recommended for production deployments - No functional changes to Lambda execution <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces flexible serverless execution modes and safer local execution. > > - **New driver:** `DISABLED` serverless driver with wiring in `serverless.interface`, factory, module provider, and GraphQL exception mapping; new exception code `SERVERLESS_FUNCTION_DISABLED`. > - **Local driver hardening:** Strip `NODE_OPTIONS` when spawning child processes; cleanup promise signature; better log capture. > - **Dependency build reliability:** Use `execFile` with bundled Yarn (`.yarn/releases/yarn-4.9.2.cjs`), strip `NODE_OPTIONS`, improved error messages, and parallel cleanup excluding `node_modules`. > - **Docs:** Add serverless section detailing `SERVERLESS_TYPE` options (LOCAL, LAMBDA, DISABLED), security notice, and recommended configs. > - **Config/env:** Default `IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS` set to `true` (examples/tests default `false`); sample envs updated. > - **Tests:** Add integration tests and GraphQL helpers for creating, updating, publishing, executing, and deleting serverless functions, including external package usage and error paths. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1a2958cc19cff1b0108c51b83095bbf95e75d931. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
+231
@@ -0,0 +1,231 @@
|
||||
import { createOneServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util';
|
||||
import { deleteServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/delete-serverless-function.util';
|
||||
import { executeServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/execute-serverless-function.util';
|
||||
import { publishServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/publish-serverless-function.util';
|
||||
import { updateServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/update-serverless-function.util';
|
||||
|
||||
import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
|
||||
|
||||
// Test function using external packages from default layer (lodash.groupby)
|
||||
const EXTERNAL_PACKAGES_FUNCTION_CODE = {
|
||||
'src/index.ts': `import groupBy from 'lodash.groupby';
|
||||
|
||||
export const main = async (params: { items: Array<{ category: string; name: string }> }): Promise<object> => {
|
||||
const grouped = groupBy(params.items, 'category');
|
||||
return {
|
||||
grouped,
|
||||
categories: Object.keys(grouped),
|
||||
};
|
||||
};`,
|
||||
};
|
||||
|
||||
// Test function that throws an error
|
||||
const ERROR_FUNCTION_CODE = {
|
||||
'src/index.ts': `export const main = async (params: { shouldFail: boolean }): Promise<object> => {
|
||||
if (params.shouldFail) {
|
||||
throw new Error('Intentional test error');
|
||||
}
|
||||
return { success: true };
|
||||
};`,
|
||||
};
|
||||
|
||||
describe('Serverless Function Execution', () => {
|
||||
const createdFunctionIds: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up all created functions
|
||||
for (const functionId of createdFunctionIds) {
|
||||
try {
|
||||
await deleteServerlessFunction({
|
||||
input: { id: functionId },
|
||||
expectToFail: false,
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute the default serverless function template', async () => {
|
||||
// Create the function (uses default template)
|
||||
const { data: createData } = await createOneServerlessFunction({
|
||||
input: {
|
||||
name: 'Test Default Function',
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const functionId = createData?.createOneServerlessFunction?.id;
|
||||
|
||||
expect(functionId).toBeDefined();
|
||||
createdFunctionIds.push(functionId);
|
||||
|
||||
// Publish the function
|
||||
const { data: publishData } = await publishServerlessFunction({
|
||||
input: { id: functionId },
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(publishData?.publishServerlessFunction?.latestVersion).toBeDefined();
|
||||
|
||||
// Execute with the default template's expected params: { a: string, b: number }
|
||||
const { data: executeData } = await executeServerlessFunction({
|
||||
input: {
|
||||
id: functionId,
|
||||
payload: { a: 'hello', b: 42 },
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const result = executeData?.executeOneServerlessFunction;
|
||||
|
||||
if (result?.status !== ServerlessFunctionExecutionStatus.SUCCESS) {
|
||||
throw new Error(JSON.stringify(result?.error, null, 2));
|
||||
}
|
||||
|
||||
expect(result?.status).toBe(ServerlessFunctionExecutionStatus.SUCCESS);
|
||||
expect(result?.data).toMatchObject({
|
||||
message: 'Hello, input: hello and 42',
|
||||
});
|
||||
expect(result?.duration).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should execute a function with external packages (lodash.groupby)', async () => {
|
||||
// Create the function (uses default template initially)
|
||||
const { data: createData } = await createOneServerlessFunction({
|
||||
input: {
|
||||
name: 'External Packages Test',
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const functionId = createData?.createOneServerlessFunction?.id;
|
||||
|
||||
expect(functionId).toBeDefined();
|
||||
createdFunctionIds.push(functionId);
|
||||
|
||||
// Update with custom code using external packages
|
||||
await updateServerlessFunction({
|
||||
input: {
|
||||
id: functionId,
|
||||
update: {
|
||||
name: 'External Packages Test',
|
||||
code: EXTERNAL_PACKAGES_FUNCTION_CODE,
|
||||
},
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
// Publish the function
|
||||
await publishServerlessFunction({
|
||||
input: { id: functionId },
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
// Execute the function with items to group
|
||||
const { data: executeData } = await executeServerlessFunction({
|
||||
input: {
|
||||
id: functionId,
|
||||
payload: {
|
||||
items: [
|
||||
{ category: 'fruit', name: 'apple' },
|
||||
{ category: 'vegetable', name: 'carrot' },
|
||||
{ category: 'fruit', name: 'banana' },
|
||||
],
|
||||
},
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const result = executeData?.executeOneServerlessFunction;
|
||||
|
||||
if (result?.status !== ServerlessFunctionExecutionStatus.SUCCESS) {
|
||||
throw new Error(JSON.stringify(result?.error, null, 2));
|
||||
}
|
||||
|
||||
expect(result?.status).toBe(ServerlessFunctionExecutionStatus.SUCCESS);
|
||||
|
||||
const data = result?.data as unknown as {
|
||||
grouped: Record<string, Array<{ category: string; name: string }>>;
|
||||
categories: string[];
|
||||
};
|
||||
|
||||
expect(data?.grouped).toMatchObject({
|
||||
fruit: [
|
||||
{ category: 'fruit', name: 'apple' },
|
||||
{ category: 'fruit', name: 'banana' },
|
||||
],
|
||||
vegetable: [{ category: 'vegetable', name: 'carrot' }],
|
||||
});
|
||||
expect(data?.categories).toEqual(
|
||||
expect.arrayContaining(['fruit', 'vegetable']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors thrown by serverless functions', async () => {
|
||||
// Create the function
|
||||
const { data: createData } = await createOneServerlessFunction({
|
||||
input: {
|
||||
name: 'Error Test Function',
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const functionId = createData?.createOneServerlessFunction?.id;
|
||||
|
||||
expect(functionId).toBeDefined();
|
||||
createdFunctionIds.push(functionId);
|
||||
|
||||
// Update with error-throwing code
|
||||
await updateServerlessFunction({
|
||||
input: {
|
||||
id: functionId,
|
||||
update: {
|
||||
name: 'Error Test Function',
|
||||
code: ERROR_FUNCTION_CODE,
|
||||
},
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
// Publish the function
|
||||
await publishServerlessFunction({
|
||||
input: { id: functionId },
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
// Execute with shouldFail = false (should succeed)
|
||||
const { data: successData } = await executeServerlessFunction({
|
||||
input: {
|
||||
id: functionId,
|
||||
payload: { shouldFail: false },
|
||||
},
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(successData?.executeOneServerlessFunction?.status).toBe(
|
||||
ServerlessFunctionExecutionStatus.SUCCESS,
|
||||
);
|
||||
expect(successData?.executeOneServerlessFunction?.data).toMatchObject({
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Execute with shouldFail = true (should return error status)
|
||||
const { data: errorData } = await executeServerlessFunction({
|
||||
input: {
|
||||
id: functionId,
|
||||
payload: { shouldFail: true },
|
||||
},
|
||||
expectToFail: false, // The GraphQL call succeeds, but the function execution fails
|
||||
});
|
||||
|
||||
const errorResult = errorData?.executeOneServerlessFunction;
|
||||
|
||||
expect(errorResult?.status).toBe(ServerlessFunctionExecutionStatus.ERROR);
|
||||
expect(errorResult?.error).toMatchObject({
|
||||
errorType: 'UnhandledError',
|
||||
errorMessage: expect.stringContaining('Intentional test error'),
|
||||
});
|
||||
expect(errorResult?.data).toBeNull();
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export type DeleteServerlessFunctionFactoryInput = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const deleteServerlessFunctionQueryFactory = ({
|
||||
input,
|
||||
}: {
|
||||
input: DeleteServerlessFunctionFactoryInput;
|
||||
}) => ({
|
||||
query: gql`
|
||||
mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {
|
||||
deleteOneServerlessFunction(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
type DeleteServerlessFunctionFactoryInput,
|
||||
deleteServerlessFunctionQueryFactory,
|
||||
} from 'test/integration/metadata/suites/serverless-function/utils/delete-serverless-function-query-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
export const deleteServerlessFunction = async ({
|
||||
input,
|
||||
expectToFail = false,
|
||||
token,
|
||||
}: {
|
||||
input: DeleteServerlessFunctionFactoryInput;
|
||||
expectToFail?: boolean;
|
||||
token?: string;
|
||||
}): CommonResponseBody<{
|
||||
deleteOneServerlessFunction: { id: string };
|
||||
}> => {
|
||||
const graphqlOperation = deleteServerlessFunctionQueryFactory({
|
||||
input,
|
||||
});
|
||||
|
||||
const response = await makeMetadataAPIRequest(graphqlOperation, token);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage:
|
||||
'Serverless Function deletion should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Serverless Function deletion has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export type ExecuteServerlessFunctionFactoryInput = {
|
||||
id: string;
|
||||
payload: Record<string, unknown>;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_EXECUTION_RESULT_GQL_FIELDS = `
|
||||
data
|
||||
logs
|
||||
duration
|
||||
status
|
||||
error
|
||||
`;
|
||||
|
||||
export const executeServerlessFunctionQueryFactory = ({
|
||||
input,
|
||||
gqlFields = DEFAULT_EXECUTION_RESULT_GQL_FIELDS,
|
||||
}: {
|
||||
input: ExecuteServerlessFunctionFactoryInput;
|
||||
gqlFields?: string;
|
||||
}) => ({
|
||||
query: gql`
|
||||
mutation ExecuteOneServerlessFunction($input: ExecuteServerlessFunctionInput!) {
|
||||
executeOneServerlessFunction(input: $input) {
|
||||
${gqlFields}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
version: input.version ?? 'latest',
|
||||
},
|
||||
},
|
||||
});
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
type ExecuteServerlessFunctionFactoryInput,
|
||||
executeServerlessFunctionQueryFactory,
|
||||
} from 'test/integration/metadata/suites/serverless-function/utils/execute-serverless-function-query-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
import { type ServerlessFunctionExecutionResultDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
|
||||
|
||||
export const executeServerlessFunction = async ({
|
||||
input,
|
||||
gqlFields,
|
||||
expectToFail = false,
|
||||
token,
|
||||
}: {
|
||||
input: ExecuteServerlessFunctionFactoryInput;
|
||||
gqlFields?: string;
|
||||
expectToFail?: boolean;
|
||||
token?: string;
|
||||
}): CommonResponseBody<{
|
||||
executeOneServerlessFunction: ServerlessFunctionExecutionResultDTO;
|
||||
}> => {
|
||||
const graphqlOperation = executeServerlessFunctionQueryFactory({
|
||||
input,
|
||||
gqlFields,
|
||||
});
|
||||
|
||||
const response = await makeMetadataAPIRequest(graphqlOperation, token);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage:
|
||||
'Serverless Function execution should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Serverless Function execution has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export type PublishServerlessFunctionFactoryInput = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS = `
|
||||
id
|
||||
name
|
||||
latestVersion
|
||||
publishedVersions
|
||||
`;
|
||||
|
||||
export const publishServerlessFunctionQueryFactory = ({
|
||||
input,
|
||||
gqlFields = DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS,
|
||||
}: {
|
||||
input: PublishServerlessFunctionFactoryInput;
|
||||
gqlFields?: string;
|
||||
}) => ({
|
||||
query: gql`
|
||||
mutation PublishServerlessFunction($input: PublishServerlessFunctionInput!) {
|
||||
publishServerlessFunction(input: $input) {
|
||||
${gqlFields}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
type PublishServerlessFunctionFactoryInput,
|
||||
publishServerlessFunctionQueryFactory,
|
||||
} from 'test/integration/metadata/suites/serverless-function/utils/publish-serverless-function-query-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
import { type ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
|
||||
export const publishServerlessFunction = async ({
|
||||
input,
|
||||
gqlFields,
|
||||
expectToFail = false,
|
||||
token,
|
||||
}: {
|
||||
input: PublishServerlessFunctionFactoryInput;
|
||||
gqlFields?: string;
|
||||
expectToFail?: boolean;
|
||||
token?: string;
|
||||
}): CommonResponseBody<{
|
||||
publishServerlessFunction: ServerlessFunctionDTO;
|
||||
}> => {
|
||||
const graphqlOperation = publishServerlessFunctionQueryFactory({
|
||||
input,
|
||||
gqlFields,
|
||||
});
|
||||
|
||||
const response = await makeMetadataAPIRequest(graphqlOperation, token);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage:
|
||||
'Serverless Function publish should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Serverless Function publish has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { type Sources } from 'twenty-shared/types';
|
||||
|
||||
export type UpdateServerlessFunctionFactoryInput = {
|
||||
id: string;
|
||||
update: {
|
||||
name: string;
|
||||
description?: string;
|
||||
code: Sources;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS = `
|
||||
id
|
||||
name
|
||||
description
|
||||
latestVersion
|
||||
`;
|
||||
|
||||
export const updateServerlessFunctionQueryFactory = ({
|
||||
input,
|
||||
gqlFields = DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS,
|
||||
}: {
|
||||
input: UpdateServerlessFunctionFactoryInput;
|
||||
gqlFields?: string;
|
||||
}) => ({
|
||||
query: gql`
|
||||
mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {
|
||||
updateOneServerlessFunction(input: $input) {
|
||||
${gqlFields}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
type UpdateServerlessFunctionFactoryInput,
|
||||
updateServerlessFunctionQueryFactory,
|
||||
} from 'test/integration/metadata/suites/serverless-function/utils/update-serverless-function-query-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
import { type ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
|
||||
export const updateServerlessFunction = async ({
|
||||
input,
|
||||
gqlFields,
|
||||
expectToFail = false,
|
||||
token,
|
||||
}: {
|
||||
input: UpdateServerlessFunctionFactoryInput;
|
||||
gqlFields?: string;
|
||||
expectToFail?: boolean;
|
||||
token?: string;
|
||||
}): CommonResponseBody<{
|
||||
updateOneServerlessFunction: ServerlessFunctionDTO;
|
||||
}> => {
|
||||
const graphqlOperation = updateServerlessFunctionQueryFactory({
|
||||
input,
|
||||
gqlFields,
|
||||
});
|
||||
|
||||
const response = await makeMetadataAPIRequest(graphqlOperation, token);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Serverless Function update should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Serverless Function update has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
Reference in New Issue
Block a user