@wpelevator/tug 0.2.0 → 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,29 +9,405 @@ 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 });
361
+ const missingProject = (project) => `project ${project} has no desired state`;
362
+ const missingService = (project, service) => `service ${service} not found in project ${project}`;
363
+ /** Collapse a node report into the deployment error `tug deploy` prints. Compose failures are multi-line; keep the reason, not the progress log. */
364
+ function deploymentFailureMessage(rows) {
365
+ const limit = 500;
366
+ const oneLine = (value) => {
367
+ const flat = value.replace(/\s+/g, ' ').trim();
368
+ return flat.length > limit ? `${flat.slice(0, limit)}...` : flat;
369
+ };
370
+ const details = rows.map((row) => {
371
+ let status = {};
372
+ try {
373
+ status = JSON.parse(row.status_json);
374
+ }
375
+ catch {
376
+ /* a bad report still names the node */
377
+ }
378
+ const parts = [];
379
+ if (typeof status.error === 'string' && status.error.trim())
380
+ parts.push(oneLine(status.error));
381
+ for (const [service, state] of Object.entries(status.serviceStates || {})) {
382
+ if (typeof state?.rolloutError === 'string' && state.rolloutError.trim())
383
+ parts.push(`${service}: ${oneLine(state.rolloutError)}`);
384
+ }
385
+ const detail = parts.join('; ');
386
+ return detail ? `${row.node_id}: ${detail}` : row.node_id;
387
+ });
388
+ return `one or more assigned nodes reported failed health: ${details.join('; ')}`;
389
+ }
24
390
  /** Versions of rollback deployments start with this; `tug deploy` may not use it, which is how automatic rollback tells them apart. */
25
391
  const ROLLBACK_VERSION_PREFIX = 'rollback-';
26
- const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
392
+ const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map((s) => `'${s}'`).join(',');
27
393
  const PORT = Number(process.env.PORT || 8080);
28
394
  export async function createApp(options = {}) {
29
- 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
+ };
30
403
  const DATA_DIR = process.env.TUG_DATA_DIR || './data';
31
404
  const MAX_IMAGE_BYTES = Number(process.env.TUG_MAX_IMAGE_BYTES || 5 * 1024 ** 3);
32
405
  if (!Number.isSafeInteger(MAX_IMAGE_BYTES) || MAX_IMAGE_BYTES < 1)
33
406
  throw new Error('TUG_MAX_IMAGE_BYTES must be a positive safe integer');
34
- 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
+ });
35
411
  const dbFile = path.join(DATA_DIR, 'tug.db');
36
412
  const masterKeyFile = path.join(DATA_DIR, 'master.key');
37
413
  const REGISTRY = process.env.TUG_REGISTRY?.trim() || '';
@@ -54,7 +430,7 @@ export async function createApp(options = {}) {
54
430
  async function reloadRegistry() {
55
431
  if (!REGISTRY_CONTAINER)
56
432
  return;
57
- 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}`));
58
434
  }
59
435
  async function upsertRegistryCredential(name, password) {
60
436
  if (!REGISTRY || !REGISTRY_AUTH_PATH)
@@ -67,7 +443,10 @@ export async function createApp(options = {}) {
67
443
  existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
68
444
  }
69
445
  catch { }
70
- 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}:`));
71
450
  lines.push(line);
72
451
  await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
73
452
  await fs.writeFile(REGISTRY_AUTH_PATH, `${lines.join('\n')}\n`);
@@ -83,31 +462,74 @@ export async function createApp(options = {}) {
83
462
  existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
84
463
  }
85
464
  catch { }
