@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/control.js CHANGED
@@ -9,17 +9,355 @@ import sqlite3 from 'sqlite3';
9
9
  import { open } from 'sqlite';
10
10
  import { scanArchive } from "./archive.js";
11
11
  import https from 'node:https';
12
+ import http from 'node:http';
12
13
  import path from 'node:path';
14
+ import { WebSocketServer, WebSocket } from 'ws';
13
15
  import * as tar from 'tar';
14
16
  import { openDb } from "./db.js";
15
17
  import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
16
- import { buildDesiredState, serviceSecretNames, validateCompose } from "./compose.js";
18
+ import { buildDesiredState, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
17
19
  import { buildAndPush, unpackContext, run as runCommand } from "./build.js";
20
+ import { sentinelNames } from "./normalize.js";
18
21
  import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
19
- import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint } from "./mesh.js";
22
+ import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } from "./mesh.js";
20
23
  import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
21
- import { ACTIVE_DEPLOYMENT_STATES } from "./protocol.js";
22
- import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken } from "./tokens.js";
24
+ import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, } from "./protocol.js";
25
+ import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken, } from "./tokens.js";
26
+ class WakeHub {
27
+ cursors = new Map();
28
+ projects = new Map();
29
+ streams = new Map();
30
+ waiters = new Map();
31
+ cursor(nodeId) {
32
+ return this.cursors.get(nodeId) ?? 0;
33
+ }
34
+ connect(nodeId, ws) {
35
+ const previous = this.streams.get(nodeId);
36
+ if (previous && previous !== ws)
37
+ previous.close(1000, 'replaced by a newer agent connection');
38
+ this.streams.set(nodeId, ws);
39
+ const ready = { type: 'ready', nodeId, cursor: this.cursor(nodeId) };
40
+ ws.send(JSON.stringify(ready));
41
+ ws.once('close', () => {
42
+ if (this.streams.get(nodeId) === ws)
43
+ this.streams.delete(nodeId);
44
+ });
45
+ }
46
+ wake(nodeId, project) {
47
+ const cursor = this.cursor(nodeId) + 1;
48
+ this.cursors.set(nodeId, cursor);
49
+ this.projects.set(nodeId, project);
50
+ const ws = this.streams.get(nodeId);
51
+ if (ws?.readyState === WebSocket.OPEN) {
52
+ const message = {
53
+ type: 'work-available',
54
+ cursor,
55
+ ...(project ? { project } : {}),
56
+ };
57
+ ws.send(JSON.stringify(message));
58
+ }
59
+ for (const resolve of this.waiters.get(nodeId) ?? [])
60
+ resolve();
61
+ }
62
+ async wait(nodeId, after, timeoutMs) {
63
+ const response = () => {
64
+ const cursor = this.cursor(nodeId);
65
+ const project = this.projects.get(nodeId);
66
+ return {
67
+ type: cursor > after ? 'work-available' : 'timeout',
68
+ cursor,
69
+ ...(project ? { project } : {}),
70
+ };
71
+ };
72
+ if (this.cursor(nodeId) > after)
73
+ return response();
74
+ await new Promise((resolve) => {
75
+ const waiters = this.waiters.get(nodeId) ?? new Set();
76
+ const done = () => {
77
+ clearTimeout(timer);
78
+ waiters.delete(done);
79
+ if (!waiters.size)
80
+ this.waiters.delete(nodeId);
81
+ resolve();
82
+ };
83
+ waiters.add(done);
84
+ this.waiters.set(nodeId, waiters);
85
+ const timer = setTimeout(done, timeoutMs);
86
+ });
87
+ return response();
88
+ }
89
+ }
90
+ const objectFrame = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
91
+ const frameKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
92
+ const liveDimension = (value) => Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65535;
93
+ const liveBase64 = (value) => typeof value === 'string' &&
94
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
95
+ function parseLiveRequest(value) {
96
+ if (!objectFrame(value) ||
97
+ typeof value.project !== 'string' ||
98
+ !PROJECT_NAME_PATTERN.test(value.project) ||
99
+ value.project.length > 128 ||
100
+ (value.node !== undefined &&
101
+ (typeof value.node !== 'string' ||
102
+ !PROJECT_NAME_PATTERN.test(value.node) ||
103
+ value.node.length > 128)))
104
+ throw new Error('invalid live request project or node');
105
+ const common = ['operation', 'project', 'node'];
106
+ if (value.operation === 'ps' && frameKeys(value, common))
107
+ return value;
108
+ if (typeof value.service !== 'string' ||
109
+ !PROJECT_NAME_PATTERN.test(value.service) ||
110
+ value.service.length > 128)
111
+ throw new Error('invalid live request service');
112
+ if (value.operation === 'inspect' && frameKeys(value, [...common, 'service']))
113
+ return value;
114
+ if (value.operation === 'logs' &&
115
+ frameKeys(value, [...common, 'service', 'follow', 'tail']) &&
116
+ value.follow === true &&
117
+ Number.isSafeInteger(value.tail) &&
118
+ Number(value.tail) >= 0 &&
119
+ Number(value.tail) <= 10_000)
120
+ return value;
121
+ if (value.operation === 'exec' &&
122
+ frameKeys(value, [...common, 'service', 'command', 'interactive', 'tty', 'columns', 'rows']) &&
123
+ Array.isArray(value.command) &&
124
+ value.command.length > 0 &&
125
+ value.command.length <= 256 &&
126
+ typeof value.command[0] === 'string' &&
127
+ value.command[0].length > 0 &&
128
+ value.command.every((part) => typeof part === 'string' && !part.includes('\0') && part.length <= 32768) &&
129
+ typeof value.interactive === 'boolean' &&
130
+ typeof value.tty === 'boolean' &&
131
+ (value.columns === undefined || liveDimension(value.columns)) &&
132
+ (value.rows === undefined || liveDimension(value.rows)))
133
+ return value;
134
+ throw new Error('invalid or unsupported live request');
135
+ }
136
+ /** Ephemeral sessions are bound to socket identities; reconnecting never replays work. */
137
+ class LiveHub {
138
+ agents = new Map();
139
+ sessions = new Map();
140
+ retired = new WeakMap();
141
+ startTimeoutMs;
142
+ resolve;
143
+ constructor(resolve, startTimeoutMs = LIVE_START_TIMEOUT_MS) {
144
+ this.resolve = resolve;
145
+ this.startTimeoutMs = startTimeoutMs;
146
+ }
147
+ send(ws, frame) {
148
+ if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > LIVE_MAX_BUFFERED_BYTES)
149
+ return false;
150
+ const data = JSON.stringify(frame);
151
+ if (Buffer.byteLength(data) + ws.bufferedAmount > LIVE_MAX_BUFFERED_BYTES)
152
+ return false;
153
+ try {
154
+ ws.send(data);
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ finish(session, frame, cancel = true) {
162
+ if (!this.sessions.delete(session.id))
163
+ return;
164
+ clearTimeout(session.timer);
165
+ const retired = this.retired.get(session.agent) ?? new Set();
166
+ retired.add(session.id);
167
+ if (retired.size > 1024)
168
+ retired.delete(retired.values().next().value);
169
+ this.retired.set(session.agent, retired);
170
+ // If cancellation cannot enter a congested agent socket, disconnect it so its local engine disposes every operation.
171
+ if (cancel && !this.send(session.agent, { v: 1, type: 'live.cancel', sessionId: session.id }))
172
+ session.agent.terminate();
173
+ if (frame)
174
+ this.send(session.operator, frame);
175
+ session.operator.close(1000, 'live operation finished');
176
+ }
177
+ fail(session, message) {
178
+ this.finish(session, { v: 1, type: 'live.error', sessionId: session.id, message });
179
+ }
180
+ agent(nodeId, ws) {
181
+ const previous = this.agents.get(nodeId);
182
+ if (previous && previous !== ws)
183
+ for (const session of this.sessions.values())
184
+ if (session.agent === previous)
185
+ this.fail(session, 'agent connection replaced');
186
+ this.agents.set(nodeId, ws);
187
+ ws.on('error', () => { });
188
+ ws.on('close', () => {
189
+ if (this.agents.get(nodeId) === ws)
190
+ this.agents.delete(nodeId);
191
+ for (const session of this.sessions.values())
192
+ if (session.agent === ws)
193
+ this.fail(session, 'agent disconnected');
194
+ });
195
+ ws.on('message', (data, binary) => {
196
+ try {
197
+ if (binary || Buffer.byteLength(data.toString()) > LIVE_MAX_FRAME_BYTES)
198
+ throw new Error('invalid frame');
199
+ const frame = JSON.parse(data.toString());
200
+ if (!objectFrame(frame) || frame.v !== 1 || typeof frame.sessionId !== 'string')
201
+ throw new Error('invalid frame');
202
+ const session = this.sessions.get(frame.sessionId);
203
+ if (!session && this.retired.get(ws)?.has(frame.sessionId))
204
+ return;
205
+ if (!session || session.agent !== ws)
206
+ throw new Error('foreign or unknown session');
207
+ const keys = ['v', 'type', 'sessionId'];
208
+ if (frame.type === 'live.started' && frameKeys(frame, keys) && !session.started) {
209
+ session.started = true;
210
+ clearTimeout(session.timer);
211
+ if (!this.send(session.operator, {
212
+ v: 1,
213
+ type: 'live.started',
214
+ sessionId: session.id,
215
+ node: session.node,
216
+ }))
217
+ this.fail(session, 'operator buffer limit exceeded');
218
+ }
219
+ else if (frame.type === 'live.output' &&
220
+ frameKeys(frame, [...keys, 'stream', 'data']) &&
221
+ (frame.stream === 'stdout' || frame.stream === 'stderr') &&
222
+ liveBase64(frame.data)) {
223
+ if (!this.send(session.operator, frame))
224
+ this.fail(session, 'operator buffer limit exceeded');
225
+ }
226
+ else if (frame.type === 'live.result' &&
227
+ frameKeys(frame, [...keys, 'result']) &&
228
+ Object.hasOwn(frame, 'result') &&
229
+ ['ps', 'inspect'].includes(session.request.operation)) {
230
+ if (!this.send(session.operator, {
231
+ v: 1,
232
+ type: 'live.result',
233
+ sessionId: session.id,
234
+ value: frame.result,
235
+ }))
236
+ this.fail(session, 'operator buffer limit exceeded');
237
+ }
238
+ else if (frame.type === 'live.exit' &&
239
+ frameKeys(frame, [...keys, 'code', 'signal', 'cancelled', 'error']) &&
240
+ (frame.code === null ||
241
+ (Number.isInteger(frame.code) &&
242
+ Number(frame.code) >= 0 &&
243
+ Number(frame.code) <= 255)) &&
244
+ (frame.signal === null || typeof frame.signal === 'string') &&
245
+ typeof frame.cancelled === 'boolean' &&
246
+ (frame.error === undefined || typeof frame.error === 'string')) {
247
+ if (frame.error)
248
+ this.fail(session, frame.error);
249
+ else
250
+ this.finish(session, {
251
+ v: 1,
252
+ type: 'live.exit',
253
+ sessionId: session.id,
254
+ code: typeof frame.code === 'number' ? frame.code : frame.cancelled ? 130 : 1,
255
+ }, false);
256
+ }
257
+ else
258
+ throw new Error('unsupported agent frame');
259
+ }
260
+ catch {
261
+ ws.close(1008, 'invalid live session frame');
262
+ }
263
+ });
264
+ }
265
+ operator(actor, ws) {
266
+ let opening = false;
267
+ let session;
268
+ const timer = setTimeout(() => {
269
+ if (!session)
270
+ reject('live.open timeout');
271
+ }, this.startTimeoutMs);
272
+ const reject = (message) => {
273
+ clearTimeout(timer);
274
+ if (session)
275
+ this.fail(session, message);
276
+ else {
277
+ this.send(ws, { v: 1, type: 'live.error', message });
278
+ ws.close(1008, message.slice(0, 100));
279
+ }
280
+ };
281
+ ws.on('error', () => { });
282
+ ws.on('close', () => {
283
+ clearTimeout(timer);
284
+ if (session)
285
+ this.finish(session);
286
+ });
287
+ ws.on('message', (data, binary) => {
288
+ void (async () => {
289
+ if (binary || Buffer.byteLength(data.toString()) > LIVE_MAX_FRAME_BYTES)
290
+ throw new Error('invalid or oversized live frame');
291
+ const frame = JSON.parse(data.toString());
292
+ if (!objectFrame(frame) || frame.v !== 1)
293
+ throw new Error('invalid live frame');
294
+ if (!session) {
295
+ if (opening || frame.type !== 'live.open' || !frameKeys(frame, ['v', 'type', 'request']))
296
+ throw new Error('first frame must be live.open');
297
+ opening = true;
298
+ const request = parseLiveRequest(frame.request);
299
+ const node = await this.resolve(actor, request);
300
+ if (ws.readyState !== WebSocket.OPEN)
301
+ return;
302
+ const agent = this.agents.get(node.id);
303
+ if (!agent || agent.readyState !== WebSocket.OPEN)
304
+ throw new Error('agent is not connected');
305
+ if (this.sessions.size >= LIVE_MAX_SESSIONS)
306
+ throw new Error('too many live sessions');
307
+ clearTimeout(timer);
308
+ const sessionId = id('live');
309
+ session = {
310
+ id: sessionId,
311
+ operator: ws,
312
+ agent,
313
+ node: node.name,
314
+ request,
315
+ started: false,
316
+ timer: setTimeout(() => {
317
+ if (session)
318
+ this.fail(session, 'agent live startup timeout');
319
+ }, this.startTimeoutMs),
320
+ };
321
+ this.sessions.set(sessionId, session);
322
+ if (!this.send(agent, { v: 1, type: 'live.open', sessionId, request }))
323
+ this.fail(session, 'agent buffer limit exceeded');
324
+ return;
325
+ }
326
+ if (!this.sessions.has(session.id))
327
+ return;
328
+ if (!frameKeys(frame, [
329
+ 'v',
330
+ 'type',
331
+ ...(frame.type === 'live.stdin'
332
+ ? ['data']
333
+ : frame.type === 'live.resize'
334
+ ? ['columns', 'rows']
335
+ : []),
336
+ ]))
337
+ throw new Error('invalid live input');
338
+ if (frame.type === 'live.cancel') {
339
+ this.finish(session, { v: 1, type: 'live.exit', sessionId: session.id, code: 130 });
340
+ return;
341
+ }
342
+ if (session.request.operation !== 'exec')
343
+ throw new Error('operation does not accept input');
344
+ if (frame.type === 'live.stdin'
345
+ ? !session.request.interactive || !liveBase64(frame.data)
346
+ : frame.type === 'live.stdin-end'
347
+ ? !session.request.interactive
348
+ : frame.type === 'live.resize'
349
+ ? !session.request.tty ||
350
+ !liveDimension(frame.columns) ||
351
+ !liveDimension(frame.rows)
352
+ : true)
353
+ throw new Error('invalid live input');
354
+ if (!this.send(session.agent, { ...frame, sessionId: session.id }))
355
+ this.fail(session, 'agent buffer limit exceeded');
356
+ })().catch((error) => reject(errMessage(error)));
357
+ });
358
+ }
359
+ }
360
+ const streamRuntimes = new WeakMap();
23
361
  const httpError = (status, message) => Object.assign(new Error(message), { status });
24
362
  const missingProject = (project) => `project ${project} has no desired state`;
25
363
  const missingService = (project, service) => `service ${service} not found in project ${project}`;
@@ -30,12 +368,14 @@ function deploymentFailureMessage(rows) {
30
368
  const flat = value.replace(/\s+/g, ' ').trim();
31
369
  return flat.length > limit ? `${flat.slice(0, limit)}...` : flat;
32
370
  };
33
- const details = rows.map(row => {
371
+ const details = rows.map((row) => {
34
372
  let status = {};
35
373
  try {
36
374
  status = JSON.parse(row.status_json);
37
375
  }
38
- catch { /* a bad report still names the node */ }
376
+ catch {
377
+ /* a bad report still names the node */
378
+ }
39
379
  const parts = [];
40
380
  if (typeof status.error === 'string' && status.error.trim())
41
381
  parts.push(oneLine(status.error));
@@ -50,15 +390,25 @@ function deploymentFailureMessage(rows) {
50
390
  }
51
391
  /** Versions of rollback deployments start with this; `tug deploy` may not use it, which is how automatic rollback tells them apart. */
52
392
  const ROLLBACK_VERSION_PREFIX = 'rollback-';
53
- const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
393
+ const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map((s) => `'${s}'`).join(',');
54
394
  const PORT = Number(process.env.PORT || 8080);
55
395
  export async function createApp(options = {}) {
56
- const images = { build: buildAndPush, save: saveImageArchive, inspect: inspectImage, validate: validateImageArchive, run: runCommand, ...options.images };
396
+ const images = {
397
+ build: buildAndPush,
398
+ save: saveImageArchive,
399
+ inspect: inspectImage,
400
+ validate: validateImageArchive,
401
+ run: runCommand,
402
+ ...options.images,
403
+ };
57
404
  const DATA_DIR = process.env.TUG_DATA_DIR || './data';
58
405
  const MAX_IMAGE_BYTES = Number(process.env.TUG_MAX_IMAGE_BYTES || 5 * 1024 ** 3);
59
406
  if (!Number.isSafeInteger(MAX_IMAGE_BYTES) || MAX_IMAGE_BYTES < 1)
60
407
  throw new Error('TUG_MAX_IMAGE_BYTES must be a positive safe integer');
61
- const upload = multer({ dest: process.env.TUG_UPLOAD_DIR || path.join(DATA_DIR, 'uploads'), limits: { fileSize: MAX_IMAGE_BYTES, files: 64, fields: 1, fieldSize: 1024 ** 2 } });
408
+ const upload = multer({
409
+ dest: process.env.TUG_UPLOAD_DIR || path.join(DATA_DIR, 'uploads'),
410
+ limits: { fileSize: MAX_IMAGE_BYTES, files: 64, fields: 1, fieldSize: 1024 ** 2 },
411
+ });
62
412
  const dbFile = path.join(DATA_DIR, 'tug.db');
63
413
  const masterKeyFile = path.join(DATA_DIR, 'master.key');
64
414
  const REGISTRY = process.env.TUG_REGISTRY?.trim() || '';
@@ -81,7 +431,7 @@ export async function createApp(options = {}) {
81
431
  async function reloadRegistry() {
82
432
  if (!REGISTRY_CONTAINER)
83
433
  return;
84
- await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch(e => console.error(`registry reload failed: ${e.message}`));
434
+ await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch((e) => console.error(`registry reload failed: ${e.message}`));
85
435
  }
86
436
  async function upsertRegistryCredential(name, password) {
87
437
  if (!REGISTRY || !REGISTRY_AUTH_PATH)
@@ -94,7 +444,10 @@ export async function createApp(options = {}) {
94
444
  existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
95
445
  }
96
446
  catch { }
97
- const lines = existing.split('\n').filter(Boolean).filter(l => !l.startsWith(`${name}:`));
447
+ const lines = existing
448
+ .split('\n')
449
+ .filter(Boolean)
450
+ .filter((l) => !l.startsWith(`${name}:`));
98
451
  lines.push(line);
99
452
  await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
100
453
  await fs.writeFile(REGISTRY_AUTH_PATH, `${lines.join('\n')}\n`);
@@ -110,13 +463,17 @@ export async function createApp(options = {}) {
110
463
  existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
111
464
  }
112
465
  catch { }
113
- const lines = existing.split('\n').filter(Boolean).filter(l => !l.startsWith(`${name}:`));
466
+ const lines = existing
467
+ .split('\n')
468
+ .filter(Boolean)
469
+ .filter((l) => !l.startsWith(`${name}:`));
114
470
  await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
115
471
  await fs.writeFile(REGISTRY_AUTH_PATH, lines.length ? `${lines.join('\n')}\n` : '');
116
472
  });
117
473
  await reloadRegistry();
118
474
  }
119
475
  const db = await (options.openDatabase || openDb)(dbFile);
476
+ const wakeHub = new WakeHub();
120
477
  // Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
121
478
  // not a whole number falls back to the default.
122
479
  const envCount = (name, fallback) => {
@@ -137,32 +494,43 @@ export async function createApp(options = {}) {
137
494
  lastEventPrune = Date.now();
138
495
  let removed = 0;
139
496
  if (EVENT_RETENTION_DAYS > 0)
140
- removed += (await db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString())).changes ?? 0;
497
+ removed +=
498
+ (await db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString())).changes ?? 0;
141
499
  if (EVENT_MAX_ROWS > 0)
142
- removed += (await db.run('DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)', EVENT_MAX_ROWS)).changes ?? 0;
500
+ removed +=
501
+ (await db.run('DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)', EVENT_MAX_ROWS)).changes ?? 0;
143
502
  if (removed)
144
503
  console.log(`removed ${removed} old events (kept ${EVENT_RETENTION_DAYS || 'any'} days, at most ${EVENT_MAX_ROWS || 'unlimited'} rows)`);
145
504
  return removed;
146
505
  }
147
- await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
506
+ await pruneEvents().catch((e) => console.error(`event pruning failed: ${errMessage(e)}`));
148
507
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
149
508
  const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
150
509
  if (!operator) {
151
510
  const t = process.env.TUG_OPERATOR_TOKEN || token('tug_op');
152
511
  await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
153
- await fs.writeFile(path.join(DATA_DIR, 'operator.token'), `${t}\n`, { mode: 0o600 }).catch(() => { });
512
+ await fs
513
+ .writeFile(path.join(DATA_DIR, 'operator.token'), `${t}\n`, { mode: 0o600 })
514
+ .catch(() => { });
154
515
  console.log(`operator token: ${t}`);
155
516
  }
156
517
  const app = express();
518
+ const authenticateAgent = async (raw) => raw
519
+ ? db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw))
520
+ : undefined;
157
521
  let restartRequired = false;
158
- app.use((_req, res, next) => restartRequired ? res.status(503).json({ error: 'control restored; restart required', restartRequired: true }) : next());
522
+ app.use((_req, res, next) => restartRequired
523
+ ? res.status(503).json({ error: 'control restored; restart required', restartRequired: true })
524
+ : next());
159
525
  app.use(express.json({ limit: '20mb' }));
160
526
  // Hold the writer for the operation lifetime, including after an HTTP disconnect.
161
527
  let writer = Promise.resolve();
162
528
  async function withWriter(operation) {
163
529
  const previous = writer;
164
530
  let release;
165
- writer = new Promise(resolve => { release = resolve; });
531
+ writer = new Promise((resolve) => {
532
+ release = resolve;
533
+ });
166
534
  await previous;
167
535
  try {
168
536
  if (restartRequired)
@@ -177,7 +545,9 @@ export async function createApp(options = {}) {
177
545
  async function withPublication(operation) {
178
546
  const previous = publication;
179
547
  let release;
180
- publication = new Promise(resolve => { release = resolve; });
548
+ publication = new Promise((resolve) => {
549
+ release = resolve;
550
+ });
181
551
  await previous;
182
552
  try {
183
553
  return await operation();
@@ -210,18 +580,31 @@ export async function createApp(options = {}) {
210
580
  route(method, url, [guard(role, resolve ?? undefined), ...handlers]);
211
581
  };
212
582
  const deploymentProject = async (req) => (await db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id))?.project_id;
213
- const commandProject = async (req) => { const row = await db.get('SELECT project_id FROM commands WHERE id=?', req.params.id); return row ? row.project_id : undefined; };
583
+ const commandProject = async (req) => {
584
+ const row = await db.get('SELECT project_id FROM commands WHERE id=?', req.params.id);
585
+ return row ? row.project_id : undefined;
586
+ };
214
587
  /** Audit trail entry for a control action; bookkeeping failures are logged, never returned to the caller. */
215
- const audit = (actor, event) => recordEvent(db, { ...event, actor: actor.name }).catch(e => console.error(`audit event failed: ${errMessage(e)}`));
216
- const projectScope = (actor, column) => actor.projects ? { where: `${column} IN (${actor.projects.map(() => '?').join(',') || "''"})`, args: actor.projects } : { where: '1=1', args: [] };
588
+ const audit = (actor, event) => recordEvent(db, { ...event, actor: actor.name }).catch((e) => console.error(`audit event failed: ${errMessage(e)}`));
589
+ const projectScope = (actor, column) => actor.projects
590
+ ? {
591
+ where: `${column} IN (${actor.projects.map(() => '?').join(',') || "''"})`,
592
+ args: actor.projects,
593
+ }
594
+ : { where: '1=1', args: [] };
217
595
  const authAgent = async (req, res, next) => {
218
596
  const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
219
- const node = raw && await db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw));
597
+ const node = await authenticateAgent(raw);
220
598
  if (!node)
221
599
  return res.status(401).json({ error: 'unauthorized' });
222
600
  req.node = node;
223
601
  next();
224
602
  };
603
+ const wakeAll = async (project) => {
604
+ for (const node of await db.all('SELECT id FROM nodes'))
605
+ wakeHub.wake(node.id, project);
606
+ };
607
+ let endpointSnapshot = '';
225
608
  async function updateDeploymentConvergence(projectId) {
226
609
  const project = await db.get('SELECT * FROM projects WHERE id=?', projectId);
227
610
  if (!project?.current_deployment_id)
@@ -237,15 +620,22 @@ export async function createApp(options = {}) {
237
620
  if (!required.size)
238
621
  return;
239
622
  const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
240
- const byNode = new Map(statuses.map(s => [s.node_id, s]));
241
- const reportsRevision = (node) => state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version;
242
- const failedNodes = [...required].filter(node => reportsRevision(node) && byNode.get(node)?.health === 'failed').map(node => byNode.get(node));
623
+ const byNode = new Map(statuses.map((s) => [s.node_id, s]));
624
+ const reportsRevision = (node) => state.revision
625
+ ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision
626
+ : byNode.get(node)?.observed_version === state.version;
627
+ const failedNodes = [...required]
628
+ .filter((node) => reportsRevision(node) && byNode.get(node)?.health === 'failed')
629
+ .map((node) => byNode.get(node));
243
630
  if (failedNodes.length) {
244
631
  const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', deploymentFailureMessage(failedNodes), now(), project.current_deployment_id, 'rolling_out');
245
632
  if (marked.changes === 1)
246
633
  scheduleAutoRollback(projectId, project.current_deployment_id, state);
247
634
  }
248
- const ok = [...required].every(node => (state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version) && ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
635
+ const ok = [...required].every((node) => (state.revision
636
+ ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision
637
+ : byNode.get(node)?.observed_version === state.version) &&
638
+ ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
249
639
  if (ok)
250
640
  await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
251
641
  }
@@ -280,7 +670,11 @@ export async function createApp(options = {}) {
280
670
  await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at,actor) VALUES(?,?,?,?,?,?,?)', depId, project, `${ROLLBACK_VERSION_PREFIX}${previousState.version}-${Date.now()}`, 'rolling_out', ts, ts, opts.actor);
281
671
  await db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, previousState.version, JSON.stringify(previousState), ts);
282
672
  await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
283
- return { deployment: await db.get('SELECT * FROM deployments WHERE id=?', depId), desiredState: previousState };
673
+ await wakeAll(project);
674
+ return {
675
+ deployment: await db.get('SELECT * FROM deployments WHERE id=?', depId),
676
+ desiredState: previousState,
677
+ };
284
678
  }
285
679
  finally {
286
680
  await releaseLock(lock);
@@ -302,17 +696,60 @@ export async function createApp(options = {}) {
302
696
  return;
303
697
  }
304
698
  try {
305
- const out = await withWriter(() => createRollback(projectId, { actor: AUTO_ROLLBACK_ACTOR, auto: true, failedDeploymentId: deploymentId }));
306
- await recordAutoRollbackEvent(projectId, out.deployment.id, 'deployment.auto_rollback', `deployment ${deploymentId} (${failed?.version}) reported failed health; rolled back to ${out.desiredState.version} as deployment ${out.deployment.id}`, { failedDeploymentId: deploymentId, rollbackDeploymentId: out.deployment.id, version: out.desiredState.version });
699
+ const out = await withWriter(() => createRollback(projectId, {
700
+ actor: AUTO_ROLLBACK_ACTOR,
701
+ auto: true,
702
+ failedDeploymentId: deploymentId,
703
+ }));
704
+ await recordAutoRollbackEvent(projectId, out.deployment.id, 'deployment.auto_rollback', `deployment ${deploymentId} (${failed?.version}) reported failed health; rolled back to ${out.desiredState.version} as deployment ${out.deployment.id}`, {
705
+ failedDeploymentId: deploymentId,
706
+ rollbackDeploymentId: out.deployment.id,
707
+ version: out.desiredState.version,
708
+ });
307
709
  }
308
710
  catch (e) {
309
711
  await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `deployment ${deploymentId} failed but automatic rollback was not possible: ${errMessage(e)}`).catch(() => { });
310
712
  }
311
- })().catch(e => console.error('auto rollback failed:', errMessage(e)));
713
+ })().catch((e) => console.error('auto rollback failed:', errMessage(e)));
312
714
  }
