@wpelevator/tug 0.3.0 → 0.5.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.
@@ -0,0 +1,428 @@
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
+ const name = ref.slice(0, digest);
24
+ const colon = name.lastIndexOf(':');
25
+ return {
26
+ fromImage: colon > name.lastIndexOf('/') ? name.slice(0, colon) : name,
27
+ tag: ref.slice(digest + 1),
28
+ };
29
+ }
30
+ const colon = ref.lastIndexOf(':');
31
+ if (colon > ref.lastIndexOf('/'))
32
+ return { fromImage: ref.slice(0, colon), tag: ref.slice(colon + 1) };
33
+ return { fromImage: ref, tag: 'latest' };
34
+ }
35
+ /**
36
+ * Splits the 8-byte-header multiplexed stream (`[stream, 0, 0, 0, size(4, big endian)]` frames) that
37
+ * non-TTY attach, exec and logs use into `stdout` and `stderr`. `end` fires once the source ends.
38
+ */
39
+ export function demux(source, stdout, stderr) {
40
+ let buffer = Buffer.alloc(0);
41
+ source.on('data', (chunk) => {
42
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
43
+ while (buffer.length >= 8) {
44
+ const size = buffer.readUInt32BE(4);
45
+ if (buffer.length < 8 + size)
46
+ break;
47
+ const frame = buffer.subarray(8, 8 + size);
48
+ (buffer[0] === 2 ? stderr : stdout).write(frame);
49
+ buffer = buffer.subarray(8 + size);
50
+ }
51
+ });
52
+ const end = () => {
53
+ stdout.end();
54
+ stderr.end();
55
+ };
56
+ source.on('end', end);
57
+ source.on('close', end);
58
+ source.on('error', (error) => {
59
+ stdout.destroy(error);
60
+ stderr.destroy();
61
+ });
62
+ }
63
+ function filtersParam(filter) {
64
+ const filters = {};
65
+ if (filter.label?.length)
66
+ filters.label = filter.label;
67
+ if (filter.ancestor)
68
+ filters.ancestor = [filter.ancestor];
69
+ if (filter.name)
70
+ filters.name = [filter.name];
71
+ return JSON.stringify(filters);
72
+ }
73
+ async function readBody(res) {
74
+ const chunks = [];
75
+ for await (const chunk of res)
76
+ chunks.push(chunk);
77
+ return Buffer.concat(chunks).toString('utf8');
78
+ }
79
+ /** `unix:///path` or a bare path from `DOCKER_HOST`; the agent talks to the local daemon only. */
80
+ export function socketFromEnv(env = process.env) {
81
+ const host = env.DOCKER_HOST;
82
+ if (!host)
83
+ return DEFAULT_SOCKET;
84
+ if (host.startsWith('unix://'))
85
+ return host.slice('unix://'.length);
86
+ throw new Error(`unsupported DOCKER_HOST ${host} (the agent uses the local unix socket)`);
87
+ }
88
+ export class DockerEngine {
89
+ socketPath;
90
+ constructor(socketPath = socketFromEnv()) {
91
+ this.socketPath = socketPath;
92
+ }
93
+ request(method, path, options = {}) {
94
+ const query = new URLSearchParams();
95
+ for (const [key, value] of Object.entries(options.query || {}))
96
+ if (value !== undefined)
97
+ query.set(key, value);
98
+ const qs = query.size ? `?${query}` : '';
99
+ return new Promise((resolve, reject) => {
100
+ const headers = { ...options.headers };
101
+ let payload;
102
+ if (options.body !== undefined) {
103
+ payload = Buffer.from(JSON.stringify(options.body));
104
+ headers['content-type'] = 'application/json';
105
+ headers['content-length'] = String(payload.length);
106
+ }
107
+ const req = http.request({ socketPath: this.socketPath, method, path: `/${API_VERSION}${path}${qs}`, headers }, resolve);
108
+ req.on('error', reject);
109
+ if (payload)
110
+ req.end(payload);
111
+ else if (Buffer.isBuffer(options.stream))
112
+ req.end(options.stream);
113
+ else if (options.stream) {
114
+ options.stream.on('error', (error) => req.destroy(error));
115
+ options.stream.pipe(req);
116
+ }
117
+ else
118
+ req.end();
119
+ });
120
+ }
121
+ /** Reads the response; non-2xx becomes a `DockerError` carrying the daemon's message. */
122
+ async call(method, path, options) {
123
+ const res = await this.request(method, path, options);
124
+ const text = await readBody(res);
125
+ const status = res.statusCode || 0;
126
+ if (status >= 200 && status < 300)
127
+ return text;
128
+ let message = text.trim();
129
+ try {
130
+ message = JSON.parse(text).message || message;
131
+ }
132
+ catch {
133
+ /* not JSON */
134
+ }
135
+ throw new DockerError(status, message || `Docker API ${method} ${path}: HTTP ${status}`);
136
+ }
137
+ async json(method, path, options) {
138
+ const text = await this.call(method, path, options);
139
+ return (text ? JSON.parse(text) : undefined);
140
+ }
141
+ /** Consumes the JSON-lines progress stream of pull and load; a line with `error` fails the call. */
142
+ async progress(res, what) {
143
+ const status = res.statusCode || 0;
144
+ const text = await readBody(res);
145
+ if (status < 200 || status >= 300) {
146
+ let message = text.trim();
147
+ try {
148
+ message = JSON.parse(text).message || message;
149
+ }
150
+ catch {
151
+ /* not JSON */
152
+ }
153
+ throw new DockerError(status, message || `${what}: HTTP ${status}`);
154
+ }
155
+ for (const line of text.split('\n')) {
156
+ if (!line.trim())
157
+ continue;
158
+ let event;
159
+ try {
160
+ event = JSON.parse(line);
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ if (event.error)
166
+ throw new Error(`${what}: ${event.error}`);
167
+ }
168
+ }
169
+ version = () => this.json('GET', '/version');
170
+ info = () => this.json('GET', '/info');
171
+ listContainers(filter = {}, all = true) {
172
+ return this.json('GET', '/containers/json', {
173
+ query: { all: all ? '1' : '0', filters: filtersParam(filter) },
174
+ });
175
+ }
176
+ inspectContainer = (id) => this.json('GET', `/containers/${encodeURIComponent(id)}/json`);
177
+ async createContainer(name, body) {
178
+ const created = await this.json('POST', '/containers/create', {
179
+ query: { name },
180
+ body,
181
+ });
182
+ return created.Id;
183
+ }
184
+ async startContainer(id) {
185
+ try {
186
+ await this.call('POST', `/containers/${encodeURIComponent(id)}/start`);
187
+ }
188
+ catch (error) {
189
+ if (error instanceof DockerError && error.status === 304)
190
+ return;
191
+ throw error;
192
+ }
193
+ }
194
+ async stopContainer(id, timeoutSeconds) {
195
+ try {
196
+ await this.call('POST', `/containers/${encodeURIComponent(id)}/stop`, {
197
+ query: { t: timeoutSeconds === undefined ? undefined : String(timeoutSeconds) },
198
+ });
199
+ }
200
+ catch (error) {
201
+ if (error instanceof DockerError && error.status === 304)
202
+ return;
203
+ throw error;
204
+ }
205
+ }
206
+ async restartContainer(id) {
207
+ await this.call('POST', `/containers/${encodeURIComponent(id)}/restart`);
208
+ }
209
+ async removeContainer(id) {
210
+ try {
211
+ await this.call('DELETE', `/containers/${encodeURIComponent(id)}`, {
212
+ query: { force: '1' },
213
+ });
214
+ }
215
+ catch (error) {
216
+ if (!isNotFound(error))
217
+ throw error;
218
+ }
219
+ }
220
+ async connectNetwork(network, container, aliases = []) {
221
+ await this.call('POST', `/networks/${encodeURIComponent(network)}/connect`, {
222
+ body: { Container: container, EndpointConfig: { Aliases: aliases } },
223
+ });
224
+ }
225
+ async logs(id, options = {}) {
226
+ const tty = await this.isTty(id);
227
+ const res = await this.request('GET', `/containers/${encodeURIComponent(id)}/logs`, {
228
+ query: {
229
+ stdout: '1',
230
+ stderr: '1',
231
+ follow: options.follow === false ? '0' : '1',
232
+ tail: String(options.tail ?? 100),
233
+ timestamps: options.timestamps ? '1' : '0',
234
+ },
235
+ });
236
+ if ((res.statusCode || 0) >= 300) {
237
+ const text = await readBody(res);
238
+ throw new DockerError(res.statusCode || 0, text.trim() || 'cannot read container logs');
239
+ }
240
+ return this.attachedFromStream(res, tty, () => res.destroy(), null, async () => 0);
241
+ }
242
+ async isTty(id) {
243
+ const inspected = (await this.inspectContainer(id));
244
+ return Boolean(inspected.Config?.Tty);
245
+ }
246
+ /**
247
+ * Wraps a response or hijacked socket as a process whose output is demultiplexed unless a TTY.
248
+ * `close` fires with `exitCode()` once the source and both output streams have ended.
249
+ */
250
+ attachedFromStream(source, tty, destroy, stdin, exitCode) {
251
+ const stdout = new PassThrough();
252
+ const stderr = new PassThrough();
253
+ const proc = Object.assign(new EventEmitter(), {
254
+ stdin,
255
+ stdout,
256
+ stderr,
257
+ kill: () => {
258
+ destroy();
259
+ return true;
260
+ },
261
+ });
262
+ if (tty) {
263
+ source.pipe(stdout);
264
+ source.on('end', () => stderr.end());
265
+ source.on('close', () => stderr.end());
266
+ }
267
+ else
268
+ demux(source, stdout, stderr);
269
+ source.on('error', (error) => proc.emit('error', error));
270
+ const ended = (stream) => new Promise((resolve) => {
271
+ stream.once('end', resolve);
272
+ stream.once('close', resolve);
273
+ stream.once('error', () => resolve());
274
+ });
275
+ void Promise.all([ended(source), ended(stdout), ended(stderr)]).then(async () => proc.emit('close', await exitCode().catch(() => null), null));
276
+ return proc;
277
+ }
278
+ async putArchive(id, dir, tar) {
279
+ await this.call('PUT', `/containers/${encodeURIComponent(id)}/archive`, {
280
+ query: { path: dir },
281
+ stream: tar,
282
+ headers: { 'content-type': 'application/x-tar' },
283
+ });
284
+ }
285
+ async execRun(id, cmd) {
286
+ const proc = await this.execStart(id, { cmd });
287
+ const out = [];
288
+ const err = [];
289
+ proc.stdout.on('data', (d) => out.push(d));
290
+ proc.stderr.on('data', (d) => err.push(d));
291
+ const code = await new Promise((resolve, reject) => {
292
+ proc.on('error', reject);
293
+ proc.on('close', (c) => resolve(c ?? -1));
294
+ });
295
+ return {
296
+ code,
297
+ out: Buffer.concat(out).toString('utf8'),
298
+ err: Buffer.concat(err).toString('utf8'),
299
+ };
300
+ }
301
+ async execStart(id, options) {
302
+ const tty = Boolean(options.tty);
303
+ const created = await this.json('POST', `/containers/${encodeURIComponent(id)}/exec`, {
304
+ body: {
305
+ AttachStdin: options.stdin === true,
306
+ AttachStdout: true,
307
+ AttachStderr: true,
308
+ Tty: tty,
309
+ Cmd: options.cmd,
310
+ ...(tty && options.rows && options.columns
311
+ ? { ConsoleSize: [options.rows, options.columns] }
312
+ : {}),
313
+ },
314
+ });
315
+ const execId = created.Id;
316
+ const payload = Buffer.from(JSON.stringify({ Detach: false, Tty: tty }));
317
+ // Exec start hijacks the connection: the daemon answers `101 UPGRADE` and the socket becomes the stream.
318
+ const socket = await new Promise((resolve, reject) => {
319
+ const req = http.request({
320
+ socketPath: this.socketPath,
321
+ method: 'POST',
322
+ path: `/${API_VERSION}/exec/${execId}/start`,
323
+ headers: {
324
+ 'content-type': 'application/json',
325
+ 'content-length': String(payload.length),
326
+ connection: 'Upgrade',
327
+ upgrade: 'tcp',
328
+ },
329
+ }, async (res) => reject(new DockerError(res.statusCode || 0, (await readBody(res)).trim())));
330
+ req.on('upgrade', (_res, sock, head) => {
331
+ if (head.length)
332
+ sock.unshift(head);
333
+ resolve(sock);
334
+ });
335
+ req.on('error', reject);
336
+ req.end(payload);
337
+ });
338
+ const proc = this.attachedFromStream(socket, tty, () => socket.destroy(), options.stdin ? socket : null, async () => (await this.json('GET', `/exec/${execId}/json`)).ExitCode ??
339
+ 0);
340
+ if (tty) {
341
+ proc.resize = (columns, rows) => void this.resizeExec(execId, columns, rows);
342
+ // ConsoleSize at create needs API 1.42; resizing once started works on every supported daemon.
343
+ if (options.columns && options.rows)
344
+ proc.resize(options.columns, options.rows);
345
+ }
346
+ return proc;
347
+ }
348
+ async resizeExec(execId, columns, rows) {
349
+ await this.call('POST', `/exec/${execId}/resize`, {
350
+ query: { h: String(rows), w: String(columns) },
351
+ }).catch(() => { });
352
+ }
353
+ async inspectNetwork(name) {
354
+ try {
355
+ return await this.json('GET', `/networks/${encodeURIComponent(name)}`);
356
+ }
357
+ catch (error) {
358
+ if (isNotFound(error))
359
+ return null;
360
+ throw error;
361
+ }
362
+ }
363
+ async createNetwork(name, labels) {
364
+ await this.call('POST', '/networks/create', {
365
+ body: { Name: name, Driver: 'bridge', CheckDuplicate: true, Labels: labels },
366
+ });
367
+ }
368
+ async inspectVolume(name) {
369
+ try {
370
+ return await this.json('GET', `/volumes/${encodeURIComponent(name)}`);
371
+ }
372
+ catch (error) {
373
+ if (isNotFound(error))
374
+ return null;
375
+ throw error;
376
+ }
377
+ }
378
+ async createVolume(name, options) {
379
+ await this.call('POST', '/volumes/create', {
380
+ body: {
381
+ Name: name,
382
+ Driver: options.driver || 'local',
383
+ DriverOpts: options.opts || {},
384
+ Labels: options.labels || {},
385
+ },
386
+ });
387
+ }
388
+ async removeVolume(name) {
389
+ await this.call('DELETE', `/volumes/${encodeURIComponent(name)}`);
390
+ }
391
+ async inspectImage(ref) {
392
+ try {
393
+ return await this.json('GET', `/images/${encodeURI(ref)}/json`);
394
+ }
395
+ catch (error) {
396
+ if (isNotFound(error))
397
+ return null;
398
+ throw error;
399
+ }
400
+ }
401
+ async pullImage(ref, auth) {
402
+ const { fromImage, tag } = splitImageRef(ref);
403
+ const res = await this.request('POST', '/images/create', {
404
+ query: { fromImage, tag },
405
+ // Docker uses Go's padded base64.URLEncoding; Node's base64url encoding omits required '='.
406
+ headers: auth
407
+ ? {
408
+ 'x-registry-auth': Buffer.from(JSON.stringify(auth))
409
+ .toString('base64')
410
+ .replaceAll('+', '-')
411
+ .replaceAll('/', '_'),
412
+ }
413
+ : {},
414
+ });
415
+ await this.progress(res, `pull ${ref}`);
416
+ }
417
+ async loadImage(archive) {
418
+ const res = await this.request('POST', '/images/load', {
419
+ query: { quiet: '1' },
420
+ stream: archive,
421
+ headers: { 'content-type': 'application/x-tar' },
422
+ });
423
+ await this.progress(res, 'load image');
424
+ }
425
+ async removeImage(ref) {
426
+ await this.call('DELETE', `/images/${encodeURI(ref)}`);
427
+ }
428
+ }
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 { DockerRunner } from './runners.ts';
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: DockerRunner;
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 copied into the
52
- * container (`docker cp`, so it works whether the agent runs on the host or in a container) and
53
- * applied with `caddy reload`, which swaps config gracefully without dropping connections. If
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 copied into the
138
- * container (`docker cp`, so it works whether the agent runs on the host or in a container) and
139
- * applied with `caddy reload`, which swaps config gracefully without dropping connections. If
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 running = await ctx
149
- .docker(['container', 'inspect', '-f', '{{.State.Running}}', CADDY_CONTAINER])
150
- .then((r) => r.out.trim() === 'true', () => null);
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.docker([
158
- 'create',
159
- '--name',
160
- CADDY_CONTAINER,
161
- '--restart',
162
- 'unless-stopped',
163
- '--network',
164
- 'host',
165
- '-v',
166
- 'tug-caddy-data:/data',
167
- '-v',
168
- 'tug-caddy-config:/config',
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(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
178
- await ctx.docker(['start', CADDY_CONTAINER]);
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(['exec', CADDY_CONTAINER, 'rm', '-rf', `${CADDY_ETC}/certs`]);
183
- await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
184
- await ctx.docker([
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
- .docker(['cp', `${applied}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`])
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(['rm', '-f', CADDY_CONTAINER]).catch(() => { });
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
- spawn?: LiveSpawn;
57
- /** A real PTY adapter is required for interactive Docker exec. */
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 Docker process lifetimes; callers own authentication, authorization and transport backpressure. */
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;