plum-e2e 2.9.7 → 2.9.9
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/backend/lib/runnerProcess.js +24 -0
- package/backend/lib/serverBootstrap.js +0 -2
- package/backend/logs/runner-cmtcyrx2v0007o0017vy2cym0.log +6 -0
- package/backend/package-lock.json +1 -59
- package/backend/package.json +1 -2
- package/backend/scripts/manage-runners.mjs +77 -25
- package/backend/services/nodeExecutionService.js +6 -37
- package/backend/services/runnerService.js +9 -26
- package/bin/plum.js +90 -19
- package/package.json +1 -1
- package/backend/services/nodeStreamRegistry.js +0 -24
- package/backend/websockets/nodeSocketHandler.js +0 -40
|
@@ -201,6 +201,29 @@ function prepareEnv() {
|
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Polls a node's own /api/ping (at `baseUrl`, e.g. http://localhost:3002) until
|
|
206
|
+
* it reports mode:'node', or the timeout elapses. A detached spawn returns a
|
|
207
|
+
* pid immediately even when the process goes on to die on EADDRINUSE — callers
|
|
208
|
+
* use this to report honestly whether the node actually came up.
|
|
209
|
+
*/
|
|
210
|
+
async function nodeReachable(baseUrl, token, timeoutMs = 15000) {
|
|
211
|
+
const url = `${String(baseUrl).replace(/\/+$/, '')}/api/ping`;
|
|
212
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
213
|
+
const deadline = Date.now() + timeoutMs;
|
|
214
|
+
while (Date.now() < deadline) {
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(3000) });
|
|
217
|
+
if (res.ok) {
|
|
218
|
+
const body = await res.json().catch(() => ({}));
|
|
219
|
+
if (body.ok && body.mode === 'node') return true;
|
|
220
|
+
}
|
|
221
|
+
} catch {}
|
|
222
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
204
227
|
/**
|
|
205
228
|
* Spawns a detached node-mode server for the given runner and records its pid.
|
|
206
229
|
* Returns the registry entry.
|
|
@@ -316,6 +339,7 @@ module.exports = {
|
|
|
316
339
|
parsePort,
|
|
317
340
|
findPidOnPort,
|
|
318
341
|
killPort,
|
|
342
|
+
nodeReachable,
|
|
319
343
|
pruneDead,
|
|
320
344
|
statusOf,
|
|
321
345
|
ensureBackendDeps,
|
|
@@ -43,12 +43,10 @@ function wireRealtimeServices(io, isNodeMode) {
|
|
|
43
43
|
if (isNodeMode) return { cronService: null, backupCronService: null };
|
|
44
44
|
|
|
45
45
|
const socketHandler = require('../websockets/socketHandler.js');
|
|
46
|
-
const nodeSocketHandler = require('../websockets/nodeSocketHandler.js');
|
|
47
46
|
const cronService = require('../services/cronService');
|
|
48
47
|
const backupCronService = require('../services/backupCronService');
|
|
49
48
|
|
|
50
49
|
socketHandler(io);
|
|
51
|
-
nodeSocketHandler(io);
|
|
52
50
|
cronService.setSocketIO(io);
|
|
53
51
|
require('../routes/trigger.routes').setSocketIO(io);
|
|
54
52
|
|
|
@@ -26,8 +26,7 @@
|
|
|
26
26
|
"node-cron": "^3.0.3",
|
|
27
27
|
"picocolors": "^1.1.1",
|
|
28
28
|
"playwright": "1.50.1",
|
|
29
|
-
"socket.io": "^4.8.1"
|
|
30
|
-
"socket.io-client": "^4.8.3"
|
|
29
|
+
"socket.io": "^4.8.1"
|
|
31
30
|
},
|
|
32
31
|
"devDependencies": {
|
|
33
32
|
"@playwright/test": "1.50.1",
|
|
@@ -2385,40 +2384,6 @@
|
|
|
2385
2384
|
"node": ">=10.2.0"
|
|
2386
2385
|
}
|
|
2387
2386
|
},
|
|
2388
|
-
"node_modules/engine.io-client": {
|
|
2389
|
-
"version": "6.6.6",
|
|
2390
|
-
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
|
2391
|
-
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
|
2392
|
-
"license": "MIT",
|
|
2393
|
-
"dependencies": {
|
|
2394
|
-
"@socket.io/component-emitter": "~3.1.0",
|
|
2395
|
-
"debug": "~4.4.1",
|
|
2396
|
-
"engine.io-parser": "~5.2.1",
|
|
2397
|
-
"ws": "~8.21.0",
|
|
2398
|
-
"xmlhttprequest-ssl": "~2.1.1"
|
|
2399
|
-
}
|
|
2400
|
-
},
|
|
2401
|
-
"node_modules/engine.io-client/node_modules/ws": {
|
|
2402
|
-
"version": "8.21.3",
|
|
2403
|
-
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
|
2404
|
-
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
|
2405
|
-
"license": "MIT",
|
|
2406
|
-
"engines": {
|
|
2407
|
-
"node": ">=10.0.0"
|
|
2408
|
-
},
|
|
2409
|
-
"peerDependencies": {
|
|
2410
|
-
"bufferutil": "^4.0.1",
|
|
2411
|
-
"utf-8-validate": ">=5.0.2"
|
|
2412
|
-
},
|
|
2413
|
-
"peerDependenciesMeta": {
|
|
2414
|
-
"bufferutil": {
|
|
2415
|
-
"optional": true
|
|
2416
|
-
},
|
|
2417
|
-
"utf-8-validate": {
|
|
2418
|
-
"optional": true
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
},
|
|
2422
2387
|
"node_modules/engine.io-parser": {
|
|
2423
2388
|
"version": "5.2.3",
|
|
2424
2389
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
@@ -4328,21 +4293,6 @@
|
|
|
4328
4293
|
}
|
|
4329
4294
|
}
|
|
4330
4295
|
},
|
|
4331
|
-
"node_modules/socket.io-client": {
|
|
4332
|
-
"version": "4.8.3",
|
|
4333
|
-
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
|
4334
|
-
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
|
4335
|
-
"license": "MIT",
|
|
4336
|
-
"dependencies": {
|
|
4337
|
-
"@socket.io/component-emitter": "~3.1.0",
|
|
4338
|
-
"debug": "~4.4.1",
|
|
4339
|
-
"engine.io-client": "~6.6.1",
|
|
4340
|
-
"socket.io-parser": "~4.2.4"
|
|
4341
|
-
},
|
|
4342
|
-
"engines": {
|
|
4343
|
-
"node": ">=10.0.0"
|
|
4344
|
-
}
|
|
4345
|
-
},
|
|
4346
4296
|
"node_modules/socket.io-parser": {
|
|
4347
4297
|
"version": "4.2.4",
|
|
4348
4298
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
|
@@ -4980,14 +4930,6 @@
|
|
|
4980
4930
|
"node": ">=8.0"
|
|
4981
4931
|
}
|
|
4982
4932
|
},
|
|
4983
|
-
"node_modules/xmlhttprequest-ssl": {
|
|
4984
|
-
"version": "2.1.2",
|
|
4985
|
-
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
|
4986
|
-
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
|
4987
|
-
"engines": {
|
|
4988
|
-
"node": ">=0.4.0"
|
|
4989
|
-
}
|
|
4990
|
-
},
|
|
4991
4933
|
"node_modules/yallist": {
|
|
4992
4934
|
"version": "4.0.0",
|
|
4993
4935
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
package/backend/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import * as clack from '@clack/prompts';
|
|
|
19
19
|
import pc from 'picocolors';
|
|
20
20
|
import runnerProcess from '../lib/runnerProcess.js';
|
|
21
21
|
import nodeRegister from '../lib/nodeRegister.js';
|
|
22
|
+
import globalRegistry from '../lib/globalRegistry.js';
|
|
22
23
|
|
|
23
24
|
const {
|
|
24
25
|
isLocalUrl,
|
|
@@ -29,7 +30,8 @@ const {
|
|
|
29
30
|
startNode,
|
|
30
31
|
stopNode,
|
|
31
32
|
findPidOnPort,
|
|
32
|
-
killPort
|
|
33
|
+
killPort,
|
|
34
|
+
nodeReachable
|
|
33
35
|
} = runnerProcess;
|
|
34
36
|
const { generateToken, registerWithPrimary, detectLanIp, loadNodeConfig } = nodeRegister;
|
|
35
37
|
|
|
@@ -37,13 +39,23 @@ const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
|
|
|
37
39
|
|
|
38
40
|
const cancelled = (v) => clack.isCancel(v);
|
|
39
41
|
|
|
40
|
-
// The mutating /runners routes accept
|
|
41
|
-
// of an admin session
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
42
|
+
// The mutating /runners routes accept any registered runner's own token in
|
|
43
|
+
// place of an admin session (runnerOrAdmin.js). Use one: handed in by `plum`
|
|
44
|
+
// (which reads it from the primary's DB when run on the server host), or from
|
|
45
|
+
// a node's own .plum-node.json — this folder, then any other node install.
|
|
46
|
+
function resolveRunnerToken() {
|
|
47
|
+
if (process.env.PLUM_RUNNER_TOKEN) return process.env.PLUM_RUNNER_TOKEN;
|
|
48
|
+
const cwdToken = loadNodeConfig(process.cwd()).token;
|
|
49
|
+
if (cwdToken) return cwdToken;
|
|
50
|
+
for (const dir of globalRegistry.getInstalls('node')) {
|
|
51
|
+
const token = loadNodeConfig(dir).token;
|
|
52
|
+
if (token) return token;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const RUNNER_TOKEN = resolveRunnerToken();
|
|
45
57
|
function authHeaders() {
|
|
46
|
-
return
|
|
58
|
+
return RUNNER_TOKEN ? { Authorization: `Bearer ${RUNNER_TOKEN}` } : {};
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
/**
|
|
@@ -301,12 +313,18 @@ async function addRunner() {
|
|
|
301
313
|
});
|
|
302
314
|
if (cancelled(name)) return;
|
|
303
315
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
316
|
+
let port;
|
|
317
|
+
for (;;) {
|
|
318
|
+
port = await clack.text({
|
|
319
|
+
message: 'Local port the node listens on',
|
|
320
|
+
placeholder: '3002',
|
|
321
|
+
defaultValue: '3002'
|
|
322
|
+
});
|
|
323
|
+
if (cancelled(port)) return;
|
|
324
|
+
const pid = findPidOnPort(Number(port));
|
|
325
|
+
if (!pid) break;
|
|
326
|
+
clack.log.warn(pc.yellow(`Port ${port} is already in use (pid ${pid}) — choose another.`));
|
|
327
|
+
}
|
|
310
328
|
|
|
311
329
|
const defToken = process.env.NODE_TOKEN || generateToken();
|
|
312
330
|
const token = await clack.text({
|
|
@@ -325,41 +343,75 @@ async function addRunner() {
|
|
|
325
343
|
if (cancelled(urlInput)) return;
|
|
326
344
|
|
|
327
345
|
const url = resolveNodeUrl(urlInput || defaultUrl);
|
|
346
|
+
const local = isLocalUrl(url);
|
|
328
347
|
|
|
329
348
|
const s = clack.spinner();
|
|
330
349
|
s.start(`Registering "${name}" with the primary...`);
|
|
331
|
-
let id;
|
|
350
|
+
let id, reused;
|
|
332
351
|
try {
|
|
333
|
-
|
|
352
|
+
({ id, reused } = await registerWithPrimary({
|
|
334
353
|
primary: API_URL,
|
|
335
354
|
name,
|
|
336
355
|
url,
|
|
337
356
|
token,
|
|
338
357
|
browser: 'chromium'
|
|
339
|
-
});
|
|
340
|
-
id = res.id;
|
|
358
|
+
}));
|
|
341
359
|
s.stop(
|
|
342
|
-
|
|
343
|
-
? pc.green(`Reusing existing runner "${name}"`)
|
|
344
|
-
: pc.green(`Registered "${name}" (id ${id})`)
|
|
360
|
+
reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}"`)
|
|
345
361
|
);
|
|
346
362
|
} catch (e) {
|
|
347
363
|
s.stop(pc.red(`Could not register "${name}": ${e.message}`));
|
|
348
364
|
return;
|
|
349
365
|
}
|
|
350
366
|
|
|
351
|
-
|
|
367
|
+
// Verify the node actually answers before keeping the registration — start a
|
|
368
|
+
// local one here, expect a remote one to already be up. A failed check rolls
|
|
369
|
+
// the registration back so no dead runner is left behind.
|
|
370
|
+
let entry = null;
|
|
371
|
+
let ok = false;
|
|
372
|
+
if (local) {
|
|
373
|
+
prepareNodeEnv();
|
|
374
|
+
entry = startNode({ id, port, token });
|
|
375
|
+
s.start(`Waiting for "${name}" to come up on port ${port}...`);
|
|
376
|
+
ok = await nodeReachable(`http://localhost:${port}`, token, 20000);
|
|
377
|
+
s.stop(ok ? pc.green(`"${name}" is up (pid ${entry.pid})`) : pc.red(`"${name}" did not start`));
|
|
378
|
+
} else {
|
|
379
|
+
s.start(`Checking for a Plum node at ${url}...`);
|
|
380
|
+
ok = await nodeReachable(url, token, 8000);
|
|
381
|
+
s.stop(ok ? pc.green(`"${name}" is reachable`) : pc.red(`No Plum node answered at ${url}`));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (ok) {
|
|
385
|
+
clack.log.success(pc.green(`Runner "${name}" is ready.`));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
352
388
|
|
|
353
|
-
|
|
354
|
-
if (!
|
|
355
|
-
|
|
356
|
-
|
|
389
|
+
if (entry) stopNode(id, Number(port));
|
|
390
|
+
if (!reused) {
|
|
391
|
+
try {
|
|
392
|
+
await deleteRunner(id);
|
|
393
|
+
} catch {}
|
|
357
394
|
}
|
|
395
|
+
if (entry?.logFile) clack.note(entry.logFile, 'Node log');
|
|
396
|
+
clack.log.warn(
|
|
397
|
+
pc.yellow(
|
|
398
|
+
reused
|
|
399
|
+
? `"${name}" stays registered but is unreachable — check the port/URL.`
|
|
400
|
+
: `"${name}" was not registered — check the port/URL and try again.`
|
|
401
|
+
)
|
|
402
|
+
);
|
|
358
403
|
}
|
|
359
404
|
|
|
360
405
|
async function main() {
|
|
361
406
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Manage Runners ')));
|
|
362
407
|
|
|
408
|
+
if (!RUNNER_TOKEN) {
|
|
409
|
+
clack.log.warn(
|
|
410
|
+
'No runner token available — stop / restart / delete will be rejected.\n' +
|
|
411
|
+
'Run `plum manage-runners` on the primary host, or from a folder where you started a node.'
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
363
415
|
for (;;) {
|
|
364
416
|
const s = clack.spinner();
|
|
365
417
|
s.start(`Loading runners from ${API_URL}...`);
|
|
@@ -8,7 +8,6 @@ const os = require('os');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const { spawn } = require('child_process');
|
|
11
|
-
const { io: ioClient } = require('socket.io-client');
|
|
12
11
|
const { startRRwebPoller } = require('../lib/rrwebPoller');
|
|
13
12
|
const { TRIGGER_REMOTE } = require('../constants/triggers');
|
|
14
13
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
@@ -24,40 +23,17 @@ function getJob(jobId) {
|
|
|
24
23
|
return jobs[jobId];
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
// Opens this node's own outbound connection back to the primary for this job
|
|
28
|
-
// — logs/rrweb events are pushed the moment they happen instead of waiting to
|
|
29
|
-
// be polled. Uses the same token this node already validates incoming HTTP
|
|
30
|
-
// calls against (see authGuard), so there's no separate credential to manage.
|
|
31
|
-
// Best-effort: if the primary is unreachable this way, the job still runs —
|
|
32
|
-
// dispatchAndPoll falls back to draining logs from the HTTP poll when it
|
|
33
|
-
// never registered a socket relay for this jobId.
|
|
34
|
-
function connectPrimaryStream(primaryUrl, jobId) {
|
|
35
|
-
try {
|
|
36
|
-
const socket = ioClient(`${primaryUrl}/node-stream`, {
|
|
37
|
-
auth: { token: process.env.NODE_TOKEN },
|
|
38
|
-
reconnectionAttempts: 5,
|
|
39
|
-
timeout: 8000
|
|
40
|
-
});
|
|
41
|
-
socket.on('connect', () => socket.emit('join', jobId));
|
|
42
|
-
return socket;
|
|
43
|
-
} catch {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
26
|
// Starts a remote test job dispatched from the primary server: materializes
|
|
49
|
-
// any uploaded test files, spawns `npm run test`, and tracks logs
|
|
50
|
-
// HTTP polling (see pollJob).
|
|
27
|
+
// any uploaded test files, spawns `npm run test`, and tracks logs + rrweb
|
|
28
|
+
// batches for HTTP polling (see pollJob).
|
|
51
29
|
function startJob({
|
|
52
30
|
tags,
|
|
53
31
|
browser = DEFAULT_BROWSER,
|
|
54
32
|
workers = 1,
|
|
55
33
|
tests = null,
|
|
56
|
-
env: userEnv = {}
|
|
57
|
-
primaryUrl = null
|
|
34
|
+
env: userEnv = {}
|
|
58
35
|
}) {
|
|
59
36
|
const jobId = crypto.randomUUID();
|
|
60
|
-
const primaryStream = primaryUrl ? connectPrimaryStream(primaryUrl, jobId) : null;
|
|
61
37
|
|
|
62
38
|
// path.resolve ensures absolute even if TMPDIR env var is set to a relative path
|
|
63
39
|
const tmpdir = path.resolve(os.tmpdir());
|
|
@@ -82,8 +58,7 @@ function startJob({
|
|
|
82
58
|
jobs[jobId] = {
|
|
83
59
|
status: JOB_STATUS.RUNNING,
|
|
84
60
|
logs: '',
|
|
85
|
-
//
|
|
86
|
-
// notifyPublicUrl to stream these back over a socket instead.
|
|
61
|
+
// Both drained by pollJob — the primary has no socket back to this node.
|
|
87
62
|
rrwebBatches: [],
|
|
88
63
|
exitCode: null,
|
|
89
64
|
startedAt: Date.now(),
|
|
@@ -112,23 +87,17 @@ function startJob({
|
|
|
112
87
|
|
|
113
88
|
const ssPoller = startRRwebPoller(ssDir, (batch) => {
|
|
114
89
|
jobs[jobId].rrwebBatches.push(batch);
|
|
115
|
-
primaryStream?.emit('rrweb-batch', batch);
|
|
116
90
|
});
|
|
117
91
|
|
|
118
92
|
const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
|
|
119
93
|
proc.stdout.on('data', (d) => {
|
|
120
|
-
|
|
121
|
-
jobs[jobId].logs += text;
|
|
122
|
-
primaryStream?.emit('log', text);
|
|
94
|
+
jobs[jobId].logs += d.toString();
|
|
123
95
|
});
|
|
124
96
|
proc.stderr.on('data', (d) => {
|
|
125
|
-
|
|
126
|
-
jobs[jobId].logs += text;
|
|
127
|
-
primaryStream?.emit('log', text);
|
|
97
|
+
jobs[jobId].logs += d.toString();
|
|
128
98
|
});
|
|
129
99
|
proc.on('close', (code) => {
|
|
130
100
|
ssPoller.stop();
|
|
131
|
-
primaryStream?.close();
|
|
132
101
|
jobs[jobId].status = code === 0 ? JOB_STATUS.DONE : JOB_STATUS.ERROR;
|
|
133
102
|
jobs[jobId].exitCode = code;
|
|
134
103
|
|
|
@@ -11,8 +11,6 @@ const { BUILT_IN_RUNNER_ID } = require('../constants/triggers');
|
|
|
11
11
|
const { DEFAULT_BROWSER } = require('../constants/defaults');
|
|
12
12
|
const { bearerHeader } = require('../lib/authHeader');
|
|
13
13
|
const { JOB_STATUS } = require('../constants/jobStatus');
|
|
14
|
-
const settingsService = require('./settingsService');
|
|
15
|
-
const nodeStreamRegistry = require('./nodeStreamRegistry');
|
|
16
14
|
|
|
17
15
|
// ---------------------------------------------------------------------------
|
|
18
16
|
// Runner CRUD
|
|
@@ -209,7 +207,6 @@ async function dispatchAndPoll(
|
|
|
209
207
|
const finish = (code, content) => {
|
|
210
208
|
if (settled) return;
|
|
211
209
|
settled = true;
|
|
212
|
-
if (jobId) nodeStreamRegistry.unregisterRelay(jobId);
|
|
213
210
|
onDone(code, content);
|
|
214
211
|
};
|
|
215
212
|
|
|
@@ -220,12 +217,6 @@ async function dispatchAndPoll(
|
|
|
220
217
|
return;
|
|
221
218
|
}
|
|
222
219
|
|
|
223
|
-
// The node needs to know where to open its own socket back to us — without
|
|
224
|
-
// this it has no way to reach the primary, since today only the reverse
|
|
225
|
-
// (primary knowing each node's url) is configured.
|
|
226
|
-
const { notifyPublicUrl } = await settingsService.getWebhooks();
|
|
227
|
-
const primaryUrl = notifyPublicUrl ? notifyPublicUrl.replace(/\/$/, '') : null;
|
|
228
|
-
|
|
229
220
|
let jobId;
|
|
230
221
|
try {
|
|
231
222
|
const res = await fetch(`${runner.url}/api/execute`, {
|
|
@@ -239,8 +230,7 @@ async function dispatchAndPoll(
|
|
|
239
230
|
browser,
|
|
240
231
|
workers,
|
|
241
232
|
tests: collectTestFiles(),
|
|
242
|
-
env: loadTestEnv(process.cwd())
|
|
243
|
-
...(primaryUrl ? { primaryUrl } : {})
|
|
233
|
+
env: loadTestEnv(process.cwd())
|
|
244
234
|
}),
|
|
245
235
|
signal: AbortSignal.timeout(10000)
|
|
246
236
|
});
|
|
@@ -254,15 +244,11 @@ async function dispatchAndPoll(
|
|
|
254
244
|
|
|
255
245
|
onLog(`Connected to runner "${runner.name}" — job ${jobId}\n`);
|
|
256
246
|
|
|
257
|
-
// With a primaryUrl configured, the node streams logs/rrweb events over its
|
|
258
|
-
// own socket instead — draining them from the poll too would duplicate them.
|
|
259
|
-
if (primaryUrl) {
|
|
260
|
-
nodeStreamRegistry.registerRelay(jobId, { onRRwebBatch, onLog });
|
|
261
|
-
}
|
|
262
|
-
|
|
263
247
|
let logOffset = 0;
|
|
264
248
|
let rrwebOffset = 0;
|
|
265
249
|
let polling = false;
|
|
250
|
+
// Tight interval: the live viewer reads logs and rrweb batches straight off
|
|
251
|
+
// this poll — primary→node, so nothing has to connect the other way.
|
|
266
252
|
const poll = setInterval(async () => {
|
|
267
253
|
if (polling) return;
|
|
268
254
|
polling = true;
|
|
@@ -277,15 +263,12 @@ async function dispatchAndPoll(
|
|
|
277
263
|
if (!res.ok) return;
|
|
278
264
|
const body = await res.json();
|
|
279
265
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
onLog(body.logs);
|
|
284
|
-
logOffset += body.logs.length;
|
|
285
|
-
}
|
|
286
|
-
for (const batch of body.rrwebBatches ?? []) onRRwebBatch?.(batch);
|
|
287
|
-
rrwebOffset += body.rrwebBatches?.length ?? 0;
|
|
266
|
+
if (body.logs) {
|
|
267
|
+
onLog(body.logs);
|
|
268
|
+
logOffset += body.logs.length;
|
|
288
269
|
}
|
|
270
|
+
for (const batch of body.rrwebBatches ?? []) onRRwebBatch?.(batch);
|
|
271
|
+
rrwebOffset += body.rrwebBatches?.length ?? 0;
|
|
289
272
|
|
|
290
273
|
if (body.status === JOB_STATUS.DONE || body.status === JOB_STATUS.ERROR) {
|
|
291
274
|
clearInterval(poll);
|
|
@@ -297,7 +280,7 @@ async function dispatchAndPoll(
|
|
|
297
280
|
} finally {
|
|
298
281
|
polling = false;
|
|
299
282
|
}
|
|
300
|
-
},
|
|
283
|
+
}, 1000);
|
|
301
284
|
}
|
|
302
285
|
|
|
303
286
|
module.exports = {
|
package/bin/plum.js
CHANGED
|
@@ -595,7 +595,8 @@ async function configureNode({ force }) {
|
|
|
595
595
|
const saved = loadNodeConfig(cwd);
|
|
596
596
|
|
|
597
597
|
let primary = getFlag(args, '--primary') ?? process.env.PRIMARY_URL ?? saved.primary ?? '';
|
|
598
|
-
|
|
598
|
+
// Not 3001 — that's the primary's default; a co-located node must not collide.
|
|
599
|
+
let port = getFlag(args, '--port') ?? saved.port ?? '3002';
|
|
599
600
|
let browser = getFlag(args, '--browser') ?? saved.browser ?? 'chromium';
|
|
600
601
|
let token = getFlag(args, '--token') ?? process.env.NODE_TOKEN ?? saved.token ?? generateToken();
|
|
601
602
|
let name = getFlag(args, '--name') ?? saved.name ?? `node-${token.slice(0, 6)}`;
|
|
@@ -613,6 +614,14 @@ async function configureNode({ force }) {
|
|
|
613
614
|
const interactive = force || (interactiveAllowed() && !hasFlags);
|
|
614
615
|
|
|
615
616
|
if (interactive) {
|
|
617
|
+
const nameVal = await clack.text({
|
|
618
|
+
message: 'Runner name',
|
|
619
|
+
placeholder: name,
|
|
620
|
+
defaultValue: name
|
|
621
|
+
});
|
|
622
|
+
if (clack.isCancel(nameVal)) cancelAndExit();
|
|
623
|
+
name = nameVal || name;
|
|
624
|
+
|
|
616
625
|
const primaryVal = await clack.text({
|
|
617
626
|
message: 'Your Plum server backend URL',
|
|
618
627
|
placeholder: primary || 'http://localhost:3001',
|
|
@@ -732,7 +741,13 @@ async function nodeStart({ reconfig }) {
|
|
|
732
741
|
const cfg = await configureNode({ force: reconfig });
|
|
733
742
|
const registeredId = await registerNode(cfg);
|
|
734
743
|
|
|
735
|
-
const {
|
|
744
|
+
const {
|
|
745
|
+
prepareEnv,
|
|
746
|
+
startNode: startNodeProc,
|
|
747
|
+
findPidOnPort,
|
|
748
|
+
killPort,
|
|
749
|
+
nodeReachable
|
|
750
|
+
} = runnerProcessLib();
|
|
736
751
|
|
|
737
752
|
clack.log.step('Preparing environment (deps + browsers)...');
|
|
738
753
|
try {
|
|
@@ -749,12 +764,30 @@ async function nodeStart({ reconfig }) {
|
|
|
749
764
|
|
|
750
765
|
if (registeredId) {
|
|
751
766
|
try {
|
|
767
|
+
// A stale process on this port makes the new node die on EADDRINUSE
|
|
768
|
+
// after a silent retry loop — clear it first (almost always a
|
|
769
|
+
// previous instance of this same node).
|
|
770
|
+
if (findPidOnPort(Number(cfg.port))) {
|
|
771
|
+
clack.log.step(`Port ${cfg.port} is in use — freeing it...`);
|
|
772
|
+
await killPort(Number(cfg.port));
|
|
773
|
+
}
|
|
752
774
|
const entry = startNodeProc({ id: String(registeredId), port: cfg.port, token: cfg.token });
|
|
753
|
-
clack.log.
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
775
|
+
clack.log.step(`Starting "${cfg.name}" (pid ${entry.pid})...`);
|
|
776
|
+
const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
|
|
777
|
+
if (up) {
|
|
778
|
+
clack.log.success(
|
|
779
|
+
pc.green(
|
|
780
|
+
`Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at ${entry.logFile}`
|
|
781
|
+
)
|
|
782
|
+
);
|
|
783
|
+
} else {
|
|
784
|
+
clack.log.error(
|
|
785
|
+
pc.red(
|
|
786
|
+
`Node "${cfg.name}" did not come up on port ${cfg.port}. Check ${entry.logFile} — the port may still be held by another process.`
|
|
787
|
+
)
|
|
788
|
+
);
|
|
789
|
+
process.exitCode = 1;
|
|
790
|
+
}
|
|
758
791
|
} catch (e) {
|
|
759
792
|
clack.log.warn(`Could not start runner process: ${e.message}`);
|
|
760
793
|
}
|
|
@@ -769,7 +802,7 @@ async function nodeStart({ reconfig }) {
|
|
|
769
802
|
async function nodeRestart() {
|
|
770
803
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Restart ')));
|
|
771
804
|
const { loadNodeConfig } = nodeRegisterLib();
|
|
772
|
-
const { prepareEnv, stopNode, startNode } = runnerProcessLib();
|
|
805
|
+
const { prepareEnv, stopNode, startNode, killPort, nodeReachable } = runnerProcessLib();
|
|
773
806
|
const cfg = loadNodeConfig(process.cwd());
|
|
774
807
|
|
|
775
808
|
if (!cfg.id) {
|
|
@@ -802,17 +835,27 @@ async function nodeRestart() {
|
|
|
802
835
|
}
|
|
803
836
|
|
|
804
837
|
try {
|
|
838
|
+
await killPort(Number(cfg.port));
|
|
805
839
|
const entry = startNode({ id: String(cfg.id), port: cfg.port, token: cfg.token });
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
840
|
+
const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
|
|
841
|
+
if (up) {
|
|
842
|
+
clack.log.success(
|
|
843
|
+
pc.green(`Node "${cfg.name}" restarted (pid ${entry.pid}) — logs at ${entry.logFile}`)
|
|
844
|
+
);
|
|
845
|
+
clack.outro(pc.green('Node restarted.'));
|
|
846
|
+
} else {
|
|
847
|
+
clack.log.error(
|
|
848
|
+
pc.red(
|
|
849
|
+
`Node "${cfg.name}" did not come back up on port ${cfg.port}. Check ${entry.logFile}.`
|
|
850
|
+
)
|
|
851
|
+
);
|
|
852
|
+
process.exitCode = 1;
|
|
853
|
+
clack.outro(pc.red('Node not restarted.'));
|
|
854
|
+
}
|
|
811
855
|
} catch (e) {
|
|
812
856
|
clack.log.warn(`Could not restart node: ${e.message}`);
|
|
857
|
+
clack.outro(pc.red('Node not restarted.'));
|
|
813
858
|
}
|
|
814
|
-
|
|
815
|
-
clack.outro(pc.green('Node restarted.'));
|
|
816
859
|
}
|
|
817
860
|
|
|
818
861
|
async function nodeReconfig() {
|
|
@@ -823,13 +866,41 @@ async function nodeReconfig() {
|
|
|
823
866
|
clack.outro(pc.dim('Done.'));
|
|
824
867
|
}
|
|
825
868
|
|
|
869
|
+
// stop/restart/delete on the /runners API want a registered runner's token
|
|
870
|
+
// (runnerOrAdmin). On the primary host those tokens sit in the backend's DB —
|
|
871
|
+
// pull one straight from the running container so the menu can authenticate
|
|
872
|
+
// without a node's .plum-node.json in the current folder. Best-effort: on a
|
|
873
|
+
// node-only box (no server install / no Docker) this no-ops and the menu falls
|
|
874
|
+
// back to a local .plum-node.json.
|
|
875
|
+
function readRunnerTokenFromPrimary() {
|
|
876
|
+
const { getInstalls } = globalRegistryLib();
|
|
877
|
+
const script =
|
|
878
|
+
"require('./services/prisma').runner.findFirst({select:{token:true}})" +
|
|
879
|
+
".then(r=>{process.stdout.write(r&&r.token||'');process.exit(0)}).catch(()=>process.exit(1))";
|
|
880
|
+
for (const dir of getInstalls('server')) {
|
|
881
|
+
try {
|
|
882
|
+
const token = execSync(`docker compose exec -T backend node -e "${script}"`, {
|
|
883
|
+
cwd: dir,
|
|
884
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
885
|
+
timeout: 15000
|
|
886
|
+
})
|
|
887
|
+
.toString()
|
|
888
|
+
.trim();
|
|
889
|
+
if (token) return token;
|
|
890
|
+
} catch {}
|
|
891
|
+
}
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
|
|
826
895
|
async function openManageRunnersMenu(primaryUrl) {
|
|
827
896
|
const manageScript = path.join(plumRoot, 'backend', 'scripts', 'manage-runners.mjs');
|
|
828
897
|
const apiUrl = primaryUrl || 'http://localhost:3001';
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
898
|
+
const env = { ...process.env, PLUM_API_URL: apiUrl };
|
|
899
|
+
if (!env.PLUM_RUNNER_TOKEN) {
|
|
900
|
+
const token = readRunnerTokenFromPrimary();
|
|
901
|
+
if (token) env.PLUM_RUNNER_TOKEN = token;
|
|
902
|
+
}
|
|
903
|
+
const menu = spawn(process.execPath, [manageScript], { stdio: 'inherit', env });
|
|
833
904
|
await new Promise((resolve) => menu.on('exit', resolve));
|
|
834
905
|
}
|
|
835
906
|
|
package/package.json
CHANGED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* This file is part of Plum.
|
|
3
|
-
* Licensed under the MIT License. See LICENSE file in the project root for details.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// In-memory jobId -> relay callbacks, so an incoming node socket (which only
|
|
7
|
-
// knows its own jobId) can find where to forward live rrweb/log events —
|
|
8
|
-
// registered by whichever dispatch call (socketHandler, cronService) started
|
|
9
|
-
// this job and already knows the browser-facing emit target/laneId.
|
|
10
|
-
const relays = new Map();
|
|
11
|
-
|
|
12
|
-
function registerRelay(jobId, { onRRwebBatch, onLog }) {
|
|
13
|
-
relays.set(jobId, { onRRwebBatch, onLog });
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function unregisterRelay(jobId) {
|
|
17
|
-
relays.delete(jobId);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function getRelay(jobId) {
|
|
21
|
-
return relays.get(jobId);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
module.exports = { registerRelay, unregisterRelay, getRelay };
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* This file is part of Plum.
|
|
3
|
-
* Licensed under the MIT License. See LICENSE file in the project root for details.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const runnerService = require('../services/runnerService');
|
|
7
|
-
const nodeStreamRegistry = require('../services/nodeStreamRegistry');
|
|
8
|
-
|
|
9
|
-
// A separate namespace from the browser-facing default one — a node connects
|
|
10
|
-
// with its own runner token (the same credential it already uses to call back
|
|
11
|
-
// into the primary's HTTP control routes, see runnerService.isValidToken) and
|
|
12
|
-
// announces the jobId it's streaming for, which nodeStreamRegistry maps back
|
|
13
|
-
// to whichever dispatch call is waiting on it.
|
|
14
|
-
function nodeSocketHandler(io) {
|
|
15
|
-
const nodeIo = io.of('/node-stream');
|
|
16
|
-
|
|
17
|
-
nodeIo.use(async (socket, next) => {
|
|
18
|
-
const token = socket.handshake.auth?.token;
|
|
19
|
-
if (await runnerService.isValidToken(token)) return next();
|
|
20
|
-
next(new Error('unauthorized'));
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
nodeIo.on('connection', (socket) => {
|
|
24
|
-
let jobId = null;
|
|
25
|
-
|
|
26
|
-
socket.on('join', (id) => {
|
|
27
|
-
jobId = id;
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
socket.on('rrweb-batch', (batch) => {
|
|
31
|
-
if (jobId) nodeStreamRegistry.getRelay(jobId)?.onRRwebBatch?.(batch);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
socket.on('log', (text) => {
|
|
35
|
-
if (jobId) nodeStreamRegistry.getRelay(jobId)?.onLog?.(text);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
module.exports = nodeSocketHandler;
|