@andrian.yablonskyy/thub-coordinator 1.0.31 → 1.0.32
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/package.json +2 -2
- package/src/api/agent.js +3 -3
- package/src/api/sse.js +24 -2
- package/src/services/jobs.js +23 -2
- package/src/services/registry.js +36 -1
- package/src/web/routes.js +4 -0
- package/test/scheduler.test.js +46 -0
- package/views/jobs/show.pug +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.32",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"lint:fix": "eslint . --fix"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@andrian.yablonskyy/thub-common": "^1.0.
|
|
22
|
+
"@andrian.yablonskyy/thub-common": "^1.0.27",
|
|
23
23
|
"better-sqlite3": "^12.4.1",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
25
|
"express-session": "^1.18.2",
|
package/src/api/agent.js
CHANGED
|
@@ -47,7 +47,7 @@ function createAgentRouter({ services, config }){
|
|
|
47
47
|
}
|
|
48
48
|
const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
49
49
|
res.json({
|
|
50
|
-
...job,
|
|
50
|
+
...services.jobs.publicJob(job),
|
|
51
51
|
resource: resource ? { id: resource.id, name: resource.name } : job.resource_name ? { id: null, name: job.resource_name } : null
|
|
52
52
|
});
|
|
53
53
|
});
|
|
@@ -60,13 +60,13 @@ function createAgentRouter({ services, config }){
|
|
|
60
60
|
mine: req.query.mine === 'true' || req.query.mine === '1',
|
|
61
61
|
limit: req.query.limit ? Number(req.query.limit) : undefined
|
|
62
62
|
});
|
|
63
|
-
res.json({ jobs });
|
|
63
|
+
res.json({ jobs: jobs.map(services.jobs.publicJob) });
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
router.post('/jobs/:id/cancel', auth, (req, res, next) => {
|
|
67
67
|
try {
|
|
68
68
|
const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: false });
|
|
69
|
-
res.json(job);
|
|
69
|
+
res.json(services.jobs.publicJob(job));
|
|
70
70
|
}
|
|
71
71
|
catch (err){
|
|
72
72
|
next(err);
|
package/src/api/sse.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const { TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
16
|
+
const { TERMINAL_JOB_STATES, JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
17
|
+
|
|
18
|
+
const WAITING_CHECK_MS = 15_000;
|
|
17
19
|
|
|
18
20
|
// §6.4: log / state / end events, resumable via Last-Event-ID (§7: "the
|
|
19
21
|
// Agent reconnects with exponential backoff and resumes from the last seq").
|
|
@@ -62,7 +64,26 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
62
64
|
const resourceName = (id) => (id ? registry.get(id)?.name : undefined);
|
|
63
65
|
writeEvent('state', { state: current.state, resource: resourceName(current.resource_id) });
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
// While QUEUED: why no Client has taken it yet (`waiting`), re-checked
|
|
68
|
+
// as Clients come and go; sent again only when the reason changes.
|
|
69
|
+
let lastReason = null;
|
|
70
|
+
function checkWaiting(){
|
|
71
|
+
const job = jobs.get(jobId);
|
|
72
|
+
if (job?.state !== JOB_STATES.QUEUED){
|
|
73
|
+
lastReason = null;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { type, labels = [], group, client } = job.spec.target,
|
|
77
|
+
reason = registry.waitingReason(type, labels, group, client, { jobImage: Boolean(job.spec.image) });
|
|
78
|
+
if (reason && reason !== lastReason){
|
|
79
|
+
writeEvent('waiting', { reason });
|
|
80
|
+
}
|
|
81
|
+
lastReason = reason;
|
|
82
|
+
}
|
|
83
|
+
checkWaiting();
|
|
84
|
+
const waitingTimer = setInterval(checkWaiting, WAITING_CHECK_MS),
|
|
85
|
+
|
|
86
|
+
onLog = (entry) => {
|
|
66
87
|
if (entry.jobId === jobId){
|
|
67
88
|
writeEvent('log', { ts: entry.ts, stream: entry.stream, line: entry.line }, entry.seq);
|
|
68
89
|
}
|
|
@@ -82,6 +103,7 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
82
103
|
};
|
|
83
104
|
|
|
84
105
|
function cleanup(){
|
|
106
|
+
clearInterval(waitingTimer);
|
|
85
107
|
bus.off('job.log', onLog);
|
|
86
108
|
bus.off('job.state', onState);
|
|
87
109
|
bus.off('job.finished', onFinished);
|
package/src/services/jobs.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
16
|
+
const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
17
|
{ paginate } = require('./list-prefs'),
|
|
18
18
|
// Named in spec errors: a field the Agent sends but this thub-common
|
|
19
19
|
// doesn't know means the Coordinator needs an update.
|
|
@@ -248,6 +248,10 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
248
248
|
bus.emit('job.queued', { jobId: id });
|
|
249
249
|
}
|
|
250
250
|
if (TERMINAL_JOB_STATES.has(state)){
|
|
251
|
+
// A LOST job may be requeued (markLost), and needs its env for that.
|
|
252
|
+
if (state !== JOB_STATES.LOST){
|
|
253
|
+
forgetEnv(id);
|
|
254
|
+
}
|
|
251
255
|
bus.emit('job.finished', { jobId: id, state });
|
|
252
256
|
// LOST means the resource is presumed OUT_OF_SERVICE (that's why the
|
|
253
257
|
// job went LOST) — it only comes back via a real heartbeat (§4.1),
|
|
@@ -378,14 +382,30 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
378
382
|
if (job.state === JOB_STATES.LOST || TERMINAL_JOB_STATES.has(job.state)){
|
|
379
383
|
return job;
|
|
380
384
|
}
|
|
381
|
-
const spec = job.spec;
|
|
382
385
|
setState(id, JOB_STATES.LOST);
|
|
383
386
|
if (config.scheduler.requeueOnLost && job.attempt < 2){
|
|
384
387
|
return setState(id, JOB_STATES.QUEUED);
|
|
385
388
|
}
|
|
389
|
+
forgetEnv(id);
|
|
386
390
|
return get(id);
|
|
387
391
|
}
|
|
388
392
|
|
|
393
|
+
// A finished job's `--env` values (registry passwords etc.) aren't kept:
|
|
394
|
+
// only the names stay, masked.
|
|
395
|
+
function forgetEnv(id){
|
|
396
|
+
const row = db.prepare('SELECT spec FROM jobs WHERE id = ?').get(id),
|
|
397
|
+
spec = row && JSON.parse(row.spec);
|
|
398
|
+
if (spec?.env){
|
|
399
|
+
db.prepare('UPDATE jobs SET spec = ? WHERE id = ?').run(JSON.stringify({ ...spec, env: maskEnv(spec.env) }), id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A job as the Agent API shows it: `--env` values masked (any Agent token
|
|
404
|
+
// can read any job; only the Client running it gets them).
|
|
405
|
+
function publicJob(job){
|
|
406
|
+
return job?.spec?.env ? { ...job, spec: { ...job.spec, env: maskEnv(job.spec.env) } } : job;
|
|
407
|
+
}
|
|
408
|
+
|
|
389
409
|
function checkTimeouts(){
|
|
390
410
|
const now = Date.now(),
|
|
391
411
|
active = db
|
|
@@ -513,6 +533,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
513
533
|
|
|
514
534
|
return {
|
|
515
535
|
get,
|
|
536
|
+
publicJob,
|
|
516
537
|
list,
|
|
517
538
|
page,
|
|
518
539
|
create,
|
package/src/services/registry.js
CHANGED
|
@@ -701,6 +701,40 @@ function createRegistryService(db, { bus, events }){
|
|
|
701
701
|
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
+
// Why a queued job isn't assigned yet, in words — for the Agent and the
|
|
705
|
+
// dashboard (null when an idle Client fits: the next scheduler pass takes
|
|
706
|
+
// it, or higher-priority jobs are ahead). Names each matching Client that
|
|
707
|
+
// isn't free and what it's doing, and — for a job's own Docker image —
|
|
708
|
+
// the idle ones that don't allow that (sw.allowJobImages).
|
|
709
|
+
function waitingReason(type, labels, groupId, resourceId, needs = {}){
|
|
710
|
+
const matching = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource)
|
|
711
|
+
.filter((r) => matchesTarget(r, labels, groupId, resourceId)),
|
|
712
|
+
free = (r) => r.status === RESOURCE_STATES.IDLE && !r.remove_requested_at,
|
|
713
|
+
what = (r) => (r.remove_requested_at
|
|
714
|
+
? 'being removed'
|
|
715
|
+
: { [RESOURCE_STATES.BUSY]: 'busy', [RESOURCE_STATES.OUT_OF_SERVICE]: 'offline', [RESOURCE_STATES.MAINTENANCE]: 'in maintenance',
|
|
716
|
+
[RESOURCE_STATES.REGISTERED]: 'not connected yet' }[r.status] || r.status.toLowerCase()),
|
|
717
|
+
names = (rs) => rs.map((r) => `${r.name} (${what(r)})`).join(', '),
|
|
718
|
+
kind = type.toUpperCase();
|
|
719
|
+
if (!matching.length){
|
|
720
|
+
return `no ${kind} Client matches this job's target`;
|
|
721
|
+
}
|
|
722
|
+
if (needs.jobImage){
|
|
723
|
+
const allowing = matching.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs)),
|
|
724
|
+
refusing = matching.filter((r) => !allowing.includes(r) && free(r));
|
|
725
|
+
if (!allowing.some(free)){
|
|
726
|
+
return `no idle ${kind} Client runs job-supplied Docker images — ` +
|
|
727
|
+
(allowing.length ? `the ones that do: ${names(allowing)}` : 'none does') +
|
|
728
|
+
(refusing.length
|
|
729
|
+
? `; idle, but not allowing them: ${refusing.map((r) => r.name).join(', ')} ` +
|
|
730
|
+
'(enable "Run jobs\' own images" on its Emulator config tab, or sw.allowJobImages in its config)'
|
|
731
|
+
: '');
|
|
732
|
+
}
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
return matching.some(free) ? null : `every matching ${kind} Client is taken: ${names(matching)}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
704
738
|
function everSatisfiable(type, labels, groupId, resourceId, needs){
|
|
705
739
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
706
740
|
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
@@ -734,7 +768,8 @@ function createRegistryService(db, { bus, events }){
|
|
|
734
768
|
requestRemoval,
|
|
735
769
|
pendingRemovals,
|
|
736
770
|
findIdleCandidates,
|
|
737
|
-
everSatisfiable
|
|
771
|
+
everSatisfiable,
|
|
772
|
+
waitingReason
|
|
738
773
|
};
|
|
739
774
|
}
|
|
740
775
|
|
package/src/web/routes.js
CHANGED
|
@@ -714,6 +714,10 @@ function createWebRouter({ services, config }){
|
|
|
714
714
|
active: 'jobs',
|
|
715
715
|
job,
|
|
716
716
|
pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
|
|
717
|
+
waitingReason: job.state === JOB_STATES.QUEUED
|
|
718
|
+
? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
|
|
719
|
+
job.spec.target.client, { jobImage: Boolean(job.spec.image) })
|
|
720
|
+
: null,
|
|
717
721
|
artifacts,
|
|
718
722
|
jobActive,
|
|
719
723
|
canCancel: req.session.user.role === 'admin' && jobActive
|
package/test/scheduler.test.js
CHANGED
|
@@ -546,6 +546,52 @@ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwi
|
|
|
546
546
|
assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
+
test('waiting reason: says why a queued job has no Client — a job image nobody idle allows, or every match taken', () => {
|
|
550
|
+
const { registry, db } = buildTestServices(),
|
|
551
|
+
reason = (needs) => registry.waitingReason('sw', [], undefined, undefined, needs),
|
|
552
|
+
strict = registerResource(registry, { name: 'sw-strict', type: 'sw' }),
|
|
553
|
+
{ resourceId: open } = registry.registerAuto({
|
|
554
|
+
clientId: 'c-open', name: 'sw-open', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: true } }
|
|
555
|
+
});
|
|
556
|
+
registry.heartbeat(strict.id, { state: 'idle' });
|
|
557
|
+
db.prepare('UPDATE resources SET status = ? WHERE id = ?').run(RESOURCE_STATES.OUT_OF_SERVICE, open);
|
|
558
|
+
|
|
559
|
+
assert.equal(
|
|
560
|
+
reason({ jobImage: true }),
|
|
561
|
+
'no idle SW Client runs job-supplied Docker images — the ones that do: sw-open (offline); idle, but not allowing them: sw-strict ' +
|
|
562
|
+
'(enable "Run jobs\' own images" on its Emulator config tab, or sw.allowJobImages in its config)'
|
|
563
|
+
);
|
|
564
|
+
assert.equal(reason({}), null); // sw-strict can take a job without an image
|
|
565
|
+
|
|
566
|
+
registry.heartbeat(open, { state: 'idle' });
|
|
567
|
+
assert.equal(reason({ jobImage: true }), null);
|
|
568
|
+
|
|
569
|
+
registry.assignToJob(open, 'cli');
|
|
570
|
+
registry.assignToJob(strict.id, 'cli');
|
|
571
|
+
assert.equal(reason({}), 'every matching SW Client is taken: sw-strict (busy), sw-open (busy)');
|
|
572
|
+
assert.equal(registry.waitingReason('hw', [], undefined, undefined, {}), 'no HW Client matches this job\'s target');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('--env: the Client gets the values; the Agent API sees them masked; they\'re dropped when the job ends (not on a requeued LOST)', () => {
|
|
576
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
577
|
+
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
578
|
+
r = registerResource(registry, { name: 'sw-env', type: 'sw' }),
|
|
579
|
+
env = { DOCKER_REGISTRY: 'registry.lab:5000', DOCKER_USERNAME: 'ci', DOCKER_PASSWORD: 's3cret' };
|
|
580
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
581
|
+
|
|
582
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ env }) });
|
|
583
|
+
assert.deepEqual(jobs.get(job.id).spec.env, env); // what the Client is handed
|
|
584
|
+
assert.deepEqual(jobs.publicJob(jobs.get(job.id)).spec.env, { DOCKER_REGISTRY: '***', DOCKER_USERNAME: '***', DOCKER_PASSWORD: '***' });
|
|
585
|
+
|
|
586
|
+
scheduler.runPass();
|
|
587
|
+
jobs.markLost(job.id); // requeued: still needs them
|
|
588
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED);
|
|
589
|
+
assert.deepEqual(jobs.get(job.id).spec.env, env);
|
|
590
|
+
|
|
591
|
+
jobs.cancel(job.id, { isAdmin: true });
|
|
592
|
+
assert.deepEqual(jobs.get(job.id).spec.env, { DOCKER_REGISTRY: '***', DOCKER_USERNAME: '***', DOCKER_PASSWORD: '***' });
|
|
593
|
+
});
|
|
594
|
+
|
|
549
595
|
test('every job carries its command and runs on any matching Client; an old-Agent spec is refused', () => {
|
|
550
596
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
551
597
|
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
package/views/jobs/show.pug
CHANGED
|
@@ -19,6 +19,10 @@ block content
|
|
|
19
19
|
)
|
|
20
20
|
button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
|
|
21
21
|
|
|
22
|
+
if waitingReason
|
|
23
|
+
.alert.alert-warning.py-2.small
|
|
24
|
+
i.bi.bi-hourglass-split.me-1
|
|
25
|
+
| Waiting: #{waitingReason}
|
|
22
26
|
dl.row
|
|
23
27
|
dt.col-sm-2 Source
|
|
24
28
|
dd.col-sm-10.text-uppercase= job.source
|
|
@@ -32,6 +36,10 @@ block content
|
|
|
32
36
|
if job.spec.target.client
|
|
33
37
|
dt.col-sm-2 Client
|
|
34
38
|
dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
|
|
39
|
+
if job.spec.env && Object.keys(job.spec.env).length
|
|
40
|
+
dt.col-sm-2 Environment
|
|
41
|
+
dd.col-sm-10.font-monospace(data-bs-toggle="tooltip" data-bs-title="Set by --env; values are only given to the Client that runs the job")
|
|
42
|
+
= Object.keys(job.spec.env).join(', ')
|
|
35
43
|
dt.col-sm-2 Created
|
|
36
44
|
dd.col-sm-10= fmtDate(job.created_at)
|
|
37
45
|
if job.duration_sec != null
|