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:
+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