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