@andrian.yablonskyy/thub-coordinator 1.1.16 → 1.1.18

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 CHANGED
@@ -172,7 +172,7 @@ The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, to
172
172
  **Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalMin` (default 15 minutes, `0` disables; the older `checkIntervalHours` is ignored; `updates.registry` for a private mirror) and immediately with the navbar's **check for updates** button (admins; also on the Agents and Resources pages), which reports in a toast whether the Coordinator is current and which connected resources can update their Client. A newer Coordinator shows as an **Update app to X.Y.Z** button in the navbar for admins (a badge under the logo for viewers); Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
173
173
  - Clients get a `self-update` command on their next heartbeat and install it through their root `thub-client-update` helper, once none of the host's instances is busy.
174
174
  - Agents install it at the start of their next run (`GET /api/v1/agents/me/update`), then re-run the command on the new version.
175
- - The Coordinator itself, only when started with `--self-update` (off by default; under systemd add it to the unit's `ExecStart`, a re-install keeps it): the **Update app** button writes `<dataDir>/update-request.json`; the root `thub-coordinator-update.path` unit (installed by `sudo npm i -g`) runs `npm i -g` for it, and the postinstall restarts the service (the dashboard and API are briefly down). Logs: `journalctl -u thub-coordinator-update`. Without that unit, use `thub-admin self-update` on the host.
175
+ - The Coordinator itself, only when started with `--self-update` (a systemd install has it; elsewhere, e.g. in a container, it's off and the dashboard shows nothing about Coordinator updates): the **Update app** button writes `<dataDir>/update-request.json`; the root `thub-coordinator-update.path` unit (installed by `sudo npm i -g`) runs `npm i -g` for it, and the postinstall restarts the service (the dashboard and API are briefly down). Logs: `journalctl -u thub-coordinator-update`. Without that unit, use `thub-admin self-update` on the host.
176
176
 
177
177
  A request is cleared when the Agent/Client reports the new version.
178
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
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.1.4",
22
+ "@andrian.yablonskyy/thub-common": "^1.1.5",
23
23
  "better-sqlite3": "^12.4.1",
24
24
  "express": "^5.1.0",
25
25
  "express-session": "^1.18.2",
@@ -40,9 +40,18 @@
40
40
  });
41
41
  }
42
42
 
43
- // An HW Client's hw-devices section (the only Clients with a form).
43
+ // An HW Client's hw-devices section (the only Clients with a form), with
44
+ // what its tabs don't edit (data-keep: usbPower) as it was loaded.
44
45
  function sectionFrom(form){
46
+ let keep = {};
47
+ try {
48
+ keep = JSON.parse(form.dataset.keep || '{}');
49
+ }
50
+ catch {
51
+ keep = {};
52
+ }
45
53
  return {
54
+ ...keep,
46
55
  stlinks: rows(form, 'stlinks'),
47
56
  uarts: rows(form, 'uarts'),
48
57
  usbs: rows(form, 'usbs')
@@ -34,23 +34,15 @@ const fs = require('node:fs'),
34
34
  // The checked-in unit only has placeholders — fill in the target user and
35
35
  // wherever *this* install's node and server.js actually are, so it works
36
36
  // regardless of npm prefix or an nvm-managed Node.
37
- // `--self-update` (README §10.2) is off by default; once added to the
38
- // installed unit's ExecStart, a re-install keeps it there.
39
- function selfUpdateFlag(existingUnit = UNIT_DEST){
40
- try {
41
- return /^ExecStart=.*\s--self-update(\s|$)/m.test(fs.readFileSync(existingUnit, 'utf8')) ? ' --self-update' : '';
42
- }
43
- catch {
44
- return '';
45
- }
46
- }
47
-
48
37
  function renderUnit(user, paths){
49
38
  return fs.readFileSync(UNIT_SRC, 'utf8')
50
39
  .replace(/^User=.*$/m, `User=${user.name}`)
51
40
  .replace(/^Group=.*$/m, `Group=${user.gid}`)
52
41
  .replace(/^Environment=THUB_COORDINATOR_CONFIG=.*$/m, `Environment=THUB_COORDINATOR_CONFIG=${paths.configPath}`)
53
- .replace(/^ExecStart=.*$/m, `ExecStart=${process.execPath} ${SERVER_PATH}${selfUpdateFlag()}`)
42
+ // --self-update: the dashboard's "Update app" (README §10.2), through the
43
+ // root thub-coordinator-update units installed below — on for a systemd
44
+ // install. To turn it off, override ExecStart in a drop-in.
45
+ .replace(/^ExecStart=.*$/m, `ExecStart=${process.execPath} ${SERVER_PATH} --self-update`)
54
46
  .replace(/^ReadWritePaths=.*$/m, `ReadWritePaths=${[paths.dataDir, paths.dbDir].filter(Boolean).join(' ')}`);
55
47
  }
56
48
 
package/src/api/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/src/api/agent.js
3
- * @description Agent API routes: job submission, status, cancel, log streaming (README §6.1)
3
+ * @description Agent API routes: job submission, status, cancel, USB power, log streaming (README §6.1)
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -88,6 +88,20 @@ function createAgentRouter({ services, config }){
88
88
  }
89
89
  });
90
90
 
91
+ // `thub power on|off|reset <jobId>` (README §8.7): the job's owner
92
+ // switches the USB power of the Client running it.
93
+ router.post('/jobs/:id/power', auth, visibleJob, (req, res, next) => {
94
+ try {
95
+ const { action, delaySec, port } = req.body || {};
96
+ res.status(202).json(services.jobs.requestPower(req.params.id, {
97
+ agentId: req.agent.id, action, delaySec, port, by: req.user?.username || req.agent.name
98
+ }));
99
+ }
100
+ catch (err){
101
+ next(err);
102
+ }
103
+ });
104
+
91
105
  router.get('/jobs/:id/logs', auth, visibleJob, (req, res) => {
92
106
  const after = Number(req.query.after || 0),
93
107
  limit = req.query.limit ? Number(req.query.limit) : undefined;
@@ -148,6 +148,46 @@ function createResourceRouter({ services, config }){
148
148
  }
149
149
  });
150
150
 
151
+ // While a job runs, the Client long-polls here for commands (README §8.7),
152
+ // so its owner's power reset — or a cancel — doesn't wait for the next
153
+ // heartbeat. Answers as soon as there's one (heartbeats still drain
154
+ // them too: whichever asks first gets them), else after `wait` seconds.
155
+ router.get('/resources/:id/commands', auth, requireOwnResource, (req, res) => {
156
+ const waitSec = Math.min(Math.max(Number(req.query.wait) || 0, 0), 60),
157
+ id = req.params.id,
158
+ queued = services.commands.drain(id);
159
+ if (queued.length || waitSec === 0){
160
+ return res.json({ commands: queued });
161
+ }
162
+ let timer = null;
163
+ const done = () => {
164
+ clearTimeout(timer);
165
+ services.bus.off('command', onCommand);
166
+ res.off('close', onClose);
167
+ },
168
+ // After the commands service queued it (setImmediate): taken off the
169
+ // queue only if the Client is still there to get it.
170
+ onCommand = (evt) => {
171
+ if (evt.resourceId === id){
172
+ setImmediate(() => {
173
+ if (res.writableEnded || res.destroyed){
174
+ return;
175
+ }
176
+ done();
177
+ res.json({ commands: services.commands.drain(id) });
178
+ });
179
+ }
180
+ },
181
+ onClose = () => done();
182
+ timer = setTimeout(() => {
183
+ done();
184
+ res.json({ commands: [] });
185
+ }, waitSec * 1000);
186
+ services.bus.on('command', onCommand);
187
+ // The Client gave up (or shut down): stop waiting.
188
+ res.on('close', onClose);
189
+ });
190
+
151
191
  router.post('/resources/:id/status', auth, requireOwnResource, (req, res, next) => {
152
192
  try {
153
193
  const { busy, source, reason } = req.body;
@@ -13,7 +13,9 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
16
+ const {
17
+ validateJobSpec, maskEnv, powerRequestErrors, DEFAULT_RESET_DELAY_SEC, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES
18
+ } = require('@andrian.yablonskyy/thub-common'),
17
19
  { LABEL_RE, LABEL_RULE } = require('./registry'),
18
20
  { paginate } = require('./list-prefs'),
19
21
  { parseTerms, likeClause } = require('./search'),
@@ -326,6 +328,46 @@ function createJobsService(db, { bus, events, registry, config }){
326
328
  return setState(id, JOB_STATES.CANCELED, reason ? { message: reason } : {});
327
329
  }
328
330
 
331
+ // Agent: POST /jobs/:id/power (`thub power`, README §8.7) — switch the USB
332
+ // power of the Client running the job, now. The job's owner only (not
333
+ // even an admin: it's their bench for as long as the job runs), and only
334
+ // while a Client has it. Delivered right away to a Client long-polling
335
+ // for commands, else with its next heartbeat; the Client logs it in the job.
336
+ function requestPower(id, { agentId, action, delaySec, port, by }){
337
+ const job = get(id);
338
+ if (!job){
339
+ throw Object.assign(new Error('Unknown job'), { status: 404 });
340
+ }
341
+ if (job.agent_id !== agentId){
342
+ throw Object.assign(new Error('Only the job\'s owner can switch its Client\'s USB power'), { status: 403 });
343
+ }
344
+ const errors = powerRequestErrors({ action, delaySec, port });
345
+ if (errors.length){
346
+ throw Object.assign(new Error(errors.join('; ')), { status: 400 });
347
+ }
348
+ // Not ASSIGNED: the Client may not have picked it up yet, and would
349
+ // drop a command for a job it doesn't know.
350
+ if (![JOB_STATES.PREPARING, JOB_STATES.RUNNING].includes(job.state) || !job.resource_id){
351
+ throw Object.assign(new Error(`Job ${id} is ${job.state} — USB power can only be switched while a Client is running it`), { status: 409 });
352
+ }
353
+ const r = registry.get(job.resource_id),
354
+ usbPower = r?.capabilities?.hw?.usbPower;
355
+ if (!usbPower?.ports?.length){
356
+ throw Object.assign(new Error(`${r?.name || 'The Client'} has no USB power ports (hw-devices.usbPower in its config), ` +
357
+ 'or its thub-client is older than USB power'), { status: 409 });
358
+ }
359
+ if (!usbPower.available){
360
+ throw Object.assign(new Error(`${r.name} can't switch USB power: ${usbPower.reason || 'uhubctl is unavailable'}`), { status: 409 });
361
+ }
362
+ if (port && port > usbPower.ports.length){
363
+ throw Object.assign(new Error(`port ${port}: ${r.name} has ${usbPower.ports.length} USB power port(s)`), { status: 400 });
364
+ }
365
+ const request = { action, ...(action === 'reset' ? { delaySec: delaySec ?? DEFAULT_RESET_DELAY_SEC } : {}), ...(port ? { port } : {}) };
366
+ bus.emit('command', { resourceId: r.id, command: 'power', jobId: id, ...request, by });
367
+ events.record('job', id, 'job.power_requested', { ...request, by });
368
+ return { jobId: id, resource: { id: r.id, name: r.name }, ...request };
369
+ }
370
+
329
371
  // Admin "Reboot" on a Client (resource card): its running job is canceled
