@andrian.yablonskyy/thub-coordinator 1.0.27 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/js/confirm.js +10 -1
- package/src/api/admin.js +11 -0
- package/src/dev/virtual.js +10 -10
- package/src/services/jobs.js +39 -16
- package/src/services/registry.js +2 -6
- package/src/services/scheduler.js +1 -1
- package/src/web/routes.js +13 -0
- package/test/cleanup.test.js +1 -1
- package/test/list-prefs.test.js +1 -1
- package/test/scheduler.test.js +50 -17
- package/views/mixins/resource.pug +31 -0
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.28",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
package/public/js/confirm.js
CHANGED
|
@@ -47,7 +47,16 @@
|
|
|
47
47
|
field('ok').className = `btn btn-${tone}`;
|
|
48
48
|
field('ok').textContent = form.dataset.confirmOk || 'Confirm';
|
|
49
49
|
field('dismiss').textContent = form.dataset.confirmDismiss || 'Cancel';
|
|
50
|
-
modal.
|
|
50
|
+
// Asked from inside another modal (e.g. the resource card): close that
|
|
51
|
+
// one first — Bootstrap doesn't stack modals.
|
|
52
|
+
const host = form.closest('.modal.show');
|
|
53
|
+
if (host && host !== modalEl){
|
|
54
|
+
host.addEventListener('hidden.bs.modal', () => modal.show(), { once: true });
|
|
55
|
+
bootstrap.Modal.getInstance(host)?.hide();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
modal.show();
|
|
59
|
+
}
|
|
51
60
|
});
|
|
52
61
|
|
|
53
62
|
field('ok').addEventListener('click', () => {
|
package/src/api/admin.js
CHANGED
|
@@ -51,6 +51,17 @@ function createAdminRouter({ services }){
|
|
|
51
51
|
res.status(204).end();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// Reboot a Client's host now (its running job is canceled first).
|
|
55
|
+
router.post('/resources/:id/reboot', (req, res, next) => {
|
|
56
|
+
try {
|
|
57
|
+
const { resource, canceledJob } = services.jobs.requestReboot(req.params.id);
|
|
58
|
+
res.json({ id: resource.id, rebooting: true, canceledJob });
|
|
59
|
+
}
|
|
60
|
+
catch (err){
|
|
61
|
+
next(err);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
54
65
|
// Scheduled host reboot: { cron } (5-field, host local time; "" clears).
|
|
55
66
|
router.post('/resources/:id/reboot-schedule', (req, res, next) => {
|
|
56
67
|
try {
|
package/src/dev/virtual.js
CHANGED
|
@@ -30,8 +30,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
30
30
|
type: 'sw',
|
|
31
31
|
labels: ['virtual', 'emulator'],
|
|
32
32
|
capabilities: {
|
|
33
|
-
sw: { image: 'dut-emulator:dev', registry: null, allowDockerHub: true, allowJobImages: true, cpus: 2, memory: '2g' }
|
|
34
|
-
allowJobCommands: true
|
|
33
|
+
sw: { image: 'dut-emulator:dev', registry: null, allowDockerHub: true, allowJobImages: true, cpus: 2, memory: '2g' }
|
|
35
34
|
},
|
|
36
35
|
address: '10.0.0.41'
|
|
37
36
|
},
|
|
@@ -47,8 +46,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
47
46
|
usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }],
|
|
48
47
|
relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
|
|
49
48
|
power: { method: 'relay' }
|
|
50
|
-
}
|
|
51
|
-
allowJobCommands: false
|
|
49
|
+
}
|
|
52
50
|
},
|
|
53
51
|
address: '10.0.0.42'
|
|
54
52
|
}
|
|
@@ -69,12 +67,12 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
69
67
|
[0.1, 'cli', 'sw', 'PASSED', 73, 'alice']
|
|
70
68
|
],
|
|
71
69
|
LOG_SCRIPT = [
|
|
72
|
-
['runner', 'downloading
|
|
73
|
-
['runner', '
|
|
70
|
+
['runner', 'downloading app.bin (virtual)'],
|
|
71
|
+
['runner', 'cloning yourorg/firmware-tests at main (virtual)'],
|
|
74
72
|
['flash', 'st-flash write app.bin 0x08000000 ... done (virtual)'],
|
|
75
73
|
['uart', '[BOOT] app v1.4.0-dev'],
|
|
76
74
|
['uart', '[INIT] peripherals ok'],
|
|
77
|
-
['runner', 'running: run
|
|
75
|
+
['runner', 'running: sh -c "./ci/run.sh"'],
|
|
78
76
|
['runner', 'test_boot ............ ok'],
|
|
79
77
|
['runner', 'test_uart_echo ....... ok'],
|
|
80
78
|
['runner', 'test_flash_crc ....... ok'],
|
|
@@ -83,8 +81,10 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
83
81
|
|
|
84
82
|
spec = (type, user, extra = {}) => ({
|
|
85
83
|
target: { type, labels: ['virtual'] },
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
command: './ci/run.sh',
|
|
85
|
+
downloads: [{ url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/app.bin' }],
|
|
86
|
+
...(type === 'sw' ? { image: 'alpine:3.20' } : {}),
|
|
87
|
+
git: { url: 'https://github.com/yourorg/firmware-tests.git', ref: 'main', depth: 1 },
|
|
88
88
|
...(user ? { user } : {}),
|
|
89
89
|
meta: { repo: 'yourorg/firmware', branch: 'main', virtual: true },
|
|
90
90
|
...extra
|
|
@@ -278,7 +278,7 @@ function startDevMode(services, config, env = process.env){
|
|
|
278
278
|
'*** DEV MODE (DEV_MODE=1): virtual Clients virtual-sw-01 / virtual-hw-01 are running in-process' +
|
|
279
279
|
`${seeded ? `, and ${seeded} past jobs + 3 live ones were seeded` : ''}.\n` +
|
|
280
280
|
`*** Virtual agent token: ${DEV_AGENT_TOKEN}\n` +
|
|
281
|
-
`*** e.g. thub --url ${config.publicUrl} --token ${DEV_AGENT_TOKEN} run --type sw --image alpine --
|
|
281
|
+
`*** e.g. thub --url ${config.publicUrl} --token ${DEV_AGENT_TOKEN} run --type sw --docker-image alpine --command 'uname -a'`
|
|
282
282
|
);
|
|
283
283
|
return virtual;
|
|
284
284
|
}
|
package/src/services/jobs.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
16
|
+
const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
17
|
{ paginate } = require('./list-prefs');
|
|
18
18
|
|
|
19
19
|
function rowToJob(row){
|
|
@@ -145,22 +145,15 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
145
145
|
spec.target.client = resource.id;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
// A job-supplied Docker image
|
|
149
|
-
//
|
|
150
|
-
const needs = { jobImage: Boolean(spec.
|
|
148
|
+
// A job-supplied Docker image needs an SW Client that opted in — say so,
|
|
149
|
+
// rather than the generic "no resource can satisfy".
|
|
150
|
+
const needs = { jobImage: Boolean(spec.image) },
|
|
151
151
|
{ type, labels, group, client } = spec.target;
|
|
152
|
-
if (
|
|
153
|
-
!registry.everSatisfiable(type, labels, group, client, needs)){
|
|
154
|
-
const missing = [
|
|
155
|
-
needs.jobImage && !registry.everSatisfiable(type, labels, group, client, { jobImage: true }) &&
|
|
156
|
-
`job-supplied Docker images (${spec.firmware.image}) — "sw": { "allowJobImages": true }`,
|
|
157
|
-
needs.jobCommand && !registry.everSatisfiable(type, labels, group, client, { jobCommand: true }) &&
|
|
158
|
-
'job-supplied commands (--run) — "allowJobCommands": true'
|
|
159
|
-
].filter(Boolean);
|
|
152
|
+
if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
|
|
160
153
|
throw Object.assign(
|
|
161
154
|
new Error(
|
|
162
|
-
`No matching Client runs ${
|
|
163
|
-
'
|
|
155
|
+
`No matching SW Client runs job-supplied Docker images (${spec.image}) — enable it on a Client ` +
|
|
156
|
+
'with "sw": { "allowJobImages": true } in its config'
|
|
164
157
|
),
|
|
165
158
|
{ status: 422 }
|
|
166
159
|
);
|
|
@@ -263,7 +256,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
263
256
|
}
|
|
264
257
|
|
|
265
258
|
// Agent: POST /jobs/:id/cancel
|
|
266
|
-
|
|
259
|
+
// `reason`: shown as the job's message (e.g. "Canceled: user reboot request").
|
|
260
|
+
function cancel(id, { agentId, isAdmin, reason }){
|
|
267
261
|
const job = get(id);
|
|
268
262
|
if (!job){
|
|
269
263
|
throw Object.assign(new Error('Unknown job'), { status: 404 });
|
|
@@ -278,7 +272,35 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
278
272
|
if (job.resource_id){
|
|
279
273
|
bus.emit('command', { resourceId: job.resource_id, command: 'cancel-job', jobId: id });
|
|
280
274
|
}
|
|
281
|
-
return setState(id, JOB_STATES.CANCELED);
|
|
275
|
+
return setState(id, JOB_STATES.CANCELED, reason ? { message: reason } : {});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Admin "Reboot" on a Client (resource card): its running job is canceled
|
|
279
|
+
// first (the host is going down anyway), then a `reboot` command goes out
|
|
280
|
+
// with its next heartbeat — the Client reboots the host through its root
|
|
281
|
+
// reboot helper once nothing on it is busy. Refused for a Client that's
|
|
282
|
+
// offline (it would reboot whenever it next reconnects) or can't reboot.
|
|
283
|
+
function requestReboot(resourceId, { by } = {}){
|
|
284
|
+
const r = registry.get(resourceId);
|
|
285
|
+
if (!r){
|
|
286
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
287
|
+
}
|
|
288
|
+
if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || !r.last_heartbeat_at){
|
|
289
|
+
throw Object.assign(new Error(`${r.name} is offline — it can only be rebooted while it's connected`), { status: 409 });
|
|
290
|
+
}
|
|
291
|
+
if (r.capabilities?.rebootSupported === false){
|
|
292
|
+
throw Object.assign(
|
|
293
|
+
new Error(`${r.name}'s host has no reboot helper — reinstall the Client as root (sudo npm i -g @andrian.yablonskyy/thub-client)`),
|
|
294
|
+
{ status: 409 }
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const job = activeForResource(resourceId);
|
|
298
|
+
if (job){
|
|
299
|
+
cancel(job.id, { isAdmin: true, reason: 'Canceled: user reboot request' });
|
|
300
|
+
}
|
|
301
|
+
bus.emit('command', { resourceId, command: 'reboot', reason: `user reboot request${by ? ` by ${by}` : ''}` });
|
|
302
|
+
events.record('resource', resourceId, 'resource.reboot_requested', { by, canceledJob: job?.id || null });
|
|
303
|
+
return { resource: r, canceledJob: job?.id || null };
|
|
282
304
|
}
|
|
283
305
|
|
|
284
306
|
// Queued jobs pinned to a resource (`target.client`) can never run once it's
|
|
@@ -493,6 +515,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
493
515
|
setState,
|
|
494
516
|
cancel,
|
|
495
517
|
cancelPinnedTo,
|
|
518
|
+
requestReboot,
|
|
496
519
|
removeResource,
|
|
497
520
|
completeRemoval,
|
|
498
521
|
activeForResource,
|
package/src/services/registry.js
CHANGED
|
@@ -67,7 +67,6 @@ const MAX_DEVICES = 8,
|
|
|
67
67
|
present: typeof d?.present === 'boolean' ? d.present : null
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
// Plus `allowJobCommands` (either type): runs a job's own `tests.command`.
|
|
71
70
|
function sanitizeCapabilities(caps){
|
|
72
71
|
if (!caps || typeof caps !== 'object'){
|
|
73
72
|
return null;
|
|
@@ -76,7 +75,6 @@ function sanitizeCapabilities(caps){
|
|
|
76
75
|
return typed
|
|
77
76
|
? {
|
|
78
77
|
...typed,
|
|
79
|
-
allowJobCommands: caps.allowJobCommands === true,
|
|
80
78
|
// Whether the host has the root reboot helper (thub-client-reboot.path)
|
|
81
79
|
// a scheduled reboot needs; unknown (null) for older Clients.
|
|
82
80
|
rebootSupported: typeof caps.rebootSupported === 'boolean' ? caps.rebootSupported : null
|
|
@@ -498,14 +496,12 @@ function createRegistryService(db, { bus, events }){
|
|
|
498
496
|
}
|
|
499
497
|
|
|
500
498
|
// `needs`: what a job brings that a Client must have opted in to run —
|
|
501
|
-
// jobImage (
|
|
502
|
-
// (tests.command; allowJobCommands).
|
|
499
|
+
// jobImage (the job's own Docker image; sw.allowJobImages).
|
|
503
500
|
function matchesTarget(r, labels, groupId, resourceId, needs = {}){
|
|
504
501
|
return labels.every((l) => r.labels.includes(l)) &&
|
|
505
502
|
(!groupId || r.group_ids.includes(groupId)) &&
|
|
506
503
|
(!resourceId || r.id === resourceId) &&
|
|
507
|
-
(!needs.jobImage || r.capabilities?.sw?.allowJobImages === true)
|
|
508
|
-
(!needs.jobCommand || r.capabilities?.allowJobCommands === true);
|
|
504
|
+
(!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
|
|
509
505
|
}
|
|
510
506
|
|
|
511
507
|
// Admin: remove a resource from the registry (dashboard "Remove"). Refused
|
|
@@ -17,7 +17,7 @@ const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
|
|
|
17
17
|
|
|
18
18
|
// What a job needs a Client to have opted in to (registry matchesTarget).
|
|
19
19
|
function jobNeeds(spec){
|
|
20
|
-
return { jobImage: Boolean(spec.
|
|
20
|
+
return { jobImage: Boolean(spec.image) };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function rowToJob(row){
|
package/src/web/routes.js
CHANGED
|
@@ -412,6 +412,19 @@ function createWebRouter({ services, config }){
|
|
|
412
412
|
res.redirect(returnTo(req, '/resources'));
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
+
// Reboot now (resource card footer): cancels a running job, then the
|
|
416
|
+
// Client reboots its host on its next heartbeat (jobs.requestReboot).
|
|
417
|
+
router.post('/resources/:id/reboot', requireAdminRole, (req, res) => {
|
|
418
|
+
try {
|
|
419
|
+
const { resource: r, canceledJob } = services.jobs.requestReboot(req.params.id, { by: req.session.user.username });
|
|
420
|
+
flash(req, 'warning', `Rebooting ${r.name}'s host on its next heartbeat.` + (canceledJob ? `\nCanceled its running job ${canceledJob}.` : ''));
|
|
421
|
+
}
|
|
422
|
+
catch (err){
|
|
423
|
+
flash(req, 'danger', err.message);
|
|
424
|
+
}
|
|
425
|
+
res.redirect(returnTo(req, '/resources'));
|
|
426
|
+
});
|
|
427
|
+
|
|
415
428
|
// Scheduled host reboot (resource card): save a cron expression, or clear
|
|
416
429
|
// it; the Client applies it on its next heartbeat.
|
|
417
430
|
router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
|
package/test/cleanup.test.js
CHANGED
|
@@ -55,7 +55,7 @@ function setup(jobRetention = 'forever'){
|
|
|
55
55
|
|
|
56
56
|
// A job that finished at `finishedAt` (or is still queued, if null), with a log line.
|
|
57
57
|
function job(finishedAt){
|
|
58
|
-
const spec = { target: { type: 'sw' },
|
|
58
|
+
const spec = { target: { type: 'sw' }, command: './run.sh' },
|
|
59
59
|
j = jobs.create({ agentId: agent.id, source: 'cli', spec });
|
|
60
60
|
db.prepare('INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, 1, ?, \'runner\', ?)').run(j.id, NOW.toISOString(), 'x'.repeat(2000));
|
|
61
61
|
if (finishedAt){
|
package/test/list-prefs.test.js
CHANGED
|
@@ -74,7 +74,7 @@ test('jobs.page sorts and pages in SQL, with empty values last', () => {
|
|
|
74
74
|
jobs.create({
|
|
75
75
|
agentId: agent.id,
|
|
76
76
|
source: 'cli',
|
|
77
|
-
spec: { target: { type: 'sw' },
|
|
77
|
+
spec: { target: { type: 'sw' }, command: './run.sh', ...(user ? { user } : {}) }
|
|
78
78
|
}).id),
|
|
79
79
|
|
|
80
80
|
byUser = (dir) => jobs.page({ sort: 'user', dir, size: 'all' }).rows.map((j) => j.spec.user ?? null);
|
package/test/scheduler.test.js
CHANGED
|
@@ -57,7 +57,7 @@ function buildTestServices(overrides = {}){
|
|
|
57
57
|
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
58
58
|
scheduler = createScheduler(db, { bus, events, registry, config }),
|
|
59
59
|
heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
|
|
60
|
-
return { db, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
|
|
60
|
+
return { db, bus, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
|
|
@@ -68,8 +68,7 @@ function registerResource(registry, { name, type, labels = [], groups = [], clie
|
|
|
68
68
|
function makeSpec(overrides = {}){
|
|
69
69
|
return {
|
|
70
70
|
target: { type: 'sw', labels: [] },
|
|
71
|
-
|
|
72
|
-
tests: { url: 'https://x/tests.tar.gz' },
|
|
71
|
+
command: './run.sh',
|
|
73
72
|
...overrides
|
|
74
73
|
};
|
|
75
74
|
}
|
|
@@ -528,7 +527,7 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
|
|
|
528
527
|
test('a job-supplied Docker image only goes to SW Clients that allow it; otherwise 422', () => {
|
|
529
528
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
530
529
|
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
531
|
-
imageJob = { target: { type: 'sw', labels: [] },
|
|
530
|
+
imageJob = { target: { type: 'sw', labels: [] }, image: 'alpine', command: './run.sh' },
|
|
532
531
|
strict = registerResource(registry, { name: 'sw-strict', type: 'sw' });
|
|
533
532
|
registry.heartbeat(strict.id, { state: 'idle' });
|
|
534
533
|
|
|
@@ -544,25 +543,25 @@ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwi
|
|
|
544
543
|
|
|
545
544
|
const fileJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
546
545
|
scheduler.runPass();
|
|
547
|
-
assert.equal(jobs.get(fileJob.id).resource_id, strict.id); //
|
|
546
|
+
assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
|
|
548
547
|
});
|
|
549
548
|
|
|
550
|
-
test('
|
|
549
|
+
test('every job carries its command and runs on any matching Client; an old-Agent spec is refused', () => {
|
|
551
550
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
552
551
|
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
registry.heartbeat(plain.id, { state: 'idle' });
|
|
556
|
-
|
|
557
|
-
assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob }), /allowJobCommands/);
|
|
552
|
+
r = registerResource(registry, { name: 'sw-any', type: 'sw' });
|
|
553
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
558
554
|
|
|
559
|
-
const {
|
|
560
|
-
clientId: 'c-cmd', name: 'sw-cmd', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, allowJobCommands: true }
|
|
561
|
-
});
|
|
562
|
-
registry.heartbeat(resourceId, { state: 'idle' });
|
|
563
|
-
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob });
|
|
555
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ command: 'make test', args: ['-j4'] }) });
|
|
564
556
|
scheduler.runPass();
|
|
565
|
-
assert.equal(jobs.get(job.id).resource_id,
|
|
557
|
+
assert.equal(jobs.get(job.id).resource_id, r.id);
|
|
558
|
+
assert.equal(jobs.get(job.id).spec.command, 'make test');
|
|
559
|
+
|
|
560
|
+
assert.throws(
|
|
561
|
+
() => jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' } } }),
|
|
562
|
+
/older Agent.*--command/
|
|
563
|
+
);
|
|
564
|
+
assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw' } } }), /command/);
|
|
566
565
|
});
|
|
567
566
|
|
|
568
567
|
test('reboot schedule: validated, then resent on heartbeats until the Client reports applying it', () => {
|
|
@@ -585,6 +584,40 @@ test('reboot schedule: validated, then resent on heartbeats until the Client rep
|
|
|
585
584
|
assert.equal(registry.pendingRebootSchedule(r.id), null);
|
|
586
585
|
});
|
|
587
586
|
|
|
587
|
+
test('reboot now: refused offline or without a helper; cancels a running job first, then sends reboot', () => {
|
|
588
|
+
const { bus, registry, agents, jobs, scheduler } = buildTestServices(),
|
|
589
|
+
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
590
|
+
reg = (name, rebootSupported) => registry.get(registry.registerAuto({
|
|
591
|
+
clientId: `c-${name}`, name, type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, rebootSupported }
|
|
592
|
+
}).resourceId),
|
|
593
|
+
offline = reg('rb-offline', true),
|
|
594
|
+
noHelper = reg('rb-nohelper', false),
|
|
595
|
+
idle = reg('rb-idle', true),
|
|
596
|
+
busy = reg('rb-busy', true),
|
|
597
|
+
commands = [];
|
|
598
|
+
for (const r of [noHelper, idle, busy]){
|
|
599
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
600
|
+
}
|
|
601
|
+
bus.on('command', (c) => commands.push(c));
|
|
602
|
+
|
|
603
|
+
assert.throws(() => jobs.requestReboot(offline.id), /offline/);
|
|
604
|
+
assert.throws(() => jobs.requestReboot(noHelper.id), /no reboot helper/);
|
|
605
|
+
|
|
606
|
+
assert.equal(jobs.requestReboot(idle.id).canceledJob, null);
|
|
607
|
+
assert.deepEqual(commands.map((c) => [c.resourceId, c.command]), [[idle.id, 'reboot']]);
|
|
608
|
+
commands.length = 0;
|
|
609
|
+
|
|
610
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: busy.id } }) });
|
|
611
|
+
scheduler.runPass();
|
|
612
|
+
jobs.setState(job.id, JOB_STATES.RUNNING);
|
|
613
|
+
assert.equal(jobs.requestReboot(busy.id, { by: 'admin' }).canceledJob, job.id);
|
|
614
|
+
assert.deepEqual(commands.map((c) => c.command), ['cancel-job', 'reboot']); // same heartbeat reply, in order
|
|
615
|
+
assert.equal(commands[1].reason, 'user reboot request by admin');
|
|
616
|
+
const after = jobs.get(job.id);
|
|
617
|
+
assert.equal(after.state, JOB_STATES.CANCELED);
|
|
618
|
+
assert.equal(after.message, 'Canceled: user reboot request');
|
|
619
|
+
});
|
|
620
|
+
|
|
588
621
|
test('a resource can belong to several groups at once', () => {
|
|
589
622
|
const { registry, groups } = buildTestServices(),
|
|
590
623
|
g1 = groups.create({ name: 'g1' }),
|
|
@@ -337,6 +337,37 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
337
337
|
.tab-pane.fade(id=`rc-${r.id}-config` role="tabpanel" aria-labelledby=`rc-${r.id}-config-tab` tabindex="0")
|
|
338
338
|
dl.row.mb-0
|
|
339
339
|
+rebootScheduleRow(r, returnTo)
|
|
340
|
+
if user.role === 'admin'
|
|
341
|
+
.modal-footer
|
|
342
|
+
+rebootButton(r, returnTo)
|
|
343
|
+
|
|
344
|
+
//- Reboot the Client's host now (card footer): confirmed first — naming the
|
|
345
|
+
//- job that gets canceled, if one is running — then sent with the Client's
|
|
346
|
+
//- next heartbeat. Disabled while it's offline or has no reboot helper.
|
|
347
|
+
mixin rebootButton(r, returnTo)
|
|
348
|
+
- const offline = r.status === 'OUT_OF_SERVICE' || !r.last_heartbeat_at
|
|
349
|
+
- const unsupported = r.capabilities && r.capabilities.rebootSupported === false
|
|
350
|
+
- const why = offline ? 'Offline — a Client can only be rebooted while connected' : unsupported ? "This host has no reboot helper — reinstall the Client as root" : null
|
|
351
|
+
if why
|
|
352
|
+
span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title=why)
|
|
353
|
+
button.btn.btn-outline-danger(type="button" disabled style="pointer-events: none;")
|
|
354
|
+
i.bi.bi-bootstrap-reboot.me-1
|
|
355
|
+
| Reboot
|
|
356
|
+
else
|
|
357
|
+
form(
|
|
358
|
+
method="post"
|
|
359
|
+
action=`/resources/${r.id}/reboot`
|
|
360
|
+
data-confirm=r.activeJob
|
|
361
|
+
? `${r.name} is running job ${r.activeJob.id}. Rebooting cancels that job (reason: user reboot request), then reboots the host on the Client's next heartbeat. Every Client instance on that host goes down with it.`
|
|
362
|
+
: `Reboot the host of ${r.name}? It reboots on the Client's next heartbeat, once nothing on the host is busy. Every Client instance on that host goes down with it.`
|
|
363
|
+
data-confirm-title=r.activeJob ? `Cancel job ${r.activeJob.id} and reboot?` : `Reboot ${r.name}?`
|
|
364
|
+
data-confirm-ok=r.activeJob ? 'Cancel job and reboot' : 'Reboot'
|
|
365
|
+
data-confirm-dismiss="Don't reboot"
|
|
366
|
+
)
|
|
367
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
368
|
+
button.btn.btn-outline-danger(type="submit")
|
|
369
|
+
i.bi.bi-bootstrap-reboot.me-1
|
|
370
|
+
| Reboot
|
|
340
371
|
|
|
341
372
|
//- The resource card's Details tab.
|
|
342
373
|
mixin resourceDetails(r, groupsById, returnTo, local, external)
|