@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 +2 -2
- package/public/js/client-config.js +10 -1
- 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/test/scheduler.test.js +2 -2
- 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/_docker.pug +29 -0
- package/views/help/_env.pug +3 -0
- package/views/help/_git.pug +24 -11
- 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/mixins/client-config.pug +3 -0
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')
|
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/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
|
});
|
|
@@ -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
|
package/views/help/_ci.pug
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
|
-
+section('ci', 'CI/CD integration (GitHub
|
|
1
|
+
+section('ci', 'CI/CD integration (GitHub, GitLab, Bitbucket, Jenkins)', 'diagram-3')
|
|
2
2
|
p.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|