@zukhruf/docker 0.3.33 → 0.3.34
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 +26 -12
- package/dist/container-process.d.ts +35 -0
- package/dist/container-process.d.ts.map +1 -0
- package/dist/container-process.js +91 -0
- package/dist/container-process.js.map +1 -0
- package/dist/containers.d.ts +160 -0
- package/dist/containers.d.ts.map +1 -0
- package/dist/containers.js +700 -0
- package/dist/containers.js.map +1 -0
- package/dist/docker.d.ts +25 -2
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +43 -7
- package/dist/docker.js.map +1 -1
- package/dist/engine-api.d.ts +369 -0
- package/dist/engine-api.d.ts.map +1 -1
- package/dist/engine-client.d.ts +52 -4
- package/dist/engine-client.d.ts.map +1 -1
- package/dist/engine-client.js +167 -31
- package/dist/engine-client.js.map +1 -1
- package/dist/errors.d.ts +95 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +156 -6
- package/dist/errors.js.map +1 -1
- package/dist/images.d.ts +40 -0
- package/dist/images.d.ts.map +1 -0
- package/dist/images.js +173 -0
- package/dist/images.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/multiplexed-stream.d.ts +11 -0
- package/dist/multiplexed-stream.d.ts.map +1 -0
- package/dist/multiplexed-stream.js +81 -0
- package/dist/multiplexed-stream.js.map +1 -0
- package/dist/ndjson.d.ts +7 -0
- package/dist/ndjson.d.ts.map +1 -0
- package/dist/ndjson.js +21 -0
- package/dist/ndjson.js.map +1 -0
- package/dist/networks.d.ts +46 -0
- package/dist/networks.d.ts.map +1 -0
- package/dist/networks.js +170 -0
- package/dist/networks.js.map +1 -0
- package/dist/output.d.ts +18 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +21 -0
- package/dist/output.js.map +1 -0
- package/dist/progress.d.ts +9 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +20 -0
- package/dist/progress.js.map +1 -0
- package/dist/ssh-connection.d.ts +3 -0
- package/dist/ssh-connection.d.ts.map +1 -1
- package/dist/ssh-connection.js +46 -10
- package/dist/ssh-connection.js.map +1 -1
- package/dist/tar-reader.d.ts +22 -0
- package/dist/tar-reader.d.ts.map +1 -0
- package/dist/tar-reader.js +163 -0
- package/dist/tar-reader.js.map +1 -0
- package/dist/tar-writer.d.ts +16 -0
- package/dist/tar-writer.d.ts.map +1 -0
- package/dist/tar-writer.js +114 -0
- package/dist/tar-writer.js.map +1 -0
- package/dist/transport.d.ts +11 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +34 -2
- package/dist/transport.js.map +1 -1
- package/dist/volumes.d.ts +41 -0
- package/dist/volumes.d.ts.map +1 -0
- package/dist/volumes.js +119 -0
- package/dist/volumes.js.map +1 -0
- package/package.json +5 -2
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { setTimeout } from 'node:timers/promises';
|
|
3
|
+
import { poll } from '@zukhruf/async';
|
|
4
|
+
import { ContainerProcess } from "./container-process.js";
|
|
5
|
+
import { isSuccess, parseBody, } from "./engine-client.js";
|
|
6
|
+
import { BindSourceNotFoundError, ContainerNotFoundError, ContainerNotRunningError, ContainerRunningError, DockerEngineError, DockerError, ImageNotFoundError, NameConflictError, NetworkNotFoundError, NotAFileError, PathNotFoundError, ReadOnlyPathError, } from "./errors.js";
|
|
7
|
+
import { outputChunks } from "./multiplexed-stream.js";
|
|
8
|
+
import { readOutput } from "./output.js";
|
|
9
|
+
import { tarEntries } from "./tar-reader.js";
|
|
10
|
+
import { tarArchive } from "./tar-writer.js";
|
|
11
|
+
/** The path stat in the header of an archive answer: base64 of JSON. */
|
|
12
|
+
function pathStat(headers) {
|
|
13
|
+
const header = headers['x-docker-container-path-stat'];
|
|
14
|
+
if (typeof header !== 'string')
|
|
15
|
+
return undefined;
|
|
16
|
+
const stat = JSON.parse(Buffer.from(header, 'base64').toString('utf8'));
|
|
17
|
+
return stat;
|
|
18
|
+
}
|
|
19
|
+
/** The deepest directory that holds each of the paths. */
|
|
20
|
+
function commonDirectory(paths) {
|
|
21
|
+
const [first = [], ...rest] = paths.map((path) => path.split('/').filter(Boolean));
|
|
22
|
+
const common = [];
|
|
23
|
+
for (const [index, part] of first.entries()) {
|
|
24
|
+
if (!rest.every((other) => other[index] === part))
|
|
25
|
+
break;
|
|
26
|
+
common.push(part);
|
|
27
|
+
}
|
|
28
|
+
return `/${common.join('/')}`;
|
|
29
|
+
}
|
|
30
|
+
/** True when the directory is in a read-only mount, or on a read-only root filesystem outside each mount. */
|
|
31
|
+
function isReadOnly(container, directory) {
|
|
32
|
+
const holding = (container.Mounts ?? [])
|
|
33
|
+
.filter(({ Destination }) => {
|
|
34
|
+
if (Destination === undefined)
|
|
35
|
+
return false;
|
|
36
|
+
return (directory === Destination ||
|
|
37
|
+
directory.startsWith(Destination.endsWith('/') ? Destination : `${Destination}/`));
|
|
38
|
+
})
|
|
39
|
+
.toSorted((left, right) => (right.Destination?.length ?? 0) - (left.Destination?.length ?? 0))[0];
|
|
40
|
+
if (holding !== undefined)
|
|
41
|
+
return holding.RW === false;
|
|
42
|
+
return container.HostConfig?.ReadonlyRootfs === true;
|
|
43
|
+
}
|
|
44
|
+
async function* bytesOf(response) {
|
|
45
|
+
for await (const chunk of response) {
|
|
46
|
+
if (chunk instanceof Uint8Array)
|
|
47
|
+
yield chunk;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The time between two inspects of an exec whose output ended while the engine still reports it running. */
|
|
51
|
+
const EXEC_EXIT_DELAY_MS = 10;
|
|
52
|
+
/** The time between two inspects of a container whose removal is in progress. */
|
|
53
|
+
const REMOVAL_POLL_MS = 50;
|
|
54
|
+
/** The time between two tries of createOrGet() while the name is taken but an inspect does not find it. */
|
|
55
|
+
const NAME_RACE_DELAY_MS = 20;
|
|
56
|
+
/** The fixed text of Moby for a missing bind source: errBindSourceDoesNotExist in daemon/volume/mounts/validate.go. */
|
|
57
|
+
const BIND_SOURCE_NOT_FOUND = /bind source path does not exist: (.+)$/;
|
|
58
|
+
/**
|
|
59
|
+
* The BindSourceNotFoundError of an answer, or the DockerEngineError. The
|
|
60
|
+
* engine gives a missing bind source the status of each bad parameter, so
|
|
61
|
+
* only its text tells it apart.
|
|
62
|
+
*/
|
|
63
|
+
function bindSourceOr(error) {
|
|
64
|
+
const source = BIND_SOURCE_NOT_FOUND.exec(error.message)?.[1];
|
|
65
|
+
if (source === undefined)
|
|
66
|
+
return error;
|
|
67
|
+
return new BindSourceNotFoundError(error.message, error, source, {
|
|
68
|
+
cause: error,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** The address of the loopback interface of the same family, for an address that binds each interface. */
|
|
72
|
+
function reachableHost(hostIp) {
|
|
73
|
+
if (hostIp === undefined || hostIp === '' || hostIp === '0.0.0.0') {
|
|
74
|
+
return '127.0.0.1';
|
|
75
|
+
}
|
|
76
|
+
if (hostIp === '::')
|
|
77
|
+
return '::1';
|
|
78
|
+
return hostIp;
|
|
79
|
+
}
|
|
80
|
+
/** The network modes that name no network of the caller. */
|
|
81
|
+
const PREDEFINED_NETWORK_MODES = new Set(['default', 'bridge', 'host', 'none']);
|
|
82
|
+
/**
|
|
83
|
+
* The names of the networks of a container: its network mode, when it names a
|
|
84
|
+
* network, and each network of its endpoints.
|
|
85
|
+
*/
|
|
86
|
+
function networksOf(container) {
|
|
87
|
+
const mode = container.HostConfig?.NetworkMode;
|
|
88
|
+
const networks = new Set();
|
|
89
|
+
if (mode !== undefined &&
|
|
90
|
+
mode !== '' &&
|
|
91
|
+
!PREDEFINED_NETWORK_MODES.has(mode) &&
|
|
92
|
+
!mode.startsWith('container:')) {
|
|
93
|
+
networks.add(mode);
|
|
94
|
+
}
|
|
95
|
+
for (const name of Object.keys(container.NetworkSettings?.Networks ?? {})) {
|
|
96
|
+
networks.add(name);
|
|
97
|
+
}
|
|
98
|
+
return [...networks];
|
|
99
|
+
}
|
|
100
|
+
/** A container, by the ID that the engine gave it. It has no disposal. */
|
|
101
|
+
export class Container {
|
|
102
|
+
id;
|
|
103
|
+
#client;
|
|
104
|
+
constructor(client, id) {
|
|
105
|
+
this.#client = client;
|
|
106
|
+
this.id = id;
|
|
107
|
+
}
|
|
108
|
+
get #path() {
|
|
109
|
+
return `/containers/${encodeURIComponent(this.id)}`;
|
|
110
|
+
}
|
|
111
|
+
async inspect(options = {}) {
|
|
112
|
+
const method = 'GET';
|
|
113
|
+
const path = `${this.#path}/json`;
|
|
114
|
+
const response = await this.#client.exchange(method, path, options);
|
|
115
|
+
this.#throwUnless(response, method, path);
|
|
116
|
+
return parseBody(response);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Resolves for 304 (already running). A 404 is a missing container or a
|
|
120
|
+
* missing network: an inspect of the container tells which, and a lookup of
|
|
121
|
+
* each of its networks names the missing one. An engine in swarm mode
|
|
122
|
+
* answers a missing network with 500 ("Could not attach to network", probed
|
|
123
|
+
* at API 1.51), so a 500 gets the same lookup.
|
|
124
|
+
*/
|
|
125
|
+
async start(options = {}) {
|
|
126
|
+
const { signal } = options;
|
|
127
|
+
const method = 'POST';
|
|
128
|
+
const path = `${this.#path}/start`;
|
|
129
|
+
const response = await this.#client.exchange(method, path, { signal });
|
|
130
|
+
if (response.status === 304 || isSuccess(response))
|
|
131
|
+
return;
|
|
132
|
+
const error = this.#client.error(response, method, path);
|
|
133
|
+
if (response.status !== 404 && response.status !== 500) {
|
|
134
|
+
throw bindSourceOr(error);
|
|
135
|
+
}
|
|
136
|
+
const inspect = await this.#client.exchange('GET', `${this.#path}/json`, {
|
|
137
|
+
signal,
|
|
138
|
+
});
|
|
139
|
+
if (inspect.status === 404 && response.status === 404) {
|
|
140
|
+
this.#throwUnless(response, method, path);
|
|
141
|
+
}
|
|
142
|
+
if (!isSuccess(inspect))
|
|
143
|
+
throw bindSourceOr(error);
|
|
144
|
+
const container = parseBody(inspect);
|
|
145
|
+
for (const network of networksOf(container)) {
|
|
146
|
+
const lookup = await this.#client.exchange('GET', `/networks/${encodeURIComponent(network)}`, { signal });
|
|
147
|
+
if (lookup.status === 404) {
|
|
148
|
+
throw new NetworkNotFoundError(error.message, { status: response.status, request: { method, path } }, network, { cause: error });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw bindSourceOr(error);
|
|
152
|
+
}
|
|
153
|
+
/** Resolves for 304 (already stopped). t: seconds before the engine kills the main process. */
|
|
154
|
+
async stop(options = {}) {
|
|
155
|
+
const method = 'POST';
|
|
156
|
+
const path = `${this.#path}/stop`;
|
|
157
|
+
const response = await this.#client.exchange(method, path, {
|
|
158
|
+
query: { t: options.t },
|
|
159
|
+
signal: options.signal,
|
|
160
|
+
});
|
|
161
|
+
if (response.status === 304)
|
|
162
|
+
return;
|
|
163
|
+
this.#throwUnless(response, method, path);
|
|
164
|
+
}
|
|
165
|
+
async restart(options = {}) {
|
|
166
|
+
await this.#post('restart', { t: options.t }, options);
|
|
167
|
+
}
|
|
168
|
+
/** A 409 means that the container does not run: an inspect gives its state. */
|
|
169
|
+
async kill(processSignal = 'SIGKILL', options = {}) {
|
|
170
|
+
const method = 'POST';
|
|
171
|
+
const path = `${this.#path}/kill`;
|
|
172
|
+
const response = await this.#client.exchange(method, path, {
|
|
173
|
+
query: { signal: processSignal },
|
|
174
|
+
signal: options.signal,
|
|
175
|
+
});
|
|
176
|
+
if (response.status === 409) {
|
|
177
|
+
throw await this.#notRunning(this.#client.error(response, method, path), options);
|
|
178
|
+
}
|
|
179
|
+
this.#throwUnless(response, method, path);
|
|
180
|
+
}
|
|
181
|
+
async pause(options = {}) {
|
|
182
|
+
await this.#post('pause', {}, options);
|
|
183
|
+
}
|
|
184
|
+
async unpause(options = {}) {
|
|
185
|
+
await this.#post('unpause', {}, options);
|
|
186
|
+
}
|
|
187
|
+
async wait(options = {}) {
|
|
188
|
+
const method = 'POST';
|
|
189
|
+
const path = `${this.#path}/wait`;
|
|
190
|
+
const response = await this.#client.exchange(method, path, {
|
|
191
|
+
query: { condition: options.condition },
|
|
192
|
+
signal: options.signal,
|
|
193
|
+
});
|
|
194
|
+
this.#throwUnless(response, method, path);
|
|
195
|
+
return parseBody(response);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolves for 404. A 409 is a removal that is already in progress, or,
|
|
199
|
+
* without `force`, a container that runs: an inspect tells which. For a
|
|
200
|
+
* removal in progress, it inspects the container until it is gone, and
|
|
201
|
+
* when the other removal ends and leaves the container, it sends a forced
|
|
202
|
+
* removal again. A container
|
|
203
|
+
* that runs or is paused rejects with ContainerRunningError. The engine
|
|
204
|
+
* gives the state 'running' while it kills the container for a forced
|
|
205
|
+
* removal, so with `force` the state alone does not tell: it waits.
|
|
206
|
+
*/
|
|
207
|
+
async remove(options = {}) {
|
|
208
|
+
const { force, signal } = options;
|
|
209
|
+
const method = 'DELETE';
|
|
210
|
+
const path = this.#path;
|
|
211
|
+
const response = await this.#client.exchange(method, path, {
|
|
212
|
+
query: { force, v: options.v },
|
|
213
|
+
signal,
|
|
214
|
+
});
|
|
215
|
+
if (isSuccess(response) || response.status === 404)
|
|
216
|
+
return;
|
|
217
|
+
const error = this.#client.error(response, method, path);
|
|
218
|
+
if (response.status !== 409)
|
|
219
|
+
throw error;
|
|
220
|
+
const inspect = await this.#client.exchange('GET', `${path}/json`, {
|
|
221
|
+
signal,
|
|
222
|
+
});
|
|
223
|
+
if (inspect.status === 404)
|
|
224
|
+
return;
|
|
225
|
+
const state = parseBody(inspect).State;
|
|
226
|
+
const runs = state?.Running === true || state?.Paused === true;
|
|
227
|
+
if (force !== true && runs) {
|
|
228
|
+
throw new ContainerRunningError(error.message, { status: 409, request: { method, path } }, this.id, { cause: error });
|
|
229
|
+
}
|
|
230
|
+
// A wait for 'removed' never returns when the other removal fails and
|
|
231
|
+
// leaves the container dead, so inspects tell when the container is gone.
|
|
232
|
+
await poll(async () => {
|
|
233
|
+
const current = await this.#client.exchange('GET', `${path}/json`, {
|
|
234
|
+
signal,
|
|
235
|
+
});
|
|
236
|
+
if (current.status === 404)
|
|
237
|
+
return true;
|
|
238
|
+
if (!isSuccess(current)) {
|
|
239
|
+
throw this.#client.error(current, 'GET', `${path}/json`);
|
|
240
|
+
}
|
|
241
|
+
const status = parseBody(current).State?.Status;
|
|
242
|
+
if (status === 'removing')
|
|
243
|
+
return undefined;
|
|
244
|
+
// The other removal ended and the container stays: remove it again.
|
|
245
|
+
const again = await this.#client.exchange(method, path, {
|
|
246
|
+
query: { force: true, v: options.v },
|
|
247
|
+
signal,
|
|
248
|
+
});
|
|
249
|
+
if (isSuccess(again) || again.status === 404)
|
|
250
|
+
return true;
|
|
251
|
+
if (again.status === 409)
|
|
252
|
+
return undefined;
|
|
253
|
+
throw this.#client.error(again, method, path);
|
|
254
|
+
}, { interval: REMOVAL_POLL_MS, signal });
|
|
255
|
+
}
|
|
256
|
+
/** The published binding of a port of the container; undefined when it is not published. The IPv4 binding comes first. */
|
|
257
|
+
async port(containerPort, protocol = 'tcp', options = {}) {
|
|
258
|
+
const { NetworkSettings } = await this.inspect(options);
|
|
259
|
+
const bindings = (NetworkSettings?.Ports?.[`${containerPort}/${protocol}`] ?? []).filter((binding) => binding.HostPort);
|
|
260
|
+
const binding = bindings.find((each) => !each.HostIp?.includes(':')) ?? bindings[0];
|
|
261
|
+
if (binding === undefined)
|
|
262
|
+
return undefined;
|
|
263
|
+
return {
|
|
264
|
+
host: reachableHost(binding.HostIp),
|
|
265
|
+
port: Number(binding.HostPort),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** The logs until now. */
|
|
269
|
+
async logs(options = {}) {
|
|
270
|
+
return readOutput(this.#logs(false, options));
|
|
271
|
+
}
|
|
272
|
+
/** The logs as the container writes them. The iteration ends when the container stops. */
|
|
273
|
+
followLogs(options = {}) {
|
|
274
|
+
return this.#logs(true, options);
|
|
275
|
+
}
|
|
276
|
+
async *#logs(follow, options) {
|
|
277
|
+
const { signal, tail, since, timestamps } = options;
|
|
278
|
+
const method = 'GET';
|
|
279
|
+
const path = `${this.#path}/logs`;
|
|
280
|
+
const response = await this.#client
|
|
281
|
+
.stream(method, path, {
|
|
282
|
+
query: {
|
|
283
|
+
stdout: 1,
|
|
284
|
+
stderr: 1,
|
|
285
|
+
follow: follow ? 1 : 0,
|
|
286
|
+
tail,
|
|
287
|
+
since,
|
|
288
|
+
timestamps,
|
|
289
|
+
},
|
|
290
|
+
signal,
|
|
291
|
+
})
|
|
292
|
+
.catch((error) => {
|
|
293
|
+
throw this.#notFoundOr(error, method, path);
|
|
294
|
+
});
|
|
295
|
+
yield* outputChunks(response, response.headers['content-type'] ?? '');
|
|
296
|
+
}
|
|
297
|
+
/** Runs a command in the container and reads its output to the end. stdin is sent, and then the input ends. */
|
|
298
|
+
async exec(body, options = {}) {
|
|
299
|
+
const { stdin, signal } = options;
|
|
300
|
+
const process = await this.spawn(body, {
|
|
301
|
+
...options,
|
|
302
|
+
stdin: stdin !== undefined,
|
|
303
|
+
});
|
|
304
|
+
const input = process.stdin;
|
|
305
|
+
if (input === undefined || stdin === undefined)
|
|
306
|
+
return process.collect();
|
|
307
|
+
const bytes = typeof stdin === 'string' ? new TextEncoder().encode(stdin) : stdin;
|
|
308
|
+
const writing = (async () => {
|
|
309
|
+
const writer = input.getWriter();
|
|
310
|
+
await writer.write(bytes);
|
|
311
|
+
await writer.close();
|
|
312
|
+
})();
|
|
313
|
+
// A command can end before it reads all of its input, such as `true` or
|
|
314
|
+
// `head -c1`. The write then fails, but the output and the exit code are
|
|
315
|
+
// complete: the result stands.
|
|
316
|
+
const [result] = await Promise.allSettled([process.collect(), writing]);
|
|
317
|
+
if (result.status === 'rejected')
|
|
318
|
+
throw result.reason;
|
|
319
|
+
return result.value;
|
|
320
|
+
}
|
|
321
|
+
/** Runs a command in the container and gives its container process while it runs. */
|
|
322
|
+
async spawn(body, options = {}) {
|
|
323
|
+
const { signal } = options;
|
|
324
|
+
const stdin = options.stdin === true;
|
|
325
|
+
const method = 'POST';
|
|
326
|
+
const path = `${this.#path}/exec`;
|
|
327
|
+
const created = await this.#client.exchange(method, path, {
|
|
328
|
+
body: {
|
|
329
|
+
...body,
|
|
330
|
+
AttachStdin: stdin,
|
|
331
|
+
AttachStdout: true,
|
|
332
|
+
AttachStderr: true,
|
|
333
|
+
},
|
|
334
|
+
signal,
|
|
335
|
+
});
|
|
336
|
+
if (created.status === 409) {
|
|
337
|
+
throw await this.#notRunning(this.#client.error(created, method, path), options);
|
|
338
|
+
}
|
|
339
|
+
this.#throwUnless(created, method, path);
|
|
340
|
+
const { Id } = parseBody(created);
|
|
341
|
+
const exec = `/exec/${encodeURIComponent(Id)}`;
|
|
342
|
+
// The container can stop between the create and the start of the exec: the start then gets 409.
|
|
343
|
+
const connection = await this.#client
|
|
344
|
+
.hijack('POST', `${exec}/start`, {
|
|
345
|
+
body: { Detach: false, Tty: body.Tty ?? false },
|
|
346
|
+
signal,
|
|
347
|
+
})
|
|
348
|
+
.catch(async (error) => {
|
|
349
|
+
if (error instanceof DockerEngineError && error.status === 409) {
|
|
350
|
+
throw await this.#notRunning(error, options);
|
|
351
|
+
}
|
|
352
|
+
throw error;
|
|
353
|
+
});
|
|
354
|
+
return new ContainerProcess(connection, {
|
|
355
|
+
stdin,
|
|
356
|
+
signal,
|
|
357
|
+
exitCode: async (outputEnded) => {
|
|
358
|
+
await outputEnded;
|
|
359
|
+
for (;;) {
|
|
360
|
+
const inspect = await this.#client.json('GET', `${exec}/json`, { signal });
|
|
361
|
+
if (!inspect.Running && inspect.ExitCode !== null) {
|
|
362
|
+
return inspect.ExitCode;
|
|
363
|
+
}
|
|
364
|
+
await setTimeout(EXEC_EXIT_DELAY_MS, undefined, { signal });
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Attaches to the output of the container. For a created container, the
|
|
371
|
+
* exit code is the code of its next exit, so attach before start(). For a
|
|
372
|
+
* running container, it is the code when the container stops. An exited or
|
|
373
|
+
* a paused container rejects with ContainerNotRunningError: Moby gives no
|
|
374
|
+
* 409 for an attach to an exited container, so the state of an inspect
|
|
375
|
+
* decides.
|
|
376
|
+
*/
|
|
377
|
+
async attach(options = {}) {
|
|
378
|
+
const { signal } = options;
|
|
379
|
+
const stdin = options.stdin === true;
|
|
380
|
+
const method = 'POST';
|
|
381
|
+
const path = `${this.#path}/attach`;
|
|
382
|
+
const state = (await this.inspect(options)).State?.Status ?? '';
|
|
383
|
+
if (state !== 'created' && state !== 'running') {
|
|
384
|
+
throw new ContainerNotRunningError(`The container ${this.id} is ${state}.`, { status: undefined, request: { method, path } }, this.id, state);
|
|
385
|
+
}
|
|
386
|
+
// The engine sends the headers of a wait when the wait is in place, so a
|
|
387
|
+
// start() after attach() resolves cannot pass before it.
|
|
388
|
+
const waiting = await this.#client
|
|
389
|
+
.stream('POST', `${this.#path}/wait`, {
|
|
390
|
+
query: { condition: state === 'created' ? 'next-exit' : 'not-running' },
|
|
391
|
+
signal,
|
|
392
|
+
})
|
|
393
|
+
.catch((error) => {
|
|
394
|
+
throw this.#notFoundOr(error, 'POST', `${this.#path}/wait`);
|
|
395
|
+
});
|
|
396
|
+
const connection = await this.#client
|
|
397
|
+
.hijack(method, path, {
|
|
398
|
+
query: { stream: 1, stdout: 1, stderr: 1, stdin: stdin ? 1 : 0 },
|
|
399
|
+
signal,
|
|
400
|
+
})
|
|
401
|
+
.catch(async (error) => {
|
|
402
|
+
waiting.destroy();
|
|
403
|
+
if (error instanceof DockerEngineError && error.status === 409) {
|
|
404
|
+
throw await this.#notRunning(error, options);
|
|
405
|
+
}
|
|
406
|
+
throw this.#notFoundOr(error, method, path);
|
|
407
|
+
});
|
|
408
|
+
return new ContainerProcess(connection, {
|
|
409
|
+
stdin,
|
|
410
|
+
signal,
|
|
411
|
+
exitCode: async (outputEnded) => {
|
|
412
|
+
outputEnded.catch(() => waiting.destroy());
|
|
413
|
+
const chunks = [];
|
|
414
|
+
for await (const chunk of waiting)
|
|
415
|
+
chunks.push(Buffer.from(chunk));
|
|
416
|
+
const answer = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
417
|
+
return answer.StatusCode;
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
/** The path stat of a path in the container: HEAD of the archive. undefined for 404 when the container exists. */
|
|
422
|
+
async stat(path, options = {}) {
|
|
423
|
+
const method = 'HEAD';
|
|
424
|
+
const requestPath = `${this.#path}/archive`;
|
|
425
|
+
const response = await this.#client.exchange(method, requestPath, {
|
|
426
|
+
query: { path },
|
|
427
|
+
signal: options.signal,
|
|
428
|
+
});
|
|
429
|
+
const answer = {
|
|
430
|
+
status: response.status,
|
|
431
|
+
request: { method, path: requestPath },
|
|
432
|
+
};
|
|
433
|
+
// An answer to HEAD has no body, so the message is written here.
|
|
434
|
+
const message = `The engine answered ${response.status} to the stat of ${path} in the container ${this.id}.`;
|
|
435
|
+
if (response.status === 404) {
|
|
436
|
+
const error = await this.#fileNotFound(new DockerEngineError(message, answer), path, options);
|
|
437
|
+
if (error instanceof PathNotFoundError)
|
|
438
|
+
return undefined;
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
const stat = isSuccess(response) ? pathStat(response.headers) : undefined;
|
|
442
|
+
if (stat === undefined)
|
|
443
|
+
throw new DockerEngineError(message, answer);
|
|
444
|
+
return stat;
|
|
445
|
+
}
|
|
446
|
+
/** The bytes of one regular file. A directory, a link and each other kind of entry reject with NotAFileError. */
|
|
447
|
+
async readFile(path, options = {}) {
|
|
448
|
+
const method = 'GET';
|
|
449
|
+
const requestPath = `${this.#path}/archive`;
|
|
450
|
+
const response = await this.#archive(path, options);
|
|
451
|
+
// The first entry of the archive is the path itself, so a directory fails before its tree comes.
|
|
452
|
+
for await (const entry of tarEntries(bytesOf(response))) {
|
|
453
|
+
if (entry.type === 'file')
|
|
454
|
+
return entry.data;
|
|
455
|
+
throw new NotAFileError(`${path} in the container ${this.id} is a ${entry.type}, not a regular file.`, { status: response.statusCode, request: { method, path: requestPath } }, this.id, path, entry.type);
|
|
456
|
+
}
|
|
457
|
+
throw new DockerError(`The archive of ${path} in the container ${this.id} has no entry.`);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Sends one archive of the files. It extracts the archive in the deepest
|
|
461
|
+
* directory that holds each entry and that exists, which stat() finds. The
|
|
462
|
+
* engine creates each parent directory that an entry implies below it.
|
|
463
|
+
*/
|
|
464
|
+
async writeFiles(files, options = {}) {
|
|
465
|
+
if (files.length === 0)
|
|
466
|
+
return;
|
|
467
|
+
const mtime = Math.floor(Date.now() / 1000);
|
|
468
|
+
const absolute = files.map((file) => ({
|
|
469
|
+
file,
|
|
470
|
+
path: posix.resolve('/', file.path),
|
|
471
|
+
}));
|
|
472
|
+
let directory = commonDirectory(absolute.map(({ path }) => posix.dirname(path)));
|
|
473
|
+
while (directory !== '/' &&
|
|
474
|
+
(await this.stat(directory, options)) === undefined) {
|
|
475
|
+
directory = posix.dirname(directory);
|
|
476
|
+
}
|
|
477
|
+
const entries = absolute.map(({ file, path }) => {
|
|
478
|
+
const isDirectory = 'directory' in file;
|
|
479
|
+
return {
|
|
480
|
+
path: posix.relative(directory, path),
|
|
481
|
+
type: isDirectory ? 'directory' : 'file',
|
|
482
|
+
data: isDirectory
|
|
483
|
+
? new Uint8Array(0)
|
|
484
|
+
: typeof file.data === 'string'
|
|
485
|
+
? new TextEncoder().encode(file.data)
|
|
486
|
+
: file.data,
|
|
487
|
+
mode: file.mode ?? (isDirectory ? 0o755 : 0o644),
|
|
488
|
+
uid: file.uid ?? 0,
|
|
489
|
+
gid: file.gid ?? 0,
|
|
490
|
+
mtime,
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
await this.putArchive(directory, tarArchive(entries), options);
|
|
494
|
+
}
|
|
495
|
+
/** The tar stream of a path in the container: a file, or a directory with its tree. */
|
|
496
|
+
async getArchive(path, options = {}) {
|
|
497
|
+
return ReadableStream.from(bytesOf(await this.#archive(path, options)));
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Extracts a tar archive in a directory of the container. With copyUIDGID,
|
|
501
|
+
* each entry belongs to the user of the container (Config.User), not to
|
|
502
|
+
* the owner in the archive.
|
|
503
|
+
*/
|
|
504
|
+
async putArchive(directory, archive, options = {}) {
|
|
505
|
+
const { signal } = options;
|
|
506
|
+
const method = 'PUT';
|
|
507
|
+
const requestPath = `${this.#path}/archive`;
|
|
508
|
+
const response = await this.#client.exchange(method, requestPath, {
|
|
509
|
+
query: {
|
|
510
|
+
path: directory,
|
|
511
|
+
copyUIDGID: options.copyUIDGID === true ? 1 : undefined,
|
|
512
|
+
},
|
|
513
|
+
content: { type: 'application/x-tar', data: archive },
|
|
514
|
+
signal,
|
|
515
|
+
});
|
|
516
|
+
if (isSuccess(response))
|
|
517
|
+
return;
|
|
518
|
+
const error = this.#client.error(response, method, requestPath);
|
|
519
|
+
if (response.status === 404) {
|
|
520
|
+
throw await this.#fileNotFound(error, directory, options);
|
|
521
|
+
}
|
|
522
|
+
// Moby answers a refused extraction with 400; the reference says 403.
|
|
523
|
+
if (response.status !== 400 && response.status !== 403)
|
|
524
|
+
throw error;
|
|
525
|
+
const inspect = await this.#client.exchange('GET', `${this.#path}/json`, {
|
|
526
|
+
signal,
|
|
527
|
+
});
|
|
528
|
+
if (inspect.status === 404) {
|
|
529
|
+
throw new ContainerNotFoundError(error.message, error, this.id, {
|
|
530
|
+
cause: error,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (isSuccess(inspect) &&
|
|
534
|
+
isReadOnly(parseBody(inspect), directory)) {
|
|
535
|
+
throw new ReadOnlyPathError(error.message, error, this.id, directory, {
|
|
536
|
+
cause: error,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
/** The answer of GET of the archive of a path, with 404 as ContainerNotFoundError or PathNotFoundError. */
|
|
542
|
+
async #archive(path, options) {
|
|
543
|
+
try {
|
|
544
|
+
return await this.#client.stream('GET', `${this.#path}/archive`, {
|
|
545
|
+
query: { path },
|
|
546
|
+
signal: options.signal,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
if (error instanceof DockerEngineError && error.status === 404) {
|
|
551
|
+
throw await this.#fileNotFound(error, path, options);
|
|
552
|
+
}
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** A 404 of a file request: an inspect tells a missing container from a missing path. */
|
|
557
|
+
async #fileNotFound(error, path, options) {
|
|
558
|
+
const inspect = await this.#client.exchange('GET', `${this.#path}/json`, {
|
|
559
|
+
signal: options.signal,
|
|
560
|
+
});
|
|
561
|
+
if (inspect.status === 404) {
|
|
562
|
+
return new ContainerNotFoundError(error.message, error, this.id, {
|
|
563
|
+
cause: error,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
return new PathNotFoundError(error.message, error, this.id, path, {
|
|
567
|
+
cause: error,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
/** A DockerEngineError with 404 as a ContainerNotFoundError; each other error as it is. */
|
|
571
|
+
#notFoundOr(error, method, path) {
|
|
572
|
+
if (!(error instanceof DockerEngineError) || error.status !== 404) {
|
|
573
|
+
return error;
|
|
574
|
+
}
|
|
575
|
+
return new ContainerNotFoundError(error.message, { status: 404, request: { method, path } }, this.id, { cause: error });
|
|
576
|
+
}
|
|
577
|
+
/** The ContainerNotRunningError of a 409, with the state of an inspect. */
|
|
578
|
+
async #notRunning(error, options) {
|
|
579
|
+
const state = (await this.inspect(options)).State?.Status ?? '';
|
|
580
|
+
return new ContainerNotRunningError(error.message, { status: 409, request: error.request }, this.id, state, { cause: error });
|
|
581
|
+
}
|
|
582
|
+
async #post(action, query, options) {
|
|
583
|
+
const method = 'POST';
|
|
584
|
+
const path = `${this.#path}/${action}`;
|
|
585
|
+
const response = await this.#client.exchange(method, path, {
|
|
586
|
+
query,
|
|
587
|
+
signal: options.signal,
|
|
588
|
+
});
|
|
589
|
+
this.#throwUnless(response, method, path);
|
|
590
|
+
}
|
|
591
|
+
/** A 2xx passes; a 404 is a ContainerNotFoundError; each other status is a DockerEngineError. */
|
|
592
|
+
#throwUnless(response, method, path) {
|
|
593
|
+
if (isSuccess(response))
|
|
594
|
+
return;
|
|
595
|
+
const error = this.#client.error(response, method, path);
|
|
596
|
+
if (response.status === 404) {
|
|
597
|
+
throw new ContainerNotFoundError(error.message, { status: 404, request: { method, path } }, this.id, { cause: error });
|
|
598
|
+
}
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** A container that this handle created. Disposal removes it with its anonymous volumes. */
|
|
603
|
+
export class OwnedContainer extends Container {
|
|
604
|
+
/** remove({ force: true, v: true }) */
|
|
605
|
+
async [Symbol.asyncDispose]() {
|
|
606
|
+
await this.remove({ force: true, v: true });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** The containers of one engine. */
|
|
610
|
+
export class Containers {
|
|
611
|
+
#client;
|
|
612
|
+
#images;
|
|
613
|
+
constructor(client, images) {
|
|
614
|
+
this.#client = client;
|
|
615
|
+
this.#images = images;
|
|
616
|
+
}
|
|
617
|
+
async create(body, options = {}) {
|
|
618
|
+
const { name, platform, signal } = options;
|
|
619
|
+
const method = 'POST';
|
|
620
|
+
const path = '/containers/create';
|
|
621
|
+
const response = await this.#client.exchange(method, path, {
|
|
622
|
+
query: { name, platform },
|
|
623
|
+
body,
|
|
624
|
+
signal,
|
|
625
|
+
});
|
|
626
|
+
if (isSuccess(response)) {
|
|
627
|
+
const { Id } = parseBody(response);
|
|
628
|
+
return new OwnedContainer(this.#client, Id);
|
|
629
|
+
}
|
|
630
|
+
const error = this.#client.error(response, method, path);
|
|
631
|
+
const answer = { status: response.status, request: { method, path } };
|
|
632
|
+
if (response.status === 409 && name !== undefined) {
|
|
633
|
+
throw new NameConflictError(error.message, answer, 'container', name, {
|
|
634
|
+
cause: error,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
if (response.status === 404 && body.Image !== undefined) {
|
|
638
|
+
const image = await this.#images.inspect(body.Image, options);
|
|
639
|
+
if (image === undefined) {
|
|
640
|
+
throw new ImageNotFoundError(error.message, answer, body.Image, {
|
|
641
|
+
cause: error,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
throw bindSourceOr(error);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Creates the container, attaches to its output, starts it, waits for its
|
|
649
|
+
* end, and removes it. A non-zero exit code is data. A failure and a cancel
|
|
650
|
+
* also remove the container.
|
|
651
|
+
*/
|
|
652
|
+
async run(body, options = {}) {
|
|
653
|
+
const container = await this.create(body, options);
|
|
654
|
+
try {
|
|
655
|
+
const process = await container.attach(options);
|
|
656
|
+
await container.start(options);
|
|
657
|
+
return await process.collect();
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
await container.remove({ force: true, v: true });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/** Creates the container, or gives the one that has the name. The container that exists wins. */
|
|
664
|
+
async createOrGet(body, options) {
|
|
665
|
+
const { signal } = options;
|
|
666
|
+
for (;;) {
|
|
667
|
+
try {
|
|
668
|
+
return { created: true, container: await this.create(body, options) };
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
if (!(error instanceof NameConflictError))
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
// A competing create reserves the name before an inspect finds it.
|
|
675
|
+
const container = await this.find(options.name, options);
|
|
676
|
+
if (container)
|
|
677
|
+
return { created: false, container };
|
|
678
|
+
await setTimeout(NAME_RACE_DELAY_MS, undefined, { signal });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/** One inspect: a handle with the ID of the engine, or undefined for 404. */
|
|
682
|
+
async find(nameOrId, options = {}) {
|
|
683
|
+
const method = 'GET';
|
|
684
|
+
const path = `/containers/${encodeURIComponent(nameOrId)}/json`;
|
|
685
|
+
const response = await this.#client.exchange(method, path, options);
|
|
686
|
+
if (response.status === 404)
|
|
687
|
+
return undefined;
|
|
688
|
+
if (!isSuccess(response))
|
|
689
|
+
throw this.#client.error(response, method, path);
|
|
690
|
+
const { Id } = parseBody(response);
|
|
691
|
+
return new Container(this.#client, Id);
|
|
692
|
+
}
|
|
693
|
+
async list(options = {}) {
|
|
694
|
+
return this.#client.json('GET', '/containers/json', {
|
|
695
|
+
query: { all: options.all, filters: options.filters },
|
|
696
|
+
signal: options.signal,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
//# sourceMappingURL=containers.js.map
|