330
372
  // first (the host is going down anyway), then a `reboot` command goes out
331
373
  // with its next heartbeat — the Client reboots the host through its root
@@ -589,6 +631,7 @@ function createJobsService(db, { bus, events, registry, config }){
589
631
  setState,
590
632
  cancel,
591
633
  cancelPinnedTo,
634
+ requestPower,
592
635
  requestReboot,
593
636
  removeResource,
594
637
  completeRemoval,
@@ -139,13 +139,27 @@ function sanitizeTypedCapabilities(caps){
139
139
  hw: {
140
140
  stlinks: capList(hw.stlinks).map(device),
141
141
  uarts: capList(hw.uarts).map(device),
142
- usbs: capList(hw.usbs).map(device)
142
+ usbs: capList(hw.usbs).map(device),
143
+ usbPower: usbPowerCapability(hw.usbPower)
143
144
  }
144
145
  };
145
146
  }
146
147
  return null;
147
148
  }
148
149
 
150
+ // The Client's uhubctl ports (README §8.7) and whether uhubctl is installed
151
+ // there: null when it has none (or is older than USB power).
152
+ function usbPowerCapability(p){
153
+ if (!p || typeof p !== 'object'){
154
+ return null;
155
+ }
156
+ return {
157
+ ports: capList(p.ports).map((x) => ({ hub: str(x?.hub, 64), port: num(x?.port) })),
158
+ available: p.available === true,
159
+ ...(p.available === true ? { version: str(p.version, 64) } : { reason: str(p.reason, 300) })
160
+ };
161
+ }
162
+
149
163
  // Heartbeat durations (README §10) arrive as seconds relative to "now" on
