@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.
Files changed (50) hide show
  1. package/README.md +38 -4
  2. package/dist/agent.d.ts +66 -6
  3. package/dist/agent.js +851 -170
  4. package/dist/archive.js +9 -3
  5. package/dist/artifacts.js +97 -34
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.js +17 -8
  8. package/dist/cli-build.js +34 -8
  9. package/dist/cli-config.js +16 -4
  10. package/dist/cli-install.d.ts +10 -0
  11. package/dist/cli-install.js +38 -4
  12. package/dist/cli-live-transport.d.ts +3 -0
  13. package/dist/cli-live-transport.js +230 -0
  14. package/dist/cli-live.d.ts +77 -0
  15. package/dist/cli-live.js +249 -0
  16. package/dist/cli.js +408 -90
  17. package/dist/compose.d.ts +35 -9
  18. package/dist/compose.js +240 -132
  19. package/dist/container-spec.d.ts +56 -0
  20. package/dist/container-spec.js +359 -0
  21. package/dist/control.d.ts +5 -3
  22. package/dist/control.js +1047 -146
  23. package/dist/db.d.ts +1 -0
  24. package/dist/db.js +7 -1
  25. package/dist/docker-api.d.ts +232 -0
  26. package/dist/docker-api.js +416 -0
  27. package/dist/ingress.d.ts +16 -8
  28. package/dist/ingress.js +84 -29
  29. package/dist/live.d.ts +83 -0
  30. package/dist/live.js +276 -0
  31. package/dist/main.js +3 -1
  32. package/dist/mesh.js +32 -13
  33. package/dist/normalize.d.ts +20 -0
  34. package/dist/normalize.js +75 -0
  35. package/dist/protocol.d.ts +158 -0
  36. package/dist/protocol.js +16 -2
  37. package/dist/reconciler.d.ts +89 -0
  38. package/dist/reconciler.js +362 -0
  39. package/dist/runners.d.ts +1 -6
  40. package/dist/runners.js +1 -5
  41. package/dist/secrets.js +3 -1
  42. package/dist/templates/control-compose.yml +1 -0
  43. package/dist/templates/install-docker.sh +12 -7
  44. package/dist/tokens.d.ts +1 -1
  45. package/dist/tokens.js +35 -9
  46. package/dist/util.d.ts +7 -0
  47. package/dist/util.js +73 -16
  48. package/package.json +8 -4
  49. package/dist/rollout.d.ts +0 -72
  50. package/dist/rollout.js +0 -319
package/dist/agent.js CHANGED
@@ -1,20 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
- import { createWriteStream } from 'node:fs';
3
+ import { createReadStream, createWriteStream } from 'node:fs';
4
4
  import { Readable } from 'node:stream';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  import { validateImageArchive, verifyDownload } from "./artifacts.js";
7
7
  import path from 'node:path';
8
8
  import { spawn, spawnSync } from 'node:child_process';
9
9
  import net from 'node:net';
10
+ import dns from 'node:dns/promises';
11
+ import { WebSocket } from 'ws';
10
12
  import { createHash } from 'node:crypto';
11
- import { composeForNode, dumpYaml, isRolling, serviceSecretVersions } from "./compose.js";
12
- import { activeRollingStates, composeBase, hasLegacyContainers, reconcileRolling, retireAll } from "./rollout.js";
13
- import { ensureDir, errMessage, now, parseDuration } from "./util.js";
13
+ import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
14
+ import { containerSpecs, networkName, volumeName } from "./container-spec.js";
15
+ import { API_VERSION, DockerEngine, isNotFound } from "./docker-api.js";
16
+ import { LIVE_MAX_FRAME_BYTES, LIVE_START_TIMEOUT_MS } from "./protocol.js";
17
+ import { LiveOperationEngine } from "./live.js";
18
+ import { activeRollingStates, clearRolloutState, ensureProjectResources, loadRolloutState, projectContainers, reconcileRecreate, reconcileRolling, sweepOrphans, } from "./reconciler.js";
19
+ import { ensureDir, errMessage, isPublicAddress, now, parseDuration } from "./util.js";
14
20
  import { openAgentDb } from "./db.js";
15
- import { createDockerRunner, createHostRunner } from "./runners.js";
21
+ import { createHostRunner } from "./runners.js";
16
22
  import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
17
- import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey } from "./mesh.js";
23
+ import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey, } from "./mesh.js";
18
24
  const CONTROL_URL_FROM_ENV = Boolean(process.env.TUG_CONTROL_URL);
19
25
  let CONTROL_URL = process.env.TUG_CONTROL_URL || 'http://localhost:8080';
20
26
  let TOKEN = process.env.TUG_AGENT_TOKEN;
@@ -25,7 +31,19 @@ const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
25
31
  // Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
26
32
  const MESH = process.env.TUG_MESH === 'true';
27
33
  export function defaultContext() {
28
- return { docker: createDockerRunner(), http: fetch, nodeName: NODE_NAME, stateDir: STATE_DIR, apply: APPLY, controlUrl: CONTROL_URL, token: TOKEN, host: createHostRunner(), mesh: MESH, caddyImage: process.env.TUG_CADDY_IMAGE, rolloutPorts: process.env.TUG_ROLLOUT_PORTS };
34
+ return {
35
+ docker: new DockerEngine(),
36
+ http: fetch,
37
+ nodeName: NODE_NAME,
38
+ stateDir: STATE_DIR,
39
+ apply: APPLY,
40
+ controlUrl: CONTROL_URL,
41
+ token: TOKEN,
42
+ host: createHostRunner(),
43
+ mesh: MESH,
44
+ caddyImage: process.env.TUG_CADDY_IMAGE,
45
+ rolloutPorts: process.env.TUG_ROLLOUT_PORTS,
46
+ };
29
47
  }
30
48
  const dbsByStateDir = new Map();
31
49
  async function agentDb(stateDir) {
@@ -37,36 +55,417 @@ async function agentDb(stateDir) {
37
55
  }
38
56
  return db;
39
57
  }
40
- function authHeaders(ctx) { if (!ctx.token)
41
- throw new Error('TUG_AGENT_TOKEN required'); return { Authorization: `Bearer ${ctx.token}` }; }
58
+ function authHeaders(ctx) {
59
+ if (!ctx.token)
60
+ throw new Error('TUG_AGENT_TOKEN required');
61
+ return { Authorization: `Bearer ${ctx.token}` };
62
+ }
42
63
  async function api(ctx, route, opts = {}) {
43
- const res = await ctx.http(`${ctx.controlUrl}${route}`, { ...opts, headers: { ...authHeaders(ctx), ...(opts.headers || {}) } });
64
+ const res = await ctx.http(`${ctx.controlUrl}${route}`, {
65
+ ...opts,
66
+ headers: {
67
+ ...authHeaders(ctx),
68
+ ...(opts.headers || {}),
69
+ },
70
+ });
44
71
  if (!res.ok)
45
72
  throw new Error(`${opts.method || 'GET'} ${route}: ${res.status} ${await res.text()}`);
46
73
  return res.json();
47
74
  }
