@zukhruf/docker 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +902 -2
- package/dist/api-version.d.ts +7 -0
- package/dist/api-version.d.ts.map +1 -0
- package/dist/api-version.js +22 -0
- package/dist/api-version.js.map +1 -0
- 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/context-store.d.ts +22 -0
- package/dist/context-store.d.ts.map +1 -0
- package/dist/context-store.js +128 -0
- package/dist/context-store.js.map +1 -0
- package/dist/docker.d.ts +57 -0
- package/dist/docker.d.ts.map +1 -0
- package/dist/docker.js +96 -0
- package/dist/docker.js.map +1 -0
- package/dist/engine-api.d.ts +390 -0
- package/dist/engine-api.d.ts.map +1 -0
- package/dist/engine-api.js +4 -0
- package/dist/engine-api.js.map +1 -0
- package/dist/engine-client.d.ts +74 -0
- package/dist/engine-client.d.ts.map +1 -0
- package/dist/engine-client.js +253 -0
- package/dist/engine-client.js.map +1 -0
- package/dist/errors.d.ts +133 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +214 -0
- package/dist/errors.js.map +1 -0
- package/dist/host.d.ts +13 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +22 -0
- package/dist/host.js.map +1 -0
- 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 +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- 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/options.d.ts +7 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +2 -0
- package/dist/options.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 +23 -0
- package/dist/ssh-connection.d.ts.map +1 -0
- package/dist/ssh-connection.js +225 -0
- package/dist/ssh-connection.js.map +1 -0
- 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 +29 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +56 -0
- package/dist/transport.js.map +1 -0
- 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 +43 -4
package/README.md
CHANGED
|
@@ -1,3 +1,903 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zukhruf/docker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Status: implemented.** Each call of the `## API` block works, and the tests run it on Docker Desktop (API 1.56) and on an SSH engine (API 1.51). A Windows named pipe is not proved yet (backlog #2753). The decisions are in the [ADRs](./docs/adr/). The package leads, and the repositories that use Docker change to fit it ([ADR 0003](./docs/adr/0003-the-package-leads-and-the-repositories-change.md)).
|
|
4
|
+
|
|
5
|
+
A client for the Docker Engine API on Node.js. It selects the engine that the Docker CLI selects, and it sends HTTP requests to that engine with `node:http`: through a Unix socket, a Windows named pipe, or SSH. It needs no Docker CLI on the `PATH`, and it has no third-party dependencies; it uses `@zukhruf/async`. Thus a desktop app that starts from the macOS Dock can use it, and so can a test, a sandbox or a deploy tool.
|
|
6
|
+
|
|
7
|
+
The package covers the containers of one engine and the resources around them: their images, volumes, networks, files, processes, output and events.
|
|
8
|
+
|
|
9
|
+
The words in these documents have one meaning each. See the glossary in [CONTEXT.md](./CONTEXT.md).
|
|
10
|
+
|
|
11
|
+
## Connect to the engine
|
|
12
|
+
|
|
13
|
+
`Docker.connect()` selects the engine in the order of the Docker CLI ([ADR 0002](./docs/adr/0002-the-engine-is-the-one-that-the-docker-cli-selects.md)):
|
|
14
|
+
|
|
15
|
+
1. `DOCKER_HOST`.
|
|
16
|
+
2. `DOCKER_CONTEXT`.
|
|
17
|
+
3. `currentContext` in `config.json` of `DOCKER_CONFIG`, or of `~/.docker`.
|
|
18
|
+
4. The `default` context. Its host is `DOCKER_HOST`, or `unix:///var/run/docker.sock`, or `npipe:////./pipe/docker_engine` on Windows.
|
|
19
|
+
|
|
20
|
+
The package does not read `process.env`. The host program gives its environment as `environment`, and the package reads `DOCKER_HOST`, `DOCKER_CONTEXT` and `DOCKER_CONFIG` from it. Without `environment`, no variable applies, and the selection reads only the context store.
|
|
21
|
+
|
|
22
|
+
Then `connect()` sends `GET /_ping` and negotiates the API version. Dispose the client to close its connections. After the disposal starts, a request in flight and each new request reject with a `DockerError` that says that the client was disposed. An idle connection does not keep the process running.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Docker } from '@zukhruf/docker';
|
|
26
|
+
|
|
27
|
+
await using docker = await Docker.connect({
|
|
28
|
+
environment: process.env,
|
|
29
|
+
signal: AbortSignal.timeout(5_000),
|
|
30
|
+
});
|
|
31
|
+
console.log(docker.context, docker.host, docker.apiVersion);
|
|
32
|
+
// desktop-linux unix:///Users/me/.docker/run/docker.sock 1.51
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A client keeps its engine. To use another engine, connect again with a context name or a host. A `context` ignores `DOCKER_CONTEXT` and `currentContext`. The `default` context still takes its host from `DOCKER_HOST`, as in the Docker CLI. A `host` ignores the environment:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
await using remote = await Docker.connect({
|
|
39
|
+
context: 'limerence-dokploy',
|
|
40
|
+
environment: process.env,
|
|
41
|
+
});
|
|
42
|
+
await using local = await Docker.connect({
|
|
43
|
+
host: 'unix:///var/run/docker.sock',
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The package accepts `unix://`, `npipe://` and `ssh://` hosts. It refuses `tcp://` ([ADR 0003](./docs/adr/0003-the-package-leads-and-the-repositories-change.md) gives the reason).
|
|
48
|
+
|
|
49
|
+
## List the contexts
|
|
50
|
+
|
|
51
|
+
`Docker.contexts()` reads the context store and gives each context with its host. The `default` context is in the list too. `current` marks the context that `Docker.connect()` selects with the same `environment` and no `context` or `host`.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const local = (await Docker.contexts({ environment: process.env })).filter(
|
|
55
|
+
({ host }) => !host.startsWith('ssh://'),
|
|
56
|
+
);
|
|
57
|
+
// [{ name: 'default', host: 'unix:///var/run/docker.sock', current: false },
|
|
58
|
+
// { name: 'desktop-linux', host: 'unix:///Users/me/.docker/run/docker.sock', current: true }]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A product with its own rule selects from this list. For example, a scan for databases on this machine connects to each local context and skips SSH engines: a container's published port on an SSH engine is on another machine.
|
|
62
|
+
|
|
63
|
+
## Catch the errors that change what you do next
|
|
64
|
+
|
|
65
|
+
An error has its own class when a caller does something different after it: start the engine, pull the image, start the container, pick another name ([ADR 0007](./docs/adr/0007-an-error-has-its-own-class-when-the-caller-handles-it-differently.md)). Each class comes from the request and its HTTP status. When one status has two meanings, the package sends one more request to tell them apart.
|
|
66
|
+
|
|
67
|
+
| Class | When | What the caller does next |
|
|
68
|
+
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
69
|
+
| `ConnectionFailedError` | The socket does not exist, the engine refuses the connection, or `ssh` cannot log in | Treat the engine as absent, or ask to start Docker |
|
|
70
|
+
| `UnsupportedEngineError` | The engine's highest API version is lower than the lowest one that the package supports | Ask to update Docker |
|
|
71
|
+
| `UnsupportedHostError` | The host is not `unix://`, `npipe://` or `ssh://`, for example `tcp://` | Skip that context, or give another host |
|
|
72
|
+
| `ContextNotFoundError` | `Docker.connect({ context })` names a context that the context store does not have | Pick another context from `Docker.contexts()` |
|
|
73
|
+
| `ContainerNotFoundError` | A request about a container gets 404 | Create the container again |
|
|
74
|
+
| `ContainerNotRunningError` | An exec or a kill gets 409, or an attach finds the container exited or paused. `state` comes from an inspect: `created`, `exited`, `paused` … | Start or unpause it, then try again |
|
|
75
|
+
| `ContainerRunningError` | A removal without `force` gets 409, and an inspect shows that the container runs | Stop it first, or remove it with `force` |
|
|
76
|
+
| `NameConflictError` | A create of a container, network or volume finds the name taken | Use `createOrGet()`, or another name |
|
|
77
|
+
| `ImageNotFoundError` | A create, a pull or a tag gets 404, or a push reports an error in its progress and an inspect does not find the image | Pull or build the image; check the name or `auth` |
|
|
78
|
+
| `NetworkNotFoundError` | A start or a connect gets 404 (500 on an engine in swarm mode), and a lookup finds the network missing; a request about a network gets 404 | Create the network first |
|
|
79
|
+
| `VolumeNotFoundError` | A request about a volume gets 404 | Create the volume again |
|
|
80
|
+
| `BindSourceNotFoundError` | A create or a start refuses a bind mount whose `source` does not exist on the engine's host | Tell the user which host path is missing |
|
|
81
|
+
| `VolumeInUseError` | A volume removal gets 409 | Remove its containers first, or wait and try again |
|
|
82
|
+
| `NetworkInUseError` | A network removal gets 403 while running containers are connected | Disconnect them first |
|
|
83
|
+
| `ImageInUseError` | An image removal gets 409 | Remove its containers first, or use `force` |
|
|
84
|
+
| `PathNotFoundError` | A file request gets 404 and the container exists | Use a default, or create the file |
|
|
85
|
+
| `NotAFileError` | `readFile()` finds a directory, a link or another kind of entry. `kind` names it | Use `getArchive()` for the tree |
|
|
86
|
+
| `ReadOnlyPathError` | The engine refuses an extraction, and an inspect shows that the directory is on a read-only root filesystem or in a read-only mount | Write under a writable mount, or use `exec()` |
|
|
87
|
+
|
|
88
|
+
Each class extends a base class, so a broad catch still works. `DockerError` is the base of every error of the package. `DockerEngineError` is the base of every answer of the engine that is an error. It has `status` and `request` (the `method` and `path` of the request), and its `message` is the message of the engine. The base keeps the request apart in `request`, so `path` on `PathNotFoundError`, `NotAFileError` and `ReadOnlyPathError` is the path in the container. An error with no class of its own, such as an error in the progress of a pull, is a `DockerEngineError`.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import {
|
|
92
|
+
ConnectionFailedError,
|
|
93
|
+
ContainerNotRunningError,
|
|
94
|
+
Docker,
|
|
95
|
+
ImageNotFoundError,
|
|
96
|
+
} from '@zukhruf/docker';
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await using docker = await Docker.connect({
|
|
100
|
+
context: 'desktop-linux',
|
|
101
|
+
environment: process.env,
|
|
102
|
+
});
|
|
103
|
+
return await docker.containers.list();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof ConnectionFailedError) return [];
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const create = () => docker.containers.create({ Image: 'postgres:18' });
|
|
110
|
+
await using postgres = await create().catch(async (error) => {
|
|
111
|
+
if (!(error instanceof ImageNotFoundError)) throw error;
|
|
112
|
+
await docker.images.pull('postgres:18');
|
|
113
|
+
return create();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await postgres.exec({ Cmd: ['pg_isready'] }).catch(async (error) => {
|
|
117
|
+
if (!(error instanceof ContainerNotRunningError)) throw error;
|
|
118
|
+
if (error.state === 'paused') await postgres.unpause();
|
|
119
|
+
else await postgres.start();
|
|
120
|
+
return postgres.exec({ Cmd: ['pg_isready'] });
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Some answers of the engine are not errors here:
|
|
125
|
+
|
|
126
|
+
| Call | Engine answer | Result |
|
|
127
|
+
| ------------------------------------------------------------------------------- | ------------- | ----------- |
|
|
128
|
+
| `containers.find()`, `volumes.find()`, `networks.find()`, `images.inspect()` | 404 | `undefined` |
|
|
129
|
+
| `container.stat()` of a missing path, when the container exists | 404 | `undefined` |
|
|
130
|
+
| `container.start()` of a running container, `container.stop()` of a stopped one | 304 | resolves |
|
|
131
|
+
| `remove()` of a missing container, volume, network or image | 404 | resolves |
|
|
132
|
+
|
|
133
|
+
A removal of a container whose removal is already in progress (409) inspects the container until it is gone, and then resolves. When the other removal fails and the container stays, it sends a forced removal again. The signal stops the wait.
|
|
134
|
+
|
|
135
|
+
## Create a container that you own
|
|
136
|
+
|
|
137
|
+
The first argument of a request is the body that the [Engine API reference](https://docs.docker.com/reference/api/engine/) documents, with its field names. The second argument holds what the client adds: the query, such as `name`, and a `signal`. The types of the bodies are in the type namespace `EngineApi`, with the names of the reference.
|
|
138
|
+
|
|
139
|
+
`containers.create()` gives an `OwnedContainer`. Disposal of an owned container removes it with its anonymous volumes ([ADR 0004](./docs/adr/0004-a-handle-owns-its-container-only-when-it-created-it.md)).
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
await using postgres = await docker.containers.create(
|
|
143
|
+
{
|
|
144
|
+
Image: 'postgres:18',
|
|
145
|
+
Env: ['POSTGRES_PASSWORD=postgres'],
|
|
146
|
+
Labels: { 'com.example.owner': 'tests' },
|
|
147
|
+
ExposedPorts: { '5432/tcp': {} },
|
|
148
|
+
HostConfig: {
|
|
149
|
+
PortBindings: { '5432/tcp': [{ HostIp: '127.0.0.1', HostPort: '' }] },
|
|
150
|
+
Memory: 1024 ** 3,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{ name: 'example-postgres' },
|
|
154
|
+
);
|
|
155
|
+
await postgres.start();
|
|
156
|
+
const published = await postgres.port(5432);
|
|
157
|
+
// { host: '127.0.0.1', port: 55012 }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`container.port()` reads the binding of the container's port from an inspect. It gives `undefined` when the port is not published. An address that binds each interface (`0.0.0.0`, `::` or empty) gives the loopback address of the same family. On an SSH engine, the port is on the remote machine. `@zukhruf/testing` forwards it to this machine.
|
|
161
|
+
|
|
162
|
+
## Find a container
|
|
163
|
+
|
|
164
|
+
`containers.find()` sends one inspect. It gives a `Container` with the ID of the engine, or `undefined`. A `Container` names its container by that ID, never by a name: when another container takes the name later, the handle still acts on the first one. A `Container` has no disposal, so `await using` on it is a type error. A found container belongs to whoever created it.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const db = await docker.containers.find('shared-db');
|
|
168
|
+
if (db && !(await db.inspect()).State?.Running) await db.start();
|
|
169
|
+
|
|
170
|
+
const owned = await docker.containers.list({
|
|
171
|
+
all: true,
|
|
172
|
+
filters: { label: ['com.example.owner=tests'] },
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Create a container, or get the one that has the name
|
|
177
|
+
|
|
178
|
+
`containers.createOrGet()` creates the container. When another container has the name, it gives that container. It handles the 409 of the engine and the short time in which the name is taken but an inspect does not find it yet.
|
|
179
|
+
|
|
180
|
+
The container that exists wins: the package does not compare its configuration. A product that needs the same configuration compares it itself, for example with a label that holds a fingerprint.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const result = await docker.containers.createOrGet(
|
|
184
|
+
{ Image: 'postgres:18', Env: ['POSTGRES_PASSWORD=postgres'] },
|
|
185
|
+
{ name: 'shared-db' },
|
|
186
|
+
);
|
|
187
|
+
if (result.created) console.log('created', result.container.id);
|
|
188
|
+
await result.container.start();
|
|
189
|
+
// result.created === true: result.container is an OwnedContainer
|
|
190
|
+
// result.created === false: result.container is a Container
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Run a container to its end
|
|
194
|
+
|
|
195
|
+
`containers.run()` creates the container, attaches to its output, starts it, waits for its end, and removes it. It gives the exit code and the output. A non-zero exit code is data, not an error. A cancel through the signal also removes the container.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const { exitCode, stdout } = await docker.containers.run(
|
|
199
|
+
{
|
|
200
|
+
Image: 'alpine:3',
|
|
201
|
+
Cmd: ['sh', '-c', 'uname -m'],
|
|
202
|
+
WorkingDir: '/work',
|
|
203
|
+
HostConfig: { Binds: [`${process.cwd()}:/work:ro`] },
|
|
204
|
+
},
|
|
205
|
+
{ signal: AbortSignal.timeout(60_000) },
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
For output while the container runs, attach before the start. A container process is one stream of chunks, in the order of the engine. Each chunk names its stream ([ADR 0005](./docs/adr/0005-output-is-one-stream-of-chunks-and-only-a-close-ends-the-input.md)):
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
await using tests = await docker.containers.create({
|
|
213
|
+
Image: 'node:lts-alpine',
|
|
214
|
+
Cmd: ['npm', 'test'],
|
|
215
|
+
WorkingDir: '/app',
|
|
216
|
+
HostConfig: { Binds: [`${process.cwd()}:/app`] },
|
|
217
|
+
});
|
|
218
|
+
const output = await tests.attach();
|
|
219
|
+
await tests.start();
|
|
220
|
+
for await (const { stream, data } of output) {
|
|
221
|
+
(stream === 'stdout' ? process.stdout : process.stderr).write(data);
|
|
222
|
+
}
|
|
223
|
+
if ((await output.exitCode) !== 0) throw new Error('The tests failed');
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`attach()` reads the state of the container first. For a created container, the exit code is the code of its next exit, so attach before `start()`. For a running container, it is the code when the container stops. An exited or a paused container makes `attach()` reject with `ContainerNotRunningError`.
|
|
227
|
+
|
|
228
|
+
## Run a command in a container
|
|
229
|
+
|
|
230
|
+
`container.exec()` runs a command next to the main process of a running container. It gives the exit code, standard output, standard error, and `output`, the two in the order of the engine. The package does not remove the last newline.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
const result = await postgres.exec({
|
|
234
|
+
Cmd: ['psql', '-U', 'postgres', '-tAc', 'select 1'],
|
|
235
|
+
Env: ['PGCONNECT_TIMEOUT=5'],
|
|
236
|
+
});
|
|
237
|
+
// { exitCode: 0, stdout: '1\n', stderr: '', output: '1\n' }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`stdin` sends input to the command, and the package then ends the input:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
await postgres.exec(
|
|
244
|
+
{ Cmd: ['psql', '-U', 'postgres'] },
|
|
245
|
+
{ stdin: 'create table notes (body text);' },
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A command can end before it reads all of its input, such as `head -c1`. The result of the command then stands: the input that the command did not read is not an error.
|
|
250
|
+
|
|
251
|
+
`container.spawn()` gives the container process while the command runs. With `stdin: true`, the container process has a writable `stdin`. Only `stdin.close()` ends the input. The package drops an empty write, so an empty write never ends the input.
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
const psql = await postgres.spawn(
|
|
255
|
+
{ Cmd: ['psql', '-U', 'postgres', '-tA'] },
|
|
256
|
+
{ stdin: true },
|
|
257
|
+
);
|
|
258
|
+
const input = psql.stdin!.getWriter();
|
|
259
|
+
await input.write(new TextEncoder().encode('select now();\n'));
|
|
260
|
+
await input.close();
|
|
261
|
+
const { stdout } = await psql.collect();
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The end of the input depends on the host. On a Unix socket, the package shuts down the write side of the socket. Over SSH, it closes the standard input of `ssh`, and `docker system dial-stdio` then closes the write side to the engine. On a Windows named pipe, it writes a message of zero bytes, because a pipe has no shutdown of one side: the engine listens on its pipe in message mode and reads that message as the end. `CloseWrite` of go-winio, which the Docker CLI uses, sends the same message.
|
|
265
|
+
|
|
266
|
+
A signal cancels the wait. It does not stop the command: the Engine API has no request that stops an exec. To stop a command, kill the container, or run the command under `timeout` in the container. A signal also cancels an iteration of a container process: the iteration rejects with the reason of the signal, as each cancel does ([the async glossary](../async/CONTEXT.md)).
|
|
267
|
+
|
|
268
|
+
A container or an exec with a TTY (`Tty: true`) gives a raw stream. Then each chunk is `stdout`.
|
|
269
|
+
|
|
270
|
+
## Read the logs
|
|
271
|
+
|
|
272
|
+
`container.logs()` gives the logs until now: standard output, standard error and `output`, as `exec()` does. `container.followLogs()` gives chunks as the container writes them. The iteration ends when the container stops. When the signal aborts first, the iteration rejects with the reason of the signal. Both take the query of the Engine API: `tail`, `since` and `timestamps`.
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
const { output } = await postgres.logs({ tail: 50 });
|
|
276
|
+
|
|
277
|
+
for await (const { data } of postgres.followLogs({
|
|
278
|
+
signal: AbortSignal.timeout(30_000),
|
|
279
|
+
})) {
|
|
280
|
+
if (new TextDecoder().decode(data).includes('ready to accept connections'))
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Read and write files
|
|
286
|
+
|
|
287
|
+
The package moves files through the archive requests of the engine, not through exec ([ADR 0006](./docs/adr/0006-files-move-through-the-archive-requests-not-through-exec.md)). Thus the requests work on a stopped container, and on an image with no shell.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
await postgres.writeFiles([
|
|
291
|
+
{
|
|
292
|
+
path: '/docker-entrypoint-initdb.d/schema.sql',
|
|
293
|
+
data: 'create table notes (body text);',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
path: '/srv/run.sh',
|
|
297
|
+
data: '#!/bin/sh\npg_isready\n',
|
|
298
|
+
mode: 0o755,
|
|
299
|
+
uid: 999,
|
|
300
|
+
gid: 999,
|
|
301
|
+
},
|
|
302
|
+
{ path: '/srv/cache', directory: true },
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
const config = await postgres.readFile(
|
|
306
|
+
'/var/lib/postgresql/data/postgresql.conf',
|
|
307
|
+
);
|
|
308
|
+
const stat = await postgres.stat('/srv/run.sh');
|
|
309
|
+
// { name: 'run.sh', size: 120, mode: 493, mtime: '…', linkTarget: '' }
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
- `writeFiles()` sends one archive. It extracts the archive in the deepest directory that holds each entry and that already exists, which it finds with `stat()`. The engine creates each parent directory that an entry implies below that directory. Thus a container with a read-only root filesystem can still get files in a writable volume: the engine checks only the directory of the extraction.
|
|
313
|
+
- A written entry belongs to its `uid` and `gid`, or to root (0:0) when it has none. The package puts them in the archive, and the engine keeps the owner of each entry in the archive.
|
|
314
|
+
- `putArchive(directory, archive, { copyUIDGID: true })` gives each extracted entry to the user of the container (`Config.User`) in place of the owner in the archive.
|
|
315
|
+
- `readFile()` gives the bytes of one regular file. A missing path makes it reject with `PathNotFoundError`, and a directory, a link or another kind of entry with `NotAFileError`.
|
|
316
|
+
- `stat()` gives `undefined` for a missing path. A missing container makes each file request reject with `ContainerNotFoundError`.
|
|
317
|
+
- `getArchive(path)` and `putArchive(directory, archive)` give the tar streams themselves, for a directory or a large tree.
|
|
318
|
+
- The engine cannot reach `/proc`, `/sys`, `/dev`, a tmpfs mount, or a mount made inside the container. For such a path, use `exec()`.
|
|
319
|
+
- When the directory of the extraction is on a read-only root filesystem or in a read-only mount, `writeFiles()` and `putArchive()` reject with `ReadOnlyPathError`.
|
|
320
|
+
|
|
321
|
+
## Control a container
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
await postgres.stop({ t: 10 }); // SIGTERM, then the engine sends SIGKILL after 10 seconds
|
|
325
|
+
await postgres.restart({ t: 10 });
|
|
326
|
+
await postgres.kill('SIGINT');
|
|
327
|
+
await postgres.pause();
|
|
328
|
+
await postgres.unpause();
|
|
329
|
+
const { StatusCode } = await postgres.wait({ condition: 'not-running' });
|
|
330
|
+
await postgres.remove({ force: true, v: true });
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`stop()` and `restart()` keep the meaning of the engine: after `t` seconds, the engine kills the main process.
|
|
334
|
+
|
|
335
|
+
## Volumes
|
|
336
|
+
|
|
337
|
+
`volumes.create()` gives an `OwnedVolume`. Disposal of an owned volume removes it. A volume has no ID, so its name names it.
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
await using data = await docker.volumes.create({
|
|
341
|
+
Labels: { 'com.example.owner': 'tests' },
|
|
342
|
+
});
|
|
343
|
+
await docker.containers.run({
|
|
344
|
+
Image: 'alpine:3',
|
|
345
|
+
Cmd: ['touch', '/data/ready'],
|
|
346
|
+
HostConfig: {
|
|
347
|
+
Mounts: [{ Type: 'volume', Source: data.name, Target: '/data' }],
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The engine gives the volume a name when the body has none. The Engine API answers a create with a taken name with the existing volume, not with an error. Thus the package adds a creation label with a random value, and it compares the label of the answer: `volumes.create()` rejects with `NameConflictError` for a taken name, and `volumes.createOrGet()` gives `{ created: false, volume }`. `volumes.find()` and `volumes.list()` work as they do for containers.
|
|
353
|
+
|
|
354
|
+
## Networks
|
|
355
|
+
|
|
356
|
+
`networks.create()` gives an `OwnedNetwork`. Disposal removes the network. `networks.find()`, `networks.createOrGet()` and `networks.list()` work as they do for containers.
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
await using network = await docker.networks.create({ Name: 'example-net' });
|
|
360
|
+
await network.connect(postgres, { Aliases: ['db'] });
|
|
361
|
+
await network.disconnect(postgres);
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
- From API 1.44, the engine refuses a create with a taken name. Thus `networks.create()` rejects with `NameConflictError`, and `networks.createOrGet()` gives `{ created: false, network }` with the network that exists.
|
|
365
|
+
- Only a running container has an endpoint on a network. Thus only a running container keeps a network from its removal, and `NetworkInUseError` gives the IDs of the running containers.
|
|
366
|
+
- The engine keeps the network of a created container for its start. Thus a connect of a created container to a missing network resolves, and `start()` then rejects with `NetworkNotFoundError`.
|
|
367
|
+
- `remove()` of `bridge`, `host` or `none` rejects with `DockerEngineError` and 403: the engine never removes a pre-defined network.
|
|
368
|
+
|
|
369
|
+
## Images
|
|
370
|
+
|
|
371
|
+
Images have no owner: an image is a cache that each container on the engine shares, so no handle removes one on disposal.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
375
|
+
import { Readable, Writable } from 'node:stream';
|
|
376
|
+
|
|
377
|
+
if (!(await docker.images.inspect('alpine:3')))
|
|
378
|
+
await docker.images.pull('alpine:3', { platform: 'linux/amd64' });
|
|
379
|
+
|
|
380
|
+
const ghcr = {
|
|
381
|
+
username: 'me',
|
|
382
|
+
password: process.env.GHCR_TOKEN!,
|
|
383
|
+
serveraddress: 'ghcr.io',
|
|
384
|
+
};
|
|
385
|
+
await docker.images.pull('ghcr.io/example/private:1', { auth: ghcr });
|
|
386
|
+
await docker.images.tag('app:1', 'ghcr.io/example/app:1');
|
|
387
|
+
await docker.images.push('ghcr.io/example/app:1', { auth: ghcr });
|
|
388
|
+
|
|
389
|
+
const archive = await docker.images.save(['alpine:3']);
|
|
390
|
+
await archive.pipeTo(Writable.toWeb(createWriteStream('alpine.tar')));
|
|
391
|
+
await docker.images.load(Readable.toWeb(createReadStream('alpine.tar')));
|
|
392
|
+
await docker.images.remove('alpine:3');
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`pull()`, `push()` and `load()` read the progress of the engine until the end. They reject with `DockerEngineError` when the engine reports an error in the progress. Then `status` is `undefined`, because the HTTP status was 200. The engine answers a push of an image that it does not have with 200 too, and reports the error in the progress. Thus `push()` inspects the image after an error in the progress: when the engine does not have the image, `push()` rejects with `ImageNotFoundError`.
|
|
396
|
+
|
|
397
|
+
`images.remove()` of one name of an image that has more names removes only that name. A removal of an image that a container uses, or a removal by ID of an image with more than one name, rejects with `ImageInUseError`. `auth` holds explicit credentials. The package runs no credential helper, because a helper is a program on the `PATH`.
|
|
398
|
+
|
|
399
|
+
## Events
|
|
400
|
+
|
|
401
|
+
`docker.events()` gives the events of the engine as they happen. The iteration ends only when the signal aborts, and then it rejects with the reason of the signal. It takes the `since` and `filters` of the Engine API.
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
for await (const event of docker.events({
|
|
405
|
+
filters: { container: [postgres.id], event: ['health_status'] },
|
|
406
|
+
signal: AbortSignal.timeout(60_000),
|
|
407
|
+
})) {
|
|
408
|
+
if (event.Action === 'health_status: healthy') break;
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Send the Docker CLI to the same engine
|
|
413
|
+
|
|
414
|
+
Image builds and Compose stay with the Docker CLI. `docker.cliEnvironment` gives the variables that send the CLI to the engine of the client, whatever context is current:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import spawn from 'nano-spawn';
|
|
418
|
+
|
|
419
|
+
await spawn(
|
|
420
|
+
'docker',
|
|
421
|
+
['buildx', 'build', '--platform', 'linux/amd64', '-t', 'app', '.'],
|
|
422
|
+
{ env: docker.cliEnvironment },
|
|
423
|
+
);
|
|
424
|
+
// docker.cliEnvironment is { DOCKER_HOST: docker.host, DOCKER_CONTEXT: undefined }
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## SSH engines
|
|
428
|
+
|
|
429
|
+
For an `ssh://` host, the connection helper is `ssh -T -o BatchMode=yes <machine> docker system dial-stdio`, as the Docker CLI uses it. `ssh` reads `~/.ssh/config`, so jump hosts, keys, agents and connection sharing work as they work for the CLI. The remote machine must have `docker` on the `PATH` of its SSH shell.
|
|
430
|
+
|
|
431
|
+
Each connection of the client is one `ssh` process. The client keeps idle connections for the next request. An attach, an exec, followed logs and events each use their own connection. Disposal of the client ends the `ssh` processes.
|
|
432
|
+
|
|
433
|
+
## The API version
|
|
434
|
+
|
|
435
|
+
The path of each request starts with the API version, as in `/v1.51/containers/json`. `Docker.connect()` reads the highest version of the engine from `GET /_ping`. The client then uses the lower of that version and its own highest version. It refuses an engine that is older than its own lowest version with `UnsupportedEngineError`. `docker.apiVersion` gives the version that the client uses.
|
|
436
|
+
|
|
437
|
+
The lowest version is 1.44. From that version on, "`POST /networks/create` with a duplicated name now fails systematically" ([Engine API version history](https://docs.docker.com/reference/api/engine/version-history/)), and `networks.createOrGet()` needs that refusal. The highest version is the highest one that the tests of the package run on.
|
|
438
|
+
|
|
439
|
+
## What the package does not do
|
|
440
|
+
|
|
441
|
+
[ADR 0003](./docs/adr/0003-the-package-leads-and-the-repositories-change.md) gives the reason for each:
|
|
442
|
+
|
|
443
|
+
- Image builds and Compose. Use the CLI with `docker.cliEnvironment`.
|
|
444
|
+
- Engines on TCP.
|
|
445
|
+
- Credential helpers. Give `auth` instead.
|
|
446
|
+
- A stop or a time limit for one exec.
|
|
447
|
+
- The output of an exec after a detach.
|
|
448
|
+
- Swarm: services, tasks, nodes, secrets and configs.
|
|
449
|
+
|
|
450
|
+
## The same concepts in other systems
|
|
451
|
+
|
|
452
|
+
| Concept | Other systems |
|
|
453
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
454
|
+
| A client of the Engine API | [moby/moby/client](https://github.com/moby/moby/tree/master/client) (Go), [bollard](https://github.com/fussybeaver/bollard) (Rust), [dockerode](https://github.com/apocas/dockerode) (Node.js) |
|
|
455
|
+
| The selection of the engine | `resolveContextName` of [docker/cli](https://github.com/docker/cli/blob/master/cli/command/cli.go); the master branch of bollard |
|
|
456
|
+
| Fixed socket paths | The client strategies of Testcontainers, and docker-modem. They do not read contexts. |
|
|
457
|
+
| The connection helper | [connhelper of docker/cli](https://github.com/docker/cli/blob/master/cli/connhelper/connhelper.go) |
|
|
458
|
+
| An owned handle | `ownsLifecycle` of microsandbox, which stops a sandbox on disposal only when the handle owns it |
|
|
459
|
+
| A handle by its ID | The stable `id` of a microsandbox handle, which refuses to act on a replacement that reused the name |
|
|
460
|
+
| Create, or get by name | `connectOrCreate` of microsandbox: the existing sandbox wins |
|
|
461
|
+
| One stream of chunks | `ExecEvent` of microsandbox; "Stream format" in the Engine API reference; `stdcopy.StdCopy` of Moby |
|
|
462
|
+
| Files through archives | `docker cp`; `copyFilesToContainer` of Testcontainers; `fs()` of microsandbox |
|
|
463
|
+
| Errors by HTTP status | `errdefs` of Moby: `NotFound` for 404, `Conflict` for 409, `NotModified` for 304 |
|
|
464
|
+
| API version negotiation | The Go client of Moby, which negotiates by default |
|
|
465
|
+
|
|
466
|
+
## API
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
export class Docker implements AsyncDisposable {
|
|
470
|
+
/** Selects the engine in the order of the Docker CLI, pings it and negotiates the API version. */
|
|
471
|
+
static connect(options?: ConnectOptions): Promise<Docker>;
|
|
472
|
+
/** Each context of the context store, and the default context. */
|
|
473
|
+
static contexts(options?: {
|
|
474
|
+
environment?: Environment;
|
|
475
|
+
}): Promise<DockerContext[]>;
|
|
476
|
+
|
|
477
|
+
/** The context that selected the engine: 'default' for DOCKER_HOST or a host option. */
|
|
478
|
+
readonly context: string;
|
|
479
|
+
readonly host: string;
|
|
480
|
+
readonly apiVersion: string;
|
|
481
|
+
/** Sends the Docker CLI to this engine: { DOCKER_HOST, DOCKER_CONTEXT: undefined }. */
|
|
482
|
+
readonly cliEnvironment: Readonly<Record<string, string | undefined>>;
|
|
483
|
+
readonly containers: Containers;
|
|
484
|
+
readonly volumes: Volumes;
|
|
485
|
+
readonly networks: Networks;
|
|
486
|
+
readonly images: Images;
|
|
487
|
+
|
|
488
|
+
info(options?: CancelOptions): Promise<EngineApi.SystemInfo>;
|
|
489
|
+
/** The events of the engine as they happen. An abort of the signal rejects the iteration with its reason. */
|
|
490
|
+
events(
|
|
491
|
+
options?: CancelOptions & { since?: number; filters?: Filters },
|
|
492
|
+
): AsyncIterable<EngineApi.EventMessage>;
|
|
493
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The environment of the host program, such as process.env. The package reads only DOCKER_HOST, DOCKER_CONTEXT and DOCKER_CONFIG. */
|
|
497
|
+
export type Environment = Readonly<Record<string, string | undefined>>;
|
|
498
|
+
|
|
499
|
+
/** A context or a host, never both. */
|
|
500
|
+
export type ConnectOptions = CancelOptions & { environment?: Environment } & (
|
|
501
|
+
{ context?: string; host?: never } | { host?: string; context?: never }
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
export interface DockerContext {
|
|
505
|
+
name: string;
|
|
506
|
+
host: string;
|
|
507
|
+
/** The context that connect() selects with the same environment and no context or host. */
|
|
508
|
+
current: boolean;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export interface CancelOptions {
|
|
512
|
+
/** Cancels the wait for the engine. */
|
|
513
|
+
signal?: AbortSignal;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The filters of a list or of the events, as the Engine API takes them. */
|
|
517
|
+
export type Filters = Record<string, string[]>;
|
|
518
|
+
|
|
519
|
+
/** The result of createOrGet(): an owned handle when this call created the resource. */
|
|
520
|
+
export type Created<Key extends string, Owned, Found> =
|
|
521
|
+
| ({ created: true } & Record<Key, Owned>)
|
|
522
|
+
| ({ created: false } & Record<Key, Found>);
|
|
523
|
+
|
|
524
|
+
export interface Containers {
|
|
525
|
+
create(
|
|
526
|
+
body: EngineApi.ContainerCreateRequest,
|
|
527
|
+
options?: CancelOptions & { name?: string; platform?: string },
|
|
528
|
+
): Promise<OwnedContainer>;
|
|
529
|
+
/** Creates the container, or gives the one that has the name. The container that exists wins. */
|
|
530
|
+
createOrGet(
|
|
531
|
+
body: EngineApi.ContainerCreateRequest,
|
|
532
|
+
options: CancelOptions & { name: string; platform?: string },
|
|
533
|
+
): Promise<Created<'container', OwnedContainer, Container>>;
|
|
534
|
+
/** Create, attach, start, wait, remove. A cancel also removes the container. */
|
|
535
|
+
run(
|
|
536
|
+
body: EngineApi.ContainerCreateRequest,
|
|
537
|
+
options?: CancelOptions & { name?: string; platform?: string },
|
|
538
|
+
): Promise<ExecResult>;
|
|
539
|
+
/** One inspect: a handle with the ID of the engine, or undefined for 404. */
|
|
540
|
+
find(
|
|
541
|
+
nameOrId: string,
|
|
542
|
+
options?: CancelOptions,
|
|
543
|
+
): Promise<Container | undefined>;
|
|
544
|
+
list(
|
|
545
|
+
options?: CancelOptions & { all?: boolean; filters?: Filters },
|
|
546
|
+
): Promise<EngineApi.ContainerSummary[]>;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** A container, by the ID that the engine gave it. It has no disposal. */
|
|
550
|
+
export class Container {
|
|
551
|
+
readonly id: string;
|
|
552
|
+
inspect(options?: CancelOptions): Promise<EngineApi.ContainerInspectResponse>;
|
|
553
|
+
/** Resolves for 304 (already running). */
|
|
554
|
+
start(options?: CancelOptions): Promise<void>;
|
|
555
|
+
/** Resolves for 304 (already stopped). t: seconds before the engine kills the main process. */
|
|
556
|
+
stop(options?: CancelOptions & { t?: number }): Promise<void>;
|
|
557
|
+
restart(options?: CancelOptions & { t?: number }): Promise<void>;
|
|
558
|
+
kill(
|
|
559
|
+
processSignal?: string /* default 'SIGKILL' */,
|
|
560
|
+
options?: CancelOptions,
|
|
561
|
+
): Promise<void>;
|
|
562
|
+
pause(options?: CancelOptions): Promise<void>;
|
|
563
|
+
unpause(options?: CancelOptions): Promise<void>;
|
|
564
|
+
wait(
|
|
565
|
+
options?: CancelOptions & {
|
|
566
|
+
condition?: 'not-running' | 'next-exit' | 'removed';
|
|
567
|
+
},
|
|
568
|
+
): Promise<EngineApi.ContainerWaitResponse>;
|
|
569
|
+
/** Resolves for 404. */
|
|
570
|
+
remove(
|
|
571
|
+
options?: CancelOptions & { force?: boolean; v?: boolean },
|
|
572
|
+
): Promise<void>;
|
|
573
|
+
/** The published binding of a port of the container; undefined when it is not published. */
|
|
574
|
+
port(
|
|
575
|
+
containerPort: number,
|
|
576
|
+
protocol?: 'tcp' | 'udp',
|
|
577
|
+
options?: CancelOptions,
|
|
578
|
+
): Promise<{ host: string; port: number } | undefined>;
|
|
579
|
+
|
|
580
|
+
logs(options?: CancelOptions & LogQuery): Promise<Output>;
|
|
581
|
+
followLogs(options?: CancelOptions & LogQuery): AsyncIterable<OutputChunk>;
|
|
582
|
+
exec(
|
|
583
|
+
body: EngineApi.ExecConfig,
|
|
584
|
+
options?: CancelOptions & { stdin?: string | Uint8Array },
|
|
585
|
+
): Promise<ExecResult>;
|
|
586
|
+
spawn(
|
|
587
|
+
body: EngineApi.ExecConfig,
|
|
588
|
+
options?: CancelOptions & { stdin?: boolean },
|
|
589
|
+
): Promise<ContainerProcess>;
|
|
590
|
+
/** Attach before start() to get all output. */
|
|
591
|
+
attach(
|
|
592
|
+
options?: CancelOptions & { stdin?: boolean },
|
|
593
|
+
): Promise<ContainerProcess>;
|
|
594
|
+
|
|
595
|
+
/** undefined for 404. */
|
|
596
|
+
stat(
|
|
597
|
+
path: string,
|
|
598
|
+
options?: CancelOptions,
|
|
599
|
+
): Promise<EngineApi.ContainerPathStat | undefined>;
|
|
600
|
+
/** The bytes of one regular file. */
|
|
601
|
+
readFile(path: string, options?: CancelOptions): Promise<Uint8Array>;
|
|
602
|
+
/** One archive, extracted at '/'. */
|
|
603
|
+
writeFiles(files: ContainerFile[], options?: CancelOptions): Promise<void>;
|
|
604
|
+
getArchive(
|
|
605
|
+
path: string,
|
|
606
|
+
options?: CancelOptions,
|
|
607
|
+
): Promise<ReadableStream<Uint8Array>>;
|
|
608
|
+
putArchive(
|
|
609
|
+
directory: string,
|
|
610
|
+
archive: ReadableStream<Uint8Array> | Uint8Array,
|
|
611
|
+
options?: CancelOptions & { copyUIDGID?: boolean },
|
|
612
|
+
): Promise<void>;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** A container that this handle created. Disposal removes it with its anonymous volumes. */
|
|
616
|
+
export class OwnedContainer extends Container implements AsyncDisposable {
|
|
617
|
+
/** remove({ force: true, v: true }) */
|
|
618
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export interface LogQuery {
|
|
622
|
+
tail?: number | 'all';
|
|
623
|
+
since?: number;
|
|
624
|
+
timestamps?: boolean;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export type ContainerFile =
|
|
628
|
+
| {
|
|
629
|
+
path: string;
|
|
630
|
+
data: string | Uint8Array;
|
|
631
|
+
mode?: number;
|
|
632
|
+
uid?: number;
|
|
633
|
+
gid?: number;
|
|
634
|
+
}
|
|
635
|
+
| {
|
|
636
|
+
path: string;
|
|
637
|
+
directory: true;
|
|
638
|
+
mode?: number;
|
|
639
|
+
uid?: number;
|
|
640
|
+
gid?: number;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
export interface OutputChunk {
|
|
644
|
+
stream: 'stdout' | 'stderr';
|
|
645
|
+
data: Uint8Array;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export interface Output {
|
|
649
|
+
stdout: string;
|
|
650
|
+
stderr: string;
|
|
651
|
+
/** Standard output and standard error, in the order of the engine. */
|
|
652
|
+
output: string;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface ExecResult extends Output {
|
|
656
|
+
exitCode: number;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** The output of an exec or of an attached container: chunks in the order of the engine, given once. */
|
|
660
|
+
export interface ContainerProcess extends AsyncIterable<OutputChunk> {
|
|
661
|
+
/** Present with { stdin: true }. Only close() ends the input; an empty write is dropped. */
|
|
662
|
+
readonly stdin: WritableStream<Uint8Array> | undefined;
|
|
663
|
+
readonly exitCode: Promise<number>;
|
|
664
|
+
/** Reads each chunk and gives the result. */
|
|
665
|
+
collect(): Promise<ExecResult>;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export interface Volumes {
|
|
669
|
+
/** Rejects with NameConflictError when the name is taken. */
|
|
670
|
+
create(
|
|
671
|
+
body?: EngineApi.VolumeCreateRequest,
|
|
672
|
+
options?: CancelOptions,
|
|
673
|
+
): Promise<OwnedVolume>;
|
|
674
|
+
createOrGet(
|
|
675
|
+
body: EngineApi.VolumeCreateRequest & { Name: string },
|
|
676
|
+
options?: CancelOptions,
|
|
677
|
+
): Promise<Created<'volume', OwnedVolume, Volume>>;
|
|
678
|
+
find(name: string, options?: CancelOptions): Promise<Volume | undefined>;
|
|
679
|
+
list(
|
|
680
|
+
options?: CancelOptions & { filters?: Filters },
|
|
681
|
+
): Promise<EngineApi.Volume[]>;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export class Volume {
|
|
685
|
+
readonly name: string;
|
|
686
|
+
inspect(options?: CancelOptions): Promise<EngineApi.Volume>;
|
|
687
|
+
/** Resolves for 404. Rejects with VolumeInUseError while a container uses the volume. */
|
|
688
|
+
remove(options?: CancelOptions & { force?: boolean }): Promise<void>;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export class OwnedVolume extends Volume implements AsyncDisposable {
|
|
692
|
+
/** remove() */
|
|
693
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export interface Networks {
|
|
697
|
+
create(
|
|
698
|
+
body: EngineApi.NetworkCreateRequest,
|
|
699
|
+
options?: CancelOptions,
|
|
700
|
+
): Promise<OwnedNetwork>;
|
|
701
|
+
createOrGet(
|
|
702
|
+
body: EngineApi.NetworkCreateRequest,
|
|
703
|
+
options?: CancelOptions,
|
|
704
|
+
): Promise<Created<'network', OwnedNetwork, Network>>;
|
|
705
|
+
find(nameOrId: string, options?: CancelOptions): Promise<Network | undefined>;
|
|
706
|
+
list(
|
|
707
|
+
options?: CancelOptions & { filters?: Filters },
|
|
708
|
+
): Promise<EngineApi.NetworkSummary[]>;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export class Network {
|
|
712
|
+
readonly id: string;
|
|
713
|
+
inspect(options?: CancelOptions): Promise<EngineApi.NetworkInspect>;
|
|
714
|
+
connect(
|
|
715
|
+
container: Container,
|
|
716
|
+
endpoint?: EngineApi.NetworkConnectRequest['EndpointConfig'],
|
|
717
|
+
options?: CancelOptions,
|
|
718
|
+
): Promise<void>;
|
|
719
|
+
disconnect(
|
|
720
|
+
container: Container,
|
|
721
|
+
options?: CancelOptions & { force?: boolean },
|
|
722
|
+
): Promise<void>;
|
|
723
|
+
/** Resolves for 404. */
|
|
724
|
+
remove(options?: CancelOptions): Promise<void>;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export class OwnedNetwork extends Network implements AsyncDisposable {
|
|
728
|
+
/** remove() */
|
|
729
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export interface Images {
|
|
733
|
+
/** undefined for 404. */
|
|
734
|
+
inspect(
|
|
735
|
+
reference: string,
|
|
736
|
+
options?: CancelOptions,
|
|
737
|
+
): Promise<EngineApi.ImageInspect | undefined>;
|
|
738
|
+
list(
|
|
739
|
+
options?: CancelOptions & { all?: boolean; filters?: Filters },
|
|
740
|
+
): Promise<EngineApi.ImageSummary[]>;
|
|
741
|
+
pull(
|
|
742
|
+
reference: string,
|
|
743
|
+
options?: CancelOptions & {
|
|
744
|
+
platform?: string;
|
|
745
|
+
auth?: EngineApi.AuthConfig;
|
|
746
|
+
},
|
|
747
|
+
): Promise<void>;
|
|
748
|
+
/** Gives the image a second name, such as a registry name before a push. target: 'repository:tag'. */
|
|
749
|
+
tag(
|
|
750
|
+
reference: string,
|
|
751
|
+
target: string,
|
|
752
|
+
options?: CancelOptions,
|
|
753
|
+
): Promise<void>;
|
|
754
|
+
push(
|
|
755
|
+
reference: string,
|
|
756
|
+
options: CancelOptions & { auth: EngineApi.AuthConfig },
|
|
757
|
+
): Promise<void>;
|
|
758
|
+
/** A tar archive of the images, as `docker save` writes it. */
|
|
759
|
+
save(
|
|
760
|
+
references: string[],
|
|
761
|
+
options?: CancelOptions,
|
|
762
|
+
): Promise<ReadableStream<Uint8Array>>;
|
|
763
|
+
load(
|
|
764
|
+
archive: ReadableStream<Uint8Array> | Uint8Array,
|
|
765
|
+
options?: CancelOptions,
|
|
766
|
+
): Promise<void>;
|
|
767
|
+
/** Resolves for 404. */
|
|
768
|
+
remove(
|
|
769
|
+
reference: string,
|
|
770
|
+
options?: CancelOptions & { force?: boolean; noprune?: boolean },
|
|
771
|
+
): Promise<void>;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The base of every error of the package. */
|
|
775
|
+
export class DockerError extends Error {}
|
|
776
|
+
|
|
777
|
+
export class ConnectionFailedError extends DockerError {
|
|
778
|
+
readonly host: string;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
export class UnsupportedHostError extends DockerError {
|
|
782
|
+
readonly host: string;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export class ContextNotFoundError extends DockerError {
|
|
786
|
+
readonly context: string;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export class UnsupportedEngineError extends DockerError {
|
|
790
|
+
readonly host: string;
|
|
791
|
+
/** The highest API version of the engine. */
|
|
792
|
+
readonly engineVersion: string;
|
|
793
|
+
/** The lowest API version that the package supports. */
|
|
794
|
+
readonly minimumVersion: string;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** The base of every answer of the engine that is an error. */
|
|
798
|
+
export class DockerEngineError extends DockerError {
|
|
799
|
+
/** The HTTP status; undefined for an error in the progress of a pull, a push or a load. */
|
|
800
|
+
readonly status: number | undefined;
|
|
801
|
+
/** The request that got the error. */
|
|
802
|
+
readonly request: { readonly method: string; readonly path: string };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export class ContainerNotFoundError extends DockerEngineError {
|
|
806
|
+
readonly container: string;
|
|
807
|
+
}
|
|
808
|
+
export class ContainerNotRunningError extends DockerEngineError {
|
|
809
|
+
readonly container: string;
|
|
810
|
+
/** From an inspect after the 409: 'created', 'exited', 'paused', 'restarting', 'removing' or 'dead'. */
|
|
811
|
+
readonly state: string;
|
|
812
|
+
}
|
|
813
|
+
export class ContainerRunningError extends DockerEngineError {
|
|
814
|
+
readonly container: string;
|
|
815
|
+
}
|
|
816
|
+
export class NameConflictError extends DockerEngineError {
|
|
817
|
+
readonly kind: 'container' | 'network' | 'volume';
|
|
818
|
+
/** The name that another resource has. Not `name`: that is the name of the error class. */
|
|
819
|
+
readonly takenName: string;
|
|
820
|
+
}
|
|
821
|
+
export class ImageNotFoundError extends DockerEngineError {
|
|
822
|
+
readonly image: string;
|
|
823
|
+
}
|
|
824
|
+
export class NetworkNotFoundError extends DockerEngineError {
|
|
825
|
+
readonly network: string;
|
|
826
|
+
}
|
|
827
|
+
export class VolumeNotFoundError extends DockerEngineError {
|
|
828
|
+
readonly volume: string;
|
|
829
|
+
}
|
|
830
|
+
export class BindSourceNotFoundError extends DockerEngineError {
|
|
831
|
+
/** The path on the engine's host. */
|
|
832
|
+
readonly source: string;
|
|
833
|
+
}
|
|
834
|
+
export class VolumeInUseError extends DockerEngineError {
|
|
835
|
+
readonly volume: string;
|
|
836
|
+
}
|
|
837
|
+
export class NetworkInUseError extends DockerEngineError {
|
|
838
|
+
readonly network: string;
|
|
839
|
+
/** The IDs of the connected containers, from an inspect after the 403. */
|
|
840
|
+
readonly containers: string[];
|
|
841
|
+
}
|
|
842
|
+
export class ImageInUseError extends DockerEngineError {
|
|
843
|
+
readonly image: string;
|
|
844
|
+
}
|
|
845
|
+
export class PathNotFoundError extends DockerEngineError {
|
|
846
|
+
readonly container: string;
|
|
847
|
+
readonly path: string;
|
|
848
|
+
}
|
|
849
|
+
export class NotAFileError extends DockerEngineError {
|
|
850
|
+
readonly container: string;
|
|
851
|
+
readonly path: string;
|
|
852
|
+
/** The kind of the entry: 'directory', 'symlink', 'link' or 'other'. */
|
|
853
|
+
readonly kind: 'directory' | 'symlink' | 'link' | 'other';
|
|
854
|
+
}
|
|
855
|
+
export class ReadOnlyPathError extends DockerEngineError {
|
|
856
|
+
readonly container: string;
|
|
857
|
+
readonly path: string;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** The bodies and answers of the Engine API, with the names of its reference. Types only. */
|
|
861
|
+
export type * as EngineApi from './engine-api.ts';
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
`EngineApi.ContainerCreateRequest` is the body of `POST /containers/create`: `ContainerConfig` with `HostConfig` and `NetworkingConfig`, as `container.CreateRequest` of Moby names it. `EngineApi.ContainerPathStat` is the JSON of the header `X-Docker-Container-Path-Stat`, as `container.PathStat` of Moby names it.
|
|
865
|
+
|
|
866
|
+
Each call sends these requests of the Engine API:
|
|
867
|
+
|
|
868
|
+
| Call | Request |
|
|
869
|
+
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
870
|
+
| `Docker.connect()` | `GET /_ping` |
|
|
871
|
+
| `docker.info()`, `docker.events()` | `GET /info`, `GET /events` |
|
|
872
|
+
| `containers.create()`, `createOrGet()` | `POST /containers/create`, and `GET /containers/{id}/json` after a 409 |
|
|
873
|
+
| `containers.run()` | `create`, `attach`, `start`, `wait`, `DELETE /containers/{id}` |
|
|
874
|
+
| `containers.find()`, `container.inspect()`, `container.port()` | `GET /containers/{id}/json` |
|
|
875
|
+
| `containers.list()` | `GET /containers/json` |
|
|
876
|
+
| `start()`, `stop()`, `restart()`, `kill()`, `pause()`, `unpause()`, `wait()` | `POST /containers/{id}/start`, `/stop`, `/restart`, `/kill`, `/pause`, `/unpause`, `/wait` |
|
|
877
|
+
| `container.remove()` | `DELETE /containers/{id}` |
|
|
878
|
+
| `container.logs()`, `followLogs()` | `GET /containers/{id}/logs` |
|
|
879
|
+
| `container.attach()` | `POST /containers/{id}/attach` (hijacked) |
|
|
880
|
+
| `container.exec()`, `spawn()` | `POST /containers/{id}/exec`, `POST /exec/{id}/start` (hijacked), `GET /exec/{id}/json` |
|
|
881
|
+
| `container.stat()`, `readFile()`, `getArchive()` | `HEAD`, `GET /containers/{id}/archive` |
|
|
882
|
+
| `container.writeFiles()`, `putArchive()` | `PUT /containers/{id}/archive` |
|
|
883
|
+
| `volumes.*`, `volume.*` | `POST /volumes/create`, `GET /volumes/{name}`, `GET /volumes`, `DELETE /volumes/{name}` |
|
|
884
|
+
| `networks.*`, `network.*` | `POST /networks/create`, `GET /networks/{id}`, `GET /networks`, `POST /networks/{id}/connect`, `/disconnect`, `DELETE /networks/{id}` |
|
|
885
|
+
| `images.*` | `GET /images/{name}/json`, `GET /images/json`, `POST /images/create`, `POST /images/{name}/tag`, `POST /images/{name}/push`, `GET /images/get`, `POST /images/load`, `DELETE /images/{name}` |
|
|
886
|
+
|
|
887
|
+
## What each repository changes
|
|
888
|
+
|
|
889
|
+
The package leads. Each repository below changes to fit it, with no adapter. Each change is a backlog item in that repository.
|
|
890
|
+
|
|
891
|
+
| Repository | Change | Backlog |
|
|
892
|
+
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
|
893
|
+
| `@zukhruf/testing/docker` | Rebuilt on the package. `reuse()` uses `createOrGet()` and compares its fingerprint label. Handles become owned or found. | #2752 |
|
|
894
|
+
| text2sql finders | `Docker.contexts()`, `containers.list()` and `find()`, `ConnectionFailedError` in place of the fixed sockets | #2754 |
|
|
895
|
+
| text2sql daemon tests | `@zukhruf/testing/docker` in place of the CLI by hand | #2755 |
|
|
896
|
+
| text2sql desktop tests | `images.inspect()`, `pull()`, `save()` | #2756 |
|
|
897
|
+
| text2sql artifacts volume adapter | `writeFiles()`, `readFile()` and `stat()` in place of base64 through exec | #2757 |
|
|
898
|
+
| `@deepagents/context` sandbox | `createOrGet()` for named sandboxes, the files API in place of base64 in arguments, container processes in place of two streams, status codes in place of stderr checks | #2760 |
|
|
899
|
+
| `@deepagents/toolbox`, `convert-to-gguf` | `find()` and `exec()`, `containers.run()` | #2761 |
|
|
900
|
+
| sdk-it `e2e/docker.ts` | `attach()` and its chunks, or removal | #2764 |
|
|
901
|
+
| finetuning | `create()`, `port()`, `logs()`; test cleanup through `@zukhruf/testing` | #2765 |
|
|
902
|
+
| april | `createOrGet()` and `docker.events()` for the health of the container | #2766 |
|
|
903
|
+
| serverize | Networks, `images.load()`, `pull()` and `push()` with `auth`, `writeFiles()`, `followLogs()` | #2769 |
|