150
164
  // the Client and are anchored to the Coordinator's clock here. Anything
151
165
  // negative, non-numeric or over ~10 years is ignored.
package/src/web/routes.js CHANGED
@@ -807,9 +807,13 @@ function createWebRouter({ services, config }){
807
807
  return { type: 'danger', text: `Update check failed — the npm registry couldn't be reached (${s.error}). Nothing was checked.` };
808
808
  }
809
809
 
810
- parts.push(s.coordinatorUpdate
811
- ? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available.`
812
- : `Coordinator is up to date (v${s.coordinatorVersion}).`);
810
+ // The Coordinator's own version only when it can update itself
811
+ // (--self-update); Agents and Clients are always reported.
812
+ if (s.selfUpdate){
813
+ parts.push(s.coordinatorUpdate
814
+ ? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available.`
815
+ : `Coordinator is up to date (v${s.coordinatorVersion}).`);
816
+ }
813
817
 
814
818
  const connected = services.registry.list().filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.last_heartbeat_at),
815
819
  outdated = connected.filter((r) => r.client_version && isNewer(s.latest.client, r.client_version)),
@@ -834,7 +838,7 @@ function createWebRouter({ services, config }){
834
838
  if (s.error){
835
839
  parts.push(`Some checks failed: ${s.error}`);
836
840
  }
837
- const behind = s.coordinatorUpdate || outdated.length || unknown.length;
841
+ const behind = (s.selfUpdate && s.coordinatorUpdate) || outdated.length || unknown.length;
838
842
  return { type: behind || s.error ? 'warning' : 'success', text: parts.join('\n') };
839
843
  }));