48
- async function writeEnv(file, secrets) {
49
- const body = Object.entries(secrets).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n') + '\n';
50
- await fs.writeFile(file, body, { mode: 0o600 });
75
+ /** Keeps one outbound stream open and falls back to a bounded HTTP wait when an intermediary rejects WebSockets. */
76
+ export class AgentWakeChannel {
77
+ ctx;
78
+ createSocket;
79
+ socket;
80
+ cursor = 0;
81
+ pending = false;
82
+ waiter;
83
+ handlers;
84
+ constructor(ctx, createSocket = (url, token) => new WebSocket(url, {
85
+ headers: { Authorization: `Bearer ${token}` },
86
+ maxPayload: LIVE_MAX_FRAME_BYTES,
87
+ }), handlers = {}) {
88
+ this.ctx = ctx;
89
+ this.createSocket = createSocket;
90
+ this.handlers = handlers;
91
+ }
92
+ notify() {
93
+ this.pending = true;
94
+ this.waiter?.();
95
+ }
96
+ async connect() {
97
+ if (this.socket?.readyState === WebSocket.OPEN)
98
+ return true;
99
+ if (!this.ctx.token)
100
+ throw new Error('TUG_AGENT_TOKEN required');
101
+ const url = new URL('/agent/v1/stream', this.ctx.controlUrl);
102
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
103
+ return new Promise((resolve) => {
104
+ let settled = false;
105
+ const socket = this.createSocket(url.toString(), this.ctx.token);
106
+ this.socket = socket;
107
+ const finish = (connected) => {
108
+ if (settled)
109
+ return;
110
+ settled = true;
111
+ clearTimeout(timer);
112
+ resolve(connected);
113
+ };
114
+ const timer = setTimeout(() => {
115
+ socket.terminate();
116
+ finish(false);
117
+ }, 1500);
118
+ socket.on('open', () => finish(true));
119
+ socket.on('message', (data) => {
120
+ if (this.socket !== socket)
121
+ return;
122
+ let message;
123
+ const json = data.toString();
124
+ if (Buffer.byteLength(json) > LIVE_MAX_FRAME_BYTES) {
125
+ this.close();
126
+ return;
127
+ }
128
+ try {
129
+ message = JSON.parse(json);
130
+ }
131
+ catch {
132
+ return;
133
+ }
134
+ if (!message || typeof message !== 'object')
135
+ return;
136
+ const frame = message;
137
+ if (typeof frame.type === 'string' && frame.type.startsWith('live.')) {
138
+ if (frame.v === 1)
139
+ void Promise.resolve(this.handlers.onLive?.(message)).catch((error) => console.error('agent live frame failed:', errMessage(error)));
140
+ return;
141
+ }
142
+ if ((frame.type !== 'ready' && frame.type !== 'work-available') ||
143
+ !Number.isSafeInteger(frame.cursor) ||
144
+ Number(frame.cursor) < 0)
145
+ return;
146
+ this.cursor = Math.max(this.cursor, Number(frame.cursor));
147
+ if (frame.type === 'work-available')
148
+ this.notify();
149
+ });
150
+ socket.on('close', () => {
151
+ if (this.socket === socket) {
152
+ this.socket = undefined;
153
+ this.handlers.onDisconnect?.();
154
+ }
155
+ if (settled)
156
+ this.waiter?.();
157
+ else
158
+ finish(false);
159
+ });
160
+ socket.on('error', () => {
161
+ if (this.socket === socket) {
162
+ this.socket = undefined;
163
+ this.handlers.onDisconnect?.();
164
+ }
165
+ socket.terminate();
166
+ this.waiter?.();
167
+ finish(false);
168
+ });
169
+ });
170
+ }
171
+ async waitOnSocket(timeoutMs) {
172
+ if (this.pending) {
173
+ this.pending = false;
174
+ return;
175
+ }
176
+ await new Promise((resolve) => {
177
+ const done = () => {
178
+ clearTimeout(timer);
179
+ if (this.waiter === done)
180
+ this.waiter = undefined;
181
+ resolve();
182
+ };
183
+ this.waiter = done;
184
+ const timer = setTimeout(done, timeoutMs);
185
+ });
186
+ this.pending = false;
187
+ }
188
+ async longPoll(timeoutMs) {
189
+ const wake = await api(this.ctx, `/agent/v1/wake?after=${this.cursor}&timeout=${Math.min(timeoutMs, 30_000)}`);
190
+ this.cursor = Math.max(this.cursor, wake.cursor);
191
+ }
192
+ async wait(timeoutMs) {
193
+ if (await this.connect())
194
+ return this.waitOnSocket(timeoutMs);
195
+ await this.longPoll(timeoutMs);
196
+ }
197
+ send(frame) {
198
+ const socket = this.socket;
199
+ if (!socket || socket.readyState !== WebSocket.OPEN)
200
+ return false;
201
+ if (socket.bufferedAmount > 1048576) {
202
+ this.close();
203
+ socket.terminate();
204
+ return false;
205
+ }
206
+ try {
207
+ socket.send(JSON.stringify(frame));
208
+ return true;
209
+ }
210
+ catch {
211
+ this.close();
212
+ return false;
213
+ }
214
+ }
215
+ close() {
216
+ const socket = this.socket;
217
+ this.socket = undefined;
218
+ this.handlers.onDisconnect?.();
219
+ socket?.close();
220
+ this.waiter?.();
221
+ }
222
+ }
223
+ /** Resolves a service once before starting a process so rollouts cannot silently retarget an open session. */
224
+ export async function resolveLiveContainer(ctx, request) {
225
+ const name = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
226
+ if (!name.test(request.project) ||
227
+ request.project === '..' ||
228
+ !request.service ||
229
+ !name.test(request.service))
230
+ throw new Error('A valid project and service are required');
231
+ const state = await loadRolloutState(path.join(ctx.stateDir, 'projects', request.project));
232
+ const rolling = state.services[request.service];
233
+ if (rolling && !rolling.active)
234
+ throw new Error('Rolling service has no active revision');
235
+ const containers = await projectContainers(ctx.docker, request.project, request.service);
236
+ // A rolling service has the draining revision beside the serving one; only the serving one is addressed.
237
+ const candidates = rolling?.active
238
+ ? containers.filter((c) => c.Names.includes(`/${rolling.active.container}`))
239
+ : containers;
240
+ if (candidates.length !== 1)
241
+ throw new Error(candidates.length
242
+ ? 'Service has multiple containers; select an unambiguous service'
243
+ : 'Service has no container on this node');
244
+ if (!/^[a-f0-9]{12,64}$/.test(candidates[0].Id))
245
+ throw new Error('Docker returned an invalid container ID');
246
+ return candidates[0].Id;
247
+ }
248
+ export class AgentLiveOperations {
249
+ ctx;
250
+ emit;
251
+ engine;
252
+ sessions = new Map();
253
+ constructor(ctx, emit, options = {}) {
254
+ this.ctx = ctx;
255
+ this.emit = emit;
256
+ this.engine = new LiveOperationEngine({
257
+ docker: ctx.docker,
258
+ ...options,
259
+ emit: (event) => {
260
+ if (!this.sessions.has(event.sessionId))
261
+ return;
262
+ if (event.type === 'exit')
263
+ this.sessions.delete(event.sessionId);
264
+ if (event.type === 'output')
265
+ this.emit({
266
+ v: 1,
267
+ type: 'live.output',
268
+ sessionId: event.sessionId,
269
+ stream: event.stream,
270
+ data: event.data,
271
+ });
272
+ else if (event.type === 'result')
273
+ this.emit({
274
+ v: 1,
275
+ type: 'live.result',
276
+ sessionId: event.sessionId,
277
+ result: event.result,
278
+ });
279
+ else
280
+ this.emit({ ...event, v: 1, type: 'live.exit' });
281
+ },
282
+ });
283
+ }
284
+ async handle(frame) {
285
+ if (frame.v !== 1 ||
286
+ typeof frame.sessionId !== 'string' ||
287
+ !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,255}$/.test(frame.sessionId))
288
+ return;
289
+ if (frame.type === 'live.open') {
290
+ // An existing open belongs to its original request; replaying it must not start another process.
291
+ if (this.sessions.has(frame.sessionId))
292
+ return;
293
+ const entry = { ready: Promise.resolve(), queuedBytes: 0 };
294
+ this.sessions.set(frame.sessionId, entry);
295
+ entry.ready = this.open({ ...frame.request, sessionId: frame.sessionId }, entry);
296
+ await entry.ready;
297
+ return;
298
+ }
299
+ const entry = this.sessions.get(frame.sessionId);
300
+ if (!entry)
301
+ return;
302
+ if (frame.type === 'live.cancel') {
303
+ this.engine.cancel(frame.sessionId);
304
+ if (this.sessions.delete(frame.sessionId))
305
+ this.emit({
306
+ v: 1,
307
+ type: 'live.exit',
308
+ sessionId: frame.sessionId,
309
+ code: null,
310
+ signal: null,
311
+ cancelled: true,
312
+ });
313
+ return;
314
+ }
315
+ let queued = 0;
316
+ try {
317
+ let input;
318
+ if (frame.type === 'live.stdin') {
319
+ if (typeof frame.data !== 'string' || frame.data.length > 87384)
320
+ throw new Error('Invalid stdin frame');
321
+ input = Buffer.from(frame.data, 'base64');
322
+ if (input.toString('base64') !== frame.data || input.length > 65536)
323
+ throw new Error('Invalid base64 stdin');
324
+ queued = input.length;
325
+ entry.queuedBytes += queued;
326
+ if (entry.queuedBytes > 262144)
327
+ throw new Error('Live stdin queue exceeds size limit');
328
+ }
329
+ await entry.ready;
330
+ if (this.sessions.get(frame.sessionId) !== entry)
331
+ return;
332
+ if (frame.type === 'live.stdin' && input)
333
+ this.engine.stdin(frame.sessionId, input);
334
+ else if (frame.type === 'live.stdin-end')
335
+ this.engine.endStdin(frame.sessionId);
336
+ else if (frame.type === 'live.resize')
337
+ this.engine.resize(frame.sessionId, frame.columns, frame.rows);
338
+ }
339
+ catch (error) {
340
+ this.fail(frame.sessionId, entry, error);
341
+ }
342
+ finally {
343
+ entry.queuedBytes -= queued;
344
+ }
345
+ }
346
+ dispose() {
347
+ const ids = [...this.sessions.keys()];
348
+ this.engine.dispose();
349
+ for (const id of ids)
350
+ if (this.sessions.delete(id))
351
+ this.emit({
352
+ v: 1,
353
+ type: 'live.exit',
354
+ sessionId: id,
355
+ code: null,
356
+ signal: null,
357
+ cancelled: true,
358
+ });
359
+ }
360
+ fail(id, entry, error) {
361
+ if (this.sessions.get(id) !== entry)
362
+ return;
363
+ this.sessions.delete(id);
364
+ this.emit({
365
+ v: 1,
366
+ type: 'live.exit',
367
+ sessionId: id,
368
+ code: null,
369
+ signal: null,
370
+ cancelled: false,
371
+ error: errMessage(error),
372
+ });
373
+ this.engine.cancel(id);
374
+ }
375
+ async open(request, entry) {
376
+ const current = () => this.sessions.get(request.sessionId) === entry;
377
+ const started = () => this.emit({ v: 1, type: 'live.started', sessionId: request.sessionId });
378
+ try {
379
+ if (this.sessions.size > 16)
380
+ throw new Error('Live session limit reached');
381
+ if (!this.ctx.apply)
382
+ throw new Error('Live operations require an applying agent');
383
+ if (request.node && request.node !== this.ctx.nodeName)
384
+ throw new Error('Live request selected a different node');
385
+ if (typeof request.project !== 'string' ||
386
+ !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(request.project))
387
+ throw new Error('Invalid project');
388
+ if (request.operation === 'ps') {
389
+ // A query's result and exit follow `started` immediately, so announce it first.
390
+ started();
391
+ await this.engine.start({
392
+ operation: 'ps',
393
+ sessionId: request.sessionId,
394
+ projects: [request.project],
395
+ });
396
+ return;
397
+ }
398
+ if (!['exec', 'logs', 'inspect'].includes(request.operation))
399
+ throw new Error('Invalid live operation');
400
+ let timer;
401
+ let container;
402
+ try {
403
+ container = await Promise.race([
404
+ resolveLiveContainer(this.ctx, request),
405
+ new Promise((_resolve, reject) => {
406
+ timer = setTimeout(() => reject(new Error('Live container resolution timed out')), LIVE_START_TIMEOUT_MS);
407
+ timer.unref();
408
+ }),
409
+ ]);
410
+ }
411
+ finally {
412
+ clearTimeout(timer);
413
+ }
414
+ if (!current())
415
+ return;
416
+ if (request.operation === 'inspect') {
417
+ started();
418
+ await this.engine.start({ operation: 'inspect', sessionId: request.sessionId, container });
419
+ return;
420
+ }
421
+ if (request.operation === 'exec')
422
+ await this.engine.start({
423
+ operation: 'exec',
424
+ sessionId: request.sessionId,
425
+ container,
426
+ command: request.command,
427
+ interactive: request.interactive,
428
+ tty: request.tty,
429
+ columns: request.columns,
430
+ rows: request.rows,
431
+ });
432
+ else
433
+ await this.engine.start({
434
+ operation: 'logs',
435
+ sessionId: request.sessionId,
436
+ container,
437
+ tail: request.tail,
438
+ });
439
+ if (current())
440
+ started();
441
+ else
442
+ this.engine.cancel(request.sessionId);
443
+ }
444
+ catch (error) {
445
+ this.fail(request.sessionId, entry, error);
446
+ }
447
+ }
51
448
  }
