1635 extensibilitytwenty cli app vars (#15143)

- Update twenty-cli to support application env variable definition
- Update twenty-server to create a new `core.applicationVariable` entity
to store env variables and provide env var when executing serverless
function
- Update twenty-front to support application environment variable value
setting

<img width="1044" height="660" alt="image"
src="https://github.com/user-attachments/assets/24c3d323-5370-4a80-8174-fc4653cc3c22"
/>

<img width="1178" height="662" alt="image"
src="https://github.com/user-attachments/assets/c124f423-8ed8-4246-ae5b-a9bd6672c7dc"
/>

<img width="1163" height="823" alt="image"
src="https://github.com/user-attachments/assets/fb7425a3-facc-4895-a5eb-8a8e278e0951"
/>

<img width="1087" height="696" alt="image"
src="https://github.com/user-attachments/assets/113da8a2-5590-433c-b1b3-5ed3137f24ca"
/>

<img width="1512" height="715" alt="image"
src="https://github.com/user-attachments/assets/1d2110b7-301d-4f21-a45c-ddd54d6e3391"
/>

<img width="1287" height="581" alt="image"
src="https://github.com/user-attachments/assets/353b16c6-0527-444c-87d6-51447a96cbc7"
/>
This commit is contained in:
martmull
2025-10-17 10:54:38 +02:00
committed by GitHub
parent 54baa47fbb
commit d2e7f2a910
58 changed files with 1305 additions and 359 deletions
@@ -6,17 +6,20 @@ export const handler = async (event) => {
const mainPath = `/tmp/${randomId}.mjs`;
const oldProcessEnv = { ...process.env };
try {
const { code, params } = event;
const { code, params, env } = event;
await fs.writeFile(mainPath, code, 'utf8');
process.env = {};
process.env = { ...process.env, ...(env ?? {}) };
const mainFile = await import(mainPath);
return await mainFile.main(params);
} finally {
await fs.rm(mainPath, { force: true });
process.env = oldProcessEnv;
}
};
@@ -46,6 +46,7 @@ import {
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60;
const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h
@@ -338,6 +339,7 @@ export class LambdaDriver implements ServerlessDriver {
const executorPayload = {
params: payload,
code: compiledCode,
env: buildEnvVar(serverlessFunction),
};
const params: InvokeCommandInput = {
@@ -1,5 +1,6 @@
import { promises as fs } from 'fs';
import { join } from 'path';
import { spawn } from 'node:child_process';
import {
type ServerlessDriver,
@@ -16,6 +17,7 @@ import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serve
import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/serverless/drivers/utils/lambda-build-directory-manager';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
export interface LocalDriverOptions {
fileStorageService: FileStorageService;
@@ -162,30 +164,53 @@ export class LocalDriver implements ServerlessDriver {
});
try {
const mainFile = await import(builtBundleFilePath);
const result = await this.executeWithTimeout<object | null>(
() => mainFile.main(payload),
serverlessFunction.timeoutSeconds * 1_000,
const runnerPath = await this.writeBootstrapRunner(
sourceTemporaryDir,
builtBundleFilePath,
);
const { ok, result, error, stack, stdout, stderr } =
await this.runChildWithEnv({
runnerPath,
env: buildEnvVar(serverlessFunction),
payload,
timeoutMs: serverlessFunction.timeoutSeconds * 1_000,
});
if (stdout)
logs +=
stdout
.split('\n')
.filter(Boolean)
.map((l) => `${new Date().toISOString()} INFO ${l}`)
.join('\n') + '\n';
if (stderr)
logs +=
stderr
.split('\n')
.filter(Boolean)
.map((l) => `${new Date().toISOString()} ERROR ${l}`)
.join('\n') + '\n';
const duration = Date.now() - startTime;
return {
data: result,
logs,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};
} catch (error) {
if (ok) {
return {
data: (result ?? null) as object | null,
logs,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};
}
return {
data: null,
logs,
duration: Date.now() - startTime,
duration,
error: {
errorType: 'UnhandledError',
errorMessage: error.message || 'Unknown error',
stackTrace: error.stack ? error.stack.split('\n') : [],
errorMessage: error || 'Unknown error',
stackTrace: stack ? String(stack).split('\n') : [],
},
status: ServerlessFunctionExecutionStatus.ERROR,
};
@@ -196,4 +221,143 @@ export class LocalDriver implements ServerlessDriver {
await lambdaBuildDirectoryManager.clean();
}
}
async writeBootstrapRunner(dir: string, builtFileAbsPath: string) {
const runnerPath = join(dir, '__runner.cjs');
const code = `
// Auto-generated. Do not edit.
const { pathToFileURL } = require('node:url');
(async () => {
try {
const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)});
const mod = await import(builtUrl.href);
if (typeof mod.main !== 'function') {
throw new Error('Export "main" not found in serverless bundle');
}
let payload = undefined;
if (process.send) {
process.on('message', async (msg) => {
if (!msg || msg.type !== 'run') return;
try {
const out = await mod.main(msg.payload);
process.send && process.send({ ok: true, result: out });
process.exit(0);
} catch (err) {
process.send && process.send({ ok: false, error: String(err), stack: err?.stack });
process.exit(1);
}
});
} else {
// Fallback: read payload from argv[2] (JSON) and print to stdout
const json = process.argv[2];
payload = json ? JSON.parse(json) : undefined;
const out = await mod.main(payload);
console.log(JSON.stringify({ ok: true, result: out }));
process.exit(0);
}
} catch (err) {
const msg = String(err);
if (process.send) {
process.send({ ok: false, error: msg, stack: err?.stack });
} else {
console.error(msg);
}
process.exit(1);
}
})();
`;
await fs.writeFile(runnerPath, code, 'utf8');
return runnerPath;
}
runChildWithEnv(options: {
runnerPath: string;
env: Record<string, string>;
payload: unknown;
timeoutMs: number;
}) {
const { runnerPath, env, payload, timeoutMs } = options;
return new Promise<{
ok: boolean;
result?: unknown;
error?: string;
stack?: string;
stdout: string;
stderr: string;
}>((resolve, _) => {
const child = spawn(process.execPath, [runnerPath], {
env: { ...process.env, ...env },
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let stdout = '';
let stderr = '';
let settled = false;
child.stdout?.on('data', (d) => (stdout += String(d)));
child.stderr?.on('data', (d) => (stderr += String(d)));
child.on(
'message',
(
msg:
| {
ok: true;
result?: unknown;
stdout?: string;
stderr?: string;
}
| {
ok: false;
error: string;
stack?: string;
stdout?: string;
stderr?: string;
},
) => {
if (settled) return;
settled = true;
resolve({ ...msg, stdout, stderr });
},
);
child.on('exit', (code) => {
if (settled) return;
settled = true;
if (code === 0) {
// Fallback path if no IPC (shouldnt happen with our stdio)
resolve({ ok: true, stdout, stderr });
} else {
resolve({
ok: false,
error: `Exited with code ${code}`,
stdout,
stderr,
});
}
});
const t = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve({
ok: false,
error: `Timed out after ${timeoutMs}ms`,
stdout,
stderr,
});
}, timeoutMs);
// Kick it off
child.send?.({ type: 'run', payload });
child.on('close', () => clearTimeout(t));
});
}
}
@@ -0,0 +1,12 @@
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
export const buildEnvVar = (serverlessFunction: ServerlessFunctionEntity) => {
return (serverlessFunction.application?.applicationVariables ?? []).reduce(
(acc, v) => {
acc[v.key] = String(v.value ?? '');
return acc;
},
{} as Record<string, string>,
);
};