86
- 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}:`));
87
469
  await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
88
470
  await fs.writeFile(REGISTRY_AUTH_PATH, lines.length ? `${lines.join('\n')}\n` : '');
89
471
  });
90
472
  await reloadRegistry();
91
473
  }
92
474
  const db = await (options.openDatabase || openDb)(dbFile);
475
+ const wakeHub = new WakeHub();
476
+ // Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
477
+ // not a whole number falls back to the default.
478
+ const envCount = (name, fallback) => {
479
+ const raw = process.env[name];
480
+ if (raw === undefined || raw === '')
481
+ return fallback;
482
+ const value = Number(raw);
483
+ if (Number.isInteger(value) && value >= 0)
484
+ return value;
485
+ console.warn(`${name}=${JSON.stringify(raw)} is not a whole number; using ${fallback}`);
486
+ return fallback;
487
+ };
488
+ const EVENT_RETENTION_DAYS = envCount('TUG_EVENT_RETENTION_DAYS', 30);
489
+ const EVENT_MAX_ROWS = envCount('TUG_EVENT_MAX_ROWS', 100_000);
490
+ const eventPruneIntervalMs = options.eventPruneIntervalMs ?? 60 * 60 * 1000;
491
+ let lastEventPrune = Date.now();
492
+ async function pruneEvents() {
493
+ lastEventPrune = Date.now();
494
+ let removed = 0;
495
+ if (EVENT_RETENTION_DAYS > 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;
498
+ if (EVENT_MAX_ROWS > 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;
501
+ if (removed)
502
+ console.log(`removed ${removed} old events (kept ${EVENT_RETENTION_DAYS || 'any'} days, at most ${EVENT_MAX_ROWS || 'unlimited'} rows)`);
503
+ return removed;
504
+ }
505
+ await pruneEvents().catch((e) => console.error(`event pruning failed: ${errMessage(e)}`));
93
506
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
94
507
  const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
95
508
  if (!operator) {
96
509
  const t = process.env.TUG_OPERATOR_TOKEN || token('tug_op');
97
510
  await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
98
- 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(() => { });
99
514
  console.log(`operator token: ${t}`);
100
515
  }
101
516
  const app = express();
517
+ const authenticateAgent = async (raw) => raw
518
+ ? db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw))
519
+ : undefined;
102
520
  let restartRequired = false;
103
- 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());
104
524
  app.use(express.json({ limit: '20mb' }));
105
525
  // Hold the writer for the operation lifetime, including after an HTTP disconnect.
106
526
  let writer = Promise.resolve();
107
527
  async function withWriter(operation) {
108
528
  const previous = writer;
109
529
  let release;
110
- writer = new Promise(resolve => { release = resolve; });
530
+ writer = new Promise((resolve) => {
531
+ release = resolve;
532
+ });
111
533
  await previous;
112
534
  try {
113
535
  if (restartRequired)
@@ -122,7 +544,9 @@ export async function createApp(options = {}) {
122
544
  async function withPublication(operation) {
123
545
  const previous = publication;
124
546
  let release;
125
- publication = new Promise(resolve => { release = resolve; });
547
+ publication = new Promise((resolve) => {
548
+ release = resolve;
549
+ });
126
550
  await previous;
127
551
  try {
128
552
  return await operation();
@@ -155,18 +579,31 @@ export async function createApp(options = {}) {
155
579
  route(method, url, [guard(role, resolve ?? undefined), ...handlers]);
156
580
  };
157
581
  const deploymentProject = async (req) => (await db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id))?.project_id;
158
- 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
+ };
159
586
  /** Audit trail entry for a control action; bookkeeping failures are logged, never returned to the caller. */
160
- const audit = (actor, event) => recordEvent(db, { ...event, actor: actor.name }).catch(e => console.error(`audit event failed: ${errMessage(e)}`));
161
- 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: [] };
162
594
  const authAgent = async (req, res, next) => {
163
595
  const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
164
- const node = raw && await db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw));
596
+ const node = await authenticateAgent(raw);
165
597
  if (!node)
166
598
  return res.status(401).json({ error: 'unauthorized' });
167
599
  req.node = node;
168
600
  next();
169
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 = '';
170
607
  async function updateDeploymentConvergence(projectId) {
171
608
  const project = await db.get('SELECT * FROM projects WHERE id=?', projectId);
172
609
  if (!project?.current_deployment_id)
@@ -182,15 +619,22 @@ export async function createApp(options = {}) {
182
619
  if (!required.size)
183
620
  return;
184
621
  const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
185
- const byNode = new Map(statuses.map(s => [s.node_id, s]));
186
- const reportsRevision = (node) => state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version;
187
- const failed = [...required].some(node => reportsRevision(node) && byNode.get(node)?.health === 'failed');
188
- if (failed) {
189
- const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', 'one or more assigned nodes reported failed health', now(), project.current_deployment_id, 'rolling_out');
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));
629
+ if (failedNodes.length) {
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');
190
631
  if (marked.changes === 1)
191
632
  scheduleAutoRollback(projectId, project.current_deployment_id, state);
192
633
  }
193
- 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 ?? ''));
194
638
  if (ok)
195
639
  await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
196
640
  }
@@ -225,7 +669,11 @@ export async function createApp(options = {}) {
225
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);
226
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);
227
671
  await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
228
- 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
+ };
229
677
  }
230
678
  finally {
231
679
  await releaseLock(lock);
@@ -247,17 +695,60 @@ export async function createApp(options = {}) {
247
695
  return;
248
696
  }
249
697
  try {
250
- const out = await withWriter(() => createRollback(projectId, { actor: AUTO_ROLLBACK_ACTOR, auto: true, failedDeploymentId: deploymentId }));
251
- 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
+ });
252
708
  }
253
709
  catch (e) {
254
710
  await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `deployment ${deploymentId} failed but automatic rollback was not possible: ${errMessage(e)}`).catch(() => { });
255
711
  }
256
- })().catch(e => console.error('auto rollback failed:', errMessage(e)));
712
+ })().catch((e) => console.error('auto rollback failed:', errMessage(e)));
257
713
  }
258
714
  async function currentDesired(projectId) {
259
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);
260
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
+ });
261
752
  async function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
262
753
  const name = `project:${project}`;
263
754
  const ts = now();
@@ -275,7 +766,11 @@ export async function createApp(options = {}) {
275
766
  err.lock = existing;
276
767
  throw err;
277
768
  }
278
- 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)));
279
774
  heartbeat.unref();
280
775
  return { name, owner, heartbeat };
281
776
  }
@@ -283,18 +778,76 @@ export async function createApp(options = {}) {
283
778
  if (lock?.heartbeat)
284
779
  clearInterval(lock.heartbeat);
285
780
  if (lock)
286
- 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 });
287
790
  }
288
- 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
+ });
289
839
  get('/healthz', (req, res) => res.json({ ok: true }));
290
- 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) => {
291
841
  const name = req.body.name || 'site';
292
842
  const ts = now();
293
843
  await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
294
844
  res.json(await db.get('SELECT * FROM projects WHERE id=?', name));
295
845
  });
296
- 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)));
297
- 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=?';
298
851
  /** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
299
852
  async function ensureOverlayIp(nodeId) {
300
853
  if (!MESH)
@@ -303,7 +856,7 @@ export async function createApp(options = {}) {
303
856
  const row = await db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
304
857
  if (row?.overlay_ip)
305
858
  return;
306
- 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);
307
860
  try {
308
861
  await db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
309
862
  return;
@@ -327,18 +880,32 @@ export async function createApp(options = {}) {
327
880
  await db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
328
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());
329
882
  }
330
- async function registerNode({ name, address, publicAddress, wgPublicKey }) {
883
+ async function registerNode({ name, address, publicAddress, wgPublicKey, labels, }) {
331
884
  if (wgPublicKey !== undefined && !isWgKey(wgPublicKey))
332
885
  throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
333
886
  const agentToken = token('tug_agent');
334
887
  const registryToken = token('tug_reg');
335
888
  const ts = now();
336
- await db.run(`INSERT INTO nodes(id,name,address,public_address,status,agent_token_hash,registry_token_hash,created_at,updated_at)
337
- 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);
338
891
  await ensureOverlayIp(name);