52
- /** `docker compose` prefix for a project; rolling revision projects pass their own Compose file. */
53
- const composeCmd = (project, cwd, composeFile) => composeFile ? composeBase(project, cwd, composeFile) : ['compose', '-p', project];
54
- export async function containerIp(ctx, project, service, cwd, composeFile) {
55
- const ids = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/).filter(Boolean);
56
- if (!ids.length)
57
- throw new Error(`${service}: no container found`);
58
- const inspected = JSON.parse((await ctx.docker(['inspect', ids[0]], { cwd })).out)[0];
59
- const networks = inspected?.NetworkSettings?.Networks || {};
60
- return Object.values(networks)[0]?.IPAddress || '127.0.0.1';
449
+ /** The container's address on the project network, which health checks dial. */
450
+ export async function containerIp(ctx, project, container) {
451
+ const networks = (await ctx.docker.inspectContainer(container)).NetworkSettings?.Networks || {};
452
+ const net = networks[networkName(project)] ?? Object.values(networks)[0];
453
+ return net?.IPAddress || net?.GlobalIPv6Address || '127.0.0.1';
61
454
  }
62
455
  function tcpCheck(host, port, timeoutMs = 2000) {
63
456
  return new Promise((resolve, reject) => {
64
- const s = net.createConnection({ host, port: Number(port), timeout: timeoutMs }, () => { s.destroy(); resolve(true); });
65
- s.on('timeout', () => { s.destroy(); reject(new Error(`tcp timeout ${host}:${port}`)); });
457
+ const s = net.createConnection({ host, port: Number(port), timeout: timeoutMs }, () => {
458
+ s.destroy();
459
+ resolve(true);
460
+ });
461
+ s.on('timeout', () => {
462
+ s.destroy();
463
+ reject(new Error(`tcp timeout ${host}:${port}`));
464
+ });
66
465
  s.on('error', reject);
67
466
  });
68
467
  }