840
844
 
@@ -3,8 +3,10 @@ Description=TestHub Coordinator
3
3
  After=network-online.target
4
4
  Wants=network-online.target
5
5
 
6
- # Add --self-update to ExecStart to let admins update the Coordinator from
7
- # the dashboard ("Update app", README §10.2); a re-install keeps it.
6
+ # --self-update lets admins update the Coordinator from the dashboard
7
+ # ("Update app", README §10.2) through thub-coordinator-update.path. To turn
8
+ # it off: sudo systemctl edit thub-coordinator, then [Service], ExecStart=
9
+ # (empty) and ExecStart=<the same line without --self-update>.
8
10
  # User, Group, THUB_COORDINATOR_CONFIG, ReadWritePaths and ExecStart are
9
11
  # rewritten by scripts/install-systemd-unit.js at install time to the user
10
12
  # who ran `sudo npm i -g` and this install's real node/server.js paths.
@@ -13,7 +15,7 @@ Type=simple
13
15
  User=thub
14
16
  Group=thub
15
17
  Environment=THUB_COORDINATOR_CONFIG=/var/lib/thub/.config/thub/coordinator.json
16
- ExecStart=/usr/bin/node /usr/lib/node_modules/@andrian.yablonskyy/thub-coordinator/src/server.js
18
+ ExecStart=/usr/bin/node /usr/lib/node_modules/@andrian.yablonskyy/thub-coordinator/src/server.js --self-update
17
19
  Restart=always
18
20
  RestartSec=2
19
21
  NoNewPrivileges=yes
@@ -726,13 +726,13 @@ test('config export/import: exported from the reported file (+ what\'s pending);
726
726
  assert.deepEqual(registry.exportClientConfig(sw), { name: 'sw1', type: 'sw', labels: ['x'] });
727
727
  });
728
728
 
729
- test('HW capabilities: no power control — reported relays/power dropped, and refused on Save', () => {
729
+ test('HW capabilities: no older power control — reported relays/power dropped, and refused on Save', () => {
730
730
  const { registry } = buildTestServices(),
731
731
  { resourceId } = registry.registerAuto({
732
732
  clientId: 'c-hw', name: 'hw1', type: 'hw', labels: [],
733
733
  capabilities: { hw: { stlinks: [], uarts: [], usbs: [], relays: [{ channel: 0 }], power: { method: 'uhubctl', hub: '1-1', port: 2 } } }
734
734
  });
735
- assert.deepEqual(registry.get(resourceId).capabilities.hw, { stlinks: [], uarts: [], usbs: [] });
735
+ assert.deepEqual(registry.get(resourceId).capabilities.hw, { stlinks: [], uarts: [], usbs: [], usbPower: null }); // usbPower: none configured
736
736
  assert.throws(() => registry.setClientConfig(resourceId, { relays: [{ channel: 0 }] }), /must NOT have additional properties/);
737
737
  assert.throws(() => registry.setClientConfig(resourceId, { power: null }), /must NOT have additional properties/);
738
738
  });
@@ -105,9 +105,11 @@ test('on startup, a finished update\'s status is cleared; a failure is kept for
105
105
  assert.equal(fs.existsSync(statusFile), true);
106
106
  });
107
107
 
108
- test('self-update is off unless the Coordinator was started with --self-update: no request, no button', async (t) => {
108
+ test('without --self-update: no request, no Coordinator check or button; Agents and Clients keep theirs', async (t) => {
109
109
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-updates-')),
110
- updates = createUpdatesService({ config: { dataDir, updates: {} }, pathUnit: path.join(dataDir, 'fake.path'), fetchLatest: async () => '99.0.0' });
110
+ updates = createUpdatesService({
111
+ config: { dataDir, updates: {} }, pathUnit: path.join(dataDir, 'fake.path'), fetchLatest: async () => '99.0.0'
112
+ });
111
113
  fs.writeFileSync(path.join(dataDir, 'fake.path'), '');
112
114
  await updates.checkNow();
113
115
  assert.equal(updates.status().selfUpdate, false);
@@ -123,7 +125,9 @@ test('self-update is off unless the Coordinator was started with --self-update:
123
125
  for (const selfUpdate of [false, true]){
124
126
  const config = { ...loadConfig(file), selfUpdate },
125
127
  services = buildServices(config);
126
- services.updates.checkNow = async () => {};
128
+ services.updates.checkNow = async () => ({
129
+ fetched: ['coordinator', 'client'], latest: { client: '9.9.9' }, coordinatorVersion: '1.0.0', coordinatorUpdate: '99.0.0', selfUpdate
130
+ });
127
131
  Object.assign(services.updates, { status: () => ({ latest: {}, coordinatorVersion: '1.0.0', coordinatorUpdate: '99.0.0', selfUpdate }) });
128
132
  services.adminUsers.create({ username: `root${selfUpdate}`, email: `r${selfUpdate}@example.com`, role: 'admin', password: 'pw' }, { by: 't' });
129
133
  const server = createApp(config, services).listen(0, '127.0.0.1');
@@ -137,7 +141,19 @@ test('self-update is off unless the Coordinator was started with --self-update:
137
141
  method: 'POST', body: new URLSearchParams({ username: `root${selfUpdate}`, password: 'pw' }), redirect: 'manual'
138
142
  })).headers.get('set-cookie').split(';')[0],
139
143
  html = await (await fetch(`${base}/runners`, { headers: { cookie } })).text();
144
+ // The Coordinator's own: Update app and the navbar's check — only with --self-update.
140
145
  assert.equal(/action="\/updates\/coordinator"/.test(html), selfUpdate, `Update app button with selfUpdate=${selfUpdate}`);
141
- assert.match(html, /v99\.0\.0 available|Update app to 99\.0\.0/);
146
+ assert.equal(/action="\/updates\/check" data-submit-busy/.test(html), selfUpdate, `navbar update check with selfUpdate=${selfUpdate}`);
147
+ assert.equal(/99\.0\.0/.test(html.slice(0, html.indexOf('<main'))), selfUpdate); // nothing about a new Coordinator in the navbar
148
+ // Clients' (and Agents') check and update-all: always.
149
+ assert.match(html, /<form class="d-inline" method="post" action="\/updates\/check">/);
150
+ assert.match(html, /action="\/runners\/update-all"/);
151
+ // "Check for updates" (Runners page): Clients always, the Coordinator only with --self-update.
152
+ const check = await fetch(`${base}/updates/check`, {
153
+ method: 'POST', headers: { cookie }, body: new URLSearchParams({ returnTo: '/runners' }), redirect: 'manual'
154
+ }),
155
+ toast = await (await fetch(`${base}${check.headers.get('location')}`, { headers: { cookie } })).text();
156
+ assert.match(toast, /No connected runners|connected runner/);
157
+ assert.equal(/Coordinator v1\.0\.0 → v99\.0\.0 available/.test(toast), selfUpdate);
142
158
  }
143
159
  });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @file packages/coordinator/test/usb-power.test.js
3
+ * @description Tests: `thub power` — the job owner switches its Client's USB power; the command reaches a Client
4
+ * long-polling for commands at once
5
+ *
6
+ * @author Andrian Yablonskyy
7
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
8
+ *
9
+ * This file is part of TestHub and is proprietary and confidential.
10
+ * Unauthorized copying, modification, distribution, or use of this file,
11
+ * via any medium, is strictly prohibited without prior written permission
12
+ * from AdSystem.PRO.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const test = require('node:test'),
18
+ assert = require('node:assert/strict'),
19
+ fs = require('node:fs'),
20
+ os = require('node:os'),
21
+ path = require('node:path'),
22
+ { loadConfig } = require('../src/config'),
23
+ { buildServices, createApp } = require('../src/server');
24
+
25
+ const USB_POWER = { ports: [{ hub: '1-1.4', port: 2 }, { hub: '1-1.4', port: 3 }], available: true, version: '2.6.0' };
26
+
27
+ async function start(t){
28
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-power-')),
29
+ file = path.join(dir, 'coordinator.json');
30
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
31
+ const config = loadConfig(file),
32
+ services = buildServices(config),
33
+ server = createApp(config, services).listen(0, '127.0.0.1');
34
+ t.after(() => new Promise((r) => {
35
+ server.closeAllConnections?.();
36
+ server.close(r);
37
+ }));
38
+ await new Promise((r) => server.on('listening', r));
39
+ const base = `http://127.0.0.1:${server.address().port}/api/v1`,
40
+ call = async (token, method, url, body) => {
41
+ const res = await fetch(`${base}${url}`, {
42
+ method,
43
+ headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
44
+ body: body ? JSON.stringify(body) : undefined
45
+ });
46
+ return { status: res.status, body: res.headers.get('content-type')?.includes('json') ? await res.json() : await res.text() };
47
+ };
48
+ return { services, call };
49
+ }
50
+
51
+ // A Client with `usbPower` capabilities (a job is only accepted when one
52
+ // could run it), and then running job `jobId` (PREPARING).
53
+ function client(services, { name, usbPower }){
54
+ return services.registry.registerAuto({ clientId: `id-${name}`, name, type: 'hw', labels: [], capabilities: { hw: { usbPower } } });
55
+ }
56
+
57
+ function runOn(services, { resourceId }, jobId){
58
+ services.registry.heartbeat(resourceId, { state: 'idle' });
59
+ services.scheduler.runPass();
60
+ assert.equal(services.jobs.get(jobId).resource_id, resourceId);
61
+ services.jobs.setState(jobId, 'PREPARING');
62
+ }
63
+
64
+ test('power: only the job\'s owner, only while a Client runs it; the Client gets it as a command', async (t) => {
65
+ const { services, call } = await start(t),
66
+ alice = services.agents.create({ name: 'alice', kind: 'cli' }).token,
67
+ ci = services.agents.create({ name: 'pipeline', kind: 'ci' }).token,
68
+ { resourceId, resourceToken } = client(services, { name: 'lab-hw-01', usbPower: USB_POWER }),
69
+ jobId = (await call(alice, 'POST', '/jobs', { target: { type: 'hw', client: 'lab-hw-01' }, command: './t.sh' })).body.jobId;
70
+
71
+ // Queued: no Client to switch yet.
72
+ assert.match((await call(alice, 'POST', `/jobs/${jobId}/power`, { action: 'reset' })).body.error,
73
+ /QUEUED — USB power can only be switched while a Client is running it/);
74
+
75
+ runOn(services, { resourceId }, jobId);
76
+
77
+ // A CI token sees the job, but it isn't its owner.
78
+ assert.equal((await call(ci, 'POST', `/jobs/${jobId}/power`, { action: 'reset' })).status, 403);
79
+ // Bad requests.
80
+ assert.match((await call(alice, 'POST', `/jobs/${jobId}/power`, { action: 'cycle' })).body.error, /action must be one of on, off, reset/);
81
+ assert.match((await call(alice, 'POST', `/jobs/${jobId}/power`, { action: 'off', delaySec: 2 })).body.error, /reset only/);
82
+ assert.match((await call(alice, 'POST', `/jobs/${jobId}/power`, { action: 'on', port: 3 })).body.error, /port 3: lab-hw-01 has 2 USB power port/);
83
+ services.commands.drain(resourceId); // the assignment's own, if any
84
+
85
+ const ok = await call(alice, 'POST', `/jobs/${jobId}/power`, { action: 'reset' });
86
+ assert.equal(ok.status, 202);
87
+ assert.deepEqual(ok.body, { jobId, resource: { id: resourceId, name: 'lab-hw-01' }, action: 'reset', delaySec: 1 }); // 1 s by default
88
+ const { body } = await call(resourceToken, 'GET', `/resources/${resourceId}/commands?wait=0`);
89
+ assert.deepEqual(body.commands, [{ command: 'power', jobId, action: 'reset', delaySec: 1, by: 'alice' }]);
90
+
91
+ // Not any more once it's over.
92
+ services.jobs.cancel(jobId, { agentId: null, isAdmin: true });
93
+ assert.equal((await call(alice, 'POST', `/jobs/${jobId}/power`, { action: 'on' })).status, 409);
94
+ });
95
+
96
+ test('power: refused for a Client without USB power ports, or without uhubctl', async (t) => {
97
+ const { services, call } = await start(t),
98
+ alice = services.agents.create({ name: 'alice', kind: 'cli' }).token,
99
+ old = client(services, { name: 'old-client' }),
100
+ noUhubctl = client(services, { name: 'no-uhubctl', usbPower: { ports: USB_POWER.ports, available: false, reason: 'uhubctl isn\'t installed' } }),
101
+ submit = async (on) => (await call(alice, 'POST', '/jobs', { target: { type: 'hw', client: on }, command: './t.sh' })).body.jobId,
102
+ first = await submit('old-client');
103
+ runOn(services, old, first);
104
+ assert.match((await call(alice, 'POST', `/jobs/${first}/power`, { action: 'on' })).body.error, /old-client has no USB power ports/);
105
+ services.jobs.cancel(first, { isAdmin: true });
106
+
107
+ const second = await submit('no-uhubctl');
108
+ runOn(services, noUhubctl, second);
109
+ assert.match((await call(alice, 'POST', `/jobs/${second}/power`, { action: 'on' })).body.error, /no-uhubctl can't switch USB power: uhubctl isn't installed/);
110
+ });
111
+
112
+ test('commands long-poll: answers as soon as a command is queued, else empty after `wait`', async (t) => {
113
+ const { services, call } = await start(t),
114
+ { resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'c1', name: 'lab-hw-01', type: 'hw', labels: [] }),
115
+ listeners = services.bus.listenerCount('command'),
116
+ started = Date.now(),
117
+ polling = call(resourceToken, 'GET', `/resources/${resourceId}/commands?wait=10`);
118
+ await new Promise((r) => setTimeout(r, 100));
119
+ services.commands.push(resourceId, { command: 'cancel-job', jobId: 'M-00001' });
120
+ const { status, body } = await polling;
121
+ assert.equal(status, 200);
122
+ assert.deepEqual(body.commands, [{ command: 'cancel-job', jobId: 'M-00001' }]);
123
+ assert.ok(Date.now() - started < 2000);
124
+ assert.deepEqual(services.commands.drain(resourceId), []); // delivered once
125
+
126
+ assert.deepEqual((await call(resourceToken, 'GET', `/resources/${resourceId}/commands?wait=1`)).body, { commands: [] });
127
+ assert.equal(services.bus.listenerCount('command'), listeners); // none left behind
128
+ assert.equal((await call(resourceToken, 'GET', '/resources/someone-else/commands')).status, 403);
129
+ });
@@ -53,7 +53,7 @@ test('help page renders every section with this Coordinator\'s URL', () => {
53
53
  coordinatorUrl: 'https://thub.example.test'
54
54
  });
55
55
  for (const id of ['overview', 'use-cases', 'quick-start', 'coordinator', 'agent-setup', 'client-setup',
56
- 'client-machines', 'docker', 'git', 'agent-cli', 'env', 'ci', 'troubleshooting']){
56
+ 'client-machines', 'usb-power', 'lab-devices', 'docker', 'git', 'agent-cli', 'env', 'storage', 'ci', 'troubleshooting']){
57
57
  assert.match(html, new RegExp(`<section[^>]* id="${id}"`), `section #${id}`);
58
58
  assert.match(html, new RegExp(`href="#${id}"`), `TOC entry for #${id}`);
59
59
  }
@@ -62,6 +62,30 @@ test('help page renders every section with this Coordinator\'s URL', () => {
62
62
  // Troubleshooting: commands and parameters are <code>, placeholders escaped.
63
63
  assert.match(html, /<code>journalctl -u thub-client@&lt;instance&gt; -f<\/code>/);
64
64
  assert.match(html, /<span>The job is rejected with <code>422<\/code><\/span>/);
65
+ // USB port power (uhubctl): its setup, the Agent options and the FAQ.
66
+ assert.match(html, /sudo uhubctl -l 1-1\.4 -p 2 -a off/);
67
+ assert.match(html, /thub power off M-00131 && thub power on M-00131/);
68
+ assert.match(html, /<code class="text-nowrap">--power-on-start &lt;action&gt;<\/code>/);
69
+ assert.match(html, /<span>USB power: <code>No compatible devices detected!<\/code><\/span>/);
70
+ // Smart sockets and PDUs: driven by the job's own command or script.
71
+ assert.match(html, /curl -fsS --digest -u "admin:\$SHELLY_PASSWORD" "\$S&on=false"/);
72
+ assert.match(html, /# ci\/power.sh on\|off\|reset \[off-seconds\]/);
73
+ assert.match(html, /older AP79xx: sPDUOutletCtl …1\.4\.4\.2\.1\.3\.&lt;outlet&gt;\)/);
74
+ assert.match(html, /cd src &amp;&amp; exec ci\/run.sh|cd src && exec ci\/run.sh/);
75
+ // CI/CD for GitHub, GitLab, Bitbucket and Jenkins, all pointed at this Coordinator; artifact storage.
76
+ for (const file of ['.github/workflows/firmware.yml', '.gitlab-ci.yml', 'bitbucket-pipelines.yml', 'Jenkinsfile']){
77
+ assert.match(html, new RegExp(`<figcaption class="thub-code-caption">${file.replace(/\./g, '\\.')}</figcaption>`), file);
78
+ }
79
+ assert.match(html, /THUB_URL {12}= 'https:\/\/thub\.example\.test'/);
80
+ assert.match(html, /aws s3 presign "s3:\/\/fw-builds\/app\/\$SHA\/app\.bin" --expires-in 3600/);
81
+ assert.match(html, /size=\$\(wc -c &lt; "\$file"/); // < escaped in code blocks
82
+ // Certificates, SSH host-key pinning, per-registry docker login, Artifactory service auth.
83
+ assert.match(html, /Environment=NODE_EXTRA_CA_CERTS=\/usr\/local\/share\/ca-certificates\/lab-ca\.crt/);
84
+ assert.match(html, /curl -fsSL --cert "\$THUB_WORK_DIR\/c\.pem" --key "\$THUB_WORK_DIR\/k\.pem"/);
85
+ assert.match(html, /StrictHostKeyChecking=yes/);
86
+ assert.match(html, /<td><code>oauth2accesstoken<\/code><\/td>/);
87
+ assert.match(html, /-d scope=applied-permissions\/user -d expires_in=3600/);
88
+ assert.match(html, /<span>`?Download failed … \(SELF_SIGNED_CERT_IN_CHAIN\)|<span><code>Download failed … \(SELF_SIGNED_CERT_IN_CHAIN\)<\/code>/);
65
89
  // Code placeholders are escaped, never parsed as tags.
66
90
  assert.doesNotMatch(html, /<(url|jobId|groupId|resourceId|work|ref)>/);
67
91
  });
@@ -4,6 +4,7 @@
4
4
  thub run [options] Submit a job and follow its log
5
5
  thub status &lt;jobId> [--json] Status; follow the log if running, verdict and test counts if done
6
6
  thub cancel &lt;jobId> Cancel a job
7
+ thub power on|off|reset &lt;jobId> [--delay &lt;sec>] [--port &lt;n>] Switch the USB power of the Client running your job
7
8
  thub resources [--json] Clients and their status
8
9
  thub jobs [--mine] [--state &lt;s>] [--json] Recent jobs (a cli token: only its own)
9
10
  thub config set &lt;url|key|group|user> &lt;value>
@@ -35,6 +36,11 @@
35
36
  ['--meta <key=value>', 'Metadata stored on the job (repeatable). The command sees THUB_META_<KEY>.', '--meta ciJobId=$GITHUB_RUN_ID'],
36
37
  ['--dry-run', 'Schedule for real, but only log what the Client would run.', '--dry-run']
37
38
  ] },
39
+ { title: 'USB power (HW, see USB port power)', rows: [
40
+ ['--power-on-start <action>', 'on, off or reset the Client\'s USB power ports before the command runs. A failure ends the job ERROR.', '--power-on-start reset'],
41
+ ['--power-on-end <action>', 'on, off or reset them when the job ends, whatever its verdict.', '--power-on-end off'],
42
+ ['--power-reset-delay <sec>', 'How long a reset keeps the power off: 0–60 s, default 1.', '--power-reset-delay 2']
43
+ ] },
38
44
  { title: 'Agent behavior', rows: [
39
45
  ['--wait', 'Follow to the end and exit with the verdict code (CI). Ctrl-C/SIGINT cancels the job.', '--wait'],
40
46
  ['--detach', 'Print the job id and exit.', '--detach --json'],
@@ -93,6 +99,13 @@
93
99
  JOB=$(thub run --type sw --command ./ci/test.sh --detach --json | jq -r .jobId)
94
100
  while thub status "$JOB" --json > job.json; [ $? -eq 5 ]; do sleep 10; done
95
101
  jq -r '"\(.state) exit=\(.exit_code) tests=\(.summary.total // 0) failed=\(.summary.failed // 0)"' job.json
102
+ +code('Power-cycle the board at the start; your job hung? reset it without canceling').
103
+ thub run --type hw --label board:nucleo-f401re --command ./ci/test.sh \
104
+ --power-on-start reset --power-reset-delay 2 --power-on-end off --detach
105
+ thub power reset M-00131 --delay 3 # owner only, while it runs
106
+ +code('Power-cycle a board on a smart socket from the test repository (see Smart sockets, PDUs, devices)').
107
+ thub run --type hw --label board:nucleo-f401re --env GH_TOKEN --env POWER_PASSWORD \
108
+ --command 'git clone --depth 1 "https://x-access-token:$GH_TOKEN@$TESTS_REPO" src && cd src && exec ci/run.sh' --wait
96
109
  +code('Lists').
97
110
  thub resources
98
111
  thub jobs --mine --state FAILED