339
892
  await syncWgPeer(name, wgPublicKey);
340
893
  await upsertRegistryCredential(name, registryToken);
341
- 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
+ };
342
909
  return { node, agentToken, registryToken };
343
910
  }
344
911
  api('post', '/api/v1/nodes/register', null, async (req, res) => {
@@ -346,7 +913,13 @@ export async function createApp(options = {}) {
346
913
  if (!name)
347
914
  return res.status(400).json({ error: 'name required' });
348
915
  try {
349
- 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
+ }));
350
923
  }
351
924
  catch (e) {
352
925
  sendError(res, e);
@@ -376,10 +949,18 @@ export async function createApp(options = {}) {
376
949
  }
377
950
  const name = row.name || body.name;
378
951
  if (!name)
379
- 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)' });
380
955
  let result;
381
956
  try {
382
- 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
+ });
383
964
  }
384
965
  catch (e) {
385
966
  return sendError(res, e);
@@ -392,7 +973,15 @@ export async function createApp(options = {}) {
392
973
  const enrolled = { ...result, registry: REGISTRY };
393
974
  res.json(enrolled);
394
975
  });
395
- 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
+ });
396
985
  api('post', '/api/v1/nodes/:name/rotate-registry-token', null, async (req, res) => {
397
986
  const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
398
987
  if (!node)
@@ -410,11 +999,15 @@ export async function createApp(options = {}) {
410
999
  await removeRegistryCredential(node.name);
411
1000
  res.json({ ok: true, node: node.name, revoked: true });
412
1001
  });
413
- async function findNode(nameOrId) { return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId); }
414
- 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 = {}) {
415
1006
  const commandId = id('cmd');
416
1007
  const ts = now();
417
- 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;
418
1011
  }
419
1012
  // The private key lives only on the node, so rotation is a command the agent executes: it
420
1013
  // generates a new keypair, reports the public key and peers pick it up from their next work response.
@@ -426,12 +1019,48 @@ export async function createApp(options = {}) {
426
1019
  return res.status(404).json({ error: 'node not found' });
427
1020
  res.json({ commandId: await queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
428
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
+ });
429
1057
  api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
430
1058
  const node = await findNode(req.params.name);
431
1059
  if (!node)
432
1060
  return res.status(404).json({ error: 'node not found' });
433
1061
  const enabled = req.body.enabled !== false;
434
1062
  await db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
1063
+ wakeHub.wake(node.id);
435
1064
  res.json({ ok: true, node: node.name, ingress: enabled });
436
1065
  });
437
1066
  post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