69
- export async function waitForHealth(ctx, project, service, health, cwd, composeFile) {
468
+ export async function waitForHealth(ctx, project, container, service, health) {
70
469
  if (!ctx.apply || !health)
71
470
  return;
72
471
  const retries = Number(health.retries || 10);
@@ -75,19 +474,20 @@ export async function waitForHealth(ctx, project, service, health, cwd, composeF
75
474
  for (let i = 0; i < retries; i++) {
76
475
  try {
77
476
  if (health.type === 'docker') {
78
- const id = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/)[0];
79
- const state = JSON.parse((await ctx.docker(['inspect', id], { cwd })).out)[0]?.State;
477
+ const state = (await ctx.docker.inspectContainer(container)).State;
80
478
  if (!state?.Running)
81
479
  throw new Error(`${service}: not running`);
82
480
  if (state.Health && state.Health.Status !== 'healthy')
83
481
  throw new Error(`${service}: docker health ${state.Health.Status}`);
84
482
  }
85
483
  else if (health.type === 'tcp') {
86
- await tcpCheck(await containerIp(ctx, project, service, cwd, composeFile), health.port, parseDuration(health.timeout || '2s'));
484
+ await tcpCheck(await containerIp(ctx, project, container), health.port, parseDuration(health.timeout || '2s'));
87
485
  }
88
486
  else if (health.type === 'http') {
89
- const ip = await containerIp(ctx, project, service, cwd, composeFile);
90
- const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, { signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')) });
487
+ const ip = await containerIp(ctx, project, container);
488
+ const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, {
489
+ signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')),
490
+ });
91
491
  if (!res.ok)
92
492
  throw new Error(`${service}: http ${res.status}`);
93
493
  }
@@ -95,7 +495,7 @@ export async function waitForHealth(ctx, project, service, health, cwd, composeF
95
495
  }
96
496
  catch (e) {
97
497
  last = e;
98
- await new Promise(r => setTimeout(r, intervalMs));
498
+ await new Promise((r) => setTimeout(r, intervalMs));
99
499
  }
100
500
  }
101
501
  throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
@@ -114,18 +514,76 @@ function describeHealthCheck(health) {
114
514
  return 'docker health check';
115
515
  }
116
516
  export { parseDuration };
517
+ /** Containers of Tug projects (`projects`) that publish a port on all interfaces or on a public IP. */
518
+ export function publicListeners(containers, projects) {
519
+ const out = [];
520
+ for (const c of containers) {
521
+ const project = c.Labels?.['tug.project'];
522
+ if (!project || !projects.includes(project))
523
+ continue;
524
+ for (const p of c.Ports || []) {
525
+ if (!p.PublicPort || !p.IP || !isPublicAddress(p.IP))
526
+ continue;
527
+ const host = p.IP.includes(':') ? `[${p.IP}]` : p.IP;
528
+ out.push({
529
+ project,
530
+ container: (c.Names?.[0] || c.Id).replace(/^\//, ''),
531
+ listen: `${host}:${p.PublicPort}->${p.PrivatePort}/${p.Type}`,
532
+ });
533
+ }
534
+ }
535
+ return out;
536
+ }
537
+ /** `ports.public` diagnostic: a warning, not a failure, since an operator may publish on purpose with an explicit host IP. */
538
+ async function publicPortsCheck(ctx) {
539
+ try {
540
+ const projects = await fs
541
+ .readdir(path.join(ctx.stateDir, 'projects'))
542
+ .catch(() => []);
543
+ const containers = await ctx.docker.listContainers({ label: ['tug.project'] }, false);
544
+ const listeners = publicListeners(containers, projects);
545
+ const stdout = listeners.map((l) => `${l.project}\t${l.container}\t${l.listen}`).join('\n');
546
+ return listeners.length
547
+ ? {
548
+ name: 'ports.public',
549
+ ok: true,
550
+ stdout: stdout.slice(-4000),
551
+ warning: `${listeners.length} published port${listeners.length === 1 ? ' of Tug projects listens' : 's of Tug projects listen'} on a public address; bind them to 127.0.0.1, the overlay IP or a private address unless that is intended`,
552
+ }
553
+ : {
554
+ name: 'ports.public',
555
+ ok: true,
556
+ stdout: 'no Tug project container listens on a public address',
557
+ };
558
+ }
559
+ catch (e) {
560
+ return { name: 'ports.public', ok: false, error: errMessage(e).slice(-4000) };
561
+ }
562
+ }
563
+ const MIN_API_VERSION = API_VERSION.replace(/^v/, '');
564
+ /** The minor number of an Engine API version such as `1.41`; 0 when unparseable. */
565
+ const apiMinor = (version) => Number(version?.split('.')[1]) || 0;
117
566
  export async function executeCommand(command, ctx = defaultContext()) {
118
- const payload = typeof command.payload_json === 'string' ? JSON.parse(command.payload_json || '{}') : command.payload || {};
567
+ const payload = typeof command.payload_json === 'string'
568
+ ? JSON.parse(command.payload_json || '{}')
569
+ : command.payload || {};
119
570
  if (command.type === 'diagnostics.run') {
120
571
  const checks = [];
121
- for (const [name, args] of [
122
- ['docker.info', ['info']],
123
- ['docker.version', ['version']],
124
- ['docker.compose.version', ['compose', 'version']]
125
- ]) {
572
+ const daemon = [
573
+ ['docker.info', async () => JSON.stringify(await ctx.docker.info(), null, 2)],
574
+ [
575
+ 'docker.version',
576
+ async () => {
577
+ const version = await ctx.docker.version();
578
+ if (apiMinor(version.ApiVersion) < apiMinor(MIN_API_VERSION))
579
+ throw new Error(`Docker Engine API ${version.ApiVersion} is older than the ${MIN_API_VERSION} the agent needs (Docker 20.10+)`);
580
+ return JSON.stringify(version, null, 2);
581
+ },
582
+ ],
583
+ ];
584
+ for (const [name, fn] of daemon) {
126
585
  try {
127
- const result = await ctx.docker([...args]);
128
- checks.push({ name, ok: true, stdout: result.out.slice(-4000), stderr: result.err.slice(-4000) });
586
+ checks.push({ name, ok: true, stdout: (await fn()).slice(-4000), stderr: '' });
129
587
  }
130
588
  catch (e) {
131
589
  checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
@@ -137,62 +595,106 @@ export async function executeCommand(command, ctx = defaultContext()) {
137
595
  extra.push(['wireguard.version', () => host('wg', ['--version'])], ['wireguard.interface', () => host('wg', ['show', WG_INTERFACE])]);
138
596
  }
139
597
  if (await fs.stat(path.join(ctx.stateDir, 'ingress')).then(() => true, () => false)) {
140
- extra.push(['ingress.caddy', () => ctx.docker(['container', 'inspect', '-f', '{{.State.Status}}', CADDY_CONTAINER])]);
598
+ extra.push([
599
+ 'ingress.caddy',
600
+ async () => ({
601
+ out: String((await ctx.docker.inspectContainer(CADDY_CONTAINER)).State?.Status),
602
+ err: '',
603
+ }),
604
+ ]);
141
605
  }
142
606
  for (const [name, fn] of extra) {
143
607
  try {
144
608
  const result = await fn();
145
- checks.push({ name, ok: true, stdout: result.out.slice(-4000), stderr: result.err.slice(-4000) });
609
+ checks.push({
610
+ name,
611
+ ok: true,
612
+ stdout: result.out.slice(-4000),
613
+ stderr: result.err.slice(-4000),
614
+ });
146
615
  }
147
616
  catch (e) {
148
617
  checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
149
618
  }
150
619
  }
151
- return { commandId: command.id, state: checks.every(c => c.ok) ? 'succeeded' : 'failed', result: { checks, node: ctx.nodeName } };
620
+ checks.push(await publicPortsCheck(ctx));
621
+ return {
622
+ commandId: command.id,
623
+ state: checks.every((c) => c.ok) ? 'succeeded' : 'failed',
624
+ result: { checks, node: ctx.nodeName },
625
+ };
152
626
  }
153
627
  if (command.type === 'service.restart') {
154
- const project = payload.project;
155
- const service = payload.service;
156
- const dir = path.join(ctx.stateDir, 'projects', project);
157
- await ctx.docker(['compose', '-p', project, 'restart', service], { cwd: dir });
158
- return { commandId: command.id, state: 'succeeded', result: { project, service, restarted: true } };
628
+ const { project, service } = payload;
629
+ await ctx.docker.restartContainer(await resolveLiveContainer(ctx, { project, service }));
630
+ return {
631
+ commandId: command.id,
632
+ state: 'succeeded',
633
+ result: { project, service, restarted: true },
634
+ };
159
635
  }
160
636
  if (command.type === 'logs.fetch') {
161
- const project = payload.project;
162
- const service = payload.service;
163
- const tail = String(payload.tail || 200);
164
- const dir = path.join(ctx.stateDir, 'projects', project);
165
- const result = await ctx.docker(['compose', '-p', project, 'logs', '--no-color', '--tail', tail, service], { cwd: dir });
166
- return { commandId: command.id, state: 'succeeded', result: { stdout: result.out, stderr: result.err } };
637
+ const { project, service } = payload;
638
+ const id = await resolveLiveContainer(ctx, { project, service });
639
+ const logs = await ctx.docker.logs(id, { follow: false, tail: Number(payload.tail || 200) });
640
+ const out = [];
641
+ const err = [];
642
+ logs.stdout.on('data', (d) => out.push(d));
643
+ logs.stderr.on('data', (d) => err.push(d));
644
+ await new Promise((resolve, reject) => {
645
+ logs.on('error', reject);
646
+ logs.on('close', () => resolve());
647
+ });
648
+ return {
649
+ commandId: command.id,
650
+ state: 'succeeded',
651
+ result: {
652
+ stdout: Buffer.concat(out).toString('utf8'),
653
+ stderr: Buffer.concat(err).toString('utf8'),
654
+ },
655
+ };
167
656
  }
168
657
  if (command.type === 'volume.delete') {
169
- const project = payload.project;
170
- const volume = payload.volume;
171
- const composeVolume = `${project}_${volume}`;
172
- try {
173
- await ctx.docker(['volume', 'rm', composeVolume]);
174
- }
175
- catch (e) {
176
- if (!errMessage(e).includes('No such volume'))
658
+ const { project, volume } = payload;
659
+ // Volumes are never removed by reconciliation; this explicit command is the only way one goes away.
660
+ await ctx.docker.removeVolume(volumeName(project, volume)).catch((e) => {
661
+ if (!isNotFound(e))
177
662
  throw e;
178
- }
179
- return { commandId: command.id, state: 'succeeded', result: { project, volume, deleted: true } };
663
+ });
664
+ return {
665
+ commandId: command.id,
666
+ state: 'succeeded',
667
+ result: { project, volume, deleted: true },
668
+ };
180
669
  }
181
670
  if (command.type === 'wg.rotate-key') {
182
671
  if (!ctx.mesh)
183
672
  throw new Error('the WireGuard mesh is not enabled on this agent (TUG_MESH=true)');
184
673
  const { publicKey } = await rotateWgKeys(ctx.stateDir);
185
- await api(ctx, '/agent/v1/wireguard/key', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ publicKey }) });
674
+ await api(ctx, '/agent/v1/wireguard/key', {
675
+ method: 'POST',
676
+ headers: { 'content-type': 'application/json' },
677
+ body: JSON.stringify({ publicKey }),
678
+ });
186
679
  await writeReportedKey(ctx.stateDir, publicKey);
187
680
  return { commandId: command.id, state: 'succeeded', result: { publicKey } };
188
681
  }
189
- return { commandId: command.id, state: 'failed', result: { error: `unsupported command ${command.type}` } };
682
+ return {
683
+ commandId: command.id,
684
+ state: 'failed',
685
+ result: { error: `unsupported command ${command.type}` },
686
+ };
687
+ }
688
+ async function managedImageFile(ctx) {
689
+ await ensureDir(ctx.stateDir);
690
+ return path.join(ctx.stateDir, 'managed-images.json');
190
691
  }
191
- async function managedImageFile(ctx) { await ensureDir(ctx.stateDir); return path.join(ctx.stateDir, 'managed-images.json'); }
192
692
  async function managedImages(ctx) {
193
693
  try {
194
694
  const refs = JSON.parse(await fs.readFile(await managedImageFile(ctx), 'utf8'));
195
- return Array.isArray(refs) ? refs.filter((ref) => typeof ref === 'string' && ref.startsWith('tug-build/')) : [];
695
+ return Array.isArray(refs)
696
+ ? refs.filter((ref) => typeof ref === 'string' && ref.startsWith('tug-build/'))
697
+ : [];
196
698
  }
197
699
  catch {
198
700
  return [];
@@ -206,24 +708,36 @@ export async function ensureArtifact(ctx, artifact) {
206
708
  }
207
709
  catch { }
208
710
  const remember = async (daemonId) => {
209
- identities[artifact.imageRef] = { daemonId, artifactId: artifact.imageId, sha256: artifact.sha256 };
711
+ identities[artifact.imageRef] = {
712
+ daemonId,
713
+ artifactId: artifact.imageId,
714
+ sha256: artifact.sha256,
715
+ };
210
716
  await fs.writeFile(identityFile, JSON.stringify(identities), { mode: 0o600 });
211
717
  const refs = await managedImages(ctx);
212
718
  if (!refs.includes(artifact.imageRef))
213
719
  await fs.writeFile(await managedImageFile(ctx), JSON.stringify([...refs, artifact.imageRef]), { mode: 0o600 });
214
720
  };
215
- const inspect = async () => JSON.parse((await ctx.docker(['image', 'inspect', artifact.imageRef])).out)[0];
721
+ const inspect = () => ctx.docker.inspectImage(artifact.imageRef);
216
722
  try {
217
723
  const image = await inspect();
218
- if ((image?.Id === artifact.imageId || (identities[artifact.imageRef]?.daemonId === image?.Id && identities[artifact.imageRef]?.artifactId === artifact.imageId && identities[artifact.imageRef]?.sha256 === artifact.sha256)) && `${image.Os}/${image.Architecture}` === artifact.platform) {
724
+ if (image &&
725
+ (image?.Id === artifact.imageId ||
726
+ (identities[artifact.imageRef]?.daemonId === image?.Id &&
727
+ identities[artifact.imageRef]?.artifactId === artifact.imageId &&
728
+ identities[artifact.imageRef]?.sha256 === artifact.sha256)) &&
729
+ `${image.Os}/${image.Architecture}` === artifact.platform) {
219
730
  await remember(image.Id);
220
731
  return;
221
732
  }
222
733
  }
223
734
  catch { }
224
- const host = JSON.parse((await ctx.docker(['info', '--format', '{{json .}}'])).out);
225
- const arch = { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] || host.Architecture;
226
- if (host.OSType && host.Architecture && `${host.OSType}/${arch}` !== artifact.platform)
735
+ const host = await ctx.docker.info();
736
+ const arch = host.Architecture
737
+ ? { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] ||
738
+ host.Architecture
739
+ : undefined;
740
+ if (host.OSType && arch && `${host.OSType}/${arch}` !== artifact.platform)
227
741
  throw new Error(`image platform ${artifact.platform} does not match node ${host.OSType}/${arch}; build for the target platform`);
228
742
  const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
229
743
  const file = path.join(downloadDir, 'image.tar.gz');
@@ -235,7 +749,7 @@ export async function ensureArtifact(ctx, artifact) {
235
749
  const archive = await validateImageArchive(file, artifact.imageRef);
236
750
  if (archive.imageId !== artifact.imageId || archive.platform !== artifact.platform)
237
751
  throw new Error('downloaded archive image metadata differs from desired artifact');
238
- await ctx.docker(['load', '-i', file]);
752
+ await ctx.docker.loadImage(createReadStream(file));
239
753
  const image = await inspect();
240
754
  if (!image?.Id || `${image.Os}/${image.Architecture}` !== artifact.platform)
241
755
  throw new Error('loaded image identity or platform does not match desired artifact');
@@ -256,12 +770,11 @@ export async function pruneManagedImages(ctx, retained) {
256
770
  continue;
257
771
  }
258
772
  try {
259
- const used = (await ctx.docker(['ps', '-aq', '--filter', `ancestor=${ref}`])).out.trim();
260
- if (used) {
773
+ if ((await ctx.docker.listContainers({ ancestor: ref })).length) {
261
774
  remaining.push(ref);
262
775
  continue;
263
776
  }
264
- await ctx.docker(['image', 'rm', ref]);
777
+ await ctx.docker.removeImage(ref);
265
778
  }
266
779
  catch {
267
780
  remaining.push(ref);
@@ -269,83 +782,161 @@ export async function pruneManagedImages(ctx, retained) {
269
782
  }
270
783
  await fs.writeFile(await managedImageFile(ctx), JSON.stringify(remaining), { mode: 0o600 });
271
784
  }
272
- export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}) {
785
+ /**
786
+ * The IP that published ports without a host IP listen on: `127.0.0.1` when the node has no
787
+ * address, the address itself when it is an IP, otherwise what it resolves to on this node.
788
+ */
789
+ export async function resolveBindAddress(address, lookup = (host) => dns.lookup(host)) {
790
+ if (!address)
791
+ return '127.0.0.1';
792
+ const bare = address.replace(/^\[|\]$/g, '');
793
+ if (net.isIP(bare))
794
+ return bare;
795
+ try {
796
+ return (await lookup(bare)).address;
797
+ }
798
+ catch (e) {
799
+ throw new Error(`cannot resolve node address ${address} to bind published ports: ${errMessage(e)}`, { cause: e });
800
+ }
801
+ }
802
+ // Last public-port warning per project, so the event is sent when it changes rather than on every poll.
803
+ const publicPortWarnings = new Map();
804
+ export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }) {
273
805
  const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
274
806
  await ensureDir(dir);
275
- const plannedCompose = composeForNode(desiredState, ctx.nodeName, {});
276
- const serviceNames = Object.keys(plannedCompose.services || {});
807
+ const serviceNames = Object.keys(containerSpecs(desiredState, ctx.nodeName).specs);
277
808
  if (!serviceNames.length) {
278
- if (ctx.apply)
279
- await retireAll(ctx, dir, desiredState).catch(() => { });
280
- return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'skipped', services: [], serviceStates: {} };
809
+ if (ctx.apply) {
810
+ await sweepOrphans(ctx.docker, desiredState.project, new Set()).catch(() => { });
811
+ await clearRolloutState(dir).catch(() => { });
812
+ }
813
+ return {
814
+ project: desiredState.project,
815
+ observedVersion: desiredState.version,
816
+ ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}),
817
+ health: 'skipped',
818
+ services: [],
819
+ serviceStates: {},
820
+ };
281
821
  }
282
- const serviceStates = Object.fromEntries(serviceNames.map(service => [service, { rolloutState: 'pending', image: desiredState.services[service]?.image }]));
283
- const emit = (service, rolloutState, message = rolloutState, level = 'info') => api(ctx, '/agent/v1/events', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: desiredState.project, level, type: `service.${rolloutState}`, message, metadata: { service, version: desiredState.version, node: ctx.nodeName } }) }).catch(() => { });
822
+ const serviceStates = Object.fromEntries(serviceNames.map((service) => [
823
+ service,
824
+ { rolloutState: 'pending', image: desiredState.services[service]?.image },
825
+ ]));
826
+ const emit = (service, rolloutState, message = rolloutState, level = 'info') => api(ctx, '/agent/v1/events', {
827
+ method: 'POST',
828
+ headers: { 'content-type': 'application/json' },
829
+ body: JSON.stringify({
830
+ projectId: desiredState.project,
831
+ level,
832
+ type: `service.${rolloutState}`,
833
+ message,
834
+ metadata: { service, version: desiredState.version, node: ctx.nodeName },
835
+ }),
836
+ }).catch(() => { });
284
837
  const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
285
838
  const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
286
839
  const db = await agentDb(ctx.stateDir);
287
- const previousRow = await db.get('SELECT version, revision, secret_versions_json FROM applied_state WHERE project = ?', desiredState.project);
288
- const previous = previousRow ? { version: previousRow.version, revision: previousRow.revision, secretVersions: JSON.parse(previousRow.secret_versions_json) } : {};
289
- const rolling = serviceNames.filter(service => isRolling(desiredState.compose.services[service]));
840
+ const previousRow = await db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
841
+ const rolling = serviceNames.filter((service) => isRolling(desiredState.compose.services[service]));
290
842
  const rollingSet = new Set(rolling);
291
- const mainServices = serviceNames.filter(service => !rollingSet.has(service));
292
- const secretChangedServices = mainServices.filter(service => JSON.stringify(previous.secretVersions?.[service] || {}) !== JSON.stringify(currentSecretVersions[service] || {}));
293
- const sameDesiredVersion = desiredState.revision ? previous.revision === desiredState.revision : previous.version === desiredState.version;
294
- // Rolling services run in their own revision projects; the main project holds only the recreated ones.
295
- const nodeCompose = composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints, rollingSet);
296
- await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
297
- await writeEnv(path.join(dir, '.env'), sec.secrets || {});
298
- const failedStatus = (error) => ({ project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'failed', services: serviceNames, serviceStates, error });
299
- // Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision,
300
- // this node's rendered configuration or the secrets change, when an apply fails (once) and when it recovers (once).
301
- const eventFingerprint = createHash('sha256').update(JSON.stringify([desiredState.revision || desiredState.version, dumpYaml(nodeCompose), mainServices.map(service => currentSecretVersions[service] || {})])).digest('hex').slice(0, 16);
302
- const knownEvents = mainServices.length ? await db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project) : undefined;
843
+ const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
844
+ // Published images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
845
+ // upstream (`mariadb:11`) recreate the container without a deploy.
846
+ const sameDesiredVersion = previousRow
847
+ ? desiredState.revision
848
+ ? previousRow.revision === desiredState.revision
849
+ : previousRow.version === desiredState.version
850
+ : false;
851
+ const loopback = new Set(ports.loopback[desiredState.project] || []);
852
+ // Resolve only when some port actually binds to the node address; a hostname nobody needs must not fail the apply.
853
+ const needsAddress = addressBoundServices(desiredState.compose, serviceNames, loopback).length > 0;
854
+ const bind = {
855
+ address: needsAddress ? await resolveBindAddress(ports.address) : '127.0.0.1',
856
+ loopback,
857
+ };
858
+ const plan = containerSpecs(desiredState, ctx.nodeName, {
859
+ secrets: sec.secrets || {},
860
+ secretVersions: sec.versions || {},
861
+ endpoints,
862
+ bind,
863
+ });
864
+ await warnPublicPorts(ctx, desiredState, serviceNames, bind);
865
+ const failedStatus = (error) => ({
866
+ project: desiredState.project,
867
+ observedVersion: desiredState.version,
868
+ ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}),
869
+ health: 'failed',
870
+ services: serviceNames,
871
+ serviceStates,
872
+ error,
873
+ });
874
+ // Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision
875
+ // or any container definition (which includes the secret versions it uses) changes, when an apply fails (once)
876
+ // and when it recovers (once).
877
+ const eventFingerprint = createHash('sha256')
878
+ .update(JSON.stringify([
879
+ desiredState.revision || desiredState.version,
880
+ mainServices.map((service) => plan.specs[service].hash),
881
+ ]))
882
+ .digest('hex')
883
+ .slice(0, 16);
884
+ const knownEvents = mainServices.length
885
+ ? await db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project)
886
+ : undefined;
303
887
  const announce = knownEvents?.fingerprint !== eventFingerprint;
