@andrian.yablonskyy/thub-coordinator 1.1.9 → 1.1.11
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 +1 -1
- package/public/css/thub.css +1 -1
- package/src/services/jobs.js +7 -0
- package/src/services/list-prefs.js +1 -1
- package/src/services/registry.js +42 -3
- package/src/web/routes.js +16 -2
- package/test/groups-tab.test.js +69 -0
- package/test/scheduler.test.js +12 -0
- package/views/admin/agents.pug +2 -4
- package/views/help/_agent-cli.pug +3 -4
- package/views/help/_ci.pug +1 -1
- package/views/help/_client-machines.pug +2 -2
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +2 -3
- package/views/help/_troubleshooting.pug +1 -1
- package/views/mixins/client-config.pug +48 -0
- package/views/mixins/resource.pug +22 -7
- package/views/resources/list.pug +5 -2
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.11",
|
|
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": {
|
package/public/css/thub.css
CHANGED
package/src/services/jobs.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ LABEL_RE, LABEL_RULE } = require('./registry'),
|
|
17
18
|
{ paginate } = require('./list-prefs'),
|
|
18
19
|
{ parseTerms, likeClause } = require('./search'),
|
|
19
20
|
{ sanitizeArtifacts } = require('./job-artifacts'),
|
|
@@ -165,6 +166,12 @@ function createJobsService(db, { bus, events, registry, config }){
|
|
|
165
166
|
if (!valid){
|
|
166
167
|
throw Object.assign(new Error(`Invalid job spec (${VERSIONS}): ${errors.join('; ')}`), { status: 400 });
|
|
167
168
|
}
|
|
169
|
+
// A label no runner could have (the Labels tab's rule): say so, rather
|
|
170
|
+
// than "no runner can ever satisfy" it.
|
|
171
|
+
const badLabels = (spec.target.labels || []).filter((l) => !LABEL_RE.test(l));
|
|
172
|
+
if (badLabels.length){
|
|
173
|
+
throw Object.assign(new Error(`Invalid label: ${badLabels.join(', ')} — ${LABEL_RULE}`), { status: 400 });
|
|
174
|
+
}
|
|
168
175
|
spec.source = source;
|
|
169
176
|
// The group is the agent's, set on the dashboard (§13.1) — whatever an
|
|
170
177
|
// older Agent sent as `target.group` is replaced.
|
|
@@ -32,7 +32,7 @@ const PAGE_SIZES = [10, 25, 50, 100, 'all'],
|
|
|
32
32
|
defaults: { size: DEFAULT_PAGE_SIZE, sort: 'name', dir: 'asc' }
|
|
33
33
|
},
|
|
34
34
|
agents: {
|
|
35
|
-
sorts: ['name', '
|
|
35
|
+
sorts: ['name', 'group', 'version', 'created', 'used', 'status'],
|
|
36
36
|
defaults: { size: DEFAULT_PAGE_SIZE, sort: 'created', dir: 'desc' }
|
|
37
37
|
},
|
|
38
38
|
users: {
|
package/src/services/registry.js
CHANGED
|
@@ -18,8 +18,14 @@ const { v4: uuid } = require('uuid'),
|
|
|
18
18
|
shareableClientConfigFile, HW_DEVICES_SECTION } = require('@andrian.yablonskyy/thub-common'),
|
|
19
19
|
{ generateToken, hashToken } = require('./tokens');
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// A label (a runner's, on the Labels tab; a job's, --label): 1–64
|
|
22
|
+
// characters, no whitespace, commas or semicolons — JOB_LABEL joins them
|
|
23
|
+
// with commas, and a label is one line on the dashboard.
|
|
24
|
+
const LABEL_RE = /^[^\s,;]{1,64}$/,
|
|
25
|
+
LABEL_RULE = '1–64 characters, no spaces, commas or semicolons',
|
|
26
|
+
|
|
27
|
+
// Never kept from or put in a Client's config file: its credentials.
|
|
28
|
+
CONFIG_FILE_SECRETS = ['joinKey'],
|
|
23
29
|
// The first Client that applies a whole config file (an Import's other
|
|
24
30
|
// fields, the Groups tab), not only its hw-devices section.
|
|
25
31
|
CONFIG_FILE_CLIENT = '1.0.30';
|
|
@@ -796,6 +802,38 @@ function createRegistryService(db, { bus, events }){
|
|
|
796
802
|
return get(resourceId);
|
|
797
803
|
}
|
|
798
804
|
|
|
805
|
+
// Labels tab (runner card): the labels a job's --label / --label board:must
|
|
806
|
+
// find on this Client. Like the Groups tab: they belong to its config
|
|
807
|
+
// file (`labels`, which every registration reports), so they go out as
|
|
808
|
+
// its next config revision, and the Coordinator applies them at once.
|
|
809
|
+
// Each label: see LABEL_RE (top); at most 64 of them.
|
|
810
|
+
function setClientLabels(resourceId, labels, { by } = {}){
|
|
811
|
+
const r = get(resourceId);
|
|
812
|
+
if (!r){
|
|
813
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
814
|
+
}
|
|
815
|
+
const list = [...new Set(labels.map((l) => String(l).trim()).filter(Boolean))],
|
|
816
|
+
bad = list.filter((l) => !LABEL_RE.test(l));
|
|
817
|
+
if (bad.length){
|
|
818
|
+
throw Object.assign(new Error(`Invalid label: ${bad.join(', ')} — ${LABEL_RULE}`), { status: 400 });
|
|
819
|
+
}
|
|
820
|
+
if (list.length > 64){
|
|
821
|
+
throw Object.assign(new Error('At most 64 labels'), { status: 400 });
|
|
822
|
+
}
|
|
823
|
+
const desired = currentSection(r);
|
|
824
|
+
if (!desired || r.config_support !== 'file'){
|
|
825
|
+
throw Object.assign(new Error(`${r.name} is too old to take its labels from the dashboard — update its Client first`), { status: 409 });
|
|
826
|
+
}
|
|
827
|
+
const revision = r.config_revision + 1,
|
|
828
|
+
fields = { ...(pendingImport(r) || {}), labels: list };
|
|
829
|
+
db.transaction(() => {
|
|
830
|
+
db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?, labels = ?
|
|
831
|
+
WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), JSON.stringify(list), resourceId);
|
|
832
|
+
})();
|
|
833
|
+
events.record('resource', resourceId, 'resource.labels_set', { by, revision, labels: list });
|
|
834
|
+
return get(resourceId);
|
|
835
|
+
}
|
|
836
|
+
|
|
799
837
|
// Export (resource card): the Client's config file as it last reported it
|
|
800
838
|
// (secrets excluded), with what's saved for it but not applied yet — so
|
|
801
839
|
// exporting right after an edit or import gives what the Client will run.
|
|
@@ -878,6 +916,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
878
916
|
return {
|
|
879
917
|
get,
|
|
880
918
|
reportClientConfig,
|
|
919
|
+
setClientLabels,
|
|
881
920
|
getByName,
|
|
882
921
|
getByClientId,
|
|
883
922
|
getByTokenHash,
|
|
@@ -912,4 +951,4 @@ function createRegistryService(db, { bus, events }){
|
|
|
912
951
|
};
|
|
913
952
|
}
|
|
914
953
|
|
|
915
|
-
module.exports = { createRegistryService };
|
|
954
|
+
module.exports = { LABEL_RE, LABEL_RULE, createRegistryService };
|
package/src/web/routes.js
CHANGED
|
@@ -52,7 +52,6 @@ const STARTED_AT = new Date().toISOString(),
|
|
|
52
52
|
// Agents page (list-prefs `agents`): never-used / never-reported last.
|
|
53
53
|
AGENT_SORT_VALUE = {
|
|
54
54
|
name: (a) => a.name,
|
|
55
|
-
kind: (a) => a.kind,
|
|
56
55
|
version: (a) => a.version,
|
|
57
56
|
created: (a) => a.created_at,
|
|
58
57
|
used: (a) => a.last_used_at,
|
|
@@ -687,6 +686,21 @@ function createWebRouter({ services, config }){
|
|
|
687
686
|
|
|
688
687
|
// Groups tab (resource card): the Client's group membership, as a config
|
|
689
688
|
// revision the Client writes to its file (registry.setClientGroups).
|
|
689
|
+
// Labels tab (runner card): one label per line.
|
|
690
|
+
router.post('/runners/:id/labels', (req, res) => {
|
|
691
|
+
try {
|
|
692
|
+
// One per line: a comma or semicolon is in a label (and refused), not a separator.
|
|
693
|
+
const labels = String(req.body.labels || '').split(/\r?\n/),
|
|
694
|
+
r = services.registry.setClientLabels(req.params.id, labels, { by: req.session.user.username });
|
|
695
|
+
flash(req, 'success', `Labels of ${r.name} saved: ${r.labels.length ? r.labels.join(', ') : 'none'} — in effect now.\n` +
|
|
696
|
+
'The Client writes them to its config file on its next heartbeat, then restarts once it has no job running.');
|
|
697
|
+
}
|
|
698
|
+
catch (err){
|
|
699
|
+
flash(req, 'danger', err.message);
|
|
700
|
+
}
|
|
701
|
+
res.redirect(returnTo(req, '/runners'));
|
|
702
|
+
});
|
|
703
|
+
|
|
690
704
|
router.post('/runners/:id/groups', (req, res) => {
|
|
691
705
|
try {
|
|
692
706
|
const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
|
|
@@ -1095,7 +1109,7 @@ function createWebRouter({ services, config }){
|
|
|
1095
1109
|
// CI tokens only: a person's key belongs to their user (Users, §10.3).
|
|
1096
1110
|
sorted = services.agents.list()
|
|
1097
1111
|
.filter((a) => !a.user_id)
|
|
1098
|
-
.filter((a) => search.matches([a.name, a.
|
|
1112
|
+
.filter((a) => search.matches([a.name, a.version, groupName(a), a.revoked_at ? 'revoked' : 'active'], terms))
|
|
1099
1113
|
.sort((a, b) => {
|
|
1100
1114
|
const va = value(a),
|
|
1101
1115
|
vb = value(b);
|
package/test/groups-tab.test.js
CHANGED
|
@@ -289,3 +289,72 @@ test('Resources renamed Runners: the page is /runners; old /resources links and
|
|
|
289
289
|
assert.match(html, /href="\/runners"[^>]*>(<i [^>]*><\/i>)?Runners</);
|
|
290
290
|
assert.doesNotMatch(html.replace(/<[^>]*>/g, ' '), /\bResources?\b/); // no visible "Resource(s)" left on the page
|
|
291
291
|
});
|
|
292
|
+
|
|
293
|
+
test('Labels tab: in effect at once, sent to the Client as its next config revision; validated', async (t) => {
|
|
294
|
+
const { base, services, admin } = await start(t),
|
|
295
|
+
id = register(services, 'lab-sw-30'),
|
|
296
|
+
save = (labels) => fetch(`${base}/runners/${id}/labels`, {
|
|
297
|
+
method: 'POST', headers: { cookie: admin }, body: new URLSearchParams({ labels, returnTo: '/runners' }), redirect: 'manual'
|
|
298
|
+
}),
|
|
299
|
+
card = async () => {
|
|
300
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
301
|
+
return html.slice(html.indexOf(`id="resource-card-${id}"`));
|
|
302
|
+
};
|
|
303
|
+
assert.match(await card(), new RegExp(`id="cfg-${id}-labels-tab"`)); // the tab
|
|
304
|
+
|
|
305
|
+
await save('board:nucleo-f401re\nuart\n\nuart\n');
|
|
306
|
+
const r = services.registry.get(id);
|
|
307
|
+
assert.deepEqual(r.labels, ['board:nucleo-f401re', 'uart']); // trimmed, deduplicated, at once
|
|
308
|
+
assert.deepEqual(services.registry.pendingConfig(id).file, { labels: ['board:nucleo-f401re', 'uart'] });
|
|
309
|
+
assert.match(await card(), /not written to the Client yet/);
|
|
310
|
+
assert.match(await card(), />board:nucleo-f401re\nuart<\/textarea>/);
|
|
311
|
+
|
|
312
|
+
// Scheduling follows at once: a job asking for them can run here.
|
|
313
|
+
services.registry.heartbeat(id, { state: 'IDLE' });
|
|
314
|
+
const { agent } = services.agents.create({ name: 'ci', kind: 'ci' }),
|
|
315
|
+
spec = { target: { type: 'sw', labels: ['uart', 'board:nucleo-f401re'] }, command: './t' };
|
|
316
|
+
assert.ok(services.jobs.create({ agentId: agent.id, source: 'ci', spec }));
|
|
317
|
+
|
|
318
|
+
// A groups save meanwhile keeps the labels in the same revision.
|
|
319
|
+
services.registry.setClientGroups(id, [], { by: 'admin' });
|
|
320
|
+
assert.deepEqual(services.registry.pendingConfig(id).file, { labels: ['board:nucleo-f401re', 'uart'], groups: [] });
|
|
321
|
+
|
|
322
|
+
await save('has space');
|
|
323
|
+
assert.deepEqual(services.registry.get(id).labels, ['board:nucleo-f401re', 'uart']); // refused
|
|
324
|
+
for (const bad of ['a,b', 'a;b', 'a\tb', 'x'.repeat(65)]){
|
|
325
|
+
assert.throws(() => services.registry.setClientLabels(id, [bad], { by: 'x' }), /1–64 characters, no spaces, commas or semicolons/, bad);
|
|
326
|
+
}
|
|
327
|
+
assert.deepEqual(services.registry.setClientLabels(id, ['x'.repeat(64), 'board:b'], { by: 'x' }).labels, ['x'.repeat(64), 'board:b']);
|
|
328
|
+
await save('uart, stlink'); // a comma is part of the label (refused), not a separator
|
|
329
|
+
assert.deepEqual(services.registry.get(id).labels, ['x'.repeat(64), 'board:b']);
|
|
330
|
+
// One per line in the card's Labels row and the Runners table.
|
|
331
|
+
const html = await card(),
|
|
332
|
+
labelsRow = html.slice(html.indexOf('<dt class="col-sm-4">Labels</dt>'));
|
|
333
|
+
assert.match(labelsRow, /^<dt[^>]*>Labels<\/dt><dd[^>]*><div class="d-flex flex-column[^"]*">/);
|
|
334
|
+
assert.match(labelsRow, /^[^]*?<span class="badge[^"]*">x{64}<\/span><span class="badge[^"]*">board:b<\/span><\/div>/);
|
|
335
|
+
|
|
336
|
+
// A job asking for a label no runner could have: refused, saying why.
|
|
337
|
+
assert.throws(() => services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw', labels: ['has space'] }, command: './t' } }),
|
|
338
|
+
/Invalid label: has space — 1–64 characters/);
|
|
339
|
+
|
|
340
|
+
const old = register(services, 'lab-old-30', { configFile: null });
|
|
341
|
+
assert.throws(() => services.registry.setClientLabels(old, ['x'], { by: 'x' }), /too old/);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('runner card: capabilities have their own tab, next to Details', async (t) => {
|
|
345
|
+
const { base, services, admin } = await start(t),
|
|
346
|
+
{ resourceId } = services.registry.registerAuto({
|
|
347
|
+
clientId: 'hw8', name: 'lab-hw-08', type: 'hw', labels: [],
|
|
348
|
+
capabilities: { hw: { stlinks: [{ index: 1, path: '/dev/thub/dut1-stlink', present: false }], uarts: [], usbs: [] } }
|
|
349
|
+
}),
|
|
350
|
+
html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text(),
|
|
351
|
+
card = html.slice(html.indexOf(`id="resource-card-${resourceId}"`)),
|
|
352
|
+
details = card.slice(card.indexOf(`id="rc-${resourceId}-details"`), card.indexOf(`id="rc-${resourceId}-caps"`)),
|
|
353
|
+
caps = card.slice(card.indexOf(`id="rc-${resourceId}-caps"`)),
|
|
354
|
+
// The tab right after Details.
|
|
355
|
+
tabs = [...card.slice(0, card.indexOf('</ul>')).matchAll(/<button class="nav-link[^"]*" id="rc-[^"]*-(\w+)-tab"/g)].map((m) => m[1]);
|
|
356
|
+
assert.deepEqual(tabs.slice(0, 2), ['details', 'caps']);
|
|
357
|
+
assert.doesNotMatch(details, />Capabilities</); // no longer a Details row
|
|
358
|
+
assert.match(caps, new RegExp(`^id="rc-${resourceId}-caps"[^>]*data-live="rc-caps-${resourceId}"`)); // updates live
|
|
359
|
+
assert.match(caps, /\/dev\/thub\/dut1-stlink<\/code><span class="badge text-bg-danger[^"]*"[^>]*>missing/);
|
|
360
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -845,3 +845,15 @@ test('migration 027 drops joinKey copies from stored config files', () => {
|
|
|
845
845
|
assert.deepEqual(JSON.parse(db.prepare('SELECT client_config_file FROM resources WHERE id = ?').get('a').client_config_file), { name: 'a', labels: [] });
|
|
846
846
|
assert.equal(db.prepare('SELECT client_config_file FROM resources WHERE id = ?').get('b').client_config_file, null);
|
|
847
847
|
});
|
|
848
|
+
|
|
849
|
+
test('a job runs only on a runner that has every label it asks for', () => {
|
|
850
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
851
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
852
|
+
some = registerResource(registry, { name: 'lab-some', type: 'hw', labels: ['board:nucleo', 'uart'] }),
|
|
853
|
+
all = registerResource(registry, { name: 'lab-all', type: 'hw', labels: ['board:nucleo', 'uart', 'stlink', 'extra'] });
|
|
854
|
+
registry.heartbeat(some.id, { state: 'idle' });
|
|
855
|
+
registry.heartbeat(all.id, { state: 'idle' });
|
|
856
|
+
const job = jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec({ target: { type: 'hw', labels: ['board:nucleo', 'uart', 'stlink'] } }) });
|
|
857
|
+
scheduler.runPass();
|
|
858
|
+
assert.equal(jobs.get(job.id).resource_id, all.id); // lab-some lacks stlink, though it's idle too
|
|
859
|
+
});
|
package/views/admin/agents.pug
CHANGED
|
@@ -10,7 +10,7 @@ block content
|
|
|
10
10
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
11
11
|
+updatesToolbar('agent', 'agents', list.pageUrl())
|
|
12
12
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
13
|
-
+searchForm(q, 'Search
|
|
13
|
+
+searchForm(q, 'Search CI tokens: name, group, version, status', list.urlFor({ q: '' }))
|
|
14
14
|
.ms-auto
|
|
15
15
|
+pageSizePicker(list)
|
|
16
16
|
|
|
@@ -30,7 +30,6 @@ block content
|
|
|
30
30
|
thead
|
|
31
31
|
tr
|
|
32
32
|
+sortHeader(list, 'name', 'Name')
|
|
33
|
-
+sortHeader(list, 'kind', 'Kind')
|
|
34
33
|
+sortHeader(list, 'group', 'Group')
|
|
35
34
|
+sortHeader(list, 'version', 'Version', 'desc')
|
|
36
35
|
+sortHeader(list, 'created', 'Created', 'desc')
|
|
@@ -43,7 +42,6 @@ block content
|
|
|
43
42
|
//- The name opens Edit (name and group).
|
|
44
43
|
td
|
|
45
44
|
a.fw-medium(href=`#editAgentModal-${a.id}` data-bs-toggle="modal" role="button")= a.name
|
|
46
|
-
td.text-uppercase= a.kind
|
|
47
45
|
td
|
|
48
46
|
if a.group_id && groupsById[a.group_id]
|
|
49
47
|
span.badge.text-bg-info= groupsById[a.group_id].name
|
|
@@ -108,7 +106,7 @@ block content
|
|
|
108
106
|
button.btn.btn-primary(type="submit") Save
|
|
109
107
|
else
|
|
110
108
|
tr
|
|
111
|
-
td.text-body-secondary(colspan="
|
|
109
|
+
td.text-body-secondary(colspan="7")
|
|
112
110
|
if q
|
|
113
111
|
| No agent matches “#{q}”.
|
|
114
112
|
a(href=list.urlFor({ q: '' })) Show all agents
|
|
@@ -19,8 +19,7 @@
|
|
|
19
19
|
const groups = [
|
|
20
20
|
{ title: 'Where to run', rows: [
|
|
21
21
|
['--type hw|sw', 'Required. Runner type.', '--type hw'],
|
|
22
|
-
['--
|
|
23
|
-
['--label <l>', 'Required label (repeatable). The Client must have all of them.', '--label uart --label stlink'],
|
|
22
|
+
['--label <l>', 'Required label (repeatable). The runner must have all of them. 1–64 characters, no spaces, commas or semicolons.', '--label uart --label stlink'],
|
|
24
23
|
['--client <name|id>', 'Only this Client; waits for it even if others are idle.', '--client lab-hw-01']
|
|
25
24
|
] },
|
|
26
25
|
{ title: 'What to run', rows: [
|
|
@@ -73,13 +72,13 @@
|
|
|
73
72
|
|
|
74
73
|
h3.h6 Examples
|
|
75
74
|
+code('Flash and test a board, fail CI on failure').
|
|
76
|
-
thub run --type hw --board
|
|
75
|
+
thub run --type hw --label board:nucleo-f401re \
|
|
77
76
|
--download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
|
|
78
77
|
--git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
|
|
79
78
|
--command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh "$@"' \
|
|
80
79
|
--arg --junit --timeout 45m --wait
|
|
81
80
|
+code('Reproduce a failure on the exact bench, labeled with your name').
|
|
82
|
-
thub run --type hw --board
|
|
81
|
+
thub run --type hw --label board:nucleo-f401re --client lab-hw-01 --user "$USER" \
|
|
83
82
|
--download-file "$IMAGE_URL" --git-repo "$TESTS_REPO" --command ./ci/test.sh
|
|
84
83
|
# Ctrl-C detaches; the job keeps running. Re-attach:
|
|
85
84
|
thub status M-00126
|
package/views/help/_ci.pug
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
steps:
|
|
32
32
|
- run: |
|
|
33
33
|
npx -y @andrian.yablonskyy/thub-agent run \
|
|
34
|
-
--type hw --board
|
|
34
|
+
--type hw --label board:nucleo-f401re \
|
|
35
35
|
--download-file "${{ needs.build.outputs.image_url }}" \
|
|
36
36
|
--git-repo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_SHA" \
|
|
37
37
|
--command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh' \
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
+code('HW machine').
|
|
48
48
|
sudo systemctl restart thub-client@client
|
|
49
49
|
ls -l /dev/thub/
|
|
50
|
-
li Check the runner card's #[strong Capabilities]: each configured device must be present (a missing one is flagged).
|
|
50
|
+
li Check the runner card's #[strong Capabilities] tab: each configured device must be present (a missing one is flagged).
|
|
51
51
|
li Your job's #[code --command] flashes the board. The Client never flashes by itself; it passes the ST-Link serials and the downloaded files in the environment (#[a(href="#env") Environment variables]).
|
|
52
52
|
+code('A typical HW job').
|
|
53
|
-
thub run --type hw --board
|
|
53
|
+
thub run --type hw --label board:nucleo-f401re \
|
|
54
54
|
--download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
|
|
55
55
|
--git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
|
|
56
56
|
--command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh' \
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
tr
|
|
79
79
|
td: code labels
|
|
80
80
|
td No
|
|
81
|
-
td Matched against a job's #[code --
|
|
81
|
+
td Matched against a job's #[code --label]s. Use #[code board:<name>] for the board (#[code --label board:<name>]). Also set on its runner card (Labels tab), which writes them here.
|
|
82
82
|
tr
|
|
83
83
|
td: code groups
|
|
84
84
|
td No
|
package/views/help/_docker.pug
CHANGED
|
@@ -121,4 +121,4 @@
|
|
|
121
121
|
td Files written to #[code /work] stay owned by the Client user, so the workspace can be cleaned up.
|
|
122
122
|
+note('info').
|
|
123
123
|
Docker access is effectively root access on that host. The Client host must have Docker and the service user must be in the
|
|
124
|
-
#[code docker] group. The runner card's #[strong Capabilities]
|
|
124
|
+
#[code docker] group. The runner card's #[strong Capabilities] tab shows whether a Client can use Docker, and why not.
|
package/views/help/_env.pug
CHANGED
|
@@ -35,8 +35,7 @@
|
|
|
35
35
|
-
|
|
36
36
|
const jobVars = [
|
|
37
37
|
['JOB_TYPE', '--type', 'hw or sw'],
|
|
38
|
-
['
|
|
39
|
-
['JOB_LABEL / JOB_LABEL_<n>', '--board, --label', 'all labels, comma-separated / each'],
|
|
38
|
+
['JOB_LABEL / JOB_LABEL_<n>', '--label', 'all labels, comma-separated / each'],
|
|
40
39
|
['JOB_GROUP', '(dashboard)', 'the submitting agent\'s group, if it has one'],
|
|
41
40
|
['JOB_CLIENT', '--client', 'this Client\'s name'],
|
|
42
41
|
['JOB_USER', '--user', 'as given'],
|
|
@@ -118,7 +117,7 @@
|
|
|
118
117
|
python:3.14 ./run-tests.sh
|
|
119
118
|
+code('Submitting the job that feeds these').
|
|
120
119
|
export API_TOKEN=…
|
|
121
|
-
thub run --type hw --board
|
|
120
|
+
thub run --type hw --label board:nucleo-f401re \
|
|
122
121
|
--download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
|
|
123
122
|
--git-repo git@github.com:yourorg/firmware-tests.git main \
|
|
124
123
|
--env TARGET=production --env API_TOKEN \
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
['A Client doesn\'t appear on Runners', 'Check `journalctl -u thub-client@<instance> -f`. A `503` at registration means the Coordinator has no `clientJoinKey`; a `401` means the `joinKey` differs. Also check that `coordinatorUrl` is reachable from the lab (`curl -sI <url>/login`).'],
|
|
7
7
|
['A Client flaps or two machines show as one runner', 'Both use the same `.client-id` (a cloned VM or a shared config file). Delete `<varDir>/<instance>/.client-id` on one of them and restart it, or set a distinct `clientId`.'],
|
|
8
8
|
['Name already in use (`409`)', 'Another live Client holds that name. Rename one of them in its config or from the runner card. A name held by an `OUT_OF_SERVICE` runner is reclaimed automatically.'],
|
|
9
|
-
['The job is rejected with `422`', 'No registered Client can ever match its type, labels, group or client. Compare `thub resources` with your `--type` and `--
|
|
9
|
+
['The job is rejected with `422`', 'No registered Client can ever match its type, labels, group or client. Compare `thub resources` with your `--type` and `--label`s; your group (set on the dashboard, see `thub whoami`) must have a Client of that kind.'],
|
|
10
10
|
['The job stays `QUEUED`', 'Matching Clients are all `BUSY`, locked (`local`), in `MAINTENANCE` or offline, or the job is pinned with `--client`. It times out after `--timeout`.'],
|
|
11
11
|
['`ERROR` during prepare', 'The clone, download or DUT image pull failed; the job log names the reason. For git, check the Client user\'s keys and `known_hosts` (`ssh -T git@<host>` as that user). For images, check the Client user\'s `docker login`.'],
|
|
12
12
|
['Permission denied on `/dev/ttyUSB*` or `docker.sock`', 'Docker or the device groups were added after the Client was installed. Re-run `sudo npm i -g @andrian.yablonskyy/thub-client` so the unit gets the `dialout`/`plugdev`/`docker` groups, then restart.'],
|
|
@@ -96,9 +96,56 @@ mixin configTabItems(r)
|
|
|
96
96
|
if r.type === 'hw'
|
|
97
97
|
each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
|
|
98
98
|
+configTab(p, t[0], t[1])
|
|
99
|
+
+configTab(p, 'labels', 'Labels')
|
|
99
100
|
+configTab(p, 'groups', 'Groups')
|
|
100
101
|
+configTab(p, 'reboot', 'Reboot')
|
|
101
102
|
|
|
103
|
+
//- Labels tab: what a job's --label / --label board:must find on this Client,
|
|
104
|
+
//- one per line. Saved like the Groups tab (registry.setClientLabels): in
|
|
105
|
+
//- effect on the Coordinator at once, written to the config file's
|
|
106
|
+
//- `labels` by the Client.
|
|
107
|
+
mixin labelsPane(r, returnTo)
|
|
108
|
+
- const p = `cfg-${r.id}`
|
|
109
|
+
- const canEdit = can.operate && r.config_support === 'file'
|
|
110
|
+
- const pending = r.config_import && r.config_import.revision > r.config_applied_revision && r.config_import.fields && r.config_import.fields.labels
|
|
111
|
+
+configPane(p, 'labels')
|
|
112
|
+
if pending || r.config_error
|
|
113
|
+
.d-flex.justify-content-end.gap-1.mb-2
|
|
114
|
+
if pending
|
|
115
|
+
span.badge.text-bg-warning(
|
|
116
|
+
data-bs-toggle="tooltip"
|
|
117
|
+
data-bs-title="In effect on the Coordinator; the Client writes them to its config file on its next heartbeat, then restarts once idle"
|
|
118
|
+
) not written to the Client yet
|
|
119
|
+
if r.config_error
|
|
120
|
+
span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
|
|
121
|
+
if r.config_support !== 'file'
|
|
122
|
+
p.small.text-body-secondary Labels can't be set here: #{noConfigReport} Or set #[code labels] in its config file.
|
|
123
|
+
form(
|
|
124
|
+
method="post"
|
|
125
|
+
action=`/runners/${r.id}/labels`
|
|
126
|
+
data-confirm=`Save the labels of ${r.name}? They apply at once; the Client also writes them to its config file and restarts once it has no job running.`
|
|
127
|
+
data-confirm-title="Save runner labels?"
|
|
128
|
+
data-confirm-ok="Save"
|
|
129
|
+
data-confirm-tone="primary"
|
|
130
|
+
)
|
|
131
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
132
|
+
fieldset(disabled=!canEdit)
|
|
133
|
+
label.form-label.small(for=`${p}-labels-input`) One label per line
|
|
134
|
+
textarea.form-control.font-monospace(
|
|
135
|
+
id=`${p}-labels-input`
|
|
136
|
+
name="labels"
|
|
137
|
+
rows="6"
|
|
138
|
+
spellcheck="false"
|
|
139
|
+
autocapitalize="off"
|
|
140
|
+
placeholder="board:nucleo-f401re\nuart\nstlink"
|
|
141
|
+
)= r.labels.join('\n')
|
|
142
|
+
.form-text
|
|
143
|
+
| A job runs here only if this runner has every label it asks for (#[code thub run --label uart --label board:nucleo-f401re]).
|
|
144
|
+
| Each up to 64 characters, no spaces, commas or semicolons; up to 64 labels.
|
|
145
|
+
if canEdit
|
|
146
|
+
.d-flex.justify-content-end.mt-3
|
|
147
|
+
button.btn.btn-sm.btn-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save the labels — in effect at once; the Client writes them to its config file") Save labels
|
|
148
|
+
|
|
102
149
|
//- Groups tab: which resource groups this Client belongs to (thub run
|
|
103
150
|
//- --group). Saved as a config revision the Client writes to its config
|
|
104
151
|
//- file's `groups` (registry.setClientGroups); in effect on the Coordinator
|
|
@@ -198,6 +245,7 @@ mixin configPanes(r, returnTo, groupsById)
|
|
|
198
245
|
+deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
|
|
199
246
|
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-usb) or a /dev path. udev devpath: the USB port it's plugged into.
|
|
200
247
|
+saveCapabilities(canEdit)
|
|
248
|
+
+labelsPane(r, returnTo)
|
|
201
249
|
+groupsPane(r, returnTo, groupsById || {})
|
|
202
250
|
+configPane(p, 'reboot')
|
|
203
251
|
dl.row.mb-0
|
|
@@ -298,8 +298,9 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
298
298
|
//- data-config-root: where public/js/client-config.js tracks unsaved
|
|
299
299
|
//- changes (⚠ on the config tabs).
|
|
300
300
|
.modal-body.pt-2(data-config-root)
|
|
301
|
-
//- Details: what the Client is and reports
|
|
302
|
-
//- (client-config.pug; read-only for
|
|
301
|
+
//- Details: what the Client is and reports; Capabilities: what it can
|
|
302
|
+
//- drive. Then its config tabs (client-config.pug; read-only for
|
|
303
|
+
//- viewers), then its USB devices.
|
|
303
304
|
ul.nav.nav-tabs.mb-3(role="tablist")
|
|
304
305
|
li.nav-item(role="presentation")
|
|
305
306
|
button.nav-link.active(
|
|
@@ -311,6 +312,16 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
311
312
|
aria-controls=`rc-${r.id}-details`
|
|
312
313
|
aria-selected="true"
|
|
313
314
|
) Details
|
|
315
|
+
li.nav-item(role="presentation")
|
|
316
|
+
button.nav-link(
|
|
317
|
+
id=`rc-${r.id}-caps-tab`
|
|
318
|
+
type="button"
|
|
319
|
+
role="tab"
|
|
320
|
+
data-bs-toggle="tab"
|
|
321
|
+
data-bs-target=`#rc-${r.id}-caps`
|
|
322
|
+
aria-controls=`rc-${r.id}-caps`
|
|
323
|
+
aria-selected="false"
|
|
324
|
+
) Capabilities
|
|
314
325
|
+configTabItems(r)
|
|
315
326
|
li.nav-item(role="presentation")
|
|
316
327
|
button.nav-link(
|
|
@@ -326,6 +337,11 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
326
337
|
.tab-content
|
|
327
338
|
.tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0" data-live=`rc-details-${r.id}`)
|
|
328
339
|
+resourceDetails(r, groupsById, returnTo, local, external)
|
|
340
|
+
//- Live too: a Client that restarts reports them again.
|
|
341
|
+
.tab-pane.fade(id=`rc-${r.id}-caps` role="tabpanel" aria-labelledby=`rc-${r.id}-caps-tab` tabindex="0" data-live=`rc-caps-${r.id}`)
|
|
342
|
+
p.small.text-body-secondary.mb-2 What the Client reported it can drive at its last start, checked against its host.
|
|
343
|
+
.resource-capabilities
|
|
344
|
+
+capabilitiesList(r.capabilities)
|
|
329
345
|
+configPanes(r, returnTo, groupsById)
|
|
330
346
|
.tab-pane.fade.thub-usb-pane(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
|
|
331
347
|
+usbScanPane(r)
|
|
@@ -496,14 +512,13 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
|
496
512
|
data-bs-toggle="tooltip"
|
|
497
513
|
data-bs-title="The address the Coordinator last saw this Client connect from"
|
|
498
514
|
) external
|
|
499
|
-
dt.col-sm-4 Capabilities
|
|
500
|
-
dd.col-sm-8.resource-capabilities
|
|
501
|
-
+capabilitiesList(r.capabilities)
|
|
502
515
|
dt.col-sm-4 Labels
|
|
503
516
|
dd.col-sm-8
|
|
504
517
|
if r.labels.length
|
|
505
|
-
|
|
506
|
-
|
|
518
|
+
//- One label per line, as on the Labels tab.
|
|
519
|
+
.d-flex.flex-column.align-items-start.gap-1
|
|
520
|
+
each l in r.labels
|
|
521
|
+
span.badge.text-bg-light.border.font-monospace= l
|
|
507
522
|
else
|
|
508
523
|
span.text-body-secondary —
|
|
509
524
|
dt.col-sm-4 Busy source / reason
|
package/views/resources/list.pug
CHANGED
|
@@ -39,9 +39,12 @@ block content
|
|
|
39
39
|
td.text-uppercase= r.type
|
|
40
40
|
td: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/runners/${r.id}/update`, list.pageUrl(), true, r.activity && r.activity.state === 'update-hold')
|
|
41
41
|
td: +resourceBadge(r.status, r.remove_requested_at)
|
|
42
|
+
//- One label per line.
|
|
42
43
|
td
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
if r.labels.length
|
|
45
|
+
.d-flex.flex-column.align-items-start.gap-1
|
|
46
|
+
each l in r.labels
|
|
47
|
+
span.badge.text-bg-light.border.font-monospace= l
|
|
45
48
|
td
|
|
46
49
|
if r.group_ids.length
|
|
47
50
|
each gid in r.group_ids
|