@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/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
- const image = config.image ?? this.baseImage;
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: 512,
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
- PortBindings: {
325
- '4000/tcp': [{ HostIp: '127.0.0.1', HostPort: '' }],
326
- '5173/tcp': [{ HostIp: '127.0.0.1', HostPort: '' }],
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: body ? { 'Content-Type': 'application/json' } : undefined,
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"}
@@ -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
+ }