@@ -442,16 +1071,21 @@ export async function createApp(options = {}) {
442
1071
  return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
443
1072
  try {
444
1073
  await syncWgPeer(req.node.id, publicKey);
1074
+ await wakeAll();
445
1075
  res.json({ ok: true });
446
1076
  }
447
1077
  catch (e) {
448
- 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);
449
1081
  }
450
1082
  });
451
1083
  post('/api/v1/registry/auth', async (req, res) => {
452
1084
  const username = req.body.username;
453
1085
  const password = req.body.password;
454
- 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;
455
1089
  if (!node)
456
1090
  return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
457
1091
  res.json({ ok: true, node: node.name });
@@ -459,14 +1093,27 @@ export async function createApp(options = {}) {
459
1093
  api('post', '/api/v1/tokens', null, async (req, res) => {
460
1094
  const request = parseTokenRequest(req.body);
461
1095
  const created = await createToken(db, request, req.actor.name);
462
- 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
+ });
463
1106
  res.json(created);
464
1107
  });
465
1108
  api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: await listTokens(db) }));
466
1109
  api('delete', '/api/v1/tokens/:name', null, async (req, res) => {
467
- if (!await revokeToken(db, req.params.name))
1110
+ if (!(await revokeToken(db, req.params.name)))
468
1111
  return res.status(404).json({ error: 'token not found' });
469
- 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
+ });
470
1117
  res.json({ ok: true, name: req.params.name });
471
1118
  });
472
1119
  api('post', '/api/v1/secrets', bodyProject, async (req, res) => {
@@ -480,7 +1127,13 @@ export async function createApp(options = {}) {
480
1127
  const ts = now();
481
1128
  await db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
482
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);
483
- 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
+ });
484
1137
  res.json({ ok: true, name, version: (old?.version || 0) + 1 });
485
1138
  }
486
1139
  catch (e) {
@@ -490,24 +1143,33 @@ export async function createApp(options = {}) {
490
1143
  await releaseLock(lock);
491
1144
  }
492
1145
  });
493
- 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
+ }));
494
1149
  api('delete', '/api/v1/secrets/:name', queryProject, async (req, res) => {
495
1150
  const project = String(req.query.project || 'site');
496
1151
  const removed = await db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
497
- if (removed.changes)
498
- 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
+ }
499
1161
  res.json({ ok: true });
500
1162
  });
501
1163
  const artifactDir = path.join(DATA_DIR, 'images');
502
1164
  await ensureDir(artifactDir);
503
1165
  async function retainedStates() {
504
1166
  // All published history is retained. This conservative superset protects failed/lagging versions too.
505
- 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));
506
1168
  }
507
1169
  async function collectArtifacts() {
508
1170
  const states = await retainedStates();
509
- const refs = new Set(states.flatMap(state => Object.values(state.services).flatMap(service => service.artifact ? [service.artifact.buildId] : [])));
510
- 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)));
511
1173
  const rows = await db.all('SELECT * FROM builds');
512
1174
  for (const row of rows) {
513
1175
  if (refs.has(row.id) || imageRefs.has(row.image_ref))
@@ -520,7 +1182,7 @@ export async function createApp(options = {}) {
520
1182
  await db.run('DELETE FROM builds WHERE id=?', row.id);
521
1183
  }
522
1184
  const remaining = await db.all('SELECT id FROM builds');
523
- 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`]));
524
1186
  for (const name of await fs.readdir(artifactDir))
525
1187
  if (!owned.has(name))
526
1188
  await fs.rm(path.join(artifactDir, name), { force: true });
@@ -530,10 +1192,10 @@ export async function createApp(options = {}) {
530
1192
  await collectArtifacts();
531
1193
  const receiveDeployment = upload.any();
532
1194
  api('post', '/api/v1/deployments', null, (req, res) => {
533
- receiveDeployment(req, res, error => {
1195
+ receiveDeployment(req, res, (error) => {
534
1196
  void withWriter(() => deployRequest(req, res, error)).catch(async (failure) => {
535
1197
  const files = Array.isArray(req.files) ? req.files : [];
536
- 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(() => { })));
537
1199
  sendError(res, failure);
538
1200
  });
539
1201
  });
@@ -561,31 +1223,89 @@ export async function createApp(options = {}) {
561
1223
  const compose = body.compose;
562
1224
  const version = body.version || `git-${Date.now()}`;
563
1225
  assertProject(req.actor, project);
564
- 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)
565
1229
  throw httpError(400, 'invalid project or version');
566
1230
  if (version.startsWith(ROLLBACK_VERSION_PREFIX))
567
1231
  throw httpError(400, `version cannot start with "${ROLLBACK_VERSION_PREFIX}" (reserved for rollback deployments)`);
568
- 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
+ }
569
1277
  try {
570
- validateCompose(compose, nodes.map(n => n.name));
1278
+ validateCompose(compose, nodes.map((n) => n.name));
571
1279
  }
572
1280
  catch (e) {
573
1281
  throw httpError(400, errMessage(e));
574
1282
  }
575
- 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);
576
1286
  const local = body.localImages;
577
- const context = files.find(file => file.fieldname === 'context');
1287
+ const context = files.find((file) => file.fieldname === 'context');
578
1288
  if (local !== undefined && (!Array.isArray(local) || context))
579
1289
  throw httpError(400, 'localImages must be an array and cannot accompany a source context');
580
- if (files.filter(file => file.fieldname === 'context').length > 1)
1290
+ if (files.filter((file) => file.fieldname === 'context').length > 1)
581
1291
  throw httpError(400, 'duplicate source context');
582
1292
  if (local) {
583
- if (local.some(item => !item || typeof item !== 'object'))
1293
+ if (local.some((item) => !item || typeof item !== 'object'))
584
1294
  throw httpError(400, 'invalid local image manifest');
585
- 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)
586
1298
  throw httpError(400, 'localImages must contain exactly one image for each build service');
587
1299
  for (const item of local) {
588
- 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)
589
1309
  throw httpError(400, 'invalid local image manifest or file');
590
1310
  }
591
1311
  }
@@ -598,7 +1318,7 @@ export async function createApp(options = {}) {
598
1318
  if (ing?.tls !== 'custom')
599
1319
  continue;
600
1320
  for (const secret of [ing.cert, ing.key])
601
- 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)))
602
1322
  throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret-set first`);
