feat(sandbox): add sandbox schema and migration

This commit is contained in:
Mauricio Siu 2026-09-10 00:49:49 -06:00
parent 88118cdb3f
commit c8db68b637
9 changed files with 9723 additions and 0 deletions

View File

@ -0,0 +1,28 @@
CREATE TYPE "public"."sandboxNetworkMode" AS ENUM('isolated', 'internet');--> statement-breakpoint
CREATE TYPE "public"."sandboxStatus" AS ENUM('creating', 'running', 'killed', 'error');--> statement-breakpoint
CREATE TABLE "sandbox" (
"sandboxId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"environmentId" text NOT NULL,
"serverId" text,
"image" text NOT NULL,
"template" text,
"containerId" text,
"status" "sandboxStatus" DEFAULT 'creating' NOT NULL,
"cpu" real DEFAULT 1 NOT NULL,
"memoryMb" integer DEFAULT 512 NOT NULL,
"pidsLimit" integer DEFAULT 256 NOT NULL,
"timeoutMs" integer DEFAULT 300000 NOT NULL,
"expiresAt" timestamp with time zone,
"lastActivityAt" timestamp with time zone,
"networkMode" "sandboxNetworkMode" DEFAULT 'isolated' NOT NULL,
"envVars" text,
"workdir" text DEFAULT '/home/user' NOT NULL,
"user" text,
"runtime" text,
"createdAt" text NOT NULL,
"killedAt" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1789022852219,
"tag": "0197_demonic_the_initiative",
"breakpoints": true
}
]
}

View File

@ -85,6 +85,7 @@
"shell-quote": "^1.8.4",
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"tar-stream": "3.2.1",
"toml": "3.0.0",
"ws": "8.16.0",
"yaml": "2.8.1",
@ -105,6 +106,7 @@
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/tar-stream": "3.1.4",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",

View File

@ -11,6 +11,7 @@ import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { projects } from "./project";
import { redis } from "./redis";
import { sandboxes } from "./sandbox";
import { encryptedText } from "./utils";
export const environments = pgTable("environment", {
@ -45,6 +46,7 @@ export const environmentRelations = relations(
mysql: many(mysql),
postgres: many(postgres),
redis: many(redis),
sandboxes: many(sandboxes),
}),
);

View File

@ -32,6 +32,7 @@ export * from "./redirects";
export * from "./redis";
export * from "./registry";
export * from "./rollbacks";
export * from "./sandbox";
export * from "./schedule";
export * from "./scim";
export * from "./security";

View File

