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