@zenera/sandbox 1.1.56 → 1.1.58
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 +16 -2
- package/dist/contract.d.ts +36 -0
- package/dist/fs.d.ts +5 -5
- package/dist/fs.js +18 -3
- package/dist/image.d.ts +62 -0
- package/dist/image.js +210 -0
- package/dist/in-memory-fs.d.ts +2 -1
- package/dist/in-memory-fs.js +8 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/podman.d.ts +6 -2
- package/dist/podman.js +22 -2
- package/dist/testing.js +18 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@ await sandbox.close();
|
|
|
17
17
|
|
|
18
18
|
## The contract
|
|
19
19
|
|
|
20
|
-
- **`Sandbox`** - `
|
|
20
|
+
- **`Sandbox`** - `prepare` (pull the image, or build it from `build:`),
|
|
21
|
+
`exec`, background jobs (`startJob`, `readJob`, `stopJob`),
|
|
21
22
|
`close` at the end of a turn, and the files: `fs` (an `FsDriver`) and
|
|
22
23
|
`files(view)`, which maps the host's workspace and mounts to the paths `fs`
|
|
23
24
|
finds them at. The invariant: **a file written through `fs` is the file
|
|
@@ -27,10 +28,23 @@ await sandbox.close();
|
|
|
27
28
|
- **`FsDriver`** - the filesystem seam, usable without a sandbox. `localFs` is
|
|
28
29
|
this machine's disk; `InMemoryFs` keeps a tree in memory.
|
|
29
30
|
|
|
31
|
+
## The image
|
|
32
|
+
|
|
33
|
+
A spec names an `image` to pull, or a `build` (`{ dockerfile, context, args }`)
|
|
34
|
+
to build. Getting it there is the sandbox's job, in `prepare()`: podman builds
|
|
35
|
+
on this machine, a sandbox service on its own builders. What every backend
|
|
36
|
+
shares is the build's identity, `buildIdentity(build)` - the context's files
|
|
37
|
+
and the hash of them - so a project names the same image everywhere.
|
|
38
|
+
`prepare({ onProgress, slow, force, allowBuild: false })` reports progress,
|
|
39
|
+
rebuilds, or refuses to build at all; `start` does whatever of it is still
|
|
40
|
+
needed. A failed build is an `ImageBuildError` (`isImageBuildError` checks it
|
|
41
|
+
by name, across copies of this package).
|
|
42
|
+
|
|
30
43
|
## Implementations
|
|
31
44
|
|
|
32
45
|
- **`PodmanSandbox`** / `podmanSandbox` - a podman container on this machine,
|
|
33
|
-
binding host directories, with `fs = localFs`.
|
|
46
|
+
binding host directories, with `fs = localFs`. `ensurePodmanImage` is its
|
|
47
|
+
image step on its own.
|
|
34
48
|
- A sandbox that keeps its files with the container (a sandbox service) serves
|
|
35
49
|
`fs` itself and returns `inSandboxPaths(view, fs)` from `files()`.
|
|
36
50
|
|
package/dist/contract.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export declare const DEFAULT_SANDBOX_IMAGE = "docker.io/library/python:3.14-slim
|
|
|
10
10
|
export interface SandboxSpec {
|
|
11
11
|
/** the base image commands run in */
|
|
12
12
|
image?: string;
|
|
13
|
+
/**
|
|
14
|
+
* A Dockerfile to build instead of an image to pull. The sandbox builds it
|
|
15
|
+
* where it runs — podman on this machine, a service on its own builders —
|
|
16
|
+
* under a tag derived from its content; `image` is then ignored.
|
|
17
|
+
*/
|
|
18
|
+
build?: BuildSpec;
|
|
13
19
|
/** fractional cores, as podman's `--cpus` */
|
|
14
20
|
cpus?: number;
|
|
15
21
|
/** MiB, as podman's `--memory` */
|
|
@@ -40,6 +46,29 @@ export interface SandboxSpec {
|
|
|
40
46
|
secrets?: readonly string[];
|
|
41
47
|
}
|
|
42
48
|
export type SandboxNetwork = 'bridge' | 'none' | 'host';
|
|
49
|
+
/** A Dockerfile and what it may see. Paths are absolute and already checked by the host. */
|
|
50
|
+
export interface BuildSpec {
|
|
51
|
+
dockerfile: string;
|
|
52
|
+
/** the build context: what `COPY` can reach */
|
|
53
|
+
context: string;
|
|
54
|
+
/** `--build-arg`s; part of the image's identity */
|
|
55
|
+
args?: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
/** How a sandbox gets its image ready. */
|
|
58
|
+
export interface PrepareOptions {
|
|
59
|
+
/** lines worth showing a person: what is being pulled or built, and how it ended */
|
|
60
|
+
onProgress?(line: string): void;
|
|
61
|
+
/** wraps a slow step — a pull, a build — so a host can show it is still going */
|
|
62
|
+
slow?<T>(label: string, work: Promise<T>): Promise<T>;
|
|
63
|
+
/** pull or build again even when the image is there; `zen sandbox pull` */
|
|
64
|
+
force?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* false: an image that would have to be built is a `SandboxError` instead —
|
|
67
|
+
* for a host whose images are built somewhere else, ahead of time.
|
|
68
|
+
*/
|
|
69
|
+
allowBuild?: boolean;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
}
|
|
43
72
|
/**
|
|
44
73
|
* Two postures, because they cannot both be the default.
|
|
45
74
|
*
|
|
@@ -114,6 +143,13 @@ export interface Sandbox {
|
|
|
114
143
|
files(opts: FilesView): FilesView;
|
|
115
144
|
/** whether anything was actually started — `close` is free when not */
|
|
116
145
|
readonly started: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Makes the image available — pulled, or built from `build` — without
|
|
148
|
+
* starting anything, so a host can do the slow part before the first turn
|
|
149
|
+
* and show it. Idempotent and single-flight unless `force`; `start` does
|
|
150
|
+
* whatever of it is still needed.
|
|
151
|
+
*/
|
|
152
|
+
prepare(opts?: PrepareOptions): Promise<void>;
|
|
117
153
|
/** idempotent and single-flight; every other call starts the sandbox itself */
|
|
118
154
|
start(): Promise<void>;
|
|
119
155
|
exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
|
package/dist/fs.d.ts
CHANGED
|
@@ -13,12 +13,12 @@ export interface FsDriver {
|
|
|
13
13
|
/** how this driver's paths are spelled: the host's for `localFs`, posix otherwise */
|
|
14
14
|
readonly path: PlatformPath;
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Where a path really is: absolute, with every symlink in the part that
|
|
17
|
+
* exists resolved, and the part that does not exist yet appended as
|
|
18
|
+
* written — what a create needs, and what containment is decided on. A
|
|
19
|
+
* path through a file is `ENOTDIR`, as `realpath` says.
|
|
20
20
|
*/
|
|
21
|
-
|
|
21
|
+
resolve(path: string): Promise<string>;
|
|
22
22
|
stat(path: string): Promise<FsStat>;
|
|
23
23
|
readdir(path: string): Promise<FsEntry[]>;
|
|
24
24
|
readFile(path: string): Promise<Buffer>;
|
package/dist/fs.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { cp, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
3
2
|
import path, {} from 'node:path';
|
|
4
3
|
/** This machine's disk, through `node:fs`. */
|
|
5
4
|
export const localFs = {
|
|
6
5
|
path,
|
|
7
|
-
|
|
6
|
+
async resolve(p) {
|
|
7
|
+
const missing = [];
|
|
8
|
+
let at = path.resolve(p);
|
|
9
|
+
for (;;) {
|
|
10
|
+
try {
|
|
11
|
+
return path.join(await realpath(at), ...missing.reverse());
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const up = path.dirname(at);
|
|
15
|
+
if (err.code !== 'ENOENT' || up === at) {
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
missing.push(path.basename(at));
|
|
19
|
+
at = up;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
8
23
|
stat: (p) => stat(p),
|
|
9
24
|
readdir: (p) => readdir(p, { withFileTypes: true }),
|
|
10
25
|
readFile: (p) => readFile(p),
|
package/dist/image.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { SandboxError, type BuildSpec, type PrepareOptions } from './contract.ts';
|
|
2
|
+
import { type Runner } from './process.ts';
|
|
3
|
+
/** Where locally built images are tagged. Not scoped: a `/` or an `@` is not a legal tag. */
|
|
4
|
+
export declare const LOCAL_IMAGE_REPO = "localhost/zenera-sandbox";
|
|
5
|
+
/**
|
|
6
|
+
* A build context is meant to be small — a Dockerfile and whatever it copies.
|
|
7
|
+
* Hashing a directory someone pointed at their whole home folder would hang
|
|
8
|
+
* before it was wrong, so it stops and says which key to narrow.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_CONTEXT_FILES = 2000;
|
|
11
|
+
/** What a build is made of: the address every backend agrees on. */
|
|
12
|
+
export interface BuildIdentity {
|
|
13
|
+
/** 12 hex characters; the tag a build is known by */
|
|
14
|
+
hash: string;
|
|
15
|
+
/** every file in the context, relative and sorted — exactly what was hashed */
|
|
16
|
+
files: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The content address of a build: the Dockerfile, then every file the build can
|
|
20
|
+
* see, by path and by content, then its arguments.
|
|
21
|
+
*
|
|
22
|
+
* `.dockerignore` is not read. The engine honours it and this does not, so an
|
|
23
|
+
* ignored file that changes yields a new tag and a build that produces the same
|
|
24
|
+
* image — wasteful, never wrong, and the alternative is reimplementing a match
|
|
25
|
+
* syntax whose disagreements would be silent.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildIdentity(spec: BuildSpec): BuildIdentity;
|
|
28
|
+
/** The tag a build is run under on this machine. */
|
|
29
|
+
export declare function localImageTag(build: BuildSpec | BuildIdentity): string;
|
|
30
|
+
/**
|
|
31
|
+
* A build that ran and failed, rather than an engine that could not be asked.
|
|
32
|
+
* Both stop a run, but only one of them is the project's fault: `zen check`
|
|
33
|
+
* reports a Dockerfile that does not build as an error, and a laptop without
|
|
34
|
+
* podman as a warning.
|
|
35
|
+
*/
|
|
36
|
+
export declare class ImageBuildError extends SandboxError {
|
|
37
|
+
constructor(message: string, hint?: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether `err` is a failed build, by name rather than by class: a plugin
|
|
41
|
+
* that provides the sandbox may bring its own copy of this package, and its
|
|
42
|
+
* `ImageBuildError` is then a different class that means the same thing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isImageBuildError(err: unknown): err is ImageBuildError;
|
|
45
|
+
/**
|
|
46
|
+
* Puts the image on this machine's engine: nothing when it is already there,
|
|
47
|
+
* a build when the spec names a Dockerfile, a pull otherwise.
|
|
48
|
+
*
|
|
49
|
+
* Skipping a present build is safe in a way it would not be for an ordinary
|
|
50
|
+
* tag, because this one is a hash of the Dockerfile and its context: the image
|
|
51
|
+
* existing *means* the content is unchanged. What it does not cover is a moved
|
|
52
|
+
* base image — `podman build` reuses whatever `FROM node:24` resolved to last
|
|
53
|
+
* time — which is what `force` is for.
|
|
54
|
+
*/
|
|
55
|
+
export declare function ensurePodmanImage(target: {
|
|
56
|
+
image: string;
|
|
57
|
+
build?: BuildSpec;
|
|
58
|
+
}, host?: {
|
|
59
|
+
engine?: string;
|
|
60
|
+
exec?: Runner;
|
|
61
|
+
}, opts?: PrepareOptions): Promise<void>;
|
|
62
|
+
//# sourceMappingURL=image.d.ts.map
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
import { SandboxError } from "./contract.js";
|
|
5
|
+
import { runProcess } from "./process.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The sandbox image
|
|
8
|
+
//
|
|
9
|
+
// `image:` names something to pull; `build:` names a Dockerfile to build. Both
|
|
10
|
+
// end as an image a sandbox runs, and getting it there is the sandbox's job:
|
|
11
|
+
// podman builds on this machine, a sandbox service builds wherever it builds.
|
|
12
|
+
// What they share is the *identity* of a build — which files go in and what
|
|
13
|
+
// they hash to — so a project names the same image on every backend.
|
|
14
|
+
//
|
|
15
|
+
// The tag is a function of what goes into the image, and it has to be: a
|
|
16
|
+
// container's name hashes its image, so a tag that stayed put while the
|
|
17
|
+
// Dockerfile changed would leave a `persist: true` container running last
|
|
18
|
+
// week's filesystem, with nothing anywhere saying so. Hashing the context as
|
|
19
|
+
// well as the Dockerfile means an edit to either yields a new tag, a new
|
|
20
|
+
// container name, and a build — which is the only honest answer.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/** Where locally built images are tagged. Not scoped: a `/` or an `@` is not a legal tag. */
|
|
23
|
+
export const LOCAL_IMAGE_REPO = 'localhost/zenera-sandbox';
|
|
24
|
+
/**
|
|
25
|
+
* A build context is meant to be small — a Dockerfile and whatever it copies.
|
|
26
|
+
* Hashing a directory someone pointed at their whole home folder would hang
|
|
27
|
+
* before it was wrong, so it stops and says which key to narrow.
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_CONTEXT_FILES = 2_000;
|
|
30
|
+
/**
|
|
31
|
+
* The content address of a build: the Dockerfile, then every file the build can
|
|
32
|
+
* see, by path and by content, then its arguments.
|
|
33
|
+
*
|
|
34
|
+
* `.dockerignore` is not read. The engine honours it and this does not, so an
|
|
35
|
+
* ignored file that changes yields a new tag and a build that produces the same
|
|
36
|
+
* image — wasteful, never wrong, and the alternative is reimplementing a match
|
|
37
|
+
* syntax whose disagreements would be silent.
|
|
38
|
+
*/
|
|
39
|
+
export function buildIdentity(spec) {
|
|
40
|
+
const hash = createHash('sha256');
|
|
41
|
+
hash.update(readFileSync(spec.dockerfile));
|
|
42
|
+
const files = walk(spec.context);
|
|
43
|
+
for (const rel of files) {
|
|
44
|
+
// The separator is hashed as posix so the same tree tags the same on
|
|
45
|
+
// any host.
|
|
46
|
+
hash.update(`\0${rel.split(sep).join('/')}\0`);
|
|
47
|
+
hash.update(readFileSync(join(spec.context, rel)));
|
|
48
|
+
}
|
|
49
|
+
// Only when there are any, so a build without arguments keeps the tag it
|
|
50
|
+
// had before arguments existed.
|
|
51
|
+
const args = Object.entries(spec.args ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
52
|
+
if (args.length > 0) {
|
|
53
|
+
hash.update(`\0args\0${JSON.stringify(args)}`);
|
|
54
|
+
}
|
|
55
|
+
return { hash: hash.digest('hex').slice(0, 12), files };
|
|
56
|
+
}
|
|
57
|
+
/** The tag a build is run under on this machine. */
|
|
58
|
+
export function localImageTag(build) {
|
|
59
|
+
const id = 'hash' in build ? build : buildIdentity(build);
|
|
60
|
+
return `${LOCAL_IMAGE_REPO}:${id.hash}`;
|
|
61
|
+
}
|
|
62
|
+
/** Every file under `dir`, relative and sorted, so the digest is stable. */
|
|
63
|
+
function walk(dir) {
|
|
64
|
+
const found = [];
|
|
65
|
+
const pending = [dir];
|
|
66
|
+
while (pending.length > 0) {
|
|
67
|
+
const at = pending.pop();
|
|
68
|
+
for (const entry of readdirSync(at, { withFileTypes: true })) {
|
|
69
|
+
const path = join(at, entry.name);
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
pending.push(path);
|
|
72
|
+
}
|
|
73
|
+
else if (entry.isFile()) {
|
|
74
|
+
found.push(relative(dir, path));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (found.length > MAX_CONTEXT_FILES) {
|
|
78
|
+
throw new SandboxError(`sandbox.build.context: ${dir} holds more than ${MAX_CONTEXT_FILES} files`, 'point `context:` at the directory the build actually copies from');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return found.sort();
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// On this machine
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* A build that ran and failed, rather than an engine that could not be asked.
|
|
88
|
+
* Both stop a run, but only one of them is the project's fault: `zen check`
|
|
89
|
+
* reports a Dockerfile that does not build as an error, and a laptop without
|
|
90
|
+
* podman as a warning.
|
|
91
|
+
*/
|
|
92
|
+
export class ImageBuildError extends SandboxError {
|
|
93
|
+
constructor(message, hint) {
|
|
94
|
+
super(message, hint);
|
|
95
|
+
this.name = 'ImageBuildError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether `err` is a failed build, by name rather than by class: a plugin
|
|
100
|
+
* that provides the sandbox may bring its own copy of this package, and its
|
|
101
|
+
* `ImageBuildError` is then a different class that means the same thing.
|
|
102
|
+
*/
|
|
103
|
+
export function isImageBuildError(err) {
|
|
104
|
+
return err instanceof Error && err.name === 'ImageBuildError';
|
|
105
|
+
}
|
|
106
|
+
/** Pulling is slow on purpose... */
|
|
107
|
+
const PULL_MS = 600_000;
|
|
108
|
+
/** ...and a build from a cold cache is slower still. */
|
|
109
|
+
const BUILD_MS = 900_000;
|
|
110
|
+
/**
|
|
111
|
+
* Images this process has already settled, per runner and per engine. A batch
|
|
112
|
+
* opens the same project dozens of times at once, and without this each one
|
|
113
|
+
* asks podman and the concurrent copies race each other to pull or build the
|
|
114
|
+
* very same image. Keyed by runner too, so a test's fake engine never inherits
|
|
115
|
+
* an answer the real one gave.
|
|
116
|
+
*/
|
|
117
|
+
const settled = new WeakMap();
|
|
118
|
+
/**
|
|
119
|
+
* Puts the image on this machine's engine: nothing when it is already there,
|
|
120
|
+
* a build when the spec names a Dockerfile, a pull otherwise.
|
|
121
|
+
*
|
|
122
|
+
* Skipping a present build is safe in a way it would not be for an ordinary
|
|
123
|
+
* tag, because this one is a hash of the Dockerfile and its context: the image
|
|
124
|
+
* existing *means* the content is unchanged. What it does not cover is a moved
|
|
125
|
+
* base image — `podman build` reuses whatever `FROM node:24` resolved to last
|
|
126
|
+
* time — which is what `force` is for.
|
|
127
|
+
*/
|
|
128
|
+
export function ensurePodmanImage(target, host = {}, opts = {}) {
|
|
129
|
+
const engine = host.engine ?? 'podman';
|
|
130
|
+
const run = host.exec ?? runProcess;
|
|
131
|
+
let byKey = settled.get(run);
|
|
132
|
+
if (!byKey) {
|
|
133
|
+
byKey = new Map();
|
|
134
|
+
settled.set(run, byKey);
|
|
135
|
+
}
|
|
136
|
+
const key = `${engine}::${target.image}`;
|
|
137
|
+
const pending = byKey.get(key);
|
|
138
|
+
if (pending && !opts.force) {
|
|
139
|
+
return pending;
|
|
140
|
+
}
|
|
141
|
+
const attempt = obtain(engine, run, target, opts).catch((err) => {
|
|
142
|
+
// A failure is not a settled answer: the user may fix the Dockerfile
|
|
143
|
+
// and try again in the same process.
|
|
144
|
+
byKey.delete(key);
|
|
145
|
+
throw err;
|
|
146
|
+
});
|
|
147
|
+
byKey.set(key, attempt);
|
|
148
|
+
return attempt;
|
|
149
|
+
}
|
|
150
|
+
async function obtain(engine, run, target, opts) {
|
|
151
|
+
const say = (line) => opts.onProgress?.(line);
|
|
152
|
+
const slow = (label, work) => opts.slow ? opts.slow(label, work) : work;
|
|
153
|
+
const tail = (res) => {
|
|
154
|
+
for (const line of res.stderr.split('\n').slice(-3)) {
|
|
155
|
+
if (line.trim()) {
|
|
156
|
+
say(` ${line.trim()}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
if (!opts.force) {
|
|
161
|
+
const present = await run(engine, ['image', 'exists', target.image], {
|
|
162
|
+
timeoutMs: 30_000,
|
|
163
|
+
});
|
|
164
|
+
if (present.code === 0) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const build = target.build;
|
|
169
|
+
if (build) {
|
|
170
|
+
if (opts.allowBuild === false) {
|
|
171
|
+
throw new SandboxError(`the sandbox image ${target.image} is not built`, 'build it before running: `zen sandbox up` builds it on this machine');
|
|
172
|
+
}
|
|
173
|
+
say(`${opts.force ? 'rebuilding' : 'building'} the sandbox image from ${build.dockerfile}`);
|
|
174
|
+
say(' the agent runs its commands inside a container, so the image is needed first');
|
|
175
|
+
say(' this takes a few minutes; later runs reuse it');
|
|
176
|
+
const args = Object.entries(build.args ?? {}).flatMap(([k, v]) => [
|
|
177
|
+
'--build-arg',
|
|
178
|
+
`${k}=${v}`,
|
|
179
|
+
]);
|
|
180
|
+
const built = await slow('building the sandbox image', run(engine, [
|
|
181
|
+
'build',
|
|
182
|
+
'--tag',
|
|
183
|
+
target.image,
|
|
184
|
+
'--file',
|
|
185
|
+
build.dockerfile,
|
|
186
|
+
...args,
|
|
187
|
+
build.context,
|
|
188
|
+
], { timeoutMs: BUILD_MS, signal: opts.signal }));
|
|
189
|
+
tail(built);
|
|
190
|
+
if (built.code !== 0) {
|
|
191
|
+
throw new ImageBuildError(`could not build ${build.dockerfile}`, last(built) || 'run the build by hand to see what the engine says');
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
say(`pulling ${target.image} — this happens once`);
|
|
196
|
+
const pulled = await slow('pulling', run(engine, ['pull', target.image], { timeoutMs: PULL_MS, signal: opts.signal }));
|
|
197
|
+
tail(pulled);
|
|
198
|
+
if (pulled.code !== 0) {
|
|
199
|
+
throw new SandboxError(`could not pull ${target.image}`, first(pulled) || 'check the image name and your network');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function first(res) {
|
|
203
|
+
return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
|
|
204
|
+
}
|
|
205
|
+
/** A build says what went wrong on its last line, not its first. */
|
|
206
|
+
function last(res) {
|
|
207
|
+
const lines = (res.stderr.trim() || res.stdout.trim()).split('\n').filter((l) => l.trim());
|
|
208
|
+
return lines.slice(-2).join(' — ');
|
|
209
|
+
}
|
|
210
|
+
//# sourceMappingURL=image.js.map
|
package/dist/in-memory-fs.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ export declare class InMemoryFs implements FsDriver {
|
|
|
6
6
|
constructor(files?: Record<string, string | Uint8Array>);
|
|
7
7
|
/** An empty directory, with its parents. */
|
|
8
8
|
dir(at: string): this;
|
|
9
|
-
|
|
9
|
+
/** No symlinks here, so only a path through a file needs saying anything about. */
|
|
10
|
+
resolve(at: string): Promise<string>;
|
|
10
11
|
stat(at: string): Promise<FsStat>;
|
|
11
12
|
readdir(at: string): Promise<FsEntry[]>;
|
|
12
13
|
readFile(at: string): Promise<Buffer>;
|
package/dist/in-memory-fs.js
CHANGED
|
@@ -15,10 +15,15 @@ export class InMemoryFs {
|
|
|
15
15
|
this.#mkdirs(this.#abs(at));
|
|
16
16
|
return this;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/** No symlinks here, so only a path through a file needs saying anything about. */
|
|
19
|
+
async resolve(at) {
|
|
19
20
|
const abs = this.#abs(at);
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
let probe = abs;
|
|
22
|
+
while (!this.#nodes.has(probe)) {
|
|
23
|
+
probe = posix.dirname(probe);
|
|
24
|
+
}
|
|
25
|
+
if (probe !== abs && this.#nodes.get(probe)?.kind === 'file') {
|
|
26
|
+
throw fsError('ENOTDIR', 'realpath', at);
|
|
22
27
|
}
|
|
23
28
|
return abs;
|
|
24
29
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// can all stand on the same contract. Contract tests: `@zenera/sandbox/testing`.
|
|
4
4
|
export * from "./contract.js";
|
|
5
5
|
export * from "./fs.js";
|
|
6
|
+
export * from "./image.js";
|
|
6
7
|
export * from "./in-memory-fs.js";
|
|
7
8
|
export * from "./limits.js";
|
|
8
9
|
export * from "./mounts.js";
|
package/dist/podman.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type FsDriver } from './fs.ts';
|
|
2
2
|
import { type FilesView, type Mount } from './mounts.ts';
|
|
3
3
|
import { type Runner } from './process.ts';
|
|
4
|
-
import { type Sandbox, type SandboxFactory, type ExecOptions, type ExecResult, type JobOutput, type JobStopped, type SandboxOptions, type SandboxSpec } from './contract.ts';
|
|
4
|
+
import { type BuildSpec, type PrepareOptions, type Sandbox, type SandboxFactory, type ExecOptions, type ExecResult, type JobOutput, type JobStopped, type SandboxOptions, type SandboxSpec } from './contract.ts';
|
|
5
5
|
/** What only a podman sandbox needs: which binary to drive, and how to spawn it. */
|
|
6
6
|
export interface PodmanHost {
|
|
7
7
|
/** the container engine binary; `podman` when unset */
|
|
@@ -9,8 +9,10 @@ export interface PodmanHost {
|
|
|
9
9
|
/** how processes are spawned — the seam tests replace */
|
|
10
10
|
exec?: Runner;
|
|
11
11
|
}
|
|
12
|
-
interface Resolved extends Required<Omit<SandboxSpec, 'user'>> {
|
|
12
|
+
interface Resolved extends Required<Omit<SandboxSpec, 'user' | 'build'>> {
|
|
13
13
|
user?: string;
|
|
14
|
+
/** the Dockerfile `image` is built from, when it is */
|
|
15
|
+
build?: BuildSpec;
|
|
14
16
|
root: string;
|
|
15
17
|
key: string;
|
|
16
18
|
readOnly: boolean;
|
|
@@ -37,6 +39,8 @@ export declare class PodmanSandbox implements Sandbox {
|
|
|
37
39
|
get started(): boolean;
|
|
38
40
|
/** The host paths, as they are: the container binds exactly these. */
|
|
39
41
|
files(opts: FilesView): FilesView;
|
|
42
|
+
/** Pulls the image, or builds it from the Dockerfile, unless the engine already has it. */
|
|
43
|
+
prepare(opts?: PrepareOptions): Promise<void>;
|
|
40
44
|
/**
|
|
41
45
|
* Idempotent, and single-flight: several tool calls in one batch all await
|
|
42
46
|
* the same start rather than racing to create the same container.
|
package/dist/podman.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { posix } from 'node:path';
|
|
3
|
+
import { ensurePodmanImage, localImageTag } from "./image.js";
|
|
3
4
|
import { localFs } from "./fs.js";
|
|
4
5
|
import { sandboxLimits } from "./limits.js";
|
|
5
6
|
import { WORKSPACE_MOUNT } from "./mounts.js";
|
|
@@ -51,15 +52,18 @@ export class PodmanSandbox {
|
|
|
51
52
|
spec;
|
|
52
53
|
engine;
|
|
53
54
|
#run;
|
|
55
|
+
#spawn;
|
|
54
56
|
#jobs = new Map();
|
|
55
57
|
#ready;
|
|
58
|
+
#prepared;
|
|
56
59
|
#created = false;
|
|
57
60
|
/** kills counted in this container's cgroup as of the last probe */
|
|
58
61
|
#kills;
|
|
59
62
|
constructor(opts, host = {}) {
|
|
60
63
|
this.spec = resolveSpec(opts);
|
|
61
64
|
this.engine = host.engine ?? 'podman';
|
|
62
|
-
this.#
|
|
65
|
+
this.#spawn = host.exec ?? runProcess;
|
|
66
|
+
this.#run = resilient(this.#spawn);
|
|
63
67
|
this.name = containerName(this.spec);
|
|
64
68
|
}
|
|
65
69
|
/** Whether anything was actually started — `close` is free when not. */
|
|
@@ -70,6 +74,17 @@ export class PodmanSandbox {
|
|
|
70
74
|
files(opts) {
|
|
71
75
|
return { ...opts, fs: this.fs };
|
|
72
76
|
}
|
|
77
|
+
/** Pulls the image, or builds it from the Dockerfile, unless the engine already has it. */
|
|
78
|
+
prepare(opts = {}) {
|
|
79
|
+
if (this.#prepared && !opts.force) {
|
|
80
|
+
return this.#prepared;
|
|
81
|
+
}
|
|
82
|
+
this.#prepared = ensurePodmanImage({ image: this.spec.image, build: this.spec.build }, { engine: this.engine, exec: this.#spawn }, opts).catch((err) => {
|
|
83
|
+
this.#prepared = undefined;
|
|
84
|
+
throw err;
|
|
85
|
+
});
|
|
86
|
+
return this.#prepared;
|
|
87
|
+
}
|
|
73
88
|
/**
|
|
74
89
|
* Idempotent, and single-flight: several tool calls in one batch all await
|
|
75
90
|
* the same start rather than racing to create the same container.
|
|
@@ -99,6 +114,10 @@ export class PodmanSandbox {
|
|
|
99
114
|
this.#created = true;
|
|
100
115
|
return;
|
|
101
116
|
}
|
|
117
|
+
// `run` pulls a registry image by itself; a built one has to exist first.
|
|
118
|
+
if (this.spec.build) {
|
|
119
|
+
await this.prepare();
|
|
120
|
+
}
|
|
102
121
|
const made = await this.#podman(this.#createArgs());
|
|
103
122
|
if (made.code !== 0) {
|
|
104
123
|
throw new SandboxError(`could not create container ${this.name}: ${message(made)}`, LOCKS.test(made.stderr) ? locksHint(this.engine) : undefined);
|
|
@@ -489,7 +508,8 @@ function resolveSpec(opts) {
|
|
|
489
508
|
return {
|
|
490
509
|
root: opts.root,
|
|
491
510
|
key: opts.key,
|
|
492
|
-
image: opts.image ?? DEFAULT_SANDBOX_IMAGE,
|
|
511
|
+
image: opts.build ? localImageTag(opts.build) : (opts.image ?? DEFAULT_SANDBOX_IMAGE),
|
|
512
|
+
build: opts.build,
|
|
493
513
|
cpus: limits.cpus,
|
|
494
514
|
memory: limits.memory,
|
|
495
515
|
explicitCpus: opts.cpus !== undefined,
|
package/dist/testing.js
CHANGED
|
@@ -52,8 +52,18 @@ export function describeFsDriver(name, make) {
|
|
|
52
52
|
code: 'ENOENT',
|
|
53
53
|
path: expect.stringContaining('ghost.txt'),
|
|
54
54
|
});
|
|
55
|
-
expect(
|
|
56
|
-
|
|
55
|
+
expect(await fs.resolve(at('.'))).toBe(await fs.resolve(at()));
|
|
56
|
+
}));
|
|
57
|
+
it('resolves where a path really is, the part that does not exist yet included', withFs(async ({ fs }, at) => {
|
|
58
|
+
const root = await fs.resolve(at());
|
|
59
|
+
await fs.mkdirp(at('real', 'dir'));
|
|
60
|
+
expect(await fs.resolve(at('real', 'dir'))).toBe(fs.path.join(root, 'real', 'dir'));
|
|
61
|
+
expect(await fs.resolve(at('real', 'ghost', 'deeper.txt'))).toBe(fs.path.join(root, 'real', 'ghost', 'deeper.txt'));
|
|
62
|
+
expect(await fs.resolve(at('real', 'dir', '..', 'x'))).toBe(fs.path.join(root, 'real', 'x'));
|
|
63
|
+
await fs.writeFile(at('file.txt'), 'x');
|
|
64
|
+
await expect(fs.resolve(at('file.txt', 'under'))).rejects.toMatchObject({
|
|
65
|
+
code: 'ENOTDIR',
|
|
66
|
+
});
|
|
57
67
|
}));
|
|
58
68
|
});
|
|
59
69
|
}
|
|
@@ -102,6 +112,12 @@ export function describeSandbox(name, make, opts = {}) {
|
|
|
102
112
|
expect(await sandbox.stopJob(id)).toMatchObject({ job_id: id, stopped: true });
|
|
103
113
|
await expect(sandbox.readJob('job-unknown')).rejects.toBeInstanceOf(SandboxError);
|
|
104
114
|
}), timeout);
|
|
115
|
+
it('prepares its image without starting, once however often it is asked', withSandbox(async ({ sandbox }) => {
|
|
116
|
+
await Promise.all([sandbox.prepare(), sandbox.prepare()]);
|
|
117
|
+
await sandbox.prepare();
|
|
118
|
+
expect(sandbox.started).toBe(false);
|
|
119
|
+
expect((await sandbox.exec('echo ready')).stdout).toBe('ready\n');
|
|
120
|
+
}), timeout);
|
|
105
121
|
it('is free to close, and closes once', withSandbox(async ({ sandbox }) => {
|
|
106
122
|
await sandbox.exec('true');
|
|
107
123
|
await sandbox.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/sandbox",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.58",
|
|
4
4
|
"description": "Where an agent's commands run and the files they see: the Sandbox contract, a podman implementation, filesystem drivers and a contract test suite for other implementations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|