@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/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
3
|
+
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
|
+
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
|
+
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
+
Generated: 2026-08-05T12:53:11.363Z
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
# @molecule/api-code-sandbox-docker
|
|
10
|
+
|
|
11
|
+
> **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
|
|
12
|
+
> It is written to be read by coding agents as much as by people, and is generated from this
|
|
13
|
+
> package's source — edit `src/index.ts` JSDoc, not this file.
|
|
14
|
+
|
|
15
|
+
Docker code-sandbox provider for molecule.dev.
|
|
16
|
+
|
|
17
|
+
## Type
|
|
18
|
+
|
|
19
|
+
`provider`
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @molecule/api-code-sandbox-docker @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
### Interfaces
|
|
30
|
+
|
|
31
|
+
#### `ContainerInfo`
|
|
32
|
+
|
|
33
|
+
Container Info interface.
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
interface ContainerInfo {
|
|
37
|
+
id: string
|
|
38
|
+
name: string
|
|
39
|
+
status: string
|
|
40
|
+
ports: Array<{ host: number; container: number }>
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### `DockerConfig`
|
|
45
|
+
|
|
46
|
+
Configuration for docker.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
interface DockerConfig {
|
|
50
|
+
/**
|
|
51
|
+
* Docker unix-socket path. Defaults to `/var/run/docker.sock` (or the
|
|
52
|
+
* `DOCKER_SOCKET_PATH` env var). This is the default transport; it is ignored
|
|
53
|
+
* when a TCP endpoint is selected via `host`/`port` or a `tcp://` `DOCKER_HOST`.
|
|
54
|
+
*/
|
|
55
|
+
socketPath?: string
|
|
56
|
+
/**
|
|
57
|
+
* Connect to a remote/TCP Docker daemon at this host instead of the unix
|
|
58
|
+
* socket. When set, the provider issues all Docker Engine API calls over
|
|
59
|
+
* plain TCP to `host:port` (`port` defaults to 2375) and `socketPath` is
|
|
60
|
+
* ignored. Also honored via `DOCKER_HOST` (`tcp://host:port`). Plain
|
|
61
|
+
* (unencrypted) TCP only — for a TLS-protected daemon (2376) front it with a
|
|
62
|
+
* local socket proxy and point `socketPath` at that.
|
|
63
|
+
*/
|
|
64
|
+
host?: string
|
|
65
|
+
/** TCP port for `host` (default 2375). Ignored unless `host` (or a `tcp://` `DOCKER_HOST`) selects a TCP endpoint. */
|
|
66
|
+
port?: number
|
|
67
|
+
/** Base image for sandbox containers. Defaults to node:22-slim. Must already be present on the host — the provider never pulls images. */
|
|
68
|
+
baseImage?: string
|
|
69
|
+
/** Default CPU allocation (cores). */
|
|
70
|
+
defaultCpu?: number
|
|
71
|
+
/** Default memory allocation (MB). */
|
|
72
|
+
defaultMemoryMB?: number
|
|
73
|
+
/**
|
|
74
|
+
* Docker network the sandbox containers attach to (sets each container's
|
|
75
|
+
* `NetworkMode`). Overrides the `SANDBOX_DOCKER_NETWORK` env var. Defaults to
|
|
76
|
+
* the ICC-off isolated `molecule-sandbox` network, auto-created on first use so
|
|
77
|
+
* tenants are L2-isolated by default. Setting it to the shared `bridge` gives NO
|
|
78
|
+
* cross-tenant isolation and is refused in production. [C1-1]
|
|
79
|
+
*/
|
|
80
|
+
network?: string
|
|
81
|
+
/** Container label prefix for identification. */
|
|
82
|
+
labelPrefix?: string
|
|
83
|
+
/** Preview URL template. Use {port} placeholder. */
|
|
84
|
+
previewUrlTemplate?: string
|
|
85
|
+
/**
|
|
86
|
+
* Docker repository holding sandbox TEMPLATES — one tag per template id.
|
|
87
|
+
* Defaults to `molecule-sandbox-template` (or `SANDBOX_TEMPLATE_REPOSITORY`).
|
|
88
|
+
*
|
|
89
|
+
* Deliberately NOT the base image's repository. Sharing one repository between
|
|
90
|
+
* the base image and every template makes `docker images <repo>` and any
|
|
91
|
+
* cleanup written against it operate on both, and the only thing standing
|
|
92
|
+
* between a template sweep and the base image is a tag-prefix convention.
|
|
93
|
+
*/
|
|
94
|
+
templateRepository?: string
|
|
95
|
+
/**
|
|
96
|
+
* Registry host for sharing templates across hosts, e.g. `registry.example.com`
|
|
97
|
+
* (or `SANDBOX_TEMPLATE_REGISTRY`). When unset, `publishTemplate`/`fetchTemplate`
|
|
98
|
+
* throw with an actionable message: the daemon CAN talk to a registry, so the
|
|
99
|
+
* capability is present and only the address is missing.
|
|
100
|
+
*/
|
|
101
|
+
templateRegistry?: string
|
|
102
|
+
/**
|
|
103
|
+
* Base64-encoded Docker `X-Registry-Auth` value for {@link DockerConfig.templateRegistry}
|
|
104
|
+
* (or `SANDBOX_TEMPLATE_REGISTRY_AUTH`). Defaults to an empty credential set,
|
|
105
|
+
* which is what an anonymous or already-logged-in daemon needs.
|
|
106
|
+
*/
|
|
107
|
+
templateRegistryAuth?: string
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### `ProcessEnv`
|
|
112
|
+
|
|
113
|
+
Environment variables the Docker sandbox provider reads.
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
interface ProcessEnv {
|
|
117
|
+
/** Unix-socket path for the Docker daemon (default `/var/run/docker.sock`). Overridden by `config.socketPath`. */
|
|
118
|
+
DOCKER_SOCKET_PATH?: string
|
|
119
|
+
/** Docker daemon endpoint, docker-client style: `tcp://host:port` or `unix:///path/to/docker.sock`. Overridden by `config.host`/`config.socketPath`. */
|
|
120
|
+
DOCKER_HOST?: string
|
|
121
|
+
/** Docker network sandbox containers attach to (default `molecule-sandbox`, ICC-off). Overridden by `config.network`. [C1-1] */
|
|
122
|
+
SANDBOX_DOCKER_NETWORK?: string
|
|
123
|
+
/** Docker repository holding sandbox templates (default `molecule-sandbox-template`). Overridden by `config.templateRepository`. */
|
|
124
|
+
SANDBOX_TEMPLATE_REPOSITORY?: string
|
|
125
|
+
/** Registry host for sharing templates across hosts. Overridden by `config.templateRegistry`. */
|
|
126
|
+
SANDBOX_TEMPLATE_REGISTRY?: string
|
|
127
|
+
/** Base64 `X-Registry-Auth` value for `SANDBOX_TEMPLATE_REGISTRY`. Overridden by `config.templateRegistryAuth`. */
|
|
128
|
+
SANDBOX_TEMPLATE_REGISTRY_AUTH?: string
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Functions
|
|
133
|
+
|
|
134
|
+
#### `createProvider(config)`
|
|
135
|
+
|
|
136
|
+
Creates a new `DockerSandboxProvider` instance with the given configuration.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
function createProvider(config?: DockerConfig): SandboxProvider
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `config` — Optional Docker-specific configuration: daemon endpoint (`socketPath`, or `host`/`port` for a TCP daemon; `DOCKER_HOST` is also honored), `baseImage`, CPU/memory defaults, and the sandbox `network`.
|
|
143
|
+
|
|
144
|
+
**Returns:** A `SandboxProvider` that manages Docker containers as sandboxes.
|
|
145
|
+
|
|
146
|
+
#### `resolvePublishPorts(ports)`
|
|
147
|
+
|
|
148
|
+
Validate the ports a caller asked to publish, falling back to this provider's
|
|
149
|
+
historical API + Vite pair when it named none.
|
|
150
|
+
|
|
151
|
+
Rejects rather than filters: a caller that asked for a port it cannot have
|
|
152
|
+
needs to hear so, because the alternative is a dev server running inside a
|
|
153
|
+
sandbox that nothing on the outside can ever reach — which looks like a broken
|
|
154
|
+
application, not a bad argument.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
function resolvePublishPorts(ports?: number[]): number[]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- `ports` — Ports inside the sandbox the caller wants reachable.
|
|
161
|
+
|
|
162
|
+
**Returns:** The ports to expose and bind.
|
|
163
|
+
|
|
164
|
+
#### `withTransientRetry(op, opts)`
|
|
165
|
+
|
|
166
|
+
Retry a Docker API operation that failed with a TRANSIENT fault — a request
|
|
167
|
+
timeout or a connection-level reset, i.e. the daemon was momentarily overwhelmed,
|
|
168
|
+
not the request malformed. This is the fix for the observed "first cold boot of a
|
|
169
|
+
concurrent batch dies on `Docker API timeout: POST /containers/create`" failure:
|
|
170
|
+
a single 30 s create timeout under daemon load currently kills the whole boot.
|
|
171
|
+
|
|
172
|
+
HTTP error responses (4xx/5xx) are deliberately NOT retried — those are real
|
|
173
|
+
answers (no-such-image, name conflict), not transient network faults. Bounded
|
|
174
|
+
attempts with linear backoff.
|
|
175
|
+
|
|
176
|
+
`onRetry` is the idempotency guard: a create that TIMED OUT client-side may have
|
|
177
|
+
still succeeded server-side, so before re-issuing it the caller can adopt the
|
|
178
|
+
already-created resource (looked up by a unique label) instead of leaking a
|
|
179
|
+
duplicate container. If `onRetry` returns non-null, that value is used and the
|
|
180
|
+
operation is not re-issued.
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
function withTransientRetry(
|
|
184
|
+
op: () => Promise<T>,
|
|
185
|
+
opts: {
|
|
186
|
+
label: string
|
|
187
|
+
attempts?: number
|
|
188
|
+
onRetry?: () => Promise<T | null>
|
|
189
|
+
delayMs?: (attempt: number) => number
|
|
190
|
+
log?: { warn: (message: string, meta?: unknown) => void }
|
|
191
|
+
},
|
|
192
|
+
): Promise<T>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- `op` — the operation to attempt.
|
|
196
|
+
- `opts` — tuning + the optional adopt-on-retry guard.
|
|
197
|
+
- `opts.label` — short operation name for log lines.
|
|
198
|
+
- `opts.attempts` — max attempts (default 3).
|
|
199
|
+
- `opts.onRetry` — adopt-an-existing-resource guard, run before each retry.
|
|
200
|
+
- `opts.delayMs` — backoff for attempt N (default `400 * N` ms).
|
|
201
|
+
- `opts.log` — optional logger for retry warnings.
|
|
202
|
+
|
|
203
|
+
**Returns:** the operation's result, or an adopted result from `onRetry`.
|
|
204
|
+
|
|
205
|
+
### Constants
|
|
206
|
+
|
|
207
|
+
#### `provider`
|
|
208
|
+
|
|
209
|
+
The provider implementation.
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const provider: SandboxProvider
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Core Interface
|
|
216
|
+
|
|
217
|
+
Implements `@molecule/api-code-sandbox` interface.
|
|
218
|
+
|
|
219
|
+
## Bond Wiring
|
|
220
|
+
|
|
221
|
+
Setup function to register this provider with the core interface:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import { setProvider } from '@molecule/api-code-sandbox'
|
|
225
|
+
import { provider } from '@molecule/api-code-sandbox-docker'
|
|
226
|
+
|
|
227
|
+
export function setupCodeSandboxDocker(): void {
|
|
228
|
+
setProvider(provider)
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Injection Notes
|
|
233
|
+
|
|
234
|
+
### Requirements
|
|
235
|
+
|
|
236
|
+
Peer dependencies:
|
|
237
|
+
|
|
238
|
+
- `@molecule/api-bond` ^1.0.1
|
|
239
|
+
- `@molecule/api-code-sandbox` ^1.0.1
|
|
240
|
+
- `@molecule/api-i18n` ^1.0.1
|
|
241
|
+
|
|
242
|
+
### Runtime Dependencies
|
|
243
|
+
|
|
244
|
+
- `@molecule/api-bond`
|
|
245
|
+
- `@molecule/api-code-sandbox`
|
|
246
|
+
- `@molecule/api-i18n`
|
|
247
|
+
|
|
248
|
+
**Tenant network isolation (secure default).** Each sandbox is placed on a dedicated
|
|
249
|
+
user-defined Docker network created with inter-container communication DISABLED
|
|
250
|
+
(`com.docker.network.bridge.enable_icc=false`), so one tenant's sandbox cannot reach another
|
|
251
|
+
tenant's Vite/API dev-server ports by IP. Do NOT set `SANDBOX_DOCKER_NETWORK="bridge"` — the
|
|
252
|
+
shared docker bridge has ICC enabled (no cross-tenant isolation) and is REFUSED in production.
|
|
253
|
+
Override `SANDBOX_DOCKER_NETWORK` only to point at another dedicated ICC-off network. This is
|
|
254
|
+
an L2 isolation control; pair it with host-layer default-deny egress filtering (operator-
|
|
255
|
+
provisioned) for full isolation. [C1-1]
|
|
256
|
+
|
|
257
|
+
**Prerequisites.** A reachable Docker daemon and the base image already pulled
|
|
258
|
+
on the host. The daemon is reached over a unix socket by default
|
|
259
|
+
(`config.socketPath` ?? `DOCKER_SOCKET_PATH` ?? `/var/run/docker.sock`); a
|
|
260
|
+
remote or rootless daemon can be selected with `config.host`/`config.port` or a
|
|
261
|
+
`DOCKER_HOST` (`tcp://host:port` or `unix:///path`). Only PLAIN (unencrypted)
|
|
262
|
+
TCP is supported — front a TLS-protected daemon (2376) with a local socket
|
|
263
|
+
proxy. The provider never pulls images, so `create()` fails with a no-such-image
|
|
264
|
+
error if the base image (default `node:22-slim`, or `config.baseImage`) is
|
|
265
|
+
absent. The isolated sandbox network is auto-created on first use; it is NOT a
|
|
266
|
+
prerequisite.
|
|
267
|
+
|
|
268
|
+
**No per-sandbox disk quota.** The Docker API cannot portably cap a
|
|
269
|
+
container/volume size (it needs specific storage drivers — e.g. overlay2 on xfs
|
|
270
|
+
with `pquota` — and errors on the common overlay2/ext4 host), so this provider
|
|
271
|
+
enforces none. The core `resources.diskMB` is accepted but not applied here; cap
|
|
272
|
+
disk at the host / volume level instead. `setResources({ diskMB })` THROWS
|
|
273
|
+
rather than accepting a ceiling it cannot enforce.
|
|
274
|
+
|
|
275
|
+
**Templates are not `commit` alone, and that is not an optimization.**
|
|
276
|
+
`commitTemplate` copies the caller-named `capturePaths` into a throwaway,
|
|
277
|
+
volume-less container and commits THAT. A sandbox keeps the project on a
|
|
278
|
+
volume, and a volume is not part of a container's writable layer — so
|
|
279
|
+
committing a sandbox directly yields an image that builds, tags, and boots
|
|
280
|
+
with an empty workspace, with no error anywhere. The throwaway container
|
|
281
|
+
handles tenant-authored bytes and is committed into something other tenants
|
|
282
|
+
boot, so it runs with `--network none`, `CapDrop: ALL` plus the three
|
|
283
|
+
capabilities extraction needs, a memory cap and a process cap, and setuid/setgid
|
|
284
|
+
bits are stripped from the extracted tree afterwards.
|
|
285
|
+
|
|
286
|
+
Templates live in their OWN repository (`templateRepository`, default
|
|
287
|
+
`molecule-sandbox-template`) rather than sharing the base image's, so nothing
|
|
288
|
+
written to sweep templates can reach the base image. `publishTemplate` /
|
|
289
|
+
`fetchTemplate` use a Docker registry and require `templateRegistry`; without
|
|
290
|
+
it they THROW rather than returning "not found", because "there is no shared
|
|
291
|
+
store" and "the shared store does not have it" are different answers.
|
|
292
|
+
|
|
293
|
+
**`hibernate()`/`resume()` report which mechanism ran.** A CRIU checkpoint
|
|
294
|
+
preserves the process tree; a stop does not, and whether CRIU is available is a
|
|
295
|
+
property of the host (the daemon's experimental flag plus the `criu` package),
|
|
296
|
+
discovered at runtime. A sandbox that runs its dev servers as detached exec
|
|
297
|
+
processes comes back from a stop-style wake alive and serving nothing, so
|
|
298
|
+
callers must branch on `processesPreserved` — never on `status`, which says
|
|
299
|
+
`running` either way. The first response that shows CRIU is unavailable
|
|
300
|
+
disables it for the rest of the process.
|
|
301
|
+
|
|
302
|
+
**`find()` costs one request per match.** Docker's container LIST omits
|
|
303
|
+
`State.StartedAt`, which is the only field distinguishing an interrupted
|
|
304
|
+
creation (`startedAt: null` — holds storage forever, will never run) from an
|
|
305
|
+
ordinary stopped sandbox, so each match is then inspected. Narrow with
|
|
306
|
+
`labels`; an unfiltered query on a busy host inspects every container.
|
|
307
|
+
|
|
308
|
+
**`capacity()` measures THIS host, so it reports nothing for a TCP daemon.**
|
|
309
|
+
Over a `tcp://` endpoint the daemon writes to a different machine's storage,
|
|
310
|
+
and returning this machine's numbers would be a confident wrong answer — so it
|
|
311
|
+
returns empty `headroom` and says why. On a local socket it reports free bytes
|
|
312
|
+
and free inodes on the daemon's `DockerRootDir` plus `MemAvailable`. Inodes are
|
|
313
|
+
omitted (not reported as zero) on filesystems with dynamic inode allocation.
|
|
314
|
+
`admits` is always `null`: Docker has no quota of its own to consult, so the
|
|
315
|
+
floors and the refusal stay with the caller.
|
|
316
|
+
|
|
317
|
+
## Translations
|
|
318
|
+
|
|
319
|
+
Translation strings are provided by `@molecule/api-locales-code-sandbox-docker`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulk file transfer in and out of a Docker sandbox, as POSIX tar streams.
|
|
3
|
+
*
|
|
4
|
+
* The Engine API has a first-class endpoint pair for this
|
|
5
|
+
* (`GET`/`PUT /containers/{id}/archive`), so the bond does not need to
|
|
6
|
+
* orchestrate `tar` through an exec. That matters beyond tidiness: an exec-based
|
|
7
|
+
* pipe has to survive two hijacked sockets and gives no status code you can
|
|
8
|
+
* trust, which is how a workspace capture ends up "succeeding" with half a tree.
|
|
9
|
+
*
|
|
10
|
+
* Nothing is buffered. A project tree is gigabytes across hundreds of thousands
|
|
11
|
+
* of files; reading one into this process to hand it to the next call would trade
|
|
12
|
+
* a slow transfer for a dead API.
|
|
13
|
+
*
|
|
14
|
+
* **Extraction does not restore ownership or setuid bits.** `copyUIDGID` is left
|
|
15
|
+
* off, and callers capturing a tenant-authored tree into something another tenant
|
|
16
|
+
* boots must additionally strip setuid/setgid — `commitTemplate` does. An archive
|
|
17
|
+
* is data, and honoring the mode bits in data is how it becomes an escalation.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import type { DockerDownload, DockerUpload } from './request.js';
|
|
22
|
+
/** What the archive capability needs from the provider. */
|
|
23
|
+
export interface ArchiveContext {
|
|
24
|
+
download: DockerDownload;
|
|
25
|
+
upload: DockerUpload;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Stream a directory tree out of a sandbox as a POSIX tar archive.
|
|
29
|
+
*
|
|
30
|
+
* The archive is rooted at the LAST segment of `path` — an export of
|
|
31
|
+
* `/workspace` yields entries under `workspace/` — which is what makes it
|
|
32
|
+
* extractable into the parent directory on the way back in.
|
|
33
|
+
*
|
|
34
|
+
* @param ctx - Archive context.
|
|
35
|
+
* @param containerId - The sandbox to read from.
|
|
36
|
+
* @param path - Absolute path inside the sandbox.
|
|
37
|
+
* @returns A tar byte stream.
|
|
38
|
+
*/
|
|
39
|
+
export declare function exportContainerFiles(ctx: ArchiveContext, containerId: string, path: string): Promise<AsyncIterable<Uint8Array>>;
|
|
40
|
+
/**
|
|
41
|
+
* Stream a POSIX tar archive into a sandbox, extracting it at `path`.
|
|
42
|
+
*
|
|
43
|
+
* @param ctx - Archive context.
|
|
44
|
+
* @param containerId - The sandbox to write to.
|
|
45
|
+
* @param path - Absolute destination directory inside the sandbox.
|
|
46
|
+
* @param archive - The tar byte stream to extract.
|
|
47
|
+
*/
|
|
48
|
+
export declare function importContainerFiles(ctx: ArchiveContext, containerId: string, path: string, archive: AsyncIterable<Uint8Array>): Promise<void>;
|
|
49
|
+
//# sourceMappingURL=archive.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAEhE,2DAA2D;AAC3D,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,cAAc,CAAA;IACxB,MAAM,EAAE,YAAY,CAAA;CACrB;AAmBD;;;;;;;;;;;GAWG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,CAMpC;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,aAAa,CAAC,UAAU,CAAC,GACjC,OAAO,CAAC,IAAI,CAAC,CAOf"}
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulk file transfer in and out of a Docker sandbox, as POSIX tar streams.
|
|
3
|
+
*
|
|
4
|
+
* The Engine API has a first-class endpoint pair for this
|
|
5
|
+
* (`GET`/`PUT /containers/{id}/archive`), so the bond does not need to
|
|
6
|
+
* orchestrate `tar` through an exec. That matters beyond tidiness: an exec-based
|
|
7
|
+
* pipe has to survive two hijacked sockets and gives no status code you can
|
|
8
|
+
* trust, which is how a workspace capture ends up "succeeding" with half a tree.
|
|
9
|
+
*
|
|
10
|
+
* Nothing is buffered. A project tree is gigabytes across hundreds of thousands
|
|
11
|
+
* of files; reading one into this process to hand it to the next call would trade
|
|
12
|
+
* a slow transfer for a dead API.
|
|
13
|
+
*
|
|
14
|
+
* **Extraction does not restore ownership or setuid bits.** `copyUIDGID` is left
|
|
15
|
+
* off, and callers capturing a tenant-authored tree into something another tenant
|
|
16
|
+
* boots must additionally strip setuid/setgid — `commitTemplate` does. An archive
|
|
17
|
+
* is data, and honoring the mode bits in data is how it becomes an escalation.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
/** Generous by design: this moves whole project trees, not files. */
|
|
22
|
+
const TRANSFER_TIMEOUT_MS = 600_000;
|
|
23
|
+
/**
|
|
24
|
+
* Reject a path that is not a plain absolute path.
|
|
25
|
+
*
|
|
26
|
+
* @param path - Candidate path.
|
|
27
|
+
* @throws {Error} When the path is relative or contains a traversal segment.
|
|
28
|
+
*/
|
|
29
|
+
function assertAbsolutePath(path) {
|
|
30
|
+
if (!path.startsWith('/') || path.includes('..') || path.includes('\0')) {
|
|
31
|
+
throw new Error(`Invalid sandbox path ${JSON.stringify(path.slice(0, 64))}: must be absolute and free of ".." segments`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Stream a directory tree out of a sandbox as a POSIX tar archive.
|
|
36
|
+
*
|
|
37
|
+
* The archive is rooted at the LAST segment of `path` — an export of
|
|
38
|
+
* `/workspace` yields entries under `workspace/` — which is what makes it
|
|
39
|
+
* extractable into the parent directory on the way back in.
|
|
40
|
+
*
|
|
41
|
+
* @param ctx - Archive context.
|
|
42
|
+
* @param containerId - The sandbox to read from.
|
|
43
|
+
* @param path - Absolute path inside the sandbox.
|
|
44
|
+
* @returns A tar byte stream.
|
|
45
|
+
*/
|
|
46
|
+
export async function exportContainerFiles(ctx, containerId, path) {
|
|
47
|
+
assertAbsolutePath(path);
|
|
48
|
+
return ctx.download(`/containers/${containerId}/archive?path=${encodeURIComponent(path)}`, TRANSFER_TIMEOUT_MS);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stream a POSIX tar archive into a sandbox, extracting it at `path`.
|
|
52
|
+
*
|
|
53
|
+
* @param ctx - Archive context.
|
|
54
|
+
* @param containerId - The sandbox to write to.
|
|
55
|
+
* @param path - Absolute destination directory inside the sandbox.
|
|
56
|
+
* @param archive - The tar byte stream to extract.
|
|
57
|
+
*/
|
|
58
|
+
export async function importContainerFiles(ctx, containerId, path, archive) {
|
|
59
|
+
assertAbsolutePath(path);
|
|
60
|
+
await ctx.upload(`/containers/${containerId}/archive?path=${encodeURIComponent(path)}`, archive, TRANSFER_TIMEOUT_MS);
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this Docker host can still fit.
|
|
3
|
+
*
|
|
4
|
+
* A sandbox is admitted long before it is expensive: creating one populates a
|
|
5
|
+
* fresh volume with a dependency tree, and by the time that fails the host is
|
|
6
|
+
* already out of something and every OTHER sandbox on it is failing too. So the
|
|
7
|
+
* caller wants to refuse the boot while it can still say so cleanly — and to do
|
|
8
|
+
* that it needs a measurement only the provider can take.
|
|
9
|
+
*
|
|
10
|
+
* Three axes, because they run out separately and independently:
|
|
11
|
+
*
|
|
12
|
+
* - **Blocks.** The obvious one.
|
|
13
|
+
* - **Inodes.** A populated `node_modules` is hundreds of thousands of tiny
|
|
14
|
+
* files. On a filesystem with a fixed inode table this exhausts first, and
|
|
15
|
+
* every file creation on the host then fails with `ENOSPC` while `df -h`
|
|
16
|
+
* reports gigabytes free — a failure that looks like nothing else.
|
|
17
|
+
* - **Memory.** Each running sandbox holds a live container resident, and unlike
|
|
18
|
+
* disk this is NOT reclaimed by idle hibernation on any useful timescale. A
|
|
19
|
+
* burst of simultaneously-active users oversubscribes the host until the OOM
|
|
20
|
+
* killer starts choosing victims among sandboxes that were working fine.
|
|
21
|
+
*
|
|
22
|
+
* `MemAvailable` from `/proc/meminfo`, not `os.freemem()`. The kernel's estimate
|
|
23
|
+
* counts reclaimable page cache; `MemFree` does not, and on any warm host it
|
|
24
|
+
* understates free memory badly enough to reject boots the host could serve.
|
|
25
|
+
*
|
|
26
|
+
* **This measures the host THIS PROCESS runs on**, which is only the right host
|
|
27
|
+
* when the daemon is local. Over a TCP endpoint the numbers would describe the
|
|
28
|
+
* wrong machine, so the answer is `inconclusive`-shaped instead: no headroom
|
|
29
|
+
* fields, and a `detail` that says why. Reporting a comfortable local number for
|
|
30
|
+
* a remote daemon is the exact conflation this codebase refuses everywhere else.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
import type { SandboxCapacity } from '@molecule/api-code-sandbox';
|
|
35
|
+
import type { DockerRequest } from './request.js';
|
|
36
|
+
/** The subset of `fs.StatsFs` this module uses. */
|
|
37
|
+
export interface FilesystemStats {
|
|
38
|
+
/** Block size in bytes. */
|
|
39
|
+
bsize: number;
|
|
40
|
+
/** Blocks available to an unprivileged process. */
|
|
41
|
+
bavail: number;
|
|
42
|
+
/** Free file nodes, or `-1`/`0` on filesystems without a fixed inode table. */
|
|
43
|
+
ffree: number;
|
|
44
|
+
}
|
|
45
|
+
/** What the capacity capability needs from the provider. */
|
|
46
|
+
export interface CapacityContext {
|
|
47
|
+
request: DockerRequest;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the daemon is reached over a local unix socket. Only then does this
|
|
50
|
+
* process's filesystem describe the storage the daemon writes to.
|
|
51
|
+
*/
|
|
52
|
+
daemonIsLocal: boolean;
|
|
53
|
+
/** Filesystem statistics for a path. Injected so this is testable without a disk. */
|
|
54
|
+
statfs: (path: string) => Promise<FilesystemStats>;
|
|
55
|
+
/** Available memory in bytes, or `null` when it cannot be read. */
|
|
56
|
+
availableMemoryBytes: () => Promise<number | null>;
|
|
57
|
+
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Measure what the host backing this daemon can still fit.
|
|
61
|
+
*
|
|
62
|
+
* `admits` is always `null`: Docker has no admission opinion of its own — it has
|
|
63
|
+
* no quota, no plan, and no notion of "too many". The caller compares the
|
|
64
|
+
* headroom against its own floors. A provider that DID have such an opinion
|
|
65
|
+
* (an account quota, a rate limit) would answer here instead.
|
|
66
|
+
*
|
|
67
|
+
* @param ctx - Capacity context.
|
|
68
|
+
* @returns Measured headroom and an explanation of anything unmeasurable.
|
|
69
|
+
*/
|
|
70
|
+
export declare function measureDockerCapacity(ctx: CapacityContext): Promise<SandboxCapacity>;
|
|
71
|
+
//# sourceMappingURL=capacity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capacity.d.ts","sourceRoot":"","sources":["../src/capacity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEjD,mDAAmD;AACnD,MAAM,WAAW,eAAe;IAC9B,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAA;IACd,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAA;CACd;AAED,4DAA4D;AAC5D,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,aAAa,CAAA;IACtB;;;OAGG;IACH,aAAa,EAAE,OAAO,CAAA;IACtB,qFAAqF;IACrF,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;IAClD,mEAAmE;IACnE,oBAAoB,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAClD,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;CACjE;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAuD1F"}
|
package/dist/capacity.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this Docker host can still fit.
|
|
3
|
+
*
|
|
4
|
+
* A sandbox is admitted long before it is expensive: creating one populates a
|
|
5
|
+
* fresh volume with a dependency tree, and by the time that fails the host is
|
|
6
|
+
* already out of something and every OTHER sandbox on it is failing too. So the
|
|
7
|
+
* caller wants to refuse the boot while it can still say so cleanly — and to do
|
|
8
|
+
* that it needs a measurement only the provider can take.
|
|
9
|
+
*
|
|
10
|
+
* Three axes, because they run out separately and independently:
|
|
11
|
+
*
|
|
12
|
+
* - **Blocks.** The obvious one.
|
|
13
|
+
* - **Inodes.** A populated `node_modules` is hundreds of thousands of tiny
|
|
14
|
+
* files. On a filesystem with a fixed inode table this exhausts first, and
|
|
15
|
+
* every file creation on the host then fails with `ENOSPC` while `df -h`
|
|
16
|
+
* reports gigabytes free — a failure that looks like nothing else.
|
|
17
|
+
* - **Memory.** Each running sandbox holds a live container resident, and unlike
|
|
18
|
+
* disk this is NOT reclaimed by idle hibernation on any useful timescale. A
|
|
19
|
+
* burst of simultaneously-active users oversubscribes the host until the OOM
|
|
20
|
+
* killer starts choosing victims among sandboxes that were working fine.
|
|
21
|
+
*
|
|
22
|
+
* `MemAvailable` from `/proc/meminfo`, not `os.freemem()`. The kernel's estimate
|
|
23
|
+
* counts reclaimable page cache; `MemFree` does not, and on any warm host it
|
|
24
|
+
* understates free memory badly enough to reject boots the host could serve.
|
|
25
|
+
*
|
|
26
|
+
* **This measures the host THIS PROCESS runs on**, which is only the right host
|
|
27
|
+
* when the daemon is local. Over a TCP endpoint the numbers would describe the
|
|
28
|
+
* wrong machine, so the answer is `inconclusive`-shaped instead: no headroom
|
|
29
|
+
* fields, and a `detail` that says why. Reporting a comfortable local number for
|
|
30
|
+
* a remote daemon is the exact conflation this codebase refuses everywhere else.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Measure what the host backing this daemon can still fit.
|
|
36
|
+
*
|
|
37
|
+
* `admits` is always `null`: Docker has no admission opinion of its own — it has
|
|
38
|
+
* no quota, no plan, and no notion of "too many". The caller compares the
|
|
39
|
+
* headroom against its own floors. A provider that DID have such an opinion
|
|
40
|
+
* (an account quota, a rate limit) would answer here instead.
|
|
41
|
+
*
|
|
42
|
+
* @param ctx - Capacity context.
|
|
43
|
+
* @returns Measured headroom and an explanation of anything unmeasurable.
|
|
44
|
+
*/
|
|
45
|
+
export async function measureDockerCapacity(ctx) {
|
|
46
|
+
if (!ctx.daemonIsLocal) {
|
|
47
|
+
return {
|
|
48
|
+
admits: null,
|
|
49
|
+
detail: 'The Docker daemon is reached over a TCP endpoint, so this process cannot measure the ' +
|
|
50
|
+
'storage or memory the daemon actually writes to. No headroom is reported rather than ' +
|
|
51
|
+
"reporting this machine's numbers for a different machine.",
|
|
52
|
+
headroom: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const notes = [];
|
|
56
|
+
const headroom = {};
|
|
57
|
+
let rootDir = '/var/lib/docker';
|
|
58
|
+
try {
|
|
59
|
+
const info = (await ctx.request('/info'));
|
|
60
|
+
if (info?.DockerRootDir)
|
|
61
|
+
rootDir = info.DockerRootDir;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Not fatal: the default root is right on the overwhelming majority of
|
|
65
|
+
// hosts, and a wrong path surfaces as a statfs failure below rather than as
|
|
66
|
+
// a confident wrong number.
|
|
67
|
+
ctx.warn?.('Could not read DockerRootDir from the daemon — assuming the default', { error });
|
|
68
|
+
notes.push(`could not read the daemon's storage root, assumed ${rootDir}`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const stats = await ctx.statfs(rootDir);
|
|
72
|
+
headroom.diskBytes = stats.bavail * stats.bsize;
|
|
73
|
+
// A filesystem with dynamic inodes (btrfs, xfs) reports 0 or -1 free nodes.
|
|
74
|
+
// That is "not applicable", not "exhausted", so it is omitted rather than
|
|
75
|
+
// reported as zero — a caller comparing against a floor would refuse every
|
|
76
|
+
// boot on such a host.
|
|
77
|
+
if (stats.ffree > 0)
|
|
78
|
+
headroom.inodes = stats.ffree;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
ctx.warn?.('Could not measure free space on the daemon storage root', { rootDir, error });
|
|
82
|
+
notes.push(`free space on ${rootDir} is unmeasurable`);
|
|
83
|
+
}
|
|
84
|
+
const memoryBytes = await ctx.availableMemoryBytes();
|
|
85
|
+
if (memoryBytes === null) {
|
|
86
|
+
notes.push('available memory is unreadable on this platform');
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
headroom.memoryBytes = memoryBytes;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
admits: null,
|
|
93
|
+
detail: notes.length > 0
|
|
94
|
+
? `Measured the host backing the local Docker daemon (${rootDir}); ${notes.join('; ')}.`
|
|
95
|
+
: `Measured the host backing the local Docker daemon (${rootDir}).`,
|
|
96
|
+
headroom,
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/egress.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { EgressVerdict } from '@molecule/api-code-sandbox';
|
|
2
|
+
import type { DockerRequest } from './request.js';
|
|
3
|
+
/** Inputs the probe needs from the provider. */
|
|
4
|
+
export interface EgressProbeContext {
|
|
5
|
+
request: DockerRequest;
|
|
6
|
+
/** The sandbox network name — the probe must be subject to the same rules. */
|
|
7
|
+
network: string;
|
|
8
|
+
/** Image to run the probe in; needs a `node` binary. */
|
|
9
|
+
baseImage: string;
|
|
10
|
+
/** Label namespace, used for the orphan sweep. */
|
|
11
|
+
labelPrefix: string;
|
|
12
|
+
/** Optional logger for cleanup failures. */
|
|
13
|
+
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The client program: raw `net.connect` to each target.
|
|
17
|
+
*
|
|
18
|
+
* Raw sockets, never `fetch` — proxy env is blanked AND a raw connect ignores it
|
|
19
|
+
* regardless, so this exercises the DIRECT path the proxy does not cover and only
|
|
20
|
+
* the firewall stops.
|
|
21
|
+
*
|
|
22
|
+
* @param targets - Destinations to attempt.
|
|
23
|
+
* @param timeoutMs - Per-connection timeout.
|
|
24
|
+
* @returns Program source.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildProbeScript(targets: Array<{
|
|
27
|
+
host: string;
|
|
28
|
+
port: number;
|
|
29
|
+
}>, timeoutMs: number): string;
|
|
30
|
+
/**
|
|
31
|
+
* The host-netns listener program for leg 2.
|
|
32
|
+
*
|
|
33
|
+
* @param port - Port to bind.
|
|
34
|
+
* @param timeoutMs - How long to wait for a connection.
|
|
35
|
+
* @returns Program source.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildListenerScript(port: number, timeoutMs: number): string;
|
|
38
|
+
/**
|
|
39
|
+
* Run BOTH legs and combine them into one verdict.
|
|
40
|
+
*
|
|
41
|
+
* A `filtered` verdict requires both legs to be denied. Either leg observing a
|
|
42
|
+
* successful connection is conclusive `open`; anything that could not be observed
|
|
43
|
+
* is `inconclusive`.
|
|
44
|
+
*
|
|
45
|
+
* @param ctx - Probe context.
|
|
46
|
+
* @returns The combined verdict.
|
|
47
|
+
*/
|
|
48
|
+
export declare function verifyDockerEgress(ctx: EgressProbeContext): Promise<EgressVerdict>;
|
|
49
|
+
//# sourceMappingURL=egress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"egress.d.ts","sourceRoot":"","sources":["../src/egress.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEjD,gDAAgD;AAChD,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,aAAa,CAAA;IACtB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAA;IACf,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAA;IACjB,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAA;IACnB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;CACjE;AAoFD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAC9C,SAAS,EAAE,MAAM,GAChB,MAAM,CAUR;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAU3E;AA2OD;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CA6ExF"}
|