Files
shsf/Backend/src/lib/Runner.ts
T
Space-Banane 5d458e3d65 feat: block function execution while container is initializing
Adds isContainerReady() which checks the container logs (from the
current StartedAt timestamp) for '[SHSF] Container ready.' before
allowing docker exec to proceed. If the container is still running
init.sh, executeFunction returns a _shsf v2 503 response with the
message 'Function is not ready yet.' and skips lastRun / log
persistence for the blocked invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:44 +02:00

1193 lines
37 KiB
TypeScript

import { Function, FunctionFile } from "@prisma/client";
import Docker from "dockerode";
import { PassThrough } from "stream"; // Added PassThrough
import { prisma } from "..";
import { HttpRequestContext } from "rjweb-server";
import { DataContext } from "rjweb-server/lib/typings/types/internal";
import { UsableMiddleware } from "rjweb-server/lib/typings/classes/Middleware";
import * as fs from "fs/promises";
import * as fsSync from "fs";
import * as path from "path";
import { getLoggingConfigFromData, stripHeadersFromPayload } from "./FunctionLogging";
import { replaceApiBaseInContent } from "./FileHelpers";
import { createLogger } from "./logger";
import type { LoggedExecutionRateLimitData } from "./FunctionRateLimit";
const log = createLogger("Runner");
import {
getCacheDir,
getFunctionAppDir,
getFunctionBaseDir,
getFunctionExecutionDir,
getFunctionExecutionsDir,
} from "./StoragePaths";
import type {
TimingEntry,
FunctionExecutionMode,
PersistedFunctionExecutionLogInput,
} from "./RunnerTypes";
export type { FunctionExecutionMode, PersistedFunctionExecutionLogInput };
import {
ServeOnlyFileNotFoundHTML,
} from "./RunnerTypes";
import {
truncateDbField,
appendLogOutput,
getRuntimeType,
isHtmlStartupFile,
parseExecutionPayloadRoute,
resolveServeOnlyHtmlFileName,
findFileByNameIgnoreCase,
} from "./RunnerUtils";
import {
resolveDotnetProjectPath,
getDotnetProjectDirectory,
DotnetProjectResolutionError,
} from "./DotnetProjectResolver";
import {
DbComScriptPY,
DbComScriptGO,
DbComScriptCS,
ShsfRuntimeScriptCS,
} from "./RunnerScripts";
import {
getRunnerTransportPaths,
prepareRunnerTransport,
readRunnerResult,
revokeLegacyFunctionDbTokens,
startStorageRpcBridge,
} from "./RunnerTransport";
import {
generateGoRunnerWrapperCode,
generatePythonRunnerScript,
generateGoRunnerShScript,
generateDotnetRunnerScript,
generatePythonInitBody,
generateGoInitBody,
generateDotnetInitBody,
generateDotnetBuildInitScript,
} from "./RunnerRuntimeScripts";
import {
mergeEnvironmentVariables,
parseStoredEnvironmentVariables,
toDockerEnvironment,
} from "./EnvironmentVariables";
async function getRuntimeEnvironment(functionData: Pick<Function, "env" | "userId">) {
const accountData = await prisma.user.findUnique({
where: { id: functionData.userId },
select: { account_env: true },
});
return toDockerEnvironment(
mergeEnvironmentVariables(
parseStoredEnvironmentVariables(accountData?.account_env),
parseStoredEnvironmentVariables(functionData.env),
),
);
}
async function isContainerReady(container: Docker.Container, since: number): Promise<boolean> {
try {
const stream = await container.logs({
stdout: true,
stderr: false,
follow: false,
since,
}) as NodeJS.ReadableStream;
return new Promise<boolean>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => { data += chunk.toString("utf8"); });
stream.on("end", () => { resolve(data.includes("[SHSF] Container ready.")); });
stream.on("error", () => resolve(false));
});
} catch {
return false;
}
}
export async function persistFunctionExecutionLog(
input: PersistedFunctionExecutionLogInput,
) {
const loggingConfig = await getLoggingConfigFromData(input.functionData.logging);
if (!loggingConfig.enabled && !input.force) {
return;
}
let disableResult = false;
let disableResultReason = "";
let payloadForDb = input.payload ?? JSON.stringify(null);
const resultForDb =
typeof input.output === "string" && input.output !== ""
? input.output
: JSON.stringify(null);
const containsBase64TransportEnvelope =
/"__shsf_transport"\s*:\s*"base64-bytes-v1"/.test(resultForDb);
if (loggingConfig.enabled && loggingConfig.hide_payload_headers) {
payloadForDb = await stripHeadersFromPayload(payloadForDb);
}
if (isHtmlStartupFile(input.functionData.startup_file)) {
disableResult = true;
disableResultReason = "HTML content detected in startup file";
}
if (resultForDb.includes("<!DOCTYPE html>") || resultForDb.includes("<html")) {
disableResult = true;
disableResultReason = "HTML content detected in result";
}
if (containsBase64TransportEnvelope) {
disableResult = true;
disableResultReason = "Base64 transport envelope detected in result";
}
await prisma.triggerLog.create({
data: {
functionId: input.functionId,
logs: truncateDbField(input.logs),
result: JSON.stringify({
exit_code: input.exit_code,
tooks: input.tooks ?? [],
output: disableResult ? disableResultReason : truncateDbField(resultForDb),
payload: truncateDbField(payloadForDb),
...(input.ratelimit ? { ratelimit: input.ratelimit } : {}),
...(input.error_type ? { error_type: input.error_type } : {}),
}),
},
});
}
export async function executeFunction(
id: number,
functionData: Function,
files: FunctionFile[],
stream:
| { enabled: true; onChunk: (data: string) => void }
| { enabled: false },
payload: string,
options?: {
ratelimit?: LoggedExecutionRateLimitData;
mode?: FunctionExecutionMode;
},
) {
const starting_time = Date.now();
const tooks: TimingEntry[] = [];
let func_result: string = "";
let logs: string = "";
// mark() — records a user-facing timing phase into tooks
let _lastMark = starting_time;
const mark = (description: string) => {
const now = Date.now();
const value = (now - _lastMark) / 1000;
tooks.push({ timestamp: now, value, description });
_lastMark = now;
log.debug({ description, durationSeconds: value.toFixed(3) }, "Execution phase");
};
// trace() — internal trace, does NOT appear in tooks
const trace = (msg: string) => log.trace(msg);
// Serve Only HTML (serve-only)
if (isHtmlStartupFile(functionData.startup_file)) {
const requestedHtmlFileName = resolveServeOnlyHtmlFileName(
functionData.startup_file,
parseExecutionPayloadRoute(payload),
);
const requestedHtmlFile = requestedHtmlFileName
? findFileByNameIgnoreCase(files, requestedHtmlFileName)
: undefined;
const rawHtml = requestedHtmlFile?.content || ServeOnlyFileNotFoundHTML;
const replacedHtml =
typeof rawHtml === "string"
? (replaceApiBaseInContent(
rawHtml,
functionData.namespaceId,
functionData.executionId,
) as string)
: rawHtml;
const responseCode = requestedHtmlFile ? 200 : 404;
const servedFile = requestedHtmlFileName ?? "invalid-route";
return {
logs: `Serve Only HTML function executed (${servedFile}).`,
result: {
_shsf: "v2",
_headers: { "Content-Type": "text/html; charset=utf-8" },
_code: responseCode,
_res: replacedHtml,
},
tooks: [
{
description: `Serve Only HTML function executed (${servedFile}).`,
value: 0,
timestamp: starting_time,
},
] as TimingEntry[],
exit_code: 0,
};
}
const docker = new Docker();
const functionIdStr = String(functionData.id);
const containerName = `shsf_func_${functionIdStr}`;
const funcAppDir = getFunctionAppDir(functionIdStr);
const runtimeType = getRuntimeType(functionData.image);
const executionMode = options?.mode ?? "dev_execute";
let exitCode = 0;
// Generate a unique execution ID for this request to avoid race conditions
// Use crypto.randomUUID() for better uniqueness if available, otherwise fallback
const executionId =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
const executionDir = getFunctionExecutionDir(functionIdStr, executionId);
const transportPaths = getRunnerTransportPaths(executionDir);
// Define startupFile and initScript here as they are needed for script generation
const startupFile = functionData.startup_file;
let initScript =
"#!/bin/sh\nset -e\necho '[SHSF INIT] Starting environment setup...'\ncd /app\n";
let containerNotReady = false;
try {
let container = docker.getContainer(containerName);
await fs.mkdir(funcAppDir, { recursive: true });
await prepareRunnerTransport(transportPaths);
await revokeLegacyFunctionDbTokens();
// Skip writing User files when git version control is active (git_url is set)
if (!functionData.git_url) {
// Write files to disk
await Promise.all(
files.map(async (file) => {
const filePath = path.join(funcAppDir, file.name);
await fs.mkdir(path.dirname(filePath), { recursive: true });
let content: string | Buffer = file.content as any;
if (typeof content === "string") {
content = replaceApiBaseInContent(content, functionData.namespaceId, functionData.executionId);
}
await fs.writeFile(filePath, content as any);
})
);
mark(`Write user files (${files.length})`);
} else {
log.info({ functionId: functionData.id }, "[GIT] Git source active — skipping DB file writes");
mark(`Skip DB file writes (git_url set)`);
}
let dotnetProjectPath: string | null = null;
if (runtimeType === "dotnet") {
dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir);
log.info({ functionId: functionData.id, dotnetProjectPath }, "Resolved .NET project");
}
// For Go runtime, generate the runner wrapper file and go.mod if needed
if (runtimeType === "golang") {
await fs.writeFile(path.join(funcAppDir, "shsf_runner.go"), generateGoRunnerWrapperCode());
const goModPath = path.join(funcAppDir, "go.mod");
if (!fsSync.existsSync(goModPath)) {
await fs.writeFile(goModPath, `module shsf_function_${functionData.id}\n\ngo 1.23\n`);
}
trace("Go runner wrapper written");
}
// Always generate/update the runner script to accept payload file path as argument
if (runtimeType === "python") {
const wrapperPath = path.join(funcAppDir, "_runner.py");
await fs.writeFile(wrapperPath, generatePythonRunnerScript(startupFile ?? "main"));
await fs.chmod(wrapperPath, "755");
trace("Python runner script written");
} else if (runtimeType === "golang") {
const wrapperPath = path.join(funcAppDir, "_runner.sh");
await fs.writeFile(wrapperPath, generateGoRunnerShScript());
await fs.chmod(wrapperPath, "755");
trace("Go runner script written"); // intermediate — mark fires after init.sh below
} else if (runtimeType === "dotnet") {
const wrapperPath = path.join(funcAppDir, "_runner.sh");
await fs.writeFile(wrapperPath, generateDotnetRunnerScript(dotnetProjectPath));
await fs.chmod(wrapperPath, "755");
trace(".NET runner script written");
} else {
log.warn({ runtimeType, functionId: functionData.id }, "Runner script generation skipped: unsupported runtime type");
}
// Always generate/update the init.sh script
if (runtimeType === "python") {
initScript += generatePythonInitBody(functionData.id, {
ffmpeg_install: functionData.ffmpeg_install,
opencv_install: functionData.opencv_install,
});
} else if (runtimeType === "golang") {
initScript += generateGoInitBody(functionData.id, { ffmpeg_install: functionData.ffmpeg_install });
} else if (runtimeType === "dotnet") {
initScript += generateDotnetInitBody(functionData.id);
} else {
// This was already checked for runner script, but as a safeguard for init.sh:
log.warn({ runtimeType, functionId: functionData.id }, "init.sh script generation skipped: unsupported runtime type");
// Potentially throw an error if an unsupported runtime should halt execution.
// throw new Error(`Unsupported runtime type for init script generation: ${runtimeType}`);
}
initScript +=
"\necho '[SHSF INIT] Environment setup finished successfully.'\n";
await fs.writeFile(path.join(funcAppDir, "init.sh"), initScript);
await fs.chmod(path.join(funcAppDir, "init.sh"), "755");
mark("Generate scripts"); // runner script(s) + init.sh
if (runtimeType === "dotnet") {
const dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir);
const dotnetProjectDir = getDotnetProjectDirectory(
funcAppDir,
dotnetProjectPath,
);
await fs.writeFile(
path.join(dotnetProjectDir, "SHSF.Runtime.cs"),
ShsfRuntimeScriptCS,
);
}
const requiresDbCom =
runtimeType === "dotnet" ||
files.some((f) => f.content.includes("_db_com") || f.content.includes("dbcom"));
if (requiresDbCom) {
if (runtimeType === "python") {
await fs.writeFile(path.join(funcAppDir, "_db_com.py"), DbComScriptPY);
await fs.chmod(path.join(funcAppDir, "_db_com.py"), "755");
} else if (runtimeType === "golang") {
const dbComDir = path.join(funcAppDir, "dbcom");
await fs.mkdir(dbComDir, { recursive: true });
await fs.writeFile(path.join(dbComDir, "dbcom.go"), DbComScriptGO);
} else if (runtimeType === "dotnet") {
const dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir);
const dotnetProjectDir = getDotnetProjectDirectory(
funcAppDir,
dotnetProjectPath,
);
await fs.writeFile(path.join(dotnetProjectDir, "_db_com.cs"), DbComScriptCS);
}
mark("Storage helper script");
}
try {
const inspectInfo = await container.inspect();
if (!inspectInfo.State.Running) {
await container.start();
mark("Container start");
} else {
trace("Reusing running container");
}
if (runtimeType === "golang") {
const initExec = await container.exec({
Cmd: ["/bin/sh", "/app/init.sh"],
AttachStdout: true,
AttachStderr: true,
});
const initStream = await initExec.start({ hijack: true, stdin: false });
const initOutput = { stdout: "", stderr: "" };
const initStdout = new PassThrough();
const initStderr = new PassThrough();
initStdout.on("data", (chunk) => {
initOutput.stdout += chunk.toString("utf8");
});
initStderr.on("data", (chunk) => {
initOutput.stderr += chunk.toString("utf8");
});
docker.modem.demuxStream(initStream, initStdout, initStderr);
await new Promise<void>((resolve) => {
initStream.on("end", resolve);
});
log.debug({ functionId: functionData.id }, `Init output: ${initOutput.stderr}`);
mark("Go init/rebuild");
}
} catch (error: any) {
if (error.statusCode === 404) {
trace("Container not found — creating");
const pipCacheHost = getCacheDir("pip");
const goCacheHost = getCacheDir("go");
const dotnetCacheHost = getCacheDir("dotnet");
await Promise.all([
fs.mkdir(pipCacheHost, { recursive: true }),
fs.mkdir(goCacheHost, { recursive: true }),
fs.mkdir(dotnetCacheHost, { recursive: true }),
]);
// Mount /app and /executions separately instead of the old /function_data
const BINDS: string[] = [
`${getFunctionAppDir(functionIdStr)}:/app`,
`${getFunctionExecutionsDir(functionIdStr)}:/executions`,
];
if (functionData.docker_mount) {
BINDS.push("/var/run/docker.sock:/var/run/docker.sock"); // Mount Docker socket
}
if (runtimeType === "python") {
BINDS.push(`${pipCacheHost}:/pip-cache`); // Mount persistent pip cache
} else if (runtimeType === "golang") {
BINDS.push(`${goCacheHost}:/go-cache`); // Mount persistent go cache
} else if (runtimeType === "dotnet") {
BINDS.push(`${dotnetCacheHost}:/dotnet-cache`);
} else {
throw new Error(
`Unsupported runtime type for container BIND setup: ${runtimeType}`
);
}
try {
const imageExists = await docker.listImages({
filters: JSON.stringify({ reference: [functionData.image] }),
});
if (imageExists.length === 0) {
log.info({ functionId: functionData.id, image: functionData.image }, "Pulling image");
const pullStream = await docker.pull(functionData.image);
await new Promise((resolve, reject) => {
docker.modem.followProgress(pullStream, (err) =>
err ? reject(err) : resolve(null)
);
});
mark("Image pull");
}
} catch (imgError) {
log.error({ err: imgError }, "Error checking or pulling image");
throw imgError;
}
const runtimeEnv = await getRuntimeEnvironment(functionData);
container = await docker.createContainer({
Image: functionData.image,
name: containerName,
Env: runtimeEnv,
HostConfig: {
Binds: BINDS,
AutoRemove: false,
Memory: (functionData.max_ram || 128) * 1024 * 1024,
...((functionData as any).network_restricted && {
NetworkMode: "none",
}),
},
Cmd: [
"/bin/sh",
"-c",
"/app/init.sh && echo '[SHSF] Container ready.' && tail -f /dev/null",
],
Tty: false,
});
await container.start();
mark("Container start + init");
} else {
// Some other error inspecting container
throw error;
}
}
// Block execution if the container has not yet finished initializing (init.sh still running)
const containerInspect = await container.inspect();
const containerStartedAt = new Date(containerInspect.State.StartedAt).getTime() / 1000;
if (!await isContainerReady(container, containerStartedAt)) {
log.warn({ functionId: id }, "Container not ready — blocking execution");
containerNotReady = true;
return {
logs: "Function is not ready yet.",
result: { _shsf: "v2" as const, _code: 503, _res: "Function is not ready yet." },
tooks: [{ description: "Container not ready", value: (Date.now() - starting_time) / 1000, timestamp: Date.now() }],
exit_code: -10,
};
}
// At this point, container is running and ready (init completed)
// Now, execute the function logic using docker exec
await fs.writeFile(transportPaths.payloadPath, payload);
const dotnetPayloadDir = path.join(funcAppDir, ".shsf-executions");
const dotnetPayloadPath = path.join(dotnetPayloadDir, `${executionId}.json`);
if (runtimeType === "dotnet") {
await fs.mkdir(dotnetPayloadDir, { recursive: true });
await fs.writeFile(dotnetPayloadPath, payload);
}
const execEnv = await getRuntimeEnvironment(functionData);
// Pass the unique payload file path as an argument to the runner script
const containerPayloadPath = `/executions/${executionId}/payload.json`;
const containerResultPath = `/executions/${executionId}/result.json`;
let execCmd: string[];
if (runtimeType === "python") {
execCmd = ["/bin/sh", "/app/_runner.py", containerPayloadPath, containerResultPath];
} else if (runtimeType === "golang") {
execCmd = ["/bin/sh", "/app/_runner.sh", containerPayloadPath, containerResultPath];
} else if (runtimeType === "dotnet") {
execCmd = [
"/bin/sh",
"/app/_runner.sh",
`/app/.shsf-executions/${executionId}.json`,
`/app/.shsf-executions/${executionId}.result.json`,
executionMode,
];
} else {
throw new Error(
`Unsupported runtime type for exec command: ${runtimeType}`
);
}
const exec = await container.exec({
Cmd: execCmd,
Env: [
...execEnv,
`SHSF_PAYLOAD_PATH=${containerPayloadPath}`,
`SHSF_RESULT_PATH=${containerResultPath}`,
`SHSF_TRANSPORT_DIR=/executions/${executionId}`,
`SHSF_STORAGE_REQUEST_DIR=/executions/${executionId}/storage-requests`,
`SHSF_STORAGE_RESPONSE_DIR=/executions/${executionId}/storage-responses`,
],
AttachStdout: true,
AttachStderr: true,
Tty: false,
});
const execStream = await exec.start({ hijack: true, stdin: false });
mark("Exec started");
const storageBridge = startStorageRpcBridge({
userId: functionData.userId,
requestDir: transportPaths.storageRequestDir,
responseDir: transportPaths.storageResponseDir,
});
const execOutput = { stdout: "", stderr: "" };
const MAX_OUTPUT_SIZE = 3 * 1024 * 1024; // 3MB limit to stay under Docker's 4MB limit
let stdoutTruncated = false;
let stderrTruncated = false;
const stdoutMultiplex = new PassThrough();
const stderrMultiplex = new PassThrough();
stdoutMultiplex.on("data", (chunk) => {
const text = chunk.toString("utf8");
if (execOutput.stdout.length + text.length <= MAX_OUTPUT_SIZE) {
execOutput.stdout += text;
} else if (!stdoutTruncated) {
const remaining = MAX_OUTPUT_SIZE - execOutput.stdout.length;
if (remaining > 0) {
execOutput.stdout += text.substring(0, remaining);
}
execOutput.stdout +=
"\n[SHSF TRUNCATED] Output exceeded 3MB limit and was truncated";
stdoutTruncated = true;
}
if (stream.enabled && !stdoutTruncated) {
// eslint-disable-next-line no-control-regex
const ansiRegex = /\x1B\[[0-9;]*[A-Za-z]/g;
const nonPrintableRegex = /[^\x20-\x7E\n\r\t]/g;
const cleanText = text
.replace(ansiRegex, "")
.replace(nonPrintableRegex, "");
stream.onChunk(cleanText);
}
});
stderrMultiplex.on("data", (chunk) => {
const text = chunk.toString("utf8");
if (execOutput.stderr.length + text.length <= MAX_OUTPUT_SIZE) {
execOutput.stderr += text;
} else if (!stderrTruncated) {
const remaining = MAX_OUTPUT_SIZE - execOutput.stderr.length;
if (remaining > 0) {
execOutput.stderr += text.substring(0, remaining);
}
execOutput.stderr +=
"\n[SHSF TRUNCATED] Logs exceeded 3MB limit and were truncated";
stderrTruncated = true;
}
if (stream.enabled && !stderrTruncated) {
// eslint-disable-next-line no-control-regex
const ansiRegex = /\x1B\[[0-9;]*[A-Za-z]/g;
const nonPrintableRegex = /[^\x20-\x7E\n\r\t]/g;
const cleanText = text
.replace(ansiRegex, "")
.replace(nonPrintableRegex, "");
stream.onChunk(cleanText);
}
});
docker.modem.demuxStream(execStream, stdoutMultiplex, stderrMultiplex);
const execTimeoutMs = (functionData.timeout || 15) * 1000; // functionData.timeout is in seconds
const execPromise = new Promise<Docker.ExecInspectInfo>((resolve, reject) => {
execStream.on("end", () => {
exec.inspect().then(resolve).catch(reject);
});
execStream.on("error", reject);
});
const timeoutPromise = new Promise<Docker.ExecInspectInfo>((_, reject) =>
setTimeout(
() =>
reject(new Error(`Execution timed out after ${execTimeoutMs / 1000}s`)),
execTimeoutMs
)
);
let execResultDetails: Docker.ExecInspectInfo;
try {
execResultDetails = await Promise.race([execPromise, timeoutPromise]);
exitCode = execResultDetails.ExitCode ?? 1; // Default to 1 if null/undefined
logs = [execOutput.stderr, execOutput.stdout].filter(Boolean).join("\n");
if (exitCode === 0 && execOutput.stdout) {
func_result = "";
} else if (exitCode !== 0) {
// Combine outputs but respect size limits
const combinedOutput = `Exit Code: ${exitCode}\n${execOutput.stderr}\n${execOutput.stdout}`;
logs =
combinedOutput.length > MAX_OUTPUT_SIZE
? combinedOutput.substring(0, MAX_OUTPUT_SIZE) +
"\n[SHSF TRUNCATED] Combined output exceeded 3MB limit"
: combinedOutput;
log.error({ exitCode }, "Exec failed, logs truncated due to size");
}
} catch (execError: any) {
log.error({ err: execError.message }, "Exec failed or timed out");
logs = `${execOutput.stderr}\nExecution Error: ${execError.message}`;
exitCode = -1;
func_result = "";
} finally {
await storageBridge.stop();
}
mark("Exec finished");
// Process result if successful
let parsedResult: any = null;
if (exitCode === 0) {
const resultPath =
runtimeType === "dotnet"
? path.join(funcAppDir, ".shsf-executions", `${executionId}.result.json`)
: transportPaths.resultPath;
const resultState = await readRunnerResult(resultPath);
if (resultState.status === "ok") {
func_result = resultState.raw;
parsedResult = resultState.result;
} else if (resultState.status === "malformed") {
log.error({ err: resultState.error.message }, "Failed to parse JSON result file");
logs += `\nError parsing result JSON file: ${resultState.error.message}`;
func_result = resultState.raw;
exitCode = -2;
} else {
func_result = JSON.stringify(null);
}
}
tooks.push({
timestamp: Date.now(),
value: (Date.now() - starting_time) / 1000,
description: "Total",
});
return {
logs,
result: parsedResult, // Return parsed object or null
tooks,
exit_code: exitCode,
};
} catch (error: any) {
log.error({ err: error, functionId: id }, "Critical error in executeFunction");
tooks.push({
timestamp: Date.now(),
value: (Date.now() - starting_time) / 1000,
description: "Total execution time until error",
});
return {
logs: `${logs}\nCritical Error: ${error.message}\n${error.stack}`,
result: "Sorry, an error occurred during execution.",
tooks,
exit_code: error.statusCode || -3, // Custom code for unhandled errors
};
} finally {
try {
await fs.rm(executionDir, { recursive: true, force: true });
if (runtimeType === "dotnet") {
await Promise.all([
fs.rm(path.join(funcAppDir, ".shsf-executions", `${executionId}.json`), {
force: true,
}),
fs.rm(path.join(funcAppDir, ".shsf-executions", `${executionId}.result.json`), {
force: true,
}),
]);
}
mark("Cleanup");
} catch (cleanupError: any) {
if (cleanupError.code !== "ENOENT") {
log.error({ err: cleanupError.message, code: cleanupError.code, executionDir }, "Cleanup failed");
}
}
log.info({
functionId: functionData.id,
functionName: functionData.name,
exitCode,
totalSeconds: ((Date.now() - starting_time) / 1000).toFixed(3),
}, "Function execution complete");
if (!containerNotReady) {
try {
await prisma.function.update({
where: { id },
data: { lastRun: new Date() },
});
} catch (dbError) {
log.error({ err: dbError }, "Error updating function lastRun");
}
try {
await persistFunctionExecutionLog({
functionId: id,
functionData,
logs,
output:
typeof func_result === "string" && func_result !== ""
? func_result
: JSON.stringify(null),
payload,
exit_code: exitCode,
tooks,
...(options?.ratelimit ? { ratelimit: options.ratelimit } : {}),
});
} catch (error) {
log.error({ err: error }, "Error creating trigger log");
}
}
}
}
/* eslint-disable @typescript-eslint/no-empty-object-type */
export async function buildPayloadFromGET(
ctr: DataContext<
"HttpRequest",
"GET",
HttpRequestContext<{}>,
UsableMiddleware<{}>[]
>
): Promise<{
headers: Record<string, string>;
queries: Record<string, string>;
source_ip: string;
route: string | "default";
method: string;
}> {
return {
headers: Object.fromEntries(ctr.headers.entries()),
queries: Object.fromEntries(ctr.queries.entries()),
source_ip: ctr.client.ip.usual(),
route: ctr.params.get("route") || "default",
method: "GET",
};
}
export async function buildPayloadFromPOST(
ctr: DataContext<
"HttpRequest",
"POST",
HttpRequestContext<{}>,
UsableMiddleware<{}>[]
>
): Promise<{
headers: Record<string, string>;
body: string;
queries: Record<string, string>;
source_ip: string;
route: string | "default";
raw_body: string;
method: string;
}> {
return {
headers: Object.fromEntries(ctr.headers.entries()),
queries: Object.fromEntries(ctr.queries.entries()),
body: await ctr.rawBody("utf-8"),
raw_body: await ctr.rawBody("binary"),
source_ip: ctr.client.ip.usual(),
route: ctr.params.get("route") || "default",
method: "POST",
};
}
/* eslint-enable @typescript-eslint/no-empty-object-type */
export async function installDependencies(
functionId: number,
functionData: any,
_files: any[]
): Promise<boolean | 404> {
const docker = new Docker();
const functionIdStr = String(functionId);
const containerName = `shsf_func_${functionIdStr}`;
try {
const container = docker.getContainer(containerName);
try {
const inspectInfo = await container.inspect();
if (!inspectInfo.State.Running) {
await container.start();
}
} catch (error: any) {
if (error.statusCode === 404) {
return 404; // We cant run it, as we dont know what it does.
} else {
throw error;
}
}
const execEnv = await getRuntimeEnvironment(functionData);
log.info({ functionId }, "Starting dependency installation");
const exec = await container.exec({
Cmd: [
"/bin/sh",
"-c",
"cd /app && if [ -f requirements.txt ]; then pip install --user -r requirements.txt; else echo 'No requirements.txt found.'; fi",
],
Env: execEnv,
AttachStdout: true,
AttachStderr: true,
Tty: false,
});
log.debug({ functionId }, "Exec command created for dependency installation");
const execStream = await exec.start({ hijack: true, stdin: false });
log.debug({ functionId }, "Exec stream started for dependency installation");
// Consume the stream to completion (required for exec to finish)
await new Promise<void>((resolve, reject) => {
execStream.on("end", () => {
resolve();
});
execStream.on("error", (error) => {
reject(error);
});
// Drain the stream
execStream.resume();
});
// Inspect the exec to get the exit code
const inspect = await exec.inspect();
log.debug({ functionId, exitCode: inspect.ExitCode }, "Exec inspection completed");
if (inspect.ExitCode === 0) {
log.info({ functionId }, "Dependency installation completed successfully");
return true;
} else {
log.error({ functionId, exitCode: inspect.ExitCode }, "Dependency installation failed");
return false;
}
} catch (error) {
log.error({ err: error }, "Error installing dependencies");
return false;
}
}
export async function buildDotnetFunction(
functionId: number,
functionData: Function,
files: FunctionFile[],
): Promise<
| { status: "success" }
| { status: "container_missing" }
| { status: "build_failed"; message: string; buildLogs?: string }
> {
if (getRuntimeType(functionData.image) !== "dotnet") {
return {
status: "build_failed",
message: ".NET build is only available for .NET SDK functions.",
};
}
const docker = new Docker();
const functionIdStr = String(functionId);
const containerName = `shsf_func_${functionIdStr}`;
const funcAppDir = getFunctionAppDir(functionIdStr);
const executionDir = getFunctionExecutionsDir(functionIdStr);
const dotnetCacheHost = getCacheDir("dotnet");
try {
await fs.mkdir(funcAppDir, { recursive: true });
await fs.mkdir(executionDir, { recursive: true });
await fs.mkdir(dotnetCacheHost, { recursive: true });
if (!functionData.git_url) {
await Promise.all(
files.map(async (file) => {
const filePath = path.join(funcAppDir, file.name);
await fs.mkdir(path.dirname(filePath), { recursive: true });
let content: string | Buffer = file.content as any;
if (typeof content === "string") {
content = replaceApiBaseInContent(
content,
functionData.namespaceId,
functionData.executionId,
);
}
await fs.writeFile(filePath, content as any);
}),
);
}
const dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir);
const dotnetProjectDir = getDotnetProjectDirectory(
funcAppDir,
dotnetProjectPath,
);
await fs.writeFile(path.join(funcAppDir, "init.sh"), generateDotnetBuildInitScript(functionId));
await fs.chmod(path.join(funcAppDir, "init.sh"), "755");
await fs.writeFile(
path.join(dotnetProjectDir, "SHSF.Runtime.cs"),
ShsfRuntimeScriptCS,
);
await fs.writeFile(path.join(dotnetProjectDir, "_db_com.cs"), DbComScriptCS);
let container = docker.getContainer(containerName);
try {
const inspectInfo = await container.inspect();
if (!inspectInfo.State.Running) {
await container.start();
}
} catch (error: any) {
if (error.statusCode !== 404) {
throw error;
}
const imageExists = await docker.listImages({
filters: JSON.stringify({ reference: [functionData.image] }),
});
if (imageExists.length === 0) {
const pullStream = await docker.pull(functionData.image);
await new Promise((resolve, reject) => {
docker.modem.followProgress(pullStream, (pullError) =>
pullError ? reject(pullError) : resolve(null),
);
});
}
container = await docker.createContainer({
Image: functionData.image,
name: containerName,
Env: await getRuntimeEnvironment(functionData),
HostConfig: {
Binds: [
`${funcAppDir}:/app`,
`${executionDir}:/executions`,
`${dotnetCacheHost}:/dotnet-cache`,
...(functionData.docker_mount
? ["/var/run/docker.sock:/var/run/docker.sock"]
: []),
],
AutoRemove: false,
Memory: (functionData.max_ram || 128) * 1024 * 1024,
...((functionData as any).network_restricted && {
NetworkMode: "none",
}),
},
Cmd: [
"/bin/sh",
"-c",
"/app/init.sh && echo '[SHSF] Container ready.' && tail -f /dev/null",
],
Tty: false,
});
await container.start();
}
const exec = await container.exec({
Cmd: [
"/bin/sh",
"-c",
`cd /app && . /app/.shsf_env && rm -f /app/.shsf_dotnet_entry && dotnet build "${dotnetProjectPath}" && PROJECT_DIR="$(dirname "${dotnetProjectPath}")" && ASSEMBLY_NAME="$(basename "${dotnetProjectPath}" .csproj)" && TARGET_PATH="$(find "/app/$PROJECT_DIR/bin" -type f -name "$ASSEMBLY_NAME.dll" ! -path "*/ref/*" ! -path "*/obj/*" | sort | tail -n 1)" && if [ -z "$TARGET_PATH" ] || [ ! -f "$TARGET_PATH" ]; then echo "Failed to resolve built .NET assembly for ${dotnetProjectPath}" >&2; exit 1; fi && printf '%s' "$TARGET_PATH" > /app/.shsf_dotnet_entry`,
],
Env: await getRuntimeEnvironment(functionData),
AttachStdout: true,
AttachStderr: true,
Tty: false,
});
const execStream = await exec.start({ hijack: true, stdin: false });
const buildOutput = { stdout: "", stderr: "" };
const stdoutMultiplex = new PassThrough();
const stderrMultiplex = new PassThrough();
stdoutMultiplex.on("data", (chunk) => {
buildOutput.stdout += chunk.toString("utf8");
});
stderrMultiplex.on("data", (chunk) => {
buildOutput.stderr += chunk.toString("utf8");
});
docker.modem.demuxStream(execStream, stdoutMultiplex, stderrMultiplex);
await new Promise<void>((resolve, reject) => {
execStream.on("end", resolve);
execStream.on("error", reject);
});
const inspect = await exec.inspect();
const buildLogs = [buildOutput.stderr.trim(), buildOutput.stdout.trim()]
.filter(Boolean)
.join("\n");
if (inspect.ExitCode !== 0) {
log.error({ functionId, buildLogs }, "dotnet build failed");
const persistedBuildLogs = appendLogOutput(
"[SHSF] .NET build failed.",
buildLogs || "dotnet build failed without output",
);
try {
await persistFunctionExecutionLog({
functionId,
functionData,
logs: persistedBuildLogs,
output: JSON.stringify(null),
exit_code: inspect.ExitCode ?? 1,
error_type: "dotnet_build",
force: true,
});
} catch (persistError) {
log.error({ err: persistError }, "Error persisting .NET build failure logs");
}
return {
status: "build_failed",
message: "dotnet build failed.",
buildLogs,
};
}
return { status: "success" };
} catch (error) {
log.error({ err: error, functionId }, "Error building .NET function");
if (error instanceof DotnetProjectResolutionError) {
return {
status: "build_failed",
message: error.message,
};
}
return {
status: "build_failed",
message: "Failed to build .NET function",
};
}
}
// Helper function to clean up container when deleting a function
export async function deleteContainerForFunction(functionId: number) {
const functionIdStr = String(functionId);
const containerName = `shsf_func_${functionIdStr}`;
try {
const docker = new Docker();
// Try to stop and remove the container if it exists
try {
const container = docker.getContainer(containerName);
const containerInfo = await container.inspect();
if (containerInfo.State.Running) {
log.info({ functionId }, "Stopping container");
await container.kill({ t: 10 }); // 10-second timeout
}
log.info({ functionId }, "Removing container");
await container.remove();
} catch (containerError: any) {
if (containerError.statusCode !== 404) {
log.error({ err: containerError, functionId }, "Error removing container");
} else {
log.debug({ functionId }, "Container not found, skipping removal");
}
}
return true;
} catch (error) {
log.error({ err: error, functionId }, "Error during container deletion");
return false;
}
}
export async function cleanupFunctionContainer(functionId: number) {
const functionIdStr = String(functionId);
const containerName = `shsf_func_${functionIdStr}`;
const funcAppDir = getFunctionBaseDir(functionIdStr);
try {
const docker = new Docker();
// Try to stop and remove the container if it exists
try {
const container = docker.getContainer(containerName);
const containerInfo = await container.inspect();
if (containerInfo.State.Running) {
log.info({ functionId }, "Stopping container for cleanup");
await container.kill({ t: 10 }); // 10-second timeout
}
log.info({ functionId }, "Removing container for cleanup");
await container.remove();
} catch (containerError: any) {
if (containerError.statusCode !== 404) {
log.error({ err: containerError, functionId }, "Error removing container");
} else {
log.debug({ functionId }, "Container not found, skipping removal");
}
}
// Remove the function directory
try {
log.info({ funcAppDir }, "Removing function directory");
await fs.rm(funcAppDir, { recursive: true, force: true });
} catch (dirError) {
log.error({ err: dirError, funcAppDir }, "Error removing function directory");
}
// Clean up cache directories
try {
// Python venv
const pipCacheDir = getCacheDir("pip", "venv", `function-${functionId}`);
if (fsSync.existsSync(pipCacheDir)) {
await fs.rm(pipCacheDir, { recursive: true, force: true });
}
// Pip hash
const pipHashDir = getCacheDir("pip", "hashes", `function-${functionId}`);
if (fsSync.existsSync(pipHashDir)) {
await fs.rm(pipHashDir, { recursive: true, force: true });
}
} catch (cacheError) {
log.error({ err: cacheError, functionId }, "Error cleaning up cache directories");
}
return true;
} catch (error) {
log.error({ err: error, functionId }, "Error during container cleanup");
return false;
}
}