@wpelevator/tug 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -4
- package/dist/agent.d.ts +66 -6
- package/dist/agent.js +851 -170
- package/dist/archive.js +9 -3
- package/dist/artifacts.js +97 -34
- package/dist/build.d.ts +1 -1
- package/dist/build.js +17 -8
- package/dist/cli-build.js +34 -8
- package/dist/cli-config.js +16 -4
- package/dist/cli-install.d.ts +10 -0
- package/dist/cli-install.js +38 -4
- package/dist/cli-live-transport.d.ts +3 -0
- package/dist/cli-live-transport.js +230 -0
- package/dist/cli-live.d.ts +77 -0
- package/dist/cli-live.js +249 -0
- package/dist/cli.js +408 -90
- package/dist/compose.d.ts +35 -9
- package/dist/compose.js +240 -132
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +5 -3
- package/dist/control.js +1047 -146
- package/dist/db.d.ts +1 -0
- package/dist/db.js +7 -1
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -0
- package/dist/ingress.d.ts +16 -8
- package/dist/ingress.js +84 -29
- package/dist/live.d.ts +83 -0
- package/dist/live.js +276 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/templates/install-docker.sh +12 -7
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +35 -9
- package/dist/util.d.ts +7 -0
- package/dist/util.js +73 -16
- package/package.json +8 -4
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -319
package/dist/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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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 {
|
|
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 {
|
|
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) {
|
|
41
|
-
|
|
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}`, {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
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 }, () => {
|
|
65
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
90
|
-
const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, {
|
|
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'
|
|
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
|
-
|
|
122
|
-
['docker.info',
|
|
123
|
-
[
|
|
124
|
-
|
|
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
|
-
|
|
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([
|
|
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({
|
|
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
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 {
|
|
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', {
|
|
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 {
|
|
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)
|
|
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] = {
|
|
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 =
|
|
721
|
+
const inspect = () => ctx.docker.inspectImage(artifact.imageRef);
|
|
216
722
|
try {
|
|
217
723
|
const image = await inspect();
|
|
218
|
-
if (
|
|
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 =
|
|
225
|
-
const arch =
|
|
226
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
280
|
-
|
|
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 => [
|
|
283
|
-
|
|
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
|
|
288
|
-
const
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
const
|
|
302
|
-
const
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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 {
|
|
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', {
|
|
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({
|
|
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({
|
|
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', {
|
|
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'], {
|
|
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 =
|
|
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), {
|
|
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',
|
|
519
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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]}`)
|