@wpelevator/tug 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -4
- package/dist/agent.d.ts +66 -6
- package/dist/agent.js +851 -170
- package/dist/archive.js +9 -3
- package/dist/artifacts.js +97 -34
- package/dist/build.d.ts +1 -1
- package/dist/build.js +17 -8
- package/dist/cli-build.js +34 -8
- package/dist/cli-config.js +16 -4
- package/dist/cli-install.d.ts +10 -0
- package/dist/cli-install.js +38 -4
- package/dist/cli-live-transport.d.ts +3 -0
- package/dist/cli-live-transport.js +230 -0
- package/dist/cli-live.d.ts +77 -0
- package/dist/cli-live.js +249 -0
- package/dist/cli.js +408 -90
- package/dist/compose.d.ts +35 -9
- package/dist/compose.js +240 -132
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +5 -3
- package/dist/control.js +1047 -146
- package/dist/db.d.ts +1 -0
- package/dist/db.js +7 -1
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -0
- package/dist/ingress.d.ts +16 -8
- package/dist/ingress.js +84 -29
- package/dist/live.d.ts +83 -0
- package/dist/live.js +276 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/templates/install-docker.sh +12 -7
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +35 -9
- package/dist/util.d.ts +7 -0
- package/dist/util.js +73 -16
- package/package.json +8 -4
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -319
package/dist/db.d.ts
CHANGED
package/dist/db.js
CHANGED
|
@@ -34,7 +34,7 @@ async function migrate(db) {
|
|
|
34
34
|
await db.exec(`
|
|
35
35
|
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
36
36
|
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
|
|
37
|
-
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
|
|
38
38
|
CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
|
|
39
39
|
CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
|
|
40
40
|
CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
|
|
@@ -51,6 +51,12 @@ CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT
|
|
|
51
51
|
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
52
52
|
CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
|
|
53
53
|
`);
|
|
54
|
+
try {
|
|
55
|
+
await db.exec('ALTER TABLE nodes ADD COLUMN labels_json TEXT');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* column already exists */
|
|
59
|
+
}
|
|
54
60
|
const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
|
55
61
|
if (!row)
|
|
56
62
|
await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
3
|
+
/** Oldest Engine API the agent speaks (Docker 20.10+, which the nested daemons in the integration tests run). */
|
|
4
|
+
export declare const API_VERSION = "v1.41";
|
|
5
|
+
export declare const DEFAULT_SOCKET = "/var/run/docker.sock";
|
|
6
|
+
/** An Engine API error; `status` is the HTTP status and `message` the daemon's own text. */
|
|
7
|
+
export declare class DockerError extends Error {
|
|
8
|
+
status: number;
|
|
9
|
+
constructor(status: number, message: string);
|
|
10
|
+
}
|
|
11
|
+
export declare const isNotFound: (error: unknown) => boolean;
|
|
12
|
+
export interface ContainerSummary {
|
|
13
|
+
Id: string;
|
|
14
|
+
Names: string[];
|
|
15
|
+
Image: string;
|
|
16
|
+
ImageID?: string;
|
|
17
|
+
State: string;
|
|
18
|
+
Status?: string;
|
|
19
|
+
Created?: number;
|
|
20
|
+
Labels: Record<string, string>;
|
|
21
|
+
Ports?: {
|
|
22
|
+
IP?: string;
|
|
23
|
+
PrivatePort: number;
|
|
24
|
+
PublicPort?: number;
|
|
25
|
+
Type: string;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
export interface ContainerInspect {
|
|
29
|
+
Id: string;
|
|
30
|
+
Name: string;
|
|
31
|
+
Image?: string;
|
|
32
|
+
Config?: {
|
|
33
|
+
Image?: string;
|
|
34
|
+
Labels?: Record<string, string>;
|
|
35
|
+
Env?: string[];
|
|
36
|
+
};
|
|
37
|
+
State?: {
|
|
38
|
+
Status?: string;
|
|
39
|
+
Running?: boolean;
|
|
40
|
+
ExitCode?: number;
|
|
41
|
+
Health?: {
|
|
42
|
+
Status?: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
NetworkSettings?: {
|
|
46
|
+
Networks?: Record<string, {
|
|
47
|
+
IPAddress?: string;
|
|
48
|
+
GlobalIPv6Address?: string;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
export interface ContainerFilter {
|
|
54
|
+
/** `key` or `key=value` label filters, all of which must match. */
|
|
55
|
+
label?: string[];
|
|
56
|
+
/** Containers created from this image reference or ID. */
|
|
57
|
+
ancestor?: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ImageInspect {
|
|
61
|
+
Id: string;
|
|
62
|
+
Os?: string;
|
|
63
|
+
Architecture?: string;
|
|
64
|
+
RepoTags?: string[];
|
|
65
|
+
RepoDigests?: string[];
|
|
66
|
+
}
|
|
67
|
+
export interface NetworkInspect {
|
|
68
|
+
Name: string;
|
|
69
|
+
Id: string;
|
|
70
|
+
Labels?: Record<string, string>;
|
|
71
|
+
Containers?: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
export interface VolumeInspect {
|
|
74
|
+
Name: string;
|
|
75
|
+
Driver?: string;
|
|
76
|
+
Labels?: Record<string, string> | null;
|
|
77
|
+
Options?: Record<string, string> | null;
|
|
78
|
+
}
|
|
79
|
+
export interface DaemonInfo {
|
|
80
|
+
OSType?: string;
|
|
81
|
+
Architecture?: string;
|
|
82
|
+
ServerVersion?: string;
|
|
83
|
+
[key: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
export interface DaemonVersion {
|
|
86
|
+
Version?: string;
|
|
87
|
+
ApiVersion?: string;
|
|
88
|
+
MinAPIVersion?: string;
|
|
89
|
+
[key: string]: unknown;
|
|
90
|
+
}
|
|
91
|
+
/** `POST /containers/create` body; the agent builds these in `containerSpecs`. */
|
|
92
|
+
export interface CreateContainerBody {
|
|
93
|
+
Image: string;
|
|
94
|
+
Env?: string[];
|
|
95
|
+
Cmd?: string[] | null;
|
|
96
|
+
Entrypoint?: string[] | null;
|
|
97
|
+
User?: string;
|
|
98
|
+
WorkingDir?: string;
|
|
99
|
+
Hostname?: string;
|
|
100
|
+
Labels?: Record<string, string>;
|
|
101
|
+
ExposedPorts?: Record<string, object>;
|
|
102
|
+
StopSignal?: string;
|
|
103
|
+
StopTimeout?: number;
|
|
104
|
+
Healthcheck?: Record<string, unknown>;
|
|
105
|
+
HostConfig?: Record<string, unknown>;
|
|
106
|
+
NetworkingConfig?: {
|
|
107
|
+
EndpointsConfig: Record<string, {
|
|
108
|
+
Aliases?: string[];
|
|
109
|
+
[key: string]: unknown;
|
|
110
|
+
}>;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** A process attached to a container: exec or logs. Shaped like a child process so `live.ts` treats both alike. */
|
|
114
|
+
export interface AttachedProcess extends EventEmitter {
|
|
115
|
+
stdin: Writable | null;
|
|
116
|
+
stdout: Readable;
|
|
117
|
+
stderr: Readable;
|
|
118
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
119
|
+
resize?(columns: number, rows: number): void;
|
|
120
|
+
}
|
|
121
|
+
export interface ExecOptions {
|
|
122
|
+
cmd: string[];
|
|
123
|
+
stdin?: boolean;
|
|
124
|
+
tty?: boolean;
|
|
125
|
+
columns?: number;
|
|
126
|
+
rows?: number;
|
|
127
|
+
}
|
|
128
|
+
export interface LogsOptions {
|
|
129
|
+
follow?: boolean;
|
|
130
|
+
tail?: number;
|
|
131
|
+
timestamps?: boolean;
|
|
132
|
+
}
|
|
133
|
+
export interface DockerApi {
|
|
134
|
+
version(): Promise<DaemonVersion>;
|
|
135
|
+
info(): Promise<DaemonInfo>;
|
|
136
|
+
listContainers(filter?: ContainerFilter, all?: boolean): Promise<ContainerSummary[]>;
|
|
137
|
+
inspectContainer(id: string): Promise<ContainerInspect>;
|
|
138
|
+
createContainer(name: string, body: CreateContainerBody): Promise<string>;
|
|
139
|
+
startContainer(id: string): Promise<void>;
|
|
140
|
+
stopContainer(id: string, timeoutSeconds?: number): Promise<void>;
|
|
141
|
+
restartContainer(id: string): Promise<void>;
|
|
142
|
+
/** Removes a container even when running; a missing container is not an error. */
|
|
143
|
+
removeContainer(id: string): Promise<void>;
|
|
144
|
+
/** Connects a running container to a network, optionally under service aliases. */
|
|
145
|
+
connectNetwork(network: string, container: string, aliases?: string[]): Promise<void>;
|
|
146
|
+
/** Container output as demultiplexed, ended streams (a TTY container's output is all stdout). */
|
|
147
|
+
logs(id: string, options?: LogsOptions): Promise<AttachedProcess>;
|
|
148
|
+
/** Copies the tar stream into the container directory `dir`. */
|
|
149
|
+
putArchive(id: string, dir: string, tar: Readable | Buffer): Promise<void>;
|
|
150
|
+
/** Runs a command to completion and returns its output; a non-zero exit is reported in `code`. */
|
|
151
|
+
execRun(id: string, cmd: string[]): Promise<{
|
|
152
|
+
code: number;
|
|
153
|
+
out: string;
|
|
154
|
+
err: string;
|
|
155
|
+
}>;
|
|
156
|
+
/** Starts an interactive or streaming exec; `close` carries the exit code. */
|
|
157
|
+
execStart(id: string, options: ExecOptions): Promise<AttachedProcess>;
|
|
158
|
+
inspectNetwork(name: string): Promise<NetworkInspect | null>;
|
|
159
|
+
createNetwork(name: string, labels: Record<string, string>): Promise<void>;
|
|
160
|
+
inspectVolume(name: string): Promise<VolumeInspect | null>;
|
|
161
|
+
createVolume(name: string, options: {
|
|
162
|
+
driver?: string;
|
|
163
|
+
opts?: Record<string, string>;
|
|
164
|
+
labels?: Record<string, string>;
|
|
165
|
+
}): Promise<void>;
|
|
166
|
+
removeVolume(name: string): Promise<void>;
|
|
167
|
+
inspectImage(ref: string): Promise<ImageInspect | null>;
|
|
168
|
+
/** Pulls `ref`; `auth` becomes `X-Registry-Auth`. */
|
|
169
|
+
pullImage(ref: string, auth?: Record<string, string>): Promise<void>;
|
|
170
|
+
loadImage(archive: Readable | Buffer): Promise<void>;
|
|
171
|
+
removeImage(ref: string): Promise<void>;
|
|
172
|
+
}
|
|
173
|
+
/** Splits a reference into `fromImage` and `tag` for `POST /images/create`. */
|
|
174
|
+
export declare function splitImageRef(ref: string): {
|
|
175
|
+
fromImage: string;
|
|
176
|
+
tag: string;
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Splits the 8-byte-header multiplexed stream (`[stream, 0, 0, 0, size(4, big endian)]` frames) that
|
|
180
|
+
* non-TTY attach, exec and logs use into `stdout` and `stderr`. `end` fires once the source ends.
|
|
181
|
+
*/
|
|
182
|
+
export declare function demux(source: Readable, stdout: Writable, stderr: Writable): void;
|
|
183
|
+
/** `unix:///path` or a bare path from `DOCKER_HOST`; the agent talks to the local daemon only. */
|
|
184
|
+
export declare function socketFromEnv(env?: NodeJS.ProcessEnv): string;
|
|
185
|
+
export declare class DockerEngine implements DockerApi {
|
|
186
|
+
private readonly socketPath;
|
|
187
|
+
constructor(socketPath?: string);
|
|
188
|
+
private request;
|
|
189
|
+
/** Reads the response; non-2xx becomes a `DockerError` carrying the daemon's message. */
|
|
190
|
+
private call;
|
|
191
|
+
private json;
|
|
192
|
+
/** Consumes the JSON-lines progress stream of pull and load; a line with `error` fails the call. */
|
|
193
|
+
private progress;
|
|
194
|
+
version: () => Promise<DaemonVersion>;
|
|
195
|
+
info: () => Promise<DaemonInfo>;
|
|
196
|
+
listContainers(filter?: ContainerFilter, all?: boolean): Promise<ContainerSummary[]>;
|
|
197
|
+
inspectContainer: (id: string) => Promise<ContainerInspect>;
|
|
198
|
+
createContainer(name: string, body: CreateContainerBody): Promise<string>;
|
|
199
|
+
startContainer(id: string): Promise<void>;
|
|
200
|
+
stopContainer(id: string, timeoutSeconds?: number): Promise<void>;
|
|
201
|
+
restartContainer(id: string): Promise<void>;
|
|
202
|
+
removeContainer(id: string): Promise<void>;
|
|
203
|
+
connectNetwork(network: string, container: string, aliases?: string[]): Promise<void>;
|
|
204
|
+
logs(id: string, options?: LogsOptions): Promise<AttachedProcess>;
|
|
205
|
+
private isTty;
|
|
206
|
+
/**
|
|
207
|
+
* Wraps a response or hijacked socket as a process whose output is demultiplexed unless a TTY.
|
|
208
|
+
* `close` fires with `exitCode()` once the source and both output streams have ended.
|
|
209
|
+
*/
|
|
210
|
+
private attachedFromStream;
|
|
211
|
+
putArchive(id: string, dir: string, tar: Readable | Buffer): Promise<void>;
|
|
212
|
+
execRun(id: string, cmd: string[]): Promise<{
|
|
213
|
+
code: number;
|
|
214
|
+
out: string;
|
|
215
|
+
err: string;
|
|
216
|
+
}>;
|
|
217
|
+
execStart(id: string, options: ExecOptions): Promise<AttachedProcess>;
|
|
218
|
+
private resizeExec;
|
|
219
|
+
inspectNetwork(name: string): Promise<NetworkInspect | null>;
|
|
220
|
+
createNetwork(name: string, labels: Record<string, string>): Promise<void>;
|
|
221
|
+
inspectVolume(name: string): Promise<VolumeInspect | null>;
|
|
222
|
+
createVolume(name: string, options: {
|
|
223
|
+
driver?: string;
|
|
224
|
+
opts?: Record<string, string>;
|
|
225
|
+
labels?: Record<string, string>;
|
|
226
|
+
}): Promise<void>;
|
|
227
|
+
removeVolume(name: string): Promise<void>;
|
|
228
|
+
inspectImage(ref: string): Promise<ImageInspect | null>;
|
|
229
|
+
pullImage(ref: string, auth?: Record<string, string>): Promise<void>;
|
|
230
|
+
loadImage(archive: Readable | Buffer): Promise<void>;
|
|
231
|
+
removeImage(ref: string): Promise<void>;
|
|
232
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Typed client for the Docker Engine API over the daemon's unix socket. The agent reconciles
|
|
2
|
+
// containers through this seam instead of running the `docker` CLI; tests inject a fake `DockerApi`.
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { PassThrough } from 'node:stream';
|
|
6
|
+
/** Oldest Engine API the agent speaks (Docker 20.10+, which the nested daemons in the integration tests run). */
|
|
7
|
+
export const API_VERSION = 'v1.41';
|
|
8
|
+
export const DEFAULT_SOCKET = '/var/run/docker.sock';
|
|
9
|
+
/** An Engine API error; `status` is the HTTP status and `message` the daemon's own text. */
|
|
10
|
+
export class DockerError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(status, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'DockerError';
|
|
15
|
+
this.status = status;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const isNotFound = (error) => error instanceof DockerError && error.status === 404;
|
|
19
|
+
/** Splits a reference into `fromImage` and `tag` for `POST /images/create`. */
|
|
20
|
+
export function splitImageRef(ref) {
|
|
21
|
+
const digest = ref.indexOf('@');
|
|
22
|
+
if (digest >= 0)
|
|
23
|
+
return { fromImage: ref.slice(0, digest), tag: ref.slice(digest + 1) };
|
|
24
|
+
const colon = ref.lastIndexOf(':');
|
|
25
|
+
if (colon > ref.lastIndexOf('/'))
|
|
26
|
+
return { fromImage: ref.slice(0, colon), tag: ref.slice(colon + 1) };
|
|
27
|
+
return { fromImage: ref, tag: 'latest' };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Splits the 8-byte-header multiplexed stream (`[stream, 0, 0, 0, size(4, big endian)]` frames) that
|
|
31
|
+
* non-TTY attach, exec and logs use into `stdout` and `stderr`. `end` fires once the source ends.
|
|
32
|
+
*/
|
|
33
|
+
export function demux(source, stdout, stderr) {
|
|
34
|
+
let buffer = Buffer.alloc(0);
|
|
35
|
+
source.on('data', (chunk) => {
|
|
36
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
37
|
+
while (buffer.length >= 8) {
|
|
38
|
+
const size = buffer.readUInt32BE(4);
|
|
39
|
+
if (buffer.length < 8 + size)
|
|
40
|
+
break;
|
|
41
|
+
const frame = buffer.subarray(8, 8 + size);
|
|
42
|
+
(buffer[0] === 2 ? stderr : stdout).write(frame);
|
|
43
|
+
buffer = buffer.subarray(8 + size);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const end = () => {
|
|
47
|
+
stdout.end();
|
|
48
|
+
stderr.end();
|
|
49
|
+
};
|
|
50
|
+
source.on('end', end);
|
|
51
|
+
source.on('close', end);
|
|
52
|
+
source.on('error', (error) => {
|
|
53
|
+
stdout.destroy(error);
|
|
54
|
+
stderr.destroy();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function filtersParam(filter) {
|
|
58
|
+
const filters = {};
|
|
59
|
+
if (filter.label?.length)
|
|
60
|
+
filters.label = filter.label;
|
|
61
|
+
if (filter.ancestor)
|
|
62
|
+
filters.ancestor = [filter.ancestor];
|
|
63
|
+
if (filter.name)
|
|
64
|
+
filters.name = [filter.name];
|
|
65
|
+
return JSON.stringify(filters);
|
|
66
|
+
}
|
|
67
|
+
async function readBody(res) {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
for await (const chunk of res)
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
72
|
+
}
|
|
73
|
+
/** `unix:///path` or a bare path from `DOCKER_HOST`; the agent talks to the local daemon only. */
|
|
74
|
+
export function socketFromEnv(env = process.env) {
|
|
75
|
+
const host = env.DOCKER_HOST;
|
|
76
|
+
if (!host)
|
|
77
|
+
return DEFAULT_SOCKET;
|
|
78
|
+
if (host.startsWith('unix://'))
|
|
79
|
+
return host.slice('unix://'.length);
|
|
80
|
+
throw new Error(`unsupported DOCKER_HOST ${host} (the agent uses the local unix socket)`);
|
|
81
|
+
}
|
|
82
|
+
export class DockerEngine {
|
|
83
|
+
socketPath;
|
|
84
|
+
constructor(socketPath = socketFromEnv()) {
|
|
85
|
+
this.socketPath = socketPath;
|
|
86
|
+
}
|
|
87
|
+
request(method, path, options = {}) {
|
|
88
|
+
const query = new URLSearchParams();
|
|
89
|
+
for (const [key, value] of Object.entries(options.query || {}))
|
|
90
|
+
if (value !== undefined)
|
|
91
|
+
query.set(key, value);
|
|
92
|
+
const qs = query.size ? `?${query}` : '';
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const headers = { ...options.headers };
|
|
95
|
+
let payload;
|
|
96
|
+
if (options.body !== undefined) {
|
|
97
|
+
payload = Buffer.from(JSON.stringify(options.body));
|
|
98
|
+
headers['content-type'] = 'application/json';
|
|
99
|
+
headers['content-length'] = String(payload.length);
|
|
100
|
+
}
|
|
101
|
+
const req = http.request({ socketPath: this.socketPath, method, path: `/${API_VERSION}${path}${qs}`, headers }, resolve);
|
|
102
|
+
req.on('error', reject);
|
|
103
|
+
if (payload)
|
|
104
|
+
req.end(payload);
|
|
105
|
+
else if (Buffer.isBuffer(options.stream))
|
|
106
|
+
req.end(options.stream);
|
|
107
|
+
else if (options.stream) {
|
|
108
|
+
options.stream.on('error', (error) => req.destroy(error));
|
|
109
|
+
options.stream.pipe(req);
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
req.end();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Reads the response; non-2xx becomes a `DockerError` carrying the daemon's message. */
|
|
116
|
+
async call(method, path, options) {
|
|
117
|
+
const res = await this.request(method, path, options);
|
|
118
|
+
const text = await readBody(res);
|
|
119
|
+
const status = res.statusCode || 0;
|
|
120
|
+
if (status >= 200 && status < 300)
|
|
121
|
+
return text;
|
|
122
|
+
let message = text.trim();
|
|
123
|
+
try {
|
|
124
|
+
message = JSON.parse(text).message || message;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* not JSON */
|
|
128
|
+
}
|
|
129
|
+
throw new DockerError(status, message || `Docker API ${method} ${path}: HTTP ${status}`);
|
|
130
|
+
}
|
|
131
|
+
async json(method, path, options) {
|
|
132
|
+
const text = await this.call(method, path, options);
|
|
133
|
+
return (text ? JSON.parse(text) : undefined);
|
|
134
|
+
}
|
|
135
|
+
/** Consumes the JSON-lines progress stream of pull and load; a line with `error` fails the call. */
|
|
136
|
+
async progress(res, what) {
|
|
137
|
+
const status = res.statusCode || 0;
|
|
138
|
+
const text = await readBody(res);
|
|
139
|
+
if (status < 200 || status >= 300) {
|
|
140
|
+
let message = text.trim();
|
|
141
|
+
try {
|
|
142
|
+
message = JSON.parse(text).message || message;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* not JSON */
|
|
146
|
+
}
|
|
147
|
+
throw new DockerError(status, message || `${what}: HTTP ${status}`);
|
|
148
|
+
}
|
|
149
|
+
for (const line of text.split('\n')) {
|
|
150
|
+
if (!line.trim())
|
|
151
|
+
continue;
|
|
152
|
+
let event;
|
|
153
|
+
try {
|
|
154
|
+
event = JSON.parse(line);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (event.error)
|
|
160
|
+
throw new Error(`${what}: ${event.error}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
version = () => this.json('GET', '/version');
|
|
164
|
+
info = () => this.json('GET', '/info');
|
|
165
|
+
listContainers(filter = {}, all = true) {
|
|
166
|
+
return this.json('GET', '/containers/json', {
|
|
167
|
+
query: { all: all ? '1' : '0', filters: filtersParam(filter) },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
inspectContainer = (id) => this.json('GET', `/containers/${encodeURIComponent(id)}/json`);
|
|
171
|
+
async createContainer(name, body) {
|
|
172
|
+
const created = await this.json('POST', '/containers/create', {
|
|
173
|
+
query: { name },
|
|
174
|
+
body,
|
|
175
|
+
});
|
|
176
|
+
return created.Id;
|
|
177
|
+
}
|
|
178
|
+
async startContainer(id) {
|
|
179
|
+
try {
|
|
180
|
+
await this.call('POST', `/containers/${encodeURIComponent(id)}/start`);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof DockerError && error.status === 304)
|
|
184
|
+
return;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async stopContainer(id, timeoutSeconds) {
|
|
189
|
+
try {
|
|
190
|
+
await this.call('POST', `/containers/${encodeURIComponent(id)}/stop`, {
|
|
191
|
+
query: { t: timeoutSeconds === undefined ? undefined : String(timeoutSeconds) },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof DockerError && error.status === 304)
|
|
196
|
+
return;
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async restartContainer(id) {
|
|
201
|
+
await this.call('POST', `/containers/${encodeURIComponent(id)}/restart`);
|
|
202
|
+
}
|
|
203
|
+
async removeContainer(id) {
|
|
204
|
+
try {
|
|
205
|
+
await this.call('DELETE', `/containers/${encodeURIComponent(id)}`, {
|
|
206
|
+
query: { force: '1' },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (!isNotFound(error))
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async connectNetwork(network, container, aliases = []) {
|
|
215
|
+
await this.call('POST', `/networks/${encodeURIComponent(network)}/connect`, {
|
|
216
|
+
body: { Container: container, EndpointConfig: { Aliases: aliases } },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async logs(id, options = {}) {
|
|
220
|
+
const tty = await this.isTty(id);
|
|
221
|
+
const res = await this.request('GET', `/containers/${encodeURIComponent(id)}/logs`, {
|
|
222
|
+
query: {
|
|
223
|
+
stdout: '1',
|
|
224
|
+
stderr: '1',
|
|
225
|
+
follow: options.follow === false ? '0' : '1',
|
|
226
|
+
tail: String(options.tail ?? 100),
|
|
227
|
+
timestamps: options.timestamps ? '1' : '0',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
if ((res.statusCode || 0) >= 300) {
|
|
231
|
+
const text = await readBody(res);
|
|
232
|
+
throw new DockerError(res.statusCode || 0, text.trim() || 'cannot read container logs');
|
|
233
|
+
}
|
|
234
|
+
return this.attachedFromStream(res, tty, () => res.destroy(), null, async () => 0);
|
|
235
|
+
}
|
|
236
|
+
async isTty(id) {
|
|
237
|
+
const inspected = (await this.inspectContainer(id));
|
|
238
|
+
return Boolean(inspected.Config?.Tty);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Wraps a response or hijacked socket as a process whose output is demultiplexed unless a TTY.
|
|
242
|
+
* `close` fires with `exitCode()` once the source and both output streams have ended.
|
|
243
|
+
*/
|
|
244
|
+
attachedFromStream(source, tty, destroy, stdin, exitCode) {
|
|
245
|
+
const stdout = new PassThrough();
|
|
246
|
+
const stderr = new PassThrough();
|
|
247
|
+
const proc = Object.assign(new EventEmitter(), {
|
|
248
|
+
stdin,
|
|
249
|
+
stdout,
|
|
250
|
+
stderr,
|
|
251
|
+
kill: () => {
|
|
252
|
+
destroy();
|
|
253
|
+
return true;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
if (tty) {
|
|
257
|
+
source.pipe(stdout);
|
|
258
|
+
source.on('end', () => stderr.end());
|
|
259
|
+
source.on('close', () => stderr.end());
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
demux(source, stdout, stderr);
|
|
263
|
+
source.on('error', (error) => proc.emit('error', error));
|
|
264
|
+
const ended = (stream) => new Promise((resolve) => {
|
|
265
|
+
stream.once('end', resolve);
|
|
266
|
+
stream.once('close', resolve);
|
|
267
|
+
stream.once('error', () => resolve());
|
|
268
|
+
});
|
|
269
|
+
void Promise.all([ended(source), ended(stdout), ended(stderr)]).then(async () => proc.emit('close', await exitCode().catch(() => null), null));
|
|
270
|
+
return proc;
|
|
271
|
+
}
|
|
272
|
+
async putArchive(id, dir, tar) {
|
|
273
|
+
await this.call('PUT', `/containers/${encodeURIComponent(id)}/archive`, {
|
|
274
|
+
query: { path: dir },
|
|
275
|
+
stream: tar,
|
|
276
|
+
headers: { 'content-type': 'application/x-tar' },
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async execRun(id, cmd) {
|
|
280
|
+
const proc = await this.execStart(id, { cmd });
|
|
281
|
+
const out = [];
|
|
282
|
+
const err = [];
|
|
283
|
+
proc.stdout.on('data', (d) => out.push(d));
|
|
284
|
+
proc.stderr.on('data', (d) => err.push(d));
|
|
285
|
+
const code = await new Promise((resolve, reject) => {
|
|
286
|
+
proc.on('error', reject);
|
|
287
|
+
proc.on('close', (c) => resolve(c ?? -1));
|
|
288
|
+
});
|
|
289
|
+
return {
|
|
290
|
+
code,
|
|
291
|
+
out: Buffer.concat(out).toString('utf8'),
|
|
292
|
+
err: Buffer.concat(err).toString('utf8'),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
async execStart(id, options) {
|
|
296
|
+
const tty = Boolean(options.tty);
|
|
297
|
+
const created = await this.json('POST', `/containers/${encodeURIComponent(id)}/exec`, {
|
|
298
|
+
body: {
|
|
299
|
+
AttachStdin: options.stdin === true,
|
|
300
|
+
AttachStdout: true,
|
|
301
|
+
AttachStderr: true,
|
|
302
|
+
Tty: tty,
|
|
303
|
+
Cmd: options.cmd,
|
|
304
|
+
...(tty && options.rows && options.columns
|
|
305
|
+
? { ConsoleSize: [options.rows, options.columns] }
|
|
306
|
+
: {}),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const execId = created.Id;
|
|
310
|
+
const payload = Buffer.from(JSON.stringify({ Detach: false, Tty: tty }));
|
|
311
|
+
// Exec start hijacks the connection: the daemon answers `101 UPGRADE` and the socket becomes the stream.
|
|
312
|
+
const socket = await new Promise((resolve, reject) => {
|
|
313
|
+
const req = http.request({
|
|
314
|
+
socketPath: this.socketPath,
|
|
315
|
+
method: 'POST',
|
|
316
|
+
path: `/${API_VERSION}/exec/${execId}/start`,
|
|
317
|
+
headers: {
|
|
318
|
+
'content-type': 'application/json',
|
|
319
|
+
'content-length': String(payload.length),
|
|
320
|
+
connection: 'Upgrade',
|
|
321
|
+
upgrade: 'tcp',
|
|
322
|
+
},
|
|
323
|
+
}, async (res) => reject(new DockerError(res.statusCode || 0, (await readBody(res)).trim())));
|
|
324
|
+
req.on('upgrade', (_res, sock, head) => {
|
|
325
|
+
if (head.length)
|
|
326
|
+
sock.unshift(head);
|
|
327
|
+
resolve(sock);
|
|
328
|
+
});
|
|
329
|
+
req.on('error', reject);
|
|
330
|
+
req.end(payload);
|
|
331
|
+
});
|
|
332
|
+
const proc = this.attachedFromStream(socket, tty, () => socket.destroy(), options.stdin ? socket : null, async () => (await this.json('GET', `/exec/${execId}/json`)).ExitCode ??
|
|
333
|
+
0);
|
|
334
|
+
if (tty) {
|
|
335
|
+
proc.resize = (columns, rows) => void this.resizeExec(execId, columns, rows);
|
|
336
|
+
// ConsoleSize at create needs API 1.42; resizing once started works on every supported daemon.
|
|
337
|
+
if (options.columns && options.rows)
|
|
338
|
+
proc.resize(options.columns, options.rows);
|
|
339
|
+
}
|
|
340
|
+
return proc;
|
|
341
|
+
}
|
|
342
|
+
async resizeExec(execId, columns, rows) {
|
|
343
|
+
await this.call('POST', `/exec/${execId}/resize`, {
|
|
344
|
+
query: { h: String(rows), w: String(columns) },
|
|
345
|
+
}).catch(() => { });
|
|
346
|
+
}
|
|
347
|
+
async inspectNetwork(name) {
|
|
348
|
+
try {
|
|
349
|
+
return await this.json('GET', `/networks/${encodeURIComponent(name)}`);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
if (isNotFound(error))
|
|
353
|
+
return null;
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async createNetwork(name, labels) {
|
|
358
|
+
await this.call('POST', '/networks/create', {
|
|
359
|
+
body: { Name: name, Driver: 'bridge', CheckDuplicate: true, Labels: labels },
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async inspectVolume(name) {
|
|
363
|
+
try {
|
|
364
|
+
return await this.json('GET', `/volumes/${encodeURIComponent(name)}`);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (isNotFound(error))
|
|
368
|
+
return null;
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async createVolume(name, options) {
|
|
373
|
+
await this.call('POST', '/volumes/create', {
|
|
374
|
+
body: {
|
|
375
|
+
Name: name,
|
|
376
|
+
Driver: options.driver || 'local',
|
|
377
|
+
DriverOpts: options.opts || {},
|
|
378
|
+
Labels: options.labels || {},
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
async removeVolume(name) {
|
|
383
|
+
await this.call('DELETE', `/volumes/${encodeURIComponent(name)}`);
|
|
384
|
+
}
|
|
385
|
+
async inspectImage(ref) {
|
|
386
|
+
try {
|
|
387
|
+
return await this.json('GET', `/images/${encodeURI(ref)}/json`);
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (isNotFound(error))
|
|
391
|
+
return null;
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async pullImage(ref, auth) {
|
|
396
|
+
const { fromImage, tag } = splitImageRef(ref);
|
|
397
|
+
const res = await this.request('POST', '/images/create', {
|
|
398
|
+
query: { fromImage, tag },
|
|
399
|
+
headers: auth
|
|
400
|
+
? { 'x-registry-auth': Buffer.from(JSON.stringify(auth)).toString('base64url') }
|
|
401
|
+
: {},
|
|
402
|
+
});
|
|
403
|
+
await this.progress(res, `pull ${ref}`);
|
|
404
|
+
}
|
|
405
|
+
async loadImage(archive) {
|
|
406
|
+
const res = await this.request('POST', '/images/load', {
|
|
407
|
+
query: { quiet: '1' },
|
|
408
|
+
stream: archive,
|
|
409
|
+
headers: { 'content-type': 'application/x-tar' },
|
|
410
|
+
});
|
|
411
|
+
await this.progress(res, 'load image');
|
|
412
|
+
}
|
|
413
|
+
async removeImage(ref) {
|
|
414
|
+
await this.call('DELETE', `/images/${encodeURI(ref)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|