313
715
  async function currentDesired(projectId) {
314
716
  return db.get('SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id WHERE p.id=?', projectId);
315
717
  }
718
+ const live = new LiveHub(async (actor, request) => {
719
+ assertProject(actor, request.project);
720
+ const row = await currentDesired(request.project);
721
+ if (!row)
722
+ throw httpError(404, missingProject(request.project));
723
+ const desired = JSON.parse(row.state_json);
724
+ const nodes = await db.all('SELECT * FROM nodes');
725
+ const eligible = new Set();
726
+ const services = request.operation === 'ps'
727
+ ? Object.values(desired.services)
728
+ : [desired.services[request.service]];
729
+ if (services.some((service) => !service))
730
+ throw httpError(404, missingService(request.project, 'service' in request ? request.service : ''));
731
+ for (const service of services)
732
+ for (const node of nodes)
733
+ if (!service.placement?.nodes?.length || service.placement.nodes.includes(node.name))
734
+ eligible.add(node.name);
735
+ if (request.node && !eligible.has(request.node))
736
+ throw httpError(400, 'requested node is not placed for this operation');
737
+ if (!request.node && eligible.size !== 1)
738
+ throw httpError(400, 'operation requires --node because placement is ambiguous');
739
+ const node = nodes.find((node) => node.name === (request.node || [...eligible][0]));
740
+ if (!node)
741
+ throw httpError(404, 'no eligible node for this operation');
742
+ return node;
743
+ }, options.liveStartTimeoutMs);
744
+ streamRuntimes.set(app, {
745
+ hub: wakeHub,
746
+ authenticate: authenticateAgent,
747
+ authenticateOperator: (raw) => authenticate(db, raw),
748
+ live,
749
+ });
750
+ api('get', '/api/v1/live', null, (_req, res) => {
751
+ res.status(426).json({ error: 'WebSocket upgrade required' });
752
+ });
316
753
  async function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
