@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.39
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 +3 -5
- package/config.json +1 -6
- package/package.json +1 -1
- package/public/js/log-viewer.js +188 -56
- 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 +4 -10
- package/src/api/resource.js +6 -11
- package/src/api/sse.js +13 -10
- package/src/config.js +16 -9
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/dev/virtual.js +1 -1
- package/src/server.js +37 -21
- package/src/services/cleanup.js +2 -2
- package/src/services/jobs.js +4 -7
- package/src/services/logs.js +12 -14
- package/src/services/retention.js +5 -14
- package/src/services/session-store.js +127 -0
- package/src/web/routes.js +75 -13
- package/test/cleanup.test.js +1 -5
- package/test/logs.test.js +150 -0
- package/test/scheduler.test.js +10 -12
- package/test/session.test.js +132 -0
- package/views/help/_agent-cli.pug +1 -1
- package/views/help/_coordinator.pug +9 -6
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +1 -1
- package/views/help/_git.pug +3 -2
- package/views/help/_overview.pug +2 -2
- package/views/help/_troubleshooting.pug +2 -0
- package/views/jobs/list.pug +2 -2
- package/views/jobs/show.pug +0 -10
- package/views/mixins/log-viewer.pug +14 -0
- package/src/services/artifacts.js +0 -149
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/logs.test.js
|
|
3
|
+
* @description Tests: job log paging for the dashboard viewer, the full SSE replay, and the raw log (RAW button)
|
|
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
|
+
// A Coordinator on a random port, an admin session, and one job with `n`
|
|
25
|
+
// log lines ("line 1".."line n").
|
|
26
|
+
async function setup(t, n){
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-logs-')),
|
|
28
|
+
file = path.join(dir, 'coordinator.json');
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
30
|
+
dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }
|
|
31
|
+
}));
|
|
32
|
+
const config = loadConfig(file),
|
|
33
|
+
services = buildServices(config),
|
|
34
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
35
|
+
t.after(() => new Promise((r) => {
|
|
36
|
+
server.closeAllConnections?.();
|
|
37
|
+
server.close(r);
|
|
38
|
+
}));
|
|
39
|
+
await new Promise((r) => server.on('listening', r));
|
|
40
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
41
|
+
|
|
42
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
43
|
+
const login = await fetch(`${base}/login`, {
|
|
44
|
+
method: 'POST', body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
|
|
45
|
+
}),
|
|
46
|
+
cookie = login.headers.get('set-cookie').split(';')[0],
|
|
47
|
+
{ agent } = services.agents.create({ name: 'ci', kind: 'ci' });
|
|
48
|
+
services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
49
|
+
const job = services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './t.sh' } });
|
|
50
|
+
for (let i = 0; i < n; i += 500){
|
|
51
|
+
services.logs.appendBatch(job.id, Array.from({ length: Math.min(500, n - i) }, (_, k) => ({ stream: 'runner', line: `line ${i + k + 1}` })));
|
|
52
|
+
}
|
|
53
|
+
const finish = () => services.db.prepare('UPDATE jobs SET state = ?, finished_at = ? WHERE id = ?').run('PASSED', new Date().toISOString(), job.id);
|
|
54
|
+
return { base, cookie, services, job, finish, get: (p) => fetch(`${base}${p}`, { headers: { cookie } }) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('log pages: the end first, then earlier pages until the start', async (t) => {
|
|
58
|
+
const { get, job } = await setup(t, 2500),
|
|
59
|
+
last = await (await get(`/jobs/${job.id}/logs?limit=1000`)).json();
|
|
60
|
+
assert.equal(last.total, 2500);
|
|
61
|
+
assert.equal(last.hasMore, true);
|
|
62
|
+
assert.equal(last.lines.length, 1000);
|
|
63
|
+
assert.equal(last.lines[0].line, 'line 1501');
|
|
64
|
+
assert.equal(last.lines.at(-1).line, 'line 2500');
|
|
65
|
+
|
|
66
|
+
const earlier = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${last.lines[0].seq}`)).json();
|
|
67
|
+
assert.deepEqual([earlier.lines[0].line, earlier.lines.at(-1).line, earlier.hasMore], ['line 501', 'line 1500', true]);
|
|
68
|
+
|
|
69
|
+
const first = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${earlier.lines[0].seq}`)).json();
|
|
70
|
+
assert.deepEqual([first.lines.length, first.lines[0].line, first.hasMore], [500, 'line 1', false]);
|
|
71
|
+
|
|
72
|
+
// Limits are clamped: never more than 5000 a request.
|
|
73
|
+
assert.equal((await (await get(`/jobs/${job.id}/logs?limit=999999`)).json()).lines.length, 2500);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('log stream replays every line, not just the first 500', async (t) => {
|
|
77
|
+
const { get, job, finish } = await setup(t, 1234);
|
|
78
|
+
finish();
|
|
79
|
+
const text = await (await get(`/jobs/${job.id}/stream`)).text(),
|
|
80
|
+
lines = [...text.matchAll(/^event: log$/gm)].length;
|
|
81
|
+
assert.equal(lines, 1234);
|
|
82
|
+
assert.match(text, /"line":"line 1234"/);
|
|
83
|
+
assert.match(text, /event: end/);
|
|
84
|
+
|
|
85
|
+
// Resuming after a seq replays only what follows it.
|
|
86
|
+
const resumed = await (await get(`/jobs/${job.id}/stream?after=1200`)).text();
|
|
87
|
+
assert.equal([...resumed.matchAll(/^event: log$/gm)].length, 34);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('RAW: the whole log as plain text, for as long as the job exists', async (t) => {
|
|
91
|
+
const { get, job, finish, services } = await setup(t, 6001),
|
|
92
|
+
res = await get(`/jobs/${job.id}/log.txt`),
|
|
93
|
+
text = await res.text();
|
|
94
|
+
assert.equal(res.status, 200);
|
|
95
|
+
assert.match(res.headers.get('content-type'), /^text\/plain; charset=utf-8/);
|
|
96
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
97
|
+
assert.match(res.headers.get('content-disposition'), new RegExp(`inline; filename="${job.id}.log"`));
|
|
98
|
+
const rows = text.trimEnd().split('\n');
|
|
99
|
+
assert.equal(rows.length, 6001); // more than one 5000-line page
|
|
100
|
+
assert.match(rows[0], /^\[\d{4}-\d\d-\d\dT[^\]]+\] \[runner\] line 1$/);
|
|
101
|
+
assert.match(rows.at(-1), /\] \[runner\] line 6001$/);
|
|
102
|
+
|
|
103
|
+
// Still complete once the job has finished — no retention of its own
|
|
104
|
+
// (log lines go with the job), and no artifact to fall back to.
|
|
105
|
+
finish();
|
|
106
|
+
assert.equal((await (await get(`/jobs/${job.id}/log.txt`)).text()).trimEnd().split('\n').length, 6001);
|
|
107
|
+
services.db.prepare('DELETE FROM job_logs WHERE job_id = ?').run(job.id);
|
|
108
|
+
assert.equal(await (await get(`/jobs/${job.id}/log.txt`)).text(), '');
|
|
109
|
+
|
|
110
|
+
assert.equal((await get('/jobs/M-99999/log.txt')).status, 404);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('artifacts are gone: old Clients\' uploads are discarded, old Agents get none, the store is removed', async (t) => {
|
|
114
|
+
const { base, services, job } = await setup(t, 3),
|
|
115
|
+
{ resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'c9', name: 'lab-hw-09', type: 'hw', labels: [] }),
|
|
116
|
+
{ token: agentToken } = services.agents.create({ name: 'old-agent', kind: 'cli' });
|
|
117
|
+
services.db.prepare('UPDATE jobs SET resource_id = ? WHERE id = ?').run(resourceId, job.id);
|
|
118
|
+
|
|
119
|
+
// An old Client still uploads its results after the job: accepted (so the
|
|
120
|
+
// job doesn't end in ERROR), stored nowhere.
|
|
121
|
+
const form = new FormData();
|
|
122
|
+
form.append('files', new Blob(['<testsuite tests="1"/>']), 'results.xml');
|
|
123
|
+
const upload = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, {
|
|
124
|
+
method: 'POST', headers: { authorization: `Bearer ${resourceToken}` }, body: form
|
|
125
|
+
});
|
|
126
|
+
assert.equal(upload.status, 201);
|
|
127
|
+
assert.deepEqual(await upload.json(), { artifacts: [] });
|
|
128
|
+
assert.equal(fs.existsSync(path.join(path.dirname(services.db.name), 'artifacts')), false);
|
|
129
|
+
|
|
130
|
+
// An old Agent's `thub status` still asks: none.
|
|
131
|
+
const listed = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, { headers: { authorization: `Bearer ${agentToken}` } });
|
|
132
|
+
assert.deepEqual(await listed.json(), { artifacts: [] });
|
|
133
|
+
|
|
134
|
+
// No table, no download route.
|
|
135
|
+
assert.equal(services.db.prepare('SELECT COUNT(*) AS n FROM sqlite_master WHERE name = ?').get('artifacts').n, 0);
|
|
136
|
+
assert.equal((await fetch(`${base}/artifacts/download/x.y.z`, { redirect: 'manual' })).status, 302); // just the login redirect
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('upgrade: a leftover <dataDir>/artifacts is deleted on startup', (t) => {
|
|
140
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-legacy-')),
|
|
141
|
+
file = path.join(dir, 'coordinator.json'),
|
|
142
|
+
legacy = path.join(dir, 'data', 'artifacts', 'M-00001');
|
|
143
|
+
fs.mkdirSync(legacy, { recursive: true });
|
|
144
|
+
fs.writeFileSync(path.join(legacy, 'console.log'), 'old');
|
|
145
|
+
fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
|
|
146
|
+
const log = t.mock.method(console, 'log', () => {});
|
|
147
|
+
buildServices(loadConfig(file));
|
|
148
|
+
assert.equal(fs.existsSync(path.join(dir, 'data', 'artifacts')), false);
|
|
149
|
+
assert.match(log.mock.calls[0].arguments[0], /no longer stores job artifacts/);
|
|
150
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -25,8 +25,8 @@ const test = require('node:test'),
|
|
|
25
25
|
{ createRegistryService } = require('../src/services/registry'),
|
|
26
26
|
{ createAgentsService } = require('../src/services/agents'),
|
|
27
27
|
{ createGroupsService } = require('../src/services/groups'),
|
|
28
|
-
{ createArtifactsService } = require('../src/services/artifacts'),
|
|
29
28
|
{ createJobsService } = require('../src/services/jobs'),
|
|
29
|
+
{ createLogsService } = require('../src/services/logs'),
|
|
30
30
|
{ createScheduler } = require('../src/services/scheduler'),
|
|
31
31
|
{ createHeartbeatMonitor } = require('../src/services/heartbeat'),
|
|
32
32
|
{ JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
@@ -42,22 +42,19 @@ function buildTestServices(overrides = {}){
|
|
|
42
42
|
registry = createRegistryService(db, { bus, events }),
|
|
43
43
|
agents = createAgentsService(db, { events }),
|
|
44
44
|
groups = createGroupsService(db, { events, registry }),
|
|
45
|
-
artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-test-artifacts-')),
|
|
46
45
|
config = {
|
|
47
46
|
scheduler: { assignAckTimeoutSec: 15, requeueOnLost: true, maxQueuedPerAgent: 20, tickIntervalSec: 3600 },
|
|
48
47
|
jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 },
|
|
49
48
|
heartbeat: { intervalSec: 10, missedLimit: 3, sweepIntervalSec: 3600 },
|
|
50
|
-
artifactsDir,
|
|
51
49
|
sessionSecret: 'test-secret',
|
|
52
50
|
publicUrl: 'http://localhost:8080',
|
|
53
|
-
artifacts: { linkTtlHours: 1 },
|
|
54
51
|
...overrides
|
|
55
52
|
},
|
|
56
|
-
|
|
57
|
-
jobs = createJobsService(db, { bus, events, registry,
|
|
53
|
+
logs = createLogsService(db, { bus }),
|
|
54
|
+
jobs = createJobsService(db, { bus, events, registry, config }),
|
|
58
55
|
scheduler = createScheduler(db, { bus, events, registry, config }),
|
|
59
56
|
heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
|
|
60
|
-
return { db, bus, registry, agents, groups,
|
|
57
|
+
return { db, bus, registry, agents, groups, logs, jobs, scheduler, heartbeatMonitor };
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
|
|
@@ -206,15 +203,16 @@ test('admin resetQueue cancels every active job and leaves finished ones alone',
|
|
|
206
203
|
assert.equal(jobs.get(finished.id).state, JOB_STATES.PASSED); // untouched
|
|
207
204
|
});
|
|
208
205
|
|
|
209
|
-
test('admin cleanHistory deletes finished jobs
|
|
210
|
-
const { registry, agents,
|
|
206
|
+
test('admin cleanHistory deletes finished jobs with their log lines, and leaves active jobs alone', () => {
|
|
207
|
+
const { db, registry, agents, logs, jobs } = buildTestServices(),
|
|
211
208
|
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
212
209
|
registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
213
210
|
|
|
214
211
|
const finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
215
212
|
jobs.setState(finished.id, JOB_STATES.PASSED);
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
logs.appendBatch(finished.id, [{ stream: 'runner', line: 'hello' }]);
|
|
214
|
+
const logLines = (id) => db.prepare('SELECT COUNT(*) AS n FROM job_logs WHERE job_id = ?').get(id).n;
|
|
215
|
+
assert.equal(logLines(finished.id), 1);
|
|
218
216
|
|
|
219
217
|
const active = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
|
|
220
218
|
|
|
@@ -222,7 +220,7 @@ test('admin cleanHistory deletes finished jobs, their artifacts on disk, and lea
|
|
|
222
220
|
|
|
223
221
|
assert.equal(deleted, 1);
|
|
224
222
|
assert.equal(jobs.get(finished.id), undefined);
|
|
225
|
-
assert.equal(
|
|
223
|
+
assert.equal(logLines(finished.id), 0);
|
|
226
224
|
assert.ok(jobs.get(active.id)); // active job untouched
|
|
227
225
|
});
|
|
228
226
|
|
|
@@ -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
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
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
8
|
thub jobs [--mine] [--state <s>] [--json] Recent jobs
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
p.small.
|
|
12
12
|
#[code sudo npm i -g] does the whole setup for the user who ran #[code sudo]. It creates
|
|
13
13
|
#[code ~/.config/thub/coordinator.json] (with a random #[code sessionSecret]) and
|
|
14
|
-
#[code ~/var/lib/thub] (database,
|
|
14
|
+
#[code ~/var/lib/thub] (database, avatars). It installs, enables and starts
|
|
15
15
|
#[code thub-coordinator.service], plus the root helper behind the navbar's #[strong Update app] button.
|
|
16
16
|
|
|
17
17
|
h3.h6 2. Configure
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"trustProxy": "loopback",
|
|
26
26
|
"dataDir": "/home/thub/var/lib/thub",
|
|
27
27
|
"sessionSecret": "<long random string>",
|
|
28
|
+
"session": { "secureCookie": "auto" },
|
|
28
29
|
"clientJoinKey": "<output of: thub-admin join-key generate>",
|
|
29
30
|
"heartbeat": { "intervalSec": 10, "missedLimit": 3, "sweepIntervalSec": 5 },
|
|
30
31
|
"scheduler": { "assignAckTimeoutSec": 15, "requeueOnLost": true, "maxQueuedPerAgent": 20, "tickIntervalSec": 10 },
|
|
31
32
|
"jobs": { "defaultTimeoutSec": 1800, "maxTimeoutSec": 14400 },
|
|
32
|
-
"retention": { "
|
|
33
|
-
"artifacts": { "maxUploadMb": 512, "linkTtlHours": 168 },
|
|
33
|
+
"retention": { "jobRetention": "forever" },
|
|
34
34
|
"updates": { "checkIntervalMin": 15, "registry": null }
|
|
35
35
|
}
|
|
36
36
|
.table-responsive
|
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
td Which proxies' #[code X-Forwarded-For] to trust for a Client's external address: #[code loopback] (default), a CIDR such as #[code "10.0.0.0/8"], a hop count, or #[code false].
|
|
52
52
|
tr
|
|
53
53
|
td: code sessionSecret
|
|
54
|
-
td Signs dashboard
|
|
54
|
+
td Signs dashboard session cookies. Keep it secret and never use the #[code change-me-…] placeholder.
|
|
55
|
+
tr
|
|
56
|
+
td: code session.secureCookie
|
|
57
|
+
td #[code "auto"] (default): the session cookie is Secure (HTTPS-only) whenever #[code publicUrl] is https://. #[code true]/#[code false] force it. Sessions are stored in the database and survive restarts.
|
|
55
58
|
tr
|
|
56
59
|
td: code clientJoinKey
|
|
57
60
|
td The shared secret Clients self-register with. Unset or #[code null] turns registration off (#[code 503]).
|
|
@@ -66,7 +69,7 @@
|
|
|
66
69
|
td Default and maximum job timeout, in seconds.
|
|
67
70
|
tr
|
|
68
71
|
td: code retention.*
|
|
69
|
-
td #[code jobRetention]: #[code 1w], #[code 2w], #[code 1m], #[code 3m], #[code 6m] or #[code forever]. Older finished jobs
|
|
72
|
+
td #[code jobRetention]: #[code 1w], #[code 2w], #[code 1m], #[code 3m], #[code 6m] or #[code forever]. Older finished jobs and their logs are deleted hourly.
|
|
70
73
|
tr
|
|
71
74
|
td: code updates.*
|
|
72
75
|
td How often to check npm for new versions (#[code 0] turns the periodic check off), and an optional private registry.
|
|
@@ -96,7 +99,7 @@
|
|
|
96
99
|
proxy_http_version 1.1;
|
|
97
100
|
proxy_buffering off; # SSE log streams
|
|
98
101
|
proxy_read_timeout 1h;
|
|
99
|
-
client_max_body_size
|
|
102
|
+
client_max_body_size 10m; # log batches, avatars
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
h3.h6 4. Users, agents and the join key
|
package/views/help/_docker.pug
CHANGED
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
td Removes the container when the tests end.
|
|
107
107
|
tr
|
|
108
108
|
td: code -v "$THUB_WORK_DIR:/work" -w /work
|
|
109
|
-
td The checkout and downloads, and the #[code results/] and #[code artifacts/] folders,
|
|
109
|
+
td The checkout and downloads, and the #[code results/] and #[code artifacts/] folders, where the Client reads JUnit XML for the test counts.
|
|
110
110
|
tr
|
|
111
111
|
td: code -e NAME
|
|
112
112
|
td Passes a job variable (#[code --env], #[code THUB_*], #[code JOB_*]) into the container.
|
package/views/help/_env.pug
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
-
|
|
10
10
|
const thubVars = [
|
|
11
11
|
['THUB_JOB_ID', 'The job id, e.g. M-00125'],
|
|
12
|
-
['THUB_WORK_DIR', 'The directory the command runs in: the --git-repo checkout, else empty. results/
|
|
12
|
+
['THUB_WORK_DIR', 'The directory the command runs in: the --git-repo checkout, else empty. JUnit XML in results/ or artifacts/ gives the test counts; deleted when the job ends'],
|
|
13
13
|
['THUB_SUITE', '--suite (default: default)'],
|
|
14
14
|
['THUB_GIT_COMMIT', 'The exact commit checked out for --git-repo'],
|
|
15
15
|
['THUB_DOWNLOADS_DIR', 'Where the --download-file files are'],
|
package/views/help/_git.pug
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
git remote add origin git@github.com:yourorg/firmware-tests.git
|
|
21
21
|
git push -u origin main
|
|
22
22
|
p.small.
|
|
23
|
-
The exit code of #[code --command] is the verdict. JUnit files under #[code results/] or #[code artifacts/] are
|
|
24
|
-
|
|
23
|
+
The exit code of #[code --command] is the verdict. JUnit files under #[code results/] or #[code artifacts/] are summed into
|
|
24
|
+
the job's test counts. Nothing is uploaded: the workspace is deleted when the job ends, so publish anything you need to keep
|
|
25
|
+
from the command itself (e.g. #[code curl -T report.html "$ARTIFACTORY/…"] with a token passed as #[code --env]).
|
|
25
26
|
|
|
26
27
|
h3.h6 2. Give Clients read access: SSH deploy key (recommended)
|
|
27
28
|
p.small.
|
package/views/help/_overview.pug
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| Coordinator
|
|
14
14
|
p.card-text.small.mb-0.
|
|
15
15
|
This service. It holds the job queue and the resource registry, and runs the scheduler and
|
|
16
|
-
the heartbeat monitor. It stores
|
|
16
|
+
the heartbeat monitor. It stores job state and logs in SQLite,
|
|
17
17
|
and serves this dashboard. It's the only thing that needs a public HTTPS port.
|
|
18
18
|
.col-md-4
|
|
19
19
|
.card.h-100
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
li The Coordinator queues it (#[code QUEUED]) and the scheduler assigns it to an idle Client whose type, labels and group match.
|
|
42
42
|
li The Client accepts it, clones #[code --git-repo], downloads #[code --download-file] files and prepares the DUT (#[code PREPARING]).
|
|
43
43
|
li It runs #[code --command] with #[code sh -c], and output streams live to the Agent and the dashboard (#[code RUNNING]).
|
|
44
|
-
li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. JUnit files and
|
|
44
|
+
li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. The Client reports the JUnit test counts; the job's files stay on the Client and are deleted with its workspace.
|
|
45
45
|
li The Agent exits with the verdict code, so a CI step passes or fails with it.
|
|
46
46
|
|
|
47
47
|
h3.h6 Key design rules
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
['Live logs don\'t stream through the proxy', 'Turn off response buffering for the Coordinator (nginx: `proxy_buffering off;`, Caddy: `flush_interval -1`).'],
|
|
17
17
|
['Finding a job, resource, group or agent', 'Use the search box on its list page (press `/` to jump to it). Every word must match, e.g. `lab-hw-01 FAILED` or a git ref. Job search covers ids, users, resources, agents, commands, repos and `--meta`, but not `--env` values (secrets). On a job page, search the log above the log viewer: `Enter`/`Shift+Enter` step through matches.'],
|
|
18
18
|
['Lists don\'t update by themselves (navbar shows Offline or Paused)', '`Paused`: you\'re editing something on the page (a field, the inline rename, a dialog); it resumes when you\'re done. `Offline`: the browser can\'t hold the `/live` stream open, often a proxy buffering it (nginx: `proxy_buffering off;`); reloading the page always shows current data. `Session ended`: sign in again. Background updates never extend the session\'s idle timeout.'],
|
|
19
|
+
['Sign-in says it needs HTTPS', 'The session cookie is HTTPS-only because `publicUrl` is `https://`, but the request arrived as plain HTTP. Open the dashboard through its https:// address; behind a reverse proxy, make it send `X-Forwarded-Proto: https` (nginx: `proxy_set_header X-Forwarded-Proto $scheme;`) and check `trustProxy`. To allow plain HTTP, set `session.secureCookie` to `false`.'],
|
|
20
|
+
['Where are a job\'s test reports and output files?', 'The Coordinator keeps only the log and the result (verdict, exit code, JUnit test counts). Files the job creates stay in its workspace on the Client and are deleted when the job ends. To keep them, publish them from `--command`, e.g. `curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/report.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/report.xml"` with `--env ART_TOKEN`. The full log is always available from the job page\'s Raw button.'],
|
|
19
21
|
['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
|
|
20
22
|
]
|
|
21
23
|
const withCode = (text) => text
|
package/views/jobs/list.pug
CHANGED
|
@@ -87,7 +87,7 @@ block content
|
|
|
87
87
|
|
|
|
88
88
|
code CANCELED
|
|
89
89
|
| .
|
|
90
|
-
p.small.text-body-secondary.mb-0 Finished jobs
|
|
90
|
+
p.small.text-body-secondary.mb-0 Finished jobs and their logs are not touched.
|
|
91
91
|
else
|
|
92
92
|
p.mb-0 There are no active jobs — nothing to cancel.
|
|
93
93
|
|
|
@@ -132,7 +132,7 @@ block content
|
|
|
132
132
|
onclick="document.getElementById('cleanup-before').value = this.dataset.value"
|
|
133
133
|
)= preset.label
|
|
134
134
|
p
|
|
135
|
-
| Deletes finished jobs that ended before then, with their logs
|
|
135
|
+
| Deletes finished jobs that ended before then, with their logs, and the resource, agent and group history logged before then — then compacts the database file.
|
|
136
136
|
|
|
|
137
137
|
strong This cannot be undone.
|
|
138
138
|
| Active jobs are never touched.
|
package/views/jobs/show.pug
CHANGED
|
@@ -49,15 +49,5 @@ block content
|
|
|
49
49
|
dt.col-sm-2 Message
|
|
50
50
|
dd.col-sm-10= job.message
|
|
51
51
|
|
|
52
|
-
h2.h6.mt-4#artifacts Artifacts
|
|
53
|
-
if artifacts.length
|
|
54
|
-
ul.list-group
|
|
55
|
-
each a in artifacts
|
|
56
|
-
li.list-group-item.d-flex.justify-content-between
|
|
57
|
-
span= a.name
|
|
58
|
-
a(href=a.url) download
|
|
59
|
-
else
|
|
60
|
-
p.text-body-secondary No artifacts yet.
|
|
61
|
-
|
|
62
52
|
h2.h6.mt-4 Log
|
|
63
53
|
+logViewer(job.id, jobActive)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
mixin logViewer(jobId, jobActive)
|
|
2
2
|
//- Stream filter + search (public/js/log-viewer.js): matches are
|
|
3
3
|
//- highlighted; Enter / Shift+Enter or the arrows step through them.
|
|
4
|
+
//- The log loads from its end; earlier lines load as you scroll up.
|
|
4
5
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-2
|
|
5
6
|
select#stream-filter.form-select.form-select-sm.w-auto(aria-label="Log stream")
|
|
6
7
|
option(value="") all streams
|
|
@@ -28,6 +29,19 @@ mixin logViewer(jobId, jobActive)
|
|
|
28
29
|
.form-check.form-check-inline.small.mb-0
|
|
29
30
|
input#log-search-only.form-check-input(type="checkbox")
|
|
30
31
|
label.form-check-label(for="log-search-only") Display only matching lines
|
|
32
|
+
a.btn.btn-sm.btn-outline-secondary.ms-auto(
|
|
33
|
+
href=`/jobs/${jobId}/log.txt`
|
|
34
|
+
target="_blank"
|
|
35
|
+
rel="noopener"
|
|
36
|
+
data-bs-toggle="tooltip"
|
|
37
|
+
data-bs-title="Open the full log as plain text in a new tab"
|
|
38
|
+
)
|
|
39
|
+
i.bi.bi-file-earmark-text.me-1
|
|
40
|
+
| Raw
|
|
41
|
+
//- "Showing the last 1,000 of 5,321 lines · Load all" (log-viewer.js).
|
|
42
|
+
.d-flex.flex-wrap.align-items-center.gap-2.small.text-body-secondary.mb-1(aria-live="polite")
|
|
43
|
+
span#log-info Loading log…
|
|
44
|
+
button#log-load-all.btn.btn-link.btn-sm.p-0.align-baseline(type="button" hidden) Load all
|
|
31
45
|
pre#log-viewer.bg-body-tertiary.p-3.rounded.position-relative(
|
|
32
46
|
style="height: 420px; overflow-y: auto;"
|
|
33
47
|
data-job-id=jobId
|