@andrian.yablonskyy/thub-coordinator 1.0.0
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 +157 -0
- package/bin/thub-admin.js +190 -0
- package/config.json +30 -0
- package/eslint.config.js +115 -0
- package/package.json +32 -0
- package/public/css/thub.css +6 -0
- package/public/js/copy-to-clipboard.js +36 -0
- package/public/js/log-viewer.js +76 -0
- package/public/js/profile-theme.js +28 -0
- package/public/js/theme.js +51 -0
- package/public/js/tooltips.js +18 -0
- package/src/api/admin.js +112 -0
- package/src/api/agent.js +110 -0
- package/src/api/resource.js +211 -0
- package/src/api/sse.js +96 -0
- package/src/auth.js +94 -0
- package/src/config.js +110 -0
- package/src/db/index.js +61 -0
- package/src/db/migrations/001_init.sql +104 -0
- package/src/db/migrations/003_split_job_id_counters.sql +5 -0
- package/src/db/migrations/004_add_client_id.sql +8 -0
- package/src/db/migrations/005_add_groups.sql +14 -0
- package/src/db/migrations/006_events_allow_group.sql +20 -0
- package/src/db/migrations/007_add_user_profile.sql +10 -0
- package/src/server.js +179 -0
- package/src/services/admin-users.js +174 -0
- package/src/services/agents.js +56 -0
- package/src/services/artifacts.js +149 -0
- package/src/services/bus.js +23 -0
- package/src/services/commands.js +43 -0
- package/src/services/events.js +37 -0
- package/src/services/groups.js +79 -0
- package/src/services/heartbeat.js +56 -0
- package/src/services/jobs.js +355 -0
- package/src/services/logs.js +70 -0
- package/src/services/registry.js +347 -0
- package/src/services/retention.js +44 -0
- package/src/services/scheduler.js +101 -0
- package/src/services/tokens.js +28 -0
- package/src/web/routes.js +343 -0
- package/test/admin-users.test.js +127 -0
- package/test/scheduler.test.js +375 -0
- package/views/admin/agents.pug +53 -0
- package/views/groups/list.pug +94 -0
- package/views/index.pug +37 -0
- package/views/jobs/list.pug +45 -0
- package/views/jobs/show.pug +42 -0
- package/views/layout.pug +73 -0
- package/views/login.pug +16 -0
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/status-badge.pug +7 -0
- package/views/profile.pug +77 -0
- package/views/resources/list.pug +65 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/scheduler.test.js
|
|
3
|
+
* @description Tests: scheduler, registry, groups, job id allocation, and admin queue operations
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
os = require('node:os'),
|
|
20
|
+
path = require('node:path'),
|
|
21
|
+
{ EventEmitter } = require('node:events'),
|
|
22
|
+
|
|
23
|
+
{ openDb } = require('../src/db'),
|
|
24
|
+
{ createEventsService } = require('../src/services/events'),
|
|
25
|
+
{ createRegistryService } = require('../src/services/registry'),
|
|
26
|
+
{ createAgentsService } = require('../src/services/agents'),
|
|
27
|
+
{ createGroupsService } = require('../src/services/groups'),
|
|
28
|
+
{ createArtifactsService } = require('../src/services/artifacts'),
|
|
29
|
+
{ createJobsService } = require('../src/services/jobs'),
|
|
30
|
+
{ createScheduler } = require('../src/services/scheduler'),
|
|
31
|
+
{ createHeartbeatMonitor } = require('../src/services/heartbeat'),
|
|
32
|
+
{ JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
33
|
+
|
|
34
|
+
function buildTestServices(overrides = {}){
|
|
35
|
+
const db = openDb(':memory:'),
|
|
36
|
+
// Own bus per test, not the module-level singleton (production's
|
|
37
|
+
// there's-only-one-Coordinator-process bus) — sharing it across every
|
|
38
|
+
// test in this file would pile up listeners test after test with no
|
|
39
|
+
// teardown, eventually tripping Node's MaxListenersExceededWarning.
|
|
40
|
+
bus = new EventEmitter(),
|
|
41
|
+
events = createEventsService(db),
|
|
42
|
+
registry = createRegistryService(db, { bus, events }),
|
|
43
|
+
agents = createAgentsService(db, { events }),
|
|
44
|
+
groups = createGroupsService(db, { events, registry }),
|
|
45
|
+
artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-test-artifacts-')),
|
|
46
|
+
config = {
|
|
47
|
+
scheduler: { assignAckTimeoutSec: 15, requeueOnLost: true, maxQueuedPerAgent: 20, tickIntervalSec: 3600 },
|
|
48
|
+
jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 },
|
|
49
|
+
heartbeat: { intervalSec: 10, missedLimit: 3, sweepIntervalSec: 3600 },
|
|
50
|
+
artifactsDir,
|
|
51
|
+
sessionSecret: 'test-secret',
|
|
52
|
+
publicUrl: 'http://localhost:8080',
|
|
53
|
+
artifacts: { linkTtlHours: 1 },
|
|
54
|
+
...overrides
|
|
55
|
+
},
|
|
56
|
+
artifacts = createArtifactsService(db, { config }),
|
|
57
|
+
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
58
|
+
scheduler = createScheduler(db, { bus, events, registry, config }),
|
|
59
|
+
heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
|
|
60
|
+
return { db, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
|
|
64
|
+
const { resourceId } = registry.registerAuto({ clientId: clientId || `client-${name}`, name, type, labels, groups });
|
|
65
|
+
return registry.get(resourceId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeSpec(overrides = {}){
|
|
69
|
+
return {
|
|
70
|
+
target: { type: 'sw', labels: [] },
|
|
71
|
+
firmware: { url: 'https://x/app.bin' },
|
|
72
|
+
tests: { url: 'https://x/tests.tar.gz' },
|
|
73
|
+
...overrides
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('scheduler assigns a queued job to a matching idle resource', () => {
|
|
78
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
79
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
80
|
+
resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
81
|
+
registry.heartbeat(resource.id, { state: 'idle' });
|
|
82
|
+
|
|
83
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
84
|
+
assert.equal(job.state, JOB_STATES.QUEUED);
|
|
85
|
+
|
|
86
|
+
scheduler.runPass();
|
|
87
|
+
|
|
88
|
+
const assigned = jobs.get(job.id);
|
|
89
|
+
assert.equal(assigned.state, JOB_STATES.ASSIGNED);
|
|
90
|
+
assert.equal(assigned.resource_id, resource.id);
|
|
91
|
+
assert.equal(registry.get(resource.id).status, RESOURCE_STATES.BUSY);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('job submission is rejected (422) when no resource could ever satisfy its labels', () => {
|
|
95
|
+
const { registry, agents, jobs } = buildTestServices(),
|
|
96
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
97
|
+
registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: ['board:a'] });
|
|
98
|
+
|
|
99
|
+
assert.throws(
|
|
100
|
+
() =>
|
|
101
|
+
jobs.create({
|
|
102
|
+
agentId: agent.id,
|
|
103
|
+
source: 'cli',
|
|
104
|
+
spec: makeSpec({ target: { type: 'sw', labels: ['board:b'] } })
|
|
105
|
+
}),
|
|
106
|
+
/No registered resource/
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a job stays QUEUED while its only matching resource is busy', () => {
|
|
111
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
112
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
113
|
+
resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: ['board:a'] });
|
|
114
|
+
registry.heartbeat(resource.id, { state: 'idle' });
|
|
115
|
+
|
|
116
|
+
const first = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: ['board:a'] } }) }),
|
|
117
|
+
second = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: ['board:a'] } }) });
|
|
118
|
+
scheduler.runPass();
|
|
119
|
+
|
|
120
|
+
assert.equal(jobs.get(first.id).state, JOB_STATES.ASSIGNED);
|
|
121
|
+
assert.equal(jobs.get(second.id).state, JOB_STATES.QUEUED);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('heartbeat sweeper marks a silent resource OUT_OF_SERVICE and requeues its active job', () => {
|
|
125
|
+
const { db, registry, agents, jobs, scheduler, heartbeatMonitor } = buildTestServices(),
|
|
126
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
127
|
+
resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
128
|
+
registry.heartbeat(resource.id, { state: 'idle' });
|
|
129
|
+
|
|
130
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
131
|
+
scheduler.runPass();
|
|
132
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.ASSIGNED);
|
|
133
|
+
|
|
134
|
+
// Simulate 3+ missed heartbeats (heartbeatIntervalSec=10) by backdating last_heartbeat_at.
|
|
135
|
+
const staleTs = new Date(Date.now() - 60_000).toISOString();
|
|
136
|
+
db.prepare('UPDATE resources SET last_heartbeat_at = ? WHERE id = ?').run(staleTs, resource.id);
|
|
137
|
+
|
|
138
|
+
heartbeatMonitor.sweepOnce();
|
|
139
|
+
|
|
140
|
+
assert.equal(registry.get(resource.id).status, RESOURCE_STATES.OUT_OF_SERVICE);
|
|
141
|
+
// requeueOnLost defaults true and attempt was 1, so LOST -> QUEUED.
|
|
142
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('job ids are split by source: A-##### for ci, M-##### for cli', () => {
|
|
146
|
+
const { registry, agents, jobs } = buildTestServices(),
|
|
147
|
+
{ agent } = agents.create({ name: 'mixed', kind: 'ci' });
|
|
148
|
+
registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
149
|
+
|
|
150
|
+
const ciJob = jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec() }),
|
|
151
|
+
cliJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
|
|
152
|
+
ciJob2 = jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec() });
|
|
153
|
+
|
|
154
|
+
assert.match(ciJob.id, /^A-\d{5}$/);
|
|
155
|
+
assert.match(cliJob.id, /^M-\d{5}$/);
|
|
156
|
+
// Independent counters: the second ci job increments the A series only.
|
|
157
|
+
assert.equal(Number(ciJob2.id.slice(2)), Number(ciJob.id.slice(2)) + 1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('admin resetQueue cancels every active job and leaves finished ones alone', () => {
|
|
161
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
162
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
163
|
+
resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
164
|
+
registry.heartbeat(resource.id, { state: 'idle' });
|
|
165
|
+
|
|
166
|
+
const assigned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
167
|
+
scheduler.runPass();
|
|
168
|
+
assert.equal(jobs.get(assigned.id).state, JOB_STATES.ASSIGNED);
|
|
169
|
+
|
|
170
|
+
const queued = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
|
|
171
|
+
finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
172
|
+
jobs.setState(finished.id, JOB_STATES.PASSED);
|
|
173
|
+
|
|
174
|
+
const canceled = jobs.resetQueue();
|
|
175
|
+
|
|
176
|
+
assert.equal(canceled, 2);
|
|
177
|
+
assert.equal(jobs.get(assigned.id).state, JOB_STATES.CANCELED);
|
|
178
|
+
assert.equal(jobs.get(queued.id).state, JOB_STATES.CANCELED);
|
|
179
|
+
assert.equal(jobs.get(finished.id).state, JOB_STATES.PASSED); // untouched
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('admin cleanHistory deletes finished jobs, their artifacts on disk, and leaves active jobs alone', () => {
|
|
183
|
+
const { registry, agents, artifacts, jobs, artifactsDir } = buildTestServices(),
|
|
184
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
185
|
+
registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
186
|
+
|
|
187
|
+
const finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
188
|
+
jobs.setState(finished.id, JOB_STATES.PASSED);
|
|
189
|
+
artifacts.storeGenerated(finished.id, 'console.log', Buffer.from('hello'), 'text/plain');
|
|
190
|
+
assert.ok(fs.existsSync(path.join(artifactsDir, finished.id)));
|
|
191
|
+
|
|
192
|
+
const active = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
|
|
193
|
+
|
|
194
|
+
deleted = jobs.cleanHistory();
|
|
195
|
+
|
|
196
|
+
assert.equal(deleted, 1);
|
|
197
|
+
assert.equal(jobs.get(finished.id), undefined);
|
|
198
|
+
assert.equal(fs.existsSync(path.join(artifactsDir, finished.id)), false);
|
|
199
|
+
assert.ok(jobs.get(active.id)); // active job untouched
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('resource re-registration overwrites type/labels/status and marks its stale active job LOST', () => {
|
|
203
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
204
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
205
|
+
resource = registerResource(registry, { name: 'lab-hw-01', type: 'hw', labels: ['board:a'] });
|
|
206
|
+
registry.heartbeat(resource.id, { state: 'idle' });
|
|
207
|
+
|
|
208
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'hw', labels: ['board:a'] } }) });
|
|
209
|
+
scheduler.runPass();
|
|
210
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.ASSIGNED);
|
|
211
|
+
assert.equal(registry.get(resource.id).status, RESOURCE_STATES.BUSY);
|
|
212
|
+
|
|
213
|
+
// Simulate the daemon crashing and restarting with a changed config.
|
|
214
|
+
const { resourceId } = registry.registerAuto({
|
|
215
|
+
clientId: 'client-lab-hw-01',
|
|
216
|
+
name: 'lab-hw-01',
|
|
217
|
+
type: 'sw',
|
|
218
|
+
labels: ['board:b']
|
|
219
|
+
});
|
|
220
|
+
assert.equal(resourceId, resource.id);
|
|
221
|
+
|
|
222
|
+
const reregistered = registry.get(resource.id);
|
|
223
|
+
assert.equal(reregistered.type, 'sw');
|
|
224
|
+
assert.deepEqual(reregistered.labels, ['board:b']);
|
|
225
|
+
assert.equal(reregistered.status, RESOURCE_STATES.REGISTERED);
|
|
226
|
+
assert.equal(reregistered.busy_source, null);
|
|
227
|
+
|
|
228
|
+
// The stale job it was holding is now LOST (then requeued, since attempt < 2).
|
|
229
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('resource re-registration does not clear an admin-set MAINTENANCE status', () => {
|
|
233
|
+
const { registry } = buildTestServices(),
|
|
234
|
+
resource = registerResource(registry, { name: 'lab-sw-02', type: 'sw', labels: [] });
|
|
235
|
+
registry.heartbeat(resource.id, { state: 'idle' });
|
|
236
|
+
registry.setMaintenance(resource.id, true);
|
|
237
|
+
|
|
238
|
+
registry.registerAuto({ clientId: 'client-lab-sw-02', name: 'lab-sw-02', type: 'sw', labels: [] });
|
|
239
|
+
|
|
240
|
+
assert.equal(registry.get(resource.id).status, RESOURCE_STATES.MAINTENANCE);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('renaming a Client (same clientId, new name) updates the same resource in place', () => {
|
|
244
|
+
const { registry } = buildTestServices(),
|
|
245
|
+
resource = registerResource(registry, { clientId: 'stable-uuid-1', name: 'old-name', type: 'sw', labels: [] }),
|
|
246
|
+
|
|
247
|
+
{ resourceId } = registry.registerAuto({ clientId: 'stable-uuid-1', name: 'new-name', type: 'sw', labels: [] });
|
|
248
|
+
|
|
249
|
+
assert.equal(resourceId, resource.id);
|
|
250
|
+
assert.equal(registry.get(resource.id).name, 'new-name');
|
|
251
|
+
assert.equal(registry.list().length, 1); // not a second resource
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a name already claimed by a different clientId is rejected, whether new or a rename', () => {
|
|
255
|
+
const { registry } = buildTestServices();
|
|
256
|
+
registerResource(registry, { clientId: 'uuid-a', name: 'shared-name', type: 'sw', labels: [] });
|
|
257
|
+
registerResource(registry, { clientId: 'uuid-b', name: 'other-name', type: 'sw', labels: [] });
|
|
258
|
+
|
|
259
|
+
assert.throws(
|
|
260
|
+
() => registry.registerAuto({ clientId: 'uuid-c', name: 'shared-name', type: 'sw', labels: [] }),
|
|
261
|
+
/already registered by a different client/
|
|
262
|
+
);
|
|
263
|
+
assert.throws(
|
|
264
|
+
() => registry.registerAuto({ clientId: 'uuid-b', name: 'shared-name', type: 'sw', labels: [] }),
|
|
265
|
+
/already used by another client/
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('a name held by an OUT_OF_SERVICE (abandoned) client can be reclaimed by a new registration', () => {
|
|
270
|
+
const { registry } = buildTestServices(),
|
|
271
|
+
abandoned = registerResource(registry, { clientId: 'old-uuid', name: 'lab-01', type: 'sw', labels: [] });
|
|
272
|
+
registry.markOutOfService(abandoned.id);
|
|
273
|
+
|
|
274
|
+
const { resourceId } = registry.registerAuto({ clientId: 'new-uuid', name: 'lab-01', type: 'hw', labels: ['x'] });
|
|
275
|
+
|
|
276
|
+
assert.equal(resourceId, abandoned.id); // took over the same row, not a duplicate
|
|
277
|
+
const reclaimed = registry.get(resourceId);
|
|
278
|
+
assert.equal(reclaimed.client_id, 'new-uuid');
|
|
279
|
+
assert.equal(reclaimed.type, 'hw');
|
|
280
|
+
assert.equal(reclaimed.status, RESOURCE_STATES.REGISTERED);
|
|
281
|
+
assert.equal(registry.list().length, 1);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('a name held by an OUT_OF_SERVICE client can be reclaimed via a rename too, without duplicating the row', () => {
|
|
285
|
+
const { registry } = buildTestServices(),
|
|
286
|
+
abandoned = registerResource(registry, { clientId: 'old-uuid', name: 'lab-02', type: 'sw', labels: [] });
|
|
287
|
+
registry.markOutOfService(abandoned.id);
|
|
288
|
+
const renaming = registerResource(registry, { clientId: 'live-uuid', name: 'my-bench', type: 'sw', labels: [] }),
|
|
289
|
+
|
|
290
|
+
{ resourceId } = registry.registerAuto({ clientId: 'live-uuid', name: 'lab-02', type: 'sw', labels: [] });
|
|
291
|
+
|
|
292
|
+
assert.equal(resourceId, renaming.id);
|
|
293
|
+
assert.equal(registry.get(renaming.id).name, 'lab-02');
|
|
294
|
+
// The abandoned row is still there (history preserved), just renamed out of the way.
|
|
295
|
+
assert.equal(registry.list().length, 2);
|
|
296
|
+
assert.equal(registry.get(abandoned.id).name, `lab-02__stale-${abandoned.id}`);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('a legacy resource with no clientId (pre-dates the feature) is adopted by name on first registration', () => {
|
|
300
|
+
const { db, registry } = buildTestServices(),
|
|
301
|
+
resource = registerResource(registry, { clientId: 'will-be-overwritten', name: 'legacy-01', type: 'sw', labels: [] });
|
|
302
|
+
// Simulate a pre-migration row: no client_id yet.
|
|
303
|
+
db.prepare('UPDATE resources SET client_id = NULL WHERE id = ?').run(resource.id);
|
|
304
|
+
|
|
305
|
+
const { resourceId } = registry.registerAuto({ clientId: 'brand-new-uuid', name: 'legacy-01', type: 'sw', labels: ['x'] });
|
|
306
|
+
|
|
307
|
+
assert.equal(resourceId, resource.id); // adopted, not duplicated
|
|
308
|
+
assert.equal(registry.get(resource.id).client_id, 'brand-new-uuid');
|
|
309
|
+
assert.equal(registry.list().length, 1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('a job with target.group only schedules onto resources that are members of that group', () => {
|
|
313
|
+
const { registry, agents, groups, jobs, scheduler } = buildTestServices(),
|
|
314
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
315
|
+
groupA = groups.create({ name: 'group-a' }),
|
|
316
|
+
groupB = groups.create({ name: 'group-b' }),
|
|
317
|
+
|
|
318
|
+
inGroupA = registerResource(registry, { name: 'lab-a', type: 'sw', groups: [groupA.id] }),
|
|
319
|
+
inGroupB = registerResource(registry, { name: 'lab-b', type: 'sw', groups: [groupB.id] });
|
|
320
|
+
registry.heartbeat(inGroupA.id, { state: 'idle' });
|
|
321
|
+
registry.heartbeat(inGroupB.id, { state: 'idle' });
|
|
322
|
+
|
|
323
|
+
const job = jobs.create({
|
|
324
|
+
agentId: agent.id,
|
|
325
|
+
source: 'cli',
|
|
326
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], group: groupA.id } })
|
|
327
|
+
});
|
|
328
|
+
scheduler.runPass();
|
|
329
|
+
|
|
330
|
+
const assigned = jobs.get(job.id);
|
|
331
|
+
assert.equal(assigned.state, JOB_STATES.ASSIGNED);
|
|
332
|
+
assert.equal(assigned.resource_id, inGroupA.id); // not inGroupB, despite being IDLE and type-matching
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('a job with target.group is rejected (422) when no resource is ever a member of that group', () => {
|
|
336
|
+
const { registry, agents, groups, jobs } = buildTestServices(),
|
|
337
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
338
|
+
group = groups.create({ name: 'empty-group' });
|
|
339
|
+
registerResource(registry, { name: 'lab-01', type: 'sw', groups: [] }); // exists, but not a member
|
|
340
|
+
|
|
341
|
+
assert.throws(
|
|
342
|
+
() =>
|
|
343
|
+
jobs.create({
|
|
344
|
+
agentId: agent.id,
|
|
345
|
+
source: 'cli',
|
|
346
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], group: group.id } })
|
|
347
|
+
}),
|
|
348
|
+
/No registered resource/
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('a resource can belong to several groups at once', () => {
|
|
353
|
+
const { registry, groups } = buildTestServices(),
|
|
354
|
+
g1 = groups.create({ name: 'g1' }),
|
|
355
|
+
g2 = groups.create({ name: 'g2' }),
|
|
356
|
+
|
|
357
|
+
resource = registerResource(registry, { name: 'multi-group', type: 'sw', groups: [g1.id, g2.id] });
|
|
358
|
+
|
|
359
|
+
assert.deepEqual(registry.get(resource.id).group_ids, [g1.id, g2.id]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('deleting a group strips it from every resource that listed it, without touching the resource otherwise', () => {
|
|
363
|
+
const { registry, groups } = buildTestServices(),
|
|
364
|
+
g1 = groups.create({ name: 'to-delete' }),
|
|
365
|
+
g2 = groups.create({ name: 'keep-me' }),
|
|
366
|
+
resource = registerResource(registry, { name: 'lab-01', type: 'sw', labels: ['x'], groups: [g1.id, g2.id] });
|
|
367
|
+
|
|
368
|
+
groups.remove(g1.id);
|
|
369
|
+
|
|
370
|
+
const after = registry.get(resource.id);
|
|
371
|
+
assert.deepEqual(after.group_ids, [g2.id]);
|
|
372
|
+
assert.deepEqual(after.labels, ['x']); // untouched
|
|
373
|
+
assert.equal(groups.get(g1.id), undefined);
|
|
374
|
+
assert.ok(groups.get(g2.id));
|
|
375
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
extends ../layout
|
|
2
|
+
|
|
3
|
+
block content
|
|
4
|
+
.d-flex.justify-content-between.align-items-center.mb-3
|
|
5
|
+
h1.h4.mb-0 Agents (CI / developers)
|
|
6
|
+
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
|
|
7
|
+
|
|
8
|
+
if newToken
|
|
9
|
+
.alert.alert-warning
|
|
10
|
+
strong Token (shown once):
|
|
11
|
+
|
|
|
12
|
+
code= newToken
|
|
13
|
+
|
|
14
|
+
table.table.table-hover.align-middle
|
|
15
|
+
thead
|
|
16
|
+
tr
|
|
17
|
+
th Name
|
|
18
|
+
th Kind
|
|
19
|
+
th Created
|
|
20
|
+
th Last used
|
|
21
|
+
th Status
|
|
22
|
+
th
|
|
23
|
+
tbody
|
|
24
|
+
each a in agents
|
|
25
|
+
tr(class=a.revoked_at ? 'opacity-50' : '')
|
|
26
|
+
td= a.name
|
|
27
|
+
td.text-uppercase= a.kind
|
|
28
|
+
td= fmtDate(a.created_at)
|
|
29
|
+
td= fmtDate(a.last_used_at, 'never')
|
|
30
|
+
td= a.revoked_at ? 'revoked' : 'active'
|
|
31
|
+
td
|
|
32
|
+
if !a.revoked_at
|
|
33
|
+
form(method="post" action=`/admin/agents/${a.id}/revoke` onsubmit="return confirm('Revoke this agent token?')")
|
|
34
|
+
button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
|
|
35
|
+
|
|
36
|
+
.modal(tabindex="-1" id="addAgentModal")
|
|
37
|
+
.modal-dialog
|
|
38
|
+
.modal-content
|
|
39
|
+
form(method="post" action="/admin/agents")
|
|
40
|
+
.modal-header
|
|
41
|
+
h5.modal-title Register agent
|
|
42
|
+
button.btn-close(type="button" data-bs-dismiss="modal")
|
|
43
|
+
.modal-body
|
|
44
|
+
.mb-3
|
|
45
|
+
label.form-label Name
|
|
46
|
+
input.form-control(type="text" name="name" placeholder="ci-firmware or jane.dev" required)
|
|
47
|
+
.mb-3
|
|
48
|
+
label.form-label Kind
|
|
49
|
+
select.form-select(name="kind")
|
|
50
|
+
option(value="ci") ci
|
|
51
|
+
option(value="cli") cli
|
|
52
|
+
.modal-footer
|
|
53
|
+
button.btn.btn-primary(type="submit") Create
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
extends ../layout
|
|
2
|
+
|
|
3
|
+
block content
|
|
4
|
+
.d-flex.justify-content-between.align-items-center.mb-3
|
|
5
|
+
h1.h4.mb-0 Groups
|
|
6
|
+
if canManage
|
|
7
|
+
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addGroupModal") Add group
|
|
8
|
+
|
|
9
|
+
p.text-body-secondary.small.
|
|
10
|
+
A job can be constrained to run only on resources in a group with `thub run --group <id>` (§13.1).
|
|
11
|
+
A resource joins a group by listing its id under `groups` in the Client's own config — membership isn't set here.
|
|
12
|
+
|
|
13
|
+
if groups.length
|
|
14
|
+
table.table.table-hover.align-middle
|
|
15
|
+
thead
|
|
16
|
+
tr
|
|
17
|
+
th Name
|
|
18
|
+
th ID
|
|
19
|
+
th Comment
|
|
20
|
+
th Resources
|
|
21
|
+
th Created
|
|
22
|
+
if canManage
|
|
23
|
+
th Actions
|
|
24
|
+
tbody
|
|
25
|
+
each g in groups
|
|
26
|
+
tr
|
|
27
|
+
td= g.name
|
|
28
|
+
td
|
|
29
|
+
code.small.me-1= g.id
|
|
30
|
+
button.btn.btn-sm.btn-outline-secondary.copy-btn(
|
|
31
|
+
type="button"
|
|
32
|
+
aria-label="Copy group ID to clipboard"
|
|
33
|
+
data-bs-toggle="tooltip"
|
|
34
|
+
data-bs-title="Copy to clipboard"
|
|
35
|
+
data-copy=g.id
|
|
36
|
+
)
|
|
37
|
+
i.bi.bi-clipboard
|
|
38
|
+
td= g.comment || '—'
|
|
39
|
+
td= g.resourceCount
|
|
40
|
+
td= fmtDate(g.created_at)
|
|
41
|
+
if canManage
|
|
42
|
+
td
|
|
43
|
+
button.btn.btn-sm.btn-outline-secondary(type="button" data-bs-toggle="modal" data-bs-target=`#editGroupModal-${g.id}`) Edit
|
|
44
|
+
form.d-inline.ms-1(method="post" action=`/groups/${g.id}/delete` onsubmit="return confirm('Delete this group? Any resource listing it will just stop matching on it.')")
|
|
45
|
+
button.btn.btn-sm.btn-outline-danger(type="submit") Delete
|
|
46
|
+
|
|
47
|
+
.modal(tabindex="-1" id=`editGroupModal-${g.id}`)
|
|
48
|
+
.modal-dialog
|
|
49
|
+
.modal-content
|
|
50
|
+
form(method="post" action=`/groups/${g.id}`)
|
|
51
|
+
.modal-header
|
|
52
|
+
h5.modal-title Edit group
|
|
53
|
+
button.btn-close(type="button" data-bs-dismiss="modal")
|
|
54
|
+
.modal-body
|
|
55
|
+
.mb-3
|
|
56
|
+
label.form-label Name
|
|
57
|
+
input.form-control(type="text" name="name" value=g.name required)
|
|
58
|
+
.mb-3
|
|
59
|
+
label.form-label Comment
|
|
60
|
+
textarea.form-control(name="comment" rows="2")= g.comment || ''
|
|
61
|
+
p.text-body-secondary.small.mb-0
|
|
62
|
+
| ID:
|
|
63
|
+
code.me-1= g.id
|
|
64
|
+
button.btn.btn-sm.btn-outline-secondary.copy-btn(
|
|
65
|
+
type="button"
|
|
66
|
+
aria-label="Copy group ID to clipboard"
|
|
67
|
+
data-bs-toggle="tooltip"
|
|
68
|
+
data-bs-title="Copy to clipboard"
|
|
69
|
+
data-copy=g.id
|
|
70
|
+
)
|
|
71
|
+
i.bi.bi-clipboard
|
|
72
|
+
.modal-footer
|
|
73
|
+
button.btn.btn-primary(type="submit") Save
|
|
74
|
+
else
|
|
75
|
+
p.text-body-secondary No groups yet.
|
|
76
|
+
|
|
77
|
+
if canManage
|
|
78
|
+
.modal(tabindex="-1" id="addGroupModal")
|
|
79
|
+
.modal-dialog
|
|
80
|
+
.modal-content
|
|
81
|
+
form(method="post" action="/groups")
|
|
82
|
+
.modal-header
|
|
83
|
+
h5.modal-title Add group
|
|
84
|
+
button.btn-close(type="button" data-bs-dismiss="modal")
|
|
85
|
+
.modal-body
|
|
86
|
+
.mb-3
|
|
87
|
+
label.form-label Name
|
|
88
|
+
input.form-control(type="text" name="name" placeholder="ci-nightly" required)
|
|
89
|
+
.mb-3
|
|
90
|
+
label.form-label Comment
|
|
91
|
+
textarea.form-control(name="comment" rows="2" placeholder="Optional")
|
|
92
|
+
p.text-body-secondary.small.mb-0 The group's id (a UUID) is generated once it's created.
|
|
93
|
+
.modal-footer
|
|
94
|
+
button.btn.btn-primary(type="submit") Create
|
package/views/index.pug
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
extends layout
|
|
2
|
+
include mixins/status-badge
|
|
3
|
+
|
|
4
|
+
block content
|
|
5
|
+
h1.h4.mb-2 Overview
|
|
6
|
+
|
|
7
|
+
.row.g-3.mb-4
|
|
8
|
+
.col-md-4
|
|
9
|
+
.card
|
|
10
|
+
.card-body
|
|
11
|
+
h6.card-subtitle.mb-2.text-body-secondary Queue length
|
|
12
|
+
p.display-6= queueLength
|
|
13
|
+
.col-md-4
|
|
14
|
+
.card
|
|
15
|
+
.card-body
|
|
16
|
+
h6.card-subtitle.mb-2.text-body-secondary Jobs in last 24h
|
|
17
|
+
p.display-6= jobsLast24h
|
|
18
|
+
.col-md-4
|
|
19
|
+
.card
|
|
20
|
+
.card-body
|
|
21
|
+
h6.card-subtitle.mb-2.text-body-secondary Resources online
|
|
22
|
+
p.display-6= `${onlineCount} / ${resources.length}`
|
|
23
|
+
|
|
24
|
+
.row.g-3
|
|
25
|
+
each r in resources
|
|
26
|
+
.col-sm-6.col-md-4.col-lg-3
|
|
27
|
+
.card.h-100(class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
|
|
28
|
+
.card-body
|
|
29
|
+
h5.card-title= r.name
|
|
30
|
+
p.card-text.mb-1
|
|
31
|
+
+resourceBadge(r.status)
|
|
32
|
+
|
|
|
33
|
+
= ' ' + r.type.toUpperCase()
|
|
34
|
+
if r.busy_source
|
|
35
|
+
p.card-text.small.text-body-secondary busy: #{r.busy_source} #{r.busy_reason ? '— ' + r.busy_reason : ''}
|
|
36
|
+
p.card-text.small.text-body-secondary
|
|
37
|
+
| heartbeat: #{fmtDate(r.last_heartbeat_at, 'never')}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
extends ../layout
|
|
2
|
+
include ../mixins/status-badge
|
|
3
|
+
|
|
4
|
+
block content
|
|
5
|
+
.d-flex.justify-content-between.align-items-center.mb-3
|
|
6
|
+
h1.h4.mb-0 Jobs
|
|
7
|
+
if canManage
|
|
8
|
+
.btn-group
|
|
9
|
+
form(method="post" action="/jobs/reset-queue" onsubmit="return confirm('Cancel every queued/assigned/preparing/running job? This stops in-flight work.')")
|
|
10
|
+
button.btn.btn-sm.btn-outline-warning(type="submit") Reset queue
|
|
11
|
+
form.ms-1(method="post" action="/jobs/clean-history" onsubmit="return confirm('Permanently delete all finished jobs and their logs/artifacts? This cannot be undone.')")
|
|
12
|
+
button.btn.btn-sm.btn-outline-danger(type="submit") Clean history
|
|
13
|
+
|
|
14
|
+
form.row.g-2.mb-3(method="get")
|
|
15
|
+
.col-auto
|
|
16
|
+
select.form-select.form-select-sm(name="state" onchange="this.form.submit()")
|
|
17
|
+
option(value="") any state
|
|
18
|
+
each s in ['QUEUED','ASSIGNED','PREPARING','RUNNING','PASSED','FAILED','ERROR','TIMEOUT','CANCELED','LOST']
|
|
19
|
+
option(value=s selected=filters.state === s)= s
|
|
20
|
+
.col-auto
|
|
21
|
+
select.form-select.form-select-sm(name="source" onchange="this.form.submit()")
|
|
22
|
+
option(value="") any source
|
|
23
|
+
option(value="ci" selected=filters.source === 'ci') ci
|
|
24
|
+
option(value="cli" selected=filters.source === 'cli') cli
|
|
25
|
+
|
|
26
|
+
table.table.table-hover.align-middle
|
|
27
|
+
thead
|
|
28
|
+
tr
|
|
29
|
+
th Job
|
|
30
|
+
th Source
|
|
31
|
+
th User
|
|
32
|
+
th State
|
|
33
|
+
th Resource
|
|
34
|
+
th Created
|
|
35
|
+
th Finished
|
|
36
|
+
tbody
|
|
37
|
+
each j in jobs
|
|
38
|
+
tr
|
|
39
|
+
td: a(href=`/jobs/${j.id}`)= j.id
|
|
40
|
+
td.text-uppercase= j.source
|
|
41
|
+
td= j.spec.user || '—'
|
|
42
|
+
td: +jobBadge(j.state)
|
|
43
|
+
td= j.resource ? j.resource.name : '—'
|
|
44
|
+
td= fmtDate(j.created_at)
|
|
45
|
+
td= fmtDate(j.finished_at)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
extends ../layout
|
|
2
|
+
include ../mixins/status-badge
|
|
3
|
+
include ../mixins/log-viewer
|
|
4
|
+
|
|
5
|
+
block content
|
|
6
|
+
.d-flex.justify-content-between.align-items-center.mb-3
|
|
7
|
+
h1.h4.mb-0
|
|
8
|
+
= job.id
|
|
9
|
+
|
|
|
10
|
+
+jobBadge(job.state)
|
|
11
|
+
if canCancel
|
|
12
|
+
form(method="post" action=`/jobs/${job.id}/cancel`)
|
|
13
|
+
button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
|
|
14
|
+
|
|
15
|
+
dl.row
|
|
16
|
+
dt.col-sm-2 Source
|
|
17
|
+
dd.col-sm-10.text-uppercase= job.source
|
|
18
|
+
if job.spec.user
|
|
19
|
+
dt.col-sm-2 User
|
|
20
|
+
dd.col-sm-10= job.spec.user
|
|
21
|
+
dt.col-sm-2 Resource
|
|
22
|
+
dd.col-sm-10= job.resource ? job.resource.name : '—'
|
|
23
|
+
dt.col-sm-2 Target
|
|
24
|
+
dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
|
|
25
|
+
dt.col-sm-2 Created
|
|
26
|
+
dd.col-sm-10= fmtDate(job.created_at)
|
|
27
|
+
if job.message
|
|
28
|
+
dt.col-sm-2 Message
|
|
29
|
+
dd.col-sm-10= job.message
|
|
30
|
+
|
|
31
|
+
h2.h6.mt-4 Log
|
|
32
|
+
+logViewer(job.id, jobActive)
|
|
33
|
+
|
|
34
|
+
h2.h6.mt-4#artifacts Artifacts
|
|
35
|
+
if artifacts.length
|
|
36
|
+
ul.list-group
|
|
37
|
+
each a in artifacts
|
|
38
|
+
li.list-group-item.d-flex.justify-content-between
|
|
39
|
+
span= a.name
|
|
40
|
+
a(href=a.url) download
|
|
41
|
+
else
|
|
42
|
+
p.text-body-secondary No artifacts yet.
|