603
1323
  }
604
1324
  lock = await acquireProjectLock(project, 'deploy');
@@ -609,7 +1329,14 @@ export async function createApp(options = {}) {
609
1329
  const ts = now();
610
1330
  await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
611
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);
612
- 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
+ });
613
1340
  if (context) {
614
1341
  contextDir = path.resolve(DATA_DIR, 'build-contexts', depId);
615
1342
  await unpackContext(context.path, contextDir);
@@ -630,15 +1357,18 @@ export async function createApp(options = {}) {
630
1357
  const temporary = `${final}.tmp`;
631
1358
  try {
632
1359
  if (local) {
633
- const item = local.find(item => item.service === service);
634
- 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);
635
1362
  try {
636
1363
  info = await images.validate(file.path, item.imageRef);
637
1364
  }
638
1365
  catch (error) {
639
1366
  throw httpError(400, `${service}: invalid image archive: ${errMessage(error)}`);
640
1367
  }
641
- 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)
642
1372
  throw httpError(400, `${service}: local image metadata/checksum mismatch`);
643
1373
  importedClientRef = item.imageRef;
644
1374
  await images.run('docker', ['load', '-i', file.path]);
@@ -648,7 +1378,13 @@ export async function createApp(options = {}) {
648
1378
  await images.run('docker', ['tag', item.imageRef, imageRef]);
649
1379
  }
650
1380
  else
651
- 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
+ });
652
1388
  if (REGISTRY) {
653
1389
  if (process.env.TUG_PUSH !== 'false')
654
1390
  await images.run('docker', ['push', imageRef]);
@@ -678,13 +1414,16 @@ export async function createApp(options = {}) {
678
1414
  await db.exec('BEGIN IMMEDIATE');
679
1415
  try {
680
1416
  const published = await db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), depId);
681
- 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())))
682
1419
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
683
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);
684
1421
  for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
685
1422
  const lp = volumeSpec?.['x-tug'] || {};
686
1423
  const nodeName = lp.placement?.nodes?.[0] || null;
687
- 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;
688
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);
689
1428
  }
690
1429
  await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
@@ -695,20 +1434,32 @@ export async function createApp(options = {}) {
695
1434
  throw e;
696
1435
  }
697
1436
  });
698
- await audit(req.actor, { type: 'deploy', project, deploymentId: depId, message: `deployment ${depId} (${version}) started by ${req.actor.name}`, metadata: { version } });
699
- 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
+ };
700
1449
  }
701
1450
  catch (e) {
702
1451
  if (depId)
703
- 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(() => { });
704
1455
  responseError = e;
705
1456
  }
706
1457
  finally {
707
1458
  await releaseLock(lock);
708
- 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(() => { })));
709
1460
  if (contextDir)
710
1461
  await fs.rm(contextDir, { recursive: true, force: true });
711
- await collectArtifacts().catch(e => console.error('image cleanup failed:', errMessage(e)));
1462
+ await collectArtifacts().catch((e) => console.error('image cleanup failed:', errMessage(e)));
712
1463
  }
713
1464
  if (responseError)
714
1465
  sendError(res, responseError);
@@ -717,7 +1468,8 @@ export async function createApp(options = {}) {
717
1468
  }
718
1469
  get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
719
1470
  const states = await retainedStates();
720
- 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))));
721
1473
  if (!allowed)
722
1474
  return res.status(404).json({ error: 'image not found' });
723
1475
  const row = await db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