304
888
  const wasFailed = !announce && knownEvents?.phase === 'failed';
305
889
  const markEvents = (phase) => db.run('INSERT INTO event_state(project, fingerprint, phase) VALUES(?,?,?) ON CONFLICT(project) DO UPDATE SET fingerprint=excluded.fingerprint, phase=excluded.phase', desiredState.project, eventFingerprint, phase);
890
+ const deps = {
891
+ waitForHealth: (container, service, dependency) => waitForHealth(ctx, desiredState.project, container, service, desiredState.services[service]?.health ?? (dependency ? { type: 'docker' } : null)),
892
+ ensureArtifact: async (service) => {
893
+ const artifact = desiredState.services[service].artifact;
894
+ if (artifact)
895
+ await ensureArtifact(ctx, artifact);
896
+ },
897
+ pullImage: (service) => ctx.docker.pullImage(plan.specs[service].body.Image),
898
+ emit,
899
+ };
306
900
  let failing;
307
901
  if (ctx.apply) {
308
902
  try {
309
- for (const service of mainServices) {
310
- serviceStates[service].rolloutState = 'pulling';
311
- if (announce)
312
- await emit(service, 'pulling');
313
- }
314
- for (const service of mainServices) {
315
- const artifact = desiredState.services[service].artifact;
316
- if (artifact)
317
- await ensureArtifact(ctx, artifact);
318
- }
319
- const pullServices = mainServices.filter(service => !desiredState.services[service].artifact);
320
- if (pullServices.length)
321
- await ctx.docker(['compose', '-p', desiredState.project, 'pull', ...(pullServices.length === mainServices.length ? [] : pullServices)], { cwd: dir });
322
- for (const service of mainServices) {
323
- serviceStates[service].rolloutState = 'starting';
324
- if (announce)
325
- await emit(service, 'starting');
326
- }
327
- if (mainServices.length) {
328
- // Services that just became rolling still run here until their replacement takes over; keep them.
329
- const keepLegacy = rolling.length > 0 && await hasLegacyContainers(ctx, desiredState.project, rolling);
330
- await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', ...(keepLegacy ? [] : ['--remove-orphans'])], { cwd: dir });
331
- }
332
- if (sameDesiredVersion && secretChangedServices.length) {
333
- for (const service of secretChangedServices)
334
- await emit(service, 'secret_recreate', 'secret version changed; recreating service');
335
- await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--no-deps', '--force-recreate', ...secretChangedServices], { cwd: dir });
336
- }
337
- for (const service of mainServices) {
338
- serviceStates[service].rolloutState = 'health_checking';
339
- if (announce)
340
- await emit(service, 'health_checking');
341
- failing = service;
342
- await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
343
- failing = undefined;
344
- serviceStates[service].rolloutState = 'serving';
345
- serviceStates[service].healthyAt = now();
346
- if (announce || wasFailed)
347
- await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
348
- }
903
+ await ensureProjectResources(ctx.docker, desiredState.project, plan);
904
+ await sweepOrphans(ctx.docker, desiredState.project, new Set(serviceNames));
905
+ if (mainServices.length)
906
+ await reconcileRecreate(ctx.docker, {
907
+ desired: desiredState,
908
+ dir,
909
+ plan,
910
+ nodeName: ctx.nodeName,
911
+ deps,
912
+ services: mainServices,
913
+ pull: !sameDesiredVersion,
914
+ }, {
915
+ pulling: async (service) => {
916
+ serviceStates[service].rolloutState = 'pulling';
917
+ if (announce)
918
+ await emit(service, 'pulling');
919
+ },
920
+ starting: async (service) => {
921
+ serviceStates[service].rolloutState = 'starting';
922
+ if (announce)
923
+ await emit(service, 'starting');
924
+ },
925
+ checking: async (service) => {
926
+ serviceStates[service].rolloutState = 'health_checking';
927
+ if (announce)
928
+ await emit(service, 'health_checking');
929
+ },
930
+ serving: async (service) => {
931
+ serviceStates[service].rolloutState = 'serving';
932
+ serviceStates[service].healthyAt = now();
933
+ if (announce || wasFailed)
934
+ await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
935
+ },
936
+ failing: (service) => {
937
+ failing = service;
938
+ },
939
+ });
349
940
  if (mainServices.length && (announce || wasFailed))
350
941
  await markEvents('serving');
351
942
  }
@@ -362,15 +953,14 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
362
953
  return failedStatus(errMessage(e));
363
954
  }
