@andrian.yablonskyy/thub-coordinator 1.0.32 → 1.0.33
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 +1 -1
- package/package.json +2 -2
- package/public/js/client-config.js +3 -20
- package/public/js/config-import.js +59 -0
- package/src/api/resource.js +2 -1
- package/src/api/sse.js +1 -1
- package/src/db/migrations/021_add_client_config_file.sql +8 -0
- package/src/dev/virtual.js +6 -17
- package/src/services/jobs.js +2 -14
- package/src/services/registry.js +75 -45
- package/src/services/scheduler.js +1 -6
- package/src/web/routes.js +37 -1
- package/test/scheduler.test.js +83 -47
- package/views/index.pug +1 -0
- package/views/mixins/client-config.pug +32 -95
- package/views/mixins/resource.pug +46 -28
- package/views/resources/list.pug +1 -0
package/README.md
CHANGED
|
@@ -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
|
-
**Resource card.** Clicking a resource on `/` or `/resources` 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
|
|
162
|
+
**Resource card.** Clicking a resource on `/` or `/resources` 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.0.
|
|
3
|
+
"version": "1.0.33",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"lint:fix": "eslint . --fix"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@andrian.yablonskyy/thub-common": "^1.0.
|
|
22
|
+
"@andrian.yablonskyy/thub-common": "^1.0.28",
|
|
23
23
|
"better-sqlite3": "^12.4.1",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
25
|
"express-session": "^1.18.2",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/public/js/client-config.js
|
|
3
3
|
* @description Dashboard: the resource card's Capabilities form — add/remove device rows, and turn the form into
|
|
4
|
-
*
|
|
4
|
+
* an HW Client's hw-devices config section (JSON) on Save
|
|
5
5
|
*
|
|
6
6
|
* @author Andrian Yablonskyy
|
|
7
7
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -24,9 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
function rows(form, kind){
|
|
26
26
|
return [...form.querySelectorAll(`[data-table="${kind}"] tbody [data-row]`)].map((tr) => {
|
|
27
|
-
if (kind === 'relays'){
|
|
28
|
-
return compact({ channel: num(tr, 'channel'), baseUrl: text(tr, 'baseUrl') });
|
|
29
|
-
}
|
|
30
27
|
let extra = {};
|
|
31
28
|
try {
|
|
32
29
|
extra = JSON.parse(tr.dataset.extra || '{}');
|
|
@@ -44,26 +41,12 @@
|
|
|
44
41
|
});
|
|
45
42
|
}
|
|
46
43
|
|
|
44
|
+
// An HW Client's hw-devices section (the only Clients with a form).
|
|
47
45
|
function sectionFrom(form){
|
|
48
|
-
if (form.dataset.clientConfig === 'sw'){
|
|
49
|
-
return compact({
|
|
50
|
-
image: text(form, 'image'),
|
|
51
|
-
registry: text(form, 'registry'),
|
|
52
|
-
cpus: num(form, 'cpus'),
|
|
53
|
-
memory: text(form, 'memory'),
|
|
54
|
-
cmd: text(form, 'cmd') ? text(form, 'cmd').split(/\s+/) : undefined,
|
|
55
|
-
allowDockerHub: val(form, 'allowDockerHub').checked,
|
|
56
|
-
allowJobImages: val(form, 'allowJobImages').checked
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
const power = form.querySelector('[data-power]'),
|
|
60
|
-
method = text(power, 'method');
|
|
61
46
|
return {
|
|
62
47
|
stlinks: rows(form, 'stlinks'),
|
|
63
48
|
uarts: rows(form, 'uarts'),
|
|
64
|
-
usbs: rows(form, 'usbs')
|
|
65
|
-
relays: rows(form, 'relays'),
|
|
66
|
-
power: method ? compact({ method, hub: text(power, 'hub'), port: num(power, 'port'), baseUrl: text(power, 'baseUrl') }) : null
|
|
49
|
+
usbs: rows(form, 'usbs')
|
|
67
50
|
};
|
|
68
51
|
}
|
|
69
52
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/config-import.js
|
|
3
|
+
* @description Dashboard: the resource card's config Import button — picks a JSON file, puts it in the import form
|
|
4
|
+
* and submits it, confirmed first (confirm.js) with what will and won't be applied
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
(function (){
|
|
18
|
+
const IGNORED = ['joinKey', 'coordinatorUrl', 'name'],
|
|
19
|
+
MAX_BYTES = 64 * 1024;
|
|
20
|
+
|
|
21
|
+
document.querySelectorAll('form[data-config-import]').forEach((form) => {
|
|
22
|
+
const picker = form.querySelector('[data-config-import-file]');
|
|
23
|
+
form.querySelector('[data-config-import-pick]').addEventListener('click', () => {
|
|
24
|
+
picker.value = ''; // the same file again still fires `change`
|
|
25
|
+
picker.click();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
picker.addEventListener('change', async () => {
|
|
29
|
+
const file = picker.files[0];
|
|
30
|
+
if (!file){
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const text = file.size > MAX_BYTES ? '' : await file.text();
|
|
34
|
+
form.querySelector('input[name="file"]').value = text;
|
|
35
|
+
form.querySelector('input[name="fileName"]').value = file.name;
|
|
36
|
+
|
|
37
|
+
let parsed = null;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
parsed = null;
|
|
43
|
+
}
|
|
44
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)){
|
|
45
|
+
// Nothing to confirm: the Coordinator says what's wrong with it.
|
|
46
|
+
delete form.dataset.confirm;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const present = IGNORED.filter((k) => k in parsed);
|
|
50
|
+
form.dataset.confirm = `Import ${file.name} into ${form.dataset.resourceName}? ` +
|
|
51
|
+
'It replaces the Client\'s config (capabilities, labels, groups, heartbeat settings, …); the Client writes it on its next heartbeat ' +
|
|
52
|
+
'and restarts once it has no job running.\n\n' +
|
|
53
|
+
`Ignored: joinKey, coordinatorUrl and name${present.length ? ` (in this file: ${present.join(', ')})` : ''}, ` +
|
|
54
|
+
'and the Client\'s id, file paths and secrets — each Client keeps its own.';
|
|
55
|
+
}
|
|
56
|
+
form.requestSubmit();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
})();
|
package/src/api/resource.js
CHANGED
|
@@ -51,7 +51,7 @@ function createResourceRouter({ services, config }){
|
|
|
51
51
|
// the resource by name (see registry.registerAuto).
|
|
52
52
|
router.post('/resources/register', requireJoinKey(config), (req, res, next) => {
|
|
53
53
|
try {
|
|
54
|
-
const { clientId, name, type, labels, groups, hostInfo, capabilities, config: clientConfig } = req.body;
|
|
54
|
+
const { clientId, name, type, labels, groups, hostInfo, capabilities, config: clientConfig, configFile } = req.body;
|
|
55
55
|
if (!clientId){
|
|
56
56
|
return res.status(400).json({ error: 'clientId is required (persisted in the Client\'s .client-id file)' });
|
|
57
57
|
}
|
|
@@ -67,6 +67,7 @@ function createResourceRouter({ services, config }){
|
|
|
67
67
|
hostInfo,
|
|
68
68
|
capabilities,
|
|
69
69
|
config: clientConfig,
|
|
70
|
+
configFile,
|
|
70
71
|
remoteAddr: req.ip,
|
|
71
72
|
clientVersion: appVersion(req, 'client')
|
|
72
73
|
});
|
package/src/api/sse.js
CHANGED
|
@@ -74,7 +74,7 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
76
76
|
const { type, labels = [], group, client } = job.spec.target,
|
|
77
|
-
reason = registry.waitingReason(type, labels, group, client
|
|
77
|
+
reason = registry.waitingReason(type, labels, group, client);
|
|
78
78
|
if (reason && reason !== lastReason){
|
|
79
79
|
writeEvent('waiting', { reason });
|
|
80
80
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Client config Export / Import (README §10, resource card Actions).
|
|
2
|
+
-- `client_config_file` is the Client's whole config file as it last
|
|
3
|
+
-- reported it at registration, its secrets (artifactory.token,
|
|
4
|
+
-- sw.registryAuth) left out. `config_import` is an imported file's fields
|
|
5
|
+
-- beyond the hw/sw section, as JSON { revision, fields }: sent with every
|
|
6
|
+
-- set-config until the Client reports applying that revision.
|
|
7
|
+
ALTER TABLE resources ADD COLUMN client_config_file TEXT;
|
|
8
|
+
ALTER TABLE resources ADD COLUMN config_import TEXT;
|
package/src/dev/virtual.js
CHANGED
|
@@ -29,9 +29,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
29
29
|
name: 'virtual-sw-01',
|
|
30
30
|
type: 'sw',
|
|
31
31
|
labels: ['virtual', 'emulator'],
|
|
32
|
-
capabilities: {
|
|
33
|
-
sw: { image: 'dut-emulator:dev', registry: null, allowDockerHub: true, allowJobImages: true, cpus: 2, memory: '2g' }
|
|
34
|
-
},
|
|
32
|
+
capabilities: { sw: {} },
|
|
35
33
|
address: '10.0.0.41'
|
|
36
34
|
},
|
|
37
35
|
{
|
|
@@ -43,9 +41,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
43
41
|
hw: {
|
|
44
42
|
stlinks: [{ path: '/dev/thub/dut1-stlink', index: 1, serial: '066DFF485457725187092834', present: true }],
|
|
45
43
|
uarts: [{ path: '/dev/thub/dut1-uart', index: 1, baudRate: 115200, present: true }],
|
|
46
|
-
usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }]
|
|
47
|
-
relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
|
|
48
|
-
power: { method: 'relay' }
|
|
44
|
+
usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }]
|
|
49
45
|
}
|
|
50
46
|
},
|
|
51
47
|
address: '10.0.0.42'
|
|
@@ -188,21 +184,14 @@ function startVirtualClients(services, config){
|
|
|
188
184
|
labels: def.labels,
|
|
189
185
|
hostInfo: { hostname: `${def.name}.virtual`, platform: 'linux', addresses: [{ iface: 'eth0', address: def.address, family: 'IPv4' }], timeZone: 'UTC' },
|
|
190
186
|
capabilities: { ...def.capabilities, rebootSupported: false },
|
|
191
|
-
// Its
|
|
187
|
+
// Its editable section as a real Client reports it: an HW Client's
|
|
188
|
+
// hw-devices; an SW Client has none ({}).
|
|
192
189
|
config: def.type === 'sw'
|
|
193
|
-
? {
|
|
194
|
-
image: def.capabilities.sw.image,
|
|
195
|
-
allowDockerHub: def.capabilities.sw.allowDockerHub,
|
|
196
|
-
allowJobImages: def.capabilities.sw.allowJobImages,
|
|
197
|
-
cpus: def.capabilities.sw.cpus,
|
|
198
|
-
memory: def.capabilities.sw.memory
|
|
199
|
-
}
|
|
190
|
+
? {}
|
|
200
191
|
: {
|
|
201
192
|
stlinks: [{ index: 1, serial: '066DFF485457725187092834', devpath: '3.3.4.3.1' }],
|
|
202
193
|
uarts: [{ index: 1, baudRate: 115200, devpath: '3.3.3.2' }],
|
|
203
|
-
usbs: [{ index: 1 }]
|
|
204
|
-
relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
|
|
205
|
-
power: { method: 'relay' }
|
|
194
|
+
usbs: [{ index: 1 }]
|
|
206
195
|
},
|
|
207
196
|
remoteAddr: '127.0.0.1',
|
|
208
197
|
clientVersion: version
|
package/src/services/jobs.js
CHANGED
|
@@ -149,20 +149,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
149
149
|
spec.target.client = resource.id;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const needs = { jobImage: Boolean(spec.image) },
|
|
155
|
-
{ type, labels, group, client } = spec.target;
|
|
156
|
-
if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
|
|
157
|
-
throw Object.assign(
|
|
158
|
-
new Error(
|
|
159
|
-
`No matching SW Client runs job-supplied Docker images (${spec.image}) — enable it on a Client ` +
|
|
160
|
-
'with "sw": { "allowJobImages": true } in its config'
|
|
161
|
-
),
|
|
162
|
-
{ status: 422 }
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
if (!registry.everSatisfiable(type, labels, group, client, needs)){
|
|
152
|
+
const { type, labels, group, client } = spec.target;
|
|
153
|
+
if (!registry.everSatisfiable(type, labels, group, client)){
|
|
166
154
|
throw Object.assign(
|
|
167
155
|
new Error(
|
|
168
156
|
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
package/src/services/registry.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { v4: uuid } = require('uuid'),
|
|
17
|
-
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron, validateClientConfig
|
|
17
|
+
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron, validateClientConfig, importClientConfigFile,
|
|
18
|
+
shareableClientConfigFile, HW_DEVICES_SECTION } = require('@andrian.yablonskyy/thub-common'),
|
|
18
19
|
{ generateToken, hashToken } = require('./tokens');
|
|
19
20
|
|
|
20
21
|
function rowToResource(row){
|
|
@@ -29,6 +30,8 @@ function rowToResource(row){
|
|
|
29
30
|
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null,
|
|
30
31
|
client_config: row.client_config ? JSON.parse(row.client_config) : null,
|
|
31
32
|
config_desired: row.config_desired ? JSON.parse(row.config_desired) : null,
|
|
33
|
+
client_config_file: row.client_config_file ? JSON.parse(row.client_config_file) : null,
|
|
34
|
+
config_import: row.config_import ? JSON.parse(row.config_import) : null,
|
|
32
35
|
usb_scan: row.usb_scan ? JSON.parse(row.usb_scan) : null,
|
|
33
36
|
activity: row.activity ? JSON.parse(row.activity) : null
|
|
34
37
|
};
|
|
@@ -86,29 +89,17 @@ function sanitizeCapabilities(caps){
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
function sanitizeTypedCapabilities(caps){
|
|
92
|
+
// An SW Client has no settings to report (older ones still send some).
|
|
89
93
|
if (caps.sw && typeof caps.sw === 'object'){
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
sw: {
|
|
93
|
-
image: str(sw.image),
|
|
94
|
-
registry: str(sw.registry),
|
|
95
|
-
allowDockerHub: sw.allowDockerHub === true,
|
|
96
|
-
allowJobImages: sw.allowJobImages === true,
|
|
97
|
-
cpus: num(sw.cpus),
|
|
98
|
-
memory: str(sw.memory, 16)
|
|
99
|
-
}
|
|
100
|
-
};
|
|
94
|
+
return { sw: {} };
|
|
101
95
|
}
|
|
102
96
|
if (caps.hw && typeof caps.hw === 'object'){
|
|
103
|
-
const hw = caps.hw
|
|
104
|
-
power = hw.power && typeof hw.power === 'object' ? hw.power : null;
|
|
97
|
+
const hw = caps.hw;
|
|
105
98
|
return {
|
|
106
99
|
hw: {
|
|
107
100
|
stlinks: capList(hw.stlinks).map(device),
|
|
108
101
|
uarts: capList(hw.uarts).map(device),
|
|
109
|
-
usbs: capList(hw.usbs).map(device)
|
|
110
|
-
relays: capList(hw.relays).map((r) => ({ channel: num(r?.channel), baseUrl: str(r?.baseUrl) })),
|
|
111
|
-
power: power ? { method: str(power.method, 32), hub: str(power.hub, 64), port: num(power.port) } : null
|
|
102
|
+
usbs: capList(hw.usbs).map(device)
|
|
112
103
|
}
|
|
113
104
|
};
|
|
114
105
|
}
|
|
@@ -195,7 +186,8 @@ function createRegistryService(db, { bus, events }){
|
|
|
195
186
|
// Config tab. Stored as reported, if it's a plain object of sane size.
|
|
196
187
|
// A dashboard rename (name_override) wins over the name the Client
|
|
197
188
|
// presents; that one is kept as reported_name.
|
|
198
|
-
|
|
189
|
+
// And `configFile`: the whole config file (secrets excluded), for Export.
|
|
190
|
+
function registerAuto({ config, configFile, ...registration }){
|
|
199
191
|
const known = registration.clientId ? getByClientId(registration.clientId) : null,
|
|
200
192
|
result = registerAutoInner({ ...registration, name: known?.name_override || registration.name });
|
|
201
193
|
db.prepare('UPDATE resources SET reported_name = ? WHERE id = ?').run(registration.name, result.resourceId);
|
|
@@ -205,6 +197,12 @@ function createRegistryService(db, { bus, events }){
|
|
|
205
197
|
db.prepare('UPDATE resources SET client_config = ? WHERE id = ?').run(json, result.resourceId);
|
|
206
198
|
}
|
|
207
199
|
}
|
|
200
|
+
if (configFile && typeof configFile === 'object' && !Array.isArray(configFile)){
|
|
201
|
+
const json = JSON.stringify(shareableClientConfigFile(configFile));
|
|
202
|
+
if (json.length <= 64 * 1024){
|
|
203
|
+
db.prepare('UPDATE resources SET client_config_file = ? WHERE id = ?').run(json, result.resourceId);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
208
206
|
return result;
|
|
209
207
|
}
|
|
210
208
|
|
|
@@ -529,13 +527,10 @@ function createRegistryService(db, { bus, events }){
|
|
|
529
527
|
);
|
|
530
528
|
}
|
|
531
529
|
|
|
532
|
-
|
|
533
|
-
// jobImage (the job's own Docker image; sw.allowJobImages).
|
|
534
|
-
function matchesTarget(r, labels, groupId, resourceId, needs = {}){
|
|
530
|
+
function matchesTarget(r, labels, groupId, resourceId){
|
|
535
531
|
return labels.every((l) => r.labels.includes(l)) &&
|
|
536
532
|
(!groupId || r.group_ids.includes(groupId)) &&
|
|
537
|
-
(!resourceId || r.id === resourceId)
|
|
538
|
-
(!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
|
|
533
|
+
(!resourceId || r.id === resourceId);
|
|
539
534
|
}
|
|
540
535
|
|
|
541
536
|
// Admin: remove a resource from the registry (dashboard "Remove"). Refused
|
|
@@ -653,13 +648,60 @@ function createRegistryService(db, { bus, events }){
|
|
|
653
648
|
return get(resourceId);
|
|
654
649
|
}
|
|
655
650
|
|
|
656
|
-
// { revision, type, config } while the Client hasn't applied the
|
|
651
|
+
// { revision, type, config[, file] } while the Client hasn't applied the
|
|
652
|
+
// saved one; `file`: an import's other fields, until it's applied.
|
|
657
653
|
function pendingConfig(resourceId){
|
|
658
654
|
const r = get(resourceId);
|
|
659
655
|
if (!r || !r.config_desired || r.config_applied_revision >= r.config_revision){
|
|
660
656
|
return null;
|
|
661
657
|
}
|
|
662
|
-
|
|
658
|
+
const file = pendingImport(r);
|
|
659
|
+
return { revision: r.config_revision, type: r.type, config: r.config_desired, ...(file ? { file } : {}) };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function pendingImport(r){
|
|
663
|
+
return r.config_import && r.config_import.revision > r.config_applied_revision ? r.config_import.fields : null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Import (resource card): a whole config file, as the Client's next config
|
|
667
|
+
// revision — its hw/sw section as if saved on the Config tabs (the one
|
|
668
|
+
// already there if the file has none), its other fields besides. Never
|
|
669
|
+
// taken from it: joinKey, coordinatorUrl, name, the Client's identity and
|
|
670
|
+
// host paths, secrets (importClientConfigFile). Returns { resource, ignored }.
|
|
671
|
+
function importClientConfig(resourceId, file, { by } = {}){
|
|
672
|
+
const r = get(resourceId);
|
|
673
|
+
if (!r){
|
|
674
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
675
|
+
}
|
|
676
|
+
const { valid, errors, section, fields, ignored } = importClientConfigFile(r.type, file);
|
|
677
|
+
if (!valid){
|
|
678
|
+
throw Object.assign(new Error(`Can't import this config into ${r.name}: ${errors.join('; ')}`), { status: 400 });
|
|
679
|
+
}
|
|
680
|
+
const desired = section || r.config_desired || r.client_config;
|
|
681
|
+
if (!desired){
|
|
682
|
+
throw Object.assign(new Error(`Can't import this config into ${r.name}: it has no "${HW_DEVICES_SECTION}" section, and the Client hasn't reported one`),
|
|
683
|
+
{ status: 400 });
|
|
684
|
+
}
|
|
685
|
+
const revision = r.config_revision + 1;
|
|
686
|
+
db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?
|
|
687
|
+
WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), resourceId);
|
|
688
|
+
events.record('resource', resourceId, 'resource.config_imported', { by, revision, fields: Object.keys(fields), ignored });
|
|
689
|
+
return { resource: get(resourceId), ignored };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Export (resource card): the Client's config file as it last reported it
|
|
693
|
+
// (secrets excluded), with what's saved for it but not applied yet — so
|
|
694
|
+
// exporting right after an edit or import gives what the Client will run.
|
|
695
|
+
// A Client too old to report its file gets what the Coordinator knows.
|
|
696
|
+
function exportClientConfig(resourceId){
|
|
697
|
+
const r = get(resourceId);
|
|
698
|
+
if (!r){
|
|
699
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
700
|
+
}
|
|
701
|
+
// Re-shared: a file stored from an older Client may still say hw / sw.
|
|
702
|
+
const base = shareableClientConfigFile(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels }),
|
|
703
|
+
section = r.type === 'hw' ? r.config_desired || r.client_config : null;
|
|
704
|
+
return { ...base, ...(pendingImport(r) || {}), type: r.type, ...(section ? { [HW_DEVICES_SECTION]: section } : {}) };
|
|
663
705
|
}
|
|
664
706
|
|
|
665
707
|
// Scheduled host reboot (resource card): a cron expression, validated
|
|
@@ -693,20 +735,19 @@ function createRegistryService(db, { bus, events }){
|
|
|
693
735
|
return { cron: r.reboot_schedule || null };
|
|
694
736
|
}
|
|
695
737
|
|
|
696
|
-
function findIdleCandidates(type, labels, groupId, resourceId
|
|
738
|
+
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
697
739
|
const rows = db
|
|
698
740
|
.prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
|
|
699
741
|
.all(RESOURCE_STATES.IDLE, type)
|
|
700
742
|
.map(rowToResource);
|
|
701
|
-
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId
|
|
743
|
+
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
702
744
|
}
|
|
703
745
|
|
|
704
746
|
// Why a queued job isn't assigned yet, in words — for the Agent and the
|
|
705
747
|
// dashboard (null when an idle Client fits: the next scheduler pass takes
|
|
706
748
|
// it, or higher-priority jobs are ahead). Names each matching Client that
|
|
707
|
-
// isn't free and what it's doing
|
|
708
|
-
|
|
709
|
-
function waitingReason(type, labels, groupId, resourceId, needs = {}){
|
|
749
|
+
// isn't free and what it's doing.
|
|
750
|
+
function waitingReason(type, labels, groupId, resourceId){
|
|
710
751
|
const matching = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource)
|
|
711
752
|
.filter((r) => matchesTarget(r, labels, groupId, resourceId)),
|
|
712
753
|
free = (r) => r.status === RESOURCE_STATES.IDLE && !r.remove_requested_at,
|
|
@@ -719,25 +760,12 @@ function createRegistryService(db, { bus, events }){
|
|
|
719
760
|
if (!matching.length){
|
|
720
761
|
return `no ${kind} Client matches this job's target`;
|
|
721
762
|
}
|
|
722
|
-
if (needs.jobImage){
|
|
723
|
-
const allowing = matching.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs)),
|
|
724
|
-
refusing = matching.filter((r) => !allowing.includes(r) && free(r));
|
|
725
|
-
if (!allowing.some(free)){
|
|
726
|
-
return `no idle ${kind} Client runs job-supplied Docker images — ` +
|
|
727
|
-
(allowing.length ? `the ones that do: ${names(allowing)}` : 'none does') +
|
|
728
|
-
(refusing.length
|
|
729
|
-
? `; idle, but not allowing them: ${refusing.map((r) => r.name).join(', ')} ` +
|
|
730
|
-
'(enable "Run jobs\' own images" on its Emulator config tab, or sw.allowJobImages in its config)'
|
|
731
|
-
: '');
|
|
732
|
-
}
|
|
733
|
-
return null;
|
|
734
|
-
}
|
|
735
763
|
return matching.some(free) ? null : `every matching ${kind} Client is taken: ${names(matching)}`;
|
|
736
764
|
}
|
|
737
765
|
|
|
738
|
-
function everSatisfiable(type, labels, groupId, resourceId
|
|
766
|
+
function everSatisfiable(type, labels, groupId, resourceId){
|
|
739
767
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
740
|
-
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId
|
|
768
|
+
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
741
769
|
}
|
|
742
770
|
|
|
743
771
|
return {
|
|
@@ -762,6 +790,8 @@ function createRegistryService(db, { bus, events }){
|
|
|
762
790
|
pendingRebootSchedule,
|
|
763
791
|
setClientConfig,
|
|
764
792
|
pendingConfig,
|
|
793
|
+
importClientConfig,
|
|
794
|
+
exportClientConfig,
|
|
765
795
|
rename,
|
|
766
796
|
requestUsbScan,
|
|
767
797
|
storeUsbScan,
|
|
@@ -15,11 +15,6 @@
|
|
|
15
15
|
|
|
16
16
|
const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
|
|
17
17
|
|
|
18
|
-
// What a job needs a Client to have opted in to (registry matchesTarget).
|
|
19
|
-
function jobNeeds(spec){
|
|
20
|
-
return { jobImage: Boolean(spec.image) };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
function rowToJob(row){
|
|
24
19
|
return { ...row, spec: JSON.parse(row.spec) };
|
|
25
20
|
}
|
|
@@ -48,7 +43,7 @@ function createScheduler(db, { bus, events, registry, config }){
|
|
|
48
43
|
for (const job of queued){
|
|
49
44
|
const { type, labels = [], group, client } = job.spec.target,
|
|
50
45
|
candidates = registry
|
|
51
|
-
.findIdleCandidates(type, labels, group, client
|
|
46
|
+
.findIdleCandidates(type, labels, group, client)
|
|
52
47
|
.filter((r) => !claimedResourceIds.has(r.id));
|
|
53
48
|
if (candidates.length === 0){
|
|
54
49
|
continue;
|
package/src/web/routes.js
CHANGED
|
@@ -493,6 +493,42 @@ function createWebRouter({ services, config }){
|
|
|
493
493
|
res.redirect(returnTo(req, '/resources'));
|
|
494
494
|
});
|
|
495
495
|
|
|
496
|
+
// Export (resource card, admins — the file holds the join key): the
|
|
497
|
+
// Client's config file as JSON, secrets excluded.
|
|
498
|
+
router.get('/resources/:id/config/export', requireAdminRole, (req, res) => {
|
|
499
|
+
try {
|
|
500
|
+
const r = services.registry.get(req.params.id),
|
|
501
|
+
file = services.registry.exportClientConfig(req.params.id);
|
|
502
|
+
res.attachment(`${r.name.replace(/[^A-Za-z0-9._-]+/g, '_')}.json`).type('application/json').send(JSON.stringify(file, null, 2) + '\n');
|
|
503
|
+
}
|
|
504
|
+
catch (err){
|
|
505
|
+
flash(req, 'danger', err.message);
|
|
506
|
+
res.redirect('/resources');
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Import (resource card, public/js/config-import.js): a config file, applied
|
|
511
|
+
// by the Client on its next heartbeat like a Config tab Save.
|
|
512
|
+
router.post('/resources/:id/config/import', requireAdminRole, (req, res) => {
|
|
513
|
+
try {
|
|
514
|
+
let file;
|
|
515
|
+
try {
|
|
516
|
+
file = JSON.parse(req.body.file || '');
|
|
517
|
+
}
|
|
518
|
+
catch (err){
|
|
519
|
+
throw Object.assign(new Error(`${req.body.fileName || 'The file'} isn't valid JSON: ${err.message}`), { status: 400 });
|
|
520
|
+
}
|
|
521
|
+
const { resource: r, ignored } = services.registry.importClientConfig(req.params.id, file, { by: req.session.user.username });
|
|
522
|
+
flash(req, 'success', `Config imported into ${r.name} (revision ${r.config_revision}).\n` +
|
|
523
|
+
'The Client applies it on its next heartbeat, then restarts once it has no job running.' +
|
|
524
|
+
(ignored.length ? `\nIgnored: ${ignored.join(', ')}.` : ''));
|
|
525
|
+
}
|
|
526
|
+
catch (err){
|
|
527
|
+
flash(req, 'danger', err.message);
|
|
528
|
+
}
|
|
529
|
+
res.redirect(returnTo(req, '/resources'));
|
|
530
|
+
});
|
|
531
|
+
|
|
496
532
|
// Scheduled host reboot (resource card): save a cron expression, or clear
|
|
497
533
|
// it; the Client applies it on its next heartbeat.
|
|
498
534
|
router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
|
|
@@ -716,7 +752,7 @@ function createWebRouter({ services, config }){
|
|
|
716
752
|
pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
|
|
717
753
|
waitingReason: job.state === JOB_STATES.QUEUED
|
|
718
754
|
? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
|
|
719
|
-
job.spec.target.client
|
|
755
|
+
job.spec.target.client)
|
|
720
756
|
: null,
|
|
721
757
|
artifacts,
|
|
722
758
|
jobActive,
|
package/test/scheduler.test.js
CHANGED
|
@@ -524,52 +524,32 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
|
|
|
524
524
|
assert.equal(again.name, 'lab-back');
|
|
525
525
|
});
|
|
526
526
|
|
|
527
|
-
test('a job-supplied Docker image
|
|
527
|
+
test('a job-supplied Docker image runs on any matching SW Client (no opt-in any more)', () => {
|
|
528
528
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
529
529
|
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
assert.
|
|
535
|
-
|
|
530
|
+
r = registerResource(registry, { name: 'sw-any', type: 'sw' });
|
|
531
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
532
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw', labels: [] }, image: 'alpine', command: './run.sh' } });
|
|
533
|
+
scheduler.runPass();
|
|
534
|
+
assert.equal(jobs.get(job.id).resource_id, r.id);
|
|
535
|
+
// An older Client still reporting its sw settings: they're dropped.
|
|
536
536
|
const { resourceId } = registry.registerAuto({
|
|
537
|
-
clientId: 'c-
|
|
537
|
+
clientId: 'c-old', name: 'sw-old', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: false } }
|
|
538
538
|
});
|
|
539
|
-
registry.
|
|
540
|
-
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: imageJob });
|
|
541
|
-
scheduler.runPass();
|
|
542
|
-
assert.equal(jobs.get(job.id).resource_id, resourceId); // not sw-strict, though it's idle too
|
|
543
|
-
|
|
544
|
-
const fileJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
545
|
-
scheduler.runPass();
|
|
546
|
-
assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
|
|
539
|
+
assert.deepEqual(registry.get(resourceId).capabilities.sw, {});
|
|
547
540
|
});
|
|
548
541
|
|
|
549
|
-
test('waiting reason: says why a queued job has no Client —
|
|
550
|
-
const { registry
|
|
551
|
-
reason = (
|
|
552
|
-
|
|
553
|
-
{
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
registry.heartbeat(strict.id, { state: 'idle' });
|
|
557
|
-
db.prepare('UPDATE resources SET status = ? WHERE id = ?').run(RESOURCE_STATES.OUT_OF_SERVICE, open);
|
|
558
|
-
|
|
559
|
-
assert.equal(
|
|
560
|
-
reason({ jobImage: true }),
|
|
561
|
-
'no idle SW Client runs job-supplied Docker images — the ones that do: sw-open (offline); idle, but not allowing them: sw-strict ' +
|
|
562
|
-
'(enable "Run jobs\' own images" on its Emulator config tab, or sw.allowJobImages in its config)'
|
|
563
|
-
);
|
|
564
|
-
assert.equal(reason({}), null); // sw-strict can take a job without an image
|
|
565
|
-
|
|
566
|
-
registry.heartbeat(open, { state: 'idle' });
|
|
567
|
-
assert.equal(reason({ jobImage: true }), null);
|
|
542
|
+
test('waiting reason: says why a queued job has no Client — every match taken, or none matching', () => {
|
|
543
|
+
const { registry } = buildTestServices(),
|
|
544
|
+
reason = () => registry.waitingReason('sw', [], undefined, undefined),
|
|
545
|
+
a = registerResource(registry, { name: 'sw-a', type: 'sw' }),
|
|
546
|
+
b = registerResource(registry, { name: 'sw-b', type: 'sw' });
|
|
547
|
+
registry.heartbeat(a.id, { state: 'idle' });
|
|
548
|
+
assert.equal(reason(), null); // sw-a can take it
|
|
568
549
|
|
|
569
|
-
registry.assignToJob(
|
|
570
|
-
|
|
571
|
-
assert.equal(
|
|
572
|
-
assert.equal(registry.waitingReason('hw', [], undefined, undefined, {}), 'no HW Client matches this job\'s target');
|
|
550
|
+
registry.assignToJob(a.id, 'cli');
|
|
551
|
+
assert.equal(reason(), 'every matching SW Client is taken: sw-a (busy), sw-b (not connected yet)');
|
|
552
|
+
assert.equal(registry.waitingReason('hw', [], undefined, undefined), 'no HW Client matches this job\'s target');
|
|
573
553
|
});
|
|
574
554
|
|
|
575
555
|
test('--env: the Client gets the values; the Agent API sees them masked; they\'re dropped when the job ends (not on a requeued LOST)', () => {
|
|
@@ -664,29 +644,85 @@ test('reboot now: refused offline or without a helper; cancels a running job fir
|
|
|
664
644
|
assert.equal(after.message, 'Canceled: user reboot request');
|
|
665
645
|
});
|
|
666
646
|
|
|
667
|
-
test('
|
|
647
|
+
test('devices from the dashboard: reported hw-devices stored, saved at a new revision, resent until applied', () => {
|
|
668
648
|
const { registry } = buildTestServices(),
|
|
669
649
|
{ resourceId } = registry.registerAuto({
|
|
670
|
-
clientId: 'c-cfg', name: 'lab-cfg', type: '
|
|
671
|
-
config: {
|
|
650
|
+
clientId: 'c-cfg', name: 'lab-cfg', type: 'hw', labels: [], capabilities: { hw: { stlinks: [], uarts: [], usbs: [] } },
|
|
651
|
+
config: { stlinks: [], uarts: [{ index: 1 }], usbs: [] }
|
|
672
652
|
});
|
|
673
|
-
assert.deepEqual(registry.get(resourceId).client_config, {
|
|
653
|
+
assert.deepEqual(registry.get(resourceId).client_config, { stlinks: [], uarts: [{ index: 1 }], usbs: [] });
|
|
674
654
|
assert.equal(registry.pendingConfig(resourceId), null);
|
|
675
655
|
|
|
676
|
-
assert.throws(() => registry.setClientConfig(resourceId, {
|
|
677
|
-
const saved = registry.setClientConfig(resourceId, {
|
|
656
|
+
assert.throws(() => registry.setClientConfig(resourceId, { uarts: [{ path: '/tmp/x' }] }), /Invalid HW config: hw-devices\.uarts\.0\.path/);
|
|
657
|
+
const saved = registry.setClientConfig(resourceId, { uarts: [{ index: 2, baudRate: 9600 }] });
|
|
678
658
|
assert.equal(saved.config_revision, 1);
|
|
679
|
-
assert.deepEqual(registry.pendingConfig(resourceId), { revision: 1, type: '
|
|
659
|
+
assert.deepEqual(registry.pendingConfig(resourceId), { revision: 1, type: 'hw', config: { uarts: [{ index: 2, baudRate: 9600 }] } });
|
|
680
660
|
|
|
681
661
|
registry.heartbeat(resourceId, { state: 'idle' }); // an older Client: reports nothing
|
|
682
662
|
assert.equal(registry.pendingConfig(resourceId).revision, 1);
|
|
683
663
|
registry.heartbeat(resourceId, { state: 'idle', configRevision: 1 });
|
|
684
664
|
assert.equal(registry.pendingConfig(resourceId), null);
|
|
685
665
|
|
|
686
|
-
registry.setClientConfig(resourceId, {
|
|
666
|
+
registry.setClientConfig(resourceId, { usbs: [{ index: 1 }] });
|
|
687
667
|
registry.heartbeat(resourceId, { state: 'idle', configRevision: 2, configError: 'Config revision 2 refused: bad' });
|
|
688
668
|
assert.equal(registry.pendingConfig(resourceId), null); // refused ones aren't resent
|
|
689
669
|
assert.match(registry.get(resourceId).config_error, /refused: bad/);
|
|
670
|
+
|
|
671
|
+
// An SW Client has no settings: only an empty section is accepted.
|
|
672
|
+
const sw = registerResource(registry, { name: 'sw1', type: 'sw' });
|
|
673
|
+
assert.throws(() => registry.setClientConfig(sw.id, { image: 'emu' }), /SW Client has no settings of its own/);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('config export/import: exported from the reported file (+ what\'s pending); an import goes out with set-config until applied', () => {
|
|
677
|
+
const { registry } = buildTestServices(),
|
|
678
|
+
{ resourceId } = registry.registerAuto({
|
|
679
|
+
clientId: 'c-io', name: 'dut1', type: 'hw', labels: [], capabilities: { hw: { stlinks: [], uarts: [], usbs: [] } },
|
|
680
|
+
config: { uarts: [{ index: 1 }] },
|
|
681
|
+
// An older Client: `hw`, plus legacy sections that never show up again.
|
|
682
|
+
configFile: { coordinatorUrl: 'https://c', name: 'dut1', joinKey: 'k', type: 'hw', labels: [], hw: { uarts: [{ index: 1 }] },
|
|
683
|
+
sw: { image: 'emu:1', registryAuth: { password: 'x' } }, artifactory: { token: 'secret', allowedArtifactPrefixes: [] } }
|
|
684
|
+
});
|
|
685
|
+
assert.deepEqual(registry.exportClientConfig(resourceId), {
|
|
686
|
+
coordinatorUrl: 'https://c', name: 'dut1', joinKey: 'k', type: 'hw', labels: [], 'hw-devices': { uarts: [{ index: 1 }] }
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const { resource, ignored } = registry.importClientConfig(resourceId, {
|
|
690
|
+
coordinatorUrl: 'https://evil', name: 'other', joinKey: 'x', type: 'hw', labels: ['board:b'], sources: { allowedPrefixes: ['*'] },
|
|
691
|
+
'hw-devices': { usbs: [{ index: 2 }] }
|
|
692
|
+
}, { by: 'admin' });
|
|
693
|
+
assert.deepEqual(ignored, ['sources', 'coordinatorUrl', 'name', 'joinKey']);
|
|
694
|
+
assert.equal(resource.config_revision, 1);
|
|
695
|
+
assert.deepEqual(registry.pendingConfig(resourceId),
|
|
696
|
+
{ revision: 1, type: 'hw', config: { usbs: [{ index: 2 }] }, file: { labels: ['board:b'] } });
|
|
697
|
+
const exported = registry.exportClientConfig(resourceId);
|
|
698
|
+
assert.deepEqual([exported.coordinatorUrl, exported.name, exported.labels, exported['hw-devices'], exported.hw],
|
|
699
|
+
['https://c', 'dut1', ['board:b'], { usbs: [{ index: 2 }] }, undefined]);
|
|
700
|
+
|
|
701
|
+
registry.setClientConfig(resourceId, { usbs: [{ index: 3 }] }); // a Config tab Save meanwhile: the import still goes with it
|
|
702
|
+
assert.deepEqual(registry.pendingConfig(resourceId).file, { labels: ['board:b'] });
|
|
703
|
+
registry.heartbeat(resourceId, { state: 'idle', configRevision: 2 });
|
|
704
|
+
assert.equal(registry.pendingConfig(resourceId), null);
|
|
705
|
+
|
|
706
|
+
assert.throws(() => registry.importClientConfig(resourceId, { type: 'sw' }), /SW Client config, but this is a HW Client/);
|
|
707
|
+
assert.throws(() => registry.importClientConfig(resourceId, { 'hw-devices': { uarts: [{ path: '/tmp/x' }] } }), /hw-devices\.uarts/);
|
|
708
|
+
|
|
709
|
+
// An SW Client: nothing of its own to import or export, the rest applies.
|
|
710
|
+
const sw = registry.registerAuto({ clientId: 'c-sw', name: 'sw1', type: 'sw', labels: [], capabilities: { sw: {} }, config: {},
|
|
711
|
+
configFile: { name: 'sw1', type: 'sw', labels: [] } }).resourceId;
|
|
712
|
+
assert.deepEqual(registry.importClientConfig(sw, { type: 'sw', labels: ['x'], sw: { image: 'emu' } }).ignored, ['sw']);
|
|
713
|
+
assert.deepEqual(registry.pendingConfig(sw), { revision: 1, type: 'sw', config: {}, file: { labels: ['x'] } });
|
|
714
|
+
assert.deepEqual(registry.exportClientConfig(sw), { name: 'sw1', type: 'sw', labels: ['x'] });
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('HW capabilities: no power control — reported relays/power dropped, and refused on Save', () => {
|
|
718
|
+
const { registry } = buildTestServices(),
|
|
719
|
+
{ resourceId } = registry.registerAuto({
|
|
720
|
+
clientId: 'c-hw', name: 'hw1', type: 'hw', labels: [],
|
|
721
|
+
capabilities: { hw: { stlinks: [], uarts: [], usbs: [], relays: [{ channel: 0 }], power: { method: 'uhubctl', hub: '1-1', port: 2 } } }
|
|
722
|
+
});
|
|
723
|
+
assert.deepEqual(registry.get(resourceId).capabilities.hw, { stlinks: [], uarts: [], usbs: [] });
|
|
724
|
+
assert.throws(() => registry.setClientConfig(resourceId, { relays: [{ channel: 0 }] }), /must NOT have additional properties/);
|
|
725
|
+
assert.throws(() => registry.setClientConfig(resourceId, { power: null }), /must NOT have additional properties/);
|
|
690
726
|
});
|
|
691
727
|
|
|
692
728
|
test('rename from the dashboard: kept across re-registration, unique, and resettable to the Client\'s own name', () => {
|
package/views/index.pug
CHANGED
|
@@ -20,14 +20,6 @@ mixin deviceRow(kind, d)
|
|
|
20
20
|
button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
|
|
21
21
|
i.bi.bi-x-lg
|
|
22
22
|
|
|
23
|
-
mixin relayRow(rl)
|
|
24
|
-
tr(data-row="relays")
|
|
25
|
-
td: input.form-control.form-control-sm(data-f="channel" type="number" min="0" max="7" value=rl ? rl.channel : '' required)
|
|
26
|
-
td: input.form-control.form-control-sm(data-f="baseUrl" type="url" value=rl && rl.baseUrl || '' placeholder="http://localhost:3000")
|
|
27
|
-
td.text-end
|
|
28
|
-
button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
|
|
29
|
-
i.bi.bi-x-lg
|
|
30
|
-
|
|
31
23
|
mixin deviceTable(kind, title, rows, columns)
|
|
32
24
|
.d-flex.justify-content-between.align-items-center.mb-1
|
|
33
25
|
strong.small= title
|
|
@@ -42,15 +34,9 @@ mixin deviceTable(kind, title, rows, columns)
|
|
|
42
34
|
th
|
|
43
35
|
tbody
|
|
44
36
|
each d in rows
|
|
45
|
-
|
|
46
|
-
+relayRow(d)
|
|
47
|
-
else
|
|
48
|
-
+deviceRow(kind, d)
|
|
37
|
+
+deviceRow(kind, d)
|
|
49
38
|
template(data-template=kind)
|
|
50
|
-
|
|
51
|
-
+relayRow(null)
|
|
52
|
-
else
|
|
53
|
-
+deviceRow(kind, null)
|
|
39
|
+
+deviceRow(kind, null)
|
|
54
40
|
|
|
55
41
|
//- The ⚠ shown on a config (sub-)tab while its data differs from what was
|
|
56
42
|
//- loaded — public/js/client-config.js toggles it.
|
|
@@ -102,104 +88,55 @@ mixin capabilitiesStatus(r)
|
|
|
102
88
|
span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
|
|
103
89
|
|
|
104
90
|
//- The Client's config, as tabs of the resource card next to Details:
|
|
105
|
-
//- HW — ST-Link, UART, DUT USB
|
|
106
|
-
//-
|
|
107
|
-
//- share one form and one "Save capabilities"; a tab with
|
|
108
|
-
//- shows ⚠ (public/js/client-config.js).
|
|
91
|
+
//- HW — ST-Link, UART, DUT USB (its hw-devices section); both — Reboot (the
|
|
92
|
+
//- schedule, its own Save). An SW Client has no settings of its own. The
|
|
93
|
+
//- device tabs share one form and one "Save capabilities"; a tab with
|
|
94
|
+
//- unsaved changes shows ⚠ (public/js/client-config.js).
|
|
109
95
|
mixin configTabItems(r)
|
|
110
96
|
- const p = `cfg-${r.id}`
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
97
|
+
if r.type === 'hw'
|
|
98
|
+
each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
|
|
99
|
+
+configTab(p, t[0], t[1])
|
|
114
100
|
+configTab(p, 'reboot', 'Reboot')
|
|
115
101
|
|
|
116
102
|
mixin configPanes(r, returnTo)
|
|
117
103
|
- const p = `cfg-${r.id}`
|
|
118
104
|
- const current = r.config_desired || r.client_config
|
|
119
105
|
- const canEdit = user.role === 'admin'
|
|
120
|
-
if
|
|
121
|
-
|
|
106
|
+
if r.type !== 'hw'
|
|
107
|
+
//- no device tabs
|
|
108
|
+
else if !current
|
|
109
|
+
each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
|
|
122
110
|
+configPane(p, t[0])
|
|
123
|
-
p.small.text-body-secondary.mb-0 This Client is too old to report its config — update it to edit its
|
|
111
|
+
p.small.text-body-secondary.mb-0 This Client is too old to report its config — update it to edit its devices here.
|
|
124
112
|
else
|
|
125
113
|
form(
|
|
126
114
|
method="post"
|
|
127
115
|
action=`/resources/${r.id}/config`
|
|
128
|
-
data-client-config=
|
|
129
|
-
data-confirm=`Apply these
|
|
130
|
-
data-confirm-title="Save Client
|
|
116
|
+
data-client-config="hw"
|
|
117
|
+
data-confirm=`Apply these devices to ${r.name}? The Client writes them to its config file on its next heartbeat and restarts once it has no job running.`
|
|
118
|
+
data-confirm-title="Save Client devices?"
|
|
131
119
|
data-confirm-ok="Save"
|
|
132
120
|
data-confirm-tone="primary"
|
|
133
121
|
)
|
|
134
122
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
135
123
|
input(type="hidden" name="config")
|
|
136
124
|
fieldset(disabled=!canEdit)
|
|
137
|
-
|
|
138
|
-
+
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
input.form-control.form-control-sm(data-f="memory" value=current.memory || '' placeholder="2g")
|
|
153
|
-
.col-sm-6
|
|
154
|
-
label.form-label.small.mb-0 Container command
|
|
155
|
-
input.form-control.form-control-sm.font-monospace(
|
|
156
|
-
data-f="cmd"
|
|
157
|
-
value=(current.cmd || []).join(' ')
|
|
158
|
-
placeholder="--firmware /downloads/app.bin"
|
|
159
|
-
title="For the image above (not a job's own); space-separated"
|
|
160
|
-
)
|
|
161
|
-
.col-12.d-flex.gap-4.mt-2
|
|
162
|
-
.form-check
|
|
163
|
-
input.form-check-input(type="checkbox" data-f="allowDockerHub" id=`cc-${r.id}-hub` checked=current.allowDockerHub === true)
|
|
164
|
-
label.form-check-label.small(for=`cc-${r.id}-hub`) Pull from Docker Hub
|
|
165
|
-
.form-check
|
|
166
|
-
input.form-check-input(type="checkbox" data-f="allowJobImages" id=`cc-${r.id}-jobimg` checked=current.allowJobImages === true)
|
|
167
|
-
label.form-check-label.small(for=`cc-${r.id}-jobimg`) Run jobs' own images (--docker-image)
|
|
168
|
-
+saveCapabilities(canEdit)
|
|
169
|
-
else
|
|
170
|
-
- const power = current.power || {}
|
|
171
|
-
+configPane(p, 'stlinks')
|
|
172
|
-
+capabilitiesStatus(r)
|
|
173
|
-
+deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
|
|
174
|
-
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
|
|
175
|
-
+saveCapabilities(canEdit)
|
|
176
|
-
+configPane(p, 'uarts')
|
|
177
|
-
+capabilitiesStatus(r)
|
|
178
|
-
+deviceTable('uarts', 'UARTs', current.uarts || [], ['Device', 'Baud rate', 'udev devpath'])
|
|
179
|
-
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-uart) or a /dev path. Baud rate: 115200 when empty.
|
|
180
|
-
+saveCapabilities(canEdit)
|
|
181
|
-
+configPane(p, 'usbs')
|
|
182
|
-
+capabilitiesStatus(r)
|
|
183
|
-
+deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
|
|
184
|
-
.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.
|
|
185
|
-
+saveCapabilities(canEdit)
|
|
186
|
-
+configPane(p, 'power')
|
|
187
|
-
+capabilitiesStatus(r)
|
|
188
|
-
+deviceTable('relays', 'Relay channels', current.relays || [], ['Channel (0-7)', 'Board URL'])
|
|
189
|
-
strong.small.d-block.mt-3.mb-1 Power control
|
|
190
|
-
.row.g-2(data-power)
|
|
191
|
-
.col-sm-3
|
|
192
|
-
select.form-select.form-select-sm(data-f="method" aria-label="Power method")
|
|
193
|
-
option(value="" selected=!power.method) none
|
|
194
|
-
option(value="uhubctl" selected=power.method === 'uhubctl') uhubctl
|
|
195
|
-
option(value="relay" selected=power.method === 'relay') relay
|
|
196
|
-
.col-sm-3
|
|
197
|
-
input.form-control.form-control-sm(data-f="hub" value=power.hub || '' placeholder="hub (uhubctl)")
|
|
198
|
-
.col-sm-2
|
|
199
|
-
input.form-control.form-control-sm(data-f="port" type="number" min="1" value=power.port != null ? power.port : '' placeholder="port")
|
|
200
|
-
.col-sm-4
|
|
201
|
-
input.form-control.form-control-sm(data-f="baseUrl" type="url" value=power.baseUrl || '' placeholder="relay URL")
|
|
202
|
-
+saveCapabilities(canEdit)
|
|
125
|
+
+configPane(p, 'stlinks')
|
|
126
|
+
+capabilitiesStatus(r)
|
|
127
|
+
+deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
|
|
128
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
|
|
129
|
+
+saveCapabilities(canEdit)
|
|
130
|
+
+configPane(p, 'uarts')
|
|
131
|
+
+capabilitiesStatus(r)
|
|
132
|
+
+deviceTable('uarts', 'UARTs', current.uarts || [], ['Device', 'Baud rate', 'udev devpath'])
|
|
133
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-uart) or a /dev path. Baud rate: 115200 when empty.
|
|
134
|
+
+saveCapabilities(canEdit)
|
|
135
|
+
+configPane(p, 'usbs')
|
|
136
|
+
+capabilitiesStatus(r)
|
|
137
|
+
+deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
|
|
138
|
+
.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.
|
|
139
|
+
+saveCapabilities(canEdit)
|
|
203
140
|
+configPane(p, 'reboot')
|
|
204
141
|
dl.row.mb-0
|
|
205
142
|
+rebootScheduleRow(r, returnTo)
|
|
@@ -133,47 +133,23 @@ mixin capDevice(d, detail)
|
|
|
133
133
|
) missing
|
|
134
134
|
|
|
135
135
|
//- What the Client reported it can drive at registration (HW: udev
|
|
136
|
-
//- devices
|
|
136
|
+
//- devices; SW: nothing of its own — it runs each job's DUT image).
|
|
137
137
|
mixin capabilitiesList(caps)
|
|
138
138
|
if !caps
|
|
139
139
|
span.text-body-secondary —
|
|
140
140
|
else if caps.sw
|
|
141
|
-
|
|
142
|
-
li
|
|
143
|
-
span.text-body-secondary.small.me-2 Image
|
|
144
|
-
code= caps.sw.image || '—'
|
|
145
|
-
li
|
|
146
|
-
span.text-body-secondary.small.me-2 Source
|
|
147
|
-
= caps.sw.registry ? caps.sw.registry : 'no local registry'
|
|
148
|
-
= caps.sw.allowDockerHub ? ', then Docker Hub' : ' (Docker Hub off)'
|
|
149
|
-
li
|
|
150
|
-
span.text-body-secondary.small.me-2 Limits
|
|
151
|
-
= `${caps.sw.cpus || '—'} CPU, ${caps.sw.memory || '—'}`
|
|
141
|
+
span.text-body-secondary Runs each job's own DUT image (--docker-image), 2 CPUs / 2 GB
|
|
152
142
|
else if caps.hw
|
|
153
143
|
- const kinds = [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'USB']]
|
|
154
|
-
- const empty = kinds.every(([k]) => !caps.hw[k]
|
|
144
|
+
- const empty = kinds.every(([k]) => !(caps.hw[k] || []).length)
|
|
155
145
|
if empty
|
|
156
146
|
span.text-body-secondary no devices configured
|
|
157
147
|
each pair in kinds
|
|
158
|
-
if caps.hw[pair[0]].length
|
|
148
|
+
if (caps.hw[pair[0]] || []).length
|
|
159
149
|
.small.text-body-secondary.mt-1= pair[1]
|
|
160
150
|
ul.list-unstyled.mb-0.ms-2
|
|
161
151
|
each d in caps.hw[pair[0]]
|
|
162
152
|
+capDevice(d, pair[0] === 'uarts' && d.baudRate ? `${d.baudRate} baud` : null)
|
|
163
|
-
if caps.hw.relays.length
|
|
164
|
-
.small.text-body-secondary.mt-1 Relays
|
|
165
|
-
ul.list-unstyled.mb-0.ms-2
|
|
166
|
-
each rl in caps.hw.relays
|
|
167
|
-
li
|
|
168
|
-
= `channel ${rl.channel}`
|
|
169
|
-
if rl.baseUrl
|
|
170
|
-
code.ms-2= rl.baseUrl
|
|
171
|
-
if caps.hw.power
|
|
172
|
-
.small.text-body-secondary.mt-1 Power
|
|
173
|
-
.ms-2
|
|
174
|
-
= caps.hw.power.method
|
|
175
|
-
if caps.hw.power.method === 'uhubctl' && caps.hw.power.hub
|
|
176
|
-
span.text-body-secondary.small.ms-2= `hub ${caps.hw.power.hub} port ${caps.hw.power.port}`
|
|
177
153
|
|
|
178
154
|
//- Host uptime and the Client's current task/status with its duration,
|
|
179
155
|
//- both from the last heartbeat. Measured up to now — or, for a Client
|
|
@@ -417,6 +393,47 @@ mixin rebootButton(r, returnTo)
|
|
|
417
393
|
)
|
|
418
394
|
i.bi.bi-power
|
|
419
395
|
|
|
396
|
+
//- Export / Import the Client's config file as JSON (card's Actions row,
|
|
397
|
+
//- icons only). Import: public/js/config-import.js reads the chosen file
|
|
398
|
+
//- into the form and submits it (confirmed first); the Client applies it on
|
|
399
|
+
//- its next heartbeat. A Client that doesn't report its config file is too
|
|
400
|
+
//- old to apply more than the hw/sw section — Import is off for it.
|
|
401
|
+
mixin configTransferButtons(r, returnTo)
|
|
402
|
+
a.btn.btn-sm.btn-outline-secondary.ms-1(
|
|
403
|
+
href=`/resources/${r.id}/config/export`
|
|
404
|
+
download
|
|
405
|
+
aria-label=`Export ${r.name}'s config`
|
|
406
|
+
data-bs-toggle="tooltip"
|
|
407
|
+
data-bs-title="Export the Client's config file (JSON; its secrets left out)"
|
|
408
|
+
)
|
|
409
|
+
i.bi.bi-download
|
|
410
|
+
if !r.client_config_file
|
|
411
|
+
span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Import unavailable: this Client is too old to report its config file — update it")
|
|
412
|
+
button.btn.btn-sm.btn-outline-secondary(type="button" disabled style="pointer-events: none;" aria-label="Import config")
|
|
413
|
+
i.bi.bi-upload
|
|
414
|
+
else
|
|
415
|
+
form.d-inline.ms-1(
|
|
416
|
+
method="post"
|
|
417
|
+
action=`/resources/${r.id}/config/import`
|
|
418
|
+
data-config-import
|
|
419
|
+
data-resource-name=r.name
|
|
420
|
+
data-confirm-title=`Import config into ${r.name}?`
|
|
421
|
+
data-confirm-ok="Import"
|
|
422
|
+
data-confirm-tone="primary"
|
|
423
|
+
)
|
|
424
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
425
|
+
input(type="hidden" name="file")
|
|
426
|
+
input(type="hidden" name="fileName")
|
|
427
|
+
input.d-none(type="file" accept=".json,application/json" data-config-import-file)
|
|
428
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
429
|
+
type="button"
|
|
430
|
+
aria-label=`Import a config into ${r.name}`
|
|
431
|
+
data-config-import-pick
|
|
432
|
+
data-bs-toggle="tooltip"
|
|
433
|
+
data-bs-title="Import a config file (JSON) — joinKey, coordinatorUrl and name in it are ignored"
|
|
434
|
+
)
|
|
435
|
+
i.bi.bi-upload
|
|
436
|
+
|
|
420
437
|
//- The resource card's Details tab.
|
|
421
438
|
mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
422
439
|
dl.row.mb-0
|
|
@@ -500,3 +517,4 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
|
500
517
|
dd.col-sm-8.mb-0
|
|
501
518
|
+resourceActions(r, returnTo)
|
|
502
519
|
+rebootButton(r, returnTo)
|
|
520
|
+
+configTransferButtons(r, returnTo)
|
package/views/resources/list.pug
CHANGED