@@ -725,7 +1477,12 @@ export async function createApp(options = {}) {
725
1477
  return res.status(404).json({ error: 'image not found' });
726
1478
  try {
727
1479
  await fs.access(path.join(DATA_DIR, row.artifact_path));
728
- 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
+ });
729
1486
  await pipeline(createReadStream(path.join(DATA_DIR, row.artifact_path)), res);
730
1487
  }
731
1488
  catch {
@@ -743,21 +1500,37 @@ export async function createApp(options = {}) {
743
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);
744
1501
  if (result.changes !== 1)
745
1502
  return res.status(409).json({ error: 'deployment is no longer active' });
746
- 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
+ });
747
1509
  res.json({ ok: true });
748
1510
  }));
749
1511
  api('post', '/api/v1/rollback', bodyProject, async (req, res) => withWriter(async () => {
750
1512
  const project = req.body.project || 'site';
751
1513
  try {
752
- const out = await createRollback(project, { actor: req.actor.name, force: Boolean(req.body.force) });
753
- 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
+ });
754
1525
  res.json(out);
755
1526
  }
756
1527
  catch (e) {
757
1528
  sendError(res, e);
758
1529
  }
759
1530
  }));
760
- 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
+ }));
761
1534
  api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
762
1535
  const project = String(req.query.project || 'site');
763
1536
  const deleteData = String(req.query.deleteData || 'false') === 'true';