317
754
  const name = `project:${project}`;
318
755
  const ts = now();
@@ -330,7 +767,11 @@ export async function createApp(options = {}) {
330
767
  err.lock = existing;
331
768
  throw err;
332
769
  }
333
- const heartbeat = setInterval(() => { void db.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner).catch(() => { }); }, Math.max(10, Math.floor(ttlMs / 3)));
770
+ const heartbeat = setInterval(() => {
771
+ void db
772
+ .run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner)
773
+ .catch(() => { });
774
+ }, Math.max(10, Math.floor(ttlMs / 3)));
334
775
  heartbeat.unref();
335
776
  return { name, owner, heartbeat };
336
777
  }
@@ -338,18 +779,76 @@ export async function createApp(options = {}) {
338
779
  if (lock?.heartbeat)
339
780
  clearInterval(lock.heartbeat);
340
781
  if (lock)
341
- await db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner).catch(() => { });
782
+ await db
783
+ .run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner)
784
+ .catch(() => { });
342
785
  }
343
- function sendError(res, e) { const err = e; res.status(restartRequired ? 503 : err.status || 500).json({ error: err.message, lock: err.lock || undefined }); }
786
+ function sendError(res, e) {
787
+ const err = e;
788
+ res
789
+ .status(restartRequired ? 503 : err.status || 500)
790
+ .json({ error: err.message, lock: err.lock || undefined });
791
+ }
792
+ api('post', '/api/v1/scale', null, async (req, res) => {
793
+ const project = req.body?.project;
794
+ const service = req.body?.service;
795
+ const replicas = Number(req.body?.replicas);
796
+ if (!project || !service || !Number.isInteger(replicas) || replicas < 1)
797
+ throw httpError(400, 'project, service and a positive integer replicas are required');
798
+ assertProject(req.actor, project);
799
+ const dsRow = await currentDesired(project);
800
+ if (!dsRow)
801
+ throw httpError(404, `project ${project} has no desired state`);
802
+ const state = JSON.parse(dsRow.state_json);
803
+ if (!state.services?.[service])
804
+ throw httpError(404, `service ${service} not found in project ${project}`);
805
+ const nodeRows = await db.all('SELECT name,labels_json,last_seen_at FROM nodes ORDER BY created_at,name');
806
+ const nodeInfos = nodeRows
807
+ .filter((row) => !isStale(row))
808
+ .map((row) => ({
809
+ name: row.name,
810
+ labels: row.labels_json ? JSON.parse(row.labels_json) : [],
811
+ }));
812
+ const composeSvc = state.compose?.services?.[service] || {};
813
+ const ext = composeSvc['x-tug'] || {};
814
+ const placement = ext.placement || {};
815
+ const global = ext.global === true || placement.global === true;
816
+ const labels = Array.isArray(placement.labels) ? placement.labels : [];
817
+ const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
818
+ const pool = labels.length ? matched : nodeInfos;
819
+ if (!pool.length)
820
+ throw httpError(400, labels.length
821
+ ? 'no healthy nodes match the service placement labels'
822
+ : 'no nodes available for scaling');
823
+ const selected = global ? pool : pool.slice(0, Math.min(replicas, pool.length));
824
+ state.services[service].placement = {
825
+ ...(state.services[service].placement || {}),
826
+ nodes: selected.map((node) => node.name),
827
+ };
828
+ state.services[service].replicas = selected.length;
829
+ state.revision = id('rev');
830
+ await db.run('UPDATE desired_states SET state_json=? WHERE id=?', JSON.stringify(state), dsRow.id);
831
+ await wakeAll(project);
832
+ res.json({
833
+ ok: true,
834
+ project,
835
+ service,
836
+ replicas: selected.length,
837
+ nodes: selected.map((node) => node.name),
838
+ });
839
+ });
344
840
  get('/healthz', (req, res) => res.json({ ok: true }));
