@andrian.yablonskyy/thub-coordinator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/bin/thub-admin.js +190 -0
- package/config.json +30 -0
- package/eslint.config.js +115 -0
- package/package.json +32 -0
- package/public/css/thub.css +6 -0
- package/public/js/copy-to-clipboard.js +36 -0
- package/public/js/log-viewer.js +76 -0
- package/public/js/profile-theme.js +28 -0
- package/public/js/theme.js +51 -0
- package/public/js/tooltips.js +18 -0
- package/src/api/admin.js +112 -0
- package/src/api/agent.js +110 -0
- package/src/api/resource.js +211 -0
- package/src/api/sse.js +96 -0
- package/src/auth.js +94 -0
- package/src/config.js +110 -0
- package/src/db/index.js +61 -0
- package/src/db/migrations/001_init.sql +104 -0
- package/src/db/migrations/003_split_job_id_counters.sql +5 -0
- package/src/db/migrations/004_add_client_id.sql +8 -0
- package/src/db/migrations/005_add_groups.sql +14 -0
- package/src/db/migrations/006_events_allow_group.sql +20 -0
- package/src/db/migrations/007_add_user_profile.sql +10 -0
- package/src/server.js +179 -0
- package/src/services/admin-users.js +174 -0
- package/src/services/agents.js +56 -0
- package/src/services/artifacts.js +149 -0
- package/src/services/bus.js +23 -0
- package/src/services/commands.js +43 -0
- package/src/services/events.js +37 -0
- package/src/services/groups.js +79 -0
- package/src/services/heartbeat.js +56 -0
- package/src/services/jobs.js +355 -0
- package/src/services/logs.js +70 -0
- package/src/services/registry.js +347 -0
- package/src/services/retention.js +44 -0
- package/src/services/scheduler.js +101 -0
- package/src/services/tokens.js +28 -0
- package/src/web/routes.js +343 -0
- package/test/admin-users.test.js +127 -0
- package/test/scheduler.test.js +375 -0
- package/views/admin/agents.pug +53 -0
- package/views/groups/list.pug +94 -0
- package/views/index.pug +37 -0
- package/views/jobs/list.pug +45 -0
- package/views/jobs/show.pug +42 -0
- package/views/layout.pug +73 -0
- package/views/login.pug +16 -0
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/status-badge.pug +7 -0
- package/views/profile.pug +77 -0
- package/views/resources/list.pug +65 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/theme.js
|
|
3
|
+
* @description Dashboard: theme toggle, persisted to the account (§10.1) with localStorage as a pre-login fallback
|
|
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
|
+
(function (){
|
|
17
|
+
const root = document.documentElement,
|
|
18
|
+
key = 'thub-theme';
|
|
19
|
+
|
|
20
|
+
function apply(theme){
|
|
21
|
+
root.setAttribute('data-bs-theme', theme);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Server-rendered value (layout.pug: html(data-bs-theme=user.theme)) wins
|
|
25
|
+
// once logged in — it's the account's saved preference, synced across
|
|
26
|
+
// devices (§10.1). "auto" means no account preference yet (or logged
|
|
27
|
+
// out, e.g. the login page), so localStorage is still the fallback there.
|
|
28
|
+
if (root.getAttribute('data-bs-theme') === 'auto'){
|
|
29
|
+
const stored = localStorage.getItem(key);
|
|
30
|
+
if (stored){
|
|
31
|
+
apply(stored);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shared by the navbar toggle below and the profile page's theme buttons
|
|
36
|
+
// (public/js/profile-theme.js) — one place that applies it locally and
|
|
37
|
+
// persists it to the account, so both call sites can't drift.
|
|
38
|
+
window.thubSetTheme = function thubSetTheme(theme){
|
|
39
|
+
apply(theme);
|
|
40
|
+
localStorage.setItem(key, theme);
|
|
41
|
+
fetch('/profile/theme', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ theme })
|
|
45
|
+
}).catch(() => {}); // best effort — logged-out visitors can still toggle locally
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
document.getElementById('theme-toggle')?.addEventListener('click', () => {
|
|
49
|
+
window.thubSetTheme(root.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark');
|
|
50
|
+
});
|
|
51
|
+
})();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/tooltips.js
|
|
3
|
+
* @description Dashboard: initializes Bootstrap tooltips on data-bs-toggle elements
|
|
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
|
+
(function (){
|
|
17
|
+
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => new bootstrap.Tooltip(el));
|
|
18
|
+
})();
|
package/src/api/admin.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/api/admin.js
|
|
3
|
+
* @description Admin API routes: agents, join key, resource maintenance, job queue reset/clean (README §6.3)
|
|
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 express = require('express'),
|
|
17
|
+
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
18
|
+
{ generateToken, hashToken } = require('../services/tokens');
|
|
19
|
+
|
|
20
|
+
// §6.3 Admin endpoints. Authenticated via the dashboard session (§10),
|
|
21
|
+
// not an API bearer token.
|
|
22
|
+
function createAdminRouter({ services }){
|
|
23
|
+
const router = express.Router();
|
|
24
|
+
router.use(requireAdminSession, requireAdminRole);
|
|
25
|
+
|
|
26
|
+
router.post('/agents', (req, res, next) => {
|
|
27
|
+
try {
|
|
28
|
+
const { name, kind } = req.body;
|
|
29
|
+
if (!name || !['ci', 'cli'].includes(kind)){
|
|
30
|
+
return res.status(400).json({ error: 'name and kind (ci|cli) are required' });
|
|
31
|
+
}
|
|
32
|
+
const { agent, token } = services.agents.create({ name, kind });
|
|
33
|
+
res.status(201).json({ agent, token });
|
|
34
|
+
}
|
|
35
|
+
catch (err){
|
|
36
|
+
next(err);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
router.delete('/agents/:id', (req, res) => {
|
|
41
|
+
services.agents.revoke(req.params.id);
|
|
42
|
+
res.status(204).end();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
router.post('/resources/:id/maintenance', (req, res, next) => {
|
|
46
|
+
try {
|
|
47
|
+
const resource = services.registry.setMaintenance(req.params.id, !!req.body.enabled);
|
|
48
|
+
res.json(resource);
|
|
49
|
+
}
|
|
50
|
+
catch (err){
|
|
51
|
+
next(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Referenced in §10's dashboard resource actions.
|
|
56
|
+
router.post('/resources/:id/rotate-token', (req, res) => {
|
|
57
|
+
const token = generateToken('res');
|
|
58
|
+
services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
|
|
59
|
+
services.events.record('resource', req.params.id, 'resource.token_rotated', {});
|
|
60
|
+
res.json({ resourceToken: token });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Cancels every QUEUED/ASSIGNED/PREPARING/RUNNING job (§13.1).
|
|
64
|
+
router.post('/jobs/reset-queue', (req, res) => {
|
|
65
|
+
const canceled = services.jobs.resetQueue();
|
|
66
|
+
res.json({ canceled });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Permanently deletes finished jobs and their logs/artifacts (§13.1).
|
|
70
|
+
router.post('/jobs/clean-history', (req, res) => {
|
|
71
|
+
const deleted = services.jobs.cleanHistory();
|
|
72
|
+
res.json({ deleted });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Resource groups (§13.1) — membership itself is declared by each
|
|
76
|
+
// Client's own config (`groups: [...]`), not managed here.
|
|
77
|
+
router.get('/groups', (req, res) => {
|
|
78
|
+
res.json({ groups: services.groups.list() });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
router.post('/groups', (req, res, next) => {
|
|
82
|
+
try {
|
|
83
|
+
const { name, comment } = req.body;
|
|
84
|
+
if (!name){
|
|
85
|
+
return res.status(400).json({ error: 'name is required' });
|
|
86
|
+
}
|
|
87
|
+
res.status(201).json(services.groups.create({ name, comment }));
|
|
88
|
+
}
|
|
89
|
+
catch (err){
|
|
90
|
+
next(err);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
router.post('/groups/:id', (req, res, next) => {
|
|
95
|
+
try {
|
|
96
|
+
const { name, comment } = req.body;
|
|
97
|
+
res.json(services.groups.update(req.params.id, { name, comment }));
|
|
98
|
+
}
|
|
99
|
+
catch (err){
|
|
100
|
+
next(err);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
router.delete('/groups/:id', (req, res) => {
|
|
105
|
+
services.groups.remove(req.params.id);
|
|
106
|
+
res.status(204).end();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return router;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { createAdminRouter };
|
package/src/api/agent.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/api/agent.js
|
|
3
|
+
* @description Agent API routes: job submission, status, cancel, log streaming (README §6.1)
|
|
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 express = require('express'),
|
|
17
|
+
{ requireRole } = require('../auth'),
|
|
18
|
+
{ attachJobStream } = require('./sse');
|
|
19
|
+
|
|
20
|
+
// §6.1 Agent endpoints.
|
|
21
|
+
function createAgentRouter({ services, config }){
|
|
22
|
+
const router = express.Router(),
|
|
23
|
+
// Applied per-route (not via router.use()) because this router shares
|
|
24
|
+
// the /api/v1 prefix with the resource and admin routers — a blanket
|
|
25
|
+
// router-level middleware would run for their paths too, before route
|
|
26
|
+
// matching even happens, and reject them for lacking an agent token.
|
|
27
|
+
auth = requireRole('agent');
|
|
28
|
+
|
|
29
|
+
router.post('/jobs', auth, (req, res, next) => {
|
|
30
|
+
try {
|
|
31
|
+
const job = services.jobs.create({ agentId: req.agent.id, source: req.body.source || 'cli', spec: req.body });
|
|
32
|
+
res.status(201).json({ jobId: job.id, state: job.state, webUrl: `${config.publicUrl}/jobs/${job.id}` });
|
|
33
|
+
}
|
|
34
|
+
catch (err){
|
|
35
|
+
next(err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
router.get('/jobs/:id', auth, (req, res) => {
|
|
40
|
+
const job = services.jobs.get(req.params.id);
|
|
41
|
+
if (!job){
|
|
42
|
+
return res.status(404).json({ error: 'Unknown job' });
|
|
43
|
+
}
|
|
44
|
+
const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
45
|
+
res.json({ ...job, resource: resource ? { id: resource.id, name: resource.name } : null });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
router.get('/jobs', auth, (req, res) => {
|
|
49
|
+
const jobs = services.jobs.list({
|
|
50
|
+
state: req.query.state,
|
|
51
|
+
source: req.query.source,
|
|
52
|
+
agentId: req.agent.id,
|
|
53
|
+
mine: req.query.mine === 'true' || req.query.mine === '1',
|
|
54
|
+
limit: req.query.limit ? Number(req.query.limit) : undefined
|
|
55
|
+
});
|
|
56
|
+
res.json({ jobs });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
router.post('/jobs/:id/cancel', auth, (req, res, next) => {
|
|
60
|
+
try {
|
|
61
|
+
const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: false });
|
|
62
|
+
res.json(job);
|
|
63
|
+
}
|
|
64
|
+
catch (err){
|
|
65
|
+
next(err);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
router.get('/jobs/:id/logs', auth, (req, res) => {
|
|
70
|
+
const after = Number(req.query.after || 0),
|
|
71
|
+
limit = req.query.limit ? Number(req.query.limit) : undefined;
|
|
72
|
+
res.json({ lines: services.logs.listSince(req.params.id, after, limit) });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
router.get('/jobs/:id/logs/stream', auth, (req, res) => {
|
|
76
|
+
attachJobStream(req, res, { jobId: req.params.id, services, config });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
router.get('/jobs/:id/artifacts', auth, (req, res) => {
|
|
80
|
+
const artifacts = services.artifacts.listForJob(req.params.id).map((a) => ({
|
|
81
|
+
id: a.id,
|
|
82
|
+
name: a.name,
|
|
83
|
+
size: a.size,
|
|
84
|
+
sha256: a.sha256,
|
|
85
|
+
contentType: a.content_type,
|
|
86
|
+
url: services.artifacts.signedUrl(a)
|
|
87
|
+
}));
|
|
88
|
+
res.json({ artifacts });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
router.get('/resources', auth, (req, res) => {
|
|
92
|
+
res.json({ resources: services.registry.list().map(publicResource) });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return router;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function publicResource(r){
|
|
99
|
+
return {
|
|
100
|
+
id: r.id,
|
|
101
|
+
name: r.name,
|
|
102
|
+
type: r.type,
|
|
103
|
+
status: r.status,
|
|
104
|
+
busySource: r.busy_source,
|
|
105
|
+
labels: r.labels,
|
|
106
|
+
lastHeartbeatAt: r.last_heartbeat_at
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { createAgentRouter };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/api/resource.js
|
|
3
|
+
* @description Resource (Client) API routes: register, heartbeat, job long-poll, result/log upload (README §6.2)
|
|
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 express = require('express'),
|
|
17
|
+
multer = require('multer'),
|
|
18
|
+
{ requireRole, requireJoinKey } = require('../auth'),
|
|
19
|
+
{ JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
20
|
+
|
|
21
|
+
const upload = multer({ dest: require('node:os').tmpdir() });
|
|
22
|
+
|
|
23
|
+
function requireOwnResource(req, res, next){
|
|
24
|
+
if (req.resource.id !== req.params.id){
|
|
25
|
+
return res.status(403).json({ error: 'Token does not match resource' });
|
|
26
|
+
}
|
|
27
|
+
next();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireOwnJob(services){
|
|
31
|
+
return (req, res, next) => {
|
|
32
|
+
const job = services.jobs.get(req.params.id);
|
|
33
|
+
if (!job){
|
|
34
|
+
return res.status(404).json({ error: 'Unknown job' });
|
|
35
|
+
}
|
|
36
|
+
if (job.resource_id !== req.resource.id){
|
|
37
|
+
return res.status(403).json({ error: 'Job is not assigned to this resource' });
|
|
38
|
+
}
|
|
39
|
+
req.job = job;
|
|
40
|
+
next();
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// §6.2 Resource (Client) endpoints.
|
|
45
|
+
function createResourceRouter({ services, config }){
|
|
46
|
+
const router = express.Router();
|
|
47
|
+
|
|
48
|
+
// Self-service registration: no admin action required. A Client proves
|
|
49
|
+
// it belongs by presenting the shared clientJoinKey (config/env on its
|
|
50
|
+
// side) and declares its own name/type/labels; the Coordinator upserts
|
|
51
|
+
// the resource by name (see registry.registerAuto).
|
|
52
|
+
router.post('/resources/register', requireJoinKey(config), (req, res, next) => {
|
|
53
|
+
try {
|
|
54
|
+
const { clientId, name, type, labels, groups, hostInfo, capabilities } = req.body;
|
|
55
|
+
if (!clientId){
|
|
56
|
+
return res.status(400).json({ error: 'clientId is required (persisted in the Client\'s .client-id file)' });
|
|
57
|
+
}
|
|
58
|
+
if (!name || !['hw', 'sw'].includes(type)){
|
|
59
|
+
return res.status(400).json({ error: 'name and type (hw|sw) are required' });
|
|
60
|
+
}
|
|
61
|
+
const { resourceId, resourceToken } = services.registry.registerAuto({
|
|
62
|
+
clientId,
|
|
63
|
+
name,
|
|
64
|
+
type,
|
|
65
|
+
labels: labels || capabilities?.labels || [],
|
|
66
|
+
groups: groups || [],
|
|
67
|
+
hostInfo
|
|
68
|
+
});
|
|
69
|
+
res.json({ resourceId, resourceToken, heartbeatIntervalSec: config.heartbeat.intervalSec });
|
|
70
|
+
}
|
|
71
|
+
catch (err){
|
|
72
|
+
next(err);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Applied per-route, not via router.use() — this router shares the
|
|
77
|
+
// /api/v1 prefix with the agent and admin routers, and a blanket
|
|
78
|
+
// router-level middleware would run for their paths too before route
|
|
79
|
+
// matching happens (see the same note in api/agent.js).
|
|
80
|
+
const auth = requireRole('resource');
|
|
81
|
+
|
|
82
|
+
router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
|
|
83
|
+
try {
|
|
84
|
+
const { state, activeJobId, localLock, metrics } = req.body;
|
|
85
|
+
services.registry.heartbeat(req.params.id, { state, activeJobId, localLock, metrics });
|
|
86
|
+
const commands = services.commands.drain(req.params.id);
|
|
87
|
+
res.json({ serverTime: new Date().toISOString(), commands });
|
|
88
|
+
}
|
|
89
|
+
catch (err){
|
|
90
|
+
next(err);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
router.post('/resources/:id/status', auth, requireOwnResource, (req, res, next) => {
|
|
95
|
+
try {
|
|
96
|
+
const { busy, source, reason } = req.body;
|
|
97
|
+
if (source && source !== 'local'){
|
|
98
|
+
return res.status(400).json({ error: 'Only source=local may be set explicitly' });
|
|
99
|
+
}
|
|
100
|
+
const resource = services.registry.setLocalLock(req.params.id, { locked: !!busy, reason });
|
|
101
|
+
res.json(resource);
|
|
102
|
+
}
|
|
103
|
+
catch (err){
|
|
104
|
+
next(err);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
router.get('/resources/:id/jobs/next', auth, requireOwnResource, async (req, res) => {
|
|
109
|
+
const waitSec = Math.min(Number(req.query.wait) || 0, 60),
|
|
110
|
+
|
|
111
|
+
existing = services.jobs.list({ state: JOB_STATES.ASSIGNED, resourceId: req.params.id, limit: 1 })[0];
|
|
112
|
+
if (existing){
|
|
113
|
+
return res.json(existing);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (waitSec <= 0){
|
|
117
|
+
return res.status(204).end();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const jobId = await waitForAssignment(services.bus, req.params.id, waitSec);
|
|
121
|
+
if (!jobId){
|
|
122
|
+
return res.status(204).end();
|
|
123
|
+
}
|
|
124
|
+
res.json(services.jobs.get(jobId));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
router.post('/jobs/:id/accept', auth, requireOwnJob(services), (req, res, next) => {
|
|
128
|
+
try {
|
|
129
|
+
if (req.job.state !== JOB_STATES.ASSIGNED){
|
|
130
|
+
return res.status(409).json({ error: `Cannot accept job in state ${req.job.state}` });
|
|
131
|
+
}
|
|
132
|
+
const job = services.jobs.setState(req.params.id, JOB_STATES.PREPARING);
|
|
133
|
+
res.json(job);
|
|
134
|
+
}
|
|
135
|
+
catch (err){
|
|
136
|
+
next(err);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
router.post('/jobs/:id/state', auth, requireOwnJob(services), (req, res, next) => {
|
|
141
|
+
try {
|
|
142
|
+
const { state, message } = req.body;
|
|
143
|
+
if (![JOB_STATES.PREPARING, JOB_STATES.RUNNING].includes(state)){
|
|
144
|
+
return res.status(400).json({ error: 'state must be PREPARING or RUNNING' });
|
|
145
|
+
}
|
|
146
|
+
const job = services.jobs.setState(req.params.id, state, { message });
|
|
147
|
+
res.json(job);
|
|
148
|
+
}
|
|
149
|
+
catch (err){
|
|
150
|
+
next(err);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
router.post('/jobs/:id/logs', auth, requireOwnJob(services), (req, res, next) => {
|
|
155
|
+
try {
|
|
156
|
+
const lines = Array.isArray(req.body) ? req.body : req.body.lines;
|
|
157
|
+
if (!Array.isArray(lines)){
|
|
158
|
+
return res.status(400).json({ error: 'Expected an array of log lines' });
|
|
159
|
+
}
|
|
160
|
+
const inserted = services.logs.appendBatch(req.params.id, lines);
|
|
161
|
+
res.status(202).json({ accepted: inserted.length });
|
|
162
|
+
}
|
|
163
|
+
catch (err){
|
|
164
|
+
next(err);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
router.post('/jobs/:id/artifacts', auth, requireOwnJob(services), upload.any(), (req, res, next) => {
|
|
169
|
+
try {
|
|
170
|
+
const stored = services.artifacts.storeUploaded(req.params.id, req.files || []);
|
|
171
|
+
res.status(201).json({ artifacts: stored.map((a) => ({ id: a.id, name: a.name, size: a.size })) });
|
|
172
|
+
}
|
|
173
|
+
catch (err){
|
|
174
|
+
next(err);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
router.post('/jobs/:id/result', auth, requireOwnJob(services), (req, res, next) => {
|
|
179
|
+
try {
|
|
180
|
+
const { state, exitCode, summary } = req.body,
|
|
181
|
+
job = services.jobs.applyResult(req.params.id, req.resource.id, { state, exitCode, summary });
|
|
182
|
+
res.json(job);
|
|
183
|
+
}
|
|
184
|
+
catch (err){
|
|
185
|
+
next(err);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return router;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function waitForAssignment(bus, resourceId, waitSec){
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const timer = setTimeout(() => {
|
|
195
|
+
bus.off('job.assigned', onAssigned);
|
|
196
|
+
resolve(null);
|
|
197
|
+
}, waitSec * 1000);
|
|
198
|
+
|
|
199
|
+
function onAssigned(evt){
|
|
200
|
+
if (evt.resourceId !== resourceId){
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
bus.off('job.assigned', onAssigned);
|
|
205
|
+
resolve(evt.jobId);
|
|
206
|
+
}
|
|
207
|
+
bus.on('job.assigned', onAssigned);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { createResourceRouter };
|
package/src/api/sse.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/api/sse.js
|
|
3
|
+
* @description Server-Sent Events helper: streams a job's log/state/end events, resumable by seq (README §6.4)
|
|
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 { TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
17
|
+
|
|
18
|
+
// §6.4: log / state / end events, resumable via Last-Event-ID (§7: "the
|
|
19
|
+
// Agent reconnects with exponential backoff and resumes from the last seq").
|
|
20
|
+
function attachJobStream(req, res, { jobId, services, config }){
|
|
21
|
+
const { jobs, logs, registry, bus } = services,
|
|
22
|
+
|
|
23
|
+
job = jobs.get(jobId);
|
|
24
|
+
if (!job){
|
|
25
|
+
res.status(404).json({ error: 'Unknown job' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
res.writeHead(200, {
|
|
30
|
+
'Content-Type': 'text/event-stream',
|
|
31
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
32
|
+
Connection: 'keep-alive',
|
|
33
|
+
'X-Accel-Buffering': 'no'
|
|
34
|
+
});
|
|
35
|
+
res.flushHeaders?.();
|
|
36
|
+
|
|
37
|
+
const lastEventId = req.get('Last-Event-ID'),
|
|
38
|
+
afterSeq = Number(lastEventId ?? req.query.after ?? 0) || 0;
|
|
39
|
+
|
|
40
|
+
function writeEvent(event, data, id){
|
|
41
|
+
if (id !== undefined){
|
|
42
|
+
res.write(`id: ${id}\n`);
|
|
43
|
+
}
|
|
44
|
+
res.write(`event: ${event}\n`);
|
|
45
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const line of logs.listSince(jobId, afterSeq)){
|
|
49
|
+
writeEvent('log', { ts: line.ts, stream: line.stream, line: line.line }, line.seq);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function artifactsUrl(){
|
|
53
|
+
return `${config.publicUrl}/jobs/${jobId}#artifacts`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const current = jobs.get(jobId);
|
|
57
|
+
if (TERMINAL_JOB_STATES.has(current.state)){
|
|
58
|
+
writeEvent('end', { state: current.state, artifactsUrl: artifactsUrl() });
|
|
59
|
+
return res.end();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resourceName = (id) => (id ? registry.get(id)?.name : undefined);
|
|
63
|
+
writeEvent('state', { state: current.state, resource: resourceName(current.resource_id) });
|
|
64
|
+
|
|
65
|
+
const onLog = (entry) => {
|
|
66
|
+
if (entry.jobId === jobId){
|
|
67
|
+
writeEvent('log', { ts: entry.ts, stream: entry.stream, line: entry.line }, entry.seq);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
onState = (evt) => {
|
|
71
|
+
if (evt.jobId === jobId){
|
|
72
|
+
writeEvent('state', { state: evt.state, resource: resourceName(evt.resourceId) });
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
onFinished = (evt) => {
|
|
76
|
+
if (evt.jobId !== jobId){
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
writeEvent('end', { state: evt.state, artifactsUrl: artifactsUrl() });
|
|
80
|
+
cleanup();
|
|
81
|
+
res.end();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function cleanup(){
|
|
85
|
+
bus.off('job.log', onLog);
|
|
86
|
+
bus.off('job.state', onState);
|
|
87
|
+
bus.off('job.finished', onFinished);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
bus.on('job.log', onLog);
|
|
91
|
+
bus.on('job.state', onState);
|
|
92
|
+
bus.on('job.finished', onFinished);
|
|
93
|
+
req.on('close', cleanup);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { attachJobStream };
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/auth.js
|
|
3
|
+
* @description Bearer token and admin session authentication/authorization middleware (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
|
+
const crypto = require('node:crypto'),
|
|
17
|
+
{ hashToken } = require('./services/tokens');
|
|
18
|
+
|
|
19
|
+
// §3.1 / §12: bearer tokens are hashed with SHA-256 and carry a role
|
|
20
|
+
// (agent | resource); admin is authenticated via the dashboard session
|
|
21
|
+
// instead of an API token (§10).
|
|
22
|
+
function requireRole(role){
|
|
23
|
+
return (req, res, next) => {
|
|
24
|
+
const header = req.headers.authorization || '',
|
|
25
|
+
[scheme, token] = header.split(' ');
|
|
26
|
+
if (scheme !== 'Bearer' || !token){
|
|
27
|
+
return res.status(401).json({ error: 'Missing bearer token' });
|
|
28
|
+
}
|
|
29
|
+
const tokenHash = hashToken(token);
|
|
30
|
+
|
|
31
|
+
if (role === 'agent'){
|
|
32
|
+
const agent = req.app.locals.services.agents.getByTokenHash(tokenHash);
|
|
33
|
+
if (!agent){
|
|
34
|
+
return res.status(401).json({ error: 'Invalid or revoked agent token' });
|
|
35
|
+
}
|
|
36
|
+
req.app.locals.services.agents.touchLastUsed(agent.id);
|
|
37
|
+
req.agent = agent;
|
|
38
|
+
return next();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (role === 'resource'){
|
|
42
|
+
const resource = req.app.locals.services.registry.getByTokenHash(tokenHash);
|
|
43
|
+
if (!resource){
|
|
44
|
+
return res.status(401).json({ error: 'Invalid resource token' });
|
|
45
|
+
}
|
|
46
|
+
req.resource = resource;
|
|
47
|
+
return next();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return res.status(500).json({ error: `Unknown role ${role}` });
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Gates self-service Client registration (see api/resource.js). A shared
|
|
55
|
+
// secret rather than a per-resource token, since the whole point is that
|
|
56
|
+
// no admin action is needed to let a new Client in — every Client just
|
|
57
|
+
// carries the same key from its own config/env.
|
|
58
|
+
function requireJoinKey(config){
|
|
59
|
+
return (req, res, next) => {
|
|
60
|
+
if (!config.clientJoinKey){
|
|
61
|
+
return res.status(503).json({ error: 'Coordinator has no clientJoinKey configured; auto-registration is disabled' });
|
|
62
|
+
}
|
|
63
|
+
const header = req.headers.authorization || '',
|
|
64
|
+
[scheme, key] = header.split(' ');
|
|
65
|
+
if (scheme !== 'Bearer' || !key){
|
|
66
|
+
return res.status(401).json({ error: 'Missing bearer join key' });
|
|
67
|
+
}
|
|
68
|
+
const a = crypto.createHash('sha256').update(key).digest(),
|
|
69
|
+
b = crypto.createHash('sha256').update(config.clientJoinKey).digest();
|
|
70
|
+
if (!crypto.timingSafeEqual(a, b)){
|
|
71
|
+
return res.status(401).json({ error: 'Invalid join key' });
|
|
72
|
+
}
|
|
73
|
+
next();
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requireAdminSession(req, res, next){
|
|
78
|
+
if (!req.session?.user){
|
|
79
|
+
if (req.accepts('html')){
|
|
80
|
+
return res.redirect('/login');
|
|
81
|
+
}
|
|
82
|
+
return res.status(401).json({ error: 'Login required' });
|
|
83
|
+
}
|
|
84
|
+
next();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requireAdminRole(req, res, next){
|
|
88
|
+
if (req.session?.user?.role !== 'admin'){
|
|
89
|
+
return res.status(403).json({ error: 'Admin role required' });
|
|
90
|
+
}
|
|
91
|
+
next();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { requireRole, requireJoinKey, requireAdminSession, requireAdminRole };
|