@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.7",
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": {
@@ -0,0 +1,3 @@
1
+ -- Deleting a CI token (dashboard): its token stops working and it leaves the
2
+ -- CI tokens list, but the row stays — its jobs reference it and keep its name.
3
+ ALTER TABLE agents ADD COLUMN deleted_at TEXT;
@@ -29,8 +29,8 @@ function createAgentsService(db, { events }){
29
29
  }
30
30
 
31
31
  function list(){
32
- return db.prepare('SELECT id, name, kind, user_id, group_id, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC')
33
- .all();
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'));
@@ -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 &quot;ci-fw&quot;|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
+ });
@@ -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]*?<code>agt_/);
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(/&#39;/g, '\'').replace(/&gt;/g, '>')]).reverse(); // oldest first
426
- assert.match(log, /<th class="text-nowrap">Timestamp<\/th><th>User<\/th><th>Action<\/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)'],
@@ -15,10 +15,13 @@ block content
15
15
  +pageSizePicker(list)
16
16
 
17
17
  if newToken
18
- .alert.alert-warning
19
- strong Token (shown once):
20
- |
21
- code= newToken
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
- td= a.name
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
- button.btn.btn-sm.btn-outline-secondary(
55
- type="button"
56
- data-bs-toggle="modal"
57
- data-bs-target=`#editAgentModal-${a.id}`
58
- aria-label=`Edit agent ${a.name}`
59
- ) Edit
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-danger(type="submit") Revoke
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
@@ -212,13 +212,13 @@ block content
212
212
  table.table.table-sm.small.align-middle
213
213
  thead
214
214
  tr
215
- th.text-nowrap Timestamp
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-nowrap.text-body-secondary= fmtDate(e.ts)
221
+ td.text-body-secondary= fmtDate(e.ts)
222
222
  td.text-nowrap= e.by
223
223
  td= e.action
224
224
  else