@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
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file packages/coordinator/src/services/artifacts.js
|
|
3
|
-
* @description Job artifact storage: saves uploaded files and generates download links
|
|
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 fs = require('node:fs'),
|
|
17
|
-
path = require('node:path'),
|
|
18
|
-
crypto = require('node:crypto');
|
|
19
|
-
|
|
20
|
-
// §3.1 / §12: stores uploaded result files under artifacts/<jobId>/ and
|
|
21
|
-
// generates HMAC-signed, time-limited download links (no server-side
|
|
22
|
-
// session needed for the Agent/CI to fetch them).
|
|
23
|
-
function createArtifactsService(db, { config }){
|
|
24
|
-
const secret = config.sessionSecret;
|
|
25
|
-
|
|
26
|
-
function jobDir(jobId){
|
|
27
|
-
const dir = path.join(config.artifactsDir, jobId);
|
|
28
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
-
return dir;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function sha256File(filePath){
|
|
33
|
-
const hash = crypto.createHash('sha256');
|
|
34
|
-
hash.update(fs.readFileSync(filePath));
|
|
35
|
-
return hash.digest('hex');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// `files` are multer file objects already written to a temp path.
|
|
39
|
-
function storeUploaded(jobId, files){
|
|
40
|
-
const dir = jobDir(jobId),
|
|
41
|
-
stored = [];
|
|
42
|
-
for (const file of files){
|
|
43
|
-
const dest = path.join(dir, path.basename(file.originalname));
|
|
44
|
-
fs.renameSync(file.path, dest);
|
|
45
|
-
const sha256 = sha256File(dest),
|
|
46
|
-
size = fs.statSync(dest).size,
|
|
47
|
-
info = db
|
|
48
|
-
.prepare(
|
|
49
|
-
`INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
|
|
50
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
51
|
-
)
|
|
52
|
-
.run(jobId, file.originalname, dest, size, sha256, file.mimetype, new Date().toISOString());
|
|
53
|
-
stored.push(get(info.lastInsertRowid));
|
|
54
|
-
}
|
|
55
|
-
return stored;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function storeGenerated(jobId, name, buffer, contentType){
|
|
59
|
-
const dir = jobDir(jobId),
|
|
60
|
-
dest = path.join(dir, name);
|
|
61
|
-
fs.writeFileSync(dest, buffer);
|
|
62
|
-
const info = db
|
|
63
|
-
.prepare(
|
|
64
|
-
`INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
|
|
65
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
66
|
-
)
|
|
67
|
-
.run(jobId, name, dest, buffer.length, sha256File(dest), contentType, new Date().toISOString());
|
|
68
|
-
return get(info.lastInsertRowid);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function get(id){
|
|
72
|
-
return db.prepare('SELECT * FROM artifacts WHERE id = ?').get(id);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function listForJob(jobId){
|
|
76
|
-
return db.prepare('SELECT * FROM artifacts WHERE job_id = ? ORDER BY id ASC').all(jobId);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function sign(artifactId, ttlHours = config.artifacts.linkTtlHours){
|
|
80
|
-
const expiresAt = Date.now() + ttlHours * 3600 * 1000,
|
|
81
|
-
payload = `${artifactId}.${expiresAt}`,
|
|
82
|
-
mac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
83
|
-
return `${payload}.${mac}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function verify(token){
|
|
87
|
-
const [artifactId, expiresAt, mac] = String(token).split('.');
|
|
88
|
-
if (!artifactId || !expiresAt || !mac){
|
|
89
|
-
throw invalidLink();
|
|
90
|
-
}
|
|
91
|
-
const payload = `${artifactId}.${expiresAt}`,
|
|
92
|
-
expectedMac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
93
|
-
if (!crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expectedMac))){
|
|
94
|
-
throw invalidLink();
|
|
95
|
-
}
|
|
96
|
-
if (Date.now() > Number(expiresAt)){
|
|
97
|
-
throw invalidLink('Link expired');
|
|
98
|
-
}
|
|
99
|
-
const artifact = get(Number(artifactId));
|
|
100
|
-
if (!artifact){
|
|
101
|
-
throw invalidLink();
|
|
102
|
-
}
|
|
103
|
-
return artifact;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function invalidLink(message = 'Invalid or expired link'){
|
|
107
|
-
return Object.assign(new Error(message), { status: 403 });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function signedUrl(artifact){
|
|
111
|
-
return `${config.publicUrl}/artifacts/download/${sign(artifact.id)}`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Removes a job's artifact files from disk (not the DB rows — the
|
|
115
|
-
// caller, jobs.js's cleanHistory, deletes those as part of the same
|
|
116
|
-
// transaction that also deletes the job itself).
|
|
117
|
-
function deleteJobArtifacts(jobId){
|
|
118
|
-
fs.rmSync(path.join(config.artifactsDir, jobId), { recursive: true, force: true });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function purgeOlderThan(days){
|
|
122
|
-
const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString(),
|
|
123
|
-
stale = db
|
|
124
|
-
.prepare(
|
|
125
|
-
`SELECT a.* FROM artifacts a JOIN jobs j ON j.id = a.job_id
|
|
126
|
-
WHERE j.finished_at IS NOT NULL AND j.finished_at <= ?`
|
|
127
|
-
)
|
|
128
|
-
.all(cutoff);
|
|
129
|
-
for (const artifact of stale){
|
|
130
|
-
fs.rmSync(artifact.path, { force: true });
|
|
131
|
-
db.prepare('DELETE FROM artifacts WHERE id = ?').run(artifact.id);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
storeUploaded,
|
|
137
|
-
storeGenerated,
|
|
138
|
-
get,
|
|
139
|
-
listForJob,
|
|
140
|
-
sign,
|
|
141
|
-
verify,
|
|
142
|
-
signedUrl,
|
|
143
|
-
purgeOlderThan,
|
|
144
|
-
deleteJobArtifacts,
|
|
145
|
-
jobDir
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
module.exports = { createArtifactsService };
|