@andrian.yablonskyy/thub-coordinator 1.0.38 → 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 +26 -5
- package/config.json +5 -6
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/log-viewer.js +188 -56
- package/public/js/settings.js +66 -0
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +28 -22
- package/src/api/resource.js +11 -14
- package/src/api/sse.js +13 -10
- package/src/config.js +33 -10
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- 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 +10 -6
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +104 -0
- package/src/server.js +53 -23
- package/src/services/admin-users.js +32 -19
- package/src/services/cleanup.js +2 -2
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +17 -9
- package/src/services/logs.js +12 -14
- package/src/services/registry.js +34 -0
- package/src/services/retention.js +5 -14
- package/src/services/session-store.js +127 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +230 -21
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- package/test/cleanup.test.js +1 -5
- 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 +150 -0
- package/test/rate-limit.test.js +106 -0
- package/test/scheduler.test.js +10 -12
- package/test/security.test.js +107 -0
- package/test/session.test.js +132 -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 +2 -2
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +18 -7
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +2 -1
- package/views/help/_git.pug +3 -2
- package/views/help/_overview.pug +2 -2
- package/views/help/_troubleshooting.pug +4 -0
- package/views/jobs/list.pug +5 -5
- package/views/jobs/show.pug +33 -7
- 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/log-viewer.pug +14 -0
- package/views/mixins/resource.pug +1 -8
- package/src/services/artifacts.js +0 -149
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/security.test.js
|
|
3
|
+
* @description Tests: security headers on every response, and the same-origin (CSRF) check on the dashboard's POSTs
|
|
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
|
+
{ loadConfig } = require('../src/config'),
|
|
22
|
+
{ buildServices, createApp } = require('../src/server');
|
|
23
|
+
|
|
24
|
+
async function start(t, extra = {}){
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sec-')),
|
|
26
|
+
file = path.join(dir, 'coordinator.json');
|
|
27
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
28
|
+
dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }, ...extra
|
|
29
|
+
}));
|
|
30
|
+
const config = loadConfig(file),
|
|
31
|
+
services = buildServices(config),
|
|
32
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
33
|
+
t.after(() => new Promise((r) => {
|
|
34
|
+
server.closeAllConnections?.();
|
|
35
|
+
server.close(r);
|
|
36
|
+
}));
|
|
37
|
+
await new Promise((r) => server.on('listening', r));
|
|
38
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
39
|
+
return { base: `http://127.0.0.1:${server.address().port}`, services };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('security headers on pages, the API and static files; HSTS only over HTTPS', async (t) => {
|
|
43
|
+
const { base } = await start(t);
|
|
44
|
+
for (const url of ['/login', '/api/v1/resources', '/css/thub.css']){
|
|
45
|
+
const res = await fetch(`${base}${url}`),
|
|
46
|
+
csp = res.headers.get('content-security-policy');
|
|
47
|
+
assert.equal(res.headers.get('x-frame-options'), 'DENY', url);
|
|
48
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff', url);
|
|
49
|
+
assert.equal(res.headers.get('referrer-policy'), 'same-origin', url);
|
|
50
|
+
assert.equal(res.headers.get('x-powered-by'), null, url);
|
|
51
|
+
assert.equal(res.headers.get('strict-transport-security'), null, url); // plain HTTP
|
|
52
|
+
assert.match(csp, /default-src 'self'/);
|
|
53
|
+
assert.match(csp, /script-src 'self' https:\/\/cdn\.jsdelivr\.net(;|$)/); // no 'unsafe-inline' for scripts
|
|
54
|
+
assert.match(csp, /frame-ancestors 'none'/);
|
|
55
|
+
assert.match(csp, /object-src 'none'/);
|
|
56
|
+
}
|
|
57
|
+
// Behind the (loopback, trusted) TLS proxy.
|
|
58
|
+
const tls = await fetch(`${base}/login`, { headers: { 'X-Forwarded-Proto': 'https' } });
|
|
59
|
+
assert.match(tls.headers.get('strict-transport-security'), /^max-age=\d+$/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('same-origin check: POSTs from other sites are refused; own pages, publicUrl and non-browser clients pass', async (t) => {
|
|
63
|
+
const { base, services } = await start(t, { publicUrl: 'http://thub.lab.example' }),
|
|
64
|
+
post = (url, headers = {}) => fetch(`${base}${url}`, {
|
|
65
|
+
method: 'POST', headers, body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
// A forged cross-site form post (login CSRF here; any dashboard action alike).
|
|
69
|
+
evil = await post('/login', { Origin: 'https://evil.example' });
|
|
70
|
+
assert.equal(evil.status, 403);
|
|
71
|
+
assert.match(await evil.text(), /came from https:\/\/evil\.example, not from this dashboard \(http:\/\/thub\.lab\.example\)/);
|
|
72
|
+
assert.equal((await post('/login', { Origin: 'null' })).status, 403); // sandboxed iframe, data: URL
|
|
73
|
+
assert.equal((await post('/login', { Referer: 'https://evil.example/page' })).status, 403); // no Origin: Referer decides
|
|
74
|
+
assert.equal((await post('/login', { Origin: 'http://thub.lab.example:8443' })).status, 403); // another port is another origin
|
|
75
|
+
|
|
76
|
+
// The dashboard's own pages: the address it was opened at, or publicUrl
|
|
77
|
+
// (a reverse proxy that rewrites Host).
|
|
78
|
+
assert.equal((await post('/login', { Origin: base })).status, 302);
|
|
79
|
+
assert.equal((await post('/login', { Origin: 'http://thub.lab.example' })).status, 302);
|
|
80
|
+
assert.equal((await post('/login', { Referer: `${base}/login` })).status, 302);
|
|
81
|
+
// Neither header: not a browser page (curl, a script) — nothing to forge.
|
|
82
|
+
assert.equal((await post('/login')).status, 302);
|
|
83
|
+
|
|
84
|
+
// Reading is never blocked.
|
|
85
|
+
assert.equal((await fetch(`${base}/login`, { headers: { Origin: 'https://evil.example' } })).status, 200);
|
|
86
|
+
|
|
87
|
+
// The session-authenticated admin API is covered too (JSON answer)…
|
|
88
|
+
const admin = await fetch(`${base}/api/v1/admin/jobs/reset-queue`, { method: 'POST', headers: { Origin: 'https://evil.example' } });
|
|
89
|
+
assert.equal(admin.status, 403);
|
|
90
|
+
assert.match((await admin.json()).error, /Blocked/);
|
|
91
|
+
// …the bearer-token Agent API isn't: a browser never attaches that token by itself.
|
|
92
|
+
const { token } = services.agents.create({ name: 'ci', kind: 'ci' }),
|
|
93
|
+
agent = await fetch(`${base}/api/v1/jobs`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { Origin: 'https://evil.example', authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({})
|
|
97
|
+
});
|
|
98
|
+
assert.notEqual(agent.status, 403); // 400 invalid spec — got past the check
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('templates carry no inline event handlers (the CSP would block them)', () => {
|
|
102
|
+
const views = path.join(__dirname, '..', 'views'),
|
|
103
|
+
files = fs.readdirSync(views, { recursive: true }).filter((f) => f.endsWith('.pug'));
|
|
104
|
+
for (const f of files){
|
|
105
|
+
assert.doesNotMatch(fs.readFileSync(path.join(views, f), 'utf8'), /\bon[a-z]+="/, f);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/session.test.js
|
|
3
|
+
* @description Tests: dashboard sessions — the SQLite session store, sessions surviving a restart, and the Secure cookie flag
|
|
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
|
+
{ promisify } = require('node:util'),
|
|
22
|
+
{ openDb } = require('../src/db'),
|
|
23
|
+
{ SqliteSessionStore } = require('../src/services/session-store'),
|
|
24
|
+
{ loadConfig } = require('../src/config'),
|
|
25
|
+
{ buildServices, createApp, cookieSecure } = require('../src/server');
|
|
26
|
+
|
|
27
|
+
test('session store: get/set/touch/destroy, expired sessions dropped and pruned', async () => {
|
|
28
|
+
let now = Date.parse('2026-09-30T12:00:00Z');
|
|
29
|
+
const store = new SqliteSessionStore(openDb(':memory:'), { pruneIntervalMs: 0, now: () => now }),
|
|
30
|
+
get = promisify(store.get.bind(store)),
|
|
31
|
+
set = promisify(store.set.bind(store)),
|
|
32
|
+
touch = promisify(store.touch.bind(store)),
|
|
33
|
+
destroy = promisify(store.destroy.bind(store)),
|
|
34
|
+
length = promisify(store.length.bind(store)),
|
|
35
|
+
sess = (minutes) => ({ cookie: { expires: new Date(now + minutes * 60_000).toISOString() }, user: { id: 'u1' } });
|
|
36
|
+
|
|
37
|
+
await set('a', sess(60));
|
|
38
|
+
await set('b', sess(5));
|
|
39
|
+
assert.deepEqual((await get('a')).user, { id: 'u1' });
|
|
40
|
+
assert.equal(await get('nope'), null);
|
|
41
|
+
assert.equal(await length(), 2);
|
|
42
|
+
|
|
43
|
+
now += 10 * 60_000; // b's 5 minutes are up
|
|
44
|
+
assert.equal(await get('b'), null); // expired: gone on read
|
|
45
|
+
await touch('a', sess(60)); // activity moves a's expiry out
|
|
46
|
+
now += 55 * 60_000;
|
|
47
|
+
assert.ok(await get('a'));
|
|
48
|
+
|
|
49
|
+
await set('c', sess(1));
|
|
50
|
+
now += 2 * 60_000;
|
|
51
|
+
assert.equal(store.prune(), 1); // c, never read again, swept
|
|
52
|
+
assert.equal(await length(), 1);
|
|
53
|
+
|
|
54
|
+
await destroy('a'); // logout
|
|
55
|
+
assert.equal(await get('a'), null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('cookieSecure: Secure for an https publicUrl, per-request otherwise, or as configured', () => {
|
|
59
|
+
assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: 'auto' } }), true);
|
|
60
|
+
assert.equal(cookieSecure({ publicUrl: 'http://localhost:8080', session: { secureCookie: 'auto' } }), 'auto');
|
|
61
|
+
assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: false } }), false);
|
|
62
|
+
assert.equal(cookieSecure({ publicUrl: 'http://lab', session: { secureCookie: true } }), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- over HTTP ------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function writeConfig(dir, extra = {}){
|
|
68
|
+
const file = path.join(dir, 'coordinator.json');
|
|
69
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
70
|
+
dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }, ...extra
|
|
71
|
+
}));
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function start(t, file){
|
|
76
|
+
const config = loadConfig(file),
|
|
77
|
+
services = buildServices(config),
|
|
78
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
79
|
+
await new Promise((r) => server.on('listening', r));
|
|
80
|
+
const stop = () => new Promise((r) => {
|
|
81
|
+
server.closeAllConnections?.();
|
|
82
|
+
server.close(r);
|
|
83
|
+
});
|
|
84
|
+
t.after(stop);
|
|
85
|
+
return { base: `http://127.0.0.1:${server.address().port}`, services, stop };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const login = (base, headers = {}) => fetch(`${base}/login`, {
|
|
89
|
+
method: 'POST', headers, body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a signed-in session survives a Coordinator restart', async (t) => {
|
|
93
|
+
const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'))),
|
|
94
|
+
first = await start(t, file);
|
|
95
|
+
first.services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
96
|
+
const cookie = (await login(first.base)).headers.get('set-cookie').split(';')[0];
|
|
97
|
+
assert.equal((await fetch(`${first.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
|
|
98
|
+
await first.stop();
|
|
99
|
+
|
|
100
|
+
const second = await start(t, file); // same database, new process state
|
|
101
|
+
assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
|
|
102
|
+
|
|
103
|
+
await fetch(`${second.base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
|
|
104
|
+
assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 302);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('https publicUrl: the session cookie is Secure; plain HTTP gets an explanation, not a login loop', async (t) => {
|
|
108
|
+
const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-')), { publicUrl: 'https://thub.example.com' }),
|
|
109
|
+
{ base, services } = await start(t, file);
|
|
110
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
111
|
+
|
|
112
|
+
// Behind the (loopback, trusted) reverse proxy terminating TLS.
|
|
113
|
+
const viaProxy = await login(base, { 'X-Forwarded-Proto': 'https' }),
|
|
114
|
+
setCookie = viaProxy.headers.get('set-cookie');
|
|
115
|
+
assert.equal(viaProxy.status, 302);
|
|
116
|
+
assert.match(setCookie, /; Secure/);
|
|
117
|
+
assert.match(setCookie, /; HttpOnly/);
|
|
118
|
+
assert.match(setCookie, /; SameSite=Lax/);
|
|
119
|
+
|
|
120
|
+
// The proxy forgot X-Forwarded-Proto: no cookie could come back — say so.
|
|
121
|
+
const plain = await login(base);
|
|
122
|
+
assert.equal(plain.status, 400);
|
|
123
|
+
assert.equal(plain.headers.get('set-cookie'), null);
|
|
124
|
+
assert.match(await plain.text(), /Signing in needs HTTPS/);
|
|
125
|
+
assert.match(await (await fetch(`${base}/login`)).text(), /X-Forwarded-Proto: https/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('config: session.secureCookie must be "auto", true or false', () => {
|
|
129
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'));
|
|
130
|
+
assert.equal(loadConfig(writeConfig(dir)).session.secureCookie, 'auto');
|
|
131
|
+
assert.throws(() => loadConfig(writeConfig(dir, { session: { secureCookie: 'yes' } })), /session\.secureCookie must be/);
|
|
132
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/settings.test.js
|
|
3
|
+
* @description Tests: Coordinator settings from the dashboard — precedence, validation, applying now vs after a restart, the page, thub-admin reset
|
|
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
|
+
{ spawnSync } = require('node:child_process'),
|
|
22
|
+
{ loadConfig } = require('../src/config'),
|
|
23
|
+
{ buildServices, createApp } = require('../src/server');
|
|
24
|
+
|
|
25
|
+
function configFile(extra = {}){
|
|
26
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-settings-')),
|
|
27
|
+
file = path.join(dir, 'coordinator.json');
|
|
28
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
29
|
+
dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 },
|
|
30
|
+
publicUrl: 'http://thub.lab', jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 }, ...extra
|
|
31
|
+
}));
|
|
32
|
+
return file;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const byKey = (list) => Object.fromEntries(list.map((s) => [s.key, s]));
|
|
36
|
+
|
|
37
|
+
test('settings: dashboard values override the file, "now" ones apply at once, "restart" ones at the next start', () => {
|
|
38
|
+
const file = configFile(),
|
|
39
|
+
config = loadConfig(file),
|
|
40
|
+
{ settings } = buildServices(config);
|
|
41
|
+
|
|
42
|
+
assert.equal(byKey(settings.list())['jobs.maxTimeoutSec'].source, 'file');
|
|
43
|
+
const result = settings.save(
|
|
44
|
+
{ changes: { 'jobs.maxTimeoutSec': '3600', 'scheduler.tickIntervalSec': '30', 'scheduler.requeueOnLost': 'false' } },
|
|
45
|
+
{ by: 'alice' }
|
|
46
|
+
);
|
|
47
|
+
assert.deepEqual(result.now.sort(), ['jobs.maxTimeoutSec', 'scheduler.requeueOnLost']);
|
|
48
|
+
assert.deepEqual(result.restart, ['scheduler.tickIntervalSec']);
|
|
49
|
+
|
|
50
|
+
// Applied now: every service reads the same config object.
|
|
51
|
+
assert.equal(config.jobs.maxTimeoutSec, 3600);
|
|
52
|
+
assert.equal(config.scheduler.requeueOnLost, false);
|
|
53
|
+
// Not yet: shown as pending.
|
|
54
|
+
assert.equal(config.scheduler.tickIntervalSec, 10);
|
|
55
|
+
const list = byKey(settings.list());
|
|
56
|
+
assert.equal(list['scheduler.tickIntervalSec'].pendingRestart, true);
|
|
57
|
+
assert.equal(list['jobs.maxTimeoutSec'].source, 'dashboard');
|
|
58
|
+
assert.equal(list['jobs.maxTimeoutSec'].updatedBy, 'alice');
|
|
59
|
+
|
|
60
|
+
// The next start (same database) runs with them all.
|
|
61
|
+
const config2 = loadConfig(file),
|
|
62
|
+
{ settings: settings2 } = buildServices(config2);
|
|
63
|
+
assert.equal(config2.scheduler.tickIntervalSec, 30);
|
|
64
|
+
assert.equal(config2.jobs.maxTimeoutSec, 3600);
|
|
65
|
+
assert.equal(byKey(settings2.list())['scheduler.tickIntervalSec'].pendingRestart, false);
|
|
66
|
+
|
|
67
|
+
// Reset: back to the config file's value.
|
|
68
|
+
settings2.save({ reset: ['jobs.maxTimeoutSec'] });
|
|
69
|
+
assert.equal(config2.jobs.maxTimeoutSec, 14400);
|
|
70
|
+
assert.equal(byKey(settings2.list())['jobs.maxTimeoutSec'].source, 'file');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('settings: validated all-or-nothing; env-locked ones can\'t change; bad stored values are skipped at startup', (t) => {
|
|
74
|
+
const file = configFile(),
|
|
75
|
+
config = loadConfig(file),
|
|
76
|
+
{ settings, db } = buildServices(config);
|
|
77
|
+
|
|
78
|
+
assert.throws(() => settings.save({ changes: { 'jobs.maxTimeoutSec': '600', publicUrl: 'not a url' } }), /Public URL: must be a URL/);
|
|
79
|
+
assert.equal(config.jobs.maxTimeoutSec, 14400); // nothing saved
|
|
80
|
+
assert.throws(() => settings.save({ changes: { 'jobs.defaultTimeoutSec': '7200', 'jobs.maxTimeoutSec': '3600' } }), /longer than the maximum/);
|
|
81
|
+
assert.throws(() => settings.save({ changes: { clientJoinKey: 'short' } }), /at least 16 characters/);
|
|
82
|
+
assert.throws(() => settings.save({ changes: { 'retention.jobRetention': '5y' } }), /must be one of/);
|
|
83
|
+
settings.save({ changes: { publicUrl: 'https://thub.example.com/', clientJoinKey: 'jk_0123456789abcdef' } });
|
|
84
|
+
assert.equal(config.publicUrl, 'https://thub.example.com'); // trailing slash dropped
|
|
85
|
+
assert.equal(config.clientJoinKey, 'jk_0123456789abcdef');
|
|
86
|
+
settings.save({ changes: { clientJoinKey: '' } }); // cleared: registration off
|
|
87
|
+
assert.equal(config.clientJoinKey, null);
|
|
88
|
+
|
|
89
|
+
// A value an older version stored that this one rejects: ignored, warned.
|
|
90
|
+
db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)').run('retention.jobRetention', '"5y"', 'x');
|
|
91
|
+
const warn = t.mock.method(console, 'warn', () => {}),
|
|
92
|
+
config2 = loadConfig(file);
|
|
93
|
+
buildServices(config2);
|
|
94
|
+
assert.equal(config2.retention.jobRetention, 'forever');
|
|
95
|
+
assert.match(warn.mock.calls[0].arguments[0], /Ignoring dashboard setting retention\.jobRetention/);
|
|
96
|
+
|
|
97
|
+
// The environment wins, and the dashboard can't override it.
|
|
98
|
+
const prev = process.env.THUB_PUBLIC_URL;
|
|
99
|
+
process.env.THUB_PUBLIC_URL = 'https://from-env.example';
|
|
100
|
+
t.after(() => (prev === undefined ? delete process.env.THUB_PUBLIC_URL : (process.env.THUB_PUBLIC_URL = prev)));
|
|
101
|
+
const config3 = loadConfig(file),
|
|
102
|
+
{ settings: settings3 } = buildServices(config3);
|
|
103
|
+
assert.equal(config3.publicUrl, 'https://from-env.example');
|
|
104
|
+
assert.equal(byKey(settings3.list()).publicUrl.source, 'env');
|
|
105
|
+
assert.deepEqual(settings3.save({ changes: { publicUrl: 'https://other.example' } }).changed, []);
|
|
106
|
+
assert.equal(config3.publicUrl, 'https://from-env.example');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('settings page: admins only; saves; refuses a setting that would lock everyone out', async (t) => {
|
|
110
|
+
const config = loadConfig(configFile()),
|
|
111
|
+
services = buildServices(config),
|
|
112
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
113
|
+
t.after(() => new Promise((r) => {
|
|
114
|
+
server.closeAllConnections?.();
|
|
115
|
+
server.close(r);
|
|
116
|
+
}));
|
|
117
|
+
await new Promise((r) => server.on('listening', r));
|
|
118
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
119
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
120
|
+
services.adminUsers.create({ username: 'view', password: 'pw', role: 'viewer' });
|
|
121
|
+
const login = async (u) => (await fetch(`${base}/login`, {
|
|
122
|
+
method: 'POST', body: new URLSearchParams({ username: u, password: 'pw' }), redirect: 'manual'
|
|
123
|
+
})).headers.get('set-cookie').split(';')[0],
|
|
124
|
+
admin = await login('admin'),
|
|
125
|
+
viewer = await login('view'),
|
|
126
|
+
post = (body) => fetch(`${base}/admin/settings`, { method: 'POST', headers: { cookie: admin }, body: new URLSearchParams(body), redirect: 'manual' }),
|
|
127
|
+
page = async () => (await fetch(`${base}/admin/settings`, { headers: { cookie: admin } })).text();
|
|
128
|
+
|
|
129
|
+
assert.equal((await fetch(`${base}/admin/settings`, { headers: { cookie: viewer } })).status, 403);
|
|
130
|
+
const html = await page();
|
|
131
|
+
assert.match(html, /name="s\[jobs\.maxTimeoutSec\]"/);
|
|
132
|
+
assert.match(html, /href="\/admin\/settings"/); // navbar link
|
|
133
|
+
assert.doesNotMatch(html, /test-secret-test-secret/); // secrets never in the page
|
|
134
|
+
|
|
135
|
+
// A checkbox posts "false" then (checked) "true"; the last one wins.
|
|
136
|
+
await post([['s[jobs.maxTimeoutSec]', '7200'], ['s[scheduler.requeueOnLost]', 'false']]);
|
|
137
|
+
assert.equal(config.jobs.maxTimeoutSec, 7200);
|
|
138
|
+
assert.equal(config.scheduler.requeueOnLost, false);
|
|
139
|
+
assert.match(await page(), /Saved and applied: Maximum job timeout \(s\), Retry lost jobs\./);
|
|
140
|
+
|
|
141
|
+
// An empty secret field leaves the key alone; "clear" removes it.
|
|
142
|
+
await post({ 's[clientJoinKey]': 'jk_0123456789abcdef' });
|
|
143
|
+
await post({ 's[clientJoinKey]': '' });
|
|
144
|
+
assert.equal(config.clientJoinKey, 'jk_0123456789abcdef');
|
|
145
|
+
await post({ 'clear[clientJoinKey]': '1' });
|
|
146
|
+
assert.equal(config.clientJoinKey, null);
|
|
147
|
+
|
|
148
|
+
// Over plain HTTP, an HTTPS-only cookie would lock everyone out: refused.
|
|
149
|
+
await post({ 's[session.secureCookie]': 'true' });
|
|
150
|
+
assert.match(await page(), /you're connected over plain HTTP|you're connected over plain HTTP/);
|
|
151
|
+
assert.equal(services.settings.list().find((s) => s.key === 'session.secureCookie').source, 'file');
|
|
152
|
+
|
|
153
|
+
// Not under systemd: no restart.
|
|
154
|
+
assert.equal((await fetch(`${base}/admin/settings/restart`, { method: 'POST', headers: { cookie: admin }, redirect: 'manual' })).status, 302);
|
|
155
|
+
assert.match(await page(), /isn't running under systemd|isn't running under systemd/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('thub-admin settings reset: the way back from a bad setting', () => {
|
|
159
|
+
const file = configFile(),
|
|
160
|
+
config = loadConfig(file),
|
|
161
|
+
{ settings } = buildServices(config);
|
|
162
|
+
settings.save({ changes: { 'jobs.maxTimeoutSec': '3600', 'scheduler.maxQueuedPerAgent': '5' } });
|
|
163
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'thub-admin.js'), 'settings', ...args], {
|
|
164
|
+
env: { ...process.env, THUB_COORDINATOR_CONFIG: file }, encoding: 'utf8'
|
|
165
|
+
});
|
|
166
|
+
assert.match(run('list').stdout, /jobs\.maxTimeoutSec = 3600/);
|
|
167
|
+
assert.match(run('reset', 'jobs.maxTimeoutSec').stdout, /Removed 1 dashboard setting/);
|
|
168
|
+
assert.match(run('reset').stdout, /Removed 1 dashboard setting/);
|
|
169
|
+
assert.match(run('list').stdout, /No settings changed from the dashboard/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('heartbeat interval from Settings: sent in every reply; lowering it gives Clients time to switch', async (t) => {
|
|
173
|
+
const config = loadConfig(configFile()),
|
|
174
|
+
services = buildServices(config),
|
|
175
|
+
{ settings, registry, heartbeatMonitor, db } = services,
|
|
176
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
177
|
+
t.after(() => new Promise((r) => {
|
|
178
|
+
server.closeAllConnections?.();
|
|
179
|
+
server.close(r);
|
|
180
|
+
}));
|
|
181
|
+
await new Promise((r) => server.on('listening', r));
|
|
182
|
+
const { resourceId, resourceToken } = registry.registerAuto({ clientId: 'c1', name: 'lab-1', type: 'sw', labels: [] }),
|
|
183
|
+
beat = () => fetch(`http://127.0.0.1:${server.address().port}/api/v1/resources/${resourceId}/heartbeat`, {
|
|
184
|
+
method: 'POST', headers: { authorization: `Bearer ${resourceToken}`, 'content-type': 'application/json' }, body: JSON.stringify({ state: 'idle' })
|
|
185
|
+
}).then((r) => r.json()),
|
|
186
|
+
status = () => registry.get(resourceId).status;
|
|
187
|
+
|
|
188
|
+
assert.equal((await beat()).heartbeatIntervalSec, 10);
|
|
189
|
+
settings.save({ changes: { 'heartbeat.intervalSec': '5' } });
|
|
190
|
+
assert.equal((await beat()).heartbeatIntervalSec, 5); // the next reply carries it
|
|
191
|
+
|
|
192
|
+
// Last heard from 25 s ago, still on the old 10 s interval: within 3 × 10 s.
|
|
193
|
+
const now = Date.now(),
|
|
194
|
+
lastBeat = (ageSec) => db.prepare('UPDATE resources SET last_heartbeat_at = ? WHERE id = ?').run(new Date(now - ageSec * 1000).toISOString(), resourceId);
|
|
195
|
+
lastBeat(25);
|
|
196
|
+
heartbeatMonitor.sweepOnce(now); // 3 × 5 s alone would call it gone
|
|
197
|
+
assert.notEqual(status(), 'OUT_OF_SERVICE');
|
|
198
|
+
// Once every Client has had (missedLimit + 1) old intervals to switch, the new one counts.
|
|
199
|
+
heartbeatMonitor.sweepOnce(now + 41_000);
|
|
200
|
+
assert.equal(status(), 'OUT_OF_SERVICE');
|
|
201
|
+
|
|
202
|
+
// missedLimit applies at once, too.
|
|
203
|
+
settings.save({ changes: { 'heartbeat.missedLimit': '6', 'heartbeat.sweepIntervalSec': '2' } });
|
|
204
|
+
assert.equal(config.heartbeat.missedLimit, 6);
|
|
205
|
+
assert.equal(config.heartbeat.sweepIntervalSec, 5); // a timer: after a restart
|
|
206
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
extends ../layout
|
|
2
|
+
|
|
3
|
+
//- One setting's input (services/settings.js SETTINGS: type text | number
|
|
4
|
+
//- | checkbox | select | secret). Names: s[<key>]; reset[] / clear[<key>].
|
|
5
|
+
mixin settingInput(s, locked)
|
|
6
|
+
- const id = `setting-${s.key.replace(/\./g, '-')}`
|
|
7
|
+
if s.type === 'checkbox'
|
|
8
|
+
.form-check.form-switch
|
|
9
|
+
input(type="hidden" name=`s[${s.key}]` value="false" disabled=locked)
|
|
10
|
+
input.form-check-input(id=id type="checkbox" name=`s[${s.key}]` value="true" checked=s.value === true disabled=locked)
|
|
11
|
+
label.form-check-label.visually-hidden(for=id)= s.label
|
|
12
|
+
else if s.type === 'select'
|
|
13
|
+
select.form-select.form-select-sm(id=id name=`s[${s.key}]` disabled=locked)
|
|
14
|
+
each o in s.options
|
|
15
|
+
option(value=o selected=String(s.value) === String(o))= s.labels && s.labels[o] ? `${o} — ${s.labels[o]}` : o
|
|
16
|
+
else if s.type === 'secret'
|
|
17
|
+
.input-group.input-group-sm
|
|
18
|
+
input.form-control(
|
|
19
|
+
id=id
|
|
20
|
+
type="password"
|
|
21
|
+
name=`s[${s.key}]`
|
|
22
|
+
autocomplete="new-password"
|
|
23
|
+
placeholder=s.value ? `set (…${String(s.value).slice(-4)}) — type to replace` : 'not set — registration is off'
|
|
24
|
+
disabled=locked
|
|
25
|
+
data-secret-input
|
|
26
|
+
)
|
|
27
|
+
button.btn.btn-outline-secondary(type="button" data-secret-generate=`#${id}` disabled=locked) Generate
|
|
28
|
+
button.btn.btn-outline-secondary(type="button" data-secret-show=`#${id}` disabled=locked aria-label="Show")
|
|
29
|
+
i.bi.bi-eye
|
|
30
|
+
if s.value && !locked
|
|
31
|
+
.form-check.mt-1
|
|
32
|
+
input.form-check-input(id=`${id}-clear` type="checkbox" name=`clear[${s.key}]` value="1")
|
|
33
|
+
label.form-check-label.small(for=`${id}-clear`) Clear it (turns self-registration off)
|
|
34
|
+
else
|
|
35
|
+
input.form-control.form-control-sm(
|
|
36
|
+
id=id
|
|
37
|
+
type=s.type === 'number' ? 'number' : 'text'
|
|
38
|
+
name=`s[${s.key}]`
|
|
39
|
+
value=s.value === null || s.value === undefined ? '' : String(s.value)
|
|
40
|
+
disabled=locked
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
block content
|
|
44
|
+
.d-flex.justify-content-between.align-items-center.mb-2
|
|
45
|
+
h1.h4.mb-0 Settings
|
|
46
|
+
p.text-body-secondary.small.
|
|
47
|
+
Coordinator options, saved in its database on top of the config file (#[code= readOnly.configPath]).
|
|
48
|
+
An environment variable still wins over both. Options marked #[span.badge.text-bg-secondary after restart]
|
|
49
|
+
are read only at startup.
|
|
50
|
+
|
|
51
|
+
if restarting
|
|
52
|
+
.alert.alert-info.d-flex.align-items-center.gap-2(data-restarting=restarting role="status")
|
|
53
|
+
span.spinner-border.spinner-border-sm(aria-hidden="true")
|
|
54
|
+
span Restarting the Coordinator… this page reloads by itself once it's back.
|
|
55
|
+
else if pendingRestart.length
|
|
56
|
+
.alert.alert-warning.d-flex.flex-wrap.align-items-center.gap-2
|
|
57
|
+
i.bi.bi-arrow-clockwise
|
|
58
|
+
span.me-auto
|
|
59
|
+
| Saved but not active until a restart: #{pendingRestart.map((s) => s.label).join(', ')}.
|
|
60
|
+
if canRestart
|
|
61
|
+
form(
|
|
62
|
+
method="post"
|
|
63
|
+
action="/admin/settings/restart"
|
|
64
|
+
data-confirm="Restart the Coordinator now? The dashboard and API are unavailable for a few seconds; Clients and Agents reconnect by themselves and running jobs carry on."
|
|
65
|
+
data-confirm-title="Restart the Coordinator?"
|
|
66
|
+
data-confirm-ok="Restart now"
|
|
67
|
+
data-confirm-tone="warning"
|
|
68
|
+
)
|
|
69
|
+
button.btn.btn-sm.btn-warning(type="submit") Restart now
|
|
70
|
+
else
|
|
71
|
+
span.small Restart it by hand: #[code sudo systemctl restart thub-coordinator].
|
|
72
|
+
|
|
73
|
+
form(method="post" action="/admin/settings")
|
|
74
|
+
each group in groups
|
|
75
|
+
.card.mb-3
|
|
76
|
+
.card-header.fw-semibold= group.name
|
|
77
|
+
ul.list-group.list-group-flush
|
|
78
|
+
each s in group.items
|
|
79
|
+
- const locked = s.source === 'env'
|
|
80
|
+
li.list-group-item
|
|
81
|
+
.row.g-2.align-items-start
|
|
82
|
+
.col-md-4
|
|
83
|
+
label.form-label.mb-0.fw-medium(for=`setting-${s.key.replace(/\./g, '-')}`)= s.label
|
|
84
|
+
.small.text-body-secondary
|
|
85
|
+
code.small= s.key
|
|
86
|
+
.d-flex.flex-wrap.gap-1.mt-1
|
|
87
|
+
if s.applies === 'now'
|
|
88
|
+
span.badge.text-bg-success applies now
|
|
89
|
+
else
|
|
90
|
+
span.badge.text-bg-secondary after restart
|
|
91
|
+
if s.pendingRestart
|
|
92
|
+
span.badge.text-bg-warning restart pending
|
|
93
|
+
if s.source === 'env'
|
|
94
|
+
span.badge.text-bg-dark(data-bs-toggle="tooltip" data-bs-title=`Set by the environment variable ${s.env}; unset it to change this here`)= `from ${s.env}`
|
|
95
|
+
else if s.source === 'dashboard'
|
|
96
|
+
span.badge.text-bg-info(data-bs-toggle="tooltip" data-bs-title=`Changed ${fmtDate(s.updatedAt)}${s.updatedBy ? ` by ${s.updatedBy}` : ''}`) from dashboard
|
|
97
|
+
else
|
|
98
|
+
span.badge.text-bg-light.border config file / default
|
|
99
|
+
.col-md-5
|
|
100
|
+
+settingInput(s, locked)
|
|
101
|
+
.form-text= s.help
|
|
102
|
+
.col-md-3
|
|
103
|
+
if s.source === 'dashboard'
|
|
104
|
+
.form-check.small
|
|
105
|
+
input.form-check-input(id=`reset-${s.key}` type="checkbox" name="reset" value=s.key)
|
|
106
|
+
label.form-check-label(for=`reset-${s.key}`)
|
|
107
|
+
| Back to the config file's value
|
|
108
|
+
if s.type !== 'secret'
|
|
109
|
+
| (
|
|
110
|
+
code= s.fileValue === null || s.fileValue === undefined ? 'unset' : String(s.fileValue)
|
|
111
|
+
| )
|
|
112
|
+
.d-flex.justify-content-end.gap-2.mb-4
|
|
113
|
+
a.btn.btn-outline-secondary(href="/admin/settings") Discard changes
|
|
114
|
+
button.btn.btn-primary(type="submit") Save settings
|
|
115
|
+
|
|
116
|
+
.card.mb-4
|
|
117
|
+
.card-header.fw-semibold Config file only
|
|
118
|
+
.card-body.small
|
|
119
|
+
p.text-body-secondary.
|
|
120
|
+
These change the address, storage or session signing the dashboard itself runs on, so they're set in the
|
|
121
|
+
config file (then #[code sudo systemctl restart thub-coordinator]).
|
|
122
|
+
dl.row.mb-0
|
|
123
|
+
dt.col-sm-3 listen
|
|
124
|
+
dd.col-sm-9: code= readOnly.listen
|
|
125
|
+
dt.col-sm-3 dataDir
|
|
126
|
+
dd.col-sm-9: code= readOnly.dataDir
|
|
127
|
+
dt.col-sm-3 sessionSecret
|
|
128
|
+
dd.col-sm-9 set (never shown)
|
|
129
|
+
p.small.text-body-secondary.mt-3.mb-0
|
|
130
|
+
| Locked out by a setting? On the Coordinator host: #[code thub-admin settings reset], then restart it.
|
|
131
|
+
|
|
132
|
+
block scripts
|
|
133
|
+
script(src="/js/settings.js")
|
package/views/groups/list.pug
CHANGED
|
@@ -9,7 +9,7 @@ block content
|
|
|
9
9
|
|
|
10
10
|
p.text-body-secondary.small: i.
|
|
11
11
|
A job can be constrained to run only on resources in a group with `thub run --group <id>` (§13.1).
|
|
12
|
-
|
|
12
|
+
Which groups a Client is in is set on its resource card (Groups tab, admins), or as `groups` in the Client's own config — the tab writes it there.
|
|
13
13
|
|
|
14
14
|
if totalCount
|
|
15
15
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-2
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
h3.h6 Commands
|
|
3
3
|
+code('thub --help').
|
|
4
4
|
thub run [options] Submit a job and follow its log
|
|
5
|
-
thub status <jobId> [--json] Status; follow the log if running,
|
|
5
|
+
thub status <jobId> [--json] Status; follow the log if running, verdict and test counts if done
|
|
6
6
|
thub cancel <jobId> Cancel a job
|
|
7
7
|
thub resources [--json] Clients and their status
|
|
8
|
-
thub jobs [--mine] [--state <s>] [--json] Recent jobs
|
|
8
|
+
thub jobs [--mine] [--state <s>] [--json] Recent jobs (a cli token: only its own)
|
|
9
9
|
thub config set <url|token|group|user> <value>
|
|
10
10
|
thub check-update Compare with the latest published Agent
|
|
11
11
|
thub self-update [--to <x.y.z>] Update with npm i -g
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
h3.h6 Get a token
|
|
14
14
|
p.small.
|
|
15
15
|
An admin registers an agent on #[a(href="/admin/agents") Agents] (or with #[code thub-admin agent add]) and passes you
|
|
16
|
-
its token, which is shown only once. The agent's #[strong kind] decides the job id prefix
|
|
16
|
+
its token, which is shown only once. The agent's #[strong kind] decides the job id prefix, the default priority and which jobs it can see:
|
|
17
17
|
table.table.table-sm.small.w-auto
|
|
18
18
|
thead
|
|
19
19
|
tr
|
|
@@ -21,17 +21,20 @@
|
|
|
21
21
|
th For
|
|
22
22
|
th Job ids
|
|
23
23
|
th Default priority
|
|
24
|
+
th Can see
|
|
24
25
|
tbody
|
|
25
26
|
tr
|
|
26
27
|
td: code ci
|
|
27
28
|
td Pipelines
|
|
28
29
|
td: code A-00001…
|
|
29
30
|
td 50
|
|
31
|
+
td every job (details, log)
|
|
30
32
|
tr
|
|
31
33
|
td: code cli
|
|
32
34
|
td Developers
|
|
33
35
|
td: code M-00001…
|
|
34
36
|
td 60 (so a busy pipeline doesn't starve them)
|
|
37
|
+
td only the jobs it submitted
|
|
35
38
|
|
|
36
39
|
h3.h6 Configure
|
|
37
40
|
+code('Save the settings once').
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
tr
|
|
84
84
|
td: code groups
|
|
85
85
|
td No
|
|
86
|
-
td Group ids (from #[a(href="/groups") Groups]) this Client belongs to, for #[code thub run --group].
|
|
86
|
+
td Group ids (from #[a(href="/groups") Groups]) this Client belongs to, for #[code thub run --group]. Admins can also set them on the resource card (Groups tab), which writes them here.
|
|
87
87
|
tr
|
|
88
88
|
td: code varDir
|
|
89
89
|
td No
|