@wpelevator/tug 0.3.0 → 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/dist/agent.d.ts +9 -10
- package/dist/agent.js +226 -274
- package/dist/cli.js +24 -8
- package/dist/compose.d.ts +6 -19
- package/dist/compose.js +118 -146
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.js +9 -0
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -0
- package/dist/ingress.d.ts +6 -6
- package/dist/ingress.js +56 -42
- package/dist/live.d.ts +10 -7
- package/dist/live.js +130 -194
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- 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/templates/install-docker.sh +12 -7
- package/package.json +1 -1
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -385
|
@@ -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
|
+
}
|
package/dist/ingress.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DesiredState, IngressRoute, IngressStatus, IngressWork, ProjectStatus, ServiceEndpoints } from './protocol.ts';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DockerApi } from './docker-api.ts';
|
|
3
3
|
export declare const CADDY_CONTAINER = "tug-caddy";
|
|
4
4
|
export declare const DEFAULT_CADDY_IMAGE = "caddy:2-alpine";
|
|
5
5
|
export interface NodeView {
|
|
@@ -43,15 +43,15 @@ export declare function renderCaddyfile(routes: IngressRoute[], opts?: {
|
|
|
43
43
|
}): string;
|
|
44
44
|
export declare function ingressConfigHash(work: Pick<IngressWork, 'caddyfile' | 'files'>): string;
|
|
45
45
|
export interface IngressContext {
|
|
46
|
-
docker:
|
|
46
|
+
docker: DockerApi;
|
|
47
47
|
stateDir: string;
|
|
48
48
|
caddyImage?: string;
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* Makes the Caddy container match the control-rendered config. Config is
|
|
52
|
-
* container
|
|
53
|
-
* applied with `caddy reload`, which swaps config gracefully without dropping
|
|
54
|
-
* Caddy rejects the new config the previous one is restored and the error reported.
|
|
51
|
+
* Makes the Caddy container match the control-rendered config. Config is uploaded into the
|
|
52
|
+
* container with the archive endpoint (so it works whether the agent runs on the host or in a
|
|
53
|
+
* container) and applied with `caddy reload`, which swaps config gracefully without dropping
|
|
54
|
+
* connections. If Caddy rejects the new config the previous one is restored and the error reported.
|
|
55
55
|
*/
|
|
56
56
|
export declare function reconcileIngress(ctx: IngressContext, work: IngressWork): Promise<IngressStatus>;
|
|
57
57
|
/** Removes the Caddy container when a node stops being an ingress node; ACME data volumes are kept. */
|
package/dist/ingress.js
CHANGED
|
@@ -6,6 +6,8 @@ import fs from 'node:fs/promises';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { loopbackIngressServices, publishedPort } from "./compose.js";
|
|
8
8
|
import { ensureDir, errMessage } from "./util.js";
|
|
9
|
+
import * as tar from 'tar';
|
|
10
|
+
import { isNotFound } from "./docker-api.js";
|
|
9
11
|
export const CADDY_CONTAINER = 'tug-caddy';
|
|
10
12
|
export const DEFAULT_CADDY_IMAGE = 'caddy:2-alpine';
|
|
11
13
|
const CADDY_ETC = '/etc/caddy';
|
|
@@ -133,11 +135,35 @@ async function writeStage(dir, work) {
|
|
|
133
135
|
await fs.writeFile(file, body, { mode: name.endsWith('.key') ? 0o600 : 0o644 });
|
|
134
136
|
}
|
|
135
137
|
}
|
|
138
|
+
async function tarOf(dir) {
|
|
139
|
+
const chunks = [];
|
|
140
|
+
for await (const chunk of tar.c({ cwd: dir, portable: true }, ['.']))
|
|
141
|
+
chunks.push(chunk);
|
|
142
|
+
return Buffer.concat(chunks);
|
|
143
|
+
}
|
|
144
|
+
async function ensureImage(ctx) {
|
|
145
|
+
const image = ctx.caddyImage || DEFAULT_CADDY_IMAGE;
|
|
146
|
+
if (!(await ctx.docker.inspectImage(image)))
|
|
147
|
+
await ctx.docker.pullImage(image);
|
|
148
|
+
return image;
|
|
149
|
+
}
|
|
150
|
+
async function reload(ctx) {
|
|
151
|
+
const r = await ctx.docker.execRun(CADDY_CONTAINER, [
|
|
152
|
+
'caddy',
|
|
153
|
+
'reload',
|
|
154
|
+
'--config',
|
|
155
|
+
`${CADDY_ETC}/Caddyfile`,
|
|
156
|
+
'--adapter',
|
|
157
|
+
'caddyfile',
|
|
158
|
+
]);
|
|
159
|
+
if (r.code !== 0)
|
|
160
|
+
throw new Error(`caddy reload failed (${r.code}): ${(r.err || r.out).trim()}`);
|
|
161
|
+
}
|
|
136
162
|
/**
|
|
137
|
-
* Makes the Caddy container match the control-rendered config. Config is
|
|
138
|
-
* container
|
|
139
|
-
* applied with `caddy reload`, which swaps config gracefully without dropping
|
|
140
|
-
* Caddy rejects the new config the previous one is restored and the error reported.
|
|
163
|
+
* Makes the Caddy container match the control-rendered config. Config is uploaded into the
|
|
164
|
+
* container with the archive endpoint (so it works whether the agent runs on the host or in a
|
|
165
|
+
* container) and applied with `caddy reload`, which swaps config gracefully without dropping
|
|
166
|
+
* connections. If Caddy rejects the new config the previous one is restored and the error reported.
|
|
141
167
|
*/
|
|
142
168
|
export async function reconcileIngress(ctx, work) {
|
|
143
169
|
const root = path.join(ctx.stateDir, 'ingress');
|
|
@@ -145,57 +171,45 @@ export async function reconcileIngress(ctx, work) {
|
|
|
145
171
|
const applied = path.join(root, 'applied');
|
|
146
172
|
const configHash = ingressConfigHash(work);
|
|
147
173
|
const routes = work.routes.length;
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
const existing = await ctx.docker.inspectContainer(CADDY_CONTAINER).catch((e) => {
|
|
175
|
+
if (isNotFound(e))
|
|
176
|
+
return null;
|
|
177
|
+
throw e;
|
|
178
|
+
});
|
|
179
|
+
const running = existing ? Boolean(existing.State?.Running) : null;
|
|
151
180
|
const appliedHash = await fs.readFile(path.join(root, 'applied.hash'), 'utf8').then((s) => s.trim(), () => '');
|
|
152
181
|
if (running && appliedHash === configHash)
|
|
153
182
|
return { state: 'serving', configHash, routes };
|
|
154
183
|
await writeStage(stage, work);
|
|
155
184
|
try {
|
|
156
185
|
if (running === null) {
|
|
157
|
-
await ctx
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
'-v',
|
|
170
|
-
`tug-caddy-etc:${CADDY_ETC}`,
|
|
171
|
-
ctx.caddyImage || DEFAULT_CADDY_IMAGE,
|
|
172
|
-
]);
|
|
173
|
-
await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
|
|
174
|
-
await ctx.docker(['start', CADDY_CONTAINER]);
|
|
186
|
+
const image = await ensureImage(ctx);
|
|
187
|
+
await ctx.docker.createContainer(CADDY_CONTAINER, {
|
|
188
|
+
Image: image,
|
|
189
|
+
Labels: { 'tug.managed': 'ingress' },
|
|
190
|
+
HostConfig: {
|
|
191
|
+
NetworkMode: 'host',
|
|
192
|
+
RestartPolicy: { Name: 'unless-stopped' },
|
|
193
|
+
Binds: ['tug-caddy-data:/data', 'tug-caddy-config:/config', `tug-caddy-etc:${CADDY_ETC}`],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
await ctx.docker.putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(stage));
|
|
197
|
+
await ctx.docker.startContainer(CADDY_CONTAINER);
|
|
175
198
|
}
|
|
176
199
|
else if (running === false) {
|
|
177
|
-
await ctx.docker(
|
|
178
|
-
await ctx.docker(
|
|
200
|
+
await ctx.docker.putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(stage));
|
|
201
|
+
await ctx.docker.startContainer(CADDY_CONTAINER);
|
|
179
202
|
}
|
|
180
203
|
else {
|
|
181
204
|
try {
|
|
182
|
-
await ctx.docker(
|
|
183
|
-
await ctx.docker(
|
|
184
|
-
await ctx
|
|
185
|
-
'exec',
|
|
186
|
-
CADDY_CONTAINER,
|
|
187
|
-
'caddy',
|
|
188
|
-
'reload',
|
|
189
|
-
'--config',
|
|
190
|
-
`${CADDY_ETC}/Caddyfile`,
|
|
191
|
-
'--adapter',
|
|
192
|
-
'caddyfile',
|
|
193
|
-
]);
|
|
205
|
+
await ctx.docker.execRun(CADDY_CONTAINER, ['rm', '-rf', `${CADDY_ETC}/certs`]);
|
|
206
|
+
await ctx.docker.putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(stage));
|
|
207
|
+
await reload(ctx);
|
|
194
208
|
}
|
|
195
209
|
catch (e) {
|
|
196
210
|
if (await fs.stat(applied).then(() => true, () => false))
|
|
197
|
-
await ctx
|
|
198
|
-
.
|
|
211
|
+
await ctx.docker
|
|
212
|
+
.putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(applied))
|
|
199
213
|
.catch(() => { });
|
|
200
214
|
throw e;
|
|
201
215
|
}
|
|
@@ -215,7 +229,7 @@ export async function removeIngress(ctx) {
|
|
|
215
229
|
const root = path.join(ctx.stateDir, 'ingress');
|
|
216
230
|
if (!(await fs.stat(root).then(() => true, () => false)))
|
|
217
231
|
return false;
|
|
218
|
-
await ctx.docker(
|
|
232
|
+
await ctx.docker.removeContainer(CADDY_CONTAINER).catch(() => { });
|
|
219
233
|
await fs.rm(root, { recursive: true, force: true });
|
|
220
234
|
return true;
|
|
221
235
|
}
|
package/dist/live.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { DockerApi } from './docker-api.ts';
|
|
2
3
|
import type { Readable, Writable } from 'node:stream';
|
|
3
4
|
export interface LiveProcess extends EventEmitter {
|
|
4
5
|
stdin: Writable | null;
|
|
@@ -8,7 +9,6 @@ export interface LiveProcess extends EventEmitter {
|
|
|
8
9
|
/** PTY adapters implement this in terminal columns and rows. */
|
|
9
10
|
resize?(columns: number, rows: number): void;
|
|
10
11
|
}
|
|
11
|
-
export type LiveSpawn = (file: string, args: string[]) => LiveProcess;
|
|
12
12
|
export type LiveRequest = {
|
|
13
13
|
operation: 'exec';
|
|
14
14
|
sessionId: string;
|
|
@@ -53,21 +53,24 @@ export type LiveEvent = {
|
|
|
53
53
|
};
|
|
54
54
|
export interface LiveEngineOptions {
|
|
55
55
|
emit: (event: LiveEvent) => void;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
spawnTty?: LiveSpawn;
|
|
56
|
+
/** The Engine API: exec (with a real PTY), logs, `ps` and `inspect` all go through it. */
|
|
57
|
+
docker: DockerApi;
|
|
59
58
|
maxSessions?: number;
|
|
60
|
-
maxResultBytes?: number;
|
|
61
59
|
maxSessionMs?: number;
|
|
62
60
|
queryTimeoutMs?: number;
|
|
63
61
|
}
|
|
64
|
-
/** Owns
|
|
62
|
+
/** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
65
63
|
export declare class LiveOperationEngine {
|
|
66
64
|
private readonly options;
|
|
67
65
|
private readonly sessions;
|
|
66
|
+
/** Sessions whose container stream is still being opened; a cancel marks them so they close on arrival. */
|
|
67
|
+
private readonly starting;
|
|
68
68
|
constructor(options: LiveEngineOptions);
|
|
69
69
|
get activeSessions(): number;
|
|
70
|
-
start(value: unknown): void
|
|
70
|
+
start(value: unknown): Promise<void>;
|
|
71
|
+
/** `ps` and `inspect` are one Engine API call whose JSON result is the whole session. */
|
|
72
|
+
private query;
|
|
73
|
+
private open;
|
|
71
74
|
/** Returns the writable backpressure signal; callers must pause transport input on false. */
|
|
72
75
|
stdin(sessionId: string, data: Uint8Array): boolean;
|
|
73
76
|
endStdin(sessionId: string): void;
|