@molecule/api-code-sandbox-docker 1.0.1 → 1.1.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 +92 -2
- package/dist/archive.d.ts +49 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +61 -0
- package/dist/capacity.d.ts +71 -0
- package/dist/capacity.d.ts.map +1 -0
- package/dist/capacity.js +98 -0
- package/dist/egress.d.ts +49 -0
- package/dist/egress.d.ts.map +1 -0
- package/dist/egress.js +424 -0
- package/dist/hibernate.d.ts +79 -0
- package/dist/hibernate.d.ts.map +1 -0
- package/dist/hibernate.js +142 -0
- package/dist/index.d.ts +44 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -1
- package/dist/inspect.d.ts +131 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +212 -0
- package/dist/provider.d.ts +14 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +496 -19
- package/dist/request.d.ts +66 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +56 -0
- package/dist/templates.d.ts +109 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +381 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/provider.js
CHANGED
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
+
import { readFile, statfs } from 'fs/promises';
|
|
9
10
|
import http from 'http';
|
|
11
|
+
import { freemem } from 'os';
|
|
12
|
+
import { Readable } from 'stream';
|
|
10
13
|
import { getLogger } from '@molecule/api-bond';
|
|
11
14
|
import { t } from '@molecule/api-i18n';
|
|
12
15
|
const logger = getLogger();
|
|
16
|
+
import { exportContainerFiles, importContainerFiles } from './archive.js';
|
|
17
|
+
import { measureDockerCapacity } from './capacity.js';
|
|
18
|
+
import { verifyDockerEgress } from './egress.js';
|
|
19
|
+
import { hibernateContainer, resumeContainer } from './hibernate.js';
|
|
20
|
+
import { describeContainer, findContainers, listDockerVolumes, updateContainerResources, } from './inspect.js';
|
|
21
|
+
import * as templates from './templates.js';
|
|
13
22
|
const DEFAULT_IMAGE = 'node:22-slim';
|
|
14
23
|
/**
|
|
15
24
|
* Shell-safe single-quote escaping for file paths. Prevents command injection
|
|
@@ -34,6 +43,17 @@ const LABEL_PREFIX = 'molecule-sandbox';
|
|
|
34
43
|
const DEFAULT_SANDBOX_NETWORK = 'molecule-sandbox';
|
|
35
44
|
/** Default Docker daemon TCP port for a plain (unencrypted) `tcp://` endpoint. */
|
|
36
45
|
const DEFAULT_DOCKER_TCP_PORT = 2375;
|
|
46
|
+
/**
|
|
47
|
+
* Repository that holds sandbox templates, kept separate from the base image's
|
|
48
|
+
* repository so a template sweep can never reach `molecule-sandbox:latest`.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_TEMPLATE_REPOSITORY = 'molecule-sandbox-template';
|
|
51
|
+
/** `X-Registry-Auth` for an anonymous or already-logged-in daemon. */
|
|
52
|
+
const EMPTY_REGISTRY_AUTH = Buffer.from('{}').toString('base64');
|
|
53
|
+
/** Ports published for every sandbox when the caller names none. */
|
|
54
|
+
const DEFAULT_PUBLISH_PORTS = [4000, 5173];
|
|
55
|
+
/** Maximum processes a sandbox may hold when the caller sets no `pidsLimit`. */
|
|
56
|
+
const DEFAULT_PIDS_LIMIT = 512;
|
|
37
57
|
/**
|
|
38
58
|
* Parse a `DOCKER_HOST`-style value into an endpoint, honoring the docker-client
|
|
39
59
|
* conventions: `unix:///path/to/docker.sock` selects a socket; `tcp://host:port`
|
|
@@ -135,6 +155,63 @@ class DockerMuxParser {
|
|
|
135
155
|
}
|
|
136
156
|
}
|
|
137
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Validate the ports a caller asked to publish, falling back to this provider's
|
|
160
|
+
* historical API + Vite pair when it named none.
|
|
161
|
+
*
|
|
162
|
+
* Rejects rather than filters: a caller that asked for a port it cannot have
|
|
163
|
+
* needs to hear so, because the alternative is a dev server running inside a
|
|
164
|
+
* sandbox that nothing on the outside can ever reach — which looks like a broken
|
|
165
|
+
* application, not a bad argument.
|
|
166
|
+
*
|
|
167
|
+
* @param ports - Ports inside the sandbox the caller wants reachable.
|
|
168
|
+
* @returns The ports to expose and bind.
|
|
169
|
+
* @throws {Error} When a port is not a valid TCP port number.
|
|
170
|
+
*/
|
|
171
|
+
export function resolvePublishPorts(ports) {
|
|
172
|
+
if (!ports || ports.length === 0)
|
|
173
|
+
return DEFAULT_PUBLISH_PORTS;
|
|
174
|
+
for (const port of ports) {
|
|
175
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
176
|
+
throw new Error(`Invalid publish port ${String(port)}: must be an integer in 1-65535`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...new Set(ports)];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Available memory in bytes, or `null` when it cannot be read.
|
|
183
|
+
*
|
|
184
|
+
* Prefers Linux `MemAvailable`, which is the kernel's estimate of what a new
|
|
185
|
+
* workload can use WITHOUT swapping and counts reclaimable page cache.
|
|
186
|
+
* `os.freemem()` excludes that cache and badly understates free memory on any
|
|
187
|
+
* warm host, so it is only the off-Linux fallback — using it as the primary
|
|
188
|
+
* signal rejects boots the host could comfortably serve.
|
|
189
|
+
*
|
|
190
|
+
* @returns Available bytes, or `null` when unreadable.
|
|
191
|
+
*/
|
|
192
|
+
async function availableMemoryBytes() {
|
|
193
|
+
try {
|
|
194
|
+
const match = /^MemAvailable:\s+(\d+)\s+kB/m.exec(await readFile('/proc/meminfo', 'utf-8'));
|
|
195
|
+
if (match) {
|
|
196
|
+
const kb = Number.parseInt(match[1], 10);
|
|
197
|
+
if (Number.isFinite(kb) && kb > 0)
|
|
198
|
+
return kb * 1024;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (_error) {
|
|
202
|
+
// /proc/meminfo is absent on non-Linux hosts and unreadable in some
|
|
203
|
+
// sandboxes — fall through to the portable estimate rather than failing.
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const bytes = freemem();
|
|
207
|
+
return Number.isFinite(bytes) && bytes > 0 ? bytes : null;
|
|
208
|
+
}
|
|
209
|
+
catch (_error) {
|
|
210
|
+
// No usable memory reading at all — report "unknown" so the caller does not
|
|
211
|
+
// mistake it for "exhausted".
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
138
215
|
/**
|
|
139
216
|
* Retry a Docker API operation that failed with a TRANSIENT fault — a request
|
|
140
217
|
* timeout or a connection-level reset, i.e. the daemon was momentarily overwhelmed,
|
|
@@ -209,6 +286,20 @@ class DockerSandboxProvider {
|
|
|
209
286
|
configNetwork;
|
|
210
287
|
/** [C1-1] Memoize the one-time ICC-off network ensure so create() pays it only once. */
|
|
211
288
|
networkEnsured = false;
|
|
289
|
+
/** Explicit template repository from config; env is consulted at call time. */
|
|
290
|
+
configTemplateRepository;
|
|
291
|
+
/** Explicit template registry from config; env is consulted at call time. */
|
|
292
|
+
configTemplateRegistry;
|
|
293
|
+
/** Explicit registry credential from config; env is consulted at call time. */
|
|
294
|
+
configTemplateRegistryAuth;
|
|
295
|
+
/**
|
|
296
|
+
* Whether this host still looks capable of CRIU checkpoints. Shared by
|
|
297
|
+
* `hibernate` and `resume` for every sandbox this provider hands out, so a host
|
|
298
|
+
* without CRIU pays one rejected request per process instead of one per
|
|
299
|
+
* hibernation. Held on the instance rather than at module scope so two
|
|
300
|
+
* providers (and two tests) do not inherit each other's verdict.
|
|
301
|
+
*/
|
|
302
|
+
checkpoints = { supported: true };
|
|
212
303
|
constructor(config = {}) {
|
|
213
304
|
this.endpoint = resolveDockerEndpoint(config);
|
|
214
305
|
this.baseImage = config.baseImage ?? DEFAULT_IMAGE;
|
|
@@ -217,6 +308,48 @@ class DockerSandboxProvider {
|
|
|
217
308
|
this.defaultCpu = config.defaultCpu ?? 1;
|
|
218
309
|
this.defaultMemoryMB = config.defaultMemoryMB ?? 1024;
|
|
219
310
|
this.configNetwork = config.network;
|
|
311
|
+
this.configTemplateRepository = config.templateRepository;
|
|
312
|
+
this.configTemplateRegistry = config.templateRegistry;
|
|
313
|
+
this.configTemplateRegistryAuth = config.templateRegistryAuth;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Assemble the context every template operation runs against. Env is read here
|
|
317
|
+
* rather than in the constructor, matching {@link resolveNetwork}: this
|
|
318
|
+
* provider is commonly constructed before the process has loaded its
|
|
319
|
+
* environment.
|
|
320
|
+
*
|
|
321
|
+
* @returns The template context.
|
|
322
|
+
*/
|
|
323
|
+
templateContext() {
|
|
324
|
+
return {
|
|
325
|
+
request: (path, method, body, timeoutMs, headers) => this.dockerApi(path, method, body, timeoutMs, headers),
|
|
326
|
+
download: (path, timeoutMs) => this.dockerDownload(path, timeoutMs),
|
|
327
|
+
upload: (path, body, timeoutMs) => this.dockerUpload(path, body, timeoutMs),
|
|
328
|
+
baseImage: this.baseImage,
|
|
329
|
+
repository: this.configTemplateRepository ??
|
|
330
|
+
process.env.SANDBOX_TEMPLATE_REPOSITORY ??
|
|
331
|
+
DEFAULT_TEMPLATE_REPOSITORY,
|
|
332
|
+
registry: this.configTemplateRegistry ?? process.env.SANDBOX_TEMPLATE_REGISTRY ?? '',
|
|
333
|
+
registryAuth: this.configTemplateRegistryAuth ??
|
|
334
|
+
process.env.SANDBOX_TEMPLATE_REGISTRY_AUTH ??
|
|
335
|
+
EMPTY_REGISTRY_AUTH,
|
|
336
|
+
labelPrefix: this.labelPrefix,
|
|
337
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
338
|
+
debug: (message, meta) => logger.debug(message, meta),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The context shared by the inspection capabilities.
|
|
343
|
+
*
|
|
344
|
+
* @returns The inspection context.
|
|
345
|
+
*/
|
|
346
|
+
inspectContext() {
|
|
347
|
+
return {
|
|
348
|
+
request: (path, method, body, timeoutMs, headers) => this.dockerApi(path, method, body, timeoutMs, headers),
|
|
349
|
+
labelPrefix: this.labelPrefix,
|
|
350
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
351
|
+
debug: (message, meta) => logger.debug(message, meta),
|
|
352
|
+
};
|
|
220
353
|
}
|
|
221
354
|
/**
|
|
222
355
|
* Resolve the Docker network to attach containers to. Precedence:
|
|
@@ -285,7 +418,22 @@ class DockerSandboxProvider {
|
|
|
285
418
|
await this.ensureSandboxNetwork();
|
|
286
419
|
const cpu = config.resources?.cpu ?? this.defaultCpu;
|
|
287
420
|
const memoryMB = config.resources?.memoryMB ?? this.defaultMemoryMB;
|
|
288
|
-
|
|
421
|
+
let image = config.image ?? this.baseImage;
|
|
422
|
+
// Booting from a template is the caller naming a filesystem it captured. If
|
|
423
|
+
// it is gone, FAIL — falling back to the base image would hand back a
|
|
424
|
+
// healthy-looking sandbox whose contents are not the ones that were asked
|
|
425
|
+
// for, and nothing downstream would notice.
|
|
426
|
+
if (config.templateId) {
|
|
427
|
+
const template = await templates.getTemplate(this.templateContext(), config.templateId);
|
|
428
|
+
if (!template) {
|
|
429
|
+
throw new Error(t('codeSandbox.docker.error.templateMissing', { templateId: config.templateId }, {
|
|
430
|
+
defaultValue: `Cannot create a sandbox from template "${config.templateId}": it does not exist. ` +
|
|
431
|
+
'Commit it first, or fetch it from the shared template store.',
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
image = template.ref;
|
|
435
|
+
}
|
|
436
|
+
const publishPorts = resolvePublishPorts(config.publishPorts);
|
|
289
437
|
const env = Object.entries(config.env ?? {}).map(([k, v]) => `${k}=${v}`);
|
|
290
438
|
const labels = {
|
|
291
439
|
[`${this.labelPrefix}.projectId`]: config.projectId,
|
|
@@ -311,7 +459,7 @@ class DockerSandboxProvider {
|
|
|
311
459
|
// the Memory cap does not bound swap. [C1-2]
|
|
312
460
|
MemorySwap: memoryBytes * 2,
|
|
313
461
|
Init: true, // Use tini init to reap zombie processes
|
|
314
|
-
PidsLimit:
|
|
462
|
+
PidsLimit: config.resources?.pidsLimit ?? DEFAULT_PIDS_LIMIT,
|
|
315
463
|
SecurityOpt: ['no-new-privileges'],
|
|
316
464
|
CapDrop: ['ALL'],
|
|
317
465
|
// Minimal caps for the dev toolchain (file ownership + tini privilege drop).
|
|
@@ -320,11 +468,10 @@ class DockerSandboxProvider {
|
|
|
320
468
|
// bridge. Egress filtering must be enforced host-side, not in a tenant-rooted
|
|
321
469
|
// container.
|
|
322
470
|
CapAdd: ['CHOWN', 'SETGID', 'SETUID'],
|
|
323
|
-
// Bind ports to localhost only — prevents external access
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
},
|
|
471
|
+
// Bind ports to localhost only — prevents external access. Which ports is
|
|
472
|
+
// the caller's business (`config.publishPorts`); the historical
|
|
473
|
+
// API + Vite pair is the default so existing callers are unaffected.
|
|
474
|
+
PortBindings: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, [{ HostIp: '127.0.0.1', HostPort: '' }]])),
|
|
328
475
|
Tmpfs: { '/tmp': 'rw,noexec,nosuid,size=256m' },
|
|
329
476
|
ReadonlyPaths: [
|
|
330
477
|
'/proc/asound',
|
|
@@ -361,16 +508,29 @@ class DockerSandboxProvider {
|
|
|
361
508
|
labels[`${this.labelPrefix}.volumeName`] = config.volumeName;
|
|
362
509
|
hostConfig.Binds = [`${config.volumeName}:/workspace`];
|
|
363
510
|
}
|
|
511
|
+
// What the container does when its main process exits. Docker's default is
|
|
512
|
+
// `no`, matching this provider's historical behaviour: a sandbox whose
|
|
513
|
+
// process exits stays stopped for the caller to inspect. A caller running
|
|
514
|
+
// something long-lived (a production instance) asks for `always`/`on-failure`
|
|
515
|
+
// so the workload comes back after a crash or a daemon restart without a
|
|
516
|
+
// control plane in the loop.
|
|
517
|
+
if (config.restartPolicy && config.restartPolicy !== 'no') {
|
|
518
|
+
hostConfig.RestartPolicy = { Name: config.restartPolicy };
|
|
519
|
+
}
|
|
364
520
|
const body = {
|
|
365
521
|
Image: image,
|
|
366
522
|
Env: env,
|
|
367
523
|
Labels: labels,
|
|
368
524
|
HostConfig: hostConfig,
|
|
369
|
-
ExposedPorts: {
|
|
370
|
-
'4000/tcp': {},
|
|
371
|
-
'5173/tcp': {},
|
|
372
|
-
},
|
|
525
|
+
ExposedPorts: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, {}])),
|
|
373
526
|
};
|
|
527
|
+
// The caller's own main process, replacing the image's CMD. Without it a
|
|
528
|
+
// container that Docker restarts comes back running the image's idle command
|
|
529
|
+
// and serving nothing, while `status` still reports it as running — which it
|
|
530
|
+
// is, and empty. `Cmd` (not `Entrypoint`) so the image's entrypoint — tini,
|
|
531
|
+
// enabled above via `Init` — still wraps it and reaps its children.
|
|
532
|
+
if (config.command?.length)
|
|
533
|
+
body.Cmd = [...config.command];
|
|
374
534
|
// Retry a transient create timeout (daemon momentarily overwhelmed under
|
|
375
535
|
// concurrent boots) instead of failing the whole sandbox boot. The onRetry
|
|
376
536
|
// guard adopts a container a timed-out create may have already made — keyed on
|
|
@@ -679,17 +839,32 @@ class DockerSandboxProvider {
|
|
|
679
839
|
// (e.g. a per-sandbox subdomain `sb-{port}.preview.localhost:{port}`).
|
|
680
840
|
return provider.previewUrlTemplate.replace(/\{port\}/g, String(port ?? 5173));
|
|
681
841
|
},
|
|
842
|
+
/**
|
|
843
|
+
* Spawn a long-running process with streaming I/O, optionally on a PTY.
|
|
844
|
+
*
|
|
845
|
+
* `opts.pty` asks Docker for `Tty: true`, which gives the process a real
|
|
846
|
+
* controlling terminal: Ctrl-C (`0x03`) becomes SIGINT for the foreground
|
|
847
|
+
* job and the width can be renegotiated through `handle.resize()`. A TTY
|
|
848
|
+
* exec stream is NOT multiplexed, so the frame parser is bypassed and
|
|
849
|
+
* everything (stderr included, as on a real terminal) arrives on stdout.
|
|
850
|
+
*
|
|
851
|
+
* @param command - The command to run.
|
|
852
|
+
* @param opts - Working directory, env, and optional PTY size.
|
|
853
|
+
* @returns A handle with streaming I/O, stdin, kill, and (on a PTY) resize.
|
|
854
|
+
*/
|
|
682
855
|
async spawn(command, opts) {
|
|
856
|
+
const tty = Boolean(opts?.pty);
|
|
683
857
|
const execCreate = (await provider.dockerApi(`/containers/${containerId}/exec`, 'POST', {
|
|
684
858
|
Cmd: ['sh', '-c', command],
|
|
685
859
|
AttachStdin: true,
|
|
686
860
|
AttachStdout: true,
|
|
687
861
|
AttachStderr: true,
|
|
688
|
-
Tty:
|
|
862
|
+
Tty: tty,
|
|
863
|
+
...(opts?.pty ? { ConsoleSize: [opts.pty.rows, opts.pty.cols] } : {}),
|
|
689
864
|
WorkingDir: opts?.cwd ?? '/workspace',
|
|
690
865
|
Env: opts?.env ? Object.entries(opts.env).map(([k, v]) => `${k}=${v}`) : undefined,
|
|
691
866
|
}));
|
|
692
|
-
const socket = await provider.dockerExecUpgrade(execCreate.Id);
|
|
867
|
+
const socket = await provider.dockerExecUpgrade(execCreate.Id, tty);
|
|
693
868
|
const parser = new DockerMuxParser();
|
|
694
869
|
let closeCb = null;
|
|
695
870
|
// Kill idle spawn sockets after 10 minutes to prevent descriptor leaks
|
|
@@ -697,7 +872,17 @@ class DockerSandboxProvider {
|
|
|
697
872
|
socket.on('timeout', () => {
|
|
698
873
|
socket.destroy();
|
|
699
874
|
});
|
|
875
|
+
// A TTY exec stream carries raw terminal bytes — there are no 8-byte
|
|
876
|
+
// frame headers to strip, and feeding them to the mux parser would read
|
|
877
|
+
// the first byte of the shell's output as a stream descriptor.
|
|
878
|
+
const ttyListeners = [];
|
|
700
879
|
socket.on('data', (chunk) => {
|
|
880
|
+
if (tty) {
|
|
881
|
+
const text = chunk.toString('utf8');
|
|
882
|
+
for (const cb of ttyListeners)
|
|
883
|
+
cb(text);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
701
886
|
try {
|
|
702
887
|
parser.feed(chunk);
|
|
703
888
|
}
|
|
@@ -723,10 +908,16 @@ class DockerSandboxProvider {
|
|
|
723
908
|
socket.write(data);
|
|
724
909
|
},
|
|
725
910
|
onStdout(cb) {
|
|
726
|
-
|
|
911
|
+
if (tty)
|
|
912
|
+
ttyListeners.push(cb);
|
|
913
|
+
else
|
|
914
|
+
parser.onStdout(cb);
|
|
727
915
|
},
|
|
728
916
|
onStderr(cb) {
|
|
729
|
-
|
|
917
|
+
// On a TTY both streams share one descriptor, so nothing arrives
|
|
918
|
+
// here — registering is harmless and keeps the handle uniform.
|
|
919
|
+
if (!tty)
|
|
920
|
+
parser.onStderr(cb);
|
|
730
921
|
},
|
|
731
922
|
onClose(cb) {
|
|
732
923
|
closeCb = cb;
|
|
@@ -735,8 +926,64 @@ class DockerSandboxProvider {
|
|
|
735
926
|
if (!socket.destroyed)
|
|
736
927
|
socket.destroy();
|
|
737
928
|
},
|
|
929
|
+
...(tty
|
|
930
|
+
? {
|
|
931
|
+
resize(size) {
|
|
932
|
+
void provider
|
|
933
|
+
.dockerApi(`/exec/${execCreate.Id}/resize?h=${Math.max(1, Math.floor(size.rows))}&w=${Math.max(1, Math.floor(size.cols))}`, 'POST')
|
|
934
|
+
.catch((error) => {
|
|
935
|
+
// Best-effort: a resize races the exec exiting, and a
|
|
936
|
+
// failed one only leaves the remote width stale — never a
|
|
937
|
+
// reason to tear down a live session.
|
|
938
|
+
logger.debug('Spawn resize failed', {
|
|
939
|
+
error: error instanceof Error ? error.message : error,
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
},
|
|
943
|
+
}
|
|
944
|
+
: {}),
|
|
738
945
|
};
|
|
739
946
|
},
|
|
947
|
+
async hibernate() {
|
|
948
|
+
const outcome = await hibernateContainer({
|
|
949
|
+
request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
|
|
950
|
+
checkpoints: provider.checkpoints,
|
|
951
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
952
|
+
info: (message, meta) => logger.info(message, meta),
|
|
953
|
+
}, containerId);
|
|
954
|
+
this.status = 'sleeping';
|
|
955
|
+
return outcome;
|
|
956
|
+
},
|
|
957
|
+
async resume() {
|
|
958
|
+
const outcome = await resumeContainer({
|
|
959
|
+
request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
|
|
960
|
+
checkpoints: provider.checkpoints,
|
|
961
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
962
|
+
info: (message, meta) => logger.info(message, meta),
|
|
963
|
+
}, containerId);
|
|
964
|
+
this.status = 'running';
|
|
965
|
+
return outcome;
|
|
966
|
+
},
|
|
967
|
+
async setResources(resources) {
|
|
968
|
+
await updateContainerResources({
|
|
969
|
+
request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
|
|
970
|
+
labelPrefix: provider.labelPrefix,
|
|
971
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
972
|
+
debug: (message, meta) => logger.debug(message, meta),
|
|
973
|
+
}, containerId, resources);
|
|
974
|
+
},
|
|
975
|
+
async exportFiles(path) {
|
|
976
|
+
return exportContainerFiles({
|
|
977
|
+
download: (p, timeoutMs) => provider.dockerDownload(p, timeoutMs),
|
|
978
|
+
upload: (p, body, timeoutMs) => provider.dockerUpload(p, body, timeoutMs),
|
|
979
|
+
}, containerId, path);
|
|
980
|
+
},
|
|
981
|
+
async importFiles(path, archive) {
|
|
982
|
+
await importContainerFiles({
|
|
983
|
+
download: (p, timeoutMs) => provider.dockerDownload(p, timeoutMs),
|
|
984
|
+
upload: (p, body, timeoutMs) => provider.dockerUpload(p, body, timeoutMs),
|
|
985
|
+
}, containerId, path, archive);
|
|
986
|
+
},
|
|
740
987
|
onFileChange(_cb) {
|
|
741
988
|
let active = true;
|
|
742
989
|
const poll = async () => {
|
|
@@ -796,6 +1043,138 @@ class DockerSandboxProvider {
|
|
|
796
1043
|
return false;
|
|
797
1044
|
}
|
|
798
1045
|
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Prove whether the host actually denies sandbox egress.
|
|
1048
|
+
*
|
|
1049
|
+
* Delegates to {@link verifyDockerEgress}, which runs BOTH legs — outbound
|
|
1050
|
+
* (DOCKER-USER) and host-bound (INPUT). See that module for why one leg is not
|
|
1051
|
+
* enough: host-gateway traffic never traverses DOCKER-USER, so an outbound-only
|
|
1052
|
+
* check reports "filtered" on a host whose SSH, Redis, monitoring and admin
|
|
1053
|
+
* consoles are reachable from every sandbox.
|
|
1054
|
+
*
|
|
1055
|
+
* @returns What was observed about egress filtering.
|
|
1056
|
+
*/
|
|
1057
|
+
async verifyEgress() {
|
|
1058
|
+
return verifyDockerEgress({
|
|
1059
|
+
request: (path, method, body, timeoutMs) => this.dockerApi(path, method, body, timeoutMs),
|
|
1060
|
+
network: this.resolveNetwork(),
|
|
1061
|
+
baseImage: this.baseImage,
|
|
1062
|
+
labelPrefix: this.labelPrefix,
|
|
1063
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
// ---------------------------------------------------------------------------
|
|
1067
|
+
// Templates
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
1069
|
+
/**
|
|
1070
|
+
* Capture a sandbox's filesystem as a reusable template.
|
|
1071
|
+
*
|
|
1072
|
+
* See `templates.ts` for why this is not a plain `commit`: a sandbox keeps the
|
|
1073
|
+
* project on a volume, and a volume is not part of a container's writable
|
|
1074
|
+
* layer — so committing the sandbox itself produces an image that boots
|
|
1075
|
+
* successfully with an empty workspace.
|
|
1076
|
+
*
|
|
1077
|
+
* @param options - What to capture and what to call it.
|
|
1078
|
+
* @returns The template as it now exists.
|
|
1079
|
+
*/
|
|
1080
|
+
async commitTemplate(options) {
|
|
1081
|
+
return templates.commitTemplate(this.templateContext(), options);
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Read one template by the caller's identifier.
|
|
1085
|
+
*
|
|
1086
|
+
* @param templateId - The caller's identifier.
|
|
1087
|
+
* @returns The template, or `null` when no image carries that tag.
|
|
1088
|
+
*/
|
|
1089
|
+
async getTemplate(templateId) {
|
|
1090
|
+
return templates.getTemplate(this.templateContext(), templateId);
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Enumerate templates in this provider's template repository.
|
|
1094
|
+
*
|
|
1095
|
+
* @param options - Narrowing by id prefix.
|
|
1096
|
+
* @returns Every matching template.
|
|
1097
|
+
*/
|
|
1098
|
+
async listTemplates(options) {
|
|
1099
|
+
return templates.listTemplates(this.templateContext(), options);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Delete a template, refusing while a container is still backed by it.
|
|
1103
|
+
*
|
|
1104
|
+
* @param templateId - The caller's identifier.
|
|
1105
|
+
*/
|
|
1106
|
+
async removeTemplate(templateId) {
|
|
1107
|
+
return templates.removeTemplate(this.templateContext(), templateId);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Push a template to the configured registry so other hosts can boot from it.
|
|
1111
|
+
*
|
|
1112
|
+
* @param templateId - The caller's identifier.
|
|
1113
|
+
*/
|
|
1114
|
+
async publishTemplate(templateId) {
|
|
1115
|
+
return templates.publishTemplate(this.templateContext(), templateId);
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Pull a template from the configured registry onto this host.
|
|
1119
|
+
*
|
|
1120
|
+
* @param templateId - The caller's identifier.
|
|
1121
|
+
* @returns The now-local template, or `null` when the registry does not have it.
|
|
1122
|
+
*/
|
|
1123
|
+
async fetchTemplate(templateId) {
|
|
1124
|
+
return templates.fetchTemplate(this.templateContext(), templateId);
|
|
1125
|
+
}
|
|
1126
|
+
// ---------------------------------------------------------------------------
|
|
1127
|
+
// Inspection
|
|
1128
|
+
// ---------------------------------------------------------------------------
|
|
1129
|
+
/**
|
|
1130
|
+
* Describe one sandbox without building a handle for it.
|
|
1131
|
+
*
|
|
1132
|
+
* @param id - The container id.
|
|
1133
|
+
* @returns The descriptor, or `null` when no such container exists.
|
|
1134
|
+
*/
|
|
1135
|
+
async describe(id) {
|
|
1136
|
+
return describeContainer(this.inspectContext(), id);
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Find sandboxes by the labels the caller put on them.
|
|
1140
|
+
*
|
|
1141
|
+
* @param query - Narrowing by label, status and project.
|
|
1142
|
+
* @returns Descriptors for every match.
|
|
1143
|
+
*/
|
|
1144
|
+
async find(query) {
|
|
1145
|
+
return findContainers(this.inspectContext(), query);
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Enumerate named volumes and whether anything still has them attached.
|
|
1149
|
+
*
|
|
1150
|
+
* @param options - Narrowing by name prefix and attachment.
|
|
1151
|
+
* @returns Every matching volume.
|
|
1152
|
+
*/
|
|
1153
|
+
async listVolumes(options) {
|
|
1154
|
+
return listDockerVolumes(this.inspectContext(), options);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Measure what the host backing this daemon can still fit.
|
|
1158
|
+
*
|
|
1159
|
+
* @returns Measured headroom and an explanation of anything unmeasurable.
|
|
1160
|
+
*/
|
|
1161
|
+
async capacity() {
|
|
1162
|
+
return measureDockerCapacity({
|
|
1163
|
+
request: (path, method, body, timeoutMs, headers) => this.dockerApi(path, method, body, timeoutMs, headers),
|
|
1164
|
+
// Only a unix-socket daemon writes to storage this process can measure.
|
|
1165
|
+
daemonIsLocal: 'socketPath' in this.endpoint,
|
|
1166
|
+
statfs: async (path) => {
|
|
1167
|
+
const stats = await statfs(path);
|
|
1168
|
+
return {
|
|
1169
|
+
bsize: Number(stats.bsize),
|
|
1170
|
+
bavail: Number(stats.bavail),
|
|
1171
|
+
ffree: Number(stats.ffree),
|
|
1172
|
+
};
|
|
1173
|
+
},
|
|
1174
|
+
availableMemoryBytes,
|
|
1175
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
799
1178
|
/**
|
|
800
1179
|
* Makes an HTTP request to the Docker Engine API via the Unix socket.
|
|
801
1180
|
* @param path - The API endpoint path (e.g. `/containers/create`).
|
|
@@ -806,15 +1185,20 @@ class DockerSandboxProvider {
|
|
|
806
1185
|
* image's contents during those calls, which scales with image size — a
|
|
807
1186
|
* multi-GB `/workspace` reliably exceeds 30 s, and a client-side timeout on
|
|
808
1187
|
* a create that succeeds server-side leaks a duplicate container.
|
|
1188
|
+
* @param headers - Extra request headers. Registry operations need
|
|
1189
|
+
* `X-Registry-Auth`, which the daemon requires even for anonymous pulls.
|
|
809
1190
|
* @returns The parsed JSON response, or raw text for non-JSON responses.
|
|
810
1191
|
*/
|
|
811
|
-
async dockerApi(path, method = 'GET', body, timeoutMs = 30_000) {
|
|
1192
|
+
async dockerApi(path, method = 'GET', body, timeoutMs = 30_000, headers) {
|
|
812
1193
|
return new Promise((resolve, reject) => {
|
|
813
1194
|
const opts = {
|
|
814
1195
|
...this.endpoint,
|
|
815
1196
|
path: `/v1.44${path}`,
|
|
816
1197
|
method,
|
|
817
|
-
headers:
|
|
1198
|
+
headers: {
|
|
1199
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
1200
|
+
...headers,
|
|
1201
|
+
},
|
|
818
1202
|
};
|
|
819
1203
|
const req = http.request(opts, (res) => {
|
|
820
1204
|
const chunks = [];
|
|
@@ -845,6 +1229,96 @@ class DockerSandboxProvider {
|
|
|
845
1229
|
req.end();
|
|
846
1230
|
});
|
|
847
1231
|
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Issue a Docker Engine API GET and hand back the response body as a byte
|
|
1234
|
+
* stream.
|
|
1235
|
+
*
|
|
1236
|
+
* Unbuffered on purpose: this carries whole project trees, and materializing
|
|
1237
|
+
* one in this process to hand it to the next call trades a slow transfer for
|
|
1238
|
+
* an out-of-memory API. The error path DOES buffer, because an error body is
|
|
1239
|
+
* a sentence and the caller needs to read it.
|
|
1240
|
+
*
|
|
1241
|
+
* @param path - The API endpoint path.
|
|
1242
|
+
* @param timeoutMs - Request timeout (default 10 min).
|
|
1243
|
+
* @returns The response body as an async byte stream.
|
|
1244
|
+
*/
|
|
1245
|
+
dockerDownload(path, timeoutMs = 600_000) {
|
|
1246
|
+
return new Promise((resolve, reject) => {
|
|
1247
|
+
const req = http.request({ ...this.endpoint, path: `/v1.44${path}`, method: 'GET' }, (res) => {
|
|
1248
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
1249
|
+
const chunks = [];
|
|
1250
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
1251
|
+
res.on('end', () => {
|
|
1252
|
+
reject(new Error(t('codeSandbox.docker.error.apiError', {
|
|
1253
|
+
method: 'GET',
|
|
1254
|
+
path,
|
|
1255
|
+
status: String(res.statusCode),
|
|
1256
|
+
error: Buffer.concat(chunks).toString(),
|
|
1257
|
+
}, {
|
|
1258
|
+
defaultValue: `Docker API GET ${path}: ${res.statusCode} ${Buffer.concat(chunks).toString()}`,
|
|
1259
|
+
})));
|
|
1260
|
+
});
|
|
1261
|
+
res.on('error', reject);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
resolve(res);
|
|
1265
|
+
});
|
|
1266
|
+
req.setTimeout(timeoutMs, () => {
|
|
1267
|
+
req.destroy(new Error(`Docker API timeout: GET ${path}`));
|
|
1268
|
+
});
|
|
1269
|
+
req.on('error', reject);
|
|
1270
|
+
req.end();
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Issue a Docker Engine API PUT whose request body is a byte stream.
|
|
1275
|
+
*
|
|
1276
|
+
* Piped rather than written in a loop so Node applies backpressure — without
|
|
1277
|
+
* it, a fast source fills this process's memory with everything the socket has
|
|
1278
|
+
* not drained yet, which for a project tree is the whole tree.
|
|
1279
|
+
*
|
|
1280
|
+
* @param path - The API endpoint path.
|
|
1281
|
+
* @param body - The request body as an async byte stream.
|
|
1282
|
+
* @param timeoutMs - Request timeout (default 10 min).
|
|
1283
|
+
*/
|
|
1284
|
+
dockerUpload(path, body, timeoutMs = 600_000) {
|
|
1285
|
+
return new Promise((resolve, reject) => {
|
|
1286
|
+
const req = http.request({
|
|
1287
|
+
...this.endpoint,
|
|
1288
|
+
path: `/v1.44${path}`,
|
|
1289
|
+
method: 'PUT',
|
|
1290
|
+
headers: { 'Content-Type': 'application/x-tar' },
|
|
1291
|
+
}, (res) => {
|
|
1292
|
+
const chunks = [];
|
|
1293
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
1294
|
+
res.on('end', () => {
|
|
1295
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
1296
|
+
reject(new Error(t('codeSandbox.docker.error.apiError', {
|
|
1297
|
+
method: 'PUT',
|
|
1298
|
+
path,
|
|
1299
|
+
status: String(res.statusCode),
|
|
1300
|
+
error: Buffer.concat(chunks).toString(),
|
|
1301
|
+
}, {
|
|
1302
|
+
defaultValue: `Docker API PUT ${path}: ${res.statusCode} ${Buffer.concat(chunks).toString()}`,
|
|
1303
|
+
})));
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
resolve();
|
|
1307
|
+
});
|
|
1308
|
+
res.on('error', reject);
|
|
1309
|
+
});
|
|
1310
|
+
req.setTimeout(timeoutMs, () => {
|
|
1311
|
+
req.destroy(new Error(`Docker API timeout: PUT ${path}`));
|
|
1312
|
+
});
|
|
1313
|
+
req.on('error', reject);
|
|
1314
|
+
const source = Readable.from(body);
|
|
1315
|
+
source.on('error', (error) => {
|
|
1316
|
+
req.destroy(error);
|
|
1317
|
+
reject(error);
|
|
1318
|
+
});
|
|
1319
|
+
source.pipe(req);
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
848
1322
|
/**
|
|
849
1323
|
* Like `dockerApi` but returns the raw response Buffer without parsing.
|
|
850
1324
|
* Used for exec start responses which return a multiplexed binary stream.
|
|
@@ -900,11 +1374,14 @@ class DockerSandboxProvider {
|
|
|
900
1374
|
* bidirectional socket. Docker hijacks the connection (101 Switching Protocols)
|
|
901
1375
|
* when stdin is attached, giving us a raw TCP socket for streaming I/O.
|
|
902
1376
|
* @param execId - The exec instance ID from the create step.
|
|
1377
|
+
* @param tty - Whether the exec was created with a TTY; the start call must
|
|
1378
|
+
* agree, or Docker streams multiplexed frames to a terminal expecting raw
|
|
1379
|
+
* bytes.
|
|
903
1380
|
* @returns The raw socket for stdin writes and multiplexed stdout/stderr reads.
|
|
904
1381
|
*/
|
|
905
|
-
dockerExecUpgrade(execId) {
|
|
1382
|
+
dockerExecUpgrade(execId, tty = false) {
|
|
906
1383
|
return new Promise((resolve, reject) => {
|
|
907
|
-
const body = JSON.stringify({ Detach: false, Tty:
|
|
1384
|
+
const body = JSON.stringify({ Detach: false, Tty: tty });
|
|
908
1385
|
const req = http.request({
|
|
909
1386
|
...this.endpoint,
|
|
910
1387
|
path: `/v1.44/exec/${execId}/start`,
|