@schemavaults/sandbox-management-sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -2
- package/dist/commands.d.ts +16 -0
- package/dist/commands.js +99 -0
- package/dist/dbh/index.d.ts +4 -0
- package/dist/dbh/index.js +2 -0
- package/dist/dbh/migration.d.ts +18 -0
- package/dist/dbh/migration.js +41 -0
- package/dist/dbh/store.d.ts +42 -0
- package/dist/dbh/store.js +105 -0
- package/dist/docker/egress-proxy/config.d.ts +20 -0
- package/dist/docker/egress-proxy/config.js +29 -0
- package/dist/docker/egress-proxy/main.d.ts +1 -0
- package/dist/docker/egress-proxy/main.js +19 -0
- package/dist/docker/egress-proxy/policy.d.ts +49 -0
- package/dist/docker/egress-proxy/policy.js +89 -0
- package/dist/docker/egress-proxy/server.d.ts +25 -0
- package/dist/docker/egress-proxy/server.js +225 -0
- package/dist/docker/egress.d.ts +41 -0
- package/dist/docker/egress.js +67 -0
- package/dist/docker/index.d.ts +3 -0
- package/dist/docker/index.js +2 -0
- package/dist/docker/provider.d.ts +101 -0
- package/dist/docker/provider.js +373 -0
- package/dist/docker/tar.d.ts +20 -0
- package/dist/docker/tar.js +124 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +67 -0
- package/dist/in-memory-store.d.ts +11 -0
- package/dist/in-memory-store.js +40 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +8 -0
- package/dist/internal/base64.d.ts +7 -0
- package/dist/internal/base64.js +20 -0
- package/dist/internal/output.d.ts +6 -0
- package/dist/internal/output.js +25 -0
- package/dist/internal/posix-path.d.ts +8 -0
- package/dist/internal/posix-path.js +38 -0
- package/dist/manager.d.ts +126 -0
- package/dist/manager.js +477 -0
- package/dist/network.d.ts +27 -0
- package/dist/network.js +128 -0
- package/dist/provider.d.ts +77 -0
- package/dist/provider.js +1 -0
- package/dist/shell-main-process.d.ts +27 -0
- package/dist/shell-main-process.js +94 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.js +19 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/mock-provider.d.ts +99 -0
- package/dist/testing/mock-provider.js +181 -0
- package/dist/types.d.ts +158 -0
- package/dist/types.js +21 -0
- package/dist/vercel/index.d.ts +2 -0
- package/dist/vercel/index.js +1 -0
- package/dist/vercel/provider.d.ts +125 -0
- package/dist/vercel/provider.js +190 -0
- package/dist/web-storage/index.d.ts +2 -0
- package/dist/web-storage/index.js +1 -0
- package/dist/web-storage/store.d.ts +53 -0
- package/dist/web-storage/store.js +118 -0
- package/package.json +117 -4
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import Docker from "dockerode";
|
|
2
|
+
import type { MainLogChunk, ProviderCreateInput, ProviderCreateResult, ProviderSandbox, ProviderSandboxStatus, ResolvedSandboxFile, SandboxProvider } from "../provider.js";
|
|
3
|
+
import type { ExecOptions, ExecResult, MainProcessInfo, NormalizedCommand } from "../types.js";
|
|
4
|
+
export type DockerPullPolicy = "if-not-present" | "always" | "never";
|
|
5
|
+
/** Per-sandbox options, set as `spec.providerOptions.docker`. */
|
|
6
|
+
export interface DockerSandboxOptions {
|
|
7
|
+
/** Image to run. Must provide `/bin/sh`; `git` is needed for `spec.git`. */
|
|
8
|
+
image?: string;
|
|
9
|
+
/** User commands run as (container `User`). */
|
|
10
|
+
user?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Docker network to attach the container to. With a `spec.network`
|
|
13
|
+
* allowlist, this is the network the egress proxy reaches the outside
|
|
14
|
+
* through instead (default `"bridge"`). Leave it unset for `"deny-all"`.
|
|
15
|
+
*/
|
|
16
|
+
network?: string;
|
|
17
|
+
pullPolicy?: DockerPullPolicy;
|
|
18
|
+
/** Extra container labels. */
|
|
19
|
+
labels?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
declare module "../types.js" {
|
|
22
|
+
interface SandboxProviderOptions {
|
|
23
|
+
docker?: DockerSandboxOptions;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export interface DockerSandboxProviderOptions {
|
|
27
|
+
/** A configured dockerode client. Defaults to `new Docker()` (honors `DOCKER_HOST`). */
|
|
28
|
+
docker?: Docker;
|
|
29
|
+
/** Provider name used in sandbox records. Default `"docker"`. */
|
|
30
|
+
name?: string;
|
|
31
|
+
/** Image used when the spec does not set one. */
|
|
32
|
+
defaultImage?: string;
|
|
33
|
+
/** Default `"/workspace"`. */
|
|
34
|
+
defaultWorkdir?: string;
|
|
35
|
+
/** Default `"if-not-present"`. */
|
|
36
|
+
pullPolicy?: DockerPullPolicy;
|
|
37
|
+
/** Host interface published ports bind to. Default `"127.0.0.1"`. */
|
|
38
|
+
portBindingHost?: string;
|
|
39
|
+
/** Host name used in URLs from `getUrl`. Default `"localhost"`. */
|
|
40
|
+
urlHost?: string;
|
|
41
|
+
/** Container name prefix. Default `"sandbox-"`. */
|
|
42
|
+
containerNamePrefix?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Image of the egress proxy that enforces `spec.network` allowlists. Default
|
|
45
|
+
* `DOCKER_EGRESS_PROXY_IMAGE`, published for each SDK release. Pulled
|
|
46
|
+
* according to `pullPolicy`.
|
|
47
|
+
*/
|
|
48
|
+
egressProxyImage?: string;
|
|
49
|
+
/**
|
|
50
|
+
* How long to wait for an egress proxy to report healthy. Default 60 seconds.
|
|
51
|
+
* Docker Engine 25+ reports it within a second; older engines ignore the
|
|
52
|
+
* image's `--start-interval` and take up to its 30-second interval.
|
|
53
|
+
*/
|
|
54
|
+
egressProxyStartTimeoutMs?: number;
|
|
55
|
+
}
|
|
56
|
+
export declare const DOCKER_SANDBOX_ID_LABEL = "dev.schemavaults.sandbox.id";
|
|
57
|
+
/**
|
|
58
|
+
* Runs each sandbox as a long-lived container. `stop` / `wake` map to
|
|
59
|
+
* `docker stop` / `docker start`, which keep the container filesystem.
|
|
60
|
+
* Docker has no native timeout: run `SandboxManager.stopExpired()` periodically.
|
|
61
|
+
*
|
|
62
|
+
* A sandbox with a `spec.network` allowlist (or `"deny-all"` and `ports`) gets
|
|
63
|
+
* an internal network of its own, with no route out, and an egress proxy
|
|
64
|
+
* container (`<name>-egress`) that is its only way to the outside: it enforces
|
|
65
|
+
* the allowlist for HTTP(S) and forwards the published ports.
|
|
66
|
+
*/
|
|
67
|
+
export declare class DockerSandboxProvider implements SandboxProvider {
|
|
68
|
+
readonly name: string;
|
|
69
|
+
readonly defaultWorkdir: string;
|
|
70
|
+
/** `"deny-all"` without ports is `--network none`; the rest runs behind an egress proxy. */
|
|
71
|
+
readonly networkPolicies: readonly ["deny-all", "allowlist"];
|
|
72
|
+
private readonly docker;
|
|
73
|
+
private readonly defaultImage;
|
|
74
|
+
private readonly pullPolicy;
|
|
75
|
+
private readonly portBindingHost;
|
|
76
|
+
private readonly urlHost;
|
|
77
|
+
private readonly containerNamePrefix;
|
|
78
|
+
private readonly egressProxyImage;
|
|
79
|
+
private readonly egressProxyStartTimeoutMs;
|
|
80
|
+
constructor(options?: DockerSandboxProviderOptions);
|
|
81
|
+
create(input: ProviderCreateInput): Promise<ProviderCreateResult>;
|
|
82
|
+
wake(sandbox: ProviderSandbox): Promise<void>;
|
|
83
|
+
stop(sandbox: ProviderSandbox): Promise<void>;
|
|
84
|
+
destroy(sandbox: ProviderSandbox): Promise<void>;
|
|
85
|
+
status(sandbox: ProviderSandbox): Promise<ProviderSandboxStatus>;
|
|
86
|
+
exec(sandbox: ProviderSandbox, command: NormalizedCommand, options?: ExecOptions): Promise<ExecResult>;
|
|
87
|
+
startMainProcess(sandbox: ProviderSandbox, command: NormalizedCommand): Promise<void>;
|
|
88
|
+
getMainProcess(sandbox: ProviderSandbox): Promise<MainProcessInfo | null>;
|
|
89
|
+
readMainLog(sandbox: ProviderSandbox, offset: number): Promise<MainLogChunk>;
|
|
90
|
+
writeFiles(sandbox: ProviderSandbox, files: ResolvedSandboxFile[]): Promise<void>;
|
|
91
|
+
readFile(sandbox: ProviderSandbox, path: string): Promise<Uint8Array | null>;
|
|
92
|
+
getUrl(sandbox: ProviderSandbox, port: number): Promise<string>;
|
|
93
|
+
private container;
|
|
94
|
+
private inspect;
|
|
95
|
+
private assertRunning;
|
|
96
|
+
private findEgressProxy;
|
|
97
|
+
private requireEgressProxy;
|
|
98
|
+
/** Waits for the proxy's HEALTHCHECK to pass. An image without one counts as ready once running. */
|
|
99
|
+
private waitForEgressProxy;
|
|
100
|
+
private ensureImage;
|
|
101
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import Docker from "dockerode";
|
|
2
|
+
import { ProviderSandboxMissingError, ProviderSandboxStoppedError, SandboxError } from "../errors.js";
|
|
3
|
+
import { outputCollector } from "../internal/output.js";
|
|
4
|
+
import { shellGetMainProcess, shellReadMainLog, shellStartMainProcess } from "../shell-main-process.js";
|
|
5
|
+
import { DOCKER_EGRESS_PROXY_IMAGE, DOCKER_EGRESS_PROXY_LABEL, EGRESS_PROXY_ALIAS, SANDBOX_ALIAS, dockerNetworkPlan, egressProxyConfig, egressProxyEnv, usesEgressProxy, } from "./egress.js";
|
|
6
|
+
import { EGRESS_PROXY_CONFIG_ENV } from "./egress-proxy/config.js";
|
|
7
|
+
import { createTar, extractFirstFile } from "./tar.js";
|
|
8
|
+
export const DOCKER_SANDBOX_ID_LABEL = "dev.schemavaults.sandbox.id";
|
|
9
|
+
/** PID 1 of every sandbox container: idle until stopped, exiting promptly on SIGTERM. */
|
|
10
|
+
const KEEPALIVE_SCRIPT = "trap 'exit 0' TERM INT; while :; do sleep 3600 & wait $!; done";
|
|
11
|
+
/**
|
|
12
|
+
* Runs each sandbox as a long-lived container. `stop` / `wake` map to
|
|
13
|
+
* `docker stop` / `docker start`, which keep the container filesystem.
|
|
14
|
+
* Docker has no native timeout: run `SandboxManager.stopExpired()` periodically.
|
|
15
|
+
*
|
|
16
|
+
* A sandbox with a `spec.network` allowlist (or `"deny-all"` and `ports`) gets
|
|
17
|
+
* an internal network of its own, with no route out, and an egress proxy
|
|
18
|
+
* container (`<name>-egress`) that is its only way to the outside: it enforces
|
|
19
|
+
* the allowlist for HTTP(S) and forwards the published ports.
|
|
20
|
+
*/
|
|
21
|
+
export class DockerSandboxProvider {
|
|
22
|
+
name;
|
|
23
|
+
defaultWorkdir;
|
|
24
|
+
/** `"deny-all"` without ports is `--network none`; the rest runs behind an egress proxy. */
|
|
25
|
+
networkPolicies = ["deny-all", "allowlist"];
|
|
26
|
+
docker;
|
|
27
|
+
defaultImage;
|
|
28
|
+
pullPolicy;
|
|
29
|
+
portBindingHost;
|
|
30
|
+
urlHost;
|
|
31
|
+
containerNamePrefix;
|
|
32
|
+
egressProxyImage;
|
|
33
|
+
egressProxyStartTimeoutMs;
|
|
34
|
+
constructor(options = {}) {
|
|
35
|
+
this.docker = options.docker ?? new Docker();
|
|
36
|
+
this.name = options.name ?? "docker";
|
|
37
|
+
this.defaultImage = options.defaultImage;
|
|
38
|
+
this.defaultWorkdir = options.defaultWorkdir ?? "/workspace";
|
|
39
|
+
this.pullPolicy = options.pullPolicy ?? "if-not-present";
|
|
40
|
+
this.portBindingHost = options.portBindingHost ?? "127.0.0.1";
|
|
41
|
+
this.urlHost = options.urlHost ?? "localhost";
|
|
42
|
+
this.containerNamePrefix = options.containerNamePrefix ?? "sandbox-";
|
|
43
|
+
this.egressProxyImage = options.egressProxyImage ?? DOCKER_EGRESS_PROXY_IMAGE;
|
|
44
|
+
this.egressProxyStartTimeoutMs = options.egressProxyStartTimeoutMs ?? 60_000;
|
|
45
|
+
}
|
|
46
|
+
async create(input) {
|
|
47
|
+
const options = input.spec.providerOptions?.docker ?? {};
|
|
48
|
+
const image = options.image ?? this.defaultImage;
|
|
49
|
+
if (!image) {
|
|
50
|
+
throw new SandboxError("No Docker image: set spec.providerOptions.docker.image or defaultImage");
|
|
51
|
+
}
|
|
52
|
+
const plan = dockerNetworkPlan(this, input.spec, options.network);
|
|
53
|
+
await this.ensureImage(image, options.pullPolicy ?? this.pullPolicy);
|
|
54
|
+
if (plan.mode === "proxy")
|
|
55
|
+
await this.ensureImage(this.egressProxyImage, this.pullPolicy);
|
|
56
|
+
const name = `${this.containerNamePrefix}${input.id}`;
|
|
57
|
+
const ports = (input.spec.ports ?? []).map((port) => `${port}/tcp`);
|
|
58
|
+
const exposedPorts = Object.fromEntries(ports.map((port) => [port, {}]));
|
|
59
|
+
const portBindings = Object.fromEntries(ports.map((port) => [port, [{ HostIp: this.portBindingHost, HostPort: "" }]]));
|
|
60
|
+
const { vcpus, memoryMb } = input.spec.resources ?? {};
|
|
61
|
+
const undo = [];
|
|
62
|
+
try {
|
|
63
|
+
let env = input.spec.env ?? {};
|
|
64
|
+
let proxy;
|
|
65
|
+
let internalNetwork;
|
|
66
|
+
if (plan.mode === "proxy") {
|
|
67
|
+
internalNetwork = `${name}-net`;
|
|
68
|
+
const network = await this.docker.createNetwork({
|
|
69
|
+
Name: internalNetwork,
|
|
70
|
+
Driver: "bridge",
|
|
71
|
+
Internal: true,
|
|
72
|
+
Labels: { [DOCKER_EGRESS_PROXY_LABEL]: input.id },
|
|
73
|
+
});
|
|
74
|
+
undo.push(() => network.remove());
|
|
75
|
+
const subnets = (await network.inspect()).IPAM?.Config?.flatMap((config) => config.Subnet ? [config.Subnet] : []);
|
|
76
|
+
const config = egressProxyConfig(plan, input.spec, subnets ?? []);
|
|
77
|
+
const egressProxy = await this.docker.createContainer({
|
|
78
|
+
name: `${name}-egress`,
|
|
79
|
+
Image: this.egressProxyImage,
|
|
80
|
+
Env: [`${EGRESS_PROXY_CONFIG_ENV}=${JSON.stringify(config)}`],
|
|
81
|
+
Labels: { ...options.labels, [DOCKER_EGRESS_PROXY_LABEL]: input.id },
|
|
82
|
+
ExposedPorts: exposedPorts,
|
|
83
|
+
HostConfig: {
|
|
84
|
+
Init: true,
|
|
85
|
+
NetworkMode: plan.outsideNetwork,
|
|
86
|
+
PortBindings: portBindings,
|
|
87
|
+
ReadonlyRootfs: true,
|
|
88
|
+
CapDrop: ["ALL"],
|
|
89
|
+
SecurityOpt: ["no-new-privileges"],
|
|
90
|
+
RestartPolicy: { Name: "on-failure" },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
proxy = egressProxy;
|
|
94
|
+
undo.push(() => egressProxy.remove({ force: true }));
|
|
95
|
+
await network.connect({ Container: egressProxy.id, EndpointConfig: { Aliases: [EGRESS_PROXY_ALIAS] } });
|
|
96
|
+
env = { ...egressProxyEnv(plan.proxyPort), ...env };
|
|
97
|
+
}
|
|
98
|
+
const container = await this.docker.createContainer({
|
|
99
|
+
name,
|
|
100
|
+
Image: image,
|
|
101
|
+
Entrypoint: ["/bin/sh", "-c", KEEPALIVE_SCRIPT],
|
|
102
|
+
Cmd: [],
|
|
103
|
+
WorkingDir: input.workdir,
|
|
104
|
+
Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
|
|
105
|
+
User: options.user,
|
|
106
|
+
Labels: { ...options.labels, [DOCKER_SANDBOX_ID_LABEL]: input.id },
|
|
107
|
+
ExposedPorts: exposedPorts,
|
|
108
|
+
HostConfig: {
|
|
109
|
+
Init: true,
|
|
110
|
+
// Behind a proxy, the proxy publishes the ports: Docker publishes none from internal networks.
|
|
111
|
+
PortBindings: proxy ? {} : portBindings,
|
|
112
|
+
Memory: memoryMb ? memoryMb * 1024 * 1024 : undefined,
|
|
113
|
+
NanoCpus: vcpus ? Math.round(vcpus * 1e9) : undefined,
|
|
114
|
+
NetworkMode: internalNetwork ?? (plan.mode === "direct" ? plan.networkMode : undefined),
|
|
115
|
+
},
|
|
116
|
+
NetworkingConfig: internalNetwork
|
|
117
|
+
? { EndpointsConfig: { [internalNetwork]: { Aliases: [SANDBOX_ALIAS] } } }
|
|
118
|
+
: undefined,
|
|
119
|
+
});
|
|
120
|
+
undo.push(() => container.remove({ force: true }));
|
|
121
|
+
if (proxy) {
|
|
122
|
+
await proxy.start();
|
|
123
|
+
await this.waitForEgressProxy(proxy);
|
|
124
|
+
}
|
|
125
|
+
await container.start();
|
|
126
|
+
return { providerRef: container.id };
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
for (const step of undo.reverse())
|
|
130
|
+
await step().catch(() => undefined);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async wake(sandbox) {
|
|
135
|
+
const info = await this.inspect(sandbox);
|
|
136
|
+
if (usesEgressProxy(sandbox.spec)) {
|
|
137
|
+
const proxy = await this.requireEgressProxy(sandbox);
|
|
138
|
+
if (!(await proxy.inspect()).State.Running)
|
|
139
|
+
await proxy.start();
|
|
140
|
+
await this.waitForEgressProxy(proxy);
|
|
141
|
+
}
|
|
142
|
+
if (!info.State.Running) {
|
|
143
|
+
await this.container(sandbox).start();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async stop(sandbox) {
|
|
147
|
+
const info = await this.inspect(sandbox);
|
|
148
|
+
if (info.State.Running)
|
|
149
|
+
await stopContainer(this.container(sandbox));
|
|
150
|
+
if (usesEgressProxy(sandbox.spec)) {
|
|
151
|
+
const proxy = await this.findEgressProxy(sandbox);
|
|
152
|
+
if (proxy)
|
|
153
|
+
await stopContainer(proxy);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async destroy(sandbox) {
|
|
157
|
+
await ignoreNotFound(this.container(sandbox).remove({ force: true, v: true }));
|
|
158
|
+
// The egress proxy and internal network, if any. Looked up by label so they are removed even if the spec changed.
|
|
159
|
+
const filters = { label: [`${DOCKER_EGRESS_PROXY_LABEL}=${sandbox.id}`] };
|
|
160
|
+
for (const proxy of await this.docker.listContainers({ all: true, filters })) {
|
|
161
|
+
await ignoreNotFound(this.docker.getContainer(proxy.Id).remove({ force: true }));
|
|
162
|
+
}
|
|
163
|
+
for (const network of await this.docker.listNetworks({ filters })) {
|
|
164
|
+
await ignoreNotFound(this.docker.getNetwork(network.Id).remove());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async status(sandbox) {
|
|
168
|
+
try {
|
|
169
|
+
const info = await this.inspect(sandbox);
|
|
170
|
+
if (!usesEgressProxy(sandbox.spec))
|
|
171
|
+
return info.State.Running ? "running" : "stopped";
|
|
172
|
+
// Without its proxy the sandbox cannot reach anything: it only counts as running with it.
|
|
173
|
+
const proxy = await this.findEgressProxy(sandbox);
|
|
174
|
+
if (!proxy)
|
|
175
|
+
return "missing";
|
|
176
|
+
return info.State.Running && (await proxy.inspect()).State.Running ? "running" : "stopped";
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error instanceof ProviderSandboxMissingError)
|
|
180
|
+
return "missing";
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async exec(sandbox, command, options = {}) {
|
|
185
|
+
await this.assertRunning(sandbox);
|
|
186
|
+
const exec = await this.container(sandbox).exec({
|
|
187
|
+
Cmd: [command.cmd, ...command.args],
|
|
188
|
+
Env: Object.entries(command.env).map(([key, value]) => `${key}=${value}`),
|
|
189
|
+
WorkingDir: command.cwd,
|
|
190
|
+
AttachStdout: true,
|
|
191
|
+
AttachStderr: true,
|
|
192
|
+
});
|
|
193
|
+
const stream = (await exec.start({ hijack: true, stdin: false }));
|
|
194
|
+
const stdout = outputCollector(options.onStdout);
|
|
195
|
+
const stderr = outputCollector(options.onStderr);
|
|
196
|
+
this.docker.modem.demuxStream(stream, stdout.writable, stderr.writable);
|
|
197
|
+
await waitForStream(stream, options);
|
|
198
|
+
// The exit code can lag the end of the stream briefly.
|
|
199
|
+
let info = await exec.inspect();
|
|
200
|
+
for (let attempt = 0; info.Running && attempt < 50; attempt++) {
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
202
|
+
info = await exec.inspect();
|
|
203
|
+
}
|
|
204
|
+
return { exitCode: info.ExitCode ?? -1, stdout: stdout.finish(), stderr: stderr.finish() };
|
|
205
|
+
}
|
|
206
|
+
async startMainProcess(sandbox, command) {
|
|
207
|
+
await shellStartMainProcess((cmd) => this.exec(sandbox, cmd), async (launcher) => {
|
|
208
|
+
const exec = await this.container(sandbox).exec({
|
|
209
|
+
Cmd: [launcher.cmd, ...launcher.args],
|
|
210
|
+
WorkingDir: launcher.cwd,
|
|
211
|
+
});
|
|
212
|
+
await exec.start({ Detach: true });
|
|
213
|
+
}, command);
|
|
214
|
+
}
|
|
215
|
+
async getMainProcess(sandbox) {
|
|
216
|
+
return shellGetMainProcess((cmd) => this.exec(sandbox, cmd));
|
|
217
|
+
}
|
|
218
|
+
async readMainLog(sandbox, offset) {
|
|
219
|
+
return shellReadMainLog((cmd) => this.exec(sandbox, cmd), offset);
|
|
220
|
+
}
|
|
221
|
+
async writeFiles(sandbox, files) {
|
|
222
|
+
await this.assertRunning(sandbox);
|
|
223
|
+
const tar = createTar(files.map((file) => ({ path: file.path, content: file.content, mode: file.mode })));
|
|
224
|
+
await this.container(sandbox).putArchive(Buffer.from(tar), { path: "/", copyUIDGID: true });
|
|
225
|
+
}
|
|
226
|
+
async readFile(sandbox, path) {
|
|
227
|
+
await this.assertRunning(sandbox);
|
|
228
|
+
let stream;
|
|
229
|
+
try {
|
|
230
|
+
stream = await this.container(sandbox).getArchive({ path });
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (statusCode(error) === 404)
|
|
234
|
+
return null;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
const chunks = [];
|
|
238
|
+
for await (const chunk of stream)
|
|
239
|
+
chunks.push(Buffer.from(chunk));
|
|
240
|
+
const file = extractFirstFile(new Uint8Array(Buffer.concat(chunks)));
|
|
241
|
+
return file?.content ?? null;
|
|
242
|
+
}
|
|
243
|
+
async getUrl(sandbox, port) {
|
|
244
|
+
let info = await this.assertRunning(sandbox);
|
|
245
|
+
if (usesEgressProxy(sandbox.spec))
|
|
246
|
+
info = await (await this.requireEgressProxy(sandbox)).inspect();
|
|
247
|
+
const hostPort = info.NetworkSettings.Ports?.[`${port}/tcp`]?.[0]?.HostPort;
|
|
248
|
+
if (!hostPort)
|
|
249
|
+
throw new SandboxError(`Port ${port} is not published on sandbox "${sandbox.id}"`);
|
|
250
|
+
return `http://${this.urlHost}:${hostPort}`;
|
|
251
|
+
}
|
|
252
|
+
container(sandbox) {
|
|
253
|
+
return this.docker.getContainer(sandbox.providerRef);
|
|
254
|
+
}
|
|
255
|
+
async inspect(sandbox) {
|
|
256
|
+
try {
|
|
257
|
+
return await this.container(sandbox).inspect();
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (statusCode(error) === 404)
|
|
261
|
+
throw new ProviderSandboxMissingError(sandbox.providerRef);
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async assertRunning(sandbox) {
|
|
266
|
+
const info = await this.inspect(sandbox);
|
|
267
|
+
if (!info.State.Running)
|
|
268
|
+
throw new ProviderSandboxStoppedError(sandbox.providerRef);
|
|
269
|
+
return info;
|
|
270
|
+
}
|
|
271
|
+
async findEgressProxy(sandbox) {
|
|
272
|
+
const [proxy] = await this.docker.listContainers({
|
|
273
|
+
all: true,
|
|
274
|
+
filters: { label: [`${DOCKER_EGRESS_PROXY_LABEL}=${sandbox.id}`] },
|
|
275
|
+
});
|
|
276
|
+
return proxy ? this.docker.getContainer(proxy.Id) : null;
|
|
277
|
+
}
|
|
278
|
+
async requireEgressProxy(sandbox) {
|
|
279
|
+
const proxy = await this.findEgressProxy(sandbox);
|
|
280
|
+
if (!proxy) {
|
|
281
|
+
throw new SandboxError(`The egress proxy of sandbox "${sandbox.id}" no longer exists; destroy and recreate the sandbox`);
|
|
282
|
+
}
|
|
283
|
+
return proxy;
|
|
284
|
+
}
|
|
285
|
+
/** Waits for the proxy's HEALTHCHECK to pass. An image without one counts as ready once running. */
|
|
286
|
+
async waitForEgressProxy(proxy) {
|
|
287
|
+
const deadline = Date.now() + this.egressProxyStartTimeoutMs;
|
|
288
|
+
for (;;) {
|
|
289
|
+
const { Name, State } = await proxy.inspect();
|
|
290
|
+
const health = State.Health?.Status;
|
|
291
|
+
if (State.Running && (health === undefined || health === "healthy"))
|
|
292
|
+
return;
|
|
293
|
+
const name = Name.replace(/^\//, "");
|
|
294
|
+
if (!State.Running && !State.Restarting) {
|
|
295
|
+
throw new SandboxError(`Egress proxy ${name} exited with code ${State.ExitCode}; see \`docker logs ${name}\``);
|
|
296
|
+
}
|
|
297
|
+
if (health === "unhealthy" || Date.now() > deadline) {
|
|
298
|
+
throw new SandboxError(`Egress proxy ${name} did not become healthy; see \`docker logs ${name}\``);
|
|
299
|
+
}
|
|
300
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async ensureImage(image, policy) {
|
|
304
|
+
if (policy === "never")
|
|
305
|
+
return;
|
|
306
|
+
if (policy === "if-not-present") {
|
|
307
|
+
try {
|
|
308
|
+
await this.docker.getImage(image).inspect();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (statusCode(error) !== 404)
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const stream = await this.docker.pull(image);
|
|
317
|
+
await new Promise((resolve, reject) => {
|
|
318
|
+
this.docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
|
|
319
|
+
});
|
|
320
|
+
await this.docker.getImage(image).inspect();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function stopContainer(container) {
|
|
324
|
+
try {
|
|
325
|
+
await container.stop({ t: 10 });
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (statusCode(error) !== 304)
|
|
329
|
+
throw error; // 304: already stopped
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function ignoreNotFound(operation) {
|
|
333
|
+
try {
|
|
334
|
+
await operation;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (statusCode(error) !== 404)
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function statusCode(error) {
|
|
342
|
+
return typeof error === "object" && error !== null && "statusCode" in error
|
|
343
|
+
? error.statusCode
|
|
344
|
+
: undefined;
|
|
345
|
+
}
|
|
346
|
+
function waitForStream(stream, options) {
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
const timer = options.timeoutMs === undefined
|
|
349
|
+
? undefined
|
|
350
|
+
: setTimeout(() => fail(new SandboxError(`Command timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
|
|
351
|
+
const onAbort = () => fail(new SandboxError("Command aborted"));
|
|
352
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
353
|
+
if (options.signal?.aborted)
|
|
354
|
+
return onAbort();
|
|
355
|
+
stream.once("end", done);
|
|
356
|
+
stream.once("close", done);
|
|
357
|
+
stream.once("error", fail);
|
|
358
|
+
function cleanup() {
|
|
359
|
+
if (timer)
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
362
|
+
}
|
|
363
|
+
function done() {
|
|
364
|
+
cleanup();
|
|
365
|
+
resolve();
|
|
366
|
+
}
|
|
367
|
+
function fail(error) {
|
|
368
|
+
cleanup();
|
|
369
|
+
stream.destroy();
|
|
370
|
+
reject(error);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal POSIX tar support for Docker's archive endpoints: write regular
|
|
3
|
+
* files (with PAX headers for long paths) and read back the first file.
|
|
4
|
+
*/
|
|
5
|
+
export interface TarFileEntry {
|
|
6
|
+
/** Path inside the archive, without a leading slash. */
|
|
7
|
+
path: string;
|
|
8
|
+
content: Uint8Array;
|
|
9
|
+
mode: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function createTar(entries: TarFileEntry[]): Uint8Array;
|
|
12
|
+
export interface TarExtractedFile {
|
|
13
|
+
path: string;
|
|
14
|
+
content: Uint8Array;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The first regular file in the archive. Throws if the archive's first entry
|
|
18
|
+
* is a directory; resolves `null` for an empty archive.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractFirstFile(tar: Uint8Array): TarExtractedFile | null;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal POSIX tar support for Docker's archive endpoints: write regular
|
|
3
|
+
* files (with PAX headers for long paths) and read back the first file.
|
|
4
|
+
*/
|
|
5
|
+
const BLOCK = 512;
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
export function createTar(entries) {
|
|
9
|
+
const parts = [];
|
|
10
|
+
const mtime = Math.floor(Date.now() / 1000);
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const path = entry.path.replace(/^\/+/, "");
|
|
13
|
+
if (encoder.encode(path).length > 100) {
|
|
14
|
+
const pax = paxRecord("path", path);
|
|
15
|
+
parts.push(header({ name: "././@PaxHeader", mode: 0o644, size: pax.length, mtime, type: "x" }), pad(pax));
|
|
16
|
+
}
|
|
17
|
+
parts.push(header({ name: truncateUtf8(path, 100), mode: entry.mode, size: entry.content.length, mtime, type: "0" }), pad(entry.content));
|
|
18
|
+
}
|
|
19
|
+
parts.push(new Uint8Array(BLOCK * 2));
|
|
20
|
+
return concat(parts);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The first regular file in the archive. Throws if the archive's first entry
|
|
24
|
+
* is a directory; resolves `null` for an empty archive.
|
|
25
|
+
*/
|
|
26
|
+
export function extractFirstFile(tar) {
|
|
27
|
+
let offset = 0;
|
|
28
|
+
let paxPath;
|
|
29
|
+
while (offset + BLOCK <= tar.length) {
|
|
30
|
+
const block = tar.subarray(offset, offset + BLOCK);
|
|
31
|
+
if (block.every((byte) => byte === 0))
|
|
32
|
+
return null;
|
|
33
|
+
const name = readString(block, 0, 100);
|
|
34
|
+
const prefix = readString(block, 345, 155);
|
|
35
|
+
const size = readOctal(block, 124, 12);
|
|
36
|
+
const type = String.fromCharCode(block[156] ?? 0);
|
|
37
|
+
const dataStart = offset + BLOCK;
|
|
38
|
+
const data = tar.subarray(dataStart, dataStart + size);
|
|
39
|
+
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
|
|
40
|
+
if (type === "x") {
|
|
41
|
+
paxPath = parsePax(data).path ?? paxPath;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (type === "L") {
|
|
45
|
+
paxPath = decoder.decode(data).replace(/\0+$/, "");
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const path = paxPath ?? (prefix ? `${prefix}/${name}` : name);
|
|
49
|
+
paxPath = undefined;
|
|
50
|
+
if (type === "5")
|
|
51
|
+
throw new Error(`"${path}" is a directory`);
|
|
52
|
+
if (type === "0" || type === "\0")
|
|
53
|
+
return { path, content: data.slice() };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function header(fields) {
|
|
58
|
+
const block = new Uint8Array(BLOCK);
|
|
59
|
+
writeString(block, 0, 100, fields.name);
|
|
60
|
+
writeOctal(block, 100, 8, fields.mode & 0o7777);
|
|
61
|
+
writeOctal(block, 108, 8, 0);
|
|
62
|
+
writeOctal(block, 116, 8, 0);
|
|
63
|
+
writeOctal(block, 124, 12, fields.size);
|
|
64
|
+
writeOctal(block, 136, 12, fields.mtime);
|
|
65
|
+
block.fill(0x20, 148, 156); // checksum is computed with this field as spaces
|
|
66
|
+
block[156] = fields.type.charCodeAt(0);
|
|
67
|
+
writeString(block, 257, 6, "ustar");
|
|
68
|
+
writeString(block, 263, 2, "00");
|
|
69
|
+
const checksum = block.reduce((sum, byte) => sum + byte, 0);
|
|
70
|
+
writeString(block, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `);
|
|
71
|
+
return block;
|
|
72
|
+
}
|
|
73
|
+
function paxRecord(key, value) {
|
|
74
|
+
// A record is "<length> <key>=<value>\n", where <length> counts its own digits.
|
|
75
|
+
const bodyLength = encoder.encode(` ${key}=${value}\n`).length;
|
|
76
|
+
let length = bodyLength;
|
|
77
|
+
while (bodyLength + String(length).length !== length)
|
|
78
|
+
length = bodyLength + String(length).length;
|
|
79
|
+
return encoder.encode(`${length} ${key}=${value}\n`);
|
|
80
|
+
}
|
|
81
|
+
function parsePax(data) {
|
|
82
|
+
const records = {};
|
|
83
|
+
for (const line of decoder.decode(data).split("\n")) {
|
|
84
|
+
const match = /^\d+ ([^=]+)=(.*)$/s.exec(line);
|
|
85
|
+
if (match?.[1])
|
|
86
|
+
records[match[1]] = match[2] ?? "";
|
|
87
|
+
}
|
|
88
|
+
return records;
|
|
89
|
+
}
|
|
90
|
+
function pad(data) {
|
|
91
|
+
const padded = new Uint8Array(Math.ceil(data.length / BLOCK) * BLOCK);
|
|
92
|
+
padded.set(data);
|
|
93
|
+
return padded;
|
|
94
|
+
}
|
|
95
|
+
function concat(parts) {
|
|
96
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
97
|
+
let offset = 0;
|
|
98
|
+
for (const part of parts) {
|
|
99
|
+
out.set(part, offset);
|
|
100
|
+
offset += part.length;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function writeString(block, offset, length, value) {
|
|
105
|
+
block.set(encoder.encode(value).subarray(0, length), offset);
|
|
106
|
+
}
|
|
107
|
+
function writeOctal(block, offset, length, value) {
|
|
108
|
+
writeString(block, offset, length, `${value.toString(8).padStart(length - 1, "0")}\0`);
|
|
109
|
+
}
|
|
110
|
+
function readString(block, offset, length) {
|
|
111
|
+
const slice = block.subarray(offset, offset + length);
|
|
112
|
+
const end = slice.indexOf(0);
|
|
113
|
+
return decoder.decode(end === -1 ? slice : slice.subarray(0, end));
|
|
114
|
+
}
|
|
115
|
+
function readOctal(block, offset, length) {
|
|
116
|
+
const text = readString(block, offset, length).trim();
|
|
117
|
+
return text ? Number.parseInt(text, 8) : 0;
|
|
118
|
+
}
|
|
119
|
+
function truncateUtf8(value, maxBytes) {
|
|
120
|
+
let result = value;
|
|
121
|
+
while (encoder.encode(result).length > maxBytes)
|
|
122
|
+
result = result.slice(1);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExecResult, SandboxStatus } from "./types.js";
|
|
2
|
+
export declare class SandboxError extends Error {
|
|
3
|
+
constructor(message: string, options?: ErrorOptions);
|
|
4
|
+
}
|
|
5
|
+
export declare class SandboxNotFoundError extends SandboxError {
|
|
6
|
+
readonly sandboxId: string;
|
|
7
|
+
constructor(sandboxId: string);
|
|
8
|
+
}
|
|
9
|
+
export declare class ProviderNotFoundError extends SandboxError {
|
|
10
|
+
readonly providerName: string;
|
|
11
|
+
constructor(providerName: string);
|
|
12
|
+
}
|
|
13
|
+
/** The operation is not allowed while the sandbox is in its current status. */
|
|
14
|
+
export declare class SandboxStateError extends SandboxError {
|
|
15
|
+
readonly sandboxId: string;
|
|
16
|
+
readonly status: SandboxStatus;
|
|
17
|
+
constructor(sandboxId: string, status: SandboxStatus, operation: string);
|
|
18
|
+
}
|
|
19
|
+
/** The sandbox stayed in a transitional status for longer than the manager waits. */
|
|
20
|
+
export declare class SandboxBusyError extends SandboxError {
|
|
21
|
+
readonly sandboxId: string;
|
|
22
|
+
readonly status: SandboxStatus;
|
|
23
|
+
constructor(sandboxId: string, status: SandboxStatus);
|
|
24
|
+
}
|
|
25
|
+
/** A lifecycle command (git clone or a setup step) exited non-zero. */
|
|
26
|
+
export declare class SandboxCommandError extends SandboxError {
|
|
27
|
+
readonly step: string;
|
|
28
|
+
readonly result: ExecResult;
|
|
29
|
+
constructor(step: string, result: ExecResult);
|
|
30
|
+
}
|
|
31
|
+
/** Thrown by providers when an operation needs a running sandbox but it is stopped. */
|
|
32
|
+
export declare class ProviderSandboxStoppedError extends SandboxError {
|
|
33
|
+
readonly providerRef: string;
|
|
34
|
+
constructor(providerRef: string);
|
|
35
|
+
}
|
|
36
|
+
/** Thrown by providers when the sandbox no longer exists on the provider. */
|
|
37
|
+
export declare class ProviderSandboxMissingError extends SandboxError {
|
|
38
|
+
readonly providerRef: string;
|
|
39
|
+
constructor(providerRef: string);
|
|
40
|
+
}
|