364
955
  if (rolling.length) {
365
- const rolled = await reconcileRolling(ctx, {
366
- desired: desiredState, dir, services: rolling, secretVersions: currentSecretVersions,
367
- full: composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints),
368
- deps: {
369
- waitForHealth: (project, service, composeFile) => waitForHealth(ctx, project, service, desiredState.services[service]?.health, dir, composeFile),
370
- ensureArtifact: async (service) => { const artifact = desiredState.services[service].artifact; if (artifact)
371
- await ensureArtifact(ctx, artifact); },
372
- emit
373
- }
956
+ const rolled = await reconcileRolling(ctx.docker, {
957
+ desired: desiredState,
958
+ dir,
959
+ services: rolling,
960
+ plan,
961
+ nodeName: ctx.nodeName,
962
+ rolloutPorts: ctx.rolloutPorts,
963
+ deps,
374
964
  });
375
965
  Object.assign(serviceStates, rolled.serviceStates);
376
966
  const errors = Object.entries(rolled.errors);
@@ -378,7 +968,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
378
968
  return failedStatus(errors.map(([service, error]) => `${service}: ${error}`).join('; '));
379
969
  }
380
970
  else
381
- await retireAll(ctx, dir, desiredState);
971
+ await clearRolloutState(dir);
382
972
  }
