@andrian.yablonskyy/thub-coordinator 1.0.39 → 1.0.40
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/bin/thub-admin.js +23 -0
- package/config.json +4 -0
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/settings.js +66 -0
- package/src/api/agent.js +27 -15
- package/src/api/resource.js +5 -3
- package/src/config.js +17 -1
- package/src/db/migrations/024_add_settings.sql +10 -0
- package/src/db/migrations/025_add_job_artifacts.sql +4 -0
- package/src/dev/virtual.js +9 -5
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +104 -0
- package/src/server.js +17 -3
- package/src/services/admin-users.js +32 -19
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +13 -2
- package/src/services/registry.js +34 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +157 -10
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- package/test/groups-tab.test.js +97 -0
- package/test/job-artifacts.test.js +115 -0
- package/test/login-guard.test.js +139 -0
- package/test/logs.test.js +1 -1
- package/test/rate-limit.test.js +106 -0
- package/test/security.test.js +107 -0
- package/test/settings.test.js +206 -0
- package/views/admin/settings.pug +133 -0
- package/views/groups/list.pug +1 -1
- package/views/help/_agent-cli.pug +1 -1
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +9 -1
- package/views/help/_env.pug +1 -0
- package/views/help/_troubleshooting.pug +3 -1
- package/views/jobs/list.pug +3 -3
- package/views/jobs/show.pug +36 -0
- package/views/layout.pug +4 -1
- package/views/mixins/client-config.pug +65 -3
- package/views/mixins/list-controls.pug +1 -1
- package/views/mixins/resource.pug +1 -8
package/src/security.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/security.js
|
|
3
|
+
* @description HTTP security headers for every response, and the same-origin check on the dashboard's state-changing requests (README §12)
|
|
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
|
+
// Everything the dashboard loads: its own files, plus Bootstrap and its
|
|
17
|
+
// icon font from jsDelivr (layout.pug). No inline script at all — the
|
|
18
|
+
// templates use data-* attributes (public/js/actions.js) — while inline
|
|
19
|
+
// style="" attributes, used throughout the templates, stay allowed.
|
|
20
|
+
const CDN = 'https://cdn.jsdelivr.net',
|
|
21
|
+
CONTENT_SECURITY_POLICY = [
|
|
22
|
+
'default-src \'self\'',
|
|
23
|
+
`script-src 'self' ${CDN}`,
|
|
24
|
+
`style-src 'self' ${CDN} 'unsafe-inline'`,
|
|
25
|
+
`font-src 'self' ${CDN}`,
|
|
26
|
+
// data: — Bootstrap's CSS draws some icons (select arrow, close button)
|
|
27
|
+
// as inline SVG images.
|
|
28
|
+
'img-src \'self\' data:',
|
|
29
|
+
'connect-src \'self\'',
|
|
30
|
+
'object-src \'none\'',
|
|
31
|
+
'base-uri \'self\'',
|
|
32
|
+
'form-action \'self\'',
|
|
33
|
+
'frame-ancestors \'none\''
|
|
34
|
+
].join('; '),
|
|
35
|
+
// 180 days, this host only: HTTPS is how a TLS-fronted Coordinator is
|
|
36
|
+
// reached; other hosts under the same domain are none of its business.
|
|
37
|
+
HSTS = 'max-age=15552000',
|
|
38
|
+
UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
39
|
+
|
|
40
|
+
// Sent on every response (dashboard, API, static files).
|
|
41
|
+
function securityHeaders(req, res, next){
|
|
42
|
+
res.set({
|
|
43
|
+
'Content-Security-Policy': CONTENT_SECURITY_POLICY,
|
|
44
|
+
// Also frame-ancestors above; this one for browsers that predate it.
|
|
45
|
+
'X-Frame-Options': 'DENY',
|
|
46
|
+
'X-Content-Type-Options': 'nosniff',
|
|
47
|
+
// Full URLs (job ids, searches) only within the dashboard itself.
|
|
48
|
+
'Referrer-Policy': 'same-origin',
|
|
49
|
+
'Cross-Origin-Opener-Policy': 'same-origin'
|
|
50
|
+
});
|
|
51
|
+
// Only on HTTPS (behind a TLS proxy: X-Forwarded-Proto from a trusted
|
|
52
|
+
// proxy, trustProxy) — over plain HTTP a browser ignores it anyway.
|
|
53
|
+
if (req.secure){
|
|
54
|
+
res.set('Strict-Transport-Security', HSTS);
|
|
55
|
+
}
|
|
56
|
+
next();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function originOf(url){
|
|
60
|
+
try {
|
|
61
|
+
return new URL(url).origin;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// CSRF defence for the session-authenticated routes (the dashboard, and
|
|
69
|
+
// /api/v1/admin), on top of the session cookie's SameSite=Lax: a request
|
|
70
|
+
// that changes something must come from this dashboard's own pages. The
|
|
71
|
+
// browser says where from in Origin (every modern browser sends it on a
|
|
72
|
+
// POST), else Referer; a request carrying neither isn't from a browser page
|
|
73
|
+
// (curl, a script) and couldn't have been forged by one, so it passes. The
|
|
74
|
+
// Agent/Client APIs use bearer tokens, which a browser never attaches by
|
|
75
|
+
// itself, and don't go through here.
|
|
76
|
+
function sameOriginOnly(config){
|
|
77
|
+
return (req, res, next) => {
|
|
78
|
+
if (!UNSAFE_METHODS.has(req.method)){
|
|
79
|
+
return next();
|
|
80
|
+
}
|
|
81
|
+
const origin = req.get('origin'),
|
|
82
|
+
from = origin !== undefined ? origin : req.get('referer') !== undefined ? originOf(req.get('referer')) : undefined;
|
|
83
|
+
if (from === undefined){
|
|
84
|
+
return next();
|
|
85
|
+
}
|
|
86
|
+
// The address the browser used (Host, as the reverse proxy passes it),
|
|
87
|
+
// or the configured publicUrl (a proxy that rewrites Host) — read on
|
|
88
|
+
// every request, since the dashboard can change it (§13.2).
|
|
89
|
+
const configured = originOf(config.publicUrl),
|
|
90
|
+
allowed = [configured, `${req.protocol}://${req.get('host')}`];
|
|
91
|
+
if (from && allowed.includes(from)){
|
|
92
|
+
return next();
|
|
93
|
+
}
|
|
94
|
+
const text = `Blocked: this request came from ${from || 'an unknown page'}, not from this dashboard (${configured}). ` +
|
|
95
|
+
'Changes can only be made from the dashboard\'s own pages. If you opened the dashboard at a different address, ' +
|
|
96
|
+
'set publicUrl to that address.';
|
|
97
|
+
if (req.originalUrl.startsWith('/api/')){ // req.path is relative to the router's mount point
|
|
98
|
+
return res.status(403).json({ error: text });
|
|
99
|
+
}
|
|
100
|
+
res.status(403).type('text/plain').send(text);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { securityHeaders, sameOriginOnly, CONTENT_SECURITY_POLICY };
|
package/src/server.js
CHANGED
|
@@ -39,6 +39,9 @@ const fs = require('node:fs'),
|
|
|
39
39
|
{ createUpdatesService } = require('./services/updates'),
|
|
40
40
|
{ createLiveService } = require('./services/live'),
|
|
41
41
|
{ SqliteSessionStore } = require('./services/session-store'),
|
|
42
|
+
{ securityHeaders, sameOriginOnly } = require('./security'),
|
|
43
|
+
{ createSettingsService } = require('./services/settings'),
|
|
44
|
+
{ createRateLimiter } = require('./rate-limit'),
|
|
42
45
|
|
|
43
46
|
{ createAgentRouter } = require('./api/agent'),
|
|
44
47
|
{ createResourceRouter } = require('./api/resource'),
|
|
@@ -58,6 +61,9 @@ function removeLegacyArtifacts(config){
|
|
|
58
61
|
function buildServices(config){
|
|
59
62
|
removeLegacyArtifacts(config);
|
|
60
63
|
const db = openDb(config.dbPath),
|
|
64
|
+
// First: dashboard settings (README §13.2) go into `config` before any
|
|
65
|
+
// service reads it — some read theirs only once, at creation.
|
|
66
|
+
settings = createSettingsService(db, { config }).applyAtStartup(),
|
|
61
67
|
events = createEventsService(db),
|
|
62
68
|
registry = createRegistryService(db, { bus, events }),
|
|
63
69
|
agents = createAgentsService(db, { events }),
|
|
@@ -101,7 +107,8 @@ function buildServices(config){
|
|
|
101
107
|
cleanup,
|
|
102
108
|
updates,
|
|
103
109
|
live,
|
|
104
|
-
sessionStore
|
|
110
|
+
sessionStore,
|
|
111
|
+
settings
|
|
105
112
|
};
|
|
106
113
|
}
|
|
107
114
|
|
|
@@ -130,6 +137,9 @@ function createApp(config, services){
|
|
|
130
137
|
app.set('view engine', 'pug');
|
|
131
138
|
app.set('views', path.join(__dirname, '..', 'views'));
|
|
132
139
|
app.set('trust proxy', config.trustProxy);
|
|
140
|
+
app.disable('x-powered-by');
|
|
141
|
+
// CSP, framing, nosniff, Referrer-Policy, HSTS on HTTPS (security.js, §12).
|
|
142
|
+
app.use(securityHeaders);
|
|
133
143
|
app.locals.services = services;
|
|
134
144
|
// Shown in every dashboard page's footer (layout.pug).
|
|
135
145
|
app.locals.coordinatorVersion = require('../package.json').version;
|
|
@@ -174,12 +184,16 @@ function createApp(config, services){
|
|
|
174
184
|
});
|
|
175
185
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
176
186
|
app.use('/avatars', express.static(config.avatarsDir));
|
|
187
|
+
// Everything below (API, dashboard, sign-in) — static files above aren't
|
|
188
|
+
// counted. Per credential, else per IP (rate-limit.js, §12).
|
|
189
|
+
app.use(createRateLimiter(config, services));
|
|
177
190
|
|
|
178
191
|
app.use('/api/v1', createAgentRouter({ services, config }));
|
|
179
192
|
app.use('/api/v1', createResourceRouter({ services, config }));
|
|
180
|
-
|
|
193
|
+
// Session-authenticated, like the dashboard: same-origin requests only.
|
|
194
|
+
app.use('/api/v1/admin', sameOriginOnly(config), createAdminRouter({ services }));
|
|
181
195
|
|
|
182
|
-
app.use('/', createWebRouter({ services, config }));
|
|
196
|
+
app.use('/', sameOriginOnly(config), createWebRouter({ services, config }));
|
|
183
197
|
|
|
184
198
|
app.use((err, req, res, next) => {
|
|
185
199
|
const status = err.status || 500;
|
|
@@ -14,25 +14,42 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const crypto = require('node:crypto'),
|
|
17
|
+
{ promisify } = require('node:util'),
|
|
17
18
|
{ v4: uuid } = require('uuid'),
|
|
18
19
|
{ normalize: normalizeListPrefs } = require('./list-prefs');
|
|
19
20
|
|
|
21
|
+
// Stored as "<salt hex>:<scrypt hex>". The synchronous one is only for the
|
|
22
|
+
// CLI and startup (thub-admin create-admin, the bootstrap password); every
|
|
23
|
+
// request uses the async versions, which run scrypt on libuv's thread pool —
|
|
24
|
+
// scryptSync would stall every request for each sign-in attempt.
|
|
25
|
+
const scrypt = promisify(crypto.scrypt);
|
|
26
|
+
|
|
20
27
|
function hashPassword(password){
|
|
21
28
|
const salt = crypto.randomBytes(16).toString('hex'),
|
|
22
29
|
derived = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
23
30
|
return `${salt}:${derived}`;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
function
|
|
33
|
+
async function hashPasswordAsync(password){
|
|
34
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
35
|
+
return `${salt}:${(await scrypt(password, salt, 64)).toString('hex')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function verifyPassword(password, stored){
|
|
27
39
|
const [salt, derived] = stored.split(':'),
|
|
28
|
-
check =
|
|
29
|
-
return crypto.timingSafeEqual(
|
|
40
|
+
check = await scrypt(String(password ?? ''), salt, 64);
|
|
41
|
+
return crypto.timingSafeEqual(check, Buffer.from(derived, 'hex'));
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const
|
|
44
|
+
// Checked instead of a real hash when the username doesn't exist, so that
|
|
45
|
+
// answer takes as long as a wrong password and doesn't reveal which
|
|
46
|
+
// usernames exist.
|
|
47
|
+
const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex')),
|
|
48
|
+
|
|
49
|
+
// Presets offered on the profile page (§10.1) — a fixed list rather than a
|
|
50
|
+
// free-text field, so a user can't accidentally set a 3-second or 10-year
|
|
51
|
+
// idle timeout.
|
|
52
|
+
SESSION_TIMEOUT_OPTIONS_MIN = [15, 30, 60, 120, 240, 480, 1440],
|
|
36
53
|
THEMES = ['auto', 'light', 'dark'];
|
|
37
54
|
|
|
38
55
|
function toProfile(row){
|
|
@@ -105,26 +122,22 @@ function createAdminUsersService(db){
|
|
|
105
122
|
// Self-service password change (§10.1) — requires knowing the *current*
|
|
106
123
|
// password, unlike resetPassword() above (that's the admin/operator
|
|
107
124
|
// break-glass path, which deliberately doesn't need it).
|
|
108
|
-
function changePassword(id, currentPassword, newPassword){
|
|
125
|
+
async function changePassword(id, currentPassword, newPassword){
|
|
109
126
|
const row = db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
|
|
110
|
-
if (!row || !verifyPassword(currentPassword, row.password_hash)){
|
|
127
|
+
if (!row || !(await verifyPassword(currentPassword, row.password_hash))){
|
|
111
128
|
throw Object.assign(new Error('Current password is incorrect'), { status: 401 });
|
|
112
129
|
}
|
|
113
130
|
if (!newPassword){
|
|
114
131
|
throw Object.assign(new Error('New password must not be empty'), { status: 400 });
|
|
115
132
|
}
|
|
116
|
-
db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(
|
|
133
|
+
db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!verifyPassword(password, user.password_hash)){
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
return toProfile(user);
|
|
136
|
+
// Async (thread pool); the same work whether or not the user exists.
|
|
137
|
+
async function verify(username, password){
|
|
138
|
+
const user = getByUsername(String(username ?? '')),
|
|
139
|
+
ok = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
|
|
140
|
+
return user && ok ? toProfile(user) : null;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
// Partial update — only the fields present in `fields` are touched, so a
|
|
@@ -18,9 +18,29 @@ const { RESOURCE_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub
|
|
|
18
18
|
// §5.1 sweeper: runs every sweepIntervalSec, marks resources OUT_OF_SERVICE
|
|
19
19
|
// after missedLimit missed heartbeats and moves any active job to LOST.
|
|
20
20
|
function createHeartbeatMonitor(db, { bus, events, registry, jobs, config }){
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// heartbeat.intervalSec can change while running (dashboard Settings,
|
|
22
|
+
// §13.2). Clients learn the new one from their next heartbeat reply, so
|
|
23
|
+
// right after it's *lowered* they're still on the old, longer one: keep
|
|
24
|
+
// judging by that until every Client has had time to switch over.
|
|
25
|
+
let seenInterval = config.heartbeat.intervalSec,
|
|
26
|
+
graceInterval = 0,
|
|
27
|
+
graceUntil = 0;
|
|
28
|
+
function effectiveInterval(now){
|
|
29
|
+
const current = config.heartbeat.intervalSec;
|
|
30
|
+
if (current !== seenInterval){
|
|
31
|
+
if (current < seenInterval){
|
|
32
|
+
graceInterval = seenInterval;
|
|
33
|
+
graceUntil = now + (config.heartbeat.missedLimit + 1) * seenInterval * 1000;
|
|
34
|
+
}
|
|
35
|
+
seenInterval = current;
|
|
36
|
+
}
|
|
37
|
+
return now < graceUntil ? Math.max(current, graceInterval) : current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sweepOnce(now = Date.now()){
|
|
41
|
+
const { missedLimit } = config.heartbeat,
|
|
42
|
+
intervalSec = effectiveInterval(now),
|
|
43
|
+
cutoff = new Date(now - missedLimit * intervalSec * 1000).toISOString(),
|
|
24
44
|
|
|
25
45
|
stale = db
|
|
26
46
|
.prepare(
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/job-artifacts.js
|
|
3
|
+
* @description Artifacts a finished job reports (metadata only): validation, normalization and size formatting
|
|
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
|
+
// README §7.3. The job's command publishes its files wherever it likes and
|
|
17
|
+
// lists them in $THUB_ARTIFACTS_FILE; the Client sends that list with the
|
|
18
|
+
// result, and the job keeps only this metadata:
|
|
19
|
+
// { name, size, link, timestamp }
|
|
20
|
+
// It comes from the job's own command, so it's treated as untrusted: only
|
|
21
|
+
// http(s) links (a `javascript:` one would run in the dashboard when
|
|
22
|
+
// clicked), bounded sizes, and entries that don't fit are dropped, not fatal.
|
|
23
|
+
const MAX_ARTIFACTS = 500,
|
|
24
|
+
MAX_NAME = 255,
|
|
25
|
+
MAX_LINK = 2048;
|
|
26
|
+
|
|
27
|
+
// Unix seconds, Unix milliseconds or an ISO date -> ISO string (or null).
|
|
28
|
+
function normalizeTimestamp(value){
|
|
29
|
+
let ms = NaN;
|
|
30
|
+
if (typeof value === 'number' && Number.isFinite(value)){
|
|
31
|
+
ms = value < 1e11 ? value * 1000 : value; // 1e11 s is the year 5138: smaller means seconds
|
|
32
|
+
}
|
|
33
|
+
else if (typeof value === 'string' && value.trim()){
|
|
34
|
+
ms = /^\d+(\.\d+)?$/.test(value.trim()) ? normalizeTimestampMs(Number(value)) : Date.parse(value);
|
|
35
|
+
}
|
|
36
|
+
return Number.isFinite(ms) && ms >= 0 && ms < 8.64e15 ? new Date(ms).toISOString() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeTimestampMs(n){
|
|
40
|
+
return n < 1e11 ? n * 1000 : n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeLink(value){
|
|
44
|
+
if (typeof value !== 'string' || value.length > MAX_LINK){
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(value.trim());
|
|
49
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -> { artifacts: [...valid, normalized], dropped: <count> }
|
|
57
|
+
function sanitizeArtifacts(raw){
|
|
58
|
+
if (!Array.isArray(raw)){
|
|
59
|
+
return { artifacts: [], dropped: raw === undefined || raw === null ? 0 : 1 };
|
|
60
|
+
}
|
|
61
|
+
const artifacts = [];
|
|
62
|
+
let dropped = Math.max(0, raw.length - MAX_ARTIFACTS);
|
|
63
|
+
for (const item of raw.slice(0, MAX_ARTIFACTS)){
|
|
64
|
+
const name = typeof item?.name === 'string' ? item.name.trim().slice(0, MAX_NAME) : '',
|
|
65
|
+
link = normalizeLink(item?.link),
|
|
66
|
+
size = Number.isSafeInteger(item?.size) && item.size >= 0 ? item.size : null;
|
|
67
|
+
if (!name || !link){
|
|
68
|
+
dropped += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
artifacts.push({ name, size, link, timestamp: normalizeTimestamp(item.timestamp) });
|
|
72
|
+
}
|
|
73
|
+
return { artifacts, dropped };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 1233 -> "1.2 KB" (1024-based, as file managers show it).
|
|
77
|
+
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
78
|
+
function formatBytes(bytes){
|
|
79
|
+
if (!Number.isFinite(bytes) || bytes < 0){
|
|
80
|
+
return '—';
|
|
81
|
+
}
|
|
82
|
+
let value = bytes,
|
|
83
|
+
unit = 0;
|
|
84
|
+
while (value >= 1024 && unit < UNITS.length - 1){
|
|
85
|
+
value /= 1024;
|
|
86
|
+
unit += 1;
|
|
87
|
+
}
|
|
88
|
+
return unit === 0 ? `${value} B` : `${value < 10 ? value.toFixed(1) : Math.round(value)} ${UNITS[unit]}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { sanitizeArtifacts, normalizeTimestamp, formatBytes, MAX_ARTIFACTS };
|
package/src/services/jobs.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
17
|
{ paginate } = require('./list-prefs'),
|
|
18
18
|
{ parseTerms, likeClause } = require('./search'),
|
|
19
|
+
{ sanitizeArtifacts } = require('./job-artifacts'),
|
|
19
20
|
// Named in spec errors: a field the Agent sends but this thub-common
|
|
20
21
|
// doesn't know means the Coordinator needs an update.
|
|
21
22
|
VERSIONS = `Coordinator v${require('../../package.json').version}, ` +
|
|
@@ -28,7 +29,8 @@ function rowToJob(row){
|
|
|
28
29
|
return {
|
|
29
30
|
...row,
|
|
30
31
|
spec: JSON.parse(row.spec),
|
|
31
|
-
summary: row.summary ? JSON.parse(row.summary) : null
|
|
32
|
+
summary: row.summary ? JSON.parse(row.summary) : null,
|
|
33
|
+
artifacts: row.artifacts ? JSON.parse(row.artifacts) : []
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -365,7 +367,9 @@ function createJobsService(db, { bus, events, registry, config }){
|
|
|
365
367
|
}
|
|
366
368
|
|
|
367
369
|
// Resource: POST /jobs/:id/result — final verdict from the test runner.
|
|
368
|
-
|
|
370
|
+
// `artifacts`: what the job published elsewhere (job-artifacts.js) — kept
|
|
371
|
+
// as metadata, stored before the job turns final so its page shows them.
|
|
372
|
+
function applyResult(id, resourceId, { state, exitCode, summary, artifacts }){
|
|
369
373
|
const job = get(id);
|
|
370
374
|
if (!job || job.resource_id !== resourceId){
|
|
371
375
|
throw Object.assign(new Error('Job not assigned to this resource'), { status: 403 });
|
|
@@ -373,6 +377,13 @@ function createJobsService(db, { bus, events, registry, config }){
|
|
|
373
377
|
if (![JOB_STATES.PASSED, JOB_STATES.FAILED, JOB_STATES.ERROR].includes(state)){
|
|
374
378
|
throw Object.assign(new Error('Invalid result state'), { status: 400 });
|
|
375
379
|
}
|
|
380
|
+
if (artifacts !== undefined){
|
|
381
|
+
const { artifacts: list, dropped } = sanitizeArtifacts(artifacts);
|
|
382
|
+
db.prepare('UPDATE jobs SET artifacts = ? WHERE id = ?').run(list.length ? JSON.stringify(list) : null, id);
|
|
383
|
+
if (dropped){
|
|
384
|
+
events.record('job', id, 'job.artifacts_dropped', { dropped });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
376
387
|
return setState(id, state, { exitCode, summary });
|
|
377
388
|
}
|
|
378
389
|
|
package/src/services/registry.js
CHANGED
|
@@ -696,6 +696,39 @@ function createRegistryService(db, { bus, events }){
|
|
|
696
696
|
return { resource: get(resourceId), ignored };
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
+
// Groups tab (resource card): the Client's group membership. It belongs to
|
|
700
|
+
// the Client's config file (`groups`, which every registration reports),
|
|
701
|
+
// so it goes out as the next config revision — like an Import's top-level
|
|
702
|
+
// fields — for the Client to write there and restart with. The Coordinator
|
|
703
|
+
// applies it at once too, so scheduling follows without waiting for that.
|
|
704
|
+
// Fields of an Import not applied yet are carried along, not lost.
|
|
705
|
+
function setClientGroups(resourceId, groupIds, { by, knownGroupIds } = {}){
|
|
706
|
+
const r = get(resourceId);
|
|
707
|
+
if (!r){
|
|
708
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
709
|
+
}
|
|
710
|
+
const ids = [...new Set(groupIds)],
|
|
711
|
+
unknown = knownGroupIds ? ids.filter((id) => !knownGroupIds.includes(id) && !r.group_ids.includes(id)) : [];
|
|
712
|
+
if (unknown.length){
|
|
713
|
+
throw Object.assign(new Error(`Unknown group: ${unknown.join(', ')}`), { status: 400 });
|
|
714
|
+
}
|
|
715
|
+
// Writing top-level fields needs a Client that reports its config file
|
|
716
|
+
// (the same that Import needs); an older one would drop them.
|
|
717
|
+
const desired = r.config_desired || r.client_config;
|
|
718
|
+
if (!desired || !r.client_config_file){
|
|
719
|
+
throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
|
|
720
|
+
}
|
|
721
|
+
const revision = r.config_revision + 1,
|
|
722
|
+
fields = { ...(pendingImport(r) || {}), groups: ids };
|
|
723
|
+
db.transaction(() => {
|
|
724
|
+
db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?
|
|
725
|
+
WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), resourceId);
|
|
726
|
+
setGroups(resourceId, ids);
|
|
727
|
+
})();
|
|
728
|
+
events.record('resource', resourceId, 'resource.groups_set', { by, revision, groups: ids });
|
|
729
|
+
return get(resourceId);
|
|
730
|
+
}
|
|
731
|
+
|
|
699
732
|
// Export (resource card): the Client's config file as it last reported it
|
|
700
733
|
// (secrets excluded), with what's saved for it but not applied yet — so
|
|
701
734
|
// exporting right after an edit or import gives what the Client will run.
|
|
@@ -796,6 +829,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
796
829
|
setRebootSchedule,
|
|
797
830
|
pendingRebootSchedule,
|
|
798
831
|
setClientConfig,
|
|
832
|
+
setClientGroups,
|
|
799
833
|
pendingConfig,
|
|
800
834
|
importClientConfig,
|
|
801
835
|
exportClientConfig,
|