@wpelevator/tug 0.2.1 → 0.3.0

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