@ -0,0 +1,170 @@
import { relations } from "drizzle-orm";
import {
integer,
pgEnum,
pgTable,
real,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { environments } from "./environment";
import { server } from "./server";
import { encryptedText } from "./utils";
export const sandboxStatus = pgEnum("sandboxStatus", [
"creating",
"running",
"killed",
"error",
]);
export const sandboxNetworkMode = pgEnum("sandboxNetworkMode", [
"isolated",
"internet",
]);
export const SANDBOX_TEMPLATE_NAMES = ["base", "python", "node"] as const;
export type SandboxTemplateName = (typeof SANDBOX_TEMPLATE_NAMES)[number];
export const SANDBOX_DEFAULTS = {
cpu: 1,
memoryMb: 512,
pidsLimit: 256,
timeoutMs: 300_000,
workdir: "/home/user",
execTimeoutMs: 60_000,
} as const;
export const sandboxes = pgTable("sandbox", {
sandboxId: text("sandboxId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
environmentId: text("environmentId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
image: text("image").notNull(),
template: text("template"),
containerId: text("containerId"),
status: sandboxStatus("status").notNull().default("creating"),
cpu: real("cpu").notNull().default(SANDBOX_DEFAULTS.cpu),
memoryMb: integer("memoryMb").notNull().default(SANDBOX_DEFAULTS.memoryMb),
pidsLimit: integer("pidsLimit")
.notNull()
.default(SANDBOX_DEFAULTS.pidsLimit),
timeoutMs: integer("timeoutMs")
.notNull()
.default(SANDBOX_DEFAULTS.timeoutMs),
expiresAt: timestamp("expiresAt", { withTimezone: true }),
lastActivityAt: timestamp("lastActivityAt", { withTimezone: true }),
networkMode: sandboxNetworkMode("networkMode").notNull().default("isolated"),
envVars: encryptedText("envVars"),
workdir: text("workdir").notNull().default(SANDBOX_DEFAULTS.workdir),
user: text("user"),
runtime: text("runtime"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
killedAt: timestamp("killedAt", { withTimezone: true }),
});
export const sandboxRelations = relations(sandboxes, ({ one }) => ({
environment: one(environments, {
fields: [sandboxes.environmentId],
references: [environments.environmentId],
}),
server: one(server, {
fields: [sandboxes.serverId],
references: [server.serverId],
}),
}));
const absolutePath = z
.string()
.min(1)
.max(4096)
.refine(
(value) => value.startsWith("/") && !value.includes("\0"),
"Path must be absolute",
);
export const apiCreateSandbox = z
.object({
environmentId: z.string().min(1),
name: z.string().min(1).max(64).optional(),
template: z.enum(SANDBOX_TEMPLATE_NAMES).optional(),
image: z.string().min(1).max(255).optional(),
serverId: z.string().nullable().optional(),
cpu: z.number().min(0.1).max(64).optional(),
memoryMb: z.number().int().min(64).max(262_144).optional(),
pidsLimit: z.number().int().min(16).max(65_536).optional(),
timeoutMs: z
.number()
.int()
.min(10_000)
.max(24 * 60 * 60 * 1000)
.optional(),
networkMode: z.enum(["isolated", "internet"]).optional(),
envVars: z.string().max(65_536).optional(),
workdir: absolutePath.optional(),
})
.refine((value) => !!value.template || !!value.image, {
message: "Either template or image is required",
path: ["image"],
});
export const apiFindOneSandbox = z.object({
sandboxId: z.string().min(1),
});
export const apiListSandboxes = z
.object({
environmentId: z.string().min(1).optional(),
projectId: z.string().min(1).optional(),
})
.refine((value) => !!value.environmentId || !!value.projectId, {
message: "Either environmentId or projectId is required",
path: ["environmentId"],
});
export const apiExecSandbox = z.object({
sandboxId: z.string().min(1),
cmd: z.string().min(1).max(65_536),
cwd: absolutePath.optional(),
env: z.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.string()).optional(),
timeoutMs: z.number().int().min(1000).max(600_000).optional(),
});
export const apiWriteFileSandbox = z.object({
sandboxId: z.string().min(1),
path: absolutePath,
content: z.string(),
encoding: z.enum(["utf8", "base64"]).default("utf8"),
mode: z.number().int().min(0).max(0o7777).optional(),
});
export const apiReadFileSandbox = z.object({
sandboxId: z.string().min(1),
path: absolutePath,
encoding: z.enum(["utf8", "base64"]).default("utf8"),
});
export const apiListFilesSandbox = z.object({
sandboxId: z.string().min(1),
path: absolutePath.optional(),
});
export const apiSetTimeoutSandbox = z.object({
sandboxId: z.string().min(1),
timeoutMs: z
.number()
.int()
.min(10_000)
.max(24 * 60 * 60 * 1000),
});

View File

@ -22,6 +22,7 @@ import { mysql } from "./mysql";
import { network } from "./network";
import { postgres } from "./postgres";
import { redis } from "./redis";
import { sandboxes } from "./sandbox";
import { schedules } from "./schedule";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
@ -125,6 +126,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
mongo: many(mongo),
mysql: many(mysql),
postgres: many(postgres),
sandboxes: many(sandboxes),
certificates: many(certificates),
networks: many(network),
organization: one(organization, {

View File

@ -740,6 +740,9 @@ importers:
ssh2:
specifier: ~1.16.0
version: 1.16.0
tar-stream:
specifier: 3.2.1
version: 3.2.1
toml:
specifier: 3.0.0
version: 3.0.0
@ -795,6 +798,9 @@ importers:
'@types/ssh2':
specifier: 1.15.1
version: 1.15.1
'@types/tar-stream':
specifier: 3.1.4
version: 3.1.4
'@types/ws':
specifier: 8.5.10
version: 8.5.10
@ -4435,6 +4441,9 @@ packages:
'@types/swagger-ui-react@4.19.0':
resolution: {integrity: sha512-uScp1xkLZJej0bt3/lO4U11ywWEBnI5CFCR0tqp+5Rvxl1Mj1v6VkGED0W70jJwqlBvbD+/a6bDiK8rjepCr8g==}
'@types/tar-stream@3.1.4':
resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==}
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@ -4861,6 +4870,14 @@ packages:
axios@1.18.0:
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
b4a@1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@ -4871,6 +4888,43 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.9.2:
resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-fs@4.8.1:
resolution: {integrity: sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==}
engines: {bare: '>=1.28.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-path@3.1.2:
resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==}
bare-stream@2.13.4:
resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
bare-events: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-buffer:
optional: true
bare-events:
optional: true
bare-url@2.5.4:
resolution: {integrity: sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
@ -5932,6 +5986,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@ -5993,6 +6050,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@ -8446,6 +8506,9 @@ packages:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
streamx@2.28.1:
resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@ -8606,16 +8669,25 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar-stream@3.2.1:
resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==}
tar@7.5.22:
resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==}
engines: {node: '>=18'}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
temporal-polyfill@0.2.5:
resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==}
temporal-spec@0.2.4:
resolution: {integrity: sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@ -13626,6 +13698,10 @@ snapshots:
dependencies:
'@types/react': 18.3.5
'@types/tar-stream@3.1.4':
dependencies:
'@types/node': 24.10.13
'@types/tedious@4.0.14':
dependencies:
'@types/node': 24.10.13
@ -13994,12 +14070,43 @@ snapshots:
- debug
- supports-color
b4a@1.8.1: {}
bail@2.0.2: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
bare-events@2.9.2: {}
bare-fs@4.8.1:
dependencies:
bare-events: 2.9.2
bare-path: 3.1.2
bare-stream: 2.13.4(bare-events@2.9.2)
bare-url: 2.5.4
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-path@3.1.2: {}
bare-stream@2.13.4(bare-events@2.9.2):
dependencies:
b4a: 1.8.1
streamx: 2.28.1
teex: 1.0.1
optionalDependencies:
bare-events: 2.9.2
transitivePeerDependencies:
- react-native-b4a
bare-url@2.5.4:
dependencies:
bare-path: 3.1.2
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
@ -14910,6 +15017,12 @@ snapshots:
eventemitter3@5.0.4: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.9.2
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
eventsource-parser@3.0.6: {}
@ -15018,6 +15131,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -17755,6 +17870,15 @@ snapshots:
stdin-discarder@0.2.2: {}
streamx@2.28.1:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-argv@0.3.2: {}
string-width@4.2.3:
@ -17998,6 +18122,17 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
tar-stream@3.2.1:
dependencies:
b4a: 1.8.1
bare-fs: 4.8.1
fast-fifo: 1.3.2
streamx: 2.28.1
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
tar@7.5.22:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@ -18006,12 +18141,25 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
teex@1.0.1:
dependencies:
streamx: 2.28.1
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
temporal-polyfill@0.2.5:
dependencies:
temporal-spec: 0.2.4
temporal-spec@0.2.4: {}
text-decoder@1.2.7:
dependencies:
b4a: 1.8.1
transitivePeerDependencies:
- react-native-b4a
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1