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