@andrian.yablonskyy/thub-coordinator 1.1.7 → 1.1.8
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/package.json +1 -1
- package/src/db/migrations/029_agent_deleted.sql +3 -0
- package/src/services/agents.js +36 -3
- package/src/web/routes.js +23 -0
- package/test/groups-tab.test.js +32 -0
- package/test/users.test.js +2 -2
- package/views/admin/agents.pug +34 -13
- package/views/admin/users.pug +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
package/src/services/agents.js
CHANGED
|
@@ -29,8 +29,8 @@ function createAgentsService(db, { events }){
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function list(){
|
|
32
|
-
return db.prepare(
|
|
33
|
-
|
|
32
|
+
return db.prepare(`SELECT id, name, kind, user_id, group_id, version, update_to, created_at, last_used_at, revoked_at, token_created_at
|
|
33
|
+
FROM agents WHERE deleted_at IS NULL ORDER BY created_at DESC`).all();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function create({ name, kind }){
|
|
@@ -79,6 +79,39 @@ function createAgentsService(db, { events }){
|
|
|
79
79
|
return get(id);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// CI tokens page: a new token for the same agent — the old one stops
|
|
83
|
+
// working at once (a revoked token comes back with it). Returned once;
|
|
84
|
+
// the row, and so its jobs and name, stay the same.
|
|
85
|
+
function rotateToken(id){
|
|
86
|
+
const agent = get(id);
|
|
87
|
+
if (!agent || agent.deleted_at){
|
|
88
|
+
throw Object.assign(new Error('Unknown agent'), { status: 404 });
|
|
89
|
+
}
|
|
90
|
+
if (agent.user_id){
|
|
91
|
+
throw Object.assign(new Error('A user\'s key is replaced on Users, not here'), { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
const token = generateToken('agt');
|
|
94
|
+
db.prepare('UPDATE agents SET token_hash = ?, token_created_at = ?, revoked_at = NULL WHERE id = ?').run(hashToken(token), new Date().toISOString(), id);
|
|
95
|
+
events.record('agent', id, 'agent.token_rotated', { name: agent.name });
|
|
96
|
+
return token;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// CI tokens page: gone from the list, its token revoked; the row stays for
|
|
100
|
+
// the jobs that reference it (they keep showing its name).
|
|
101
|
+
function remove(id){
|
|
102
|
+
const agent = get(id);
|
|
103
|
+
if (!agent || agent.deleted_at){
|
|
104
|
+
throw Object.assign(new Error('Unknown agent'), { status: 404 });
|
|
105
|
+
}
|
|
106
|
+
if (agent.user_id){
|
|
107
|
+
throw Object.assign(new Error('A user\'s key is managed on Users, not here'), { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
db.prepare('UPDATE agents SET deleted_at = ?, revoked_at = COALESCE(revoked_at, ?), update_to = NULL WHERE id = ?').run(now, now, id);
|
|
111
|
+
events.record('agent', id, 'agent.deleted', { name: agent.name });
|
|
112
|
+
return agent;
|
|
113
|
+
}
|
|
114
|
+
|
|
82
115
|
function revoke(id){
|
|
83
116
|
db.prepare('UPDATE agents SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
|
84
117
|
events.record('agent', id, 'agent.revoked', {});
|
|
@@ -116,7 +149,7 @@ function createAgentsService(db, { events }){
|
|
|
116
149
|
return ids.length;
|
|
117
150
|
}
|
|
118
151
|
|
|
119
|
-
return { get, getByTokenHash, list, create, rename, setGroup, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
|
|
152
|
+
return { get, getByTokenHash, list, create, rename, setGroup, rotateToken, remove, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
|
|
120
153
|
}
|
|
121
154
|
|
|
122
155
|
module.exports = { createAgentsService };
|
package/src/web/routes.js
CHANGED
|
@@ -1478,6 +1478,29 @@ function createWebRouter({ services, config }){
|
|
|
1478
1478
|
res.redirect(returnTo(req, '/admin/agents'));
|
|
1479
1479
|
});
|
|
1480
1480
|
|
|
1481
|
+
router.post('/admin/agents/:id/token', (req, res) => {
|
|
1482
|
+
try {
|
|
1483
|
+
const agent = services.agents.get(req.params.id),
|
|
1484
|
+
token = services.agents.rotateToken(req.params.id);
|
|
1485
|
+
return renderAgents(req, res, { newToken: token, newTokenFor: agent.name });
|
|
1486
|
+
}
|
|
1487
|
+
catch (err){
|
|
1488
|
+
flash(req, 'danger', err.message);
|
|
1489
|
+
}
|
|
1490
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
router.post('/admin/agents/:id/delete', (req, res) => {
|
|
1494
|
+
try {
|
|
1495
|
+
const agent = services.agents.remove(req.params.id);
|
|
1496
|
+
flash(req, 'success', `Deleted CI token "${agent.name}". Its jobs stay, listed under its name.`);
|
|
1497
|
+
}
|
|
1498
|
+
catch (err){
|
|
1499
|
+
flash(req, 'danger', err.message);
|
|
1500
|
+
}
|
|
1501
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1481
1504
|
router.post('/admin/agents/:id/revoke', (req, res) => {
|
|
1482
1505
|
services.agents.revoke(req.params.id);
|
|
1483
1506
|
res.redirect(returnTo(req, '/admin/agents'));
|
package/test/groups-tab.test.js
CHANGED
|
@@ -243,3 +243,35 @@ test('agent groups are set on the dashboard (Users, CI tokens) and shown by name
|
|
|
243
243
|
assert.ok(!text.includes(g.id), 'a group id shows as text');
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
|
+
|
|
247
|
+
test('CI tokens: the name opens Edit; a new token replaces the old one; delete keeps its jobs', async (t) => {
|
|
248
|
+
const { base, services, admin } = await start(t),
|
|
249
|
+
form = (p, body = {}) => fetch(`${base}${p}`, { method: 'POST', headers: { cookie: admin }, body: new URLSearchParams(body), redirect: 'manual' }),
|
|
250
|
+
{ agent, token } = services.agents.create({ name: 'ci-fw', kind: 'ci' }),
|
|
251
|
+
me = (tok) => fetch(`${base}/api/v1/me`, { headers: { authorization: `Bearer ${tok}` } });
|
|
252
|
+
services.registry.registerAuto({ clientId: 'sw7', name: 'lab-sw-07', type: 'sw', labels: [] });
|
|
253
|
+
const job = services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw', labels: [] }, command: './t.sh' } });
|
|
254
|
+
|
|
255
|
+
let html = await (await fetch(`${base}/admin/agents`, { headers: { cookie: admin } })).text();
|
|
256
|
+
assert.match(html, new RegExp(`<a class="fw-medium" href="#editAgentModal-${agent.id}" data-bs-toggle="modal" role="button">ci-fw</a>`));
|
|
257
|
+
assert.doesNotMatch(html, />Edit<\/button>/);
|
|
258
|
+
|
|
259
|
+
// New token: shown once, the old one stops at once.
|
|
260
|
+
const res = await form(`/admin/agents/${agent.id}/token`);
|
|
261
|
+
html = await res.text();
|
|
262
|
+
const fresh = /value="(agt_[^"]+)" readonly/.exec(html)?.[1];
|
|
263
|
+
assert.ok(fresh, 'the new token is shown');
|
|
264
|
+
assert.match(html, /New token for ci-fw \(shown once — the old one has stopped working\)/);
|
|
265
|
+
assert.equal((await me(token)).status, 401);
|
|
266
|
+
assert.equal((await me(fresh)).status, 200);
|
|
267
|
+
|
|
268
|
+
// Delete: gone from the list and from the API; its job keeps its name.
|
|
269
|
+
await form(`/admin/agents/${agent.id}/delete`);
|
|
270
|
+
html = await (await fetch(`${base}/admin/agents`, { headers: { cookie: admin } })).text();
|
|
271
|
+
assert.match(html, /Deleted CI token "ci-fw"|Deleted CI token "ci-fw"/); // the flash
|
|
272
|
+
assert.doesNotMatch(html.slice(html.indexOf('<tbody')), /ci-fw/); // the list
|
|
273
|
+
assert.equal((await me(fresh)).status, 401);
|
|
274
|
+
assert.ok((await (await fetch(`${base}/jobs?q=ci-fw`, { headers: { cookie: admin } })).text()).includes(job.id)); // still found by its name
|
|
275
|
+
assert.match(await (await form(`/admin/agents/${agent.id}/token`)).headers.get('location'), /agents/); // can't come back
|
|
276
|
+
assert.equal(services.agents.get(agent.id).revoked_at !== null, true);
|
|
277
|
+
});
|
package/test/users.test.js
CHANGED
|
@@ -358,7 +358,7 @@ test('maintainers manage Agent-only users, their own account and CI tokens — n
|
|
|
358
358
|
|
|
359
359
|
// CI tokens: issue, rename, revoke.
|
|
360
360
|
page = await post('/admin/agents', { name: 'ci-nightly' });
|
|
361
|
-
assert.match(page, /Token \(shown once\):[\s\S]
|
|
361
|
+
assert.match(page, /Token \(shown once\):[\s\S]*?value="agt_/);
|
|
362
362
|
const ci = services.agents.list().find((a) => a.name === 'ci-nightly');
|
|
363
363
|
await post(`/admin/agents/${ci.id}/rename`, { name: 'ci-night' });
|
|
364
364
|
await post(`/admin/agents/${ci.id}/revoke`);
|
|
@@ -423,7 +423,7 @@ test('audit log: Timestamp, User (who did it — always), Action — from the da
|
|
|
423
423
|
log = html.slice(html.indexOf('Recent activity'), html.indexOf('id="addUserModal"')),
|
|
424
424
|
rows = [...log.matchAll(/<tr><td[^>]*>[^<]*<\/td><td[^>]*>([^<]*)<\/td><td>([^<]*)<\/td><\/tr>/g)]
|
|
425
425
|
.map((m) => [m[1], m[2].replace(/'/g, '\'').replace(/>/g, '>')]).reverse(); // oldest first
|
|
426
|
-
assert.match(log, /<th
|
|
426
|
+
assert.match(log, /<th[^>]*>Timestamp<\/th><th>User<\/th><th>Action<\/th>/);
|
|
427
427
|
assert.deepEqual(rows, [
|
|
428
428
|
['root', 'Created root (Admin)'],
|
|
429
429
|
['root', 'Created dev (User)'],
|
package/views/admin/agents.pug
CHANGED
|
@@ -15,10 +15,13 @@ block content
|
|
|
15
15
|
+pageSizePicker(list)
|
|
16
16
|
|
|
17
17
|
if newToken
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
//- Shown once: nothing below keeps it.
|
|
19
|
+
.alert.alert-warning(role="alert")
|
|
20
|
+
strong= newTokenFor ? `New token for ${newTokenFor} (shown once — the old one has stopped working):` : 'Token (shown once):'
|
|
21
|
+
.input-group.input-group-sm.mt-2
|
|
22
|
+
input.form-control.font-monospace(type="text" value=newToken readonly aria-label="Token")
|
|
23
|
+
button.btn.btn-outline-secondary(type="button" data-copy=newToken aria-label="Copy token")
|
|
24
|
+
i.bi.bi-clipboard
|
|
22
25
|
|
|
23
26
|
div(data-live="list-toolbar")
|
|
24
27
|
+listToolbar(list, 'agents')
|
|
@@ -37,7 +40,9 @@ block content
|
|
|
37
40
|
tbody(data-live="rows")
|
|
38
41
|
each a in agents
|
|
39
42
|
tr(class=a.revoked_at ? 'opacity-50' : '')
|
|
40
|
-
|
|
43
|
+
//- The name opens Edit (name and group).
|
|
44
|
+
td
|
|
45
|
+
a.fw-medium(href=`#editAgentModal-${a.id}` data-bs-toggle="modal" role="button")= a.name
|
|
41
46
|
td.text-uppercase= a.kind
|
|
42
47
|
td
|
|
43
48
|
if a.group_id && groupsById[a.group_id]
|
|
@@ -50,13 +55,18 @@ block content
|
|
|
50
55
|
td= fmtDate(a.last_used_at, 'never')
|
|
51
56
|
td= a.revoked_at ? 'revoked' : 'active'
|
|
52
57
|
td
|
|
53
|
-
.d-flex.justify-content-end
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
data-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
.d-flex.justify-content-end.text-nowrap
|
|
59
|
+
form(
|
|
60
|
+
method="post"
|
|
61
|
+
action=`/admin/agents/${a.id}/token`
|
|
62
|
+
data-confirm=a.revoked_at ? `Issue a new token for "${a.name}"? It becomes active again with it.` : `Issue a new token for "${a.name}"? Its current token stops working at once — update it wherever it's used (a CI secret).`
|
|
63
|
+
data-confirm-title="Issue a new token?"
|
|
64
|
+
data-confirm-ok="Issue token"
|
|
65
|
+
data-confirm-tone="primary"
|
|
66
|
+
)
|
|
67
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
68
|
+
button.btn.btn-sm.btn-outline-primary(type="submit" aria-label=`New token for ${a.name}` data-bs-toggle="tooltip" data-bs-title="Issue a new token (the old one stops working; shown once)")
|
|
69
|
+
i.bi.bi-key
|
|
60
70
|
if !a.revoked_at
|
|
61
71
|
form.ms-1(
|
|
62
72
|
method="post"
|
|
@@ -66,7 +76,18 @@ block content
|
|
|
66
76
|
data-confirm-ok="Revoke"
|
|
67
77
|
)
|
|
68
78
|
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
69
|
-
button.btn.btn-sm.btn-outline-
|
|
79
|
+
button.btn.btn-sm.btn-outline-warning(type="submit" aria-label=`Revoke ${a.name}` data-bs-toggle="tooltip" data-bs-title="Revoke the token (a new one can be issued later)")
|
|
80
|
+
i.bi.bi-key-fill
|
|
81
|
+
form.ms-1(
|
|
82
|
+
method="post"
|
|
83
|
+
action=`/admin/agents/${a.id}/delete`
|
|
84
|
+
data-confirm=`Delete CI token "${a.name}"? Its token stops working and it leaves this list for good; its jobs stay, listed under its name.`
|
|
85
|
+
data-confirm-title="Delete CI token?"
|
|
86
|
+
data-confirm-ok="Delete"
|
|
87
|
+
)
|
|
88
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
89
|
+
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label=`Delete ${a.name}` data-bs-toggle="tooltip" data-bs-title="Delete the CI token")
|
|
90
|
+
i.bi.bi-trash
|
|
70
91
|
|
|
71
92
|
.modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
|
|
72
93
|
.modal-dialog
|
package/views/admin/users.pug
CHANGED
|
@@ -212,13 +212,13 @@ block content
|
|
|
212
212
|
table.table.table-sm.small.align-middle
|
|
213
213
|
thead
|
|
214
214
|
tr
|
|
215
|
-
th
|
|
215
|
+
th Timestamp
|
|
216
216
|
th User
|
|
217
217
|
th Action
|
|
218
218
|
tbody
|
|
219
219
|
each e in audit
|
|
220
220
|
tr
|
|
221
|
-
td.text-
|
|
221
|
+
td.text-body-secondary= fmtDate(e.ts)
|
|
222
222
|
td.text-nowrap= e.by
|
|
223
223
|
td= e.action
|
|
224
224
|
else
|