383
973
  else {
384
974
  for (const service of serviceNames)
@@ -388,14 +978,51 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
388
978
  VALUES(?,?,?,?,?,?)
389
979
  ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
390
980
  service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, desiredState.revision || null, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
391
- return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'serving', services: serviceNames, serviceStates };
981
+ return {
982
+ project: desiredState.project,
983
+ observedVersion: desiredState.version,
984
+ ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}),
985
+ health: 'serving',
986
+ services: serviceNames,
987
+ serviceStates,
988
+ };
989
+ }
990
+ /** Emits a `ports.public` warning when published ports of this project are bound to a public node address. */
991
+ async function warnPublicPorts(ctx, desiredState, serviceNames, bind) {
992
+ const key = `${ctx.stateDir}\0${desiredState.project}`;
993
+ const exposed = isPublicAddress(bind.address)
994
+ ? addressBoundServices(desiredState.compose, serviceNames, bind.loopback)
995
+ : [];
996
+ const message = exposed.length
997
+ ? `published ports of ${exposed.join(', ')} listen on public address ${bind.address}; enable the mesh, give the node a private address, or write the host IP in ports to make the exposure explicit`
998
+ : '';
999
+ if ((publicPortWarnings.get(key) ?? '') === message)
1000
+ return;
1001
+ publicPortWarnings.set(key, message);
1002
+ if (!message)
1003
+ return;
1004
+ await api(ctx, '/agent/v1/events', {
1005
+ method: 'POST',
1006
+ headers: { 'content-type': 'application/json' },
1007
+ body: JSON.stringify({
1008
+ projectId: desiredState.project,
1009
+ level: 'warn',
1010
+ type: 'ports.public',
1011
+ message,
1012
+ metadata: { services: exposed, address: bind.address, node: ctx.nodeName },
1013
+ }),
1014
+ }).catch(() => { });
392
1015
  }
393
1016
  /** Reports this node's WireGuard public key when control has not seen the current one yet. */
394
1017
  export async function reportWgKey(ctx) {
395
1018
  const { publicKey } = await ensureWgKeys(ctx.stateDir);
396
- if (await readReportedKey(ctx.stateDir) === publicKey)
1019
+ if ((await readReportedKey(ctx.stateDir)) === publicKey)
397
1020
  return;
398
- await api(ctx, '/agent/v1/wireguard/key', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ publicKey }) });
1021
+ await api(ctx, '/agent/v1/wireguard/key', {
1022
+ method: 'POST',
1023
+ headers: { 'content-type': 'application/json' },
1024
+ body: JSON.stringify({ publicKey }),
1025
+ });
399
1026
  await writeReportedKey(ctx.stateDir, publicKey);
400
1027
  }
401
1028
  let lastNetworkError = '';
@@ -423,10 +1050,16 @@ export async function pollOnce(ctx = defaultContext()) {
423
1050
  const projects = [];
424
1051
  for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
425
1052
  try {
426
- projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project]));
1053
+ projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project], work.ports));
427
1054
  }
