@molecule/api-code-sandbox-docker 1.0.0 → 1.0.2
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 +319 -0
- 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 +424 -13
- 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 +8 -7
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',
|
|
@@ -366,10 +513,7 @@ class DockerSandboxProvider {
|
|
|
366
513
|
Env: env,
|
|
367
514
|
Labels: labels,
|
|
368
515
|
HostConfig: hostConfig,
|
|
369
|
-
ExposedPorts: {
|
|
370
|
-
'4000/tcp': {},
|
|
371
|
-
'5173/tcp': {},
|
|
372
|
-
},
|
|
516
|
+
ExposedPorts: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, {}])),
|
|
373
517
|
};
|
|
374
518
|
// Retry a transient create timeout (daemon momentarily overwhelmed under
|
|
375
519
|
// concurrent boots) instead of failing the whole sandbox boot. The onRetry
|
|
@@ -737,6 +881,46 @@ class DockerSandboxProvider {
|
|
|
737
881
|
},
|
|
738
882
|
};
|
|
739
883
|
},
|
|
884
|
+
async hibernate() {
|
|
885
|
+
const outcome = await hibernateContainer({
|
|
886
|
+
request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
|
|
887
|
+
checkpoints: provider.checkpoints,
|
|
888
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
889
|
+
info: (message, meta) => logger.info(message, meta),
|
|
890
|
+
}, containerId);
|
|
891
|
+
this.status = 'sleeping';
|
|
892
|
+
return outcome;
|
|
893
|
+
},
|
|
894
|
+
async resume() {
|
|
895
|
+
const outcome = await resumeContainer({
|
|
896
|
+
request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
|
|
897
|
+
checkpoints: provider.checkpoints,
|
|
898
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
899
|
+
info: (message, meta) => logger.info(message, meta),
|
|
900
|
+
}, containerId);
|
|
901
|
+
this.status = 'running';
|
|
902
|
+
return outcome;
|
|
903
|
+
},
|
|
904
|
+
async setResources(resources) {
|
|
905
|
+
await updateContainerResources({
|
|
906
|
+
request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
|
|
907
|
+
labelPrefix: provider.labelPrefix,
|
|
908
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
909
|
+
debug: (message, meta) => logger.debug(message, meta),
|
|
910
|
+
}, containerId, resources);
|
|
911
|
+
},
|
|
912
|
+
async exportFiles(path) {
|
|
913
|
+
return exportContainerFiles({
|
|
914
|
+
download: (p, timeoutMs) => provider.dockerDownload(p, timeoutMs),
|
|
915
|
+
upload: (p, body, timeoutMs) => provider.dockerUpload(p, body, timeoutMs),
|
|
916
|
+
}, containerId, path);
|
|
917
|
+
},
|
|
918
|
+
async importFiles(path, archive) {
|
|
919
|
+
await importContainerFiles({
|
|
920
|
+
download: (p, timeoutMs) => provider.dockerDownload(p, timeoutMs),
|
|
921
|
+
upload: (p, body, timeoutMs) => provider.dockerUpload(p, body, timeoutMs),
|
|
922
|
+
}, containerId, path, archive);
|
|
923
|
+
},
|
|
740
924
|
onFileChange(_cb) {
|
|
741
925
|
let active = true;
|
|
742
926
|
const poll = async () => {
|
|
@@ -796,6 +980,138 @@ class DockerSandboxProvider {
|
|
|
796
980
|
return false;
|
|
797
981
|
}
|
|
798
982
|
}
|
|
983
|
+
/**
|
|
984
|
+
* Prove whether the host actually denies sandbox egress.
|
|
985
|
+
*
|
|
986
|
+
* Delegates to {@link verifyDockerEgress}, which runs BOTH legs — outbound
|
|
987
|
+
* (DOCKER-USER) and host-bound (INPUT). See that module for why one leg is not
|
|
988
|
+
* enough: host-gateway traffic never traverses DOCKER-USER, so an outbound-only
|
|
989
|
+
* check reports "filtered" on a host whose SSH, Redis, monitoring and admin
|
|
990
|
+
* consoles are reachable from every sandbox.
|
|
991
|
+
*
|
|
992
|
+
* @returns What was observed about egress filtering.
|
|
993
|
+
*/
|
|
994
|
+
async verifyEgress() {
|
|
995
|
+
return verifyDockerEgress({
|
|
996
|
+
request: (path, method, body, timeoutMs) => this.dockerApi(path, method, body, timeoutMs),
|
|
997
|
+
network: this.resolveNetwork(),
|
|
998
|
+
baseImage: this.baseImage,
|
|
999
|
+
labelPrefix: this.labelPrefix,
|
|
1000
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
// ---------------------------------------------------------------------------
|
|
1004
|
+
// Templates
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
/**
|
|
1007
|
+
* Capture a sandbox's filesystem as a reusable template.
|
|
1008
|
+
*
|
|
1009
|
+
* See `templates.ts` for why this is not a plain `commit`: a sandbox keeps the
|
|
1010
|
+
* project on a volume, and a volume is not part of a container's writable
|
|
1011
|
+
* layer — so committing the sandbox itself produces an image that boots
|
|
1012
|
+
* successfully with an empty workspace.
|
|
1013
|
+
*
|
|
1014
|
+
* @param options - What to capture and what to call it.
|
|
1015
|
+
* @returns The template as it now exists.
|
|
1016
|
+
*/
|
|
1017
|
+
async commitTemplate(options) {
|
|
1018
|
+
return templates.commitTemplate(this.templateContext(), options);
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Read one template by the caller's identifier.
|
|
1022
|
+
*
|
|
1023
|
+
* @param templateId - The caller's identifier.
|
|
1024
|
+
* @returns The template, or `null` when no image carries that tag.
|
|
1025
|
+
*/
|
|
1026
|
+
async getTemplate(templateId) {
|
|
1027
|
+
return templates.getTemplate(this.templateContext(), templateId);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Enumerate templates in this provider's template repository.
|
|
1031
|
+
*
|
|
1032
|
+
* @param options - Narrowing by id prefix.
|
|
1033
|
+
* @returns Every matching template.
|
|
1034
|
+
*/
|
|
1035
|
+
async listTemplates(options) {
|
|
1036
|
+
return templates.listTemplates(this.templateContext(), options);
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Delete a template, refusing while a container is still backed by it.
|
|
1040
|
+
*
|
|
1041
|
+
* @param templateId - The caller's identifier.
|
|
1042
|
+
*/
|
|
1043
|
+
async removeTemplate(templateId) {
|
|
1044
|
+
return templates.removeTemplate(this.templateContext(), templateId);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Push a template to the configured registry so other hosts can boot from it.
|
|
1048
|
+
*
|
|
1049
|
+
* @param templateId - The caller's identifier.
|
|
1050
|
+
*/
|
|
1051
|
+
async publishTemplate(templateId) {
|
|
1052
|
+
return templates.publishTemplate(this.templateContext(), templateId);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Pull a template from the configured registry onto this host.
|
|
1056
|
+
*
|
|
1057
|
+
* @param templateId - The caller's identifier.
|
|
1058
|
+
* @returns The now-local template, or `null` when the registry does not have it.
|
|
1059
|
+
*/
|
|
1060
|
+
async fetchTemplate(templateId) {
|
|
1061
|
+
return templates.fetchTemplate(this.templateContext(), templateId);
|
|
1062
|
+
}
|
|
1063
|
+
// ---------------------------------------------------------------------------
|
|
1064
|
+
// Inspection
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
/**
|
|
1067
|
+
* Describe one sandbox without building a handle for it.
|
|
1068
|
+
*
|
|
1069
|
+
* @param id - The container id.
|
|
1070
|
+
* @returns The descriptor, or `null` when no such container exists.
|
|
1071
|
+
*/
|
|
1072
|
+
async describe(id) {
|
|
1073
|
+
return describeContainer(this.inspectContext(), id);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Find sandboxes by the labels the caller put on them.
|
|
1077
|
+
*
|
|
1078
|
+
* @param query - Narrowing by label, status and project.
|
|
1079
|
+
* @returns Descriptors for every match.
|
|
1080
|
+
*/
|
|
1081
|
+
async find(query) {
|
|
1082
|
+
return findContainers(this.inspectContext(), query);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Enumerate named volumes and whether anything still has them attached.
|
|
1086
|
+
*
|
|
1087
|
+
* @param options - Narrowing by name prefix and attachment.
|
|
1088
|
+
* @returns Every matching volume.
|
|
1089
|
+
*/
|
|
1090
|
+
async listVolumes(options) {
|
|
1091
|
+
return listDockerVolumes(this.inspectContext(), options);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Measure what the host backing this daemon can still fit.
|
|
1095
|
+
*
|
|
1096
|
+
* @returns Measured headroom and an explanation of anything unmeasurable.
|
|
1097
|
+
*/
|
|
1098
|
+
async capacity() {
|
|
1099
|
+
return measureDockerCapacity({
|
|
1100
|
+
request: (path, method, body, timeoutMs, headers) => this.dockerApi(path, method, body, timeoutMs, headers),
|
|
1101
|
+
// Only a unix-socket daemon writes to storage this process can measure.
|
|
1102
|
+
daemonIsLocal: 'socketPath' in this.endpoint,
|
|
1103
|
+
statfs: async (path) => {
|
|
1104
|
+
const stats = await statfs(path);
|
|
1105
|
+
return {
|
|
1106
|
+
bsize: Number(stats.bsize),
|
|
1107
|
+
bavail: Number(stats.bavail),
|
|
1108
|
+
ffree: Number(stats.ffree),
|
|
1109
|
+
};
|
|
1110
|
+
},
|
|
1111
|
+
availableMemoryBytes,
|
|
1112
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
799
1115
|
/**
|
|
800
1116
|
* Makes an HTTP request to the Docker Engine API via the Unix socket.
|
|
801
1117
|
* @param path - The API endpoint path (e.g. `/containers/create`).
|
|
@@ -806,15 +1122,20 @@ class DockerSandboxProvider {
|
|
|
806
1122
|
* image's contents during those calls, which scales with image size — a
|
|
807
1123
|
* multi-GB `/workspace` reliably exceeds 30 s, and a client-side timeout on
|
|
808
1124
|
* a create that succeeds server-side leaks a duplicate container.
|
|
1125
|
+
* @param headers - Extra request headers. Registry operations need
|
|
1126
|
+
* `X-Registry-Auth`, which the daemon requires even for anonymous pulls.
|
|
809
1127
|
* @returns The parsed JSON response, or raw text for non-JSON responses.
|
|
810
1128
|
*/
|
|
811
|
-
async dockerApi(path, method = 'GET', body, timeoutMs = 30_000) {
|
|
1129
|
+
async dockerApi(path, method = 'GET', body, timeoutMs = 30_000, headers) {
|
|
812
1130
|
return new Promise((resolve, reject) => {
|
|
813
1131
|
const opts = {
|
|
814
1132
|
...this.endpoint,
|
|
815
1133
|
path: `/v1.44${path}`,
|
|
816
1134
|
method,
|
|
817
|
-
headers:
|
|
1135
|
+
headers: {
|
|
1136
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
1137
|
+
...headers,
|
|
1138
|
+
},
|
|
818
1139
|
};
|
|
819
1140
|
const req = http.request(opts, (res) => {
|
|
820
1141
|
const chunks = [];
|
|
@@ -845,6 +1166,96 @@ class DockerSandboxProvider {
|
|
|
845
1166
|
req.end();
|
|
846
1167
|
});
|
|
847
1168
|
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Issue a Docker Engine API GET and hand back the response body as a byte
|
|
1171
|
+
* stream.
|
|
1172
|
+
*
|
|
1173
|
+
* Unbuffered on purpose: this carries whole project trees, and materializing
|
|
1174
|
+
* one in this process to hand it to the next call trades a slow transfer for
|
|
1175
|
+
* an out-of-memory API. The error path DOES buffer, because an error body is
|
|
1176
|
+
* a sentence and the caller needs to read it.
|
|
1177
|
+
*
|
|
1178
|
+
* @param path - The API endpoint path.
|
|
1179
|
+
* @param timeoutMs - Request timeout (default 10 min).
|
|
1180
|
+
* @returns The response body as an async byte stream.
|
|
1181
|
+
*/
|
|
1182
|
+
dockerDownload(path, timeoutMs = 600_000) {
|
|
1183
|
+
return new Promise((resolve, reject) => {
|
|
1184
|
+
const req = http.request({ ...this.endpoint, path: `/v1.44${path}`, method: 'GET' }, (res) => {
|
|
1185
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
1186
|
+
const chunks = [];
|
|
1187
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
1188
|
+
res.on('end', () => {
|
|
1189
|
+
reject(new Error(t('codeSandbox.docker.error.apiError', {
|
|
1190
|
+
method: 'GET',
|
|
1191
|
+
path,
|
|
1192
|
+
status: String(res.statusCode),
|
|
1193
|
+
error: Buffer.concat(chunks).toString(),
|
|
1194
|
+
}, {
|
|
1195
|
+
defaultValue: `Docker API GET ${path}: ${res.statusCode} ${Buffer.concat(chunks).toString()}`,
|
|
1196
|
+
})));
|
|
1197
|
+
});
|
|
1198
|
+
res.on('error', reject);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
resolve(res);
|
|
1202
|
+
});
|
|
1203
|
+
req.setTimeout(timeoutMs, () => {
|
|
1204
|
+
req.destroy(new Error(`Docker API timeout: GET ${path}`));
|
|
1205
|
+
});
|
|
1206
|
+
req.on('error', reject);
|
|
1207
|
+
req.end();
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Issue a Docker Engine API PUT whose request body is a byte stream.
|
|
1212
|
+
*
|
|
1213
|
+
* Piped rather than written in a loop so Node applies backpressure — without
|
|
1214
|
+
* it, a fast source fills this process's memory with everything the socket has
|
|
1215
|
+
* not drained yet, which for a project tree is the whole tree.
|
|
1216
|
+
*
|
|
1217
|
+
* @param path - The API endpoint path.
|
|
1218
|
+
* @param body - The request body as an async byte stream.
|
|
1219
|
+
* @param timeoutMs - Request timeout (default 10 min).
|
|
1220
|
+
*/
|
|
1221
|
+
dockerUpload(path, body, timeoutMs = 600_000) {
|
|
1222
|
+
return new Promise((resolve, reject) => {
|
|
1223
|
+
const req = http.request({
|
|
1224
|
+
...this.endpoint,
|
|
1225
|
+
path: `/v1.44${path}`,
|
|
1226
|
+
method: 'PUT',
|
|
1227
|
+
headers: { 'Content-Type': 'application/x-tar' },
|
|
1228
|
+
}, (res) => {
|
|
1229
|
+
const chunks = [];
|
|
1230
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
1231
|
+
res.on('end', () => {
|
|
1232
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
1233
|
+
reject(new Error(t('codeSandbox.docker.error.apiError', {
|
|
1234
|
+
method: 'PUT',
|
|
1235
|
+
path,
|
|
1236
|
+
status: String(res.statusCode),
|
|
1237
|
+
error: Buffer.concat(chunks).toString(),
|
|
1238
|
+
}, {
|
|
1239
|
+
defaultValue: `Docker API PUT ${path}: ${res.statusCode} ${Buffer.concat(chunks).toString()}`,
|
|
1240
|
+
})));
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
resolve();
|
|
1244
|
+
});
|
|
1245
|
+
res.on('error', reject);
|
|
1246
|
+
});
|
|
1247
|
+
req.setTimeout(timeoutMs, () => {
|
|
1248
|
+
req.destroy(new Error(`Docker API timeout: PUT ${path}`));
|
|
1249
|
+
});
|
|
1250
|
+
req.on('error', reject);
|
|
1251
|
+
const source = Readable.from(body);
|
|
1252
|
+
source.on('error', (error) => {
|
|
1253
|
+
req.destroy(error);
|
|
1254
|
+
reject(error);
|
|
1255
|
+
});
|
|
1256
|
+
source.pipe(req);
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
848
1259
|
/**
|
|
849
1260
|
* Like `dockerApi` but returns the raw response Buffer without parsing.
|
|
850
1261
|
* Used for exec start responses which return a multiplexed binary stream.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Docker Engine API transports every capability module is given.
|
|
3
|
+
*
|
|
4
|
+
* Each capability lives in its own module and receives its transport as a
|
|
5
|
+
* parameter rather than reaching for `http` itself. That is not decoration: a
|
|
6
|
+
* module that opens its own socket can only be tested by intercepting HTTP, and
|
|
7
|
+
* the things worth testing here — a push that reports failure inside a 200
|
|
8
|
+
* response body, an eviction that must refuse when it cannot see what is in use —
|
|
9
|
+
* are decisions about response CONTENT, not about wire mechanics. Injecting the
|
|
10
|
+
* request function makes each of those a plain function call in a test.
|
|
11
|
+
*
|
|
12
|
+
* Three transports rather than one, because the Engine API genuinely has three
|
|
13
|
+
* shapes: JSON in/JSON out, a tar stream out, and a tar stream in. Forcing the
|
|
14
|
+
* streaming pair through the JSON transport would mean buffering a whole project
|
|
15
|
+
* tree in memory, which is the thing bulk transfer exists to avoid.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Issues a Docker Engine API request and returns the parsed JSON response (or
|
|
21
|
+
* the raw text when the body is not JSON — several endpoints stream JSON lines).
|
|
22
|
+
*
|
|
23
|
+
* Implementations MUST reject on a 4xx/5xx status. Several Engine endpoints also
|
|
24
|
+
* report failure INSIDE a 200 body; see {@link assertNoStreamError}.
|
|
25
|
+
*
|
|
26
|
+
* @param path - API path below the version prefix, e.g. `/containers/create`.
|
|
27
|
+
* @param method - HTTP method; defaults to `GET`.
|
|
28
|
+
* @param body - Optional JSON request body.
|
|
29
|
+
* @param timeoutMs - Request timeout.
|
|
30
|
+
* @param headers - Extra request headers (e.g. `X-Registry-Auth`).
|
|
31
|
+
* @returns The parsed JSON response, or raw text for non-JSON bodies.
|
|
32
|
+
*/
|
|
33
|
+
export type DockerRequest = (path: string, method?: string, body?: unknown, timeoutMs?: number, headers?: Record<string, string>) => Promise<unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* Issues a Docker Engine API GET and hands back the response body as a byte
|
|
36
|
+
* stream, unbuffered.
|
|
37
|
+
*
|
|
38
|
+
* @param path - API path below the version prefix.
|
|
39
|
+
* @param timeoutMs - Request timeout.
|
|
40
|
+
* @returns The response body as an async byte stream.
|
|
41
|
+
*/
|
|
42
|
+
export type DockerDownload = (path: string, timeoutMs?: number) => Promise<AsyncIterable<Uint8Array>>;
|
|
43
|
+
/**
|
|
44
|
+
* Issues a Docker Engine API PUT whose request body is a byte stream,
|
|
45
|
+
* unbuffered and with backpressure.
|
|
46
|
+
*
|
|
47
|
+
* @param path - API path below the version prefix.
|
|
48
|
+
* @param body - The request body as an async byte stream.
|
|
49
|
+
* @param timeoutMs - Request timeout.
|
|
50
|
+
*/
|
|
51
|
+
export type DockerUpload = (path: string, body: AsyncIterable<Uint8Array>, timeoutMs?: number) => Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Fail on an error reported inside an otherwise-successful streaming response.
|
|
54
|
+
*
|
|
55
|
+
* `/images/create` (pull), `/images/{name}/push` and friends answer `200 OK` and
|
|
56
|
+
* then stream newline-delimited JSON progress objects. A failure — no such
|
|
57
|
+
* manifest, denied credentials, a broken layer — arrives as an `error` field in
|
|
58
|
+
* that stream, LONG after the status line. Treating the 200 as success is how a
|
|
59
|
+
* fleet ends up believing every host has a template that only one host has.
|
|
60
|
+
*
|
|
61
|
+
* @param response - The value returned by a {@link DockerRequest} for a streaming endpoint.
|
|
62
|
+
* @param operation - Short description used in the thrown message.
|
|
63
|
+
* @throws {Error} When the stream carried an error object.
|
|
64
|
+
*/
|
|
65
|
+
export declare function assertNoStreamError(response: unknown, operation: string): void;
|
|
66
|
+
//# sourceMappingURL=request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,OAAO,EACd,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAC7B,OAAO,CAAC,OAAO,CAAC,CAAA;AAErB;;;;;;;GAOG;AACH,MAAM,MAAM,cAAc,GAAG,CAC3B,IAAI,EAAE,MAAM,EACZ,SAAS,CAAC,EAAE,MAAM,KACf,OAAO,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,CAAA;AAEvC;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,EAC/B,SAAS,CAAC,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,CAAA;AAElB;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAsB9E"}
|
package/dist/request.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Docker Engine API transports every capability module is given.
|
|
3
|
+
*
|
|
4
|
+
* Each capability lives in its own module and receives its transport as a
|
|
5
|
+
* parameter rather than reaching for `http` itself. That is not decoration: a
|
|
6
|
+
* module that opens its own socket can only be tested by intercepting HTTP, and
|
|
7
|
+
* the things worth testing here — a push that reports failure inside a 200
|
|
8
|
+
* response body, an eviction that must refuse when it cannot see what is in use —
|
|
9
|
+
* are decisions about response CONTENT, not about wire mechanics. Injecting the
|
|
10
|
+
* request function makes each of those a plain function call in a test.
|
|
11
|
+
*
|
|
12
|
+
* Three transports rather than one, because the Engine API genuinely has three
|
|
13
|
+
* shapes: JSON in/JSON out, a tar stream out, and a tar stream in. Forcing the
|
|
14
|
+
* streaming pair through the JSON transport would mean buffering a whole project
|
|
15
|
+
* tree in memory, which is the thing bulk transfer exists to avoid.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Fail on an error reported inside an otherwise-successful streaming response.
|
|
21
|
+
*
|
|
22
|
+
* `/images/create` (pull), `/images/{name}/push` and friends answer `200 OK` and
|
|
23
|
+
* then stream newline-delimited JSON progress objects. A failure — no such
|
|
24
|
+
* manifest, denied credentials, a broken layer — arrives as an `error` field in
|
|
25
|
+
* that stream, LONG after the status line. Treating the 200 as success is how a
|
|
26
|
+
* fleet ends up believing every host has a template that only one host has.
|
|
27
|
+
*
|
|
28
|
+
* @param response - The value returned by a {@link DockerRequest} for a streaming endpoint.
|
|
29
|
+
* @param operation - Short description used in the thrown message.
|
|
30
|
+
* @throws {Error} When the stream carried an error object.
|
|
31
|
+
*/
|
|
32
|
+
export function assertNoStreamError(response, operation) {
|
|
33
|
+
if (typeof response !== 'string') {
|
|
34
|
+
// A parsed object comes from a single-JSON-document endpoint; those already
|
|
35
|
+
// surfaced failure through the status code.
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const line of response.split('\n')) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (!trimmed.startsWith('{'))
|
|
41
|
+
continue;
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(trimmed);
|
|
45
|
+
}
|
|
46
|
+
catch (_error) {
|
|
47
|
+
// A partial line from a chunked stream is not an error report; the error
|
|
48
|
+
// object, if there is one, is a complete line of its own.
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const message = parsed.errorDetail?.message ?? parsed.error;
|
|
52
|
+
if (message !== undefined && message !== null && message !== '') {
|
|
53
|
+
throw new Error(`Docker ${operation} failed: ${String(message)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|