@andrian.yablonskyy/thub-coordinator 1.0.24 → 1.0.25

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.24",
3
+ "version": "1.0.25",
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": {
@@ -145,15 +145,22 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
145
145
  spec.target.client = resource.id;
146
146
  }
147
147
 
148
- // A job-supplied Docker image needs an SW Client that opted in — say so
149
- // specifically rather than the generic "no resource can satisfy".
150
- const needs = { jobImage: Boolean(spec.firmware.image) },
148
+ // A job-supplied Docker image or command needs a Client that opted in —
149
+ // say which, rather than the generic "no resource can satisfy".
150
+ const needs = { jobImage: Boolean(spec.firmware.image), jobCommand: Boolean(spec.tests.command) },
151
151
  { type, labels, group, client } = spec.target;
152
- if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
152
+ if ((needs.jobImage || needs.jobCommand) && registry.everSatisfiable(type, labels, group, client) &&
153
+ !registry.everSatisfiable(type, labels, group, client, needs)){
154
+ const missing = [
155
+ needs.jobImage && !registry.everSatisfiable(type, labels, group, client, { jobImage: true }) &&
156
+ `job-supplied Docker images (${spec.firmware.image}) — "sw": { "allowJobImages": true }`,
157
+ needs.jobCommand && !registry.everSatisfiable(type, labels, group, client, { jobCommand: true }) &&
158
+ 'job-supplied commands (--run) — "allowJobCommands": true'
159
+ ].filter(Boolean);
153
160
  throw Object.assign(
154
161
  new Error(
155
- `No matching SW Client runs job-supplied Docker images (${spec.firmware.image}) — enable it on a Client ` +
156
- 'with "sw": { "allowJobImages": true } in its config, or pass a firmware URL instead'
162
+ `No matching Client runs ${missing.length ? missing.join(', nor ') : 'both a job-supplied Docker image and command together'}` +
163
+ ' — enable it in a Client\'s config'
157
164
  ),
158
165
  { status: 422 }
159
166
  );
@@ -67,10 +67,16 @@ const MAX_DEVICES = 8,
67
67
  present: typeof d?.present === 'boolean' ? d.present : null
68
68
  });
69
69
 
70
+ // Plus `allowJobCommands` (either type): runs a job's own `tests.command`.
70
71
  function sanitizeCapabilities(caps){
71
72
  if (!caps || typeof caps !== 'object'){
72
73
  return null;
73
74
  }
75
+ const typed = sanitizeTypedCapabilities(caps);
76
+ return typed ? { ...typed, allowJobCommands: caps.allowJobCommands === true } : null;
77
+ }
78
+
79
+ function sanitizeTypedCapabilities(caps){
74
80
  if (caps.sw && typeof caps.sw === 'object'){
75
81
  const sw = caps.sw;
76
82
  return {
@@ -469,13 +475,15 @@ function createRegistryService(db, { bus, events }){
469
475
  );
470
476
  }
471
477
 
472
- // `needs.jobImage`: the job brings its own Docker image (firmware.image),
473
- // which only SW Clients that opted in (sw.allowJobImages) will run.
478
+ // `needs`: what a job brings that a Client must have opted in to run —
479
+ // jobImage (firmware.image; sw.allowJobImages) and jobCommand
480
+ // (tests.command; allowJobCommands).
474
481
  function matchesTarget(r, labels, groupId, resourceId, needs = {}){
475
482
  return labels.every((l) => r.labels.includes(l)) &&
476
483
  (!groupId || r.group_ids.includes(groupId)) &&
477
484
  (!resourceId || r.id === resourceId) &&
478
- (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
485
+ (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true) &&
486
+ (!needs.jobCommand || r.capabilities?.allowJobCommands === true);
479
487
  }
480
488
 
481
489
  // Admin: remove a resource from the registry (dashboard "Remove"). Refused
@@ -15,6 +15,11 @@
15
15
 
16
16
  const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
17
17
 
18
+ // What a job needs a Client to have opted in to (registry matchesTarget).
19
+ function jobNeeds(spec){
20
+ return { jobImage: Boolean(spec.firmware?.image), jobCommand: Boolean(spec.tests?.command) };
21
+ }
22
+
18
23
  function rowToJob(row){
19
24
  return { ...row, spec: JSON.parse(row.spec) };
20
25
  }
@@ -43,7 +48,7 @@ function createScheduler(db, { bus, events, registry, config }){
43
48
  for (const job of queued){
44
49
  const { type, labels = [], group, client } = job.spec.target,
45
50
  candidates = registry
46
- .findIdleCandidates(type, labels, group, client, { jobImage: Boolean(job.spec.firmware?.image) })
51
+ .findIdleCandidates(type, labels, group, client, jobNeeds(job.spec))
47
52
  .filter((r) => !claimedResourceIds.has(r.id));
48
53
  if (candidates.length === 0){
49
54
  continue;
@@ -547,6 +547,24 @@ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwi
547
547
  assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // firmware URLs still go anywhere
548
548
  });
549
549
 
550
+ test('a job-supplied command only goes to Clients with allowJobCommands; otherwise 422 naming it', () => {
551
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
552
+ { agent } = agents.create({ name: 'dev', kind: 'cli' }),
553
+ commandJob = makeSpec({ tests: { url: 'https://x/t.tar.gz', command: 'make test' } }),
554
+ plain = registerResource(registry, { name: 'sw-plain', type: 'sw' });
555
+ registry.heartbeat(plain.id, { state: 'idle' });
556
+
557
+ assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob }), /allowJobCommands/);
558
+
559
+ const { resourceId } = registry.registerAuto({
560
+ clientId: 'c-cmd', name: 'sw-cmd', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, allowJobCommands: true }
561
+ });
562
+ registry.heartbeat(resourceId, { state: 'idle' });
563
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob });
564
+ scheduler.runPass();
565
+ assert.equal(jobs.get(job.id).resource_id, resourceId);
566
+ });
567
+
550
568
  test('a resource can belong to several groups at once', () => {
551
569
  const { registry, groups } = buildTestServices(),
552
570
  g1 = groups.create({ name: 'g1' }),