428
1055
  catch (e) {
429
- projects.push({ project: desired.project, observedVersion: desired.version, ...(desired.revision ? { observedRevision: desired.revision } : {}), health: 'failed', error: errMessage(e) });
1056
+ projects.push({
1057
+ project: desired.project,
1058
+ observedVersion: desired.version,
1059
+ ...(desired.revision ? { observedRevision: desired.revision } : {}),
1060
+ health: 'failed',
1061
+ error: errMessage(e),
1062
+ });
430
1063
  }
431
1064
  }
432
1065
  let ingress;
@@ -443,15 +1076,23 @@ export async function pollOnce(ctx = defaultContext()) {
443
1076
  commandResults.push(await executeCommand(command, ctx));
444
1077
  }
445
1078
  catch (e) {
446
- commandResults.push({ commandId: command.id, state: 'failed', result: { error: errMessage(e) } });
1079
+ commandResults.push({
1080
+ commandId: command.id,
1081
+ state: 'failed',
1082
+ result: { error: errMessage(e) },
1083
+ });
447
1084
  }
448
1085
  }
449
1086
  const report = { node: ctx.nodeName, projects, commandResults };
450
1087
  if (ingress)
451
1088
  report.ingress = ingress;
452
- if (work.retainedImages && projects.every(project => project.health !== 'failed'))
1089
+ if (work.retainedImages && projects.every((project) => project.health !== 'failed'))
453
1090
  await pruneManagedImages(ctx, work.retainedImages);
454
- await api(ctx, '/agent/v1/status', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report) });
1091
+ await api(ctx, '/agent/v1/status', {
1092
+ method: 'POST',
1093
+ headers: { 'content-type': 'application/json' },
1094
+ body: JSON.stringify(report),
1095
+ });
455
1096
  return projects;
456
1097
  }
457
1098
  const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
@@ -483,11 +1124,13 @@ async function readJoinKey() {
483
1124
  }
484
1125
  function dockerLogin(registry, user, password) {
485
1126
  return new Promise((resolve, reject) => {
486
- const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], { stdio: ['pipe', 'ignore', 'pipe'] });
1127
+ const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], {
1128
+ stdio: ['pipe', 'ignore', 'pipe'],
1129
+ });
487
1130
  let err = '';
488
- p.stderr.on('data', d => err += d);
1131
+ p.stderr.on('data', (d) => (err += d));
489
1132
  p.on('error', reject);
490
- p.on('close', code => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
1133
+ p.on('close', (code) => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
491
1134
  p.stdin.write(password);
492
1135
  p.stdin.end();
493
1136
  });
@@ -502,26 +1145,48 @@ async function enroll(joinKey) {
502
1145
  }
503
1146
  if (!decoded.enrollToken)
504
1147
  throw new Error('join key is missing enrollToken');
505
- const controlUrl = (!CONTROL_URL_FROM_ENV && decoded.controlUrl) ? decoded.controlUrl : CONTROL_URL;
1148
+ const controlUrl = !CONTROL_URL_FROM_ENV && decoded.controlUrl ? decoded.controlUrl : CONTROL_URL;
506
1149
  // node's fetch reads NODE_EXTRA_CA_CERTS only at startup, so apply an embedded CA by
507
1150
  // persisting it and re-execing ourselves once with the env var set.
508
1151
  if (decoded.ca && !process.env.NODE_EXTRA_CA_CERTS) {
509
1152
  await ensureDir(STATE_DIR);
510
1153
  const caPath = path.join(STATE_DIR, 'control-ca.pem');
511
1154
  await fs.writeFile(caPath, decoded.ca);
512
- const r = spawnSync(process.execPath, process.argv.slice(1), { stdio: 'inherit', env: { ...process.env, NODE_EXTRA_CA_CERTS: caPath } });
1155
+ const r = spawnSync(process.execPath, process.argv.slice(1), {
1156
+ stdio: 'inherit',
1157
+ env: { ...process.env, NODE_EXTRA_CA_CERTS: caPath },
1158
+ });
513
1159
  process.exit(r.status ?? 1);
514
1160
  }
515
1161
  console.log(`enrolling with ${controlUrl} as ${NODE_NAME}`);
516
1162
  const wgPublicKey = MESH ? (await ensureWgKeys(STATE_DIR)).publicKey : undefined;
517
1163
  const res = await fetch(`${controlUrl}/agent/v1/enroll`, {
518
- method: 'POST', headers: { 'content-type': 'application/json' },
519
- body: JSON.stringify({ enrollToken: decoded.enrollToken, name: NODE_NAME, address: process.env.TUG_NODE_ADDRESS, publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS, wgPublicKey })
1164
+ method: 'POST',
1165
+ headers: { 'content-type': 'application/json' },
1166
+ body: JSON.stringify({
1167
+ enrollToken: decoded.enrollToken,
1168
+ name: NODE_NAME,
1169
+ address: process.env.TUG_NODE_ADDRESS,
1170
+ publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS,
1171
+ wgPublicKey,
1172
+ labels: decoded.labels ||
1173
+ (process.env.TUG_LABELS
1174
+ ? process.env.TUG_LABELS.split(',')
1175
+ .map((s) => s.trim())
1176
+ .filter(Boolean)
1177
+ : undefined),
1178
+ }),
520
1179
  });
521
1180
  if (!res.ok)
522
1181
  throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
523
- const out = await res.json();
524
- const creds = { agentToken: out.agentToken, nodeName: out.node?.name || NODE_NAME, controlUrl, registry: out.registry || null, registryToken: out.registryToken || null };
1182
+ const out = (await res.json());
1183
+ const creds = {
1184
+ agentToken: out.agentToken,
1185
+ nodeName: out.node?.name || NODE_NAME,
1186
+ controlUrl,
1187
+ registry: out.registry || null,
1188
+ registryToken: out.registryToken || null,
1189
+ };
525
1190
  await saveCreds(creds);
526
1191
  if (wgPublicKey)
527
1192
  await writeReportedKey(STATE_DIR, wgPublicKey);
@@ -531,7 +1196,9 @@ async function enroll(joinKey) {
531
1196
  // Establish durable credentials before polling: explicit env token (SSH/push install) wins,
532
1197
  // then credentials persisted on the state volume, then a one-time enroll via the join key.
533
1198
  export async function loadOrEnroll() {
534
- let creds = TOKEN ? { agentToken: TOKEN, nodeName: NODE_NAME, controlUrl: CONTROL_URL } : await loadCreds();
1199
+ let creds = TOKEN
1200
+ ? { agentToken: TOKEN, nodeName: NODE_NAME, controlUrl: CONTROL_URL }
1201
+ : await loadCreds();
535
1202
  if (!creds) {
536
1203
  const joinKey = await readJoinKey();
537
1204
  if (!joinKey)
@@ -555,14 +1222,28 @@ export async function loadOrEnroll() {
555
1222
  export async function main() {
556
1223
  await loadOrEnroll();
557
1224
  console.log(`tug-agent node=${NODE_NAME} control=${CONTROL_URL} apply=${APPLY}`);
1225
+ const ctx = defaultContext();
1226
+ const wake = new AgentWakeChannel(ctx, undefined, {
1227
+ onLive: (frame) => live.handle(frame),
1228
+ onDisconnect: () => live.dispose(),
1229
+ });
1230
+ const live = new AgentLiveOperations(ctx, (frame) => {
1231
+ wake.send(frame);
1232
+ });
558
1233
  for (;;) {
559
1234
  try {
560
- await pollOnce();
1235
+ await pollOnce(ctx);
561
1236
  }
562
1237
  catch (e) {
563
1238
  console.error(errMessage(e));
564
1239
  }
565
- await new Promise(r => setTimeout(r, INTERVAL));
1240
+ try {
1241
+ await wake.wait(INTERVAL);
1242
+ }
1243
+ catch (e) {
1244
+ console.error(`agent wake wait failed: ${errMessage(e)}`);
1245
+ await new Promise((r) => setTimeout(r, INTERVAL));
1246
+ }
566
1247
  }
567
1248
  }
568
1249
  if (import.meta.url === `file://${process.argv[1]}`)