@andrian.yablonskyy/thub-coordinator 1.1.8 → 1.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/public/js/remove-resource.js +2 -2
- 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 +63 -40
- package/test/groups-tab.test.js +74 -9
- package/test/live.test.js +2 -2
- package/test/scheduler.test.js +12 -0
- package/test/users.test.js +2 -2
- package/views/admin/agents.pug +2 -4
- package/views/groups/list.pug +3 -3
- package/views/help/_agent-cli.pug +4 -5
- package/views/help/_agent-setup.pug +1 -1
- package/views/help/_ci.pug +1 -1
- package/views/help/_client-machines.pug +5 -5
- package/views/help/_client-setup.pug +4 -4
- package/views/help/_coordinator.pug +3 -3
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +2 -3
- package/views/help/_overview.pug +3 -3
- package/views/help/_quick-start.pug +1 -1
- package/views/help/_troubleshooting.pug +6 -6
- package/views/index.pug +1 -1
- package/views/jobs/list.pug +3 -3
- package/views/jobs/show.pug +1 -1
- package/views/layout.pug +2 -2
- package/views/mixins/client-config.pug +50 -2
- package/views/mixins/group-select.pug +2 -2
- package/views/mixins/resource.pug +21 -19
- package/views/mixins/updates.pug +1 -1
- package/views/resources/list.pug +11 -8
package/README.md
CHANGED
|
@@ -149,8 +149,8 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
149
149
|
|
|
150
150
|
| Page | Content |
|
|
151
151
|
|---|---|
|
|
152
|
-
| `/` |
|
|
153
|
-
| `/
|
|
152
|
+
| `/` | Runner cards by status, queue length, jobs in the last 24 h. Click a card for its runner card (below). |
|
|
153
|
+
| `/runners` | **Runners** (formerly Resources; `/resources` redirects): Type, Client version, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. Click a row for its resource card (below). |
|
|
154
154
|
| `/groups` | Resource groups — id/name/comment, member count; admin create/rename/delete. Membership itself is set per-resource in the Client's own config. |
|
|
155
155
|
| `/jobs` | Filterable job list (state, source, resource), with the job's `user` label if set; admin **Reset queue** / **Clean history**. |
|
|
156
156
|
| `/jobs/:id` | Spec, timeline, live log viewer with stream filter, artifact downloads, cancel button. |
|
|
@@ -159,7 +159,7 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
159
159
|
|
|
160
160
|
Notifications (results of dashboard actions, errors) appear as toasts in the top-right corner and close themselves by severity: **errors stay until closed**, warnings close after **30 s**, info and success after **10 s**. A one-time secret such as a rotated resource token never closes on its own.
|
|
161
161
|
|
|
162
|
-
**
|
|
162
|
+
**Runner card.** Clicking a runner on `/` or `/runners` opens a card with its name, type, status, host uptime and current task/status with its duration (both from the Client's heartbeats, anchored to the Coordinator's clock; as of the last heartbeat for an OUT_OF_SERVICE resource), Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts. Admins get a **Cancel** button on it, enabled only while the Client is doing something: it cancels its current job, releases a manual local lock, or aborts a self-update hold on its host (the last two delivered as `unlock` / `cancel-update` heartbeat commands).
|
|
163
163
|
|
|
164
164
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
165
165
|
|
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.10",
|
|
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": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
field('job').textContent = job;
|
|
30
30
|
field('job').href = `/jobs/${encodeURIComponent(job)}`;
|
|
31
31
|
field('job-text').textContent = job;
|
|
32
|
-
field('form').action = `/
|
|
33
|
-
field('return').value = returnTo || '/
|
|
32
|
+
field('form').action = `/runners/${encodeURIComponent(id)}/remove`;
|
|
33
|
+
field('return').value = returnTo || '/runners';
|
|
34
34
|
|
|
35
35
|
// Clicked from inside a resource card: close that first — Bootstrap
|
|
36
36
|
// doesn't stack modals.
|
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,
|
|
@@ -292,6 +291,15 @@ function createWebRouter({ services, config }){
|
|
|
292
291
|
req.session.destroy(() => res.redirect('/login'));
|
|
293
292
|
});
|
|
294
293
|
|
|
294
|
+
// Resources were renamed Runners on the dashboard: old links and
|
|
295
|
+
// bookmarks (and a form from a page loaded before) still land.
|
|
296
|
+
router.use((req, res, next) => {
|
|
297
|
+
if (!/^\/resources(\/|$)/.test(req.path)){
|
|
298
|
+
return next();
|
|
299
|
+
}
|
|
300
|
+
res.redirect(req.method === 'GET' || req.method === 'HEAD' ? 301 : 308, req.originalUrl.replace(/^\/resources/, '/runners'));
|
|
301
|
+
});
|
|
302
|
+
|
|
295
303
|
router.use(requireAdminSession);
|
|
296
304
|
router.use((req, res, next) => {
|
|
297
305
|
const u = req.session.user;
|
|
@@ -496,7 +504,7 @@ function createWebRouter({ services, config }){
|
|
|
496
504
|
res.render('index', { title: 'Overview', active: 'overview', liveTopics: 'resources jobs', resources, queueLength, jobsLast24h, onlineCount, groupsById });
|
|
497
505
|
});
|
|
498
506
|
|
|
499
|
-
router.get('/
|
|
507
|
+
router.get('/runners', (req, res) => {
|
|
500
508
|
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
|
|
501
509
|
q = search.normalizeQuery(req.query.q),
|
|
502
510
|
terms = search.parseTerms(q),
|
|
@@ -518,7 +526,7 @@ function createWebRouter({ services, config }){
|
|
|
518
526
|
pagination = listPrefs.paginate(sorted.length, size, req.query.page),
|
|
519
527
|
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
520
528
|
res.render('resources/list', {
|
|
521
|
-
title: '
|
|
529
|
+
title: 'Runners',
|
|
522
530
|
active: 'resources',
|
|
523
531
|
liveTopics: 'resources',
|
|
524
532
|
resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
|
|
@@ -527,21 +535,21 @@ function createWebRouter({ services, config }){
|
|
|
527
535
|
});
|
|
528
536
|
});
|
|
529
537
|
|
|
530
|
-
router.post('/
|
|
538
|
+
router.post('/runners/:id/maintenance', (req, res) => {
|
|
531
539
|
services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
|
|
532
|
-
res.redirect(returnTo(req, '/
|
|
540
|
+
res.redirect(returnTo(req, '/runners'));
|
|
533
541
|
});
|
|
534
542
|
|
|
535
543
|
// Cancel whatever the Client is doing (resource card): its job, a manual
|
|
536
544
|
// local lock, or a self-update hold (README §10). The Client applies the
|
|
537
545
|
// lock/update ones via heartbeat commands; a job is canceled right here.
|
|
538
|
-
router.post('/
|
|
546
|
+
router.post('/runners/:id/cancel-activity', (req, res) => {
|
|
539
547
|
const r = services.registry.get(req.params.id),
|
|
540
548
|
job = r && services.jobs.activeForResource(r.id),
|
|
541
549
|
activity = r?.activity?.state;
|
|
542
550
|
try {
|
|
543
551
|
if (!r){
|
|
544
|
-
throw Object.assign(new Error('Unknown
|
|
552
|
+
throw Object.assign(new Error('Unknown runner'), { status: 404 });
|
|
545
553
|
}
|
|
546
554
|
if (job){
|
|
547
555
|
services.jobs.cancel(job.id, { isAdmin: true });
|
|
@@ -572,10 +580,10 @@ function createWebRouter({ services, config }){
|
|
|
572
580
|
catch (err){
|
|
573
581
|
flash(req, 'danger', err.message);
|
|
574
582
|
}
|
|
575
|
-
res.redirect(returnTo(req, '/
|
|
583
|
+
res.redirect(returnTo(req, '/runners'));
|
|
576
584
|
});
|
|
577
585
|
|
|
578
|
-
router.post('/
|
|
586
|
+
router.post('/runners/:id/remove', (req, res) => {
|
|
579
587
|
try {
|
|
580
588
|
const { resource: r, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
|
|
581
589
|
by: req.session.user.username,
|
|
@@ -592,12 +600,12 @@ function createWebRouter({ services, config }){
|
|
|
592
600
|
catch (err){
|
|
593
601
|
flash(req, 'danger', err.message);
|
|
594
602
|
}
|
|
595
|
-
res.redirect(returnTo(req, '/
|
|
603
|
+
res.redirect(returnTo(req, '/runners'));
|
|
596
604
|
});
|
|
597
605
|
|
|
598
606
|
// Reboot now (resource card footer): cancels a running job, then the
|
|
599
607
|
// Client reboots its host on its next heartbeat (jobs.requestReboot).
|
|
600
|
-
router.post('/
|
|
608
|
+
router.post('/runners/:id/reboot', (req, res) => {
|
|
601
609
|
try {
|
|
602
610
|
const { resource: r, canceledJob } = services.jobs.requestReboot(req.params.id, { by: req.session.user.username });
|
|
603
611
|
flash(req, 'warning', `Rebooting ${r.name}'s host on its next heartbeat.` + (canceledJob ? `\nCanceled its running job ${canceledJob}.` : ''));
|
|
@@ -605,7 +613,7 @@ function createWebRouter({ services, config }){
|
|
|
605
613
|
catch (err){
|
|
606
614
|
flash(req, 'danger', err.message);
|
|
607
615
|
}
|
|
608
|
-
res.redirect(returnTo(req, '/
|
|
616
|
+
res.redirect(returnTo(req, '/runners'));
|
|
609
617
|
});
|
|
610
618
|
|
|
611
619
|
// "Connected USB devices" tab (public/js/usb-scan.js): Refresh queues a
|
|
@@ -621,7 +629,7 @@ function createWebRouter({ services, config }){
|
|
|
621
629
|
};
|
|
622
630
|
}
|
|
623
631
|
|
|
624
|
-
router.post('/
|
|
632
|
+
router.post('/runners/:id/usb-scan', (req, res) => {
|
|
625
633
|
try {
|
|
626
634
|
services.registry.requestUsbScan(req.params.id, { by: req.session.user.username });
|
|
627
635
|
res.json(usbScanView(req, services.registry.get(req.params.id)));
|
|
@@ -631,16 +639,16 @@ function createWebRouter({ services, config }){
|
|
|
631
639
|
}
|
|
632
640
|
});
|
|
633
641
|
|
|
634
|
-
router.get('/
|
|
642
|
+
router.get('/runners/:id/usb-scan', (req, res) => {
|
|
635
643
|
const r = services.registry.get(req.params.id);
|
|
636
644
|
if (!r){
|
|
637
|
-
return res.status(404).json({ error: 'Unknown
|
|
645
|
+
return res.status(404).json({ error: 'Unknown runner' });
|
|
638
646
|
}
|
|
639
647
|
res.set('Cache-Control', 'no-store').json(usbScanView(req, r));
|
|
640
648
|
});
|
|
641
649
|
|
|
642
650
|
// Rename (resource card, Name row); empty = back to the Client's own name.
|
|
643
|
-
router.post('/
|
|
651
|
+
router.post('/runners/:id/rename', (req, res) => {
|
|
644
652
|
try {
|
|
645
653
|
const before = services.registry.get(req.params.id)?.name,
|
|
646
654
|
r = services.registry.rename(req.params.id, req.body.name, { by: req.session.user.username });
|
|
@@ -651,13 +659,13 @@ function createWebRouter({ services, config }){
|
|
|
651
659
|
catch (err){
|
|
652
660
|
flash(req, 'danger', err.message);
|
|
653
661
|
}
|
|
654
|
-
res.redirect(returnTo(req, '/
|
|
662
|
+
res.redirect(returnTo(req, '/runners'));
|
|
655
663
|
});
|
|
656
664
|
|
|
657
665
|
// Capabilities (resource card, Config tab): public/js/client-config.js
|
|
658
666
|
// sends the edited hw/sw section as JSON in `config`. Applied by the Client
|
|
659
667
|
// on its next heartbeat, then it restarts once idle.
|
|
660
|
-
router.post('/
|
|
668
|
+
router.post('/runners/:id/config', (req, res) => {
|
|
661
669
|
try {
|
|
662
670
|
let config;
|
|
663
671
|
try {
|
|
@@ -673,12 +681,27 @@ function createWebRouter({ services, config }){
|
|
|
673
681
|
catch (err){
|
|
674
682
|
flash(req, 'danger', err.message);
|
|
675
683
|
}
|
|
676
|
-
res.redirect(returnTo(req, '/
|
|
684
|
+
res.redirect(returnTo(req, '/runners'));
|
|
677
685
|
});
|
|
678
686
|
|
|
679
687
|
// Groups tab (resource card): the Client's group membership, as a config
|
|
680
688
|
// revision the Client writes to its file (registry.setClientGroups).
|
|
681
|
-
|
|
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
|
+
|
|
704
|
+
router.post('/runners/:id/groups', (req, res) => {
|
|
682
705
|
try {
|
|
683
706
|
const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
|
|
684
707
|
r = services.registry.setClientGroups(req.params.id, ids, {
|
|
@@ -691,12 +714,12 @@ function createWebRouter({ services, config }){
|
|
|
691
714
|
catch (err){
|
|
692
715
|
flash(req, 'danger', err.message);
|
|
693
716
|
}
|
|
694
|
-
res.redirect(returnTo(req, '/
|
|
717
|
+
res.redirect(returnTo(req, '/runners'));
|
|
695
718
|
});
|
|
696
719
|
|
|
697
720
|
// Export (resource card, admins — the file holds the join key): the
|
|
698
721
|
// Client's config file as JSON, secrets excluded.
|
|
699
|
-
router.get('/
|
|
722
|
+
router.get('/runners/:id/config/export', (req, res) => {
|
|
700
723
|
try {
|
|
701
724
|
const r = services.registry.get(req.params.id),
|
|
702
725
|
file = services.registry.exportClientConfig(req.params.id);
|
|
@@ -704,13 +727,13 @@ function createWebRouter({ services, config }){
|
|
|
704
727
|
}
|
|
705
728
|
catch (err){
|
|
706
729
|
flash(req, 'danger', err.message);
|
|
707
|
-
res.redirect('/
|
|
730
|
+
res.redirect('/runners');
|
|
708
731
|
}
|
|
709
732
|
});
|
|
710
733
|
|
|
711
734
|
// Import (resource card, public/js/config-import.js): a config file, applied
|
|
712
735
|
// by the Client on its next heartbeat like a Config tab Save.
|
|
713
|
-
router.post('/
|
|
736
|
+
router.post('/runners/:id/config/import', (req, res) => {
|
|
714
737
|
try {
|
|
715
738
|
let file;
|
|
716
739
|
try {
|
|
@@ -727,12 +750,12 @@ function createWebRouter({ services, config }){
|
|
|
727
750
|
catch (err){
|
|
728
751
|
flash(req, 'danger', err.message);
|
|
729
752
|
}
|
|
730
|
-
res.redirect(returnTo(req, '/
|
|
753
|
+
res.redirect(returnTo(req, '/runners'));
|
|
731
754
|
});
|
|
732
755
|
|
|
733
756
|
// Scheduled host reboot (resource card): save a cron expression, or clear
|
|
734
757
|
// it; the Client applies it on its next heartbeat.
|
|
735
|
-
router.post('/
|
|
758
|
+
router.post('/runners/:id/reboot-schedule', (req, res) => {
|
|
736
759
|
try {
|
|
737
760
|
const cron = req.body.clear === '1' ? '' : req.body.cron,
|
|
738
761
|
r = services.registry.setRebootSchedule(req.params.id, cron, { by: req.session.user.username });
|
|
@@ -743,17 +766,17 @@ function createWebRouter({ services, config }){
|
|
|
743
766
|
catch (err){
|
|
744
767
|
flash(req, 'danger', err.message);
|
|
745
768
|
}
|
|
746
|
-
res.redirect(returnTo(req, '/
|
|
769
|
+
res.redirect(returnTo(req, '/runners'));
|
|
747
770
|
});
|
|
748
771
|
|
|
749
|
-
router.post('/
|
|
772
|
+
router.post('/runners/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
750
773
|
const { generateToken, hashToken } = require('../services/tokens');
|
|
751
774
|
const token = generateToken('res');
|
|
752
775
|
services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
|
|
753
776
|
services.events.record('resource', req.params.id, 'resource.token_rotated', {});
|
|
754
777
|
// Sticky: shown only once, so it mustn't close before it's copied.
|
|
755
|
-
flash(req, 'warning', `New
|
|
756
|
-
res.redirect(returnTo(req, '/
|
|
778
|
+
flash(req, 'warning', `New runner token (copy it now):\n${token}`, { sticky: true });
|
|
779
|
+
res.redirect(returnTo(req, '/runners'));
|
|
757
780
|
});
|
|
758
781
|
|
|
759
782
|
// Self-update (README §10.2). Clients get a `self-update` heartbeat
|
|
@@ -797,13 +820,13 @@ function createWebRouter({ services, config }){
|
|
|
797
820
|
parts.push('Latest Client version unknown.');
|
|
798
821
|
}
|
|
799
822
|
else if (!connected.length){
|
|
800
|
-
parts.push('No connected
|
|
823
|
+
parts.push('No connected runners.');
|
|
801
824
|
}
|
|
802
825
|
else if (outdated.length){
|
|
803
|
-
parts.push(`${outdated.length} of ${connected.length} connected
|
|
826
|
+
parts.push(`${outdated.length} of ${connected.length} connected runner(s) can update to Client v${s.latest.client}: ${names(outdated)}.`);
|
|
804
827
|
}
|
|
805
828
|
else {
|
|
806
|
-
parts.push(`All ${connected.length} connected
|
|
829
|
+
parts.push(`All ${connected.length} connected runner(s) run the latest Client (v${s.latest.client}).`);
|
|
807
830
|
}
|
|
808
831
|
if (unknown.length){
|
|
809
832
|
parts.push(`Version unknown (Client too old to report it): ${names(unknown)}.`);
|
|
@@ -833,20 +856,20 @@ function createWebRouter({ services, config }){
|
|
|
833
856
|
});
|
|
834
857
|
});
|
|
835
858
|
|
|
836
|
-
router.post('/
|
|
859
|
+
router.post('/runners/update-all', updateAction(async () => {
|
|
837
860
|
const version = await services.updates.targetVersion('client'),
|
|
838
861
|
n = services.registry.requestUpdateAll(version);
|
|
839
|
-
return `Requested update to client v${version} on ${n}
|
|
862
|
+
return `Requested update to client v${version} on ${n} runner(s).`;
|
|
840
863
|
}));
|
|
841
864
|
|
|
842
|
-
router.post('/
|
|
865
|
+
router.post('/runners/:id/update', updateAction(async (req) => {
|
|
843
866
|
// Cancel (next to the version): withdraw the request and, if the host is
|
|
844
867
|
// already holding for the update, abort that too (the Client's next
|
|
845
868
|
// heartbeat) — the one control for canceling a Client update.
|
|
846
869
|
if (req.body.cancel === '1'){
|
|
847
870
|
const r = services.registry.get(req.params.id);
|
|
848
871
|
if (!r){
|
|
849
|
-
throw Object.assign(new Error('Unknown
|
|
872
|
+
throw Object.assign(new Error('Unknown runner'), { status: 404 });
|
|
850
873
|
}
|
|
851
874
|
services.registry.setUpdateTo(r.id, null);
|
|
852
875
|
if (r.activity?.state === 'update-hold'){
|
|
@@ -1069,7 +1092,7 @@ function createWebRouter({ services, config }){
|
|
|
1069
1092
|
|
|
1070
1093
|
router.post('/groups/:id/delete', (req, res) => {
|
|
1071
1094
|
services.groups.remove(req.params.id);
|
|
1072
|
-
flash(req, 'warning', 'Group deleted; any
|
|
1095
|
+
flash(req, 'warning', 'Group deleted; any runner that listed it just stopped matching on it.');
|
|
1073
1096
|
res.redirect('/groups');
|
|
1074
1097
|
});
|
|
1075
1098
|
|
|
@@ -1086,7 +1109,7 @@ function createWebRouter({ services, config }){
|
|
|
1086
1109
|
// CI tokens only: a person's key belongs to their user (Users, §10.3).
|
|
1087
1110
|
sorted = services.agents.list()
|
|
1088
1111
|
.filter((a) => !a.user_id)
|
|
1089
|
-
.filter((a) => search.matches([a.name, a.
|
|
1112
|
+
.filter((a) => search.matches([a.name, a.version, groupName(a), a.revoked_at ? 'revoked' : 'active'], terms))
|
|
1090
1113
|
.sort((a, b) => {
|
|
1091
1114
|
const va = value(a),
|
|
1092
1115
|
vb = value(b);
|
|
@@ -1469,7 +1492,7 @@ function createWebRouter({ services, config }){
|
|
|
1469
1492
|
if (req.body.groupId !== undefined && (req.body.groupId || null) !== (before.group_id || null)){
|
|
1470
1493
|
const group = req.body.groupId ? services.groups.get(req.body.groupId) : null;
|
|
1471
1494
|
services.agents.setGroup(req.params.id, req.body.groupId);
|
|
1472
|
-
flash(req, 'success', group ? `"${agent.name}" now runs its jobs in group ${group.name}.` : `"${agent.name}" now runs its jobs on any
|
|
1495
|
+
flash(req, 'success', group ? `"${agent.name}" now runs its jobs in group ${group.name}.` : `"${agent.name}" now runs its jobs on any runner.`);
|
|
1473
1496
|
}
|
|
1474
1497
|
}
|
|
1475
1498
|
catch (err){
|
package/test/groups-tab.test.js
CHANGED
|
@@ -52,10 +52,10 @@ test('Groups tab: in effect at once, sent to the Client as its next config revis
|
|
|
52
52
|
nightly = services.groups.create({ name: 'nightly' }),
|
|
53
53
|
pr = services.groups.create({ name: 'pr-pool', comment: 'pull requests' }),
|
|
54
54
|
id = register(services, 'lab-sw-01'),
|
|
55
|
-
save = (cookie, groups) => fetch(`${base}/
|
|
56
|
-
method: 'POST', headers: { cookie }, body: new URLSearchParams([...groups.map((g) => ['groups', g]), ['returnTo', '/
|
|
55
|
+
save = (cookie, groups) => fetch(`${base}/runners/${id}/groups`, {
|
|
56
|
+
method: 'POST', headers: { cookie }, body: new URLSearchParams([...groups.map((g) => ['groups', g]), ['returnTo', '/runners']]), redirect: 'manual'
|
|
57
57
|
}),
|
|
58
|
-
page = async () => (await fetch(`${base}/
|
|
58
|
+
page = async () => (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
59
59
|
|
|
60
60
|
assert.equal((await save(viewer, [nightly.id])).status, 302); // a maintainer may (§10.3)
|
|
61
61
|
assert.equal((await save(admin, [nightly.id, pr.id])).status, 302);
|
|
@@ -105,7 +105,7 @@ test('device tabs show what the Client runs — not an old saved revision; group
|
|
|
105
105
|
}).resourceId,
|
|
106
106
|
id = register('1.1'),
|
|
107
107
|
devpaths = async () => {
|
|
108
|
-
const html = await (await fetch(`${base}/
|
|
108
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text(),
|
|
109
109
|
table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
|
|
110
110
|
return [...table.matchAll(/data-f="devpath" value="([^"]*)"/g)].map((m) => m[1]).filter(Boolean); // not the blank add-row template
|
|
111
111
|
};
|
|
@@ -134,7 +134,7 @@ test('ST-Link tab has no Serial field; a serial set in the config file is kept o
|
|
|
134
134
|
clientId: 'hw2', name: 'lab-hw-02', type: 'hw', labels: [],
|
|
135
135
|
config: { stlinks: [{ index: 1, serial: '066DFF48', devpath: '1.2' }] }, configFile: { name: 'lab-hw-02', type: 'hw' }
|
|
136
136
|
}).resourceId,
|
|
137
|
-
html = await (await fetch(`${base}/
|
|
137
|
+
html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text(),
|
|
138
138
|
table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
|
|
139
139
|
assert.doesNotMatch(table, /data-f="serial"|>Serial</);
|
|
140
140
|
// Rides along in data-extra, which public/js/client-config.js spreads back into the row.
|
|
@@ -147,7 +147,7 @@ test('resource card: USB actions update live and every disabled button says why'
|
|
|
147
147
|
clientId: 'hw3', name: 'lab-hw-03', type: 'hw', labels: [], config: { stlinks: [{ index: 1 }] }, configFile: { name: 'lab-hw-03', type: 'hw' }
|
|
148
148
|
}),
|
|
149
149
|
card = async () => {
|
|
150
|
-
const html = await (await fetch(`${base}/
|
|
150
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
151
151
|
return html.slice(html.indexOf(`id="resource-card-${reg.resourceId}"`));
|
|
152
152
|
};
|
|
153
153
|
let html = await card();
|
|
@@ -169,7 +169,7 @@ test('Import and Export work for a Client that doesn\'t re-register (no joinKey)
|
|
|
169
169
|
const { base, services, admin } = await start(t),
|
|
170
170
|
{ resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'hw4', name: 'lab-hw-04', type: 'hw', labels: [] }),
|
|
171
171
|
card = async () => {
|
|
172
|
-
const html = await (await fetch(`${base}/
|
|
172
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
173
173
|
return html.slice(html.indexOf(`id="resource-card-${resourceId}"`));
|
|
174
174
|
};
|
|
175
175
|
assert.match(await card(), /Import unavailable: this Client is too old/); // nothing reported, no version known
|
|
@@ -188,7 +188,7 @@ test('Import and Export work for a Client that doesn\'t re-register (no joinKey)
|
|
|
188
188
|
const html = await card();
|
|
189
189
|
assert.match(html, /data-config-import-pick/); // Import enabled
|
|
190
190
|
assert.doesNotMatch(html, /Import unavailable/);
|
|
191
|
-
const exported = await (await fetch(`${base}/
|
|
191
|
+
const exported = await (await fetch(`${base}/runners/${resourceId}/config/export`, { headers: { cookie: admin } })).json();
|
|
192
192
|
assert.equal(exported.joinKey, undefined);
|
|
193
193
|
assert.deepEqual(exported['hw-devices'], { stlinks: [{ index: 1, devpath: '1.1' }] });
|
|
194
194
|
|
|
@@ -238,7 +238,7 @@ test('agent groups are set on the dashboard (Users, CI tokens) and shown by name
|
|
|
238
238
|
assert.match(agents, /<span class="badge text-bg-info">nightly-pool<\/span>/);
|
|
239
239
|
assert.match(groups, /data-bs-title="dev, ci-night"[^>]*>2</); // Agents column: users, then CI tokens
|
|
240
240
|
// The id is never shown as text on a page (it's only in values and URLs).
|
|
241
|
-
for (const html of [users, agents, groups, await page(admin, '/
|
|
241
|
+
for (const html of [users, agents, groups, await page(admin, '/runners'), await page(admin, `/jobs/${job.id}`)]){
|
|
242
242
|
const text = html.replace(/<[^>]*>/g, ' ');
|
|
243
243
|
assert.ok(!text.includes(g.id), 'a group id shows as text');
|
|
244
244
|
}
|
|
@@ -275,3 +275,68 @@ test('CI tokens: the name opens Edit; a new token replaces the old one; delete k
|
|
|
275
275
|
assert.match(await (await form(`/admin/agents/${agent.id}/token`)).headers.get('location'), /agents/); // can't come back
|
|
276
276
|
assert.equal(services.agents.get(agent.id).revoked_at !== null, true);
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
test('Resources renamed Runners: the page is /runners; old /resources links and forms still land', async (t) => {
|
|
280
|
+
const { base, admin } = await start(t),
|
|
281
|
+
old = await fetch(`${base}/resources?q=lab`, { headers: { cookie: admin }, redirect: 'manual' });
|
|
282
|
+
assert.equal(old.status, 301);
|
|
283
|
+
assert.equal(old.headers.get('location'), '/runners?q=lab');
|
|
284
|
+
const post = await fetch(`${base}/resources/abc/rename`, { method: 'POST', headers: { cookie: admin }, redirect: 'manual' });
|
|
285
|
+
assert.equal(post.status, 308); // keeps the method and body
|
|
286
|
+
assert.equal(post.headers.get('location'), '/runners/abc/rename');
|
|
287
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
288
|
+
assert.match(html, /<title>Runners · TestHub<\/title>/);
|
|
289
|
+
assert.match(html, /href="\/runners"[^>]*>(<i [^>]*><\/i>)?Runners</);
|
|
290
|
+
assert.doesNotMatch(html.replace(/<[^>]*>/g, ' '), /\bResources?\b/); // no visible "Resource(s)" left on the page
|
|
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
|
+
});
|
package/test/live.test.js
CHANGED
|
@@ -133,7 +133,7 @@ test('live: /live streams hello and changes; passive re-fetches keep the idle ti
|
|
|
133
133
|
await new Promise((r) => setTimeout(r, 1100));
|
|
134
134
|
|
|
135
135
|
// …survives a passive re-fetch, which also doesn't push the expiry out.
|
|
136
|
-
const passive = await fetch(`${base}/
|
|
136
|
+
const passive = await fetch(`${base}/runners`, { headers: { cookie, 'X-Thub-Live': '1' } });
|
|
137
137
|
assert.equal(passive.status, 200);
|
|
138
138
|
assert.match(passive.headers.get('cache-control'), /no-store/);
|
|
139
139
|
const passiveExpires = expiresOf(passive);
|
|
@@ -149,5 +149,5 @@ test('live: /live streams hello and changes; passive re-fetches keep the idle ti
|
|
|
149
149
|
// passive re-fetch is redirected to the login page.
|
|
150
150
|
await fetch(`${base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
|
|
151
151
|
assert.equal((await fetch(`${base}/live`, { headers: { cookie, accept: 'text/event-stream' } })).status, 401);
|
|
152
|
-
assert.equal((await fetch(`${base}/
|
|
152
|
+
assert.equal((await fetch(`${base}/runners`, { headers: { cookie, 'X-Thub-Live': '1' }, redirect: 'manual' })).status, 302);
|
|
153
153
|
});
|
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
|
+
});
|