@@ -769,19 +1542,26 @@ export async function createApp(options = {}) {
769
1542
  return res.status(404).json({ error: 'volume not found' });
770
1543
  const config = JSON.parse(volume.config_json || '{}');
771
1544
  if ((config.persistent || volume.type !== 'ephemeral') && !deleteData)
772
- 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' });
773
1548
  const ds = await currentDesired(project);
774
1549
  if (ds) {
775
1550
  const state = JSON.parse(ds.state_json);
776
- 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);
777
1554
  if (inUse.length)
778
- 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(', ')}` });
779
1558
  }
780
1559
  let commandId = null;
781
1560
  if (deleteData && volume.node_id) {
782
- commandId = id('cmd');
783
- const ts = now();
784
- 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
+ });
785
1565
  }
786
1566
  await db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
787
1567
  res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
@@ -807,14 +1587,21 @@ export async function createApp(options = {}) {
807
1587
  await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
808
1588
  await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
809
1589
  const artifactRef = path.resolve(backupDir, `${backupId}.tar.gz`);
810
- 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
+ ]);
811
1596
  await fs.rm(staging, { recursive: true, force: true });
812
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());
813
1598
  res.json({ backupId, artifactRef, state: 'completed' });
814
1599
  }));
815
1600
  api('post', '/api/v1/control/restore', null, async (req, res) => withWriter(async () => {
816
1601
  if (!req.body.confirm)
817
- 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
+ });
818
1605
  const artifactRef = req.body.artifactRef;
819
1606
  if (typeof artifactRef !== 'string' || !artifactRef)
820
1607
  return res.status(400).json({ error: 'artifactRef required' });
@@ -823,19 +1610,25 @@ export async function createApp(options = {}) {
823
1610
  try {
824
1611
  await fs.rm(restoreDir, { recursive: true, force: true });
825
1612
  await ensureDir(restoreDir);
826
- await scanArchive(artifactRef, entry => {
827
- 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))
828
1617
  throw new Error('unsafe control backup entry');
829
1618
  });
830
1619
  await tar.x({ file: artifactRef, cwd: restoreDir, strict: true });
831
1620
  const key = (await fs.readFile(path.join(restoreDir, 'master.key'), 'utf8')).trim();
832
1621
  if (!/^[A-Za-z0-9+/]{43}=$/.test(key))
833
1622
  throw new Error('invalid backup master key');
834
- 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
+ });
835
1628
  const integrity = await snapshot.get('PRAGMA integrity_check');
836
1629
  if (integrity?.integrity_check !== 'ok')
837
1630
  throw new Error('backup database failed integrity check');
838
- 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'")))
839
1632
  throw new Error('backup database has no operator identity');
840
1633
  for (const secret of await snapshot.all('SELECT * FROM secrets'))
841
1634
  decrypt(secret.encrypted_value, key);
@@ -846,10 +1639,14 @@ export async function createApp(options = {}) {
846
1639
  if (!service.artifact)
847
1640
  continue;
848
1641
  const build = await snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
849
- 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))
850
1644
  throw new Error('backup contains an unsafe or missing image reference');
851
1645
  const actual = await hashArchive(path.join(restoreDir, build.artifact_path));
852
- 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)
853
1650
  throw new Error('backup image archive checksum mismatch');
854
1651
  }
855
1652
  }
@@ -872,32 +1669,40 @@ export async function createApp(options = {}) {
872
1669
  res.status(restartRequired ? 500 : 400).json({ error: errMessage(e), restartRequired });
873
1670
  }
874
1671
  }));
875
- async function commandForService({ project, service, type, payload }) {
1672
+ async function commandForService({ project, service, type, payload, }) {
876
1673
  if (!service)
877
1674
  throw httpError(400, 'service required');
878
1675
  const ds = await currentDesired(project);
879
1676
  if (!ds)
880
- throw httpError(404, 'project has no desired state');
1677
+ throw httpError(404, missingProject(project));
881
1678
  const state = JSON.parse(ds.state_json);
882
1679
  const svc = state.services?.[service];
883
1680
  if (!svc)
884
- throw httpError(404, 'service not found');
1681
+ throw httpError(404, missingService(project, service));
885
1682
  const nodeName = svc.placement?.nodes?.[0];
886
1683
  if (!nodeName)
887
1684
  throw httpError(400, 'service has no explicit node placement');
888
1685
  const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
889
1686
  if (!node)
890
1687
  throw httpError(404, 'node not found');
891
- const cmdId = id('cmd');
892
- const ts = now();
893
- 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 });
894
1689
  return { commandId: cmdId, nodeId: node.id };
895
1690
  }
896
1691
  api('post', '/api/v1/restart', bodyProject, async (req, res) => {
897
1692
  try {
898
1693
  const project = req.body.project || 'site';
899
- const queued = await commandForService({ project, service: req.body.service, type: 'service.restart', payload: {} });
900
- 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
+ });
901
1706
  res.json(queued);
902
1707
  }
903
1708
  catch (e) {
@@ -912,20 +1717,18 @@ export async function createApp(options = {}) {
912
1717
  return res.status(400).json({ error: 'service required' });
913
1718
  const ds = await currentDesired(project);
914
1719
  if (!ds)
915
- return res.status(404).json({ error: 'project has no desired state' });
1720
+ return res.status(404).json({ error: missingProject(project) });
916
1721
  const state = JSON.parse(ds.state_json);
917
1722
  const svc = state.services?.[service];
918
1723
  if (!svc)
919
- return res.status(404).json({ error: 'service not found' });
1724
+ return res.status(404).json({ error: missingService(project, service) });
920
1725
  const nodeName = svc.placement?.nodes?.[0];
921
1726
  if (!nodeName)
922
1727
  return res.status(400).json({ error: 'service has no explicit node placement' });
923
1728
  const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
924
1729
  if (!node)
925
1730
  return res.status(404).json({ error: 'node not found' });
926
- const cmdId = id('cmd');
927
- const ts = now();
928
- 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 });
929
1732
  res.json({ commandId: cmdId, nodeId: node.id });
930
1733
  });
931
1734
  api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(await db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
@@ -936,7 +1739,12 @@ export async function createApp(options = {}) {
936
1739
  const projects = projectScope(req.actor, 'id');
937
1740
  const deployments = projectScope(req.actor, 'project_id');
938
1741
  const statuses = projectScope(req.actor, 'project_id');
939
- 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
+ });
940
1748
  });
941
1749
  api('get', '/api/v1/events', null, async (req, res) => {
942
1750
  const project = req.query.project ? String(req.query.project) : null;
@@ -959,15 +1767,23 @@ export async function createApp(options = {}) {
959
1767
  checks.push({ name, ok: false, error: errMessage(e) });
960
1768
  }
961
1769
  }
962
- await check('sqlite', async () => ({ value: (await db.get('SELECT COUNT(*) AS count FROM projects')).count }));
963
- 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
+ }));
964
1776
  await check('master_key', async () => ({ configured: Boolean(masterKey) }));
965
- await check('docker', async () => ({ output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim() }));
966
- 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
+ }));
967
1783
  if (MESH) {
968
1784
  await check('mesh', async () => {
969
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');
970
- 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);
971
1787
  if (missing.length)
972
1788
  throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
973
1789
  return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
@@ -976,21 +1792,26 @@ export async function createApp(options = {}) {
976
1792
  const ingressNodes = await db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
977
1793
  if (ingressNodes.length) {
978
1794
  await check('ingress', async () => {
979
- 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);
980
1799
  if (bad.length)
981
1800
  throw new Error(`ingress nodes not serving: ${bad.join(', ')}`);
982
- return { nodes: ingressNodes.map(n => n.name) };
1801
+ return { nodes: ingressNodes.map((n) => n.name) };
983
1802
  });
984
1803
  }
985
- res.json({ ok: checks.every(c => c.ok), registry: REGISTRY, checks });
1804
+ res.json({ ok: checks.every((c) => c.ok), registry: REGISTRY, checks });
986
1805
  });
987
1806
  api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
988
- 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');
989
- const ts = now();
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');
1810
+ if (req.body.node && !nodes.length)
1811
+ return res.status(404).json({ error: `node ${req.body.node} not found` });
990
1812
  const commands = [];
991
1813
  for (const node of nodes) {
992
- const cmdId = id('cmd');
993
- 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');
994
1815
  commands.push({ commandId: cmdId, nodeId: node.id });
995
1816
  }
996
1817
  res.json({ commands });
@@ -1002,9 +1823,17 @@ export async function createApp(options = {}) {
1002
1823
  async function endpointsFor(states) {
1003
1824
  const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
1004
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');
1005
- 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 }));
1006
- const statuses = (await db.all('SELECT node_id,project_id,status_json FROM node_statuses'))
1007
- .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
+ }));
1008
1837
  return resolveServiceEndpoints(states, views, statuses, staleBefore);
1009
1838
  }
1010
1839
  async function networkFor(node) {
@@ -1012,10 +1841,39 @@ export async function createApp(options = {}) {
1012
1841
  return undefined;
1013
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);
1014
1843
  return {
1015
- interface: WG_INTERFACE, overlayIp: node.overlay_ip, cidr: MESH_CIDR, listenPort: WG_PORT,
1016
- 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
+ })),
1855
+ };
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),
1017
1863
  };
1018
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
+ }
1019
1877
  async function ingressFor(node, states) {
1020
1878
  const secrets = new Map();
1021
1879
  for (const state of states) {
@@ -1030,24 +1888,49 @@ export async function createApp(options = {}) {
1030
1888
  }
1031
1889
  }
1032
1890
  }
1033
- 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
+ });
1034
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
+ });
1035
1906
  get('/agent/v1/work', authAgent, async (req, res) => {
1036
1907
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
1037
1908
  const node = (await db.get('SELECT * FROM nodes WHERE id=?', req.node.id));
1038
1909
  const allStates = await currentDesiredStates();
1039
- 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));
1040
1911
  const commands = await db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
1041
1912
  if (commands.length)
1042
1913
  await db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
1043
- 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)))];
1044
- 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
+ };
1045
1927
  const network = await networkFor(node);
1046
1928
  if (network)
1047
1929
  work.network = network;
1930
+ work.ports = await portBindingFor(node, desiredStates);
1048
1931
  if (SERVICE_DNS) {
1049
1932
  const all = await endpointsFor(allStates);
1050
- 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] || {}]));
1051
1934
  }
1052
1935
  if (node.ingress)
1053
1936
  work.ingress = await ingressFor(node, allStates);
@@ -1067,20 +1950,39 @@ export async function createApp(options = {}) {
1067
1950
  if (report.ingress)
1068
1951
  await db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
1069
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
+ }
1958
+ res.json({ ok: true });
1959
+ });
1960
+ post('/agent/v1/events', authAgent, async (req, res) => {
1961
+ const evs = req.body.events || [req.body];
1962
+ for (const ev of evs)
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());
1964
+ if (Date.now() - lastEventPrune >= eventPruneIntervalMs)
1965
+ await pruneEvents().catch((e) => console.error(`event pruning failed: ${errMessage(e)}`));
1070
1966
  res.json({ ok: true });
1071
1967
  });
1072
- post('/agent/v1/events', authAgent, async (req, res) => { const evs = req.body.events || [req.body]; for (const ev of evs)
1073
- 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()); res.json({ ok: true }); });
1074
1968
  get('/agent/v1/secrets', authAgent, async (req, res) => {
1075
1969
  const project = String(req.query.project || 'site');
1076
1970
  let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
1077
1971
  if (req.query.services) {
1078
1972
  const ds = await currentDesired(project);
1079
- const names = String(req.query.services).split(',').map(s => s.trim()).filter(Boolean);
1080
- const allowed = ds ? serviceSecretNames(JSON.parse(ds.state_json).compose, names) : new Set();
1081
- 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));
1082
1981
  }
1083
- 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
+ };
1084
1986
  res.json(secrets);
1085
1987
  });
1086
1988
  app.use((error, _req, res, next) => {
@@ -1090,16 +1992,69 @@ export async function createApp(options = {}) {
1090
1992
  });
1091
1993
  return app;
1092
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
+ }
1093
2039
  export async function listen(app, port = PORT) {
1094
2040
  const certFile = process.env.TUG_TLS_CERT_FILE;
1095
2041
  const keyFile = process.env.TUG_TLS_KEY_FILE;
1096
2042
  if (certFile && keyFile) {
1097
2043
  const [cert, key] = await Promise.all([fs.readFile(certFile), fs.readFile(keyFile)]);
1098
2044
  const server = https.createServer({ cert, key }, app);
1099
- 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
+ }));
1100
2050
  }
1101
2051
  console.warn('TUG_TLS_CERT_FILE/TUG_TLS_KEY_FILE not set; serving plain HTTP');
1102
- 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
+ }));
1103
2058
  }
1104
2059
  if (import.meta.url === `file://${process.argv[1]}`)
1105
- createApp().then(app => listen(app));
2060
+ createApp().then((app) => listen(app));