@andrian.yablonskyy/thub-coordinator 1.1.17 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.17",
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')
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.
@@ -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
  });
@@ -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
@@ -1,8 +1,34 @@
1
- +section('ci', 'CI/CD integration (GitHub Actions)', 'github')
1
+ +section('ci', 'CI/CD integration (GitHub, GitLab, Bitbucket, Jenkins)', 'diagram-3')
2
2
  p.
3
- The test job only needs the Agent and HTTPS access to the Coordinator, so it runs on an ordinary hosted runner.
4
- Create a #[strong CI token] on #[a(href="/admin/agents") CI tokens] (admins and maintainers) and store it as the repository secret
5
- #[code THUB_CI_TOKEN].
3
+ Any CI system can run TestHub tests. A pipeline step installs the Agent and runs #[code thub run … --wait]. The step's
4
+ exit code is the verdict, so the pipeline fails exactly when the tests do.
5
+
6
+ h3.h6 What every pipeline needs
7
+ ul.small
8
+ li
9
+ | #[strong A CI token]: create one on #[a(href="/admin/agents") CI tokens] (admins and maintainers). Store it as a masked or
10
+ | secret variable #[code THUB_KEY], and set #[code THUB_URL] to #[code #{coordinatorUrl}]. Its jobs get #[code A-] ids and the token's group.
11
+ li
12
+ | #[strong Node.js 24+ and outbound HTTPS to the Coordinator] on the CI runner. It needs no access to the lab. Use
13
+ | #[code npx -y @andrian.yablonskyy/thub-agent …] (pin a version, e.g. #[code @1.1.5], for reproducible pipelines) and set
14
+ | #[code THUB_NO_SELF_UPDATE=1] on short-lived runners.
15
+ li
16
+ | #[strong Exit codes] (#[code --wait]): #[code 0] PASSED, #[code 1] FAILED, #[code 2] ERROR/TIMEOUT/LOST, #[code 3] CANCELED,
17
+ | #[code 4] usage, auth or connection error.
18
+ li
19
+ | #[strong Canceling the pipeline cancels the job.] With #[code --wait], #[code SIGINT] and #[code SIGTERM] (how GitHub,
20
+ | GitLab and Jenkins stop a step) make the Agent cancel the TestHub job before exiting. If the runner kills it outright
21
+ | (#[code SIGKILL]), the job runs until its #[code --timeout], so keep that tight, or use #[code thub cancel].
22
+ li #[strong Timeouts]: make the CI step's timeout longer than the queue wait plus #[code --timeout].
23
+ li #[strong Secrets] go to the job with #[code --env NAME] (the value comes from the step's environment). The CI token never reaches the Client.
24
+ li #[strong Traceability]: #[code --meta] attaches the pipeline URL and commit to the job.
25
+ li
26
+ | #[strong Test reports]: the Coordinator keeps only the JUnit counts. For the CI system's own report view, have the command
27
+ | publish its JUnit XML to #[a(href="#storage") artifact storage], then download it in the CI step (examples below).
28
+
29
+ h3.h6 GitHub Actions
30
+ p.small.
31
+ Store the CI token as the repository secret #[code THUB_CI_TOKEN]. The test job runs on an ordinary hosted runner.
6
32
  +code('.github/workflows/firmware.yml').
7
33
  name: firmware
8
34
  on: [push, pull_request]
@@ -38,8 +64,171 @@
38
64
  st-flash --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh' \
39
65
  --suite smoke --timeout 30m --wait \
40
66
  --meta runUrl="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
41
- ul.small.mb-0
42
- li The step's exit code is the verdict (#[a(href="#agent-cli") exit codes]), so the workflow fails exactly when the tests do.
43
- li If the workflow is canceled, the Agent (in #[code --wait] mode) cancels the TestHub job, so abandoned runs don't hold hardware.
44
- li Downloads carry no credentials. Publish the image to a URL the Client can read, or fetch it in #[code --command] with a token passed as #[code --env].
45
- li Other CI systems (GitLab CI, Jenkins, Azure Pipelines) work the same way: install Node.js, set #[code THUB_URL]/#[code THUB_KEY] and run #[code thub run … --wait].
67
+ p.small.
68
+ If the workflow is canceled, the runner sends #[code SIGINT] and the Agent cancels the job.
69
+
70
+ h3.h6 GitLab CI/CD
71
+ p.small.
72
+ Add #[code THUB_KEY] (masked; protected if only protected branches should reach hardware) and #[code ART_TOKEN] under
73
+ #[strong Settings → CI/CD → Variables]. The tests are cloned with the job's own #[code CI_JOB_TOKEN]; allow it under
74
+ #[strong Settings → CI/CD → Job token permissions]. The build hands the image URL to the test job as a #[code dotenv] report,
75
+ and the JUnit XML comes back through storage for GitLab's test report.
76
+ +code('.gitlab-ci.yml').
77
+ # .gitlab-ci.yml
78
+ stages: [build, test]
79
+
80
+ variables:
81
+ THUB_URL: #{coordinatorUrl}
82
+ THUB_NO_SELF_UPDATE: "1"
83
+ ART_BASE: https://artifactory.example.com/fw-local/app
84
+
85
+ build:
86
+ stage: build
87
+ image: registry.example.com/toolchains/arm-gcc:13
88
+ script:
89
+ - cmake -B build -G Ninja && cmake --build build
90
+ - URL="$ART_BASE/$CI_COMMIT_SHORT_SHA-$CI_PIPELINE_IID/app.bin"
91
+ - 'curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T build/app.bin "$URL"'
92
+ - echo "IMAGE_URL=$URL" > build.env
93
+ artifacts:
94
+ reports:
95
+ dotenv: build.env
96
+
97
+ test-hw:
98
+ stage: test
99
+ image: node:24
100
+ needs: [build]
101
+ timeout: 1h # > queue wait + --timeout
102
+ script:
103
+ - npm i -g @andrian.yablonskyy/thub-agent
104
+ - |
105
+ thub run --type hw --label board:nucleo-f401re \
106
+ --download-file "$IMAGE_URL" \
107
+ --env CI_JOB_TOKEN --env CI_SERVER_HOST --env CI_PROJECT_PATH --env CI_COMMIT_SHA --env CI_JOB_ID --env ART_TOKEN \
108
+ --command 'git init -q src && cd src &&
109
+ git fetch -q --depth 1 "https://gitlab-ci-token:$CI_JOB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git" "$CI_COMMIT_SHA" &&
110
+ git checkout -q FETCH_HEAD &&
111
+ st-flash --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh; rc=$?
112
+ for f in results/*.xml; do
113
+ curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T "$f" "https://artifactory.example.com/qa/gitlab/$CI_JOB_ID/$(basename "$f")"
114
+ done
115
+ exit $rc' \
116
+ --timeout 30m --wait \
117
+ --meta pipelineUrl="$CI_PIPELINE_URL" --meta commit="$CI_COMMIT_SHA" || rc=$?
118
+ mkdir -p test-results
119
+ curl -fsS -H "Authorization: Bearer $ART_TOKEN" -o test-results/junit.xml \
120
+ "https://artifactory.example.com/qa/gitlab/$CI_JOB_ID/junit.xml" || true
121
+ exit ${rc:-0}
122
+ artifacts:
123
+ when: always
124
+ reports:
125
+ junit: test-results/*.xml
126
+
127
+ h3.h6 Bitbucket Pipelines
128
+ p.small.
129
+ Add #[code THUB_URL], #[code THUB_KEY] (secured), #[code ART_TOKEN] (secured) and #[code TESTS_TOKEN] under
130
+ #[strong Repository settings → Repository variables]. #[code TESTS_TOKEN] is a repository access token with read
131
+ access, used as the password of the user #[code x-token-auth]. #[code >-] folds the command into one line, so separate its
132
+ script with #[code ;] and #[code &&]. #[code after-script] collects the JUnit XML into #[code test-results/], which
133
+ Bitbucket reads automatically.
134
+ +code('bitbucket-pipelines.yml').
135
+ # bitbucket-pipelines.yml
136
+ image: node:24
137
+
138
+ definitions:
139
+ steps:
140
+ - step: &build
141
+ name: Build
142
+ image: registry.example.com/toolchains/arm-gcc:13
143
+ script:
144
+ - cmake -B build -G Ninja && cmake --build build
145
+ - URL="https://artifactory.example.com/fw-local/app/${BITBUCKET_COMMIT:0:7}-$BITBUCKET_BUILD_NUMBER/app.bin"
146
+ - 'curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T build/app.bin "$URL"'
147
+ - echo "$URL" > image_url.txt
148
+ artifacts: [image_url.txt]
149
+ - step: &test-hw
150
+ name: HW tests
151
+ max-time: 60 # minutes; > queue wait + --timeout
152
+ script:
153
+ - export THUB_NO_SELF_UPDATE=1 IMAGE_URL="$(cat image_url.txt)"
154
+ - npm i -g @andrian.yablonskyy/thub-agent
155
+ - >-
156
+ thub run --type hw --label board:nucleo-f401re
157
+ --download-file "$IMAGE_URL"
158
+ --env TESTS_TOKEN --env BITBUCKET_REPO_FULL_NAME --env BITBUCKET_COMMIT --env BITBUCKET_BUILD_NUMBER --env ART_TOKEN
159
+ --command 'git init -q src && cd src &&
160
+ git fetch -q --depth 1 "https://x-token-auth:$TESTS_TOKEN@bitbucket.org/$BITBUCKET_REPO_FULL_NAME.git" "$BITBUCKET_COMMIT" &&
161
+ git checkout -q FETCH_HEAD &&
162
+ st-flash --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh; rc=$?;
163
+ curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/junit.xml
164
+ "https://artifactory.example.com/qa/bitbucket/$BITBUCKET_BUILD_NUMBER/junit.xml"; exit $rc'
165
+ --timeout 30m --wait
166
+ --meta buildUrl="$BITBUCKET_GIT_HTTP_ORIGIN/pipelines/results/$BITBUCKET_BUILD_NUMBER"
167
+ after-script:
168
+ - mkdir -p test-results
169
+ - 'curl -fsS -H "Authorization: Bearer $ART_TOKEN" -o test-results/junit.xml "https://artifactory.example.com/qa/bitbucket/$BITBUCKET_BUILD_NUMBER/junit.xml" || true'
170
+
171
+ pipelines:
172
+ default:
173
+ - step: *build
174
+ - step: *test-hw
175
+
176
+ h3.h6 Jenkins
177
+ p.small.
178
+ Add the CI token as a #[strong Secret text] credential (#[code thub-ci-token]), plus #[code artifactory-token] and a
179
+ username/password for the tests repository (#[code tests-repo]). Run the stage in #[code node:24], or install Node.js with the
180
+ NodeJS plugin. #[code sh '''…'''] (single quotes) leaves every #[code $] to the shell, so no secret is interpolated by
181
+ Groovy. Aborting the build sends #[code SIGTERM], and the Agent cancels the job. In a freestyle job, put the same
182
+ #[code npx … run … --wait] line in an #[em Execute shell] step.
183
+ +code('Jenkinsfile').
184
+ // Jenkinsfile (declarative)
185
+ pipeline {
186
+ agent none
187
+ options { timeout(time: 1, unit: 'HOURS') } // > queue wait + --timeout
188
+ environment {
189
+ THUB_URL = '#{coordinatorUrl}'
190
+ THUB_NO_SELF_UPDATE = '1'
191
+ }
192
+ stages {
193
+ stage('Build') {
194
+ agent { label 'fw-build' }
195
+ environment { ART_TOKEN = credentials('artifactory-token') }
196
+ steps {
197
+ sh 'cmake -B build -G Ninja && cmake --build build'
198
+ script {
199
+ env.IMAGE_URL = "https://artifactory.example.com/fw-local/app/${env.GIT_COMMIT.take(7)}-${env.BUILD_NUMBER}/app.bin"
200
+ }
201
+ sh 'curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T build/app.bin "$IMAGE_URL"'
202
+ }
203
+ }
204
+ stage('HW tests') {
205
+ agent { docker { image 'node:24' } }
206
+ environment {
207
+ THUB_KEY = credentials('thub-ci-token')
208
+ ART_TOKEN = credentials('artifactory-token')
209
+ NPM_CONFIG_CACHE = "${env.WORKSPACE}/.npm"
210
+ }
211
+ steps {
212
+ withCredentials([usernamePassword(credentialsId: 'tests-repo', usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) {
213
+ sh '''
214
+ npx -y @andrian.yablonskyy/thub-agent@1.1.5 run --type hw --label board:nucleo-f401re \
215
+ --download-file "$IMAGE_URL" \
216
+ --env GIT_USER --env GIT_PASS --env TESTS_COMMIT="$GIT_COMMIT" --env BUILD_TAG --env ART_TOKEN \
217
+ --command 'git clone -q "https://$GIT_USER:$GIT_PASS@git.example.com/fw/firmware-tests.git" src && cd src &&
218
+ git checkout -q "$TESTS_COMMIT" &&
219
+ st-flash --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh; rc=$?
220
+ curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/junit.xml "https://artifactory.example.com/qa/jenkins/$BUILD_TAG/junit.xml"
221
+ exit $rc' \
222
+ --timeout 30m --wait --meta buildUrl="$BUILD_URL"
223
+ '''
224
+ }
225
+ }
226
+ post {
227
+ always {
228
+ sh 'mkdir -p test-results && curl -fsS -H "Authorization: Bearer $ART_TOKEN" -o test-results/junit.xml "https://artifactory.example.com/qa/jenkins/$BUILD_TAG/junit.xml" || true'
229
+ junit allowEmptyResults: true, testResults: 'test-results/*.xml'
230
+ }
231
+ }
232
+ }
233
+ }
234
+ }