345
- api('post', '/api/v1/projects', req => String(req.body?.name || 'site'), async (req, res) => {
841
+ api('post', '/api/v1/projects', (req) => String(req.body?.name || 'site'), async (req, res) => {
346
842
  const name = req.body.name || 'site';
347
843
  const ts = now();
348
844
  await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
349
845
  res.json(await db.get('SELECT * FROM projects WHERE id=?', name));
350
846
  });
351
- api('get', '/api/v1/projects/:project', req => req.params.project, async (req, res) => res.json(await db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
352
- const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,status,created_at,updated_at FROM nodes WHERE id=?';
847
+ api('get', '/api/v1/projects/:project', (req) => req.params.project, async (req, res) => res.json(await db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
848
+ /** A node whose agent has been seen before but not within TUG_STALE_SECONDS; never-seen (just registered) nodes stay schedulable. */
849
+ const isStale = (row) => !!row.last_seen_at &&
850
+ Date.parse(row.last_seen_at) < Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000;
851
+ const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,labels_json,status,created_at,updated_at FROM nodes WHERE id=?';
353
852
  /** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
354
853
  async function ensureOverlayIp(nodeId) {
355
854
  if (!MESH)
@@ -358,7 +857,7 @@ export async function createApp(options = {}) {
358
857
  const row = await db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
359
858
  if (row?.overlay_ip)
360
859
  return;
361
- const used = (await db.all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')).map(r => r.overlay_ip);
860
+ const used = (await db.all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')).map((r) => r.overlay_ip);
362
861
  try {
363
862
  await db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
364
863
  return;
@@ -382,18 +881,32 @@ export async function createApp(options = {}) {
382
881
  await db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
383
882
  ON CONFLICT(node_id) DO UPDATE SET public_key=excluded.public_key,overlay_ip=excluded.overlay_ip,endpoint=excluded.endpoint,allowed_ips=excluded.allowed_ips,updated_at=excluded.updated_at`, nodeId, key, node.overlay_ip, wgEndpoint(node.public_address, WG_PORT) ?? null, `${node.overlay_ip}/32`, now());
384
883
  }
385
- async function registerNode({ name, address, publicAddress, wgPublicKey }) {
884
+ async function registerNode({ name, address, publicAddress, wgPublicKey, labels, }) {
386
885
  if (wgPublicKey !== undefined && !isWgKey(wgPublicKey))
387
886
  throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
388
887
  const agentToken = token('tug_agent');
389
888
  const registryToken = token('tug_reg');
390
889
  const ts = now();
391
- await db.run(`INSERT INTO nodes(id,name,address,public_address,status,agent_token_hash,registry_token_hash,created_at,updated_at)
392
- VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,agent_token_hash=excluded.agent_token_hash,registry_token_hash=excluded.registry_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', hashToken(agentToken), hashToken(registryToken), ts, ts);
890
+ await db.run(`INSERT INTO nodes(id,name,address,public_address,status,labels_json,agent_token_hash,registry_token_hash,created_at,updated_at)
891
+ VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,labels_json=excluded.labels_json,agent_token_hash=excluded.agent_token_hash,registry_token_hash=excluded.registry_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', JSON.stringify(Array.isArray(labels) ? labels : []), hashToken(agentToken), hashToken(registryToken), ts, ts);
393
892
  await ensureOverlayIp(name);
394
893
  await syncWgPeer(name, wgPublicKey);
395
894
  await upsertRegistryCredential(name, registryToken);
396
- const node = (await db.get(NODE_INFO_SQL, name));
895
+ await wakeAll();
896
+ const row = await db.get(NODE_INFO_SQL, name);
897
+ if (!row)
898
+ throw httpError(500, 'node not found after registration');
899
+ const node = {
900
+ id: row.id,
901
+ name: row.name,
902
+ address: row.address,
903
+ public_address: row.public_address,
904
+ overlay_ip: row.overlay_ip,
905
+ labels: row.labels_json ? JSON.parse(row.labels_json) : [],
906
+ status: row.status,
907
+ created_at: row.created_at,
908
+ updated_at: row.updated_at,
909
+ };
397
910
  return { node, agentToken, registryToken };
398
911
  }
399
912
  api('post', '/api/v1/nodes/register', null, async (req, res) => {
@@ -401,7 +914,13 @@ export async function createApp(options = {}) {
401
914
  if (!name)
402
915
  return res.status(400).json({ error: 'name required' });
403
916
  try {
404
- res.json(await registerNode({ name, address: req.body.address, publicAddress: req.body.publicAddress, wgPublicKey: req.body.wgPublicKey }));
917
+ res.json(await registerNode({
918
+ name,
919
+ address: req.body.address,
920
+ publicAddress: req.body.publicAddress,
921
+ wgPublicKey: req.body.wgPublicKey,
922
+ labels: req.body.labels,
923
+ }));
405
924
  }
406
925
  catch (e) {
407
926
  sendError(res, e);
@@ -431,10 +950,18 @@ export async function createApp(options = {}) {
431
950
  }
432
951
  const name = row.name || body.name;
433
952
  if (!name)
434
- return res.status(400).json({ error: 'node name required (bind one to the enroll token or send name)' });
953
+ return res
954
+ .status(400)
955
+ .json({ error: 'node name required (bind one to the enroll token or send name)' });
435
956
  let result;
436
957
  try {
437
- result = await registerNode({ name, address: body.address, publicAddress: body.publicAddress, wgPublicKey: body.wgPublicKey });
958
+ result = await registerNode({
959
+ name,
960
+ address: body.address,
961
+ publicAddress: body.publicAddress,
962
+ wgPublicKey: body.wgPublicKey,
963
+ labels: body.labels,
964
+ });
438
965
  }
439
966
  catch (e) {
440
967
  return sendError(res, e);
@@ -447,7 +974,15 @@ export async function createApp(options = {}) {
447
974
  const enrolled = { ...result, registry: REGISTRY };
448
975
  res.json(enrolled);
449
976
  });
450
- api('get', '/api/v1/nodes', null, async (req, res) => res.json({ nodes: await db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,last_seen_at,created_at,updated_at FROM nodes ORDER BY name') }));
977
+ api('get', '/api/v1/nodes', null, async (req, res) => {
978
+ const rows = await db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,labels_json,last_seen_at,created_at,updated_at FROM nodes ORDER BY name');
979
+ res.json({
980
+ nodes: rows.map((row) => {
981
+ const { labels_json, ...rest } = row;
982
+ return { ...rest, labels: labels_json ? JSON.parse(labels_json) : [] };
983
+ }),
984
+ });
985
+ });
451
986
  api('post', '/api/v1/nodes/:name/rotate-registry-token', null, async (req, res) => {
452
987
  const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
453
988
  if (!node)
@@ -465,11 +1000,15 @@ export async function createApp(options = {}) {
465
1000
  await removeRegistryCredential(node.name);
466
1001
  res.json({ ok: true, node: node.name, revoked: true });
467
1002
  });
468
- async function findNode(nameOrId) { return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId); }
469
- function queueCommand(nodeId, projectId, type, payload = {}) {
1003
+ async function findNode(nameOrId) {
1004
+ return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId);
1005
+ }
1006
+ async function queueCommand(nodeId, projectId, type, payload = {}) {
470
1007
  const commandId = id('cmd');
471
1008
  const ts = now();
472
- return db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts, null, ts, ts).then(() => commandId);
1009
+ await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts, null, ts, ts);
1010
+ wakeHub.wake(nodeId, projectId ?? undefined);
1011
+ return commandId;
473
1012
  }
474
1013
  // The private key lives only on the node, so rotation is a command the agent executes: it
475
1014
  // generates a new keypair, reports the public key and peers pick it up from their next work response.
@@ -481,12 +1020,48 @@ export async function createApp(options = {}) {
481
1020
  return res.status(404).json({ error: 'node not found' });
482
1021
  res.json({ commandId: await queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
483
1022
  });
1023
+ // Removes a retired node: its token, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1024
+ // something on it (redeploy without it first) or while its agent is still reporting, unless force=true.
1025
+ api('delete', '/api/v1/nodes/:name', null, async (req, res) => {
1026
+ const node = await db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
1027
+ if (!node)
1028
+ return res.status(404).json({ error: 'node not found' });
1029
+ const referenced = [];
1030
+ for (const state of await currentDesiredStates()) {
1031
+ for (const [name, svc] of Object.entries(state.services || {}))
1032
+ if (svc.placement?.nodes?.includes(node.name))
1033
+ referenced.push(`${state.project}/${name}`);
1034
+ for (const [name, vol] of Object.entries(state.compose?.volumes || {}))
1035
+ if (vol?.['x-tug']?.placement?.nodes?.includes(node.name))
1036
+ referenced.push(`${state.project}/volume ${name}`);
1037
+ }
1038
+ if (referenced.length)
1039
+ return res.status(409).json({
1040
+ error: `node ${node.name} is still placed in current deployments: ${referenced.join(', ')}; deploy without it first`,
1041
+ });
1042
+ if (node.last_seen_at && !isStale(node) && String(req.query.force) !== 'true')
1043
+ return res.status(409).json({
1044
+ error: `node ${node.name} is still reporting; stop its agent first or pass force=true`,
1045
+ });
1046
+ await db.run('DELETE FROM commands WHERE node_id=?', node.id);
1047
+ await db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
1048
+ await db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
1049
+ await db.run('DELETE FROM nodes WHERE id=?', node.id);
1050
+ await audit(req.actor, {
1051
+ type: 'node.removed',
1052
+ message: `node ${node.name} removed`,
1053
+ metadata: { node: node.name },
1054
+ });
1055
+ await wakeAll();
1056
+ res.json({ ok: true, node: node.name });
1057
+ });
484
1058
  api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
485
1059
  const node = await findNode(req.params.name);
486
1060
  if (!node)
487
1061
  return res.status(404).json({ error: 'node not found' });
488
1062
  const enabled = req.body.enabled !== false;
489
1063
  await db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
1064
+ wakeHub.wake(node.id);
490
1065
  res.json({ ok: true, node: node.name, ingress: enabled });
491
1066
  });
492
1067
  post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
@@ -497,16 +1072,21 @@ export async function createApp(options = {}) {
497
1072
  return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
498
1073
  try {
499
1074
  await syncWgPeer(req.node.id, publicKey);
1075
+ await wakeAll();
500
1076
  res.json({ ok: true });
501
1077
  }
502
1078
  catch (e) {
503
- sendError(res, /UNIQUE constraint/.test(errMessage(e)) ? httpError(409, 'public key already registered to another node') : e);
1079
+ sendError(res, /UNIQUE constraint/.test(errMessage(e))
1080
+ ? httpError(409, 'public key already registered to another node')
1081
+ : e);
504
1082
  }
505
1083
  });
506
1084
  post('/api/v1/registry/auth', async (req, res) => {
507
1085
  const username = req.body.username;
508
1086
  const password = req.body.password;
509
- const node = username && password ? await db.get('SELECT id,name FROM nodes WHERE (name=? OR id=?) AND registry_token_hash IS NOT NULL AND registry_token_hash=?', username, username, hashToken(password)) : null;
1087
+ const node = username && password
1088
+ ? await db.get('SELECT id,name FROM nodes WHERE (name=? OR id=?) AND registry_token_hash IS NOT NULL AND registry_token_hash=?', username, username, hashToken(password))
1089
+ : null;
510
1090
  if (!node)
511
1091
  return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
512
1092
  res.json({ ok: true, node: node.name });
@@ -514,14 +1094,27 @@ export async function createApp(options = {}) {
514
1094
  api('post', '/api/v1/tokens', null, async (req, res) => {
515
1095
  const request = parseTokenRequest(req.body);
516
1096
  const created = await createToken(db, request, req.actor.name);
517
- await audit(req.actor, { type: 'token.create', message: `token ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`, metadata: { name: created.name, role: created.role, projects: created.projects, expiresAt: created.expiresAt } });
1097
+ await audit(req.actor, {
1098
+ type: 'token.create',
1099
+ message: `token ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
1100
+ metadata: {
1101
+ name: created.name,
1102
+ role: created.role,
1103
+ projects: created.projects,
1104
+ expiresAt: created.expiresAt,
1105
+ },
1106
+ });
518
1107
  res.json(created);
519
1108
  });
520
1109
  api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: await listTokens(db) }));
521
1110
  api('delete', '/api/v1/tokens/:name', null, async (req, res) => {
522
- if (!await revokeToken(db, req.params.name))
1111
+ if (!(await revokeToken(db, req.params.name)))
523
1112
  return res.status(404).json({ error: 'token not found' });
524
- await audit(req.actor, { type: 'token.revoke', message: `token ${req.params.name} revoked by ${req.actor.name}`, metadata: { name: req.params.name } });
1113
+ await audit(req.actor, {
1114
+ type: 'token.revoke',
1115
+ message: `token ${req.params.name} revoked by ${req.actor.name}`,
1116
+ metadata: { name: req.params.name },
1117
+ });
525
1118
  res.json({ ok: true, name: req.params.name });
526
1119
  });
527
1120
  api('post', '/api/v1/secrets', bodyProject, async (req, res) => {
@@ -535,7 +1128,13 @@ export async function createApp(options = {}) {
535
1128
  const ts = now();
536
1129
  await db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
537
1130
  ON CONFLICT(project_id,name) DO UPDATE SET encrypted_value=excluded.encrypted_value,version=excluded.version,updated_at=excluded.updated_at`, id('sec'), project, name, encrypt(value, masterKey), (old?.version || 0) + 1, ts, ts);
538
- await audit(req.actor, { type: 'secret.set', project, message: `secret ${name} set to version ${(old?.version || 0) + 1} by ${req.actor.name}`, metadata: { name, version: (old?.version || 0) + 1 } });
1131
+ await wakeAll(project);
1132
+ await audit(req.actor, {
1133
+ type: 'secret.set',
1134
+ project,
1135
+ message: `secret ${name} set to version ${(old?.version || 0) + 1} by ${req.actor.name}`,
1136
+ metadata: { name, version: (old?.version || 0) + 1 },
1137
+ });
539
1138
  res.json({ ok: true, name, version: (old?.version || 0) + 1 });
540
1139
  }
541
1140
  catch (e) {
@@ -545,24 +1144,33 @@ export async function createApp(options = {}) {
545
1144
  await releaseLock(lock);
546
1145
  }
547
1146
  });
548
- api('get', '/api/v1/secrets', queryProject, async (req, res) => res.json({ secrets: await db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
1147
+ api('get', '/api/v1/secrets', queryProject, async (req, res) => res.json({
1148
+ secrets: await db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', req.query.project || 'site'),
1149
+ }));
549
1150
  api('delete', '/api/v1/secrets/:name', queryProject, async (req, res) => {
550
1151
  const project = String(req.query.project || 'site');
551
1152
  const removed = await db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
552
- if (removed.changes)
553
- await audit(req.actor, { type: 'secret.delete', project, message: `secret ${req.params.name} deleted by ${req.actor.name}`, metadata: { name: req.params.name } });
1153
+ if (removed.changes) {
1154
+ await wakeAll(project);
1155
+ await audit(req.actor, {
1156
+ type: 'secret.delete',
1157
+ project,
1158
+ message: `secret ${req.params.name} deleted by ${req.actor.name}`,
1159
+ metadata: { name: req.params.name },
1160
+ });
1161
+ }
554
1162
  res.json({ ok: true });
555
1163
  });
556
1164
  const artifactDir = path.join(DATA_DIR, 'images');
557
1165
  await ensureDir(artifactDir);
558
1166
  async function retainedStates() {
559
1167
  // All published history is retained. This conservative superset protects failed/lagging versions too.
560
- return (await db.all('SELECT * FROM desired_states')).map(row => JSON.parse(row.state_json));
1168
+ return (await db.all('SELECT * FROM desired_states')).map((row) => JSON.parse(row.state_json));
561
1169
  }
562
1170
  async function collectArtifacts() {
563
1171
  const states = await retainedStates();
564
- const refs = new Set(states.flatMap(state => Object.values(state.services).flatMap(service => service.artifact ? [service.artifact.buildId] : [])));
565
- const imageRefs = new Set(states.flatMap(state => Object.values(state.services).map(service => service.image)));
1172
+ const refs = new Set(states.flatMap((state) => Object.values(state.services).flatMap((service) => service.artifact ? [service.artifact.buildId] : [])));
1173
+ const imageRefs = new Set(states.flatMap((state) => Object.values(state.services).map((service) => service.image)));
566
1174
  const rows = await db.all('SELECT * FROM builds');
567
1175
  for (const row of rows) {
568
1176
  if (refs.has(row.id) || imageRefs.has(row.image_ref))
@@ -575,7 +1183,7 @@ export async function createApp(options = {}) {
575
1183
  await db.run('DELETE FROM builds WHERE id=?', row.id);
576
1184
  }
577
1185
  const remaining = await db.all('SELECT id FROM builds');
578
- const owned = new Set(remaining.flatMap(row => [`${row.id}.tar.gz`, `${row.id}.tar.gz.tmp`]));
1186
+ const owned = new Set(remaining.flatMap((row) => [`${row.id}.tar.gz`, `${row.id}.tar.gz.tmp`]));
579
1187
  for (const name of await fs.readdir(artifactDir))
580
1188
  if (!owned.has(name))
581
1189
  await fs.rm(path.join(artifactDir, name), { force: true });
@@ -585,10 +1193,10 @@ export async function createApp(options = {}) {
585
1193
  await collectArtifacts();
586
1194
  const receiveDeployment = upload.any();
587
1195
  api('post', '/api/v1/deployments', null, (req, res) => {
588
- receiveDeployment(req, res, error => {
1196
+ receiveDeployment(req, res, (error) => {
589
1197
  void withWriter(() => deployRequest(req, res, error)).catch(async (failure) => {
590
1198
  const files = Array.isArray(req.files) ? req.files : [];
591
- await Promise.all(files.map(file => fs.rm(file.path, { force: true }).catch(() => { })));
1199
+ await Promise.all(files.map((file) => fs.rm(file.path, { force: true }).catch(() => { })));
592
1200
  sendError(res, failure);
593
1201
  });
594
1202
  });
@@ -616,31 +1224,97 @@ export async function createApp(options = {}) {
616
1224
  const compose = body.compose;
617
1225
  const version = body.version || `git-${Date.now()}`;
618
1226
  assertProject(req.actor, project);
619
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(project) || typeof version !== 'string' || !version.length)
1227
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(project) ||
1228
+ typeof version !== 'string' ||
1229
+ !version.length)
620
1230
  throw httpError(400, 'invalid project or version');
621
1231
  if (version.startsWith(ROLLBACK_VERSION_PREFIX))
622
1232
  throw httpError(400, `version cannot start with "${ROLLBACK_VERSION_PREFIX}" (reserved for rollback deployments)`);
623
- const nodes = await db.all('SELECT name FROM nodes ORDER BY created_at,name');
1233
+ const nodeRows = await db.all('SELECT name,labels_json,last_seen_at,created_at FROM nodes ORDER BY created_at,name');
1234
+ // Stale nodes (agent not seen recently) are not scheduled onto, so a dead host cannot hold a rollout open.
1235
+ const nodeInfos = nodeRows
1236
+ .filter((row) => !isStale(row))
1237
+ .map((row) => ({
1238
+ name: row.name,
1239
+ labels: row.labels_json ? JSON.parse(row.labels_json) : [],
1240
+ created_at: row.created_at,
1241
+ }));
1242
+ // Resolve label-based and global placement into concrete node lists before validation.
1243
+ for (const svc of Object.values(compose.services || {})) {
1244
+ const ext = (svc['x-tug'] ||= {});
1245
+ const placement = ext.placement || {};
1246
+ const global = ext.global === true || placement.global === true;
1247
+ const labels = Array.isArray(placement.labels) ? placement.labels : [];
1248
+ if (!placement.nodes?.length && (labels.length || global)) {
1249
+ const matched = nodeInfos
1250
+ .filter((node) => labels.every((label) => node.labels.includes(label)))
1251
+ .sort((a, b) => a.name.localeCompare(b.name));
1252
+ if (!matched.length)
1253
+ throw httpError(400, 'no nodes match placement labels');
1254
+ let selected = matched;
1255
+ if (!global) {
1256
+ const replicas = ext.replicas || matched.length;
1257
+ if (!Number.isInteger(replicas) || replicas < 1)
1258
+ throw httpError(400, 'replicas must be a positive integer');
1259
+ selected = matched.slice(0, Math.min(replicas, matched.length));
1260
+ }
1261
+ ext.placement = { ...placement, nodes: selected.map((node) => node.name) };
1262
+ ext.replicas = selected.length;
1263
+ }
1264
+ }
1265
+ const nodes = nodeRows.map((row) => ({ name: row.name }));
1266
+ for (const vol of Object.values(compose.volumes || {})) {
1267
+ const xe = vol?.['x-tug'];
1268
+ if (!xe ||
1269
+ (xe.type !== 'nfs' && xe.driver !== 'nfs') ||
1270
+ xe.address ||
1271
+ !xe.placement?.nodes?.[0])
1272
+ continue;
1273
+ const exporter = await db.get('SELECT address,overlay_ip FROM nodes WHERE name=?', xe.placement.nodes[0]);
1274
+ const addr = (MESH && exporter?.overlay_ip) || exporter?.address;
1275
+ if (addr)
1276
+ xe.address = addr;
1277
+ }
624
1278
  try {
625
- validateCompose(compose, nodes.map(n => n.name));
1279
+ validateCompose(compose, nodes.map((n) => n.name));
626
1280
  }
627
1281
  catch (e) {
628
1282
  throw httpError(400, errMessage(e));
629
1283
  }
630
- const builtServices = Object.entries(compose.services).filter(([, service]) => service.build).map(([name]) => name);
1284
+ // The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
1285
+ const referenced = sentinelNames(compose);
1286
+ if (referenced.length) {
1287
+ const known = new Set((await db.all('SELECT name FROM secrets WHERE project_id=?', project)).map((row) => row.name));
1288
+ const missing = referenced.filter((name) => !known.has(name));
1289
+ if (missing.length)
1290
+ throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret-set`);
1291
+ }
1292
+ const builtServices = Object.entries(compose.services)
1293
+ .filter(([, service]) => service.build)
1294
+ .map(([name]) => name);
631
1295
  const local = body.localImages;
632
- const context = files.find(file => file.fieldname === 'context');
1296
+ const context = files.find((file) => file.fieldname === 'context');
633
1297
  if (local !== undefined && (!Array.isArray(local) || context))
634
1298
  throw httpError(400, 'localImages must be an array and cannot accompany a source context');
635
- if (files.filter(file => file.fieldname === 'context').length > 1)
1299
+ if (files.filter((file) => file.fieldname === 'context').length > 1)
636
1300
  throw httpError(400, 'duplicate source context');
637
1301
  if (local) {
638
- if (local.some(item => !item || typeof item !== 'object'))
1302
+ if (local.some((item) => !item || typeof item !== 'object'))
639
1303
  throw httpError(400, 'invalid local image manifest');
640
- if (local.length !== builtServices.length || new Set(local.map(item => item.service)).size !== local.length || new Set(local.map(item => item.field)).size !== local.length)
1304
+ if (local.length !== builtServices.length ||
1305
+ new Set(local.map((item) => item.service)).size !== local.length ||
1306
+ new Set(local.map((item) => item.field)).size !== local.length)
641
1307
  throw httpError(400, 'localImages must contain exactly one image for each build service');
642
1308
  for (const item of local) {
643
- if (!/^tug-build\/[a-f0-9-]{36}\/image-\d+:local$/.test(item.imageRef) || item.imageRef.split('/').at(-1) !== `${item.field}:local` || !builtServices.includes(item.service) || !/^image-\d+$/.test(item.field) || files.filter(file => file.fieldname === item.field).length !== 1 || !/^[a-f0-9]{64}$/.test(item.sha256) || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes <= 0 || item.sizeBytes > MAX_IMAGE_BYTES)
1309
+ if (!/^tug-build\/[a-f0-9-]{36}\/image-\d+:local$/.test(item.imageRef) ||
1310
+ item.imageRef.split('/').at(-1) !== `${item.field}:local` ||
1311
+ !builtServices.includes(item.service) ||
1312
+ !/^image-\d+$/.test(item.field) ||
1313
+ files.filter((file) => file.fieldname === item.field).length !== 1 ||
1314
+ !/^[a-f0-9]{64}$/.test(item.sha256) ||
1315
+ !Number.isSafeInteger(item.sizeBytes) ||
1316
+ item.sizeBytes <= 0 ||
1317
+ item.sizeBytes > MAX_IMAGE_BYTES)
644
1318
  throw httpError(400, 'invalid local image manifest or file');
645
1319
  }
646
1320
  }
@@ -653,7 +1327,7 @@ export async function createApp(options = {}) {
653
1327
  if (ing?.tls !== 'custom')
654
1328
  continue;
655
1329
  for (const secret of [ing.cert, ing.key])
656
- if (!await db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret))
1330
+ if (!(await db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret)))
657
1331
  throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret-set first`);
658
1332
  }
659
1333
  lock = await acquireProjectLock(project, 'deploy');
@@ -664,7 +1338,14 @@ export async function createApp(options = {}) {
664
1338
  const ts = now();
665
1339
  await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
666
1340
  await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at,actor) VALUES(?,?,?,?,?,?,?)', depId, project, version, 'building', ts, ts, req.actor.name);
667
- const desired = buildDesiredState({ project, version, revision: depId, compose, registry: REGISTRY, defaultNode: nodes[0]?.name || null });
1341
+ const desired = buildDesiredState({
1342
+ project,
1343
+ version,
1344
+ revision: depId,
1345
+ compose,
1346
+ registry: REGISTRY,
1347
+ defaultNode: nodes[0]?.name || null,
1348
+ });
668
1349
  if (context) {
669
1350
  contextDir = path.resolve(DATA_DIR, 'build-contexts', depId);
670
1351
  await unpackContext(context.path, contextDir);
@@ -685,15 +1366,18 @@ export async function createApp(options = {}) {
685
1366
  const temporary = `${final}.tmp`;
686
1367
  try {
687
1368
  if (local) {
688
- const item = local.find(item => item.service === service);
689
- const file = files.find(file => file.fieldname === item.field);
1369
+ const item = local.find((item) => item.service === service);
1370
+ const file = files.find((file) => file.fieldname === item.field);
690
1371
  try {
691
1372
  info = await images.validate(file.path, item.imageRef);
692
1373
  }
693
1374
  catch (error) {
694
1375
  throw httpError(400, `${service}: invalid image archive: ${errMessage(error)}`);
695
1376
  }
696
- if (info.sha256 !== item.sha256 || info.sizeBytes !== item.sizeBytes || info.imageId !== item.imageId || info.platform !== item.platform)
1377
+ if (info.sha256 !== item.sha256 ||
1378
+ info.sizeBytes !== item.sizeBytes ||
1379
+ info.imageId !== item.imageId ||
1380
+ info.platform !== item.platform)
697
1381
  throw httpError(400, `${service}: local image metadata/checksum mismatch`);
698
1382
  importedClientRef = item.imageRef;
699
1383
  await images.run('docker', ['load', '-i', file.path]);
@@ -703,7 +1387,13 @@ export async function createApp(options = {}) {
703
1387
  await images.run('docker', ['tag', item.imageRef, imageRef]);
704
1388
  }
705
1389
  else
706
- await images.build({ contextDir: contextDir, service, buildSpec: compose.services[service].build, imageRef, push: false });
1390
+ await images.build({
1391
+ contextDir: contextDir,
1392
+ service,
1393
+ buildSpec: compose.services[service].build,
1394
+ imageRef,
1395
+ push: false,
1396
+ });
707
1397
  if (REGISTRY) {
708
1398
  if (process.env.TUG_PUSH !== 'false')
709
1399
  await images.run('docker', ['push', imageRef]);
@@ -733,13 +1423,16 @@ export async function createApp(options = {}) {
733
1423
  await db.exec('BEGIN IMMEDIATE');
734
1424
  try {
735
1425
  const published = await db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), depId);
736
- if (published.changes !== 1 || !await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
1426
+ if (published.changes !== 1 ||
1427
+ !(await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now())))
737
1428
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
738
1429
  await db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, version, JSON.stringify(desired), ts);
739
1430
  for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
740
1431
  const lp = volumeSpec?.['x-tug'] || {};
741
1432
  const nodeName = lp.placement?.nodes?.[0] || null;
742
- const node = nodeName ? await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName) : null;
1433
+ const node = nodeName
1434
+ ? await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName)
1435
+ : null;
743
1436
  await db.run(`INSERT INTO volumes(id,project_id,name,type,driver,node_id,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(project_id,name) DO UPDATE SET type=excluded.type,driver=excluded.driver,node_id=excluded.node_id,config_json=excluded.config_json,updated_at=excluded.updated_at`, id('vol'), project, volumeName, lp.type || 'local', lp.driver || null, node?.id || null, JSON.stringify(lp), ts, ts);
744
1437
  }
745
1438
  await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
@@ -750,20 +1443,32 @@ export async function createApp(options = {}) {
750
1443
  throw e;
751
1444
  }
752
1445
  });
753
- await audit(req.actor, { type: 'deploy', project, deploymentId: depId, message: `deployment ${depId} (${version}) started by ${req.actor.name}`, metadata: { version } });
754
- responseBody = { deployment: { id: depId, project, version, state: 'rolling_out', actor: req.actor.name }, desiredState: desired };
1446
+ await audit(req.actor, {
1447
+ type: 'deploy',
1448
+ project,
1449
+ deploymentId: depId,
1450
+ message: `deployment ${depId} (${version}) started by ${req.actor.name}`,
1451
+ metadata: { version },
1452
+ });
1453
+ await wakeAll(project);
1454
+ responseBody = {
1455
+ deployment: { id: depId, project, version, state: 'rolling_out', actor: req.actor.name },
1456
+ desiredState: desired,
1457
+ };
755
1458
  }
756
1459
  catch (e) {
757
1460
  if (depId)
758
- await db.run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId).catch(() => { });
1461
+ await db
1462
+ .run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId)
1463
+ .catch(() => { });
759
1464
  responseError = e;
760
1465
  }
761
1466
  finally {
762
1467
  await releaseLock(lock);
763
- await Promise.all(files.map(file => fs.rm(file.path, { force: true }).catch(() => { })));
1468
+ await Promise.all(files.map((file) => fs.rm(file.path, { force: true }).catch(() => { })));
764
1469
  if (contextDir)
765
1470
  await fs.rm(contextDir, { recursive: true, force: true });
766
- await collectArtifacts().catch(e => console.error('image cleanup failed:', errMessage(e)));
1471
+ await collectArtifacts().catch((e) => console.error('image cleanup failed:', errMessage(e)));
767
1472
  }
768
1473
  if (responseError)
769
1474
  sendError(res, responseError);
@@ -772,7 +1477,8 @@ export async function createApp(options = {}) {
772
1477
  }
773
1478
  get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
774
1479
  const states = await retainedStates();
775
- const allowed = states.some(state => Object.values(state.services).some(service => service.artifact?.buildId === req.params.buildId && (!service.placement.nodes?.length || service.placement.nodes.includes(req.node.name))));
1480
+ const allowed = states.some((state) => Object.values(state.services).some((service) => service.artifact?.buildId === req.params.buildId &&
1481
+ (!service.placement.nodes?.length || service.placement.nodes.includes(req.node.name))));
776
1482
  if (!allowed)
777
1483
  return res.status(404).json({ error: 'image not found' });
778
1484
  const row = await db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
@@ -780,7 +1486,12 @@ export async function createApp(options = {}) {
780
1486
  return res.status(404).json({ error: 'image not found' });
781
1487
  try {
782
1488
  await fs.access(path.join(DATA_DIR, row.artifact_path));
783
- res.set({ 'Content-Type': 'application/gzip', 'Content-Length': String(row.size_bytes), 'X-Tug-Sha256': row.sha256, 'Cache-Control': 'private, no-store' });
1489
+ res.set({
1490
+ 'Content-Type': 'application/gzip',
1491
+ 'Content-Length': String(row.size_bytes),
1492
+ 'X-Tug-Sha256': row.sha256,
1493
+ 'Cache-Control': 'private, no-store',
1494
+ });
784
1495
  await pipeline(createReadStream(path.join(DATA_DIR, row.artifact_path)), res);
785
1496
  }
786
1497
  catch {
@@ -798,21 +1509,37 @@ export async function createApp(options = {}) {
798
1509
  const result = await db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), req.params.id);
799
1510
  if (result.changes !== 1)
800
1511
  return res.status(409).json({ error: 'deployment is no longer active' });
801
- await audit(req.actor, { type: 'cancel', project: row.project_id, deploymentId: row.id, message: `deployment ${row.id} cancelled by ${req.actor.name}` });
1512
+ await audit(req.actor, {
1513
+ type: 'cancel',
1514
+ project: row.project_id,
1515
+ deploymentId: row.id,
1516
+ message: `deployment ${row.id} cancelled by ${req.actor.name}`,
1517
+ });
802
1518
  res.json({ ok: true });
803
1519
  }));
804
1520
  api('post', '/api/v1/rollback', bodyProject, async (req, res) => withWriter(async () => {
805
1521
  const project = req.body.project || 'site';
806
1522
  try {
807
- const out = await createRollback(project, { actor: req.actor.name, force: Boolean(req.body.force) });
808
- await audit(req.actor, { type: 'rollback', project, deploymentId: out.deployment.id, message: `rollback to ${out.desiredState.version} (deployment ${out.deployment.id}) started by ${req.actor.name}`, metadata: { version: out.desiredState.version, force: Boolean(req.body.force) } });
1523
+ const out = await createRollback(project, {
1524
+ actor: req.actor.name,
1525
+ force: Boolean(req.body.force),
1526
+ });
1527
+ await audit(req.actor, {
1528
+ type: 'rollback',
1529
+ project,
1530
+ deploymentId: out.deployment.id,
1531
+ message: `rollback to ${out.desiredState.version} (deployment ${out.deployment.id}) started by ${req.actor.name}`,
1532
+ metadata: { version: out.desiredState.version, force: Boolean(req.body.force) },
1533
+ });
809
1534
  res.json(out);
810
1535
  }
811
1536
  catch (e) {
812
1537
  sendError(res, e);
813
1538
  }
814
1539
  }));
815
- api('get', '/api/v1/volumes', queryProject, async (req, res) => res.json({ volumes: await db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
1540
+ api('get', '/api/v1/volumes', queryProject, async (req, res) => res.json({
1541
+ volumes: await db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', req.query.project || 'site'),
1542
+ }));
816
1543
  api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
817
1544
  const project = String(req.query.project || 'site');
818
1545
  const deleteData = String(req.query.deleteData || 'false') === 'true';
@@ -824,19 +1551,26 @@ export async function createApp(options = {}) {
824
1551
  return res.status(404).json({ error: 'volume not found' });
825
1552
  const config = JSON.parse(volume.config_json || '{}');
826
1553
  if ((config.persistent || volume.type !== 'ephemeral') && !deleteData)
827
- return res.status(409).json({ error: 'refusing to delete persistent volume without deleteData=true' });
1554
+ return res
1555
+ .status(409)
1556
+ .json({ error: 'refusing to delete persistent volume without deleteData=true' });
828
1557
  const ds = await currentDesired(project);
829
1558
  if (ds) {
830
1559
  const state = JSON.parse(ds.state_json);
831
- const inUse = Object.entries(state.compose?.services || {}).filter(([, svc]) => (svc.volumes || []).some(v => (typeof v === 'string' ? v.split(':')[0] : v.source) === req.params.name)).map(([name]) => name);
1560
+ const inUse = Object.entries(state.compose?.services || {})
1561
+ .filter(([, svc]) => (svc.volumes || []).some((v) => (typeof v === 'string' ? v.split(':')[0] : v.source) === req.params.name))
1562
+ .map(([name]) => name);
832
1563
  if (inUse.length)
833
- return res.status(409).json({ error: `volume is still referenced by services: ${inUse.join(', ')}` });
1564
+ return res
1565
+ .status(409)
1566
+ .json({ error: `volume is still referenced by services: ${inUse.join(', ')}` });
834
1567
  }
835
1568
  let commandId = null;
836
1569
  if (deleteData && volume.node_id) {
837
- commandId = id('cmd');
838
- const ts = now();
839
- await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, volume.node_id, project, 'volume.delete', 'pending', JSON.stringify({ project, volume: req.params.name }), 0, ts, null, ts, ts);
1570
+ commandId = await queueCommand(volume.node_id, project, 'volume.delete', {
1571
+ project,
1572
+ volume: req.params.name,
1573
+ });
840
1574
  }
841
1575
  await db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
842
1576
  res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
@@ -862,14 +1596,21 @@ export async function createApp(options = {}) {
862
1596
  await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
863
1597
  await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
864
1598
  const artifactRef = path.resolve(backupDir, `${backupId}.tar.gz`);
865
- await tar.c({ gzip: true, file: artifactRef, cwd: staging }, ['tug.db', 'master.key', 'metadata.json', 'images']);
1599
+ await tar.c({ gzip: true, file: artifactRef, cwd: staging }, [
1600
+ 'tug.db',
1601
+ 'master.key',
1602
+ 'metadata.json',
1603
+ 'images',
1604
+ ]);
866
1605
  await fs.rm(staging, { recursive: true, force: true });
867
1606
  await db.run('INSERT INTO backups(id,project_id,target,node_id,state,artifact_ref,metadata_json,created_at,completed_at) VALUES(?,?,?,?,?,?,?,?,?)', backupId, '_control', 'control-plane', null, 'completed', artifactRef, JSON.stringify({ type: 'control' }), ts, now());
868
1607
  res.json({ backupId, artifactRef, state: 'completed' });
869
1608
  }));
870
1609
  api('post', '/api/v1/control/restore', null, async (req, res) => withWriter(async () => {
871
1610
  if (!req.body.confirm)
872
- return res.status(400).json({ error: 'confirm=true is required; restore overwrites control data then requires restart' });
1611
+ return res.status(400).json({
1612
+ error: 'confirm=true is required; restore overwrites control data then requires restart',
1613
+ });
873
1614
  const artifactRef = req.body.artifactRef;
874
1615
  if (typeof artifactRef !== 'string' || !artifactRef)
875
1616
  return res.status(400).json({ error: 'artifactRef required' });
@@ -878,19 +1619,25 @@ export async function createApp(options = {}) {
878
1619
  try {
879
1620
  await fs.rm(restoreDir, { recursive: true, force: true });
880
1621
  await ensureDir(restoreDir);
881
- await scanArchive(artifactRef, entry => {
882
- if (path.isAbsolute(entry.path) || entry.path.split('/').includes('..') || !['File', 'Directory'].includes(entry.type))
1622
+ await scanArchive(artifactRef, (entry) => {
1623
+ if (path.isAbsolute(entry.path) ||
1624
+ entry.path.split('/').includes('..') ||
1625
+ !['File', 'Directory'].includes(entry.type))
883
1626
  throw new Error('unsafe control backup entry');
884
1627
  });
885
1628
  await tar.x({ file: artifactRef, cwd: restoreDir, strict: true });
886
1629
  const key = (await fs.readFile(path.join(restoreDir, 'master.key'), 'utf8')).trim();
887
1630
  if (!/^[A-Za-z0-9+/]{43}=$/.test(key))
888
1631
  throw new Error('invalid backup master key');
889
- snapshot = await open({ filename: path.join(restoreDir, 'tug.db'), driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY });
1632
+ snapshot = await open({
1633
+ filename: path.join(restoreDir, 'tug.db'),
1634
+ driver: sqlite3.Database,
1635
+ mode: sqlite3.OPEN_READONLY,
1636
+ });
890
1637
  const integrity = await snapshot.get('PRAGMA integrity_check');
891
1638
  if (integrity?.integrity_check !== 'ok')
892
1639
  throw new Error('backup database failed integrity check');
893
- if (!await snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'"))
1640
+ if (!(await snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'")))
894
1641
  throw new Error('backup database has no operator identity');
895
1642
  for (const secret of await snapshot.all('SELECT * FROM secrets'))
896
1643
  decrypt(secret.encrypted_value, key);
@@ -901,10 +1648,14 @@ export async function createApp(options = {}) {
901
1648
  if (!service.artifact)
902
1649
  continue;
903
1650
  const build = await snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
904
- if (!build?.artifact_path || !/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
1651
+ if (!build?.artifact_path ||
1652
+ !/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
905
1653
  throw new Error('backup contains an unsafe or missing image reference');
906
1654
  const actual = await hashArchive(path.join(restoreDir, build.artifact_path));
907
- if (actual.sha256 !== build.sha256 || actual.sizeBytes !== build.size_bytes || actual.sha256 !== service.artifact.sha256 || actual.sizeBytes !== service.artifact.sizeBytes)
1655
+ if (actual.sha256 !== build.sha256 ||
1656
+ actual.sizeBytes !== build.size_bytes ||
1657
+ actual.sha256 !== service.artifact.sha256 ||
1658
+ actual.sizeBytes !== service.artifact.sizeBytes)
908
1659
  throw new Error('backup image archive checksum mismatch');
909
1660
  }
910
1661
  }
@@ -927,7 +1678,7 @@ export async function createApp(options = {}) {
927
1678
  res.status(restartRequired ? 500 : 400).json({ error: errMessage(e), restartRequired });
928
1679
  }
929
1680
  }));
930
- async function commandForService({ project, service, type, payload }) {
1681
+ async function commandForService({ project, service, type, payload, }) {
931
1682
  if (!service)
932
1683
  throw httpError(400, 'service required');
933
1684
  const ds = await currentDesired(project);
@@ -943,16 +1694,24 @@ export async function createApp(options = {}) {
943
1694
  const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
944
1695
  if (!node)
945
1696
  throw httpError(404, 'node not found');
946
- const cmdId = id('cmd');
947
- const ts = now();
948
- await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, project, type, 'pending', JSON.stringify({ project, service, ...payload }), 0, ts, null, ts, ts);
1697
+ const cmdId = await queueCommand(node.id, project, type, { project, service, ...payload });
949
1698
  return { commandId: cmdId, nodeId: node.id };
950
1699
  }
951
1700
  api('post', '/api/v1/restart', bodyProject, async (req, res) => {
952
1701
  try {
953
1702
  const project = req.body.project || 'site';
954
- const queued = await commandForService({ project, service: req.body.service, type: 'service.restart', payload: {} });
955
- await audit(req.actor, { type: 'restart', project, message: `service ${req.body.service} restart requested by ${req.actor.name}`, metadata: { service: req.body.service, commandId: queued.commandId } });
1703
+ const queued = await commandForService({
1704
+ project,
1705
+ service: req.body.service,
1706
+ type: 'service.restart',
1707
+ payload: {},
1708
+ });
1709
+ await audit(req.actor, {
1710
+ type: 'restart',
1711
+ project,
1712
+ message: `service ${req.body.service} restart requested by ${req.actor.name}`,
1713
+ metadata: { service: req.body.service, commandId: queued.commandId },
1714
+ });
956
1715
  res.json(queued);
957
1716
  }
958
1717
  catch (e) {
@@ -978,9 +1737,7 @@ export async function createApp(options = {}) {
978
1737
  const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
979
1738
  if (!node)
980
1739
  return res.status(404).json({ error: 'node not found' });
981
- const cmdId = id('cmd');
982
- const ts = now();
983
- await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, project, 'logs.fetch', 'pending', JSON.stringify({ project, service, tail }), 0, ts, null, ts, ts);
1740
+ const cmdId = await queueCommand(node.id, project, 'logs.fetch', { project, service, tail });
984
1741
  res.json({ commandId: cmdId, nodeId: node.id });
985
1742
  });
986
1743
  api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(await db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
@@ -991,7 +1748,12 @@ export async function createApp(options = {}) {
991
1748
  const projects = projectScope(req.actor, 'id');
992
1749
  const deployments = projectScope(req.actor, 'project_id');
993
1750
  const statuses = projectScope(req.actor, 'project_id');
994
- res.json({ projects: await db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args), deployments: await db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args), nodes: await db.all('SELECT id,name,status,last_seen_at FROM nodes'), statuses: await db.all(`SELECT * FROM node_statuses WHERE ${statuses.where}`, ...statuses.args) });
1751
+ res.json({
1752
+ projects: await db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args),
1753
+ deployments: await db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args),
1754
+ nodes: await db.all('SELECT id,name,status,last_seen_at FROM nodes'),
1755
+ statuses: await db.all(`SELECT * FROM node_statuses WHERE ${statuses.where}`, ...statuses.args),
1756
+ });
995
1757
  });
996
1758
  api('get', '/api/v1/events', null, async (req, res) => {
997
1759
  const project = req.query.project ? String(req.query.project) : null;
@@ -1014,15 +1776,23 @@ export async function createApp(options = {}) {
1014
1776
  checks.push({ name, ok: false, error: errMessage(e) });
1015
1777
  }
1016
1778
  }
1017
- await check('sqlite', async () => ({ value: (await db.get('SELECT COUNT(*) AS count FROM projects')).count }));
1018
- await check('operator_token', async () => ({ configured: Boolean(await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')) }));
1779
+ await check('sqlite', async () => ({
1780
+ value: (await db.get('SELECT COUNT(*) AS count FROM projects')).count,
1781
+ }));
1782
+ await check('operator_token', async () => ({
1783
+ configured: Boolean(await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')),
1784
+ }));
1019
1785
  await check('master_key', async () => ({ configured: Boolean(masterKey) }));
1020
- await check('docker', async () => ({ output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim() }));
1021
- await check('docker_compose', async () => ({ output: (await runCommand('docker', ['compose', 'version'])).out.trim() }));
1786
+ await check('docker', async () => ({
1787
+ output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim(),
1788
+ }));
1789
+ await check('docker_compose', async () => ({
1790
+ output: (await runCommand('docker', ['compose', 'version'])).out.trim(),
1791
+ }));
1022
1792
  if (MESH) {
1023
1793
  await check('mesh', async () => {
1024
1794
  const nodes = await db.all('SELECT n.name, n.overlay_ip, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
1025
- const missing = nodes.filter(n => !n.has_key).map(n => n.name);
1795
+ const missing = nodes.filter((n) => !n.has_key).map((n) => n.name);
1026
1796
  if (missing.length)
1027
1797
  throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
1028
1798
  return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
@@ -1031,23 +1801,26 @@ export async function createApp(options = {}) {
1031
1801
  const ingressNodes = await db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
1032
1802
  if (ingressNodes.length) {
1033
1803
  await check('ingress', async () => {
1034
- const bad = ingressNodes.filter(n => (n.ingress_status_json ? JSON.parse(n.ingress_status_json).state : 'unreported') !== 'serving').map(n => n.name);
1804
+ const bad = ingressNodes
1805
+ .filter((n) => (n.ingress_status_json ? JSON.parse(n.ingress_status_json).state : 'unreported') !==
1806
+ 'serving')
1807
+ .map((n) => n.name);
1035
1808
  if (bad.length)
1036
1809
  throw new Error(`ingress nodes not serving: ${bad.join(', ')}`);
1037
- return { nodes: ingressNodes.map(n => n.name) };
1810
+ return { nodes: ingressNodes.map((n) => n.name) };
1038
1811
  });
1039
1812
  }
1040
- res.json({ ok: checks.every(c => c.ok), registry: REGISTRY, checks });
1813
+ res.json({ ok: checks.every((c) => c.ok), registry: REGISTRY, checks });
1041
1814
  });
1042
1815
  api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
1043
- const nodes = req.body.node ? await db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node) : await db.all('SELECT id FROM nodes');
1816
+ const nodes = req.body.node
1817
+ ? await db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node)
1818
+ : await db.all('SELECT id FROM nodes');
1044
1819
  if (req.body.node && !nodes.length)
1045
1820
  return res.status(404).json({ error: `node ${req.body.node} not found` });
1046
- const ts = now();
1047
1821
  const commands = [];
1048
1822
  for (const node of nodes) {
1049
- const cmdId = id('cmd');
1050
- await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, null, 'diagnostics.run', 'pending', '{}', 0, ts, null, ts, ts);
1823
+ const cmdId = await queueCommand(node.id, null, 'diagnostics.run');
1051
1824
  commands.push({ commandId: cmdId, nodeId: node.id });
1052
1825
  }
1053
1826
  res.json({ commands });
@@ -1059,9 +1832,17 @@ export async function createApp(options = {}) {
1059
1832
  async function endpointsFor(states) {
1060
1833
  const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
1061
1834
  const nodes = await db.all('SELECT n.*, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
1062
- const views = nodes.map(n => ({ name: n.name, overlayIp: MESH && n.has_key ? n.overlay_ip : null, address: n.address, lastSeenAt: n.last_seen_at }));
1063
- const statuses = (await db.all('SELECT node_id,project_id,status_json FROM node_statuses'))
1064
- .map(r => ({ node: nodes.find(n => n.id === r.node_id)?.name ?? r.node_id, project: r.project_id, status: JSON.parse(r.status_json) }));
1835
+ const views = nodes.map((n) => ({
1836
+ name: n.name,
1837
+ overlayIp: MESH && n.has_key ? n.overlay_ip : null,
1838
+ address: n.address,
1839
+ lastSeenAt: n.last_seen_at,
1840
+ }));
1841
+ const statuses = (await db.all('SELECT node_id,project_id,status_json FROM node_statuses')).map((r) => ({
1842
+ node: nodes.find((n) => n.id === r.node_id)?.name ?? r.node_id,
1843
+ project: r.project_id,
1844
+ status: JSON.parse(r.status_json),
1845
+ }));
1065
1846
  return resolveServiceEndpoints(states, views, statuses, staleBefore);
1066
1847
  }
1067
1848
  async function networkFor(node) {
@@ -1069,10 +1850,39 @@ export async function createApp(options = {}) {
1069
1850
  return undefined;
1070
1851
  const peers = await db.all('SELECT w.*, n.name FROM wg_peers w JOIN nodes n ON n.id=w.node_id WHERE w.node_id != ? ORDER BY n.name', node.id);
1071
1852
  return {
1072
- interface: WG_INTERFACE, overlayIp: node.overlay_ip, cidr: MESH_CIDR, listenPort: WG_PORT,
1073
- peers: peers.map(p => ({ node: p.name, publicKey: p.public_key, endpoint: p.endpoint ?? undefined, allowedIps: p.allowed_ips.split(','), persistentKeepalive: WG_KEEPALIVE }))
1853
+ interface: WG_INTERFACE,
1854
+ overlayIp: node.overlay_ip,
1855
+ cidr: MESH_CIDR,
1856
+ listenPort: WG_PORT,
1857
+ peers: peers.map((p) => ({
1858
+ node: p.name,
1859
+ publicKey: p.public_key,
1860
+ endpoint: p.endpoint ?? undefined,
1861
+ allowedIps: p.allowed_ips.split(','),
1862
+ persistentKeepalive: WG_KEEPALIVE,
1863
+ })),
1074
1864
  };
1075
1865
  }
1866
+ /** Names of all nodes and of the ingress nodes, for port binding and upstream decisions. */
1867
+ async function nodeTopology() {
1868
+ const rows = await db.all('SELECT name,ingress FROM nodes ORDER BY name');
1869
+ return {
1870
+ nodes: rows.map((r) => r.name),
1871
+ ingressNodes: rows.filter((r) => r.ingress).map((r) => r.name),
1872
+ };
1873
+ }
1874
+ /** Where this node's published ports listen: the address other nodes and the ingress reach it on, or loopback. */
1875
+ async function portBindingFor(node, states) {
1876
+ const hasKey = MESH && node.overlay_ip && (await db.get('SELECT 1 FROM wg_peers WHERE node_id=?', node.id));
1877
+ const { nodes, ingressNodes } = await nodeTopology();
1878
+ const loopback = {};
1879
+ for (const state of states) {
1880
+ const services = loopbackIngressServices(state, node.name, ingressNodes, nodes);
1881
+ if (services.length)
1882
+ loopback[state.project] = services;
1883
+ }
1884
+ return { address: (hasKey ? node.overlay_ip : node.address) || null, loopback };
1885
+ }
1076
1886
  async function ingressFor(node, states) {
1077
1887
  const secrets = new Map();
1078
1888
  for (const state of states) {
@@ -1087,24 +1897,49 @@ export async function createApp(options = {}) {
1087
1897
  }
1088
1898
  }
1089
1899
  }
1090
- return buildIngressWork({ states, endpoints: await endpointsFor(states), node: node.name, secrets, acmeEmail: ACME_EMAIL });
1900
+ return buildIngressWork({
1901
+ states,
1902
+ endpoints: await endpointsFor(states),
1903
+ node: node.name,
1904
+ ...(await nodeTopology()),
1905
+ secrets,
1906
+ acmeEmail: ACME_EMAIL,
1907
+ });
1091
1908
  }
1909
+ get('/agent/v1/wake', authAgent, async (req, res) => {
1910
+ const after = Math.max(0, Number.parseInt(String(req.query.after ?? '0'), 10) || 0);
1911
+ const timeoutMs = Math.min(30_000, Math.max(0, Number.parseInt(String(req.query.timeout ?? '25000'), 10) || 0));
1912
+ await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
1913
+ res.json(await wakeHub.wait(req.node.id, after, timeoutMs));
1914
+ });
1092
1915
  get('/agent/v1/work', authAgent, async (req, res) => {
1093
1916
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
1094
1917
  const node = (await db.get('SELECT * FROM nodes WHERE id=?', req.node.id));
1095
1918
  const allStates = await currentDesiredStates();
1096
- const desiredStates = allStates.filter(st => Object.values(st.services).some(svc => (svc.placement?.nodes || []).includes(node.name) || !(svc.placement?.nodes?.length)));
1919
+ const desiredStates = allStates.filter((st) => Object.values(st.services).some((svc) => (svc.placement?.nodes || []).includes(node.name) || !svc.placement?.nodes?.length));
1097
1920
  const commands = await db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
1098
1921
  if (commands.length)
1099
1922
  await db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
1100
- const retainedImages = [...new Set((await retainedStates()).flatMap(state => Object.values(state.services).filter(service => service.artifact && (!service.placement.nodes?.length || service.placement.nodes.includes(node.name))).map(service => service.image)))];
1101
- const work = { serverTime: now(), nodeId: node.id, desiredStates, commands, retainedImages };
1923
+ const retainedImages = [
1924
+ ...new Set((await retainedStates()).flatMap((state) => Object.values(state.services)
1925
+ .filter((service) => service.artifact &&
1926
+ (!service.placement.nodes?.length || service.placement.nodes.includes(node.name)))
1927
+ .map((service) => service.image))),
1928
+ ];
1929
+ const work = {
1930
+ serverTime: now(),
1931
+ nodeId: node.id,
1932
+ desiredStates,
1933
+ commands,
1934
+ retainedImages,
1935
+ };
1102
1936
  const network = await networkFor(node);
1103
1937
  if (network)
1104
1938
  work.network = network;
1939
+ work.ports = await portBindingFor(node, desiredStates);
1105
1940
  if (SERVICE_DNS) {
1106
1941
  const all = await endpointsFor(allStates);
1107
- work.serviceEndpoints = Object.fromEntries(desiredStates.map(st => [st.project, all[st.project] || {}]));
1942
+ work.serviceEndpoints = Object.fromEntries(desiredStates.map((st) => [st.project, all[st.project] || {}]));
1108
1943
  }
1109
1944
  if (node.ingress)
1110
1945
  work.ingress = await ingressFor(node, allStates);
@@ -1124,6 +1959,11 @@ export async function createApp(options = {}) {
1124
1959
  if (report.ingress)
1125
1960
  await db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
1126
1961
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
1962
+ const nextEndpointSnapshot = JSON.stringify(await endpointsFor(await currentDesiredStates()));
1963
+ if (nextEndpointSnapshot !== endpointSnapshot) {
1964
+ endpointSnapshot = nextEndpointSnapshot;
1965
+ await wakeAll();
1966
+ }
1127
1967
  res.json({ ok: true });
1128
1968
  });
1129
1969
  post('/agent/v1/events', authAgent, async (req, res) => {
@@ -1131,7 +1971,7 @@ export async function createApp(options = {}) {
1131
1971
  for (const ev of evs)
1132
1972
  await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)', id('evt'), ev.projectId || null, ev.deploymentId || null, req.node.id, ev.level || 'info', ev.type || 'event', ev.message || '', JSON.stringify(ev.metadata || {}), now());
1133
1973
  if (Date.now() - lastEventPrune >= eventPruneIntervalMs)
1134
- await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
1974
+ await pruneEvents().catch((e) => console.error(`event pruning failed: ${errMessage(e)}`));
1135
1975
  res.json({ ok: true });
1136
1976
  });
1137
1977
  get('/agent/v1/secrets', authAgent, async (req, res) => {
@@ -1139,11 +1979,19 @@ export async function createApp(options = {}) {
1139
1979
  let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
1140
1980
  if (req.query.services) {
1141
1981
  const ds = await currentDesired(project);
1142
- const names = String(req.query.services).split(',').map(s => s.trim()).filter(Boolean);
1143
- const allowed = ds ? serviceSecretNames(JSON.parse(ds.state_json).compose, names) : new Set();
1144
- rows = rows.filter(r => allowed.has(r.name));
1982
+ const names = String(req.query.services)
1983
+ .split(',')
1984
+ .map((s) => s.trim())
1985
+ .filter(Boolean);
1986
+ const allowed = ds
1987
+ ? serviceSecretNames(JSON.parse(ds.state_json).compose, names)
1988
+ : new Set();
1989
+ rows = rows.filter((r) => allowed.has(r.name));
1145
1990
  }
1146
- const secrets = { secrets: Object.fromEntries(rows.map(r => [r.name, decrypt(r.encrypted_value, masterKey)])), versions: Object.fromEntries(rows.map(r => [r.name, r.version])) };
1991
+ const secrets = {
1992
+ secrets: Object.fromEntries(rows.map((r) => [r.name, decrypt(r.encrypted_value, masterKey)])),
1993
+ versions: Object.fromEntries(rows.map((r) => [r.name, r.version])),
1994
+ };
1147
1995
  res.json(secrets);
1148
1996
  });
1149
1997
  app.use((error, _req, res, next) => {
@@ -1153,16 +2001,69 @@ export async function createApp(options = {}) {
1153
2001
  });
1154
2002
  return app;
1155
2003
  }
2004
+ function attachAgentStream(app, server) {
2005
+ const runtime = streamRuntimes.get(app);
2006
+ if (!runtime)
2007
+ throw new Error('control app is missing its stream runtime');
2008
+ const wss = new WebSocketServer({ noServer: true, maxPayload: LIVE_MAX_FRAME_BYTES });
2009
+ server.on('upgrade', (request, socket, head) => {
2010
+ const reject = (status, reason) => {
2011
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
2012
+ socket.destroy();
2013
+ };
2014
+ const pathname = new URL(request.url || '/', 'http://localhost').pathname;
2015
+ if (!['/agent/v1/stream', '/api/v1/live'].includes(pathname)) {
2016
+ reject(404, 'Not Found');
2017
+ return;
2018
+ }
2019
+ const raw = String(request.headers.authorization || '').replace(/^Bearer /, '');
2020
+ void (async () => {
2021
+ if (pathname === '/api/v1/live') {
2022
+ const actor = await runtime.authenticateOperator(raw);
2023
+ if (!actor) {
2024
+ reject(401, 'Unauthorized');
2025
+ return;
2026
+ }
2027
+ if (!hasRole(actor, 'deploy')) {
2028
+ reject(403, 'Forbidden');
2029
+ return;
2030
+ }
2031
+ wss.handleUpgrade(request, socket, head, (ws) => runtime.live.operator(actor, ws));
2032
+ }
2033
+ else {
2034
+ const node = await runtime.authenticate(raw);
2035
+ if (!node) {
2036
+ reject(401, 'Unauthorized');
2037
+ return;
2038
+ }
2039
+ wss.handleUpgrade(request, socket, head, (ws) => {
2040
+ runtime.live.agent(node.id, ws);
2041
+ runtime.hub.connect(node.id, ws);
2042
+ });
2043
+ }
2044
+ })().catch(() => reject(500, 'Internal Server Error'));
2045
+ });
2046
+ server.once('close', () => wss.close());
2047
+ }
1156
2048
  export async function listen(app, port = PORT) {
1157
2049
  const certFile = process.env.TUG_TLS_CERT_FILE;
1158
2050
  const keyFile = process.env.TUG_TLS_KEY_FILE;
1159
2051
  if (certFile && keyFile) {
1160
2052
  const [cert, key] = await Promise.all([fs.readFile(certFile), fs.readFile(keyFile)]);
1161
2053
  const server = https.createServer({ cert, key }, app);
1162
- return new Promise(resolve => server.listen(port, () => { console.log(`tug-control listening on ${port} (tls)`); resolve(server); }));
2054
+ attachAgentStream(app, server);
2055
+ return new Promise((resolve) => server.listen(port, () => {
2056
+ console.log(`tug-control listening on ${port} (tls)`);
2057
+ resolve(server);
2058
+ }));
1163
2059
  }
1164
2060
  console.warn('TUG_TLS_CERT_FILE/TUG_TLS_KEY_FILE not set; serving plain HTTP');
1165
- return new Promise(resolve => { const server = app.listen(port, () => { console.log(`tug-control listening on ${port}`); resolve(server); }); });
2061
+ const server = http.createServer(app);
2062
+ attachAgentStream(app, server);
2063
+ return new Promise((resolve) => server.listen(port, () => {
2064
+ console.log(`tug-control listening on ${port}`);
2065
+ resolve(server);
2066
+ }));
1166
2067
  }
1167
2068
  if (import.meta.url === `file://${process.argv[1]}`)
1168
- createApp().then(app => listen(app));
2069
+ createApp().then((app) => listen(app));