@multiplayer-app/sandbox 2.0.0-wip-yjs-state-changes.0 → 3.0.0-wip-yjs-state-changes.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/cli.js +3011 -97
- package/dist/cli.js.map +7 -1
- package/dist/executor/detect.d.ts +1 -1
- package/dist/executor/docker-executor.d.ts +1 -1
- package/dist/executor/docker-sandbox-executor.d.ts +1 -1
- package/dist/executor/fargate-executor.d.ts +1 -1
- package/dist/executor/firecracker-executor.d.ts +1 -1
- package/dist/executor/index.d.ts +1 -1
- package/dist/executor/lambda-microvm-executor.d.ts +1 -1
- package/dist/executor/mock-executor.d.ts +1 -1
- package/dist/executor/qemu-executor.d.ts +1 -1
- package/dist/executor/remote-executor.d.ts +1 -1
- package/dist/executor/types.d.ts +1 -1
- package/dist/images.d.ts +1 -1
- package/dist/index.js +3005 -29
- package/dist/index.js.map +7 -1
- package/dist/vendor/builder-types/access.d.ts +25 -0
- package/dist/vendor/builder-types/access.d.ts.map +1 -0
- package/dist/vendor/builder-types/events.d.ts +80 -0
- package/dist/vendor/builder-types/events.d.ts.map +1 -0
- package/dist/vendor/builder-types/index.d.ts +11 -0
- package/dist/vendor/builder-types/index.d.ts.map +1 -0
- package/dist/vendor/builder-types/release.d.ts +49 -0
- package/dist/vendor/builder-types/release.d.ts.map +1 -0
- package/dist/vendor/builder-types/vm.d.ts +101 -0
- package/dist/vendor/builder-types/vm.d.ts.map +1 -0
- package/dist/vendor/builder-types/worker.d.ts +25 -0
- package/dist/vendor/builder-types/worker.d.ts.map +1 -0
- package/package.json +15 -6
- package/dist/config.js +0 -144
- package/dist/config.js.map +0 -1
- package/dist/console-relays.js +0 -46
- package/dist/console-relays.js.map +0 -1
- package/dist/executor/detect.js +0 -184
- package/dist/executor/detect.js.map +0 -1
- package/dist/executor/docker-executor.js +0 -331
- package/dist/executor/docker-executor.js.map +0 -1
- package/dist/executor/docker-sandbox-executor.js +0 -343
- package/dist/executor/docker-sandbox-executor.js.map +0 -1
- package/dist/executor/fargate-executor.js +0 -293
- package/dist/executor/fargate-executor.js.map +0 -1
- package/dist/executor/firecracker-executor.js +0 -352
- package/dist/executor/firecracker-executor.js.map +0 -1
- package/dist/executor/index.js +0 -166
- package/dist/executor/index.js.map +0 -1
- package/dist/executor/lambda-microvm-executor.js +0 -211
- package/dist/executor/lambda-microvm-executor.js.map +0 -1
- package/dist/executor/mock-executor.js +0 -198
- package/dist/executor/mock-executor.js.map +0 -1
- package/dist/executor/mock-executor.test.js +0 -81
- package/dist/executor/mock-executor.test.js.map +0 -1
- package/dist/executor/qemu-executor.js +0 -383
- package/dist/executor/qemu-executor.js.map +0 -1
- package/dist/executor/reattach-utils.js +0 -11
- package/dist/executor/reattach-utils.js.map +0 -1
- package/dist/executor/remote-executor.js +0 -125
- package/dist/executor/remote-executor.js.map +0 -1
- package/dist/executor/types.js +0 -3
- package/dist/executor/types.js.map +0 -1
- package/dist/images.js +0 -163
- package/dist/images.js.map +0 -1
- package/dist/services/serial-relay.js +0 -74
- package/dist/services/serial-relay.js.map +0 -1
- package/dist/services/vnc-relay.js +0 -101
- package/dist/services/vnc-relay.js.map +0 -1
- package/dist/socket-client.js +0 -119
- package/dist/socket-client.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,105 +1,3019 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/cli.ts
|
|
27
|
+
var import_config5 = require("dotenv/config");
|
|
28
|
+
var import_commander = require("commander");
|
|
29
|
+
|
|
30
|
+
// package.json
|
|
31
|
+
var version = "3.0.0-wip-yjs-state-changes.0";
|
|
32
|
+
|
|
33
|
+
// src/config.ts
|
|
34
|
+
var import_os2 = __toESM(require("os"));
|
|
35
|
+
var import_path2 = __toESM(require("path"));
|
|
36
|
+
|
|
37
|
+
// src/images.ts
|
|
38
|
+
var import_fs = __toESM(require("fs"));
|
|
39
|
+
var import_os = __toESM(require("os"));
|
|
40
|
+
var import_path = __toESM(require("path"));
|
|
41
|
+
var import_promises = require("stream/promises");
|
|
42
|
+
var import_stream = require("stream");
|
|
43
|
+
|
|
44
|
+
// ../builder-types/src/vm.ts
|
|
45
|
+
var VM_STACKS = ["generic", "node", "python", "static"];
|
|
46
|
+
|
|
47
|
+
// ../builder-types/src/events.ts
|
|
48
|
+
var WorkerEvent = {
|
|
49
|
+
// worker → sandbox
|
|
50
|
+
HEARTBEAT: "worker:heartbeat",
|
|
51
|
+
// sandbox → worker, once right after auth on every (re)connect — the VMs sandbox's
|
|
52
|
+
// records say this workerId owns, so the worker can try to reattach before its first
|
|
53
|
+
// heartbeat. Always sent, even with an empty array, as a handshake signal.
|
|
54
|
+
REATTACH: "worker:reattach",
|
|
55
|
+
// sandbox → worker (VM lifecycle commands)
|
|
56
|
+
VM_CREATE: "vm:create",
|
|
57
|
+
VM_PAUSE: "vm:pause",
|
|
58
|
+
VM_RESUME: "vm:resume",
|
|
59
|
+
VM_STOP: "vm:stop",
|
|
60
|
+
VM_RESTORE: "vm:restore",
|
|
61
|
+
VM_DESTROY: "vm:destroy",
|
|
62
|
+
// sandbox / gateway → worker (HTTP proxy)
|
|
63
|
+
PROXY_HTTP: "proxy:http",
|
|
64
|
+
// sandbox → worker (guest workspace commands)
|
|
65
|
+
VM_EXEC: "vm:exec",
|
|
66
|
+
VM_WRITE_FILES: "vm:write-files",
|
|
67
|
+
// worker → sandbox (VNC/terminal media relay)
|
|
68
|
+
VNC_INIT: "vnc:init",
|
|
69
|
+
VNC_FRAME: "vnc:frame",
|
|
70
|
+
VNC_COPY_FRAME: "vnc:copy-frame",
|
|
71
|
+
TERMINAL_DATA: "terminal:data",
|
|
72
|
+
// sandbox → worker (browser input forwarded downstream)
|
|
73
|
+
VNC_MOUSE: "vnc:mouse",
|
|
74
|
+
VNC_KEYBOARD: "vnc:keyboard",
|
|
75
|
+
TERMINAL_INPUT: "terminal:input"
|
|
76
|
+
};
|
|
77
|
+
var VM_ACTION_EVENTS = {
|
|
78
|
+
pause: WorkerEvent.VM_PAUSE,
|
|
79
|
+
resume: WorkerEvent.VM_RESUME,
|
|
80
|
+
stop: WorkerEvent.VM_STOP,
|
|
81
|
+
restore: WorkerEvent.VM_RESTORE
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/images.ts
|
|
85
|
+
var IMAGE_FILES = ["vmlinuz", "initramfs.img"];
|
|
86
|
+
function hostArch() {
|
|
87
|
+
switch (process.arch) {
|
|
88
|
+
case "arm64":
|
|
89
|
+
return "aarch64";
|
|
90
|
+
case "x64":
|
|
91
|
+
return "x86_64";
|
|
92
|
+
default:
|
|
93
|
+
return process.arch;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function defaultImagesDir(arch = hostArch()) {
|
|
97
|
+
return import_path.default.join(import_os.default.homedir(), ".multiplayer", "vm-images", arch);
|
|
98
|
+
}
|
|
99
|
+
function resolveStackImages(imagesDir, stack) {
|
|
100
|
+
const kernelPath = import_path.default.join(imagesDir, stack, "vmlinuz");
|
|
101
|
+
const initrdPath = import_path.default.join(imagesDir, stack, "initramfs.img");
|
|
102
|
+
return import_fs.default.existsSync(kernelPath) && import_fs.default.existsSync(initrdPath) ? { kernelPath, initrdPath } : void 0;
|
|
103
|
+
}
|
|
104
|
+
function availableStacks(imagesDir) {
|
|
105
|
+
return VM_STACKS.filter((stack) => resolveStackImages(imagesDir, stack) !== void 0);
|
|
106
|
+
}
|
|
107
|
+
function parseS3Uri(uri) {
|
|
108
|
+
const match = /^s3:\/\/([^/]+)\/?(.*)$/.exec(uri);
|
|
109
|
+
if (!match) throw new Error(`invalid S3 URI "${uri}" \u2014 expected s3://bucket[/prefix]`);
|
|
110
|
+
return { bucket: match[1], prefix: match[2].replace(/\/+$/, "") };
|
|
111
|
+
}
|
|
112
|
+
function s3Key(prefix, arch, stack, file) {
|
|
113
|
+
return [prefix, arch, stack, file].filter(Boolean).join("/");
|
|
114
|
+
}
|
|
115
|
+
async function makeS3Client(endpoint) {
|
|
116
|
+
const { S3Client } = await import("@aws-sdk/client-s3");
|
|
117
|
+
return new S3Client({
|
|
118
|
+
...endpoint ? { endpoint, forcePathStyle: true } : {},
|
|
119
|
+
...process.env.AWS_REGION ? {} : { region: "us-east-1" },
|
|
120
|
+
// aws-chunked checksum streaming breaks older S3-compatible stores (MinIO)
|
|
121
|
+
requestChecksumCalculation: "WHEN_REQUIRED"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function downloadFromS3(opts, stack, file, dest) {
|
|
125
|
+
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
|
|
126
|
+
const { bucket, prefix } = parseS3Uri(opts.location);
|
|
127
|
+
const client = await makeS3Client(opts.endpoint);
|
|
128
|
+
const key = s3Key(prefix, opts.arch, stack, file);
|
|
129
|
+
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
130
|
+
if (!response.Body) throw new Error(`empty response for s3://${bucket}/${key}`);
|
|
131
|
+
await (0, import_promises.pipeline)(response.Body, import_fs.default.createWriteStream(dest));
|
|
132
|
+
}
|
|
133
|
+
async function downloadFromHttp(opts, stack, file, dest) {
|
|
134
|
+
const url = `${opts.location.replace(/\/+$/, "")}/${opts.arch}/${stack}/${file}`;
|
|
135
|
+
const response = await fetch(url);
|
|
136
|
+
if (!response.ok || !response.body) {
|
|
137
|
+
throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
|
|
138
|
+
}
|
|
139
|
+
await (0, import_promises.pipeline)(import_stream.Readable.fromWeb(response.body), import_fs.default.createWriteStream(dest));
|
|
140
|
+
}
|
|
141
|
+
async function pullImages(opts, destDir) {
|
|
142
|
+
const isS3 = opts.location.startsWith("s3://");
|
|
143
|
+
const downloaded = [];
|
|
144
|
+
for (const stack of opts.stacks ?? VM_STACKS) {
|
|
145
|
+
const stackDir = import_path.default.join(destDir, stack);
|
|
146
|
+
import_fs.default.mkdirSync(stackDir, { recursive: true });
|
|
147
|
+
for (const file of IMAGE_FILES) {
|
|
148
|
+
const dest = import_path.default.join(stackDir, file);
|
|
149
|
+
const partial = `${dest}.partial`;
|
|
150
|
+
if (isS3) await downloadFromS3(opts, stack, file, partial);
|
|
151
|
+
else await downloadFromHttp(opts, stack, file, partial);
|
|
152
|
+
import_fs.default.renameSync(partial, dest);
|
|
153
|
+
downloaded.push(dest);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return downloaded;
|
|
157
|
+
}
|
|
158
|
+
async function pushImages(opts, srcDir) {
|
|
159
|
+
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
|
160
|
+
const { bucket, prefix } = parseS3Uri(opts.location);
|
|
161
|
+
const client = await makeS3Client(opts.endpoint);
|
|
162
|
+
const uploaded = [];
|
|
163
|
+
const stacks = opts.stacks ?? availableStacks(srcDir);
|
|
164
|
+
if (!stacks.length) {
|
|
165
|
+
throw new Error(`no stack images found under ${srcDir} \u2014 run scripts/setup-vm-images.sh first`);
|
|
166
|
+
}
|
|
167
|
+
for (const stack of stacks) {
|
|
168
|
+
for (const file of IMAGE_FILES) {
|
|
169
|
+
const src = import_path.default.join(srcDir, stack, file);
|
|
170
|
+
if (!import_fs.default.existsSync(src)) {
|
|
171
|
+
throw new Error(`${src} not found \u2014 run scripts/setup-vm-images.sh first`);
|
|
172
|
+
}
|
|
173
|
+
const key = s3Key(prefix, opts.arch, stack, file);
|
|
174
|
+
await client.send(new PutObjectCommand({
|
|
175
|
+
Bucket: bucket,
|
|
176
|
+
Key: key,
|
|
177
|
+
Body: import_fs.default.readFileSync(src)
|
|
178
|
+
}));
|
|
179
|
+
uploaded.push(`s3://${bucket}/${key}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return uploaded;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/config.ts
|
|
186
|
+
function parseStackMap(raw) {
|
|
187
|
+
const map = {};
|
|
188
|
+
if (!raw) return map;
|
|
189
|
+
for (const pair of raw.split(",")) {
|
|
190
|
+
const idx = pair.indexOf(":");
|
|
191
|
+
if (idx === -1) continue;
|
|
192
|
+
const stack = pair.slice(0, idx).trim();
|
|
193
|
+
const arn = pair.slice(idx + 1).trim();
|
|
194
|
+
if (stack && arn) map[stack] = arn;
|
|
195
|
+
}
|
|
196
|
+
return map;
|
|
197
|
+
}
|
|
198
|
+
function parseArnList(raw) {
|
|
199
|
+
if (!raw) return void 0;
|
|
200
|
+
const arns = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
201
|
+
return arns.length ? arns : void 0;
|
|
202
|
+
}
|
|
203
|
+
function loadWorkerConfigFromEnv(env = process.env) {
|
|
204
|
+
const workerId = env.WORKER_ID?.trim() || import_os2.default.hostname();
|
|
205
|
+
const token = env.WORKER_TOKEN;
|
|
206
|
+
if (!token) {
|
|
207
|
+
throw new Error("WORKER_TOKEN is required");
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
workerId,
|
|
211
|
+
token,
|
|
212
|
+
sandboxUrl: env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com",
|
|
213
|
+
workerSocketPath: env.WORKER_SOCKET_PATH ?? "/v0/sapi/workers/ws",
|
|
214
|
+
maxVms: Number(env.MAX_VMS) || 10,
|
|
215
|
+
region: env.WORKER_REGION,
|
|
216
|
+
remote: {
|
|
217
|
+
// No default: an unset AGENT_URL means the remote engine is not configured on this worker
|
|
218
|
+
agentUrl: env.AGENT_URL ?? "",
|
|
219
|
+
authToken: env.AGENT_TOKEN
|
|
220
|
+
},
|
|
221
|
+
firecracker: {
|
|
222
|
+
bin: env.FIRECRACKER_BIN ?? "firecracker",
|
|
223
|
+
// Per-stack base images (<imagesDir>/<stack>/vmlinuz + initramfs.img) from
|
|
224
|
+
// `worker-cli images pull` / setup-vm-images.sh; engine detection checks
|
|
225
|
+
// existence, so missing images just disable the engine.
|
|
226
|
+
imagesDir: env.FIRECRACKER_IMAGES_DIR ?? env.VM_IMAGES_DIR ?? defaultImagesDir(),
|
|
227
|
+
// Explicit single-image override, applied to every stack when both are set
|
|
228
|
+
kernelPath: env.FIRECRACKER_KERNEL,
|
|
229
|
+
initrdPath: env.FIRECRACKER_INITRD,
|
|
230
|
+
vmDataPath: env.FIRECRACKER_VM_DATA ?? import_path2.default.join(import_os2.default.tmpdir(), "firecracker-vms")
|
|
231
|
+
},
|
|
232
|
+
lambdaMicrovm: {
|
|
233
|
+
region: env.MICROVM_AWS_REGION ?? env.AWS_REGION,
|
|
234
|
+
imageArn: env.MICROVM_IMAGE_ARN ?? "",
|
|
235
|
+
stackImageArns: parseStackMap(env.MICROVM_STACK_IMAGE_ARNS),
|
|
236
|
+
ingressConnectorArns: parseArnList(env.MICROVM_INGRESS_CONNECTORS),
|
|
237
|
+
egressConnectorArns: parseArnList(env.MICROVM_EGRESS_CONNECTORS),
|
|
238
|
+
executionRoleArn: env.MICROVM_EXECUTION_ROLE_ARN,
|
|
239
|
+
// 0 disables auto-suspend; the sandbox drives pause/resume explicitly by default
|
|
240
|
+
maxIdleSeconds: Number(env.MICROVM_MAX_IDLE_SECONDS ?? 900),
|
|
241
|
+
suspendedDurationSeconds: Number(env.MICROVM_SUSPENDED_DURATION_SECONDS ?? 28800),
|
|
242
|
+
maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800)
|
|
243
|
+
},
|
|
244
|
+
fargate: {
|
|
245
|
+
region: env.FARGATE_AWS_REGION ?? env.AWS_REGION,
|
|
246
|
+
cluster: env.FARGATE_CLUSTER ?? "",
|
|
247
|
+
taskDefinition: env.FARGATE_TASK_DEFINITION,
|
|
248
|
+
// Per-stack task definitions: "node:builder-node:3,python:builder-python:1"
|
|
249
|
+
stackTaskDefinitions: parseStackMap(env.FARGATE_STACK_TASK_DEFS),
|
|
250
|
+
subnets: env.FARGATE_SUBNETS?.split(",").map((s) => s.trim()).filter(Boolean) ?? [],
|
|
251
|
+
securityGroups: env.FARGATE_SECURITY_GROUPS?.split(",").map((s) => s.trim()).filter(Boolean),
|
|
252
|
+
// ENABLED when the worker runs outside the task VPC (proxies to the task's public IP)
|
|
253
|
+
assignPublicIp: env.FARGATE_ASSIGN_PUBLIC_IP === "true",
|
|
254
|
+
guestPort: Number(env.FARGATE_GUEST_PORT) || 8080,
|
|
255
|
+
startTimeoutMs: Number(env.FARGATE_START_TIMEOUT_MS) || 18e4
|
|
256
|
+
},
|
|
257
|
+
docker: {
|
|
258
|
+
bin: env.DOCKER_BIN ?? "docker",
|
|
259
|
+
// Local images from `scripts/setup-docker-images.sh`: <imagePrefix>-<stack>:<imageTag>
|
|
260
|
+
imagePrefix: env.DOCKER_IMAGE_PREFIX ?? "multiplayer-builder-vm",
|
|
261
|
+
imageTag: env.DOCKER_IMAGE_TAG ?? "local",
|
|
262
|
+
stackImages: parseStackMap(env.DOCKER_STACK_IMAGES),
|
|
263
|
+
network: env.DOCKER_NETWORK,
|
|
264
|
+
portRangeStart: Number(env.DOCKER_PORT_MIN) || 9100,
|
|
265
|
+
portRangeEnd: Number(env.DOCKER_PORT_MAX) || 9199,
|
|
266
|
+
guestPort: Number(env.DOCKER_GUEST_PORT) || 8080
|
|
267
|
+
},
|
|
268
|
+
dockerSandbox: {
|
|
269
|
+
bin: env.SBX_BIN ?? "sbx",
|
|
270
|
+
// Bridged from the same local images `docker` (imagePrefix-imageTag) already uses —
|
|
271
|
+
// `docker save` + `sbx template load`, see docker-sandbox-executor.ts.
|
|
272
|
+
imagePrefix: env.SBX_IMAGE_PREFIX ?? "multiplayer-builder-vm",
|
|
273
|
+
imageTag: env.SBX_IMAGE_TAG ?? "local",
|
|
274
|
+
stackTemplates: parseStackMap(env.SBX_STACK_TEMPLATES),
|
|
275
|
+
// `sbx create shell` doesn't run the image's own CMD, so the engine launches
|
|
276
|
+
// it explicitly — defaults match each Dockerfile's own WORKDIR /app + CMD.
|
|
277
|
+
stackAgentCommands: {
|
|
278
|
+
node: "node app.js",
|
|
279
|
+
python: "python app.py",
|
|
280
|
+
static: "node app.js",
|
|
281
|
+
...parseStackMap(env.SBX_STACK_AGENT_COMMANDS)
|
|
282
|
+
},
|
|
283
|
+
workspaceRoot: env.SBX_WORKSPACE_DIR ?? import_path2.default.join(import_os2.default.tmpdir(), "sbx-vms"),
|
|
284
|
+
portRangeStart: Number(env.SBX_PORT_MIN) || 9200,
|
|
285
|
+
portRangeEnd: Number(env.SBX_PORT_MAX) || 9299,
|
|
286
|
+
guestPort: Number(env.SBX_GUEST_PORT) || 8080,
|
|
287
|
+
// sbx rejects sandboxes below 1 GiB memory.
|
|
288
|
+
minMemoryMib: Number(env.SBX_MIN_MEMORY_MIB) || 1024
|
|
289
|
+
},
|
|
290
|
+
qemu: {
|
|
291
|
+
bin: env.QEMU_BIN ?? "qemu-system-aarch64",
|
|
292
|
+
// Per-stack base images (<imagesDir>/<stack>/vmlinuz + initramfs.img) from
|
|
293
|
+
// `worker-cli images pull` / setup-vm-images.sh; engine detection checks
|
|
294
|
+
// existence, so missing images just disable the engine.
|
|
295
|
+
imagesDir: env.QEMU_IMAGES_DIR ?? env.VM_IMAGES_DIR ?? defaultImagesDir(),
|
|
296
|
+
// Explicit single-image override, applied to every stack when both are set
|
|
297
|
+
kernelPath: env.QEMU_KERNEL,
|
|
298
|
+
initrdPath: env.QEMU_INITRD,
|
|
299
|
+
vmDataPath: env.QEMU_VM_DATA ?? import_path2.default.join(import_os2.default.tmpdir(), "qemu-vms"),
|
|
300
|
+
portRangeStart: Number(env.QEMU_PORT_MIN) || 9e3,
|
|
301
|
+
portRangeEnd: Number(env.QEMU_PORT_MAX) || 9999,
|
|
302
|
+
// Accelerator: hvf (macOS host), kvm (Linux + /dev/kvm), tcg (software, any host — slow)
|
|
303
|
+
accel: env.QEMU_ACCEL ?? (process.platform === "darwin" ? "hvf" : "kvm"),
|
|
304
|
+
// CPU model: host requires hvf/kvm; max is best for tcg on aarch64; qemu64 for tcg x86_64
|
|
305
|
+
cpu: env.QEMU_CPU ?? "host",
|
|
306
|
+
// Machine type: virt (aarch64), q35 (x86_64)
|
|
307
|
+
machine: env.QEMU_MACHINE ?? "virt"
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ../logger/src/logger.ts
|
|
313
|
+
var import_bunyan = __toESM(require("bunyan"));
|
|
314
|
+
var import_os3 = require("os");
|
|
315
|
+
|
|
316
|
+
// ../logger/src/config.ts
|
|
317
|
+
var NODE_ENV = process.env.NODE_ENV || "development";
|
|
318
|
+
var isProduction = NODE_ENV === "production";
|
|
319
|
+
var APP_NAME = process.env.npm_package_name?.split("/").pop() || "tests";
|
|
320
|
+
var LOG_LEVEL = process.env.LOG_LEVEL || (isProduction ? "info" : "debug");
|
|
321
|
+
|
|
322
|
+
// ../logger/src/logger.ts
|
|
323
|
+
var HOST_NAME = (0, import_os3.hostname)();
|
|
324
|
+
var loggerOptions = {
|
|
325
|
+
name: APP_NAME,
|
|
326
|
+
level: LOG_LEVEL,
|
|
327
|
+
hostname: HOST_NAME,
|
|
328
|
+
serializers: import_bunyan.default.stdSerializers
|
|
329
|
+
};
|
|
330
|
+
var logger = import_bunyan.default.createLogger(loggerOptions);
|
|
331
|
+
|
|
332
|
+
// ../logger/src/kafkajs-logger-creator.ts
|
|
333
|
+
var import_bunyan2 = __toESM(require("bunyan"));
|
|
334
|
+
var import_os4 = require("os");
|
|
335
|
+
var HOST_NAME2 = (0, import_os4.hostname)();
|
|
336
|
+
|
|
337
|
+
// ../logger/src/index.ts
|
|
338
|
+
var src_default = logger;
|
|
339
|
+
|
|
340
|
+
// src/executor/mock-executor.ts
|
|
341
|
+
var import_child_process = require("child_process");
|
|
342
|
+
var import_promises2 = __toESM(require("fs/promises"));
|
|
343
|
+
var import_http = __toESM(require("http"));
|
|
344
|
+
var import_os5 = __toESM(require("os"));
|
|
345
|
+
var import_path3 = __toESM(require("path"));
|
|
346
|
+
var DEFAULT_EXEC_TIMEOUT_MS = 6e4;
|
|
347
|
+
var MAX_EXEC_TIMEOUT_MS = 3e5;
|
|
348
|
+
var MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
349
|
+
var MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
350
|
+
var CONTENT_TYPES = {
|
|
351
|
+
".html": "text/html; charset=utf-8",
|
|
352
|
+
".js": "text/javascript; charset=utf-8",
|
|
353
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
354
|
+
".css": "text/css; charset=utf-8",
|
|
355
|
+
".json": "application/json; charset=utf-8",
|
|
356
|
+
".svg": "image/svg+xml",
|
|
357
|
+
".png": "image/png",
|
|
358
|
+
".txt": "text/plain; charset=utf-8",
|
|
359
|
+
".md": "text/plain; charset=utf-8"
|
|
360
|
+
};
|
|
361
|
+
function resolveWorkspacePath(workspaceDir, relativePath) {
|
|
362
|
+
const normalized = import_path3.default.normalize(relativePath).replace(/^([/\\])+/, "");
|
|
363
|
+
const resolved = import_path3.default.resolve(workspaceDir, normalized);
|
|
364
|
+
if (resolved !== workspaceDir && !resolved.startsWith(workspaceDir + import_path3.default.sep)) {
|
|
365
|
+
throw new Error(`Path escapes the VM workspace: ${relativePath}`);
|
|
366
|
+
}
|
|
367
|
+
return resolved;
|
|
368
|
+
}
|
|
369
|
+
function startStandInServer(spec, workspaceDir) {
|
|
370
|
+
const server = import_http.default.createServer((req, res) => {
|
|
371
|
+
void (async () => {
|
|
372
|
+
const requestPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
373
|
+
const relative = requestPath === "/" ? "index.html" : requestPath;
|
|
374
|
+
try {
|
|
375
|
+
const filePath = resolveWorkspacePath(workspaceDir, relative);
|
|
376
|
+
const content = await import_promises2.default.readFile(filePath);
|
|
377
|
+
res.writeHead(200, { "content-type": CONTENT_TYPES[import_path3.default.extname(filePath).toLowerCase()] ?? "application/octet-stream" });
|
|
378
|
+
res.end(content);
|
|
379
|
+
} catch {
|
|
380
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
381
|
+
res.end(`mock vm ${spec.vmId} (${spec.stack}) \u2014 session ${spec.sessionId} \u2014 ${req.method} ${req.url}
|
|
382
|
+
`);
|
|
383
|
+
}
|
|
384
|
+
})();
|
|
385
|
+
});
|
|
386
|
+
return new Promise((resolve, reject) => {
|
|
387
|
+
server.once("error", reject);
|
|
388
|
+
server.listen(0, "127.0.0.1", () => {
|
|
389
|
+
resolve({ server, port: server.address().port });
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
var MockExecutor = class {
|
|
394
|
+
constructor() {
|
|
395
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
396
|
+
}
|
|
397
|
+
async create(spec) {
|
|
398
|
+
const workspaceDir = await import_promises2.default.mkdtemp(import_path3.default.join(import_os5.default.tmpdir(), `mock-vm-${spec.vmId}-`));
|
|
399
|
+
const { server, port } = await startStandInServer(spec, workspaceDir);
|
|
400
|
+
const handle = {
|
|
401
|
+
vmId: spec.vmId,
|
|
402
|
+
sessionId: spec.sessionId,
|
|
403
|
+
stack: spec.stack,
|
|
404
|
+
resources: spec.resources,
|
|
405
|
+
running: true
|
|
406
|
+
};
|
|
407
|
+
this.vms.set(spec.vmId, { handle, server, port, workspaceDir });
|
|
408
|
+
src_default.info({ vmId: spec.vmId, port, workspaceDir }, "mock vm created");
|
|
409
|
+
return handle;
|
|
410
|
+
}
|
|
411
|
+
async pause(vmId) {
|
|
412
|
+
return this.setRunning(vmId, false);
|
|
413
|
+
}
|
|
414
|
+
async resume(vmId) {
|
|
415
|
+
return this.setRunning(vmId, true);
|
|
416
|
+
}
|
|
417
|
+
async stop(vmId) {
|
|
418
|
+
return this.setRunning(vmId, false);
|
|
419
|
+
}
|
|
420
|
+
async restore(vmId) {
|
|
421
|
+
return this.setRunning(vmId, true);
|
|
422
|
+
}
|
|
423
|
+
async destroy(vmId) {
|
|
424
|
+
const vm = this.vms.get(vmId);
|
|
425
|
+
if (!vm) return;
|
|
426
|
+
await new Promise((resolve) => vm.server.close(() => resolve()));
|
|
427
|
+
await import_promises2.default.rm(vm.workspaceDir, { recursive: true, force: true });
|
|
428
|
+
this.vms.delete(vmId);
|
|
429
|
+
src_default.info({ vmId }, "mock vm destroyed");
|
|
430
|
+
}
|
|
431
|
+
get(vmId) {
|
|
432
|
+
return this.vms.get(vmId)?.handle;
|
|
433
|
+
}
|
|
434
|
+
list() {
|
|
435
|
+
return [...this.vms.values()].map((vm) => vm.handle);
|
|
436
|
+
}
|
|
437
|
+
async proxyHttp(vmId, request) {
|
|
438
|
+
const vm = this.requireRunningVm(vmId);
|
|
439
|
+
return new Promise((resolve, reject) => {
|
|
440
|
+
const proxyReq = import_http.default.request(
|
|
441
|
+
{
|
|
442
|
+
host: "127.0.0.1",
|
|
443
|
+
port: vm.port,
|
|
444
|
+
method: request.method,
|
|
445
|
+
path: request.path,
|
|
446
|
+
headers: request.headers
|
|
447
|
+
},
|
|
448
|
+
(proxyRes) => {
|
|
449
|
+
const chunks = [];
|
|
450
|
+
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
|
451
|
+
proxyRes.on("end", () => {
|
|
452
|
+
resolve({
|
|
453
|
+
statusCode: proxyRes.statusCode ?? 502,
|
|
454
|
+
headers: Object.fromEntries(
|
|
455
|
+
Object.entries(proxyRes.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value ?? ""])
|
|
456
|
+
),
|
|
457
|
+
body: Buffer.concat(chunks)
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
);
|
|
462
|
+
proxyReq.on("error", reject);
|
|
463
|
+
if (request.body) proxyReq.write(request.body);
|
|
464
|
+
proxyReq.end();
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async writeFiles(vmId, files) {
|
|
468
|
+
const vm = this.requireRunningVm(vmId);
|
|
469
|
+
const written = [];
|
|
470
|
+
for (const file of files) {
|
|
471
|
+
const buffer = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8");
|
|
472
|
+
if (buffer.byteLength > MAX_FILE_BYTES) {
|
|
473
|
+
throw new Error(`File too large (${buffer.byteLength} bytes, max ${MAX_FILE_BYTES}): ${file.path}`);
|
|
474
|
+
}
|
|
475
|
+
const filePath = resolveWorkspacePath(vm.workspaceDir, file.path);
|
|
476
|
+
await import_promises2.default.mkdir(import_path3.default.dirname(filePath), { recursive: true });
|
|
477
|
+
await import_promises2.default.writeFile(filePath, buffer);
|
|
478
|
+
written.push({ path: import_path3.default.relative(vm.workspaceDir, filePath), bytes: buffer.byteLength });
|
|
479
|
+
}
|
|
480
|
+
src_default.info({ vmId, files: written.length }, "mock vm files written");
|
|
481
|
+
return { written };
|
|
482
|
+
}
|
|
483
|
+
async exec(vmId, request) {
|
|
484
|
+
const vm = this.requireRunningVm(vmId);
|
|
485
|
+
const cwd = request.cwd ? resolveWorkspacePath(vm.workspaceDir, request.cwd) : vm.workspaceDir;
|
|
486
|
+
const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS, MAX_EXEC_TIMEOUT_MS);
|
|
487
|
+
const startedAt = Date.now();
|
|
488
|
+
return new Promise((resolve) => {
|
|
489
|
+
(0, import_child_process.execFile)(
|
|
490
|
+
"/bin/sh",
|
|
491
|
+
["-c", request.command],
|
|
492
|
+
{
|
|
493
|
+
cwd,
|
|
494
|
+
timeout: timeoutMs,
|
|
495
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
496
|
+
env: { ...process.env, HOME: vm.workspaceDir }
|
|
497
|
+
},
|
|
498
|
+
(error, stdout, stderr) => {
|
|
499
|
+
const failed = error;
|
|
500
|
+
const timedOut = Boolean(failed?.killed);
|
|
501
|
+
const truncated = failed?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
502
|
+
resolve({
|
|
503
|
+
exitCode: typeof failed?.code === "number" ? failed.code : failed ? 1 : 0,
|
|
504
|
+
stdout: stdout.slice(0, MAX_OUTPUT_BYTES),
|
|
505
|
+
stderr: stderr.slice(0, MAX_OUTPUT_BYTES),
|
|
506
|
+
durationMs: Date.now() - startedAt,
|
|
507
|
+
truncated: Boolean(truncated),
|
|
508
|
+
timedOut
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
requireRunningVm(vmId) {
|
|
515
|
+
const vm = this.vms.get(vmId);
|
|
516
|
+
if (!vm || !vm.handle.running) {
|
|
517
|
+
throw new Error(`vm ${vmId} is not running`);
|
|
518
|
+
}
|
|
519
|
+
return vm;
|
|
520
|
+
}
|
|
521
|
+
setRunning(vmId, running) {
|
|
522
|
+
const vm = this.vms.get(vmId);
|
|
523
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
524
|
+
vm.handle = { ...vm.handle, running };
|
|
525
|
+
return vm.handle;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// src/executor/qemu-executor.ts
|
|
530
|
+
var import_child_process2 = require("child_process");
|
|
531
|
+
var import_net = __toESM(require("net"));
|
|
532
|
+
var import_path4 = __toESM(require("path"));
|
|
533
|
+
var import_promises3 = __toESM(require("fs/promises"));
|
|
534
|
+
var import_os6 = __toESM(require("os"));
|
|
535
|
+
|
|
536
|
+
// src/executor/reattach-utils.ts
|
|
537
|
+
function withTimeout(promise, ms) {
|
|
538
|
+
return Promise.race([
|
|
539
|
+
promise,
|
|
540
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms))
|
|
541
|
+
]);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/executor/qemu-executor.ts
|
|
545
|
+
var QemuExecutor = class {
|
|
546
|
+
constructor(config) {
|
|
547
|
+
this.config = config;
|
|
548
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
549
|
+
this.usedHttpPorts = /* @__PURE__ */ new Set();
|
|
550
|
+
this.usedDisplays = /* @__PURE__ */ new Set();
|
|
551
|
+
// VNC display indices
|
|
552
|
+
this.usedSerialPorts = /* @__PURE__ */ new Set();
|
|
553
|
+
this.bin = config.bin ?? "qemu-system-aarch64";
|
|
554
|
+
this.vmDataPath = config.vmDataPath ?? import_path4.default.join(import_os6.default.tmpdir(), "qemu-vms");
|
|
555
|
+
this.httpPortStart = config.httpPortRangeStart ?? 9e3;
|
|
556
|
+
this.httpPortEnd = config.httpPortRangeEnd ?? 9099;
|
|
557
|
+
this.vncBasePort = config.vncBasePort ?? 5900;
|
|
558
|
+
this.serialBasePort = config.serialBasePort ?? 8e3;
|
|
559
|
+
this.accel = config.accel ?? "hvf";
|
|
560
|
+
this.cpu = config.cpu ?? "host";
|
|
561
|
+
this.machine = config.machine ?? "virt";
|
|
562
|
+
}
|
|
563
|
+
allocateHttpPort() {
|
|
564
|
+
for (let p = this.httpPortStart; p <= this.httpPortEnd; p++) {
|
|
565
|
+
if (!this.usedHttpPorts.has(p)) {
|
|
566
|
+
this.usedHttpPorts.add(p);
|
|
567
|
+
return p;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
throw new Error("no free HTTP ports in QEMU port range");
|
|
571
|
+
}
|
|
572
|
+
allocateVncDisplay() {
|
|
573
|
+
for (let d = 1; d <= 99; d++) {
|
|
574
|
+
if (!this.usedDisplays.has(d)) {
|
|
575
|
+
this.usedDisplays.add(d);
|
|
576
|
+
return d;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
throw new Error("no free VNC displays");
|
|
580
|
+
}
|
|
581
|
+
allocateSerialPort() {
|
|
582
|
+
for (let p = this.serialBasePort; p <= this.serialBasePort + 99; p++) {
|
|
583
|
+
if (!this.usedSerialPorts.has(p)) {
|
|
584
|
+
this.usedSerialPorts.add(p);
|
|
585
|
+
return p;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
throw new Error("no free serial ports");
|
|
589
|
+
}
|
|
590
|
+
/** Explicit kernel/initrd override wins; otherwise the stack's base image, falling back to generic. */
|
|
591
|
+
resolveImages(stack) {
|
|
592
|
+
if (this.config.kernelPath && this.config.initrdPath) {
|
|
593
|
+
return { kernelPath: this.config.kernelPath, initrdPath: this.config.initrdPath };
|
|
594
|
+
}
|
|
595
|
+
const resolved = this.config.imagesDir ? resolveStackImages(this.config.imagesDir, stack) ?? resolveStackImages(this.config.imagesDir, "generic") : void 0;
|
|
596
|
+
if (!resolved) {
|
|
597
|
+
throw new Error(`no base image for stack "${stack}" under ${this.config.imagesDir} \u2014 run "worker-cli images pull"`);
|
|
598
|
+
}
|
|
599
|
+
return resolved;
|
|
600
|
+
}
|
|
601
|
+
async create(spec) {
|
|
602
|
+
const { kernelPath, initrdPath } = this.resolveImages(spec.stack);
|
|
603
|
+
const hostPort = this.allocateHttpPort();
|
|
604
|
+
const vncDisplay = this.allocateVncDisplay();
|
|
605
|
+
const serialPort = this.allocateSerialPort();
|
|
606
|
+
const vncPort = this.vncBasePort + vncDisplay;
|
|
607
|
+
const vmDir = import_path4.default.join(this.vmDataPath, spec.vmId);
|
|
608
|
+
await import_promises3.default.mkdir(vmDir, { recursive: true });
|
|
609
|
+
const qmpSocketPath = import_path4.default.join(vmDir, "qmp.sock");
|
|
610
|
+
const args = [
|
|
611
|
+
"-machine",
|
|
612
|
+
this.machine,
|
|
613
|
+
"-cpu",
|
|
614
|
+
this.cpu,
|
|
615
|
+
"-accel",
|
|
616
|
+
this.accel,
|
|
617
|
+
"-smp",
|
|
618
|
+
String(spec.resources.vcpuCount),
|
|
619
|
+
"-m",
|
|
620
|
+
String(spec.resources.memMib),
|
|
621
|
+
"-kernel",
|
|
622
|
+
kernelPath,
|
|
623
|
+
"-initrd",
|
|
624
|
+
initrdPath,
|
|
625
|
+
// console=tty0 sends output to VGA framebuffer (VNC); ttyAMA0 to serial.
|
|
626
|
+
// APP_URL_B64: base64-encoded presigned artifact URL — kernel cmdline params are
|
|
627
|
+
// space-separated, so the raw URL (query string, &) can't be passed directly.
|
|
628
|
+
"-append",
|
|
629
|
+
[
|
|
630
|
+
`console=tty0 console=ttyAMA0 net.ifnames=0 init=/init VM_ID=${spec.vmId} STACK=${spec.stack}`,
|
|
631
|
+
spec.sessionId ? `SESSION_ID=${spec.sessionId}` : "",
|
|
632
|
+
spec.appUrl ? `APP_URL_B64=${Buffer.from(spec.appUrl).toString("base64")}` : ""
|
|
633
|
+
].filter(Boolean).join(" "),
|
|
634
|
+
// Networking
|
|
635
|
+
"-netdev",
|
|
636
|
+
`user,id=net0,hostfwd=tcp::${hostPort}-:8080`,
|
|
637
|
+
"-device",
|
|
638
|
+
"virtio-net-pci,netdev=net0",
|
|
639
|
+
// Display: virtio-gpu-pci works on both aarch64 and x86_64; virtio-vga is x86-only
|
|
640
|
+
"-device",
|
|
641
|
+
"virtio-gpu-pci",
|
|
642
|
+
"-display",
|
|
643
|
+
"none",
|
|
644
|
+
"-vnc",
|
|
645
|
+
`127.0.0.1:${vncDisplay}`,
|
|
646
|
+
// Serial console via TCP (xterm.js connects here)
|
|
647
|
+
"-serial",
|
|
648
|
+
`tcp:127.0.0.1:${serialPort},server,nowait`,
|
|
649
|
+
// Control
|
|
650
|
+
"-no-reboot",
|
|
651
|
+
"-qmp",
|
|
652
|
+
`unix:${qmpSocketPath},server,nowait`
|
|
653
|
+
];
|
|
654
|
+
src_default.info({ vmId: spec.vmId, bin: this.bin, args }, "spawning qemu");
|
|
655
|
+
const proc = (0, import_child_process2.spawn)(this.bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
656
|
+
proc.stdout?.on("data", (d) => src_default.info({ vmId: spec.vmId }, d.toString().trimEnd()));
|
|
657
|
+
proc.stderr?.on("data", (d) => src_default.error({ vmId: spec.vmId }, d.toString().trimEnd()));
|
|
658
|
+
proc.on("exit", (code, signal) => {
|
|
659
|
+
const log = code === 0 || code === null ? src_default.info.bind(src_default) : src_default.error.bind(src_default);
|
|
660
|
+
log({ vmId: spec.vmId, code, signal }, "qemu process exited");
|
|
661
|
+
const vm2 = this.vms.get(spec.vmId);
|
|
662
|
+
if (vm2) {
|
|
663
|
+
this.usedHttpPorts.delete(vm2.hostPort);
|
|
664
|
+
this.usedDisplays.delete(vm2.vncDisplay);
|
|
665
|
+
this.usedSerialPorts.delete(vm2.serialPort);
|
|
666
|
+
this.vms.delete(spec.vmId);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
const metadata = { hostPort, qmpSocketPath, vncDisplay, serialPort, pid: proc.pid };
|
|
670
|
+
const handle = {
|
|
671
|
+
vmId: spec.vmId,
|
|
672
|
+
sessionId: spec.sessionId,
|
|
673
|
+
stack: spec.stack,
|
|
674
|
+
resources: spec.resources,
|
|
675
|
+
running: true,
|
|
676
|
+
vncPort,
|
|
677
|
+
serialPort,
|
|
678
|
+
metadata
|
|
679
|
+
};
|
|
680
|
+
const vm = { handle, process: proc, pid: proc.pid, hostPort, qmpSocketPath, vncDisplay, serialPort };
|
|
681
|
+
this.vms.set(spec.vmId, vm);
|
|
682
|
+
await waitForQmp(qmpSocketPath);
|
|
683
|
+
src_default.info({ vmId: spec.vmId, hostPort, vncPort, serialPort }, "qemu vm ready");
|
|
684
|
+
return handle;
|
|
685
|
+
}
|
|
686
|
+
async pause(vmId) {
|
|
687
|
+
await this.qmp(vmId, "stop");
|
|
688
|
+
return this.setRunning(vmId, false);
|
|
689
|
+
}
|
|
690
|
+
async resume(vmId) {
|
|
691
|
+
await this.qmp(vmId, "cont");
|
|
692
|
+
return this.setRunning(vmId, true);
|
|
693
|
+
}
|
|
694
|
+
async stop(vmId) {
|
|
695
|
+
await this.qmp(vmId, "stop");
|
|
696
|
+
return this.setRunning(vmId, false);
|
|
697
|
+
}
|
|
698
|
+
async restore(vmId) {
|
|
699
|
+
await this.qmp(vmId, "cont");
|
|
700
|
+
return this.setRunning(vmId, true);
|
|
701
|
+
}
|
|
702
|
+
async destroy(vmId) {
|
|
703
|
+
const vm = this.vms.get(vmId);
|
|
704
|
+
if (!vm) return;
|
|
705
|
+
try {
|
|
706
|
+
await this.qmp(vmId, "quit");
|
|
707
|
+
} catch {
|
|
708
|
+
this.killVm(vm);
|
|
709
|
+
}
|
|
710
|
+
if (vm.process) {
|
|
711
|
+
const proc = vm.process;
|
|
712
|
+
await new Promise((resolve) => {
|
|
713
|
+
if (proc.exitCode !== null) return resolve();
|
|
714
|
+
proc.once("exit", resolve);
|
|
715
|
+
setTimeout(() => {
|
|
716
|
+
this.killVm(vm);
|
|
717
|
+
resolve();
|
|
718
|
+
}, 3e3);
|
|
719
|
+
});
|
|
720
|
+
} else {
|
|
721
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
722
|
+
this.killVm(vm);
|
|
723
|
+
}
|
|
724
|
+
this.usedHttpPorts.delete(vm.hostPort);
|
|
725
|
+
this.usedDisplays.delete(vm.vncDisplay);
|
|
726
|
+
this.usedSerialPorts.delete(vm.serialPort);
|
|
727
|
+
this.vms.delete(vmId);
|
|
728
|
+
await import_promises3.default.rm(import_path4.default.join(this.vmDataPath, vmId), { recursive: true, force: true });
|
|
729
|
+
src_default.info({ vmId }, "qemu vm destroyed");
|
|
730
|
+
}
|
|
731
|
+
get(vmId) {
|
|
732
|
+
return this.vms.get(vmId)?.handle;
|
|
733
|
+
}
|
|
734
|
+
list() {
|
|
735
|
+
return [...this.vms.values()].map((v) => v.handle);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Recover a QEMU VM after a worker restart: the process may well have survived (it's not
|
|
739
|
+
* killed just because its Node parent exited), so probe its QMP socket — if it answers,
|
|
740
|
+
* lifecycle control and HTTP proxying resume normally. There's no live ChildProcess to
|
|
741
|
+
* reattach to, so destroy() falls back to killing the stored pid.
|
|
742
|
+
*/
|
|
743
|
+
async reattach(record) {
|
|
744
|
+
const meta = record.runtimeMetadata;
|
|
745
|
+
if (!meta?.qmpSocketPath || meta.hostPort === void 0 || meta.vncDisplay === void 0 || meta.serialPort === void 0) {
|
|
746
|
+
return void 0;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
await withTimeout(sendQmpCommand(meta.qmpSocketPath, "query-status"), 5e3);
|
|
750
|
+
} catch {
|
|
751
|
+
return void 0;
|
|
752
|
+
}
|
|
753
|
+
const handle = {
|
|
754
|
+
vmId: record.vmId,
|
|
755
|
+
sessionId: record.sessionId,
|
|
756
|
+
stack: record.stack,
|
|
757
|
+
resources: record.resources,
|
|
758
|
+
running: record.state === "running",
|
|
759
|
+
vncPort: this.vncBasePort + meta.vncDisplay,
|
|
760
|
+
serialPort: meta.serialPort,
|
|
761
|
+
metadata: meta
|
|
762
|
+
};
|
|
763
|
+
this.usedHttpPorts.add(meta.hostPort);
|
|
764
|
+
this.usedDisplays.add(meta.vncDisplay);
|
|
765
|
+
this.usedSerialPorts.add(meta.serialPort);
|
|
766
|
+
this.vms.set(record.vmId, {
|
|
767
|
+
handle,
|
|
768
|
+
pid: meta.pid,
|
|
769
|
+
hostPort: meta.hostPort,
|
|
770
|
+
qmpSocketPath: meta.qmpSocketPath,
|
|
771
|
+
vncDisplay: meta.vncDisplay,
|
|
772
|
+
serialPort: meta.serialPort
|
|
773
|
+
});
|
|
774
|
+
src_default.info({ vmId: record.vmId }, "qemu vm reattached");
|
|
775
|
+
return handle;
|
|
776
|
+
}
|
|
777
|
+
killVm(vm) {
|
|
778
|
+
if (vm.process) {
|
|
779
|
+
vm.process.kill("SIGKILL");
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (vm.pid) {
|
|
783
|
+
try {
|
|
784
|
+
process.kill(vm.pid, "SIGKILL");
|
|
785
|
+
} catch {
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async proxyHttp(vmId, request) {
|
|
790
|
+
const vm = this.vms.get(vmId);
|
|
791
|
+
if (!vm?.handle.running) throw new Error(`vm ${vmId} is not running`);
|
|
792
|
+
const { default: http7 } = await import("http");
|
|
793
|
+
return new Promise((resolve, reject) => {
|
|
794
|
+
const req = http7.request(
|
|
795
|
+
{ host: "127.0.0.1", port: vm.hostPort, method: request.method, path: request.path, headers: request.headers },
|
|
796
|
+
(res) => {
|
|
797
|
+
const chunks = [];
|
|
798
|
+
res.on("data", (c) => chunks.push(c));
|
|
799
|
+
res.on(
|
|
800
|
+
"end",
|
|
801
|
+
() => resolve({
|
|
802
|
+
statusCode: res.statusCode ?? 502,
|
|
803
|
+
headers: Object.fromEntries(
|
|
804
|
+
Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
|
|
805
|
+
),
|
|
806
|
+
body: Buffer.concat(chunks)
|
|
807
|
+
})
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
);
|
|
811
|
+
req.on("error", reject);
|
|
812
|
+
if (request.body) req.write(request.body);
|
|
813
|
+
req.end();
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
setRunning(vmId, running) {
|
|
817
|
+
const vm = this.vms.get(vmId);
|
|
818
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
819
|
+
vm.handle = { ...vm.handle, running };
|
|
820
|
+
return vm.handle;
|
|
821
|
+
}
|
|
822
|
+
async qmp(vmId, execute) {
|
|
823
|
+
const vm = this.vms.get(vmId);
|
|
824
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
825
|
+
await sendQmpCommand(vm.qmpSocketPath, execute);
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
function waitForQmp(socketPath, timeoutMs = 3e4) {
|
|
829
|
+
return new Promise((resolve, reject) => {
|
|
830
|
+
const deadline = Date.now() + timeoutMs;
|
|
831
|
+
const attempt = () => {
|
|
832
|
+
if (Date.now() > deadline) return reject(new Error(`timed out waiting for QMP socket at ${socketPath}`));
|
|
833
|
+
const sock = import_net.default.createConnection(socketPath);
|
|
834
|
+
sock.once("connect", () => {
|
|
835
|
+
sock.destroy();
|
|
836
|
+
resolve();
|
|
837
|
+
});
|
|
838
|
+
sock.once("error", () => setTimeout(attempt, 500));
|
|
839
|
+
};
|
|
840
|
+
attempt();
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
async function sendQmpCommand(socketPath, execute) {
|
|
844
|
+
return new Promise((resolve, reject) => {
|
|
845
|
+
const sock = import_net.default.createConnection(socketPath);
|
|
846
|
+
let buf = "";
|
|
847
|
+
let ready = false;
|
|
848
|
+
const send = (obj) => sock.write(JSON.stringify(obj) + "\r\n");
|
|
849
|
+
sock.on("data", (data) => {
|
|
850
|
+
buf += data.toString();
|
|
851
|
+
let idx;
|
|
852
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
853
|
+
const line = buf.slice(0, idx).trim();
|
|
854
|
+
buf = buf.slice(idx + 1);
|
|
855
|
+
if (!line) continue;
|
|
856
|
+
let msg;
|
|
857
|
+
try {
|
|
858
|
+
msg = JSON.parse(line);
|
|
859
|
+
} catch {
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (msg["QMP"]) {
|
|
863
|
+
send({ execute: "qmp_capabilities" });
|
|
864
|
+
} else if (!ready && "return" in msg) {
|
|
865
|
+
ready = true;
|
|
866
|
+
send({ execute });
|
|
867
|
+
} else if (ready && "return" in msg) {
|
|
868
|
+
sock.destroy();
|
|
869
|
+
resolve();
|
|
870
|
+
} else if (msg["error"]) {
|
|
871
|
+
sock.destroy();
|
|
872
|
+
reject(new Error(`QMP error for "${execute}": ${JSON.stringify(msg["error"])}`));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
sock.on("error", (e) => {
|
|
877
|
+
if (execute === "quit") resolve();
|
|
878
|
+
else reject(e);
|
|
879
|
+
});
|
|
880
|
+
sock.on("close", () => resolve());
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/executor/firecracker-executor.ts
|
|
885
|
+
var import_child_process3 = require("child_process");
|
|
886
|
+
var import_child_process4 = require("child_process");
|
|
887
|
+
var import_util = require("util");
|
|
888
|
+
var import_http2 = __toESM(require("http"));
|
|
889
|
+
var import_net2 = __toESM(require("net"));
|
|
890
|
+
var import_path5 = __toESM(require("path"));
|
|
891
|
+
var import_promises4 = __toESM(require("fs/promises"));
|
|
892
|
+
var import_os7 = __toESM(require("os"));
|
|
893
|
+
var execAsync = (0, import_util.promisify)(import_child_process4.exec);
|
|
894
|
+
var FirecrackerExecutor = class {
|
|
895
|
+
constructor(config) {
|
|
896
|
+
this.config = config;
|
|
897
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
898
|
+
this.usedSubnets = /* @__PURE__ */ new Set();
|
|
899
|
+
this.tapCounter = 0;
|
|
900
|
+
this.networkReady = false;
|
|
901
|
+
this.bin = config.bin ?? "firecracker";
|
|
902
|
+
this.vmDataPath = config.vmDataPath ?? import_path5.default.join(import_os7.default.tmpdir(), "firecracker-vms");
|
|
903
|
+
}
|
|
904
|
+
allocateSubnet() {
|
|
905
|
+
for (let s = 1; s <= 254; s++) {
|
|
906
|
+
if (!this.usedSubnets.has(s)) {
|
|
907
|
+
this.usedSubnets.add(s);
|
|
908
|
+
return s;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
throw new Error("no free TAP subnets (max 254 concurrent VMs)");
|
|
912
|
+
}
|
|
913
|
+
// Called once before the first VM; sets up host-level IP forwarding + masquerade.
|
|
914
|
+
async ensureNetworking() {
|
|
915
|
+
if (this.networkReady) return;
|
|
916
|
+
await execAsync("sysctl -w net.ipv4.ip_forward=1");
|
|
917
|
+
const alreadySet = await execAsync(
|
|
918
|
+
"iptables -t nat -C POSTROUTING -s 172.16.0.0/12 ! -d 172.16.0.0/12 -j MASQUERADE"
|
|
919
|
+
).catch(() => null);
|
|
920
|
+
if (!alreadySet) {
|
|
921
|
+
await execAsync("iptables -t nat -A POSTROUTING -s 172.16.0.0/12 ! -d 172.16.0.0/12 -j MASQUERADE");
|
|
922
|
+
}
|
|
923
|
+
this.networkReady = true;
|
|
924
|
+
}
|
|
925
|
+
/** Explicit kernel/initrd override wins; otherwise the stack's base image, falling back to generic. */
|
|
926
|
+
resolveImages(stack) {
|
|
927
|
+
if (this.config.kernelPath && this.config.initrdPath) {
|
|
928
|
+
return { kernelPath: this.config.kernelPath, initrdPath: this.config.initrdPath };
|
|
929
|
+
}
|
|
930
|
+
const resolved = this.config.imagesDir ? resolveStackImages(this.config.imagesDir, stack) ?? resolveStackImages(this.config.imagesDir, "generic") : void 0;
|
|
931
|
+
if (!resolved) {
|
|
932
|
+
throw new Error(`no base image for stack "${stack}" under ${this.config.imagesDir} \u2014 run "worker-cli images pull"`);
|
|
933
|
+
}
|
|
934
|
+
return resolved;
|
|
935
|
+
}
|
|
936
|
+
async create(spec) {
|
|
937
|
+
const { kernelPath, initrdPath } = this.resolveImages(spec.stack);
|
|
938
|
+
await this.ensureNetworking();
|
|
939
|
+
const subnet = this.allocateSubnet();
|
|
940
|
+
const tapName = `fctap${++this.tapCounter}`;
|
|
941
|
+
const hostIp = `172.16.${subnet}.1`;
|
|
942
|
+
const guestIp = `172.16.${subnet}.2`;
|
|
943
|
+
const vmDir = import_path5.default.join(this.vmDataPath, spec.vmId);
|
|
944
|
+
await import_promises4.default.mkdir(vmDir, { recursive: true });
|
|
945
|
+
const apiSocketPath = import_path5.default.join(vmDir, "api.sock");
|
|
946
|
+
await import_promises4.default.rm(apiSocketPath, { force: true });
|
|
947
|
+
await execAsync(`ip tuntap add dev ${tapName} mode tap`);
|
|
948
|
+
await execAsync(`ip addr add ${hostIp}/30 dev ${tapName}`);
|
|
949
|
+
await execAsync(`ip link set ${tapName} up`);
|
|
950
|
+
const proc = (0, import_child_process3.spawn)(this.bin, ["--api-sock", apiSocketPath], {
|
|
951
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
952
|
+
});
|
|
953
|
+
proc.stdout?.on("data", (d) => src_default.debug({ vmId: spec.vmId }, d.toString().trimEnd()));
|
|
954
|
+
proc.stderr?.on("data", (d) => src_default.debug({ vmId: spec.vmId }, d.toString().trimEnd()));
|
|
955
|
+
proc.on("exit", (code, signal) => {
|
|
956
|
+
src_default.info({ vmId: spec.vmId, code, signal }, "firecracker process exited");
|
|
957
|
+
const vm = this.vms.get(spec.vmId);
|
|
958
|
+
if (vm) {
|
|
959
|
+
this.usedSubnets.delete(vm.subnet);
|
|
960
|
+
this.vms.delete(spec.vmId);
|
|
961
|
+
}
|
|
962
|
+
execAsync(`ip link del ${tapName}`).catch(() => {
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
await waitForSocket(apiSocketPath);
|
|
966
|
+
const macHex = (n) => n.toString(16).padStart(2, "0");
|
|
967
|
+
await fcRequest(apiSocketPath, "PUT", "/machine-config", {
|
|
968
|
+
vcpu_count: spec.resources.vcpuCount,
|
|
969
|
+
mem_size_mib: spec.resources.memMib
|
|
970
|
+
});
|
|
971
|
+
await fcRequest(apiSocketPath, "PUT", "/boot-source", {
|
|
972
|
+
kernel_image_path: kernelPath,
|
|
973
|
+
initrd_path: initrdPath,
|
|
974
|
+
boot_args: [
|
|
975
|
+
"console=ttyS0",
|
|
976
|
+
"reboot=k",
|
|
977
|
+
"panic=1",
|
|
978
|
+
"pci=off",
|
|
979
|
+
"net.ifnames=0",
|
|
980
|
+
"init=/init",
|
|
981
|
+
`VM_ID=${spec.vmId}`,
|
|
982
|
+
...spec.sessionId ? [`SESSION_ID=${spec.sessionId}`] : [],
|
|
983
|
+
`STACK=${spec.stack}`,
|
|
984
|
+
// base64: kernel cmdline is space-separated, raw presigned URLs are not safe
|
|
985
|
+
...spec.appUrl ? [`APP_URL_B64=${Buffer.from(spec.appUrl).toString("base64")}`] : [],
|
|
986
|
+
`GUEST_IP=${guestIp}`,
|
|
987
|
+
`HOST_IP=${hostIp}`
|
|
988
|
+
].join(" ")
|
|
989
|
+
});
|
|
990
|
+
await fcRequest(apiSocketPath, "PUT", "/network-interfaces/eth0", {
|
|
991
|
+
iface_id: "eth0",
|
|
992
|
+
// AA = locally-administered unicast; encode subnet as last two bytes
|
|
993
|
+
guest_mac: `AA:FC:00:00:${macHex(subnet >> 8)}:${macHex(subnet & 255)}`,
|
|
994
|
+
host_dev_name: tapName
|
|
995
|
+
});
|
|
996
|
+
await fcRequest(apiSocketPath, "PUT", "/actions", { action_type: "InstanceStart" });
|
|
997
|
+
await waitForHttp(guestIp, 8080);
|
|
998
|
+
const metadata = { apiSocketPath, tapName, subnet, guestIp, pid: proc.pid };
|
|
999
|
+
const handle = {
|
|
1000
|
+
vmId: spec.vmId,
|
|
1001
|
+
sessionId: spec.sessionId,
|
|
1002
|
+
stack: spec.stack,
|
|
1003
|
+
resources: spec.resources,
|
|
1004
|
+
running: true,
|
|
1005
|
+
metadata
|
|
1006
|
+
};
|
|
1007
|
+
this.vms.set(spec.vmId, { handle, process: proc, pid: proc.pid, apiSocketPath, tapName, subnet, guestIp });
|
|
1008
|
+
src_default.info({ vmId: spec.vmId, guestIp, tapName }, "firecracker vm ready");
|
|
1009
|
+
return handle;
|
|
1010
|
+
}
|
|
1011
|
+
async pause(vmId) {
|
|
1012
|
+
await this.patchVmState(vmId, "Paused");
|
|
1013
|
+
return this.setRunning(vmId, false);
|
|
1014
|
+
}
|
|
1015
|
+
async resume(vmId) {
|
|
1016
|
+
await this.patchVmState(vmId, "Resumed");
|
|
1017
|
+
return this.setRunning(vmId, true);
|
|
1018
|
+
}
|
|
1019
|
+
async stop(vmId) {
|
|
1020
|
+
await this.patchVmState(vmId, "Paused");
|
|
1021
|
+
return this.setRunning(vmId, false);
|
|
1022
|
+
}
|
|
1023
|
+
async restore(vmId) {
|
|
1024
|
+
await this.patchVmState(vmId, "Resumed");
|
|
1025
|
+
return this.setRunning(vmId, true);
|
|
1026
|
+
}
|
|
1027
|
+
async destroy(vmId) {
|
|
1028
|
+
const vm = this.vms.get(vmId);
|
|
1029
|
+
if (!vm) return;
|
|
1030
|
+
if (vm.process) {
|
|
1031
|
+
const proc = vm.process;
|
|
1032
|
+
proc.kill("SIGKILL");
|
|
1033
|
+
await new Promise((resolve) => {
|
|
1034
|
+
if (proc.exitCode !== null) return resolve();
|
|
1035
|
+
proc.once("exit", resolve);
|
|
1036
|
+
setTimeout(() => {
|
|
1037
|
+
proc.kill("SIGKILL");
|
|
1038
|
+
resolve();
|
|
1039
|
+
}, 3e3);
|
|
1040
|
+
});
|
|
1041
|
+
} else if (vm.pid) {
|
|
1042
|
+
try {
|
|
1043
|
+
process.kill(vm.pid, "SIGKILL");
|
|
1044
|
+
} catch {
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
this.usedSubnets.delete(vm.subnet);
|
|
1048
|
+
this.vms.delete(vmId);
|
|
1049
|
+
await execAsync(`ip link del ${vm.tapName}`).catch(() => {
|
|
1050
|
+
});
|
|
1051
|
+
await import_promises4.default.rm(import_path5.default.join(this.vmDataPath, vmId), { recursive: true, force: true });
|
|
1052
|
+
src_default.info({ vmId }, "firecracker vm destroyed");
|
|
1053
|
+
}
|
|
1054
|
+
get(vmId) {
|
|
1055
|
+
return this.vms.get(vmId)?.handle;
|
|
1056
|
+
}
|
|
1057
|
+
getSerialSource(vmId) {
|
|
1058
|
+
const vm = this.vms.get(vmId);
|
|
1059
|
+
if (!vm?.process) return void 0;
|
|
1060
|
+
return { type: "pipe", process: vm.process };
|
|
1061
|
+
}
|
|
1062
|
+
list() {
|
|
1063
|
+
return [...this.vms.values()].map((v) => v.handle);
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Recover a Firecracker VM after a worker restart: the process may well have survived, so
|
|
1067
|
+
* probe its API socket — if it answers, lifecycle control and HTTP proxying resume
|
|
1068
|
+
* normally. There's no live ChildProcess to reattach to, so destroy() falls back to
|
|
1069
|
+
* killing the stored pid, and the serial console is unavailable (see getSerialSource).
|
|
1070
|
+
*/
|
|
1071
|
+
async reattach(record) {
|
|
1072
|
+
const meta = record.runtimeMetadata;
|
|
1073
|
+
if (!meta?.apiSocketPath || !meta.tapName || meta.subnet === void 0 || !meta.guestIp) {
|
|
1074
|
+
return void 0;
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
await withTimeout(fcRequest(meta.apiSocketPath, "GET", "/", {}), 5e3);
|
|
1078
|
+
} catch {
|
|
1079
|
+
return void 0;
|
|
1080
|
+
}
|
|
1081
|
+
const handle = {
|
|
1082
|
+
vmId: record.vmId,
|
|
1083
|
+
sessionId: record.sessionId,
|
|
1084
|
+
stack: record.stack,
|
|
1085
|
+
resources: record.resources,
|
|
1086
|
+
running: record.state === "running",
|
|
1087
|
+
metadata: meta
|
|
1088
|
+
};
|
|
1089
|
+
this.usedSubnets.add(meta.subnet);
|
|
1090
|
+
const tapNumber = Number(meta.tapName.replace(/^fctap/, ""));
|
|
1091
|
+
if (Number.isFinite(tapNumber)) this.tapCounter = Math.max(this.tapCounter, tapNumber);
|
|
1092
|
+
this.vms.set(record.vmId, {
|
|
1093
|
+
handle,
|
|
1094
|
+
pid: meta.pid,
|
|
1095
|
+
apiSocketPath: meta.apiSocketPath,
|
|
1096
|
+
tapName: meta.tapName,
|
|
1097
|
+
subnet: meta.subnet,
|
|
1098
|
+
guestIp: meta.guestIp
|
|
1099
|
+
});
|
|
1100
|
+
src_default.info({ vmId: record.vmId, guestIp: meta.guestIp, tapName: meta.tapName }, "firecracker vm reattached");
|
|
1101
|
+
return handle;
|
|
1102
|
+
}
|
|
1103
|
+
async proxyHttp(vmId, request) {
|
|
1104
|
+
const vm = this.vms.get(vmId);
|
|
1105
|
+
if (!vm?.handle.running) throw new Error(`vm ${vmId} is not running`);
|
|
1106
|
+
return new Promise((resolve, reject) => {
|
|
1107
|
+
const req = import_http2.default.request(
|
|
1108
|
+
{
|
|
1109
|
+
host: vm.guestIp,
|
|
1110
|
+
port: 8080,
|
|
1111
|
+
method: request.method,
|
|
1112
|
+
path: request.path,
|
|
1113
|
+
headers: request.headers
|
|
1114
|
+
},
|
|
1115
|
+
(res) => {
|
|
1116
|
+
const chunks = [];
|
|
1117
|
+
res.on("data", (c) => chunks.push(c));
|
|
1118
|
+
res.on(
|
|
1119
|
+
"end",
|
|
1120
|
+
() => resolve({
|
|
1121
|
+
statusCode: res.statusCode ?? 502,
|
|
1122
|
+
headers: Object.fromEntries(
|
|
1123
|
+
Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
|
|
1124
|
+
),
|
|
1125
|
+
body: Buffer.concat(chunks)
|
|
1126
|
+
})
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
);
|
|
1130
|
+
req.on("error", reject);
|
|
1131
|
+
if (request.body) req.write(request.body);
|
|
1132
|
+
req.end();
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
async patchVmState(vmId, state) {
|
|
1136
|
+
const vm = this.vms.get(vmId);
|
|
1137
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
1138
|
+
await fcRequest(vm.apiSocketPath, "PATCH", "/vm", { state });
|
|
1139
|
+
}
|
|
1140
|
+
setRunning(vmId, running) {
|
|
1141
|
+
const vm = this.vms.get(vmId);
|
|
1142
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
1143
|
+
vm.handle = { ...vm.handle, running };
|
|
1144
|
+
return vm.handle;
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
function fcRequest(socketPath, method, urlPath, body) {
|
|
1148
|
+
return new Promise((resolve, reject) => {
|
|
1149
|
+
const payload = JSON.stringify(body);
|
|
1150
|
+
const req = import_http2.default.request(
|
|
1151
|
+
{
|
|
1152
|
+
socketPath,
|
|
1153
|
+
method,
|
|
1154
|
+
path: urlPath,
|
|
1155
|
+
headers: {
|
|
1156
|
+
"Content-Type": "application/json",
|
|
1157
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
1158
|
+
Accept: "application/json"
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
(res) => {
|
|
1162
|
+
const chunks = [];
|
|
1163
|
+
res.on("data", (c) => chunks.push(c));
|
|
1164
|
+
res.on("end", () => {
|
|
1165
|
+
const text = Buffer.concat(chunks).toString();
|
|
1166
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
1167
|
+
reject(new Error(`Firecracker ${method} ${urlPath} \u2192 ${res.statusCode}: ${text}`));
|
|
1168
|
+
} else {
|
|
1169
|
+
try {
|
|
1170
|
+
resolve(JSON.parse(text));
|
|
1171
|
+
} catch {
|
|
1172
|
+
resolve({});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
);
|
|
1178
|
+
req.on("error", reject);
|
|
1179
|
+
req.write(payload);
|
|
1180
|
+
req.end();
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
function waitForSocket(socketPath, timeoutMs = 1e4) {
|
|
1184
|
+
return new Promise((resolve, reject) => {
|
|
1185
|
+
const deadline = Date.now() + timeoutMs;
|
|
1186
|
+
const attempt = () => {
|
|
1187
|
+
if (Date.now() > deadline) return reject(new Error(`timed out waiting for Firecracker socket ${socketPath}`));
|
|
1188
|
+
const sock = import_net2.default.createConnection(socketPath);
|
|
1189
|
+
sock.once("connect", () => {
|
|
1190
|
+
sock.destroy();
|
|
1191
|
+
resolve();
|
|
1192
|
+
});
|
|
1193
|
+
sock.once("error", () => setTimeout(attempt, 100));
|
|
1194
|
+
};
|
|
1195
|
+
attempt();
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
function waitForHttp(host, port, timeoutMs = 6e4) {
|
|
1199
|
+
return new Promise((resolve, reject) => {
|
|
1200
|
+
const deadline = Date.now() + timeoutMs;
|
|
1201
|
+
const attempt = () => {
|
|
1202
|
+
if (Date.now() > deadline) return reject(new Error(`timed out waiting for HTTP at ${host}:${port}`));
|
|
1203
|
+
const req = import_http2.default.get(`http://${host}:${port}/health`, (res) => {
|
|
1204
|
+
if (res.statusCode && res.statusCode < 500) return resolve();
|
|
1205
|
+
res.resume();
|
|
1206
|
+
setTimeout(attempt, 1e3);
|
|
1207
|
+
});
|
|
1208
|
+
req.on("error", () => setTimeout(attempt, 1e3));
|
|
1209
|
+
req.setTimeout(2e3, () => {
|
|
1210
|
+
req.destroy();
|
|
1211
|
+
setTimeout(attempt, 1e3);
|
|
1212
|
+
});
|
|
1213
|
+
};
|
|
1214
|
+
attempt();
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// src/executor/docker-executor.ts
|
|
1219
|
+
var import_child_process5 = require("child_process");
|
|
1220
|
+
var import_util2 = require("util");
|
|
1221
|
+
var import_http3 = __toESM(require("http"));
|
|
1222
|
+
var DEFAULT_EXEC_TIMEOUT_MS2 = 6e4;
|
|
1223
|
+
var MAX_EXEC_TIMEOUT_MS2 = 3e5;
|
|
1224
|
+
var MAX_OUTPUT_BYTES2 = 1024 * 1024;
|
|
1225
|
+
var MAX_FILE_BYTES2 = 5 * 1024 * 1024;
|
|
1226
|
+
var WORKSPACE_DIR = "/workspace";
|
|
1227
|
+
var execFileAsync = (0, import_util2.promisify)(import_child_process5.execFile);
|
|
1228
|
+
function assertSafeWorkspacePath(relativePath) {
|
|
1229
|
+
const normalized = relativePath.replace(/^([/\\])+/, "");
|
|
1230
|
+
if (normalized.split(/[/\\]/).includes("..")) {
|
|
1231
|
+
throw new Error(`Path escapes the VM workspace: ${relativePath}`);
|
|
1232
|
+
}
|
|
1233
|
+
return normalized;
|
|
1234
|
+
}
|
|
1235
|
+
var DockerExecutor = class {
|
|
1236
|
+
constructor(config) {
|
|
1237
|
+
this.config = config;
|
|
1238
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
1239
|
+
this.usedPorts = /* @__PURE__ */ new Set();
|
|
1240
|
+
this.bin = config.bin;
|
|
1241
|
+
}
|
|
1242
|
+
allocatePort() {
|
|
1243
|
+
for (let p = this.config.portRangeStart; p <= this.config.portRangeEnd; p++) {
|
|
1244
|
+
if (!this.usedPorts.has(p)) {
|
|
1245
|
+
this.usedPorts.add(p);
|
|
1246
|
+
return p;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
throw new Error("no free ports in docker port range");
|
|
1250
|
+
}
|
|
1251
|
+
async resolveImage(stack) {
|
|
1252
|
+
const explicit = this.config.stackImages[stack];
|
|
1253
|
+
if (explicit) return explicit;
|
|
1254
|
+
const { imagePrefix, imageTag } = this.config;
|
|
1255
|
+
const stackImage = `${imagePrefix}-${stack}:${imageTag}`;
|
|
1256
|
+
if (await this.imageExists(stackImage)) return stackImage;
|
|
1257
|
+
const genericImage = `${imagePrefix}-generic:${imageTag}`;
|
|
1258
|
+
if (await this.imageExists(genericImage)) return genericImage;
|
|
1259
|
+
throw new Error(`no local docker image for stack "${stack}" (${stackImage}) \u2014 run scripts/setup-docker-images.sh`);
|
|
1260
|
+
}
|
|
1261
|
+
async imageExists(image) {
|
|
1262
|
+
try {
|
|
1263
|
+
await execFileAsync(this.bin, ["image", "inspect", image]);
|
|
1264
|
+
return true;
|
|
1265
|
+
} catch {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
async create(spec) {
|
|
1270
|
+
const image = await this.resolveImage(spec.stack);
|
|
1271
|
+
const hostPort = this.allocatePort();
|
|
1272
|
+
const containerName = `multiplayer-vm-${spec.vmId}`;
|
|
1273
|
+
const args = [
|
|
1274
|
+
"run",
|
|
1275
|
+
"-d",
|
|
1276
|
+
"--name",
|
|
1277
|
+
containerName,
|
|
1278
|
+
"-p",
|
|
1279
|
+
`127.0.0.1:${hostPort}:${this.config.guestPort}`,
|
|
1280
|
+
"--memory",
|
|
1281
|
+
`${spec.resources.memMib}m`,
|
|
1282
|
+
"--cpus",
|
|
1283
|
+
String(spec.resources.vcpuCount),
|
|
1284
|
+
"-e",
|
|
1285
|
+
`VM_ID=${spec.vmId}`,
|
|
1286
|
+
"-e",
|
|
1287
|
+
`STACK=${spec.stack}`,
|
|
1288
|
+
...spec.sessionId ? ["-e", `SESSION_ID=${spec.sessionId}`] : [],
|
|
1289
|
+
...spec.appUrl ? ["-e", `APP_URL=${spec.appUrl}`] : [],
|
|
1290
|
+
...this.config.network ? ["--network", this.config.network] : [],
|
|
1291
|
+
image
|
|
1292
|
+
];
|
|
1293
|
+
src_default.info({ vmId: spec.vmId, bin: this.bin, args }, "starting docker container");
|
|
1294
|
+
let containerId;
|
|
1295
|
+
try {
|
|
1296
|
+
const { stdout } = await execFileAsync(this.bin, args);
|
|
1297
|
+
containerId = stdout.trim();
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
this.usedPorts.delete(hostPort);
|
|
1300
|
+
throw error;
|
|
1301
|
+
}
|
|
1302
|
+
const handle = {
|
|
1303
|
+
vmId: spec.vmId,
|
|
1304
|
+
sessionId: spec.sessionId,
|
|
1305
|
+
stack: spec.stack,
|
|
1306
|
+
resources: spec.resources,
|
|
1307
|
+
running: true,
|
|
1308
|
+
metadata: { containerId, containerName, hostPort }
|
|
1309
|
+
};
|
|
1310
|
+
this.vms.set(spec.vmId, { handle, containerId, containerName, hostPort });
|
|
1311
|
+
this.watchExit(spec.vmId, containerId);
|
|
1312
|
+
await this.waitForAgent(hostPort);
|
|
1313
|
+
src_default.info({ vmId: spec.vmId, containerId, hostPort }, "docker vm ready");
|
|
1314
|
+
return handle;
|
|
1315
|
+
}
|
|
1316
|
+
/** Frees the allocated port once the container exits on its own (crash, OOM kill, `docker stop` from outside us). */
|
|
1317
|
+
watchExit(vmId, containerId) {
|
|
1318
|
+
execFileAsync(this.bin, ["wait", containerId]).catch(() => {
|
|
1319
|
+
}).then(() => {
|
|
1320
|
+
const vm = this.vms.get(vmId);
|
|
1321
|
+
if (vm?.containerId === containerId) {
|
|
1322
|
+
vm.handle = { ...vm.handle, running: false };
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
async pause(vmId) {
|
|
1327
|
+
const vm = this.mustGet(vmId);
|
|
1328
|
+
await execFileAsync(this.bin, ["pause", vm.containerId]);
|
|
1329
|
+
return this.setRunning(vmId, false);
|
|
1330
|
+
}
|
|
1331
|
+
async resume(vmId) {
|
|
1332
|
+
const vm = this.mustGet(vmId);
|
|
1333
|
+
await execFileAsync(this.bin, ["unpause", vm.containerId]);
|
|
1334
|
+
return this.setRunning(vmId, true);
|
|
1335
|
+
}
|
|
1336
|
+
async stop(vmId) {
|
|
1337
|
+
const vm = this.mustGet(vmId);
|
|
1338
|
+
await execFileAsync(this.bin, ["stop", vm.containerId]);
|
|
1339
|
+
return this.setRunning(vmId, false);
|
|
1340
|
+
}
|
|
1341
|
+
async restore(vmId) {
|
|
1342
|
+
const vm = this.mustGet(vmId);
|
|
1343
|
+
await execFileAsync(this.bin, ["start", vm.containerId]);
|
|
1344
|
+
return this.setRunning(vmId, true);
|
|
1345
|
+
}
|
|
1346
|
+
async destroy(vmId) {
|
|
1347
|
+
const vm = this.vms.get(vmId);
|
|
1348
|
+
if (!vm) return;
|
|
1349
|
+
await execFileAsync(this.bin, ["rm", "-f", vm.containerId]).catch(
|
|
1350
|
+
(error) => src_default.warn({ vmId, err: error }, "docker rm on destroy failed")
|
|
1351
|
+
);
|
|
1352
|
+
this.usedPorts.delete(vm.hostPort);
|
|
1353
|
+
this.vms.delete(vmId);
|
|
1354
|
+
src_default.info({ vmId }, "docker vm destroyed");
|
|
1355
|
+
}
|
|
1356
|
+
get(vmId) {
|
|
1357
|
+
return this.vms.get(vmId)?.handle;
|
|
1358
|
+
}
|
|
1359
|
+
list() {
|
|
1360
|
+
return [...this.vms.values()].map((v) => v.handle);
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Recover a container after a worker restart: it's an independent process the
|
|
1364
|
+
* Docker daemon owns, so just re-inspect it by ID and rebuild the local handle.
|
|
1365
|
+
*/
|
|
1366
|
+
async reattach(record) {
|
|
1367
|
+
const meta = record.runtimeMetadata;
|
|
1368
|
+
if (!meta?.containerId || meta.hostPort === void 0) return void 0;
|
|
1369
|
+
let running;
|
|
1370
|
+
try {
|
|
1371
|
+
const { stdout } = await execFileAsync(this.bin, ["inspect", "-f", "{{.State.Running}}", meta.containerId]);
|
|
1372
|
+
running = stdout.trim() === "true";
|
|
1373
|
+
} catch {
|
|
1374
|
+
return void 0;
|
|
1375
|
+
}
|
|
1376
|
+
const handle = {
|
|
1377
|
+
vmId: record.vmId,
|
|
1378
|
+
sessionId: record.sessionId,
|
|
1379
|
+
stack: record.stack,
|
|
1380
|
+
resources: record.resources,
|
|
1381
|
+
running,
|
|
1382
|
+
metadata: meta
|
|
1383
|
+
};
|
|
1384
|
+
this.usedPorts.add(meta.hostPort);
|
|
1385
|
+
this.vms.set(record.vmId, {
|
|
1386
|
+
handle,
|
|
1387
|
+
containerId: meta.containerId,
|
|
1388
|
+
containerName: meta.containerName ?? `multiplayer-vm-${record.vmId}`,
|
|
1389
|
+
hostPort: meta.hostPort
|
|
1390
|
+
});
|
|
1391
|
+
if (running) this.watchExit(record.vmId, meta.containerId);
|
|
1392
|
+
src_default.info({ vmId: record.vmId, containerId: meta.containerId }, "docker vm reattached");
|
|
1393
|
+
return handle;
|
|
1394
|
+
}
|
|
1395
|
+
async proxyHttp(vmId, request) {
|
|
1396
|
+
const vm = this.mustGet(vmId);
|
|
1397
|
+
if (!vm.handle.running) throw new Error(`vm ${vmId} is not running`);
|
|
1398
|
+
return new Promise((resolve, reject) => {
|
|
1399
|
+
const req = import_http3.default.request(
|
|
1400
|
+
{ host: "127.0.0.1", port: vm.hostPort, method: request.method, path: request.path, headers: request.headers },
|
|
1401
|
+
(res) => {
|
|
1402
|
+
const chunks = [];
|
|
1403
|
+
res.on("data", (c) => chunks.push(c));
|
|
1404
|
+
res.on(
|
|
1405
|
+
"end",
|
|
1406
|
+
() => resolve({
|
|
1407
|
+
statusCode: res.statusCode ?? 502,
|
|
1408
|
+
headers: Object.fromEntries(
|
|
1409
|
+
Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
|
|
1410
|
+
),
|
|
1411
|
+
body: Buffer.concat(chunks)
|
|
1412
|
+
})
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
);
|
|
1416
|
+
req.on("error", reject);
|
|
1417
|
+
if (request.body) req.write(request.body);
|
|
1418
|
+
req.end();
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
async exec(vmId, request) {
|
|
1422
|
+
const vm = this.mustGet(vmId);
|
|
1423
|
+
const cwd = request.cwd ? `${WORKSPACE_DIR}/${assertSafeWorkspacePath(request.cwd)}` : WORKSPACE_DIR;
|
|
1424
|
+
const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS2, MAX_EXEC_TIMEOUT_MS2);
|
|
1425
|
+
const startedAt = Date.now();
|
|
1426
|
+
return new Promise((resolve) => {
|
|
1427
|
+
(0, import_child_process5.execFile)(
|
|
1428
|
+
this.bin,
|
|
1429
|
+
["exec", "-w", cwd, vm.containerId, "/bin/sh", "-c", request.command],
|
|
1430
|
+
{ timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES2 },
|
|
1431
|
+
(error, stdout, stderr) => {
|
|
1432
|
+
const failed = error;
|
|
1433
|
+
const timedOut = Boolean(failed?.killed);
|
|
1434
|
+
const truncated = failed?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
1435
|
+
resolve({
|
|
1436
|
+
exitCode: typeof failed?.code === "number" ? failed.code : failed ? 1 : 0,
|
|
1437
|
+
stdout: stdout.slice(0, MAX_OUTPUT_BYTES2),
|
|
1438
|
+
stderr: stderr.slice(0, MAX_OUTPUT_BYTES2),
|
|
1439
|
+
durationMs: Date.now() - startedAt,
|
|
1440
|
+
truncated: Boolean(truncated),
|
|
1441
|
+
timedOut
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
);
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
async writeFiles(vmId, files) {
|
|
1448
|
+
const vm = this.mustGet(vmId);
|
|
1449
|
+
const written = [];
|
|
1450
|
+
for (const file of files) {
|
|
1451
|
+
const relativePath = assertSafeWorkspacePath(file.path);
|
|
1452
|
+
const buffer = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8");
|
|
1453
|
+
if (buffer.byteLength > MAX_FILE_BYTES2) {
|
|
1454
|
+
throw new Error(`File too large (${buffer.byteLength} bytes, max ${MAX_FILE_BYTES2}): ${file.path}`);
|
|
1455
|
+
}
|
|
1456
|
+
const dir = relativePath.includes("/") ? relativePath.slice(0, relativePath.lastIndexOf("/")) : "";
|
|
1457
|
+
if (dir) {
|
|
1458
|
+
await execFileAsync(this.bin, ["exec", vm.containerId, "mkdir", "-p", `${WORKSPACE_DIR}/${dir}`]);
|
|
1459
|
+
}
|
|
1460
|
+
await this.copyIntoContainer(vm.containerId, `${WORKSPACE_DIR}/${relativePath}`, buffer);
|
|
1461
|
+
written.push({ path: relativePath, bytes: buffer.byteLength });
|
|
1462
|
+
}
|
|
1463
|
+
src_default.info({ vmId, files: written.length }, "docker vm files written");
|
|
1464
|
+
return { written };
|
|
1465
|
+
}
|
|
1466
|
+
/** `docker cp` needs a tar stream on stdin; build the smallest valid ustar archive for one file. */
|
|
1467
|
+
copyIntoContainer(containerId, destPath, content) {
|
|
1468
|
+
const entryName = destPath.replace(/^\//, "");
|
|
1469
|
+
const tarStream = buildSingleFileTar(entryName, content);
|
|
1470
|
+
return new Promise((resolve, reject) => {
|
|
1471
|
+
const proc = (0, import_child_process5.spawn)(this.bin, ["cp", "-", `${containerId}:/`], { stdio: ["pipe", "ignore", "pipe"] });
|
|
1472
|
+
let stderr = "";
|
|
1473
|
+
proc.stderr?.on("data", (d) => {
|
|
1474
|
+
stderr += d.toString();
|
|
1475
|
+
});
|
|
1476
|
+
proc.on("error", reject);
|
|
1477
|
+
proc.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`docker cp exited ${code}: ${stderr}`)));
|
|
1478
|
+
proc.stdin.end(tarStream);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
mustGet(vmId) {
|
|
1482
|
+
const vm = this.vms.get(vmId);
|
|
1483
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
1484
|
+
return vm;
|
|
1485
|
+
}
|
|
1486
|
+
setRunning(vmId, running) {
|
|
1487
|
+
const vm = this.mustGet(vmId);
|
|
1488
|
+
vm.handle = { ...vm.handle, running };
|
|
1489
|
+
return vm.handle;
|
|
1490
|
+
}
|
|
1491
|
+
async waitForAgent(port, timeoutMs = 3e4) {
|
|
1492
|
+
const deadline = Date.now() + timeoutMs;
|
|
1493
|
+
for (; ; ) {
|
|
1494
|
+
const ok = await new Promise((resolve) => {
|
|
1495
|
+
const req = import_http3.default.get({ host: "127.0.0.1", port, path: "/", timeout: 2e3 }, (res) => {
|
|
1496
|
+
res.resume();
|
|
1497
|
+
resolve(true);
|
|
1498
|
+
});
|
|
1499
|
+
req.on("error", () => resolve(false));
|
|
1500
|
+
req.on("timeout", () => {
|
|
1501
|
+
req.destroy();
|
|
1502
|
+
resolve(false);
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
if (ok) return;
|
|
1506
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for docker vm agent on port ${port}`);
|
|
1507
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
65
1508
|
}
|
|
1509
|
+
}
|
|
1510
|
+
};
|
|
1511
|
+
function buildSingleFileTar(name, content) {
|
|
1512
|
+
const header = Buffer.alloc(512);
|
|
1513
|
+
header.write(name, 0, "utf8");
|
|
1514
|
+
header.write("0000644\0", 100, "utf8");
|
|
1515
|
+
header.write("0000000\0", 108, "utf8");
|
|
1516
|
+
header.write("0000000\0", 116, "utf8");
|
|
1517
|
+
header.write(content.byteLength.toString(8).padStart(11, "0") + "\0", 124, "utf8");
|
|
1518
|
+
header.write(Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0") + "\0", 136, "utf8");
|
|
1519
|
+
header.write(" ", 148, "utf8");
|
|
1520
|
+
header.write("0", 156, "utf8");
|
|
1521
|
+
header.write("ustar\0", 257, "utf8");
|
|
1522
|
+
header.write("00", 263, "utf8");
|
|
1523
|
+
let checksum = 0;
|
|
1524
|
+
for (const byte of header) checksum += byte;
|
|
1525
|
+
header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "utf8");
|
|
1526
|
+
const paddedContentLength = Math.ceil(content.byteLength / 512) * 512;
|
|
1527
|
+
const paddedContent = Buffer.alloc(paddedContentLength);
|
|
1528
|
+
content.copy(paddedContent);
|
|
1529
|
+
return Buffer.concat([header, paddedContent, Buffer.alloc(1024)]);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// src/executor/docker-sandbox-executor.ts
|
|
1533
|
+
var import_child_process6 = require("child_process");
|
|
1534
|
+
var import_util3 = require("util");
|
|
1535
|
+
var import_promises5 = __toESM(require("fs/promises"));
|
|
1536
|
+
var import_os8 = __toESM(require("os"));
|
|
1537
|
+
var import_path6 = __toESM(require("path"));
|
|
1538
|
+
var import_http4 = __toESM(require("http"));
|
|
1539
|
+
var DEFAULT_EXEC_TIMEOUT_MS3 = 6e4;
|
|
1540
|
+
var MAX_EXEC_TIMEOUT_MS3 = 3e5;
|
|
1541
|
+
var MAX_OUTPUT_BYTES3 = 1024 * 1024;
|
|
1542
|
+
var MAX_FILE_BYTES3 = 5 * 1024 * 1024;
|
|
1543
|
+
var WORKSPACE_DIR2 = "/workspace";
|
|
1544
|
+
var AGENT_DIR = "/app";
|
|
1545
|
+
var execFileAsync2 = (0, import_util3.promisify)(import_child_process6.execFile);
|
|
1546
|
+
function assertSafeWorkspacePath2(relativePath) {
|
|
1547
|
+
const normalized = relativePath.replace(/^([/\\])+/, "");
|
|
1548
|
+
if (normalized.split(/[/\\]/).includes("..")) {
|
|
1549
|
+
throw new Error(`Path escapes the VM workspace: ${relativePath}`);
|
|
1550
|
+
}
|
|
1551
|
+
return normalized;
|
|
1552
|
+
}
|
|
1553
|
+
function sandboxNameFor(vmId) {
|
|
1554
|
+
return `mp-${vmId.replace(/[^a-zA-Z0-9.-]/g, "-")}`;
|
|
1555
|
+
}
|
|
1556
|
+
var DockerSandboxExecutor = class {
|
|
1557
|
+
constructor(config) {
|
|
1558
|
+
this.config = config;
|
|
1559
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
1560
|
+
this.usedPorts = /* @__PURE__ */ new Set();
|
|
1561
|
+
this.loadedTemplates = /* @__PURE__ */ new Set();
|
|
1562
|
+
this.bin = config.bin;
|
|
1563
|
+
}
|
|
1564
|
+
allocatePort() {
|
|
1565
|
+
for (let p = this.config.portRangeStart; p <= this.config.portRangeEnd; p++) {
|
|
1566
|
+
if (!this.usedPorts.has(p)) {
|
|
1567
|
+
this.usedPorts.add(p);
|
|
1568
|
+
return p;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
throw new Error("no free ports in docker-sandbox port range");
|
|
1572
|
+
}
|
|
1573
|
+
resolveAgentCommand(stack) {
|
|
1574
|
+
return this.config.stackAgentCommands[stack] ?? this.config.stackAgentCommands.generic ?? "node app.js";
|
|
1575
|
+
}
|
|
1576
|
+
/** Local `docker` image tag for a stack, same resolution order as DockerExecutor.resolveImage. */
|
|
1577
|
+
localImageFor(stack) {
|
|
1578
|
+
const { imagePrefix, imageTag } = this.config;
|
|
1579
|
+
return `${imagePrefix}-${stack}:${imageTag}`;
|
|
1580
|
+
}
|
|
1581
|
+
/** Bridges the local docker image into sbx's own image store once per process; returns its ref. */
|
|
1582
|
+
async ensureTemplateLoaded(stack) {
|
|
1583
|
+
const explicit = this.config.stackTemplates[stack];
|
|
1584
|
+
if (explicit) return explicit;
|
|
1585
|
+
const localImage = this.localImageFor(stack);
|
|
1586
|
+
const templateRef = `docker.io/library/${localImage}`;
|
|
1587
|
+
if (this.loadedTemplates.has(templateRef)) return templateRef;
|
|
1588
|
+
const tmpFile = import_path6.default.join(import_os8.default.tmpdir(), `sbx-template-${stack}-${Date.now()}.tar`);
|
|
1589
|
+
try {
|
|
1590
|
+
await execFileAsync2("docker", ["save", localImage, "-o", tmpFile]);
|
|
1591
|
+
await execFileAsync2(this.bin, ["template", "load", tmpFile]);
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
throw new Error(`failed to bridge local image "${localImage}" into sbx \u2014 run scripts/setup-docker-images.sh: ${error}`);
|
|
1594
|
+
} finally {
|
|
1595
|
+
await import_promises5.default.rm(tmpFile, { force: true });
|
|
1596
|
+
}
|
|
1597
|
+
this.loadedTemplates.add(templateRef);
|
|
1598
|
+
return templateRef;
|
|
1599
|
+
}
|
|
1600
|
+
async create(spec) {
|
|
1601
|
+
const templateRef = await this.ensureTemplateLoaded(spec.stack);
|
|
1602
|
+
const agentCommand = this.resolveAgentCommand(spec.stack);
|
|
1603
|
+
const sandboxName = sandboxNameFor(spec.vmId);
|
|
1604
|
+
const hostPort = this.allocatePort();
|
|
1605
|
+
const vmDir = import_path6.default.join(this.config.workspaceRoot, spec.vmId);
|
|
1606
|
+
await import_promises5.default.mkdir(vmDir, { recursive: true });
|
|
1607
|
+
const memMib = Math.max(spec.resources.memMib, this.config.minMemoryMib);
|
|
1608
|
+
const createArgs = [
|
|
1609
|
+
"create",
|
|
1610
|
+
"shell",
|
|
1611
|
+
vmDir,
|
|
1612
|
+
"--template",
|
|
1613
|
+
templateRef,
|
|
1614
|
+
"--name",
|
|
1615
|
+
sandboxName,
|
|
1616
|
+
"-p",
|
|
1617
|
+
`127.0.0.1:${hostPort}:${this.config.guestPort}`,
|
|
1618
|
+
"-m",
|
|
1619
|
+
`${memMib}m`,
|
|
1620
|
+
"--cpus",
|
|
1621
|
+
String(spec.resources.vcpuCount),
|
|
1622
|
+
"-e",
|
|
1623
|
+
`VM_ID=${spec.vmId}`,
|
|
1624
|
+
"-e",
|
|
1625
|
+
`STACK=${spec.stack}`,
|
|
1626
|
+
...spec.sessionId ? ["-e", `SESSION_ID=${spec.sessionId}`] : [],
|
|
1627
|
+
...spec.appUrl ? ["-e", `APP_URL=${spec.appUrl}`] : []
|
|
1628
|
+
];
|
|
1629
|
+
src_default.info({ vmId: spec.vmId, bin: this.bin, args: createArgs }, "creating docker sandbox");
|
|
1630
|
+
try {
|
|
1631
|
+
await execFileAsync2(this.bin, createArgs);
|
|
1632
|
+
this.launchAgent(sandboxName, agentCommand);
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
this.usedPorts.delete(hostPort);
|
|
1635
|
+
await import_promises5.default.rm(vmDir, { recursive: true, force: true }).catch(() => {
|
|
1636
|
+
});
|
|
1637
|
+
throw error;
|
|
1638
|
+
}
|
|
1639
|
+
const handle = {
|
|
1640
|
+
vmId: spec.vmId,
|
|
1641
|
+
sessionId: spec.sessionId,
|
|
1642
|
+
stack: spec.stack,
|
|
1643
|
+
resources: { ...spec.resources, memMib },
|
|
1644
|
+
running: true,
|
|
1645
|
+
metadata: { sandboxName, hostPort, vmDir, agentCommand }
|
|
1646
|
+
};
|
|
1647
|
+
this.vms.set(spec.vmId, { handle, sandboxName, vmDir, hostPort, agentCommand });
|
|
1648
|
+
await this.waitForAgent(hostPort);
|
|
1649
|
+
src_default.info({ vmId: spec.vmId, sandboxName, hostPort }, "docker sandbox ready");
|
|
1650
|
+
return handle;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* `sbx create shell` doesn't run the image's own CMD — launch the guest agent explicitly.
|
|
1654
|
+
*
|
|
1655
|
+
* Fire-and-forget: `sbx exec -d` (detach) launches the background command correctly but its
|
|
1656
|
+
* own CLI process then hangs indefinitely instead of returning — confirmed live (the agent
|
|
1657
|
+
* was already answering on its port while the CLI call itself sat stuck for 14+ minutes,
|
|
1658
|
+
* needing a kill -9). `waitForAgent`'s port poll is the real readiness signal here, not this
|
|
1659
|
+
* process exiting, so it's spawned detached and never awaited.
|
|
1660
|
+
*/
|
|
1661
|
+
launchAgent(sandboxName, agentCommand) {
|
|
1662
|
+
const proc = (0, import_child_process6.spawn)(this.bin, ["exec", "-d", "-w", AGENT_DIR, sandboxName, "/bin/sh", "-c", agentCommand], {
|
|
1663
|
+
stdio: "ignore"
|
|
1664
|
+
});
|
|
1665
|
+
proc.on("error", (error) => src_default.warn({ sandboxName, err: error }, "sbx exec -d failed to spawn"));
|
|
1666
|
+
proc.unref();
|
|
1667
|
+
}
|
|
1668
|
+
async pause(vmId) {
|
|
1669
|
+
return this.stop(vmId);
|
|
1670
|
+
}
|
|
1671
|
+
async resume(vmId) {
|
|
1672
|
+
return this.restore(vmId);
|
|
1673
|
+
}
|
|
1674
|
+
async stop(vmId) {
|
|
1675
|
+
const vm = this.mustGet(vmId);
|
|
1676
|
+
await execFileAsync2(this.bin, ["stop", vm.sandboxName]);
|
|
1677
|
+
return this.setRunning(vmId, false);
|
|
1678
|
+
}
|
|
1679
|
+
async restore(vmId) {
|
|
1680
|
+
const vm = this.mustGet(vmId);
|
|
1681
|
+
await execFileAsync2(this.bin, ["run", "--name", vm.sandboxName, "--detached"]);
|
|
1682
|
+
const alreadyUp = await this.probeAgent(vm.hostPort);
|
|
1683
|
+
if (!alreadyUp) this.launchAgent(vm.sandboxName, vm.agentCommand);
|
|
1684
|
+
await this.waitForAgent(vm.hostPort);
|
|
1685
|
+
return this.setRunning(vmId, true);
|
|
1686
|
+
}
|
|
1687
|
+
async destroy(vmId) {
|
|
1688
|
+
const vm = this.vms.get(vmId);
|
|
1689
|
+
if (!vm) return;
|
|
1690
|
+
await execFileAsync2(this.bin, ["rm", "-f", vm.sandboxName]).catch(
|
|
1691
|
+
(error) => src_default.warn({ vmId, err: error }, "sbx rm on destroy failed")
|
|
1692
|
+
);
|
|
1693
|
+
await import_promises5.default.rm(vm.vmDir, { recursive: true, force: true }).catch(() => {
|
|
1694
|
+
});
|
|
1695
|
+
this.usedPorts.delete(vm.hostPort);
|
|
1696
|
+
this.vms.delete(vmId);
|
|
1697
|
+
src_default.info({ vmId }, "docker sandbox destroyed");
|
|
1698
|
+
}
|
|
1699
|
+
get(vmId) {
|
|
1700
|
+
return this.vms.get(vmId)?.handle;
|
|
1701
|
+
}
|
|
1702
|
+
list() {
|
|
1703
|
+
return [...this.vms.values()].map((v) => v.handle);
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Recover a sandbox after a worker restart: it's owned by the independent `sandboxd`
|
|
1707
|
+
* daemon, so a liveness probe against its name is enough to confirm it's still there.
|
|
1708
|
+
*/
|
|
1709
|
+
async reattach(record) {
|
|
1710
|
+
const meta = record.runtimeMetadata;
|
|
1711
|
+
if (!meta?.sandboxName || meta.hostPort === void 0 || !meta.vmDir || !meta.agentCommand) return void 0;
|
|
1712
|
+
let running;
|
|
1713
|
+
try {
|
|
1714
|
+
await execFileAsync2(this.bin, ["exec", meta.sandboxName, "true"]);
|
|
1715
|
+
running = true;
|
|
1716
|
+
} catch {
|
|
1717
|
+
try {
|
|
1718
|
+
await execFileAsync2(this.bin, ["ls", "--json"]);
|
|
1719
|
+
running = false;
|
|
1720
|
+
} catch {
|
|
1721
|
+
return void 0;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
const handle = {
|
|
1725
|
+
vmId: record.vmId,
|
|
1726
|
+
sessionId: record.sessionId,
|
|
1727
|
+
stack: record.stack,
|
|
1728
|
+
resources: record.resources,
|
|
1729
|
+
running,
|
|
1730
|
+
metadata: meta
|
|
1731
|
+
};
|
|
1732
|
+
this.usedPorts.add(meta.hostPort);
|
|
1733
|
+
this.vms.set(record.vmId, {
|
|
1734
|
+
handle,
|
|
1735
|
+
sandboxName: meta.sandboxName,
|
|
1736
|
+
vmDir: meta.vmDir,
|
|
1737
|
+
hostPort: meta.hostPort,
|
|
1738
|
+
agentCommand: meta.agentCommand
|
|
1739
|
+
});
|
|
1740
|
+
src_default.info({ vmId: record.vmId, sandboxName: meta.sandboxName }, "docker sandbox reattached");
|
|
1741
|
+
return handle;
|
|
1742
|
+
}
|
|
1743
|
+
async proxyHttp(vmId, request) {
|
|
1744
|
+
const vm = this.mustGet(vmId);
|
|
1745
|
+
if (!vm.handle.running) throw new Error(`vm ${vmId} is not running`);
|
|
1746
|
+
return new Promise((resolve, reject) => {
|
|
1747
|
+
const req = import_http4.default.request(
|
|
1748
|
+
{ host: "127.0.0.1", port: vm.hostPort, method: request.method, path: request.path, headers: request.headers },
|
|
1749
|
+
(res) => {
|
|
1750
|
+
const chunks = [];
|
|
1751
|
+
res.on("data", (c) => chunks.push(c));
|
|
1752
|
+
res.on(
|
|
1753
|
+
"end",
|
|
1754
|
+
() => resolve({
|
|
1755
|
+
statusCode: res.statusCode ?? 502,
|
|
1756
|
+
headers: Object.fromEntries(
|
|
1757
|
+
Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
|
|
1758
|
+
),
|
|
1759
|
+
body: Buffer.concat(chunks)
|
|
1760
|
+
})
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
);
|
|
1764
|
+
req.on("error", reject);
|
|
1765
|
+
if (request.body) req.write(request.body);
|
|
1766
|
+
req.end();
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
async exec(vmId, request) {
|
|
1770
|
+
const vm = this.mustGet(vmId);
|
|
1771
|
+
const cwd = request.cwd ? `${WORKSPACE_DIR2}/${assertSafeWorkspacePath2(request.cwd)}` : WORKSPACE_DIR2;
|
|
1772
|
+
const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS3, MAX_EXEC_TIMEOUT_MS3);
|
|
1773
|
+
const startedAt = Date.now();
|
|
1774
|
+
return new Promise((resolve) => {
|
|
1775
|
+
(0, import_child_process6.execFile)(
|
|
1776
|
+
this.bin,
|
|
1777
|
+
["exec", "-w", cwd, vm.sandboxName, "/bin/sh", "-c", request.command],
|
|
1778
|
+
{ timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES3 },
|
|
1779
|
+
(error, stdout, stderr) => {
|
|
1780
|
+
const failed = error;
|
|
1781
|
+
const timedOut = Boolean(failed?.killed);
|
|
1782
|
+
const truncated = failed?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
1783
|
+
resolve({
|
|
1784
|
+
exitCode: typeof failed?.code === "number" ? failed.code : failed ? 1 : 0,
|
|
1785
|
+
stdout: stdout.slice(0, MAX_OUTPUT_BYTES3),
|
|
1786
|
+
stderr: stderr.slice(0, MAX_OUTPUT_BYTES3),
|
|
1787
|
+
durationMs: Date.now() - startedAt,
|
|
1788
|
+
truncated: Boolean(truncated),
|
|
1789
|
+
timedOut
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
);
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
async writeFiles(vmId, files) {
|
|
1796
|
+
const vm = this.mustGet(vmId);
|
|
1797
|
+
const written = [];
|
|
1798
|
+
for (const file of files) {
|
|
1799
|
+
const relativePath = assertSafeWorkspacePath2(file.path);
|
|
1800
|
+
const buffer = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8");
|
|
1801
|
+
if (buffer.byteLength > MAX_FILE_BYTES3) {
|
|
1802
|
+
throw new Error(`File too large (${buffer.byteLength} bytes, max ${MAX_FILE_BYTES3}): ${file.path}`);
|
|
1803
|
+
}
|
|
1804
|
+
const dir = relativePath.includes("/") ? relativePath.slice(0, relativePath.lastIndexOf("/")) : "";
|
|
1805
|
+
if (dir) {
|
|
1806
|
+
await execFileAsync2(this.bin, ["exec", vm.sandboxName, "mkdir", "-p", `${WORKSPACE_DIR2}/${dir}`]);
|
|
1807
|
+
}
|
|
1808
|
+
const tmpFile = import_path6.default.join(import_os8.default.tmpdir(), `sbx-write-${vmId}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
1809
|
+
await import_promises5.default.writeFile(tmpFile, buffer);
|
|
1810
|
+
try {
|
|
1811
|
+
await execFileAsync2(this.bin, ["cp", tmpFile, `${vm.sandboxName}:${WORKSPACE_DIR2}/${relativePath}`]);
|
|
1812
|
+
} finally {
|
|
1813
|
+
await import_promises5.default.rm(tmpFile, { force: true });
|
|
1814
|
+
}
|
|
1815
|
+
written.push({ path: relativePath, bytes: buffer.byteLength });
|
|
1816
|
+
}
|
|
1817
|
+
src_default.info({ vmId, files: written.length }, "docker sandbox files written");
|
|
1818
|
+
return { written };
|
|
1819
|
+
}
|
|
1820
|
+
mustGet(vmId) {
|
|
1821
|
+
const vm = this.vms.get(vmId);
|
|
1822
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
1823
|
+
return vm;
|
|
1824
|
+
}
|
|
1825
|
+
setRunning(vmId, running) {
|
|
1826
|
+
const vm = this.mustGet(vmId);
|
|
1827
|
+
vm.handle = { ...vm.handle, running };
|
|
1828
|
+
return vm.handle;
|
|
1829
|
+
}
|
|
1830
|
+
async probeAgent(port) {
|
|
1831
|
+
return new Promise((resolve) => {
|
|
1832
|
+
const req = import_http4.default.get({ host: "127.0.0.1", port, path: "/", timeout: 2e3 }, (res) => {
|
|
1833
|
+
res.resume();
|
|
1834
|
+
resolve(true);
|
|
1835
|
+
});
|
|
1836
|
+
req.on("error", () => resolve(false));
|
|
1837
|
+
req.on("timeout", () => {
|
|
1838
|
+
req.destroy();
|
|
1839
|
+
resolve(false);
|
|
1840
|
+
});
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
async waitForAgent(port, timeoutMs = 3e4) {
|
|
1844
|
+
const deadline = Date.now() + timeoutMs;
|
|
1845
|
+
for (; ; ) {
|
|
1846
|
+
if (await this.probeAgent(port)) return;
|
|
1847
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for docker sandbox agent on port ${port}`);
|
|
1848
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
// src/executor/remote-executor.ts
|
|
1854
|
+
var import_http5 = __toESM(require("http"));
|
|
1855
|
+
var import_https = __toESM(require("https"));
|
|
1856
|
+
var RemoteExecutor = class {
|
|
1857
|
+
constructor(config) {
|
|
1858
|
+
// The remote agent is the actual source of truth, but MultiEngineExecutor.route() (used by
|
|
1859
|
+
// pause/resume/stop/restore/destroy/proxyHttp/exec/writeFiles) needs get() to find the
|
|
1860
|
+
// right executor for a vmId — cache the handle each call returns so it does.
|
|
1861
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
1862
|
+
this.base = config.agentUrl.replace(/\/$/, "");
|
|
1863
|
+
this.headers = {
|
|
1864
|
+
"Content-Type": "application/json",
|
|
1865
|
+
...config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {}
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
async create(spec) {
|
|
1869
|
+
const handle = await this.request("POST", "/vms", spec);
|
|
1870
|
+
this.vms.set(spec.vmId, handle);
|
|
1871
|
+
return handle;
|
|
1872
|
+
}
|
|
1873
|
+
async pause(vmId) {
|
|
1874
|
+
return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/pause`));
|
|
1875
|
+
}
|
|
1876
|
+
async resume(vmId) {
|
|
1877
|
+
return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/resume`));
|
|
1878
|
+
}
|
|
1879
|
+
async stop(vmId) {
|
|
1880
|
+
return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/stop`));
|
|
1881
|
+
}
|
|
1882
|
+
async restore(vmId) {
|
|
1883
|
+
return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/restore`));
|
|
1884
|
+
}
|
|
1885
|
+
async destroy(vmId) {
|
|
1886
|
+
await this.request("DELETE", `/vms/${vmId}`);
|
|
1887
|
+
this.vms.delete(vmId);
|
|
1888
|
+
}
|
|
1889
|
+
get(vmId) {
|
|
1890
|
+
return this.vms.get(vmId);
|
|
1891
|
+
}
|
|
1892
|
+
list() {
|
|
1893
|
+
return [...this.vms.values()];
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Recover a remote-agent VM after a worker restart. The agent owns the actual VM state
|
|
1897
|
+
* independently of this process, so reattaching is just confirming it still knows about
|
|
1898
|
+
* this vmId and re-populating the local get()/list() cache above.
|
|
1899
|
+
*/
|
|
1900
|
+
async reattach(record) {
|
|
1901
|
+
try {
|
|
1902
|
+
const handle = await withTimeout(this.request("GET", `/vms/${record.vmId}`), 5e3);
|
|
1903
|
+
this.vms.set(record.vmId, handle);
|
|
1904
|
+
return handle;
|
|
1905
|
+
} catch {
|
|
1906
|
+
return void 0;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
updateCached(vmId, handle) {
|
|
1910
|
+
this.vms.set(vmId, handle);
|
|
1911
|
+
return handle;
|
|
1912
|
+
}
|
|
1913
|
+
async proxyHttp(vmId, request) {
|
|
1914
|
+
const result = await this.request("POST", `/vms/${vmId}/proxy`, {
|
|
1915
|
+
method: request.method,
|
|
1916
|
+
path: request.path,
|
|
1917
|
+
headers: request.headers,
|
|
1918
|
+
body: request.body?.toString("base64")
|
|
1919
|
+
});
|
|
1920
|
+
return {
|
|
1921
|
+
statusCode: result.statusCode,
|
|
1922
|
+
headers: result.headers,
|
|
1923
|
+
body: result.body ? Buffer.from(result.body, "base64") : Buffer.alloc(0)
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
request(method, path7, body) {
|
|
1927
|
+
const url = new URL(this.base + path7);
|
|
1928
|
+
const lib = url.protocol === "https:" ? import_https.default : import_http5.default;
|
|
1929
|
+
const payload = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
1930
|
+
return new Promise((resolve, reject) => {
|
|
1931
|
+
const req = lib.request(
|
|
1932
|
+
{
|
|
1933
|
+
hostname: url.hostname,
|
|
1934
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
1935
|
+
path: url.pathname,
|
|
1936
|
+
method,
|
|
1937
|
+
headers: {
|
|
1938
|
+
...this.headers,
|
|
1939
|
+
...payload ? { "Content-Length": Buffer.byteLength(payload) } : {}
|
|
1940
|
+
}
|
|
1941
|
+
},
|
|
1942
|
+
(res) => {
|
|
1943
|
+
const chunks = [];
|
|
1944
|
+
res.on("data", (c) => chunks.push(c));
|
|
1945
|
+
res.on("end", () => {
|
|
1946
|
+
const text = Buffer.concat(chunks).toString();
|
|
1947
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
1948
|
+
reject(new Error(`vm-agent ${method} ${path7} \u2192 ${res.statusCode}: ${text}`));
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (res.statusCode === 204 || !text) {
|
|
1952
|
+
resolve(void 0);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
try {
|
|
1956
|
+
resolve(JSON.parse(text));
|
|
1957
|
+
} catch {
|
|
1958
|
+
resolve(text);
|
|
1959
|
+
}
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
);
|
|
1963
|
+
req.on("error", reject);
|
|
1964
|
+
if (payload) req.write(payload);
|
|
1965
|
+
req.end();
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
};
|
|
1969
|
+
|
|
1970
|
+
// src/executor/lambda-microvm-executor.ts
|
|
1971
|
+
var import_https2 = __toESM(require("https"));
|
|
1972
|
+
var import_client_lambda_microvms = require("@aws-sdk/client-lambda-microvms");
|
|
1973
|
+
var AUTH_TOKEN_TTL_MINUTES = 60;
|
|
1974
|
+
var AUTH_TOKEN_REFRESH_MARGIN_MS = 2 * 6e4;
|
|
1975
|
+
var LambdaMicrovmExecutor = class {
|
|
1976
|
+
constructor(config) {
|
|
1977
|
+
this.config = config;
|
|
1978
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
1979
|
+
this.client = new import_client_lambda_microvms.LambdaMicrovmsClient(config.region ? { region: config.region } : {});
|
|
1980
|
+
}
|
|
1981
|
+
async create(spec) {
|
|
1982
|
+
const imageIdentifier = this.config.stackImageArns?.[spec.stack] ?? this.config.imageArn;
|
|
1983
|
+
const maxIdle = this.config.maxIdleSeconds ?? 900;
|
|
1984
|
+
const run = await this.client.send(
|
|
1985
|
+
new import_client_lambda_microvms.RunMicrovmCommand({
|
|
1986
|
+
imageIdentifier,
|
|
1987
|
+
ingressNetworkConnectors: this.config.ingressConnectorArns,
|
|
1988
|
+
egressNetworkConnectors: this.config.egressConnectorArns,
|
|
1989
|
+
executionRoleArn: this.config.executionRoleArn,
|
|
1990
|
+
maximumDurationInSeconds: this.config.maximumDurationInSeconds ?? 28800,
|
|
1991
|
+
...maxIdle > 0 ? {
|
|
1992
|
+
idlePolicy: {
|
|
1993
|
+
autoResumeEnabled: true,
|
|
1994
|
+
maxIdleDurationSeconds: maxIdle,
|
|
1995
|
+
suspendedDurationSeconds: this.config.suspendedDurationSeconds ?? 28800
|
|
1996
|
+
}
|
|
1997
|
+
} : {},
|
|
1998
|
+
// The guest's /run lifecycle hook receives this payload — lets the image
|
|
1999
|
+
// identify which builder session/VM it serves.
|
|
2000
|
+
runHookPayload: JSON.stringify({ vmId: spec.vmId, sessionId: spec.sessionId, stack: spec.stack, appUrl: spec.appUrl })
|
|
2001
|
+
})
|
|
2002
|
+
);
|
|
2003
|
+
if (!run.microvmId || !run.endpoint) {
|
|
2004
|
+
throw new Error("run-microvm returned no microvmId/endpoint");
|
|
2005
|
+
}
|
|
2006
|
+
src_default.info(
|
|
2007
|
+
{ vmId: spec.vmId, microvmId: run.microvmId, endpoint: run.endpoint, state: run.state },
|
|
2008
|
+
"[LambdaMicrovm] run-microvm accepted"
|
|
2009
|
+
);
|
|
2010
|
+
const handle = {
|
|
2011
|
+
vmId: spec.vmId,
|
|
2012
|
+
sessionId: spec.sessionId,
|
|
2013
|
+
stack: spec.stack,
|
|
2014
|
+
// Resources are fixed at MicroVM image build time; echo the request back for bookkeeping.
|
|
2015
|
+
resources: spec.resources,
|
|
2016
|
+
running: true,
|
|
2017
|
+
metadata: { microvmId: run.microvmId, endpoint: run.endpoint }
|
|
2018
|
+
};
|
|
2019
|
+
const vm = { handle, microvmId: run.microvmId, endpoint: run.endpoint };
|
|
2020
|
+
this.vms.set(spec.vmId, vm);
|
|
2021
|
+
try {
|
|
2022
|
+
await this.waitForState(run.microvmId, "RUNNING", this.config.createTimeoutMs ?? 25e3);
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
this.vms.delete(spec.vmId);
|
|
2025
|
+
await this.client.send(new import_client_lambda_microvms.TerminateMicrovmCommand({ microvmIdentifier: run.microvmId })).catch(() => {
|
|
2026
|
+
});
|
|
2027
|
+
throw error;
|
|
2028
|
+
}
|
|
2029
|
+
src_default.info({ vmId: spec.vmId, microvmId: run.microvmId }, "[LambdaMicrovm] vm running");
|
|
2030
|
+
return handle;
|
|
2031
|
+
}
|
|
2032
|
+
async pause(vmId) {
|
|
2033
|
+
const vm = this.mustGet(vmId);
|
|
2034
|
+
await this.client.send(new import_client_lambda_microvms.SuspendMicrovmCommand({ microvmIdentifier: vm.microvmId }));
|
|
2035
|
+
return this.setRunning(vmId, false);
|
|
2036
|
+
}
|
|
2037
|
+
async resume(vmId) {
|
|
2038
|
+
const vm = this.mustGet(vmId);
|
|
2039
|
+
await this.client.send(new import_client_lambda_microvms.ResumeMicrovmCommand({ microvmIdentifier: vm.microvmId }));
|
|
2040
|
+
return this.setRunning(vmId, true);
|
|
2041
|
+
}
|
|
2042
|
+
// Suspend preserves memory + disk, so stop/restore are the same checkpoint
|
|
2043
|
+
// operations as pause/resume — there is no separate "powered off" state.
|
|
2044
|
+
stop(vmId) {
|
|
2045
|
+
return this.pause(vmId);
|
|
2046
|
+
}
|
|
2047
|
+
restore(vmId) {
|
|
2048
|
+
return this.resume(vmId);
|
|
2049
|
+
}
|
|
2050
|
+
async destroy(vmId) {
|
|
2051
|
+
const vm = this.vms.get(vmId);
|
|
2052
|
+
if (!vm) return;
|
|
2053
|
+
await this.client.send(new import_client_lambda_microvms.TerminateMicrovmCommand({ microvmIdentifier: vm.microvmId }));
|
|
2054
|
+
this.vms.delete(vmId);
|
|
2055
|
+
src_default.info({ vmId, microvmId: vm.microvmId }, "[LambdaMicrovm] vm terminated");
|
|
2056
|
+
}
|
|
2057
|
+
get(vmId) {
|
|
2058
|
+
return this.vms.get(vmId)?.handle;
|
|
2059
|
+
}
|
|
2060
|
+
list() {
|
|
2061
|
+
return [...this.vms.values()].map((v) => v.handle);
|
|
2062
|
+
}
|
|
2063
|
+
async proxyHttp(vmId, request) {
|
|
2064
|
+
const vm = this.mustGet(vmId);
|
|
2065
|
+
const auth = await this.getAuthHeaders(vm);
|
|
2066
|
+
return new Promise((resolve, reject) => {
|
|
2067
|
+
const req = import_https2.default.request(
|
|
2068
|
+
{
|
|
2069
|
+
host: vm.endpoint,
|
|
2070
|
+
method: request.method,
|
|
2071
|
+
path: request.path,
|
|
2072
|
+
headers: {
|
|
2073
|
+
...request.headers,
|
|
2074
|
+
...auth,
|
|
2075
|
+
// Lambda routes to port 8080 by default; make the guest port explicit.
|
|
2076
|
+
"X-aws-proxy-port": "8080",
|
|
2077
|
+
host: vm.endpoint
|
|
2078
|
+
}
|
|
2079
|
+
},
|
|
2080
|
+
(res) => {
|
|
2081
|
+
const chunks = [];
|
|
2082
|
+
res.on("data", (c) => chunks.push(c));
|
|
2083
|
+
res.on(
|
|
2084
|
+
"end",
|
|
2085
|
+
() => resolve({
|
|
2086
|
+
statusCode: res.statusCode ?? 502,
|
|
2087
|
+
headers: Object.fromEntries(
|
|
2088
|
+
Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
|
|
2089
|
+
),
|
|
2090
|
+
body: Buffer.concat(chunks)
|
|
2091
|
+
})
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
);
|
|
2095
|
+
req.on("error", reject);
|
|
2096
|
+
if (request.body) req.write(request.body);
|
|
2097
|
+
req.end();
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Recover a Lambda MicroVM after a worker restart. It's an independent AWS-managed
|
|
2102
|
+
* resource that outlives this process — just re-describe it by ID and, unless it's been
|
|
2103
|
+
* terminated, rebuild the local handle (the auth token refetches lazily as usual).
|
|
2104
|
+
*/
|
|
2105
|
+
async reattach(record) {
|
|
2106
|
+
const meta = record.runtimeMetadata;
|
|
2107
|
+
if (!meta?.microvmId || !meta.endpoint) return void 0;
|
|
2108
|
+
const described = await this.client.send(new import_client_lambda_microvms.GetMicrovmCommand({ microvmIdentifier: meta.microvmId })).catch(() => void 0);
|
|
2109
|
+
if (!described || described.state === "TERMINATED" || described.state === "TERMINATING") {
|
|
2110
|
+
return void 0;
|
|
2111
|
+
}
|
|
2112
|
+
const handle = {
|
|
2113
|
+
vmId: record.vmId,
|
|
2114
|
+
sessionId: record.sessionId,
|
|
2115
|
+
stack: record.stack,
|
|
2116
|
+
resources: record.resources,
|
|
2117
|
+
running: described.state === "RUNNING",
|
|
2118
|
+
metadata: meta
|
|
2119
|
+
};
|
|
2120
|
+
this.vms.set(record.vmId, { handle, microvmId: meta.microvmId, endpoint: meta.endpoint });
|
|
2121
|
+
src_default.info({ vmId: record.vmId, microvmId: meta.microvmId, state: described.state }, "[LambdaMicrovm] vm reattached");
|
|
2122
|
+
return handle;
|
|
2123
|
+
}
|
|
2124
|
+
mustGet(vmId) {
|
|
2125
|
+
const vm = this.vms.get(vmId);
|
|
2126
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
2127
|
+
return vm;
|
|
2128
|
+
}
|
|
2129
|
+
setRunning(vmId, running) {
|
|
2130
|
+
const vm = this.mustGet(vmId);
|
|
2131
|
+
vm.handle = { ...vm.handle, running };
|
|
2132
|
+
return vm.handle;
|
|
2133
|
+
}
|
|
2134
|
+
async getAuthHeaders(vm) {
|
|
2135
|
+
if (vm.authToken && vm.authToken.expiresAt - Date.now() > AUTH_TOKEN_REFRESH_MARGIN_MS) {
|
|
2136
|
+
return vm.authToken.headers;
|
|
2137
|
+
}
|
|
2138
|
+
const resp = await this.client.send(
|
|
2139
|
+
new import_client_lambda_microvms.CreateMicrovmAuthTokenCommand({
|
|
2140
|
+
microvmIdentifier: vm.microvmId,
|
|
2141
|
+
expirationInMinutes: AUTH_TOKEN_TTL_MINUTES,
|
|
2142
|
+
allowedPorts: [{ allPorts: {} }]
|
|
2143
|
+
})
|
|
2144
|
+
);
|
|
2145
|
+
if (!resp.authToken) throw new Error("create-microvm-auth-token returned no token");
|
|
2146
|
+
vm.authToken = {
|
|
2147
|
+
headers: resp.authToken,
|
|
2148
|
+
expiresAt: Date.now() + AUTH_TOKEN_TTL_MINUTES * 6e4
|
|
2149
|
+
};
|
|
2150
|
+
return vm.authToken.headers;
|
|
2151
|
+
}
|
|
2152
|
+
async waitForState(microvmId, target, timeoutMs) {
|
|
2153
|
+
const deadline = Date.now() + timeoutMs;
|
|
2154
|
+
for (; ; ) {
|
|
2155
|
+
const resp = await this.client.send(new import_client_lambda_microvms.GetMicrovmCommand({ microvmIdentifier: microvmId }));
|
|
2156
|
+
if (resp.state === target) return;
|
|
2157
|
+
if (resp.state === "TERMINATED" || resp.state === "TERMINATING") {
|
|
2158
|
+
throw new Error(`microvm ${microvmId} entered ${resp.state} while waiting for ${target}`);
|
|
2159
|
+
}
|
|
2160
|
+
if (Date.now() > deadline) {
|
|
2161
|
+
throw new Error(`timed out waiting for microvm ${microvmId} to reach ${target} (still ${resp.state})`);
|
|
2162
|
+
}
|
|
2163
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
};
|
|
2167
|
+
|
|
2168
|
+
// src/executor/fargate-executor.ts
|
|
2169
|
+
var import_http6 = __toESM(require("http"));
|
|
2170
|
+
var import_client_ecs = require("@aws-sdk/client-ecs");
|
|
2171
|
+
var FARGATE_MEM_RANGES = {
|
|
2172
|
+
256: [512, 2048],
|
|
2173
|
+
512: [1024, 4096],
|
|
2174
|
+
1024: [2048, 8192],
|
|
2175
|
+
2048: [4096, 16384],
|
|
2176
|
+
4096: [8192, 30720]
|
|
2177
|
+
};
|
|
2178
|
+
function fargateResources(vcpuCount, memMib) {
|
|
2179
|
+
const cpu = Math.min(4096, Math.max(256, vcpuCount * 1024));
|
|
2180
|
+
const [min, max] = FARGATE_MEM_RANGES[cpu];
|
|
2181
|
+
let memory = Math.min(max, Math.max(min, memMib));
|
|
2182
|
+
if (memory > 512) memory = Math.ceil(memory / 1024) * 1024;
|
|
2183
|
+
return { cpu: String(cpu), memory: String(Math.min(max, memory)) };
|
|
2184
|
+
}
|
|
2185
|
+
var FargateExecutor = class {
|
|
2186
|
+
constructor(config) {
|
|
2187
|
+
this.config = config;
|
|
2188
|
+
this.vms = /* @__PURE__ */ new Map();
|
|
2189
|
+
this.containerNames = /* @__PURE__ */ new Map();
|
|
2190
|
+
this.client = new import_client_ecs.ECSClient(config.region ? { region: config.region } : {});
|
|
2191
|
+
}
|
|
2192
|
+
/** First container of the task definition — the target for env overrides. Cached per definition. */
|
|
2193
|
+
async resolveContainerName(taskDefinition) {
|
|
2194
|
+
const cached = this.containerNames.get(taskDefinition);
|
|
2195
|
+
if (cached) return cached;
|
|
2196
|
+
const described = await this.client.send(new import_client_ecs.DescribeTaskDefinitionCommand({ taskDefinition }));
|
|
2197
|
+
const name = described.taskDefinition?.containerDefinitions?.[0]?.name;
|
|
2198
|
+
if (!name) throw new Error(`task definition ${taskDefinition} has no containers`);
|
|
2199
|
+
this.containerNames.set(taskDefinition, name);
|
|
2200
|
+
return name;
|
|
2201
|
+
}
|
|
2202
|
+
async waitForRunning(taskArn) {
|
|
2203
|
+
const timeoutMs = this.config.startTimeoutMs ?? 18e4;
|
|
2204
|
+
const deadline = Date.now() + timeoutMs;
|
|
2205
|
+
for (; ; ) {
|
|
2206
|
+
const described = await this.client.send(
|
|
2207
|
+
new import_client_ecs.DescribeTasksCommand({ cluster: this.config.cluster, tasks: [taskArn] })
|
|
2208
|
+
);
|
|
2209
|
+
const task = described.tasks?.[0];
|
|
2210
|
+
if (!task) throw new Error(`task ${taskArn} not found`);
|
|
2211
|
+
if (task.lastStatus === "STOPPED") {
|
|
2212
|
+
throw new Error(`fargate task stopped: ${task.stoppedReason ?? "unknown reason"}`);
|
|
2213
|
+
}
|
|
2214
|
+
if (task.lastStatus === "RUNNING") {
|
|
2215
|
+
const ip = this.config.assignPublicIp ? await this.resolvePublicIp(task.attachments) : task.containers?.[0]?.networkInterfaces?.[0]?.privateIpv4Address;
|
|
2216
|
+
if (!ip) throw new Error(`fargate task ${taskArn} is RUNNING but has no ${this.config.assignPublicIp ? "public" : "private"} IP`);
|
|
2217
|
+
return ip;
|
|
2218
|
+
}
|
|
2219
|
+
if (Date.now() > deadline) {
|
|
2220
|
+
throw new Error(`fargate task did not reach RUNNING within ${timeoutMs}ms (last status: ${task.lastStatus})`);
|
|
2221
|
+
}
|
|
2222
|
+
await new Promise((resolve) => setTimeout(resolve, 3e3));
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
async resolvePublicIp(attachments) {
|
|
2226
|
+
const eniId = attachments?.find((a) => a.type === "ElasticNetworkInterface")?.details?.find((d) => d.name === "networkInterfaceId")?.value;
|
|
2227
|
+
if (!eniId) return void 0;
|
|
2228
|
+
const { EC2Client, DescribeNetworkInterfacesCommand } = await import("@aws-sdk/client-ec2");
|
|
2229
|
+
const ec2 = new EC2Client(this.config.region ? { region: this.config.region } : {});
|
|
2230
|
+
const described = await ec2.send(new DescribeNetworkInterfacesCommand({ NetworkInterfaceIds: [eniId] }));
|
|
2231
|
+
return described.NetworkInterfaces?.[0]?.Association?.PublicIp;
|
|
2232
|
+
}
|
|
2233
|
+
async create(spec) {
|
|
2234
|
+
const taskDefinition = this.config.stackTaskDefinitions?.[spec.stack] ?? this.config.taskDefinition;
|
|
2235
|
+
if (!taskDefinition) {
|
|
2236
|
+
throw new Error(`no Fargate task definition configured for stack "${spec.stack}"`);
|
|
2237
|
+
}
|
|
2238
|
+
const containerName = await this.resolveContainerName(taskDefinition);
|
|
2239
|
+
const { cpu, memory } = fargateResources(spec.resources.vcpuCount, spec.resources.memMib);
|
|
2240
|
+
const run = await this.client.send(
|
|
2241
|
+
new import_client_ecs.RunTaskCommand({
|
|
2242
|
+
cluster: this.config.cluster,
|
|
2243
|
+
taskDefinition,
|
|
2244
|
+
launchType: "FARGATE",
|
|
2245
|
+
count: 1,
|
|
2246
|
+
networkConfiguration: {
|
|
2247
|
+
awsvpcConfiguration: {
|
|
2248
|
+
subnets: this.config.subnets,
|
|
2249
|
+
securityGroups: this.config.securityGroups,
|
|
2250
|
+
assignPublicIp: this.config.assignPublicIp ? "ENABLED" : "DISABLED"
|
|
2251
|
+
}
|
|
2252
|
+
},
|
|
2253
|
+
overrides: {
|
|
2254
|
+
cpu,
|
|
2255
|
+
memory,
|
|
2256
|
+
containerOverrides: [
|
|
2257
|
+
{
|
|
2258
|
+
name: containerName,
|
|
2259
|
+
environment: [
|
|
2260
|
+
{ name: "VM_ID", value: spec.vmId },
|
|
2261
|
+
...spec.sessionId ? [{ name: "SESSION_ID", value: spec.sessionId }] : [],
|
|
2262
|
+
{ name: "STACK", value: spec.stack },
|
|
2263
|
+
// Release artifact: the container agent pulls, unzips, and starts it
|
|
2264
|
+
...spec.appUrl ? [{ name: "APP_URL", value: spec.appUrl }] : []
|
|
2265
|
+
]
|
|
2266
|
+
}
|
|
2267
|
+
]
|
|
2268
|
+
},
|
|
2269
|
+
tags: [
|
|
2270
|
+
{ key: "vmId", value: spec.vmId },
|
|
2271
|
+
...spec.sessionId ? [{ key: "sessionId", value: spec.sessionId }] : []
|
|
2272
|
+
]
|
|
2273
|
+
})
|
|
2274
|
+
);
|
|
2275
|
+
const taskArn = run.tasks?.[0]?.taskArn;
|
|
2276
|
+
if (!taskArn) {
|
|
2277
|
+
throw new Error(`RunTask failed: ${run.failures?.[0]?.reason ?? "no task returned"}`);
|
|
2278
|
+
}
|
|
2279
|
+
src_default.info({ vmId: spec.vmId, taskArn, taskDefinition, cpu, memory }, "fargate task started");
|
|
2280
|
+
try {
|
|
2281
|
+
const ip = await this.waitForRunning(taskArn);
|
|
2282
|
+
const handle = {
|
|
2283
|
+
vmId: spec.vmId,
|
|
2284
|
+
sessionId: spec.sessionId,
|
|
2285
|
+
stack: spec.stack,
|
|
2286
|
+
resources: spec.resources,
|
|
2287
|
+
running: true,
|
|
2288
|
+
metadata: { taskArn, ip }
|
|
2289
|
+
};
|
|
2290
|
+
this.vms.set(spec.vmId, { handle, taskArn, ip });
|
|
2291
|
+
src_default.info({ vmId: spec.vmId, ip }, "fargate task running");
|
|
2292
|
+
return handle;
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
await this.client.send(new import_client_ecs.StopTaskCommand({ cluster: this.config.cluster, task: taskArn, reason: "startup failed" })).catch(() => {
|
|
2295
|
+
});
|
|
2296
|
+
throw error;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
async pause(vmId) {
|
|
2300
|
+
throw new Error(`fargate tasks cannot be paused (vm ${vmId})`);
|
|
2301
|
+
}
|
|
2302
|
+
async resume(vmId) {
|
|
2303
|
+
throw new Error(`fargate tasks cannot be resumed (vm ${vmId})`);
|
|
2304
|
+
}
|
|
2305
|
+
async restore(vmId) {
|
|
2306
|
+
throw new Error(`fargate tasks cannot be restored (vm ${vmId})`);
|
|
2307
|
+
}
|
|
2308
|
+
async stop(vmId) {
|
|
2309
|
+
const vm = this.mustGet(vmId);
|
|
2310
|
+
await this.client.send(
|
|
2311
|
+
new import_client_ecs.StopTaskCommand({ cluster: this.config.cluster, task: vm.taskArn, reason: "stopped via API" })
|
|
2312
|
+
);
|
|
2313
|
+
vm.handle.running = false;
|
|
2314
|
+
return vm.handle;
|
|
2315
|
+
}
|
|
2316
|
+
async destroy(vmId) {
|
|
2317
|
+
const vm = this.vms.get(vmId);
|
|
2318
|
+
if (!vm) return;
|
|
2319
|
+
await this.client.send(new import_client_ecs.StopTaskCommand({ cluster: this.config.cluster, task: vm.taskArn, reason: "destroyed via API" })).catch((error) => src_default.warn({ vmId, err: error }, "fargate stop on destroy failed"));
|
|
2320
|
+
this.vms.delete(vmId);
|
|
2321
|
+
}
|
|
2322
|
+
get(vmId) {
|
|
2323
|
+
return this.vms.get(vmId)?.handle;
|
|
2324
|
+
}
|
|
2325
|
+
list() {
|
|
2326
|
+
return [...this.vms.values()].map((vm) => vm.handle);
|
|
2327
|
+
}
|
|
2328
|
+
async proxyHttp(vmId, request) {
|
|
2329
|
+
const vm = this.mustGet(vmId);
|
|
2330
|
+
if (!vm.handle.running) throw new Error(`vm ${vmId} is not running`);
|
|
2331
|
+
return new Promise((resolve, reject) => {
|
|
2332
|
+
const req = import_http6.default.request(
|
|
2333
|
+
{
|
|
2334
|
+
host: vm.ip,
|
|
2335
|
+
port: this.config.guestPort ?? 8080,
|
|
2336
|
+
method: request.method,
|
|
2337
|
+
path: request.path,
|
|
2338
|
+
headers: request.headers
|
|
2339
|
+
},
|
|
2340
|
+
(res) => {
|
|
2341
|
+
const chunks = [];
|
|
2342
|
+
res.on("data", (c) => chunks.push(c));
|
|
2343
|
+
res.on(
|
|
2344
|
+
"end",
|
|
2345
|
+
() => resolve({
|
|
2346
|
+
statusCode: res.statusCode ?? 502,
|
|
2347
|
+
headers: Object.fromEntries(
|
|
2348
|
+
Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
|
|
2349
|
+
),
|
|
2350
|
+
body: Buffer.concat(chunks)
|
|
2351
|
+
})
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
);
|
|
2355
|
+
req.on("error", reject);
|
|
2356
|
+
if (request.body) req.write(request.body);
|
|
2357
|
+
req.end();
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* Recover a Fargate task after a worker restart. The task is an independent AWS resource
|
|
2362
|
+
* that outlives this process — just re-describe it by ARN and, if it's still RUNNING,
|
|
2363
|
+
* rebuild the local handle.
|
|
2364
|
+
*/
|
|
2365
|
+
async reattach(record) {
|
|
2366
|
+
const meta = record.runtimeMetadata;
|
|
2367
|
+
if (!meta?.taskArn) return void 0;
|
|
2368
|
+
const described = await this.client.send(new import_client_ecs.DescribeTasksCommand({ cluster: this.config.cluster, tasks: [meta.taskArn] })).catch(() => void 0);
|
|
2369
|
+
const task = described?.tasks?.[0];
|
|
2370
|
+
if (!task || task.lastStatus !== "RUNNING") return void 0;
|
|
2371
|
+
const ip = this.config.assignPublicIp ? await this.resolvePublicIp(task.attachments) : task.containers?.[0]?.networkInterfaces?.[0]?.privateIpv4Address;
|
|
2372
|
+
if (!ip) return void 0;
|
|
2373
|
+
const handle = {
|
|
2374
|
+
vmId: record.vmId,
|
|
2375
|
+
sessionId: record.sessionId,
|
|
2376
|
+
stack: record.stack,
|
|
2377
|
+
resources: record.resources,
|
|
2378
|
+
running: true,
|
|
2379
|
+
metadata: { taskArn: meta.taskArn, ip }
|
|
2380
|
+
};
|
|
2381
|
+
this.vms.set(record.vmId, { handle, taskArn: meta.taskArn, ip });
|
|
2382
|
+
src_default.info({ vmId: record.vmId, taskArn: meta.taskArn, ip }, "fargate task reattached");
|
|
2383
|
+
return handle;
|
|
2384
|
+
}
|
|
2385
|
+
mustGet(vmId) {
|
|
2386
|
+
const vm = this.vms.get(vmId);
|
|
2387
|
+
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
2388
|
+
return vm;
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
|
|
2392
|
+
// src/executor/detect.ts
|
|
2393
|
+
var import_fs2 = __toESM(require("fs"));
|
|
2394
|
+
var import_child_process7 = require("child_process");
|
|
2395
|
+
var ENGINE_PRIORITY = ["firecracker", "qemu", "docker-sandbox", "docker", "lambda-microvm", "fargate", "remote", "mock"];
|
|
2396
|
+
function binaryOnPath(bin) {
|
|
2397
|
+
if (bin.includes("/")) {
|
|
2398
|
+
try {
|
|
2399
|
+
import_fs2.default.accessSync(bin, import_fs2.default.constants.X_OK);
|
|
2400
|
+
return true;
|
|
2401
|
+
} catch {
|
|
2402
|
+
return false;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
try {
|
|
2406
|
+
(0, import_child_process7.execFileSync)("which", [bin], { stdio: "ignore" });
|
|
2407
|
+
return true;
|
|
2408
|
+
} catch {
|
|
2409
|
+
return false;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
function kvmUsable() {
|
|
2413
|
+
try {
|
|
2414
|
+
import_fs2.default.accessSync("/dev/kvm", import_fs2.default.constants.R_OK | import_fs2.default.constants.W_OK);
|
|
2415
|
+
return true;
|
|
2416
|
+
} catch {
|
|
2417
|
+
return false;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
function hasBaseImages(cfg) {
|
|
2421
|
+
if (cfg.kernelPath && cfg.initrdPath) return import_fs2.default.existsSync(cfg.kernelPath) && import_fs2.default.existsSync(cfg.initrdPath);
|
|
2422
|
+
return availableStacks(cfg.imagesDir).length > 0;
|
|
2423
|
+
}
|
|
2424
|
+
function probeQemu(config) {
|
|
2425
|
+
const { bin, accel } = config.qemu;
|
|
2426
|
+
if (!hasBaseImages(config.qemu)) {
|
|
2427
|
+
return { engine: "qemu", available: false, reason: `no base images under ${config.qemu.imagesDir} \u2014 run "worker-cli images pull" or scripts/setup-vm-images.sh` };
|
|
2428
|
+
}
|
|
2429
|
+
if (!binaryOnPath(bin)) {
|
|
2430
|
+
return { engine: "qemu", available: false, reason: `${bin} not found on PATH` };
|
|
2431
|
+
}
|
|
2432
|
+
if (accel === "hvf" && process.platform !== "darwin") {
|
|
2433
|
+
return { engine: "qemu", available: false, reason: "QEMU_ACCEL=hvf requires macOS (use kvm or tcg)" };
|
|
2434
|
+
}
|
|
2435
|
+
if (accel === "kvm" && !kvmUsable()) {
|
|
2436
|
+
return { engine: "qemu", available: false, reason: "QEMU_ACCEL=kvm but /dev/kvm is not accessible" };
|
|
2437
|
+
}
|
|
2438
|
+
return { engine: "qemu", available: true };
|
|
2439
|
+
}
|
|
2440
|
+
function probeFirecracker(config) {
|
|
2441
|
+
const { bin } = config.firecracker;
|
|
2442
|
+
if (process.platform !== "linux") {
|
|
2443
|
+
return { engine: "firecracker", available: false, reason: "requires a Linux host" };
|
|
2444
|
+
}
|
|
2445
|
+
if (!kvmUsable()) {
|
|
2446
|
+
return { engine: "firecracker", available: false, reason: "/dev/kvm is not accessible" };
|
|
2447
|
+
}
|
|
2448
|
+
if (!hasBaseImages(config.firecracker)) {
|
|
2449
|
+
return { engine: "firecracker", available: false, reason: `no base images under ${config.firecracker.imagesDir} \u2014 run "worker-cli images pull" or scripts/setup-vm-images.sh` };
|
|
2450
|
+
}
|
|
2451
|
+
if (!binaryOnPath(bin)) {
|
|
2452
|
+
return { engine: "firecracker", available: false, reason: `${bin} not found on PATH` };
|
|
2453
|
+
}
|
|
2454
|
+
return { engine: "firecracker", available: true };
|
|
2455
|
+
}
|
|
2456
|
+
function hasLocalDockerImage(imagePrefix, imageTag) {
|
|
2457
|
+
return VM_STACKS.some((stack) => {
|
|
2458
|
+
try {
|
|
2459
|
+
(0, import_child_process7.execFileSync)("docker", ["image", "inspect", `${imagePrefix}-${stack}:${imageTag}`], { stdio: "ignore" });
|
|
2460
|
+
return true;
|
|
2461
|
+
} catch {
|
|
2462
|
+
return false;
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
function probeDocker(config) {
|
|
2467
|
+
const { bin, imagePrefix, imageTag } = config.docker;
|
|
2468
|
+
if (!binaryOnPath(bin)) {
|
|
2469
|
+
return { engine: "docker", available: false, reason: `${bin} not found on PATH` };
|
|
2470
|
+
}
|
|
2471
|
+
try {
|
|
2472
|
+
(0, import_child_process7.execFileSync)(bin, ["info"], { stdio: "ignore" });
|
|
2473
|
+
} catch {
|
|
2474
|
+
return { engine: "docker", available: false, reason: "docker daemon not reachable" };
|
|
2475
|
+
}
|
|
2476
|
+
if (!hasLocalDockerImage(imagePrefix, imageTag)) {
|
|
2477
|
+
return { engine: "docker", available: false, reason: "no local docker images \u2014 run scripts/setup-docker-images.sh" };
|
|
2478
|
+
}
|
|
2479
|
+
return { engine: "docker", available: true };
|
|
2480
|
+
}
|
|
2481
|
+
function probeDockerSandbox(config) {
|
|
2482
|
+
const { bin, imagePrefix, imageTag } = config.dockerSandbox;
|
|
2483
|
+
if (!binaryOnPath(bin)) {
|
|
2484
|
+
return { engine: "docker-sandbox", available: false, reason: `${bin} not found on PATH` };
|
|
2485
|
+
}
|
|
2486
|
+
try {
|
|
2487
|
+
(0, import_child_process7.execFileSync)(bin, ["ls"], { stdio: "ignore" });
|
|
2488
|
+
} catch {
|
|
2489
|
+
return { engine: "docker-sandbox", available: false, reason: 'sbx daemon not reachable \u2014 run "sbx daemon start" and "sbx login"' };
|
|
2490
|
+
}
|
|
2491
|
+
if (!hasLocalDockerImage(imagePrefix, imageTag)) {
|
|
2492
|
+
return { engine: "docker-sandbox", available: false, reason: "no local docker images \u2014 run scripts/setup-docker-images.sh" };
|
|
2493
|
+
}
|
|
2494
|
+
return { engine: "docker-sandbox", available: true };
|
|
2495
|
+
}
|
|
2496
|
+
function probeRemote(config) {
|
|
2497
|
+
return config.remote.agentUrl ? { engine: "remote", available: true } : { engine: "remote", available: false, reason: "AGENT_URL not set" };
|
|
2498
|
+
}
|
|
2499
|
+
function probeLambdaMicrovm(config) {
|
|
2500
|
+
const { imageArn, stackImageArns } = config.lambdaMicrovm;
|
|
2501
|
+
return imageArn || Object.keys(stackImageArns).length ? { engine: "lambda-microvm", available: true } : { engine: "lambda-microvm", available: false, reason: "MICROVM_IMAGE_ARN / MICROVM_STACK_IMAGE_ARNS not set" };
|
|
2502
|
+
}
|
|
2503
|
+
function probeFargate(config) {
|
|
2504
|
+
const { cluster, taskDefinition, stackTaskDefinitions, subnets } = config.fargate;
|
|
2505
|
+
if (!cluster) {
|
|
2506
|
+
return { engine: "fargate", available: false, reason: "FARGATE_CLUSTER not set" };
|
|
2507
|
+
}
|
|
2508
|
+
if (!taskDefinition && !Object.keys(stackTaskDefinitions).length) {
|
|
2509
|
+
return { engine: "fargate", available: false, reason: "FARGATE_TASK_DEFINITION / FARGATE_STACK_TASK_DEFS not set" };
|
|
2510
|
+
}
|
|
2511
|
+
if (!subnets.length) {
|
|
2512
|
+
return { engine: "fargate", available: false, reason: "FARGATE_SUBNETS not set" };
|
|
2513
|
+
}
|
|
2514
|
+
return { engine: "fargate", available: true };
|
|
2515
|
+
}
|
|
2516
|
+
function probeEngines(config) {
|
|
2517
|
+
return [
|
|
2518
|
+
{ engine: "mock", available: true },
|
|
2519
|
+
probeQemu(config),
|
|
2520
|
+
probeFirecracker(config),
|
|
2521
|
+
probeDocker(config),
|
|
2522
|
+
probeDockerSandbox(config),
|
|
2523
|
+
probeRemote(config),
|
|
2524
|
+
probeLambdaMicrovm(config),
|
|
2525
|
+
probeFargate(config)
|
|
2526
|
+
];
|
|
2527
|
+
}
|
|
2528
|
+
function detectEngines(config) {
|
|
2529
|
+
const probes = probeEngines(config);
|
|
2530
|
+
for (const probe of probes) {
|
|
2531
|
+
if (probe.available) {
|
|
2532
|
+
src_default.info({ engine: probe.engine }, "micro-VM engine available");
|
|
2533
|
+
} else {
|
|
2534
|
+
src_default.debug({ engine: probe.engine, reason: probe.reason }, "micro-VM engine unavailable");
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
const engines = probes.filter((p) => p.available).map((p) => p.engine);
|
|
2538
|
+
const defaultEngine = ENGINE_PRIORITY.find((engine) => engines.includes(engine)) ?? "mock";
|
|
2539
|
+
return { engines, defaultEngine };
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
// src/executor/index.ts
|
|
2543
|
+
function findNotAttachedVms(executor, records) {
|
|
2544
|
+
return records.filter((record) => !executor.get(record.vmId));
|
|
2545
|
+
}
|
|
2546
|
+
function createEngineExecutor(engine, config) {
|
|
2547
|
+
switch (engine) {
|
|
2548
|
+
case "mock":
|
|
2549
|
+
return new MockExecutor();
|
|
2550
|
+
case "qemu":
|
|
2551
|
+
return new QemuExecutor(config.qemu);
|
|
2552
|
+
case "firecracker":
|
|
2553
|
+
return new FirecrackerExecutor(config.firecracker);
|
|
2554
|
+
case "docker":
|
|
2555
|
+
return new DockerExecutor(config.docker);
|
|
2556
|
+
case "docker-sandbox":
|
|
2557
|
+
return new DockerSandboxExecutor(config.dockerSandbox);
|
|
2558
|
+
case "remote":
|
|
2559
|
+
return new RemoteExecutor(config.remote);
|
|
2560
|
+
case "lambda-microvm":
|
|
2561
|
+
return new LambdaMicrovmExecutor(config.lambdaMicrovm);
|
|
2562
|
+
case "fargate":
|
|
2563
|
+
return new FargateExecutor(config.fargate);
|
|
2564
|
+
default:
|
|
2565
|
+
throw new Error(`unknown engine "${engine}"`);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
var MultiEngineExecutor = class {
|
|
2569
|
+
constructor(executors, defaultEngine) {
|
|
2570
|
+
this.executors = executors;
|
|
2571
|
+
this.defaultEngine = defaultEngine;
|
|
2572
|
+
}
|
|
2573
|
+
get engines() {
|
|
2574
|
+
return [...this.executors.keys()];
|
|
2575
|
+
}
|
|
2576
|
+
async create(spec) {
|
|
2577
|
+
const engine = spec.engine ?? this.defaultEngine;
|
|
2578
|
+
const executor = this.executors.get(engine);
|
|
2579
|
+
if (!executor) {
|
|
2580
|
+
throw new Error(`engine "${engine}" is not available on this worker (available: ${this.engines.join(", ")})`);
|
|
2581
|
+
}
|
|
2582
|
+
const handle = await executor.create(spec);
|
|
2583
|
+
return { ...handle, engine };
|
|
2584
|
+
}
|
|
2585
|
+
pause(vmId) {
|
|
2586
|
+
return this.route(vmId).pause(vmId);
|
|
2587
|
+
}
|
|
2588
|
+
resume(vmId) {
|
|
2589
|
+
return this.route(vmId).resume(vmId);
|
|
2590
|
+
}
|
|
2591
|
+
stop(vmId) {
|
|
2592
|
+
return this.route(vmId).stop(vmId);
|
|
2593
|
+
}
|
|
2594
|
+
restore(vmId) {
|
|
2595
|
+
return this.route(vmId).restore(vmId);
|
|
2596
|
+
}
|
|
2597
|
+
destroy(vmId) {
|
|
2598
|
+
return this.route(vmId).destroy(vmId);
|
|
2599
|
+
}
|
|
2600
|
+
proxyHttp(vmId, request) {
|
|
2601
|
+
return this.route(vmId).proxyHttp(vmId, request);
|
|
2602
|
+
}
|
|
2603
|
+
get(vmId) {
|
|
2604
|
+
for (const executor of this.executors.values()) {
|
|
2605
|
+
const handle = executor.get(vmId);
|
|
2606
|
+
if (handle) return handle;
|
|
2607
|
+
}
|
|
2608
|
+
return void 0;
|
|
2609
|
+
}
|
|
2610
|
+
list() {
|
|
2611
|
+
return [...this.executors.values()].flatMap((executor) => executor.list());
|
|
2612
|
+
}
|
|
2613
|
+
getSerialSource(vmId) {
|
|
2614
|
+
for (const executor of this.executors.values()) {
|
|
2615
|
+
if (executor.get(vmId)) return executor.getSerialSource?.(vmId);
|
|
2616
|
+
}
|
|
2617
|
+
return void 0;
|
|
2618
|
+
}
|
|
2619
|
+
exec(vmId, request) {
|
|
2620
|
+
const executor = this.route(vmId);
|
|
2621
|
+
if (!executor.exec) return Promise.reject(new Error("executor does not support guest exec"));
|
|
2622
|
+
return executor.exec(vmId, request);
|
|
2623
|
+
}
|
|
2624
|
+
writeFiles(vmId, files) {
|
|
2625
|
+
const executor = this.route(vmId);
|
|
2626
|
+
if (!executor.writeFiles) return Promise.reject(new Error("executor does not support guest file writes"));
|
|
2627
|
+
return executor.writeFiles(vmId, files);
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* Recover VMs sandbox says this workerId owns but that aren't currently tracked (a worker
|
|
2631
|
+
* restart, most likely). Runs in parallel — probing many VMs serially would needlessly
|
|
2632
|
+
* delay the first heartbeat, which reports capacity from `list()` and must reflect
|
|
2633
|
+
* reattached VMs to avoid sandbox overbooking this worker. Best-effort: failures are logged,
|
|
2634
|
+
* never thrown — a VM that can't be reattached just stays unavailable until something
|
|
2635
|
+
* touches it again (create/proxy/lifecycle), which already errors gracefully today.
|
|
2636
|
+
*/
|
|
2637
|
+
async reattachAll(records) {
|
|
2638
|
+
const pending = findNotAttachedVms(this, records);
|
|
2639
|
+
if (!pending.length) return;
|
|
2640
|
+
const results = await Promise.allSettled(
|
|
2641
|
+
pending.map(async (record) => {
|
|
2642
|
+
const executor = record.engine ? this.executors.get(record.engine) : void 0;
|
|
2643
|
+
if (!executor) throw new Error(`engine "${record.engine ?? "unknown"}" not available on this worker`);
|
|
2644
|
+
if (!executor.reattach) throw new Error(`engine "${record.engine}" does not support reattach`);
|
|
2645
|
+
const handle = await executor.reattach(record);
|
|
2646
|
+
if (!handle) throw new Error("resource no longer exists");
|
|
2647
|
+
})
|
|
2648
|
+
);
|
|
2649
|
+
results.forEach((result, i) => {
|
|
2650
|
+
const vmId = pending[i].vmId;
|
|
2651
|
+
if (result.status === "fulfilled") src_default.info({ vmId }, "vm reattached");
|
|
2652
|
+
else src_default.warn({ vmId, err: result.reason }, "vm reattach failed \u2014 staying unavailable until next touch");
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
route(vmId) {
|
|
2656
|
+
for (const executor of this.executors.values()) {
|
|
2657
|
+
if (executor.get(vmId)) return executor;
|
|
2658
|
+
}
|
|
2659
|
+
throw new Error(`vm ${vmId} not found on this worker`);
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
function createExecutor(config) {
|
|
2663
|
+
const { engines, defaultEngine } = detectEngines(config);
|
|
2664
|
+
const executors = /* @__PURE__ */ new Map();
|
|
2665
|
+
for (const engine of engines) {
|
|
2666
|
+
executors.set(engine, createEngineExecutor(engine, config));
|
|
2667
|
+
}
|
|
2668
|
+
src_default.info({ engines, defaultEngine }, "micro-VM engines initialized");
|
|
2669
|
+
return new MultiEngineExecutor(executors, defaultEngine);
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// src/socket-client.ts
|
|
2673
|
+
var import_os9 = __toESM(require("os"));
|
|
2674
|
+
var import_socket = require("socket.io-client");
|
|
2675
|
+
var HEARTBEAT_INTERVAL_MS = 1e4;
|
|
2676
|
+
var REATTACH_FALLBACK_MS = 5e3;
|
|
2677
|
+
function primaryIp() {
|
|
2678
|
+
for (const ifaces of Object.values(import_os9.default.networkInterfaces())) {
|
|
2679
|
+
for (const iface of ifaces ?? []) {
|
|
2680
|
+
if (!iface.internal && iface.family === "IPv4") return iface.address;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
return void 0;
|
|
2684
|
+
}
|
|
2685
|
+
function buildHeartbeat(config, executor) {
|
|
2686
|
+
return {
|
|
2687
|
+
hostname: import_os9.default.hostname(),
|
|
2688
|
+
ip: primaryIp(),
|
|
2689
|
+
region: config.region,
|
|
2690
|
+
version,
|
|
2691
|
+
engines: executor.engines,
|
|
2692
|
+
defaultEngine: executor.defaultEngine,
|
|
2693
|
+
capacity: {
|
|
2694
|
+
maxVms: config.maxVms,
|
|
2695
|
+
usedVms: executor.list().length,
|
|
2696
|
+
cpuCores: import_os9.default.cpus().length,
|
|
2697
|
+
memMib: Math.round(import_os9.default.totalmem() / (1024 * 1024))
|
|
2698
|
+
}
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
function wrap(handler) {
|
|
2702
|
+
return (payload, callback) => {
|
|
2703
|
+
handler(payload).then((result) => callback({ ok: true, result })).catch((error) => {
|
|
2704
|
+
src_default.error({ err: error }, "command failed");
|
|
2705
|
+
callback({ ok: false, error: error.message });
|
|
2706
|
+
});
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
function registerCommandHandlers(socket, executor) {
|
|
2710
|
+
socket.on(WorkerEvent.VM_CREATE, wrap((spec) => executor.create(spec)));
|
|
2711
|
+
socket.on(WorkerEvent.VM_PAUSE, wrap(({ vmId }) => executor.pause(vmId)));
|
|
2712
|
+
socket.on(WorkerEvent.VM_RESUME, wrap(({ vmId }) => executor.resume(vmId)));
|
|
2713
|
+
socket.on(WorkerEvent.VM_STOP, wrap(({ vmId }) => executor.stop(vmId)));
|
|
2714
|
+
socket.on(WorkerEvent.VM_RESTORE, wrap(({ vmId }) => executor.restore(vmId)));
|
|
2715
|
+
socket.on(WorkerEvent.VM_DESTROY, wrap(({ vmId }) => executor.destroy(vmId)));
|
|
2716
|
+
socket.on(
|
|
2717
|
+
WorkerEvent.PROXY_HTTP,
|
|
2718
|
+
wrap(
|
|
2719
|
+
({ vmId, ...request }) => executor.proxyHttp(vmId, request)
|
|
2720
|
+
)
|
|
2721
|
+
);
|
|
2722
|
+
socket.on(
|
|
2723
|
+
WorkerEvent.VM_EXEC,
|
|
2724
|
+
wrap(({ vmId, ...request }) => {
|
|
2725
|
+
if (!executor.exec) {
|
|
2726
|
+
return Promise.reject(new Error("executor does not support guest exec"));
|
|
2727
|
+
}
|
|
2728
|
+
return executor.exec(vmId, request);
|
|
2729
|
+
})
|
|
2730
|
+
);
|
|
2731
|
+
socket.on(
|
|
2732
|
+
WorkerEvent.VM_WRITE_FILES,
|
|
2733
|
+
wrap(({ vmId, files }) => {
|
|
2734
|
+
if (!executor.writeFiles) {
|
|
2735
|
+
return Promise.reject(new Error("executor does not support guest file writes"));
|
|
2736
|
+
}
|
|
2737
|
+
return executor.writeFiles(vmId, files);
|
|
2738
|
+
})
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
function connectToSandbox(config, executor) {
|
|
2742
|
+
const socket = (0, import_socket.io)(config.sandboxUrl, {
|
|
2743
|
+
path: config.workerSocketPath,
|
|
2744
|
+
auth: { workerId: config.workerId, token: config.token },
|
|
2745
|
+
transports: ["websocket"],
|
|
2746
|
+
reconnection: true
|
|
2747
|
+
});
|
|
2748
|
+
let heartbeatTimer;
|
|
2749
|
+
let reattachFallbackTimer;
|
|
2750
|
+
const sendHeartbeat = () => socket.emit(WorkerEvent.HEARTBEAT, buildHeartbeat(config, executor));
|
|
2751
|
+
function startHeartbeat() {
|
|
2752
|
+
if (heartbeatTimer) return;
|
|
2753
|
+
sendHeartbeat();
|
|
2754
|
+
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
2755
|
+
}
|
|
2756
|
+
socket.on("connect", () => {
|
|
2757
|
+
src_default.info({ workerId: config.workerId, url: config.sandboxUrl }, "connected to sandbox");
|
|
2758
|
+
socket.off(WorkerEvent.REATTACH);
|
|
2759
|
+
reattachFallbackTimer = setTimeout(() => {
|
|
2760
|
+
src_default.warn({ workerId: config.workerId }, "no reattach handshake from sandbox \u2014 starting heartbeat anyway");
|
|
2761
|
+
startHeartbeat();
|
|
2762
|
+
}, REATTACH_FALLBACK_MS);
|
|
2763
|
+
socket.once(WorkerEvent.REATTACH, (records) => {
|
|
2764
|
+
clearTimeout(reattachFallbackTimer);
|
|
2765
|
+
executor.reattachAll(records).catch((error) => src_default.error({ err: error }, "reattach batch failed")).finally(startHeartbeat);
|
|
2766
|
+
});
|
|
2767
|
+
});
|
|
2768
|
+
socket.on("disconnect", (reason) => {
|
|
2769
|
+
src_default.warn({ workerId: config.workerId, reason }, "disconnected from sandbox");
|
|
2770
|
+
clearInterval(heartbeatTimer);
|
|
2771
|
+
clearTimeout(reattachFallbackTimer);
|
|
2772
|
+
heartbeatTimer = void 0;
|
|
2773
|
+
socket.off(WorkerEvent.REATTACH);
|
|
2774
|
+
});
|
|
2775
|
+
socket.on("connect_error", (error) => {
|
|
2776
|
+
src_default.error({ err: error }, "sandbox connection error");
|
|
2777
|
+
});
|
|
2778
|
+
registerCommandHandlers(socket, executor);
|
|
2779
|
+
return socket;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// src/services/vnc-relay.ts
|
|
2783
|
+
var import_rfb2 = __toESM(require("rfb2"));
|
|
2784
|
+
function bgraToRgba(src) {
|
|
2785
|
+
const dst = Buffer.allocUnsafe(src.length);
|
|
2786
|
+
for (let i = 0; i < src.length; i += 4) {
|
|
2787
|
+
dst[i] = src[i + 2];
|
|
2788
|
+
dst[i + 1] = src[i + 1];
|
|
2789
|
+
dst[i + 2] = src[i];
|
|
2790
|
+
dst[i + 3] = 255;
|
|
2791
|
+
}
|
|
2792
|
+
return dst;
|
|
2793
|
+
}
|
|
2794
|
+
var VncRelay = class {
|
|
2795
|
+
constructor(vmId, host, port, socket) {
|
|
2796
|
+
this.vmId = vmId;
|
|
2797
|
+
this.host = host;
|
|
2798
|
+
this.port = port;
|
|
2799
|
+
this.socket = socket;
|
|
2800
|
+
this.rfbc = null;
|
|
2801
|
+
this.interval = null;
|
|
2802
|
+
this.mouseHandler = null;
|
|
2803
|
+
this.keyHandler = null;
|
|
2804
|
+
}
|
|
2805
|
+
start() {
|
|
2806
|
+
this.rfbc = import_rfb2.default.createConnection({ host: this.host, port: this.port, password: "" });
|
|
2807
|
+
const rfbc = this.rfbc;
|
|
2808
|
+
rfbc.on("connect", () => {
|
|
2809
|
+
rfbc.autoUpdate = true;
|
|
2810
|
+
this.socket.emit(WorkerEvent.VNC_INIT, { vmId: this.vmId, width: rfbc.width, height: rfbc.height });
|
|
2811
|
+
this.interval = setInterval(() => {
|
|
2812
|
+
rfbc.requestUpdate(false, 0, 0, rfbc.width, rfbc.height);
|
|
2813
|
+
}, 300);
|
|
2814
|
+
});
|
|
2815
|
+
rfbc.on("rect", (rect) => {
|
|
2816
|
+
switch (rect.encoding) {
|
|
2817
|
+
case import_rfb2.default.encodings.raw: {
|
|
2818
|
+
const frame = {
|
|
2819
|
+
vmId: this.vmId,
|
|
2820
|
+
x: rect.x,
|
|
2821
|
+
y: rect.y,
|
|
2822
|
+
width: rect.width,
|
|
2823
|
+
height: rect.height,
|
|
2824
|
+
encoding: "raw",
|
|
2825
|
+
data: bgraToRgba(rect.data)
|
|
2826
|
+
};
|
|
2827
|
+
this.socket.emit(WorkerEvent.VNC_FRAME, frame);
|
|
2828
|
+
break;
|
|
2829
|
+
}
|
|
2830
|
+
case import_rfb2.default.encodings.copyRect: {
|
|
2831
|
+
const frame = {
|
|
2832
|
+
vmId: this.vmId,
|
|
2833
|
+
x: rect.x,
|
|
2834
|
+
y: rect.y,
|
|
2835
|
+
src: rect.src,
|
|
2836
|
+
width: rect.width,
|
|
2837
|
+
height: rect.height
|
|
2838
|
+
};
|
|
2839
|
+
this.socket.emit(WorkerEvent.VNC_COPY_FRAME, frame);
|
|
2840
|
+
break;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
});
|
|
2844
|
+
rfbc.on("resize", (rect) => {
|
|
2845
|
+
src_default.info({ vmId: this.vmId, width: rect.width, height: rect.height }, "[VNC] desktop resized");
|
|
2846
|
+
this.socket.emit(WorkerEvent.VNC_INIT, { vmId: this.vmId, width: rect.width, height: rect.height });
|
|
2847
|
+
rfbc.requestUpdate(false, 0, 0, rect.width, rect.height);
|
|
2848
|
+
});
|
|
2849
|
+
rfbc.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[VNC] RFB error"));
|
|
2850
|
+
this.mouseHandler = (e) => {
|
|
2851
|
+
if (e.vmId === this.vmId) rfbc.pointerEvent(e.x, e.y, e.button);
|
|
2852
|
+
};
|
|
2853
|
+
this.keyHandler = (e) => {
|
|
2854
|
+
if (e.vmId === this.vmId) rfbc.keyEvent(e.keyCode, e.isDown);
|
|
2855
|
+
};
|
|
2856
|
+
this.socket.on(WorkerEvent.VNC_MOUSE, this.mouseHandler);
|
|
2857
|
+
this.socket.on(WorkerEvent.VNC_KEYBOARD, this.keyHandler);
|
|
2858
|
+
src_default.info({ vmId: this.vmId, port: this.port }, "[VNC] relay started");
|
|
2859
|
+
}
|
|
2860
|
+
destroy() {
|
|
2861
|
+
if (this.interval) clearInterval(this.interval);
|
|
2862
|
+
if (this.mouseHandler) this.socket.off(WorkerEvent.VNC_MOUSE, this.mouseHandler);
|
|
2863
|
+
if (this.keyHandler) this.socket.off(WorkerEvent.VNC_KEYBOARD, this.keyHandler);
|
|
2864
|
+
try {
|
|
2865
|
+
this.rfbc?.end();
|
|
2866
|
+
} catch {
|
|
2867
|
+
}
|
|
2868
|
+
this.rfbc = null;
|
|
2869
|
+
src_default.info({ vmId: this.vmId }, "[VNC] relay destroyed");
|
|
2870
|
+
}
|
|
2871
|
+
};
|
|
2872
|
+
|
|
2873
|
+
// src/services/serial-relay.ts
|
|
2874
|
+
var import_net3 = __toESM(require("net"));
|
|
2875
|
+
var SerialRelay = class {
|
|
2876
|
+
constructor(vmId, source, socket) {
|
|
2877
|
+
this.vmId = vmId;
|
|
2878
|
+
this.source = source;
|
|
2879
|
+
this.socket = socket;
|
|
2880
|
+
this.conn = null;
|
|
2881
|
+
this.inputHandler = null;
|
|
2882
|
+
}
|
|
2883
|
+
connect() {
|
|
2884
|
+
if (this.source.type === "tcp") {
|
|
2885
|
+
this.connectTcp();
|
|
2886
|
+
} else {
|
|
2887
|
+
this.connectPipe();
|
|
2888
|
+
}
|
|
2889
|
+
this.inputHandler = (e) => {
|
|
2890
|
+
if (e.vmId !== this.vmId) return;
|
|
2891
|
+
if (this.source.type === "tcp" && this.conn) {
|
|
2892
|
+
this.conn.write(e.data);
|
|
2893
|
+
} else if (this.source.type === "pipe") {
|
|
2894
|
+
const proc = this.source.process;
|
|
2895
|
+
proc.stdin?.write(e.data);
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
this.socket.on(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
|
|
2899
|
+
}
|
|
2900
|
+
connectTcp() {
|
|
2901
|
+
const { host, port } = this.source;
|
|
2902
|
+
const attempt = () => {
|
|
2903
|
+
const sock = import_net3.default.createConnection(port, host);
|
|
2904
|
+
sock.once("connect", () => {
|
|
2905
|
+
this.conn = sock;
|
|
2906
|
+
src_default.info({ vmId: this.vmId, port }, "[Serial] TCP connected");
|
|
2907
|
+
sock.on("data", (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d }));
|
|
2908
|
+
sock.on("end", () => src_default.info({ vmId: this.vmId }, "[Serial] TCP ended"));
|
|
2909
|
+
sock.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[Serial] TCP error"));
|
|
2910
|
+
});
|
|
2911
|
+
sock.once("error", () => setTimeout(attempt, 1e3));
|
|
2912
|
+
};
|
|
2913
|
+
attempt();
|
|
2914
|
+
}
|
|
2915
|
+
connectPipe() {
|
|
2916
|
+
const { process: proc } = this.source;
|
|
2917
|
+
proc.stdout?.on("data", (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d }));
|
|
2918
|
+
proc.stderr?.on("data", (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d }));
|
|
2919
|
+
}
|
|
2920
|
+
destroy() {
|
|
2921
|
+
if (this.inputHandler) this.socket.off(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
|
|
2922
|
+
try {
|
|
2923
|
+
this.conn?.destroy();
|
|
2924
|
+
} catch {
|
|
2925
|
+
}
|
|
2926
|
+
this.conn = null;
|
|
2927
|
+
src_default.info({ vmId: this.vmId }, "[Serial] relay destroyed");
|
|
2928
|
+
}
|
|
2929
|
+
};
|
|
2930
|
+
|
|
2931
|
+
// src/console-relays.ts
|
|
2932
|
+
function attachConsoleRelays(executor, sandboxSocket) {
|
|
2933
|
+
const relays = /* @__PURE__ */ new Map();
|
|
2934
|
+
const originalCreate = executor.create.bind(executor);
|
|
2935
|
+
executor.create = async (spec) => {
|
|
2936
|
+
const handle = await originalCreate(spec);
|
|
2937
|
+
const entry = {};
|
|
2938
|
+
if (handle.vncPort) {
|
|
2939
|
+
const vnc = new VncRelay(handle.vmId, "127.0.0.1", handle.vncPort, sandboxSocket);
|
|
2940
|
+
vnc.start();
|
|
2941
|
+
entry.vnc = vnc;
|
|
2942
|
+
}
|
|
2943
|
+
const serialSource = handle.serialPort ? { type: "tcp", host: "127.0.0.1", port: handle.serialPort } : executor.getSerialSource?.(handle.vmId);
|
|
2944
|
+
if (serialSource) {
|
|
2945
|
+
const serial = new SerialRelay(handle.vmId, serialSource, sandboxSocket);
|
|
2946
|
+
serial.connect();
|
|
2947
|
+
entry.serial = serial;
|
|
2948
|
+
}
|
|
2949
|
+
if (entry.vnc || entry.serial) relays.set(handle.vmId, entry);
|
|
2950
|
+
return handle;
|
|
2951
|
+
};
|
|
2952
|
+
const originalDestroy = executor.destroy.bind(executor);
|
|
2953
|
+
executor.destroy = async (vmId) => {
|
|
2954
|
+
const entry = relays.get(vmId);
|
|
2955
|
+
entry?.vnc?.destroy();
|
|
2956
|
+
entry?.serial?.destroy();
|
|
2957
|
+
relays.delete(vmId);
|
|
2958
|
+
return originalDestroy(vmId);
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// src/cli.ts
|
|
2963
|
+
var program = new import_commander.Command();
|
|
2964
|
+
program.name("sandbox").description("Run and manage a Multiplayer sandbox worker").version(version);
|
|
2965
|
+
program.command("start").description("start a worker and connect it to the sandbox control plane").option("--token <token>", "worker authentication token (env: WORKER_TOKEN)").option("--worker-id <id>", "stable worker ID (env: WORKER_ID; default: hostname)").option("--sandbox-url <url>", "control-plane URL (env: SANDBOX_URL; default: https://api.sandbox.filingramp.com)").option("--max-vms <count>", "maximum concurrent VMs (env: MAX_VMS)").action(async (opts) => {
|
|
2966
|
+
const config = loadWorkerConfigFromEnv({
|
|
2967
|
+
...process.env,
|
|
2968
|
+
...opts.token ? { WORKER_TOKEN: opts.token } : {},
|
|
2969
|
+
...opts.workerId ? { WORKER_ID: opts.workerId } : {},
|
|
2970
|
+
...opts.sandboxUrl ? { SANDBOX_URL: opts.sandboxUrl } : {},
|
|
2971
|
+
...opts.maxVms ? { MAX_VMS: opts.maxVms } : {}
|
|
2972
|
+
});
|
|
2973
|
+
const executor = createExecutor(config);
|
|
2974
|
+
const socket = connectToSandbox(config, executor);
|
|
2975
|
+
attachConsoleRelays(executor, socket);
|
|
2976
|
+
let stopping = false;
|
|
2977
|
+
const stop = async (signal) => {
|
|
2978
|
+
if (stopping) return;
|
|
2979
|
+
stopping = true;
|
|
2980
|
+
console.log(`
|
|
2981
|
+
${signal} received; shutting down sandbox worker...`);
|
|
2982
|
+
socket.disconnect();
|
|
2983
|
+
const results = await Promise.allSettled(executor.list().map((vm) => executor.destroy(vm.vmId)));
|
|
2984
|
+
const failed = results.filter((result) => result.status === "rejected");
|
|
2985
|
+
if (failed.length) console.error(`Failed to destroy ${failed.length} VM(s) during shutdown`);
|
|
2986
|
+
process.exit(failed.length ? 1 : 0);
|
|
2987
|
+
};
|
|
2988
|
+
process.once("SIGINT", () => {
|
|
2989
|
+
void stop("SIGINT");
|
|
2990
|
+
});
|
|
2991
|
+
process.once("SIGTERM", () => {
|
|
2992
|
+
void stop("SIGTERM");
|
|
2993
|
+
});
|
|
2994
|
+
console.log(`Starting sandbox worker ${config.workerId} \u2192 ${config.sandboxUrl}`);
|
|
2995
|
+
});
|
|
2996
|
+
program.command("detect").description("probe this host and report which micro-VM engines it can run").action(() => {
|
|
2997
|
+
const probes = probeEngines(loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" }));
|
|
2998
|
+
for (const probe of probes) {
|
|
2999
|
+
const status = probe.available ? "available" : `unavailable \u2014 ${probe.reason}`;
|
|
3000
|
+
console.log(`${probe.engine.padEnd(15)} ${status}`);
|
|
3001
|
+
}
|
|
66
3002
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.requiredOption('--from <location>', 's3://bucket[/prefix] or https:// base URL (env: VM_IMAGES_URL)', process.env.VM_IMAGES_URL)
|
|
74
|
-
.option('--arch <arch>', 'image architecture', (0, images_1.hostArch)())
|
|
75
|
-
.option('--stack <stack...>', 'stacks to pull (default: all)')
|
|
76
|
-
.option('--dest <dir>', 'destination directory (default: ~/.multiplayer/vm-images/<arch>)')
|
|
77
|
-
.option('--endpoint <url>', 'custom S3 endpoint, e.g. a MinIO URL (env: VM_IMAGES_ENDPOINT)', process.env.VM_IMAGES_ENDPOINT)
|
|
78
|
-
.action(async (opts) => {
|
|
79
|
-
var _a;
|
|
80
|
-
const dest = (_a = opts.dest) !== null && _a !== void 0 ? _a : (0, images_1.defaultImagesDir)(opts.arch);
|
|
81
|
-
const files = await (0, images_1.pullImages)({ location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, dest);
|
|
82
|
-
for (const file of files)
|
|
83
|
-
console.log(`pulled ${file}`);
|
|
84
|
-
console.log('\nbase images ready — "sandbox detect" shows which engines they enable');
|
|
3003
|
+
var images = program.command("images").description("manage engine base images (kernel + initramfs for QEMU/Firecracker)");
|
|
3004
|
+
images.command("pull").description("download per-stack base images from S3 or an HTTPS mirror into the standard location").requiredOption("--from <location>", "s3://bucket[/prefix] or https:// base URL (env: VM_IMAGES_URL)", process.env.VM_IMAGES_URL).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to pull (default: all)").option("--dest <dir>", "destination directory (default: ~/.multiplayer/vm-images/<arch>)").option("--endpoint <url>", "custom S3 endpoint, e.g. a MinIO URL (env: VM_IMAGES_ENDPOINT)", process.env.VM_IMAGES_ENDPOINT).action(async (opts) => {
|
|
3005
|
+
const dest = opts.dest ?? defaultImagesDir(opts.arch);
|
|
3006
|
+
const files = await pullImages({ location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, dest);
|
|
3007
|
+
for (const file of files) console.log(`pulled ${file}`);
|
|
3008
|
+
console.log('\nbase images ready \u2014 "sandbox detect" shows which engines they enable');
|
|
85
3009
|
});
|
|
86
|
-
images
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.option('--arch <arch>', 'image architecture', (0, images_1.hostArch)())
|
|
91
|
-
.option('--stack <stack...>', 'stacks to push (default: all found locally)')
|
|
92
|
-
.option('--src <dir>', 'source directory (default: ~/.multiplayer/vm-images/<arch>)')
|
|
93
|
-
.option('--endpoint <url>', 'custom S3 endpoint, e.g. a MinIO URL (env: VM_IMAGES_ENDPOINT)', process.env.VM_IMAGES_ENDPOINT)
|
|
94
|
-
.action(async (opts) => {
|
|
95
|
-
var _a;
|
|
96
|
-
const src = (_a = opts.src) !== null && _a !== void 0 ? _a : (0, images_1.defaultImagesDir)(opts.arch);
|
|
97
|
-
const uploaded = await (0, images_1.pushImages)({ location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, src);
|
|
98
|
-
for (const uri of uploaded)
|
|
99
|
-
console.log(`pushed ${uri}`);
|
|
3010
|
+
images.command("push").description("upload locally built per-stack base images (scripts/setup-vm-images.sh) to S3").requiredOption("--to <location>", "s3://bucket[/prefix] (env: VM_IMAGES_URL)", process.env.VM_IMAGES_URL).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to push (default: all found locally)").option("--src <dir>", "source directory (default: ~/.multiplayer/vm-images/<arch>)").option("--endpoint <url>", "custom S3 endpoint, e.g. a MinIO URL (env: VM_IMAGES_ENDPOINT)", process.env.VM_IMAGES_ENDPOINT).action(async (opts) => {
|
|
3011
|
+
const src = opts.src ?? defaultImagesDir(opts.arch);
|
|
3012
|
+
const uploaded = await pushImages({ location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, src);
|
|
3013
|
+
for (const uri of uploaded) console.log(`pushed ${uri}`);
|
|
100
3014
|
});
|
|
101
3015
|
program.parseAsync().catch((error) => {
|
|
102
|
-
|
|
103
|
-
|
|
3016
|
+
console.error(`Error: ${error.message}`);
|
|
3017
|
+
process.exit(1);
|
|
104
3018
|
});
|
|
105
|
-
//# sourceMappingURL=cli.js.map
|
|
3019
|
+
//# sourceMappingURL=cli.js.map
|