@andrian.yablonskyy/thub-coordinator 1.0.36 → 1.0.38
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/css/thub.css +129 -0
- package/public/js/config-import.js +37 -33
- package/public/js/copy-to-clipboard.js +21 -18
- package/public/js/help.js +93 -0
- package/public/js/list-search.js +51 -0
- package/public/js/live.js +287 -0
- package/public/js/log-viewer.js +123 -11
- package/public/js/remove-resource.js +5 -3
- package/public/js/resource-card.js +14 -13
- package/public/js/tooltips.js +3 -1
- package/src/server.js +20 -2
- package/src/services/jobs.js +29 -12
- package/src/services/list-prefs.js +4 -0
- package/src/services/live.js +124 -0
- package/src/services/search.js +59 -0
- package/src/web/routes.js +130 -21
- package/test/list-prefs.test.js +6 -0
- package/test/live.test.js +153 -0
- package/test/search.test.js +97 -0
- package/test/views.test.js +30 -0
- package/views/admin/agents.pug +28 -10
- package/views/groups/list.pug +14 -1
- package/views/help/_agent-cli.pug +103 -0
- package/views/help/_agent-setup.pug +72 -0
- package/views/help/_ci.pug +44 -0
- package/views/help/_client-machines.pug +87 -0
- package/views/help/_client-setup.pug +122 -0
- package/views/help/_coordinator.pug +134 -0
- package/views/help/_docker.pug +124 -0
- package/views/help/_env.pug +127 -0
- package/views/help/_git.pug +129 -0
- package/views/help/_mixins.pug +24 -0
- package/views/help/_overview.pug +118 -0
- package/views/help/_quick-start.pug +37 -0
- package/views/help/_troubleshooting.pug +42 -0
- package/views/help/index.pug +67 -0
- package/views/index.pug +3 -2
- package/views/jobs/list.pug +6 -3
- package/views/layout.pug +24 -1
- package/views/mixins/list-controls.pug +36 -0
- package/views/mixins/log-viewer.pug +25 -3
- package/views/mixins/resource.pug +1 -1
- package/views/resources/list.pug +7 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/search.test.js
|
|
3
|
+
* @description Tests: dashboard list search (?q=) — query parsing, in-memory matching and the Jobs page's SQL search
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
{ EventEmitter } = require('node:events'),
|
|
19
|
+
{ openDb } = require('../src/db'),
|
|
20
|
+
{ createEventsService } = require('../src/services/events'),
|
|
21
|
+
{ createRegistryService } = require('../src/services/registry'),
|
|
22
|
+
{ createAgentsService } = require('../src/services/agents'),
|
|
23
|
+
{ createJobsService } = require('../src/services/jobs'),
|
|
24
|
+
{ parseTerms, normalizeQuery, matches, likeClause, MAX_QUERY } = require('../src/services/search');
|
|
25
|
+
|
|
26
|
+
test('parseTerms splits into lower-case, unique words, capped', () => {
|
|
27
|
+
assert.deepEqual(parseTerms(' Lab-HW nucleo lab-hw '), ['lab-hw', 'nucleo']);
|
|
28
|
+
assert.deepEqual(parseTerms(''), []);
|
|
29
|
+
assert.deepEqual(parseTerms(undefined), []);
|
|
30
|
+
assert.deepEqual(parseTerms(['a']), []); // ?q=a&q=b arrives as an array: ignored
|
|
31
|
+
assert.equal(parseTerms(Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ')).length, 10);
|
|
32
|
+
assert.equal(normalizeQuery(` ${'x'.repeat(500)}`).length, MAX_QUERY - 2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('matches needs every word somewhere in the fields', () => {
|
|
36
|
+
const fields = ['lab-hw-01', 'IDLE', ['board:nucleo-f401re', 'uart'], null, [['10.0.0.7']]];
|
|
37
|
+
assert.ok(matches(fields, []));
|
|
38
|
+
assert.ok(matches(fields, parseTerms('NUCLEO idle')));
|
|
39
|
+
assert.ok(matches(fields, parseTerms('10.0.0')));
|
|
40
|
+
assert.ok(!matches(fields, parseTerms('nucleo busy')));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('likeClause ANDs the words, ORs the columns and matches % and _ literally', () => {
|
|
44
|
+
const { sql, params } = likeClause(['a', 'b'], ['x', '50%_off']);
|
|
45
|
+
assert.equal(sql, '(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\') AND ' +
|
|
46
|
+
'(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\')');
|
|
47
|
+
assert.deepEqual(params, ['%x%', '%x%', '%50\\%\\_off%', '%50\\%\\_off%']);
|
|
48
|
+
assert.deepEqual(likeClause(['a'], []), { sql: '', params: [] });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('jobs.page searches ids, users, resources, agents and spec fields — never --env values', () => {
|
|
52
|
+
const db = openDb(':memory:'),
|
|
53
|
+
bus = new EventEmitter(),
|
|
54
|
+
events = createEventsService(db),
|
|
55
|
+
registry = createRegistryService(db, { bus, events }),
|
|
56
|
+
agents = createAgentsService(db, { events }),
|
|
57
|
+
ci = agents.create({ name: 'ci-firmware', kind: 'ci' }).agent,
|
|
58
|
+
dev = agents.create({ name: 'alice-laptop', kind: 'cli' }).agent,
|
|
59
|
+
config = { scheduler: { maxQueuedPerAgent: 100 }, jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 } },
|
|
60
|
+
jobs = createJobsService(db, { bus, events, registry, artifacts: {}, config });
|
|
61
|
+
registry.registerAuto({ clientId: 'c', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
62
|
+
|
|
63
|
+
const a = jobs.create({
|
|
64
|
+
agentId: ci.id,
|
|
65
|
+
source: 'ci',
|
|
66
|
+
spec: {
|
|
67
|
+
target: { type: 'sw' },
|
|
68
|
+
command: './ci/smoke.sh',
|
|
69
|
+
git: { url: 'git@github.com:yourorg/firmware-tests.git', ref: 'release/1.4' },
|
|
70
|
+
meta: { ciJobId: '9165432107' },
|
|
71
|
+
env: { API_TOKEN: 'hunter2-secret' }
|
|
72
|
+
}
|
|
73
|
+
}).id,
|
|
74
|
+
m = jobs.create({
|
|
75
|
+
agentId: dev.id,
|
|
76
|
+
source: 'cli',
|
|
77
|
+
spec: { target: { type: 'sw' }, command: 'make test', user: 'Alice Smith', suite: 'regression_50%' }
|
|
78
|
+
}).id,
|
|
79
|
+
ids = (q, extra = {}) => jobs.page({ q, size: 'all', ...extra }).rows.map((j) => j.id).sort();
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(ids(''), [a, m].sort());
|
|
82
|
+
assert.deepEqual(ids(a.toLowerCase()), [a]); // job id, any case
|
|
83
|
+
assert.deepEqual(ids('alice'), [m]); // user and agent name
|
|
84
|
+
assert.deepEqual(ids('ci-firmware'), [a]); // agent name
|
|
85
|
+
assert.deepEqual(ids('firmware-tests release/1.4'), [a]); // git url + ref, both words
|
|
86
|
+
assert.deepEqual(ids('9165432107'), [a]); // meta
|
|
87
|
+
assert.deepEqual(ids('smoke alice'), []); // words must all match the same job
|
|
88
|
+
assert.deepEqual(ids('regression_50%'), [m]); // % and _ are literal…
|
|
89
|
+
assert.deepEqual(ids('regression_5%'), []); // …not wildcards
|
|
90
|
+
assert.deepEqual(ids('queued', { source: 'cli' }), [m]); // combines with filters
|
|
91
|
+
assert.equal(jobs.page({ q: 'make', size: 'all' }).pagination.total, 1);
|
|
92
|
+
|
|
93
|
+
// --env values are secrets (README §7.2): searching them must find nothing,
|
|
94
|
+
// or the search box would be an oracle for them.
|
|
95
|
+
assert.deepEqual(ids('hunter2'), []);
|
|
96
|
+
assert.deepEqual(ids('API_TOKEN'), []);
|
|
97
|
+
});
|
package/test/views.test.js
CHANGED
|
@@ -33,3 +33,33 @@ test('every view compiles', () => {
|
|
|
33
33
|
assert.doesNotThrow(() => pug.compileFile(file), `${path.relative(VIEWS, file)}`);
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// The help page is mostly static text split over partials sharing mixins
|
|
38
|
+
// (views/help/_mixins.pug): compiling each file alone doesn't catch a missing
|
|
39
|
+
// mixin or a broken include, so render the whole page.
|
|
40
|
+
test('help page renders every section with this Coordinator\'s URL', () => {
|
|
41
|
+
const html = pug.renderFile(path.join(VIEWS, 'help', 'index.pug'), {
|
|
42
|
+
user: { username: 'viewer', role: 'viewer', theme: 'auto' },
|
|
43
|
+
updates: {},
|
|
44
|
+
messages: [],
|
|
45
|
+
currentPath: '/help',
|
|
46
|
+
active: 'help',
|
|
47
|
+
title: 'Help',
|
|
48
|
+
coordinatorVersion: '1.2.3',
|
|
49
|
+
commonVersion: '1.0.0',
|
|
50
|
+
fmtDate: () => '',
|
|
51
|
+
coordinatorUrl: 'https://thub.example.test'
|
|
52
|
+
});
|
|
53
|
+
for (const id of ['overview', 'use-cases', 'quick-start', 'coordinator', 'agent-setup', 'client-setup',
|
|
54
|
+
'client-machines', 'docker', 'git', 'agent-cli', 'env', 'ci', 'troubleshooting']){
|
|
55
|
+
assert.match(html, new RegExp(`<section[^>]* id="${id}"`), `section #${id}`);
|
|
56
|
+
assert.match(html, new RegExp(`href="#${id}"`), `TOC entry for #${id}`);
|
|
57
|
+
}
|
|
58
|
+
assert.match(html, /thub config set url {3}https:\/\/thub\.example\.test/);
|
|
59
|
+
assert.match(html, /<a class="nav-link d-flex align-items-center active" href="\/help" aria-current="page"/);
|
|
60
|
+
// Troubleshooting: commands and parameters are <code>, placeholders escaped.
|
|
61
|
+
assert.match(html, /<code>journalctl -u thub-client@<instance> -f<\/code>/);
|
|
62
|
+
assert.match(html, /<span>The job is rejected with <code>422<\/code><\/span>/);
|
|
63
|
+
// Code placeholders are escaped, never parsed as tags.
|
|
64
|
+
assert.doesNotMatch(html, /<(url|jobId|groupId|resourceId|work|ref)>/);
|
|
65
|
+
});
|
package/views/admin/agents.pug
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
extends ../layout
|
|
2
2
|
include ../mixins/updates
|
|
3
|
+
include ../mixins/list-controls
|
|
3
4
|
|
|
4
5
|
block content
|
|
5
6
|
.d-flex.justify-content-between.align-items-center.mb-3
|
|
6
7
|
h1.h4.mb-0 Agents (CI / developers)
|
|
7
8
|
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
|
|
8
|
-
.mb-3
|
|
9
|
-
+updatesToolbar('agent', 'agents',
|
|
9
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
10
|
+
+updatesToolbar('agent', 'agents', list.pageUrl())
|
|
11
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
12
|
+
+searchForm(q, 'Search agents: name, kind, version, status', list.urlFor({ q: '' }))
|
|
13
|
+
.ms-auto
|
|
14
|
+
+pageSizePicker(list)
|
|
10
15
|
|
|
11
16
|
if newToken
|
|
12
17
|
.alert.alert-warning
|
|
@@ -14,23 +19,26 @@ block content
|
|
|
14
19
|
|
|
|
15
20
|
code= newToken
|
|
16
21
|
|
|
22
|
+
div(data-live="list-toolbar")
|
|
23
|
+
+listToolbar(list, 'agents')
|
|
24
|
+
|
|
17
25
|
table.table.table-hover.align-middle
|
|
18
26
|
thead
|
|
19
27
|
tr
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
+sortHeader(list, 'name', 'Name')
|
|
29
|
+
+sortHeader(list, 'kind', 'Kind')
|
|
30
|
+
+sortHeader(list, 'version', 'Version', 'desc')
|
|
31
|
+
+sortHeader(list, 'created', 'Created', 'desc')
|
|
32
|
+
+sortHeader(list, 'used', 'Last used', 'desc')
|
|
33
|
+
+sortHeader(list, 'status', 'Status')
|
|
26
34
|
th
|
|
27
|
-
tbody
|
|
35
|
+
tbody(data-live="rows")
|
|
28
36
|
each a in agents
|
|
29
37
|
tr(class=a.revoked_at ? 'opacity-50' : '')
|
|
30
38
|
td= a.name
|
|
31
39
|
td.text-uppercase= a.kind
|
|
32
40
|
//- A revoked token can't pick an update up: no button.
|
|
33
|
-
td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`,
|
|
41
|
+
td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
|
|
34
42
|
td= fmtDate(a.created_at)
|
|
35
43
|
td= fmtDate(a.last_used_at, 'never')
|
|
36
44
|
td= a.revoked_at ? 'revoked' : 'active'
|
|
@@ -50,12 +58,14 @@ block content
|
|
|
50
58
|
data-confirm-title="Revoke agent token?"
|
|
51
59
|
data-confirm-ok="Revoke"
|
|
52
60
|
)
|
|
61
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
53
62
|
button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
|
|
54
63
|
|
|
55
64
|
.modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
|
|
56
65
|
.modal-dialog
|
|
57
66
|
.modal-content
|
|
58
67
|
form(method="post" action=`/admin/agents/${a.id}/rename`)
|
|
68
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
59
69
|
.modal-header
|
|
60
70
|
h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
|
|
61
71
|
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
@@ -66,6 +76,14 @@ block content
|
|
|
66
76
|
.modal-footer
|
|
67
77
|
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
68
78
|
button.btn.btn-primary(type="submit") Save
|
|
79
|
+
else
|
|
80
|
+
tr
|
|
81
|
+
td.text-body-secondary(colspan="7")
|
|
82
|
+
if q
|
|
83
|
+
| No agent matches “#{q}”.
|
|
84
|
+
a(href=list.urlFor({ q: '' })) Show all agents
|
|
85
|
+
else
|
|
86
|
+
| No agents yet.
|
|
69
87
|
|
|
70
88
|
.modal(tabindex="-1" id="addAgentModal")
|
|
71
89
|
.modal-dialog
|
package/views/groups/list.pug
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
extends ../layout
|
|
2
|
+
include ../mixins/list-controls
|
|
2
3
|
|
|
3
4
|
block content
|
|
4
5
|
.d-flex.justify-content-between.align-items-center.mb-3
|
|
@@ -6,10 +7,18 @@ block content
|
|
|
6
7
|
if canManage
|
|
7
8
|
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addGroupModal") Add group
|
|
8
9
|
|
|
9
|
-
p.text-body-secondary.small.
|
|
10
|
+
p.text-body-secondary.small: i.
|
|
10
11
|
A job can be constrained to run only on resources in a group with `thub run --group <id>` (§13.1).
|
|
11
12
|
A resource joins a group by listing its id under `groups` in the Client's own config — membership isn't set here.
|
|
12
13
|
|
|
14
|
+
if totalCount
|
|
15
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-2
|
|
16
|
+
+searchForm(q, 'Search groups: name, id, comment', '/groups')
|
|
17
|
+
if q
|
|
18
|
+
span.small.text-body-secondary
|
|
19
|
+
| #{groups.length} of #{totalCount} groups
|
|
20
|
+
+searchNote(q)
|
|
21
|
+
|
|
13
22
|
if groups.length
|
|
14
23
|
table.table.table-hover.align-middle
|
|
15
24
|
thead
|
|
@@ -77,6 +86,10 @@ block content
|
|
|
77
86
|
i.bi.bi-clipboard
|
|
78
87
|
.modal-footer
|
|
79
88
|
button.btn.btn-primary(type="submit") Save
|
|
89
|
+
else if q
|
|
90
|
+
p.text-body-secondary
|
|
91
|
+
| No group matches “#{q}”.
|
|
92
|
+
a(href="/groups") Show all groups
|
|
80
93
|
else
|
|
81
94
|
p.text-body-secondary No groups yet.
|
|
82
95
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
+section('agent-cli', 'Agent CLI reference', 'code-square')
|
|
2
|
+
h3.h6 Commands
|
|
3
|
+
+code('thub --help').
|
|
4
|
+
thub run [options] Submit a job and follow its log
|
|
5
|
+
thub status <jobId> [--json] Status; follow the log if running, artifact links if done
|
|
6
|
+
thub cancel <jobId> Cancel a job
|
|
7
|
+
thub resources [--json] Clients and their status
|
|
8
|
+
thub jobs [--mine] [--state <s>] [--json] Recent jobs
|
|
9
|
+
thub config set <url|token|group|user> <value>
|
|
10
|
+
thub check-update Compare with the latest published Agent
|
|
11
|
+
thub self-update [--to <x.y.z>] Update with npm i -g
|
|
12
|
+
thub --version
|
|
13
|
+
Global: --url <url> --token <token> (override THUB_URL / THUB_TOKEN / config file)
|
|
14
|
+
|
|
15
|
+
h3.h6 #[code thub run] options
|
|
16
|
+
-
|
|
17
|
+
const groups = [
|
|
18
|
+
{ title: 'Where to run', rows: [
|
|
19
|
+
['--type hw|sw', 'Required. Resource type.', '--type hw'],
|
|
20
|
+
['--board <name>', 'Shorthand for --label board:<name>.', '--board nucleo-f401re'],
|
|
21
|
+
['--label <l>', 'Required label (repeatable). The Client must have all of them.', '--label uart --label stlink'],
|
|
22
|
+
['--group <id>', 'Only Clients in this group. Default: THUB_GROUP / config.', '--group 548ae4ae-…'],
|
|
23
|
+
['--client <name|id>', 'Only this Client; waits for it even if others are idle.', '--client lab-hw-01']
|
|
24
|
+
] },
|
|
25
|
+
{ title: 'What to run', rows: [
|
|
26
|
+
['--command <string>', 'Required. Run with sh -c in the work directory. Exit code 0 = PASSED.', "--command './ci/test.sh'"],
|
|
27
|
+
['--arg <value>', 'Argument for the command, as "$@" (repeatable).', '--arg --junit --arg -v'],
|
|
28
|
+
['--suite <name>', 'Passed as THUB_SUITE (default: default).', '--suite smoke'],
|
|
29
|
+
['--download-file <url>', 'File downloaded before the command (repeatable, http(s), no credentials). THUB_DOWNLOAD_1…', '--download-file https://…/app.bin'],
|
|
30
|
+
['--git-repo <url> [ref]', 'Repository cloned first; the command runs in it.', '--git-repo git@github.com:org/tests.git v1.4.0'],
|
|
31
|
+
['--depth <n>', 'Commits to fetch with --git-repo (default 1, 0 = full).', '--depth 0'],
|
|
32
|
+
['--git-options <string>', 'Extra git options between git and its subcommand. Stored with the job.', '--git-options \'-c core.sshCommand="ssh -i ~/.ssh/k"\''],
|
|
33
|
+
['--docker-image <ref>', 'SW only: DUT container image, pulled with the Client host’s Docker login.', '--docker-image registry.lab:5000/emu:1'],
|
|
34
|
+
['--env <vars>', 'NAME=value[,NAME=value] for git and the command (repeatable). --env NAME takes the value from your shell. Masked, dropped at job end.', '--env TARGET=staging --env API_TOKEN']
|
|
35
|
+
] },
|
|
36
|
+
{ title: 'Job settings', rows: [
|
|
37
|
+
['--timeout <dur>', 'e.g. 30m, 1h (default 30m, capped by the Coordinator).', '--timeout 1h'],
|
|
38
|
+
['--priority <n>', '0–100. Defaults: ci 50, cli 60.', '--priority 80'],
|
|
39
|
+
['--user <name>', 'Free-text owner label. Default: THUB_USER / config.', '--user alice'],
|
|
40
|
+
['--meta <key=value>', 'Metadata stored on the job (repeatable). The command sees THUB_META_<KEY>.', '--meta ciJobId=$GITHUB_RUN_ID'],
|
|
41
|
+
['--dry-run', 'Schedule for real, but only log what the Client would run.', '--dry-run']
|
|
42
|
+
] },
|
|
43
|
+
{ title: 'Agent behavior', rows: [
|
|
44
|
+
['--wait', 'Follow to the end and exit with the verdict code (CI). Ctrl-C/SIGINT cancels the job.', '--wait'],
|
|
45
|
+
['--detach', 'Print the job id and exit.', '--detach --json'],
|
|
46
|
+
['--json', 'Machine-readable output.', '--json']
|
|
47
|
+
] }
|
|
48
|
+
]
|
|
49
|
+
each g in groups
|
|
50
|
+
h4.h6.text-body-secondary.mt-3= g.title
|
|
51
|
+
.table-responsive
|
|
52
|
+
table.table.table-sm.small.align-middle
|
|
53
|
+
thead
|
|
54
|
+
tr
|
|
55
|
+
th(style="width: 22%") Option
|
|
56
|
+
th Description
|
|
57
|
+
th(style="width: 32%") Example
|
|
58
|
+
tbody
|
|
59
|
+
each r in g.rows
|
|
60
|
+
tr
|
|
61
|
+
td: code.text-nowrap= r[0]
|
|
62
|
+
td= r[1]
|
|
63
|
+
td: code= r[2]
|
|
64
|
+
|
|
65
|
+
h3.h6 Exit codes
|
|
66
|
+
table.table.table-sm.small.w-auto
|
|
67
|
+
tbody
|
|
68
|
+
each e in [['0', 'PASSED'], ['1', 'FAILED'], ['2', 'ERROR, TIMEOUT or LOST'], ['3', 'CANCELED'], ['4', 'Usage, auth or connection error'], ['5', 'thub status --json: still queued or running'], ['130', 'Detached with Ctrl-C (job keeps running)']]
|
|
69
|
+
tr
|
|
70
|
+
td: code= e[0]
|
|
71
|
+
td= e[1]
|
|
72
|
+
|
|
73
|
+
h3.h6 Examples
|
|
74
|
+
+code('Flash and test a board, fail CI on failure').
|
|
75
|
+
thub run --type hw --board nucleo-f401re \
|
|
76
|
+
--download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
|
|
77
|
+
--git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
|
|
78
|
+
--command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh "$@"' \
|
|
79
|
+
--arg --junit --timeout 45m --wait
|
|
80
|
+
+code('Reproduce a failure on the exact bench, labeled with your name').
|
|
81
|
+
thub run --type hw --board nucleo-f401re --client lab-hw-01 --user "$USER" \
|
|
82
|
+
--download-file "$IMAGE_URL" --git-repo "$TESTS_REPO" --command ./ci/test.sh
|
|
83
|
+
# Ctrl-C detaches; the job keeps running. Re-attach:
|
|
84
|
+
thub status M-00126
|
|
85
|
+
+code('Verify a download before using it').
|
|
86
|
+
thub run --type hw --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
|
|
87
|
+
--command 'echo "8f15bf27… $THUB_DOWNLOAD_1" | sha256sum -c && ./flash.sh "$THUB_DOWNLOAD_1"' --wait
|
|
88
|
+
+code('Secrets and parameters').
|
|
89
|
+
export API_TOKEN=…
|
|
90
|
+
thub run --type sw --git-repo "$TESTS_REPO" \
|
|
91
|
+
--env TARGET=staging,LOG_LEVEL=debug --env API_TOKEN \
|
|
92
|
+
--command './run-tests.sh --target "$TARGET"' --suite regression --wait
|
|
93
|
+
+code('Preview exactly what would run (nothing is executed)').
|
|
94
|
+
thub run --type sw --docker-image alpine --git-repo https://example.invalid/tests.git main \
|
|
95
|
+
--command ./ci/test.sh --dry-run --wait
|
|
96
|
+
+code('Scripting: submit, poll, read the result').
|
|
97
|
+
JOB=$(thub run --type sw --git-repo "$TESTS_REPO" --command ./ci/test.sh --detach --json | jq -r .jobId)
|
|
98
|
+
while thub status "$JOB" --json > job.json; [ $? -eq 5 ]; do sleep 10; done
|
|
99
|
+
jq -r '"\(.state) exit=\(.exit_code) tests=\(.summary.total // 0) failed=\(.summary.failed // 0)"' job.json
|
|
100
|
+
+code('Lists').
|
|
101
|
+
thub resources
|
|
102
|
+
thub jobs --mine --state FAILED
|
|
103
|
+
thub cancel M-00126
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
+section('agent-setup', 'Agent setup', 'terminal')
|
|
2
|
+
p.
|
|
3
|
+
The Agent (#[code thub]) is a stateless Node.js CLI. Install it wherever jobs are submitted from:
|
|
4
|
+
a developer machine (macOS, Linux or Windows with Node.js 20+) or a CI runner.
|
|
5
|
+
|
|
6
|
+
h3.h6 Install
|
|
7
|
+
+code('Developer machine').
|
|
8
|
+
npm i -g @andrian.yablonskyy/thub-agent
|
|
9
|
+
thub --version
|
|
10
|
+
p.small.
|
|
11
|
+
In CI you don't need a global install: #[code npx -y @andrian.yablonskyy/thub-agent run …].
|
|
12
|
+
|
|
13
|
+
h3.h6 Get a token
|
|
14
|
+
p.small.
|
|
15
|
+
An admin registers an agent on #[a(href="/admin/agents") Agents] (or with #[code thub-admin agent add]) and passes you
|
|
16
|
+
its token, which is shown only once. The agent's #[strong kind] decides the job id prefix and the default priority:
|
|
17
|
+
table.table.table-sm.small.w-auto
|
|
18
|
+
thead
|
|
19
|
+
tr
|
|
20
|
+
th Kind
|
|
21
|
+
th For
|
|
22
|
+
th Job ids
|
|
23
|
+
th Default priority
|
|
24
|
+
tbody
|
|
25
|
+
tr
|
|
26
|
+
td: code ci
|
|
27
|
+
td Pipelines
|
|
28
|
+
td: code A-00001…
|
|
29
|
+
td 50
|
|
30
|
+
tr
|
|
31
|
+
td: code cli
|
|
32
|
+
td Developers
|
|
33
|
+
td: code M-00001…
|
|
34
|
+
td 60 (so a busy pipeline doesn't starve them)
|
|
35
|
+
|
|
36
|
+
h3.h6 Configure
|
|
37
|
+
+code('Save the settings once').
|
|
38
|
+
thub config set url #{coordinatorUrl}
|
|
39
|
+
thub config set token agt_…
|
|
40
|
+
thub config set user "Your Name" # optional: job owner label
|
|
41
|
+
thub config set group <groupId> # optional: default --group
|
|
42
|
+
p.small.
|
|
43
|
+
These are saved in #[code ~/.config/thub/agent.json]. Order of precedence: flags (#[code --url], #[code --token]) →
|
|
44
|
+
environment → config file.
|
|
45
|
+
table.table.table-sm.small
|
|
46
|
+
thead
|
|
47
|
+
tr
|
|
48
|
+
th Variable
|
|
49
|
+
th Meaning
|
|
50
|
+
tbody
|
|
51
|
+
tr
|
|
52
|
+
td: code THUB_URL
|
|
53
|
+
td Coordinator URL, e.g. #[code #{coordinatorUrl}]
|
|
54
|
+
tr
|
|
55
|
+
td: code THUB_TOKEN
|
|
56
|
+
td Agent token. Keep it in your CI's secret store.
|
|
57
|
+
tr
|
|
58
|
+
td: code THUB_GROUP
|
|
59
|
+
td Default #[code --group]
|
|
60
|
+
tr
|
|
61
|
+
td: code THUB_USER
|
|
62
|
+
td Default #[code --user]
|
|
63
|
+
tr
|
|
64
|
+
td: code THUB_NO_SELF_UPDATE=1
|
|
65
|
+
td Don't install admin-requested Agent updates on this machine
|
|
66
|
+
+code('Check the connection').
|
|
67
|
+
thub resources # lists the Clients and their status
|
|
68
|
+
thub jobs --mine # your recent jobs
|
|
69
|
+
p.small.mb-0.
|
|
70
|
+
When an admin requests an Agent update, the next #[code thub] command installs it and then runs on
|
|
71
|
+
the new version. If it can't install (e.g. no permission in CI), it prints the manual command and
|
|
72
|
+
carries on with the current version.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
+section('ci', 'CI/CD integration (GitHub Actions)', 'github')
|
|
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]-kind agent on #[a(href="/admin/agents") Agents] and store its token as the repository secret
|
|
5
|
+
#[code THUB_AGENT_TOKEN].
|
|
6
|
+
+code('.github/workflows/firmware.yml').
|
|
7
|
+
name: firmware
|
|
8
|
+
on: [push, pull_request]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: [self-hosted, fw-build]
|
|
13
|
+
outputs:
|
|
14
|
+
image_url: ${{ steps.upload.outputs.image_url }}
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- run: cmake -B build -G Ninja && cmake --build build
|
|
18
|
+
- id: upload
|
|
19
|
+
env: { ART_TOKEN: "${{ secrets.ARTIFACTORY_TOKEN }}" }
|
|
20
|
+
run: |
|
|
21
|
+
URL="https://artifactory.example.com/fw-local/app/${GITHUB_SHA::7}-${GITHUB_RUN_NUMBER}/app.bin"
|
|
22
|
+
curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T build/app.bin "$URL"
|
|
23
|
+
echo "image_url=$URL" >> "$GITHUB_OUTPUT"
|
|
24
|
+
|
|
25
|
+
test-hw:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
env:
|
|
29
|
+
THUB_URL: #{coordinatorUrl}
|
|
30
|
+
THUB_TOKEN: ${{ secrets.THUB_AGENT_TOKEN }}
|
|
31
|
+
steps:
|
|
32
|
+
- run: |
|
|
33
|
+
npx -y @andrian.yablonskyy/thub-agent run \
|
|
34
|
+
--type hw --board nucleo-f401re \
|
|
35
|
+
--download-file "${{ needs.build.outputs.image_url }}" \
|
|
36
|
+
--git-repo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_SHA" \
|
|
37
|
+
--command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh' \
|
|
38
|
+
--suite smoke --timeout 30m --wait \
|
|
39
|
+
--meta runUrl="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
|
40
|
+
ul.small.mb-0
|
|
41
|
+
li The step's exit code is the verdict (#[a(href="#agent-cli") exit codes]), so the workflow fails exactly when the tests do.
|
|
42
|
+
li If the workflow is canceled, the Agent (in #[code --wait] mode) cancels the TestHub job, so abandoned runs don't hold hardware.
|
|
43
|
+
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].
|
|
44
|
+
li Other CI systems (GitLab CI, Jenkins, Azure Pipelines) work the same way: install Node.js, set #[code THUB_URL]/#[code THUB_TOKEN] and run #[code thub run … --wait].
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
+section('client-machines', 'Client machines: HW and SW', 'pc')
|
|
2
|
+
p.
|
|
3
|
+
Which machine you need depends on what the jobs run against. Clients are supported on
|
|
4
|
+
#[strong Ubuntu 26.04]; other systemd-based Linux distributions work the same way in practice.
|
|
5
|
+
Every Client machine needs outbound HTTPS to the Coordinator (and to wherever jobs download from:
|
|
6
|
+
Artifactory, git servers, Docker registries). Nothing inbound.
|
|
7
|
+
|
|
8
|
+
.row.g-3.mb-3
|
|
9
|
+
.col-md-6
|
|
10
|
+
.card.h-100.border-primary-subtle
|
|
11
|
+
.card-header
|
|
12
|
+
i.bi.bi-cpu.me-1
|
|
13
|
+
strong HW Client: real machine with a physical DUT
|
|
14
|
+
.card-body.small
|
|
15
|
+
ul.mb-0
|
|
16
|
+
li A physical machine (a mini-PC, NUC or Raspberry-class ARM64 board) next to the bench.
|
|
17
|
+
li USB for the DUT's debugger (ST-Link), USB-serial adapters (UART) and DUT USB. A #[strong powered USB hub] is recommended.
|
|
18
|
+
li #[code stlink-tools] and/or #[code openocd], #[code usbutils] (for the dashboard's USB scan).
|
|
19
|
+
li Docker only if jobs run containers in their #[code --command].
|
|
20
|
+
li One Client instance per board. One machine can drive several boards (up to 8 ST-Links, UARTs and USB devices per instance).
|
|
21
|
+
.col-md-6
|
|
22
|
+
.card.h-100.border-success-subtle
|
|
23
|
+
.card-header
|
|
24
|
+
i.bi.bi-pc-display.me-1
|
|
25
|
+
strong SW Client: real or virtual machine, software-only jobs
|
|
26
|
+
.card-body.small
|
|
27
|
+
ul.mb-0
|
|
28
|
+
li Any Linux host: bare metal, a VM (Proxmox, VMware, Hyper-V, KVM) or a cloud instance.
|
|
29
|
+
li Docker Engine (#[code docker.io]), with the Client's service user in the #[code docker] group (the installer arranges this).
|
|
30
|
+
li Each job's DUT container is limited to 2 CPUs and 2 GB RAM. Size the VM for one job plus your test tools: 4 vCPU, 8 GB RAM and 40 GB+ disk for images and workspaces is a good start.
|
|
31
|
+
li For KVM-accelerated emulators (QEMU) inside a VM, turn on #[strong nested virtualization].
|
|
32
|
+
li No USB or udev setup.
|
|
33
|
+
|
|
34
|
+
h3.h6 Preparing an HW machine
|
|
35
|
+
ol.small
|
|
36
|
+
li Install the OS and the Client (#[a(href="#client-setup") Client setup]), then plug in the board's ST-Link, UART adapter and DUT USB.
|
|
37
|
+
li
|
|
38
|
+
| Find each device's USB #[strong port path] (#[code devpath]). It's stable across replugs as long as the device stays in the same port:
|
|
39
|
+
+code('HW machine').
|
|
40
|
+
lsusb -t # USB tree
|
|
41
|
+
udevadm info -a -n /dev/ttyUSB0 | grep -m1 'ATTRS{devpath}'
|
|
42
|
+
st-info --probe # ST-Link serials
|
|
43
|
+
| Or open the resource card → #[strong Connected USB devices] → #[strong Refresh], then #[strong Import to config].
|
|
44
|
+
li
|
|
45
|
+
| Put the devpaths into #[code hw-devices] and restart. On every start the Client writes
|
|
46
|
+
| #[code /etc/udev/rules.d/99-thub-<instance>.rules], so the devices appear as #[code /dev/thub/dut<N>-stlink|uart|usb]:
|
|
47
|
+
+code('HW machine').
|
|
48
|
+
sudo systemctl restart thub-client@client
|
|
49
|
+
ls -l /dev/thub/
|
|
50
|
+
li Check the resource card's #[strong Capabilities]: each configured device must be present (a missing one is flagged).
|
|
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
|
+
+code('A typical HW job').
|
|
53
|
+
thub run --type hw --board nucleo-f401re \
|
|
54
|
+
--download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
|
|
55
|
+
--git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
|
|
56
|
+
--command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh' \
|
|
57
|
+
--wait
|
|
58
|
+
|
|
59
|
+
+note('warning').
|
|
60
|
+
#[strong HW in a VM?] USB passthrough works, but the #[code devpath] then reflects the #[em virtual] USB topology and
|
|
61
|
+
can change when the hypervisor reassigns devices. Serial timing-sensitive tests may also suffer. Use a physical machine for HW Clients.
|
|
62
|
+
|
|
63
|
+
h3.h6 Preparing an SW machine (real or virtual)
|
|
64
|
+
+code('SW machine / VM').
|
|
65
|
+
sudo apt install -y nodejs npm docker.io
|
|
66
|
+
sudo npm i -g @andrian.yablonskyy/thub-client
|
|
67
|
+
nano ~/.config/thub/client.json # "type": "sw", coordinatorUrl, joinKey
|
|
68
|
+
sudo systemctl enable --now thub-client@client
|
|
69
|
+
# a private registry for DUT images? log in once, as the Client's user:
|
|
70
|
+
docker login registry.lab.local:5000
|
|
71
|
+
p.small.
|
|
72
|
+
To scale out, clone the VM template. In the clone, #[strong before its first start], run
|
|
73
|
+
#[code rm -f ~/var/lib/thub/client/*/.client-id ~/var/lib/thub/client/*.token] and give it its own #[code name].
|
|
74
|
+
Otherwise both VMs register as the same resource.
|
|
75
|
+
+code('A typical SW job').
|
|
76
|
+
thub run --type sw \
|
|
77
|
+
--docker-image registry.lab.local:5000/dut-emulator:2026.08 \
|
|
78
|
+
--git-repo git@github.com:yourorg/firmware-tests.git main \
|
|
79
|
+
--command 'make test DUT="$THUB_DUT_HOST"' --wait
|
|
80
|
+
|
|
81
|
+
h3.h6 Checklist for any Client machine
|
|
82
|
+
ul.small.mb-0
|
|
83
|
+
li Correct time (NTP). Timestamps are anchored to the Coordinator's clock, but TLS needs a sane clock.
|
|
84
|
+
li Outbound HTTPS to the Coordinator; test it with #[code curl -sI #{coordinatorUrl}/login].
|
|
85
|
+
li The Client's service user has SSH keys and git credentials for private repos (#[a(href="#git") Git repositories]) and Docker logins for private registries (#[a(href="#docker") Docker registry]).
|
|
86
|
+
li Under systemd, the service can only write to its state directory. Jobs write to #[code $THUB_WORK_DIR], not to #[code ~].
|
|
87
|
+
li Optional: a nightly reboot from the resource card's #[strong Reboot] tab (cron, host-local time). It waits for running jobs.
|