@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.31",
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.26",
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
- const onLog = (entry) => {
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);
@@ -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,
@@ -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
@@ -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' }),
@@ -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