@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 +1 -1
- package/package.json +2 -2
- package/public/js/client-config.js +10 -1
- package/scripts/install-systemd-unit.js +4 -12
- package/src/api/agent.js +15 -1
- package/src/api/resource.js +40 -0
- package/src/services/jobs.js +44 -1
- package/src/services/registry.js +15 -1
- package/src/web/routes.js +8 -4
- package/systemd/thub-coordinator.service +5 -3
- package/test/scheduler.test.js +2 -2
- package/test/updates.test.js +20 -4
- package/test/usb-power.test.js +129 -0
- package/test/views.test.js +25 -1
- package/views/help/_agent-cli.pug +13 -0
- package/views/help/_ci.pug +198 -9
- package/views/help/_client-machines.pug +3 -0
- package/views/help/_client-setup.pug +2 -0
- package/views/help/_coordinator.pug +8 -9
- package/views/help/_docker.pug +29 -0
- package/views/help/_env.pug +3 -0
- package/views/help/_git.pug +24 -11
- package/views/help/_kubernetes.pug +3 -3
- package/views/help/_lab-devices.pug +154 -0
- package/views/help/_storage.pug +218 -0
- package/views/help/_troubleshooting.pug +8 -0
- package/views/help/_usb-power.pug +147 -0
- package/views/help/index.pug +7 -1
- package/views/layout.pug +7 -4
- package/views/mixins/client-config.pug +3 -0
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` (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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;
|
package/src/api/resource.js
CHANGED
|
@@ -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;
|
package/src/services/jobs.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const {
|
|
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,
|
package/src/services/registry.js
CHANGED
|
@@ -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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
#
|
|
7
|
-
#
|
|
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
|
package/test/scheduler.test.js
CHANGED
|
@@ -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
|
});
|
package/test/updates.test.js
CHANGED
|
@@ -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
|
|
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({
|
|
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.
|
|
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
|
+
});
|
package/test/views.test.js
CHANGED
|
@@ -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@<instance> -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 <action><\/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\.<outlet>\)/);
|
|
74
|
+
assert.match(html, /cd src && 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 < "\$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 <jobId> [--json] Status; follow the log if running, verdict and test counts if done
|
|
6
6
|
thub cancel <jobId> Cancel a job
|
|
7
|
+
thub power on|off|reset <jobId> [--delay <sec>] [--port <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 <s>] [--json] Recent jobs (a cli token: only its own)
|
|
9
10
|
thub config set <url|key|group|user> <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
|