@andrian.yablonskyy/thub-coordinator 1.0.11 → 1.0.12
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 +3 -3
- package/package.json +1 -1
- package/scripts/install-systemd-unit.js +27 -3
- package/scripts/install-target.js +3 -0
- package/scripts/self-update-helper.js +67 -0
- package/src/services/updates.js +31 -3
- package/src/web/routes.js +7 -0
- package/systemd/thub-coordinator-update.path +13 -0
- package/systemd/thub-coordinator-update.service +16 -0
- package/views/layout.pug +23 -2
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ thub-admin group add ci-nightly --comment "shared CI pool"
|
|
|
102
102
|
thub-admin group list
|
|
103
103
|
thub-admin group remove <groupId>
|
|
104
104
|
|
|
105
|
-
#
|
|
105
|
+
# Update the Coordinator by hand (the dashboard's "Update app" button does the same through a root helper).
|
|
106
106
|
thub-admin check-update # installed vs latest published version
|
|
107
107
|
thub-admin self-update [--to <x.y.z>] # sudo npm i -g; the postinstall restarts the service
|
|
108
108
|
```
|
|
@@ -161,10 +161,10 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
161
161
|
|
|
162
162
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
163
163
|
|
|
164
|
-
**Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalHours` (default 6, `0` disables; `updates.registry` for a private mirror) and on **Check for updates**. A newer Coordinator shows as a badge under the logo; Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
|
|
164
|
+
**Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalHours` (default 6, `0` disables; `updates.registry` for a private mirror) and on **Check for updates**. A newer Coordinator shows as an **Update app to X.Y.Z** button in the navbar for admins (a badge under the logo for viewers); Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
|
|
165
165
|
- Clients get a `self-update` command on their next heartbeat and install it through their root `thub-client-update` helper, once none of the host's instances is busy.
|
|
166
166
|
- Agents install it at the start of their next run (`GET /api/v1/agents/me/update`), then re-run the command on the new version.
|
|
167
|
-
- The Coordinator itself
|
|
167
|
+
- The Coordinator itself: the **Update app** button writes `<dataDir>/update-request.json`; the root `thub-coordinator-update.path` unit (installed by `sudo npm i -g`) runs `npm i -g` for it, and the postinstall restarts the service (the dashboard and API are briefly down). Logs: `journalctl -u thub-coordinator-update`. Without that unit, use `thub-admin self-update` on the host.
|
|
168
168
|
|
|
169
169
|
A request is cleared when the Agent/Client reports the new version.
|
|
170
170
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
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": {
|
|
@@ -24,7 +24,12 @@ const fs = require('node:fs'),
|
|
|
24
24
|
UNIT_SRC = path.join(__dirname, '..', 'systemd', UNIT_NAME),
|
|
25
25
|
SERVER_PATH = path.join(__dirname, '..', 'src', 'server.js'),
|
|
26
26
|
UNIT_DEST = `/etc/systemd/system/${UNIT_NAME}`,
|
|
27
|
-
MANUAL_HINT = 'sudo npm i -g @andrian.yablonskyy/thub-coordinator'
|
|
27
|
+
MANUAL_HINT = 'sudo npm i -g @andrian.yablonskyy/thub-coordinator',
|
|
28
|
+
// Root-side self-update (README §10.2): the path unit watches the update
|
|
29
|
+
// request the dashboard writes, the service installs it.
|
|
30
|
+
UPDATE_PATH_UNIT = 'thub-coordinator-update.path',
|
|
31
|
+
UPDATE_SERVICE_UNIT = 'thub-coordinator-update.service',
|
|
32
|
+
UPDATE_HELPER = path.join(__dirname, 'self-update-helper.js');
|
|
28
33
|
|
|
29
34
|
// The checked-in unit only has placeholders — fill in the target user and
|
|
30
35
|
// wherever *this* install's node and server.js actually are, so it works
|
|
@@ -38,6 +43,19 @@ function renderUnit(user, paths){
|
|
|
38
43
|
.replace(/^ReadWritePaths=.*$/m, `ReadWritePaths=${paths.dataDir}`);
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
function renderUpdateUnits(user, paths){
|
|
47
|
+
const nodeDir = path.dirname(process.execPath),
|
|
48
|
+
unitPath = (name) => path.join(__dirname, '..', 'systemd', name);
|
|
49
|
+
return {
|
|
50
|
+
[UPDATE_PATH_UNIT]: fs.readFileSync(unitPath(UPDATE_PATH_UNIT), 'utf8')
|
|
51
|
+
.replace(/^PathModified=.*$/m, `PathModified=${paths.updateRequestFile}`),
|
|
52
|
+
// npm is a `#!/usr/bin/env node` script, so this node goes first on PATH.
|
|
53
|
+
[UPDATE_SERVICE_UNIT]: fs.readFileSync(unitPath(UPDATE_SERVICE_UNIT), 'utf8')
|
|
54
|
+
.replace(/^Environment=PATH=.*$/m, `Environment=PATH=${nodeDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`)
|
|
55
|
+
.replace(/^ExecStart=.*$/m, `ExecStart=${process.execPath} ${UPDATE_HELPER} ${paths.updateRequestFile} ${user.name}`)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
// Best-effort, never fails the `npm install` itself. Runs after
|
|
42
60
|
// install-default-config.js, so the config and data dirs the unit points
|
|
43
61
|
// at already exist. `restart` (not `start`) so an upgrade picks up the
|
|
@@ -51,12 +69,18 @@ function main(){
|
|
|
51
69
|
return;
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
const user = resolveTargetUser()
|
|
72
|
+
const user = resolveTargetUser(),
|
|
73
|
+
paths = targetPaths(user);
|
|
55
74
|
let step = 'write';
|
|
56
75
|
try {
|
|
57
|
-
fs.writeFileSync(UNIT_DEST, renderUnit(user,
|
|
76
|
+
fs.writeFileSync(UNIT_DEST, renderUnit(user, paths));
|
|
77
|
+
for (const [name, content]of Object.entries(renderUpdateUnits(user, paths))){
|
|
78
|
+
fs.writeFileSync(`/etc/systemd/system/${name}`, content);
|
|
79
|
+
}
|
|
58
80
|
step = 'daemon-reload';
|
|
59
81
|
execFileSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
|
|
82
|
+
step = `enable ${UPDATE_PATH_UNIT}`;
|
|
83
|
+
execFileSync('systemctl', ['enable', '--now', UPDATE_PATH_UNIT], { stdio: 'ignore' });
|
|
60
84
|
step = 'enable';
|
|
61
85
|
execFileSync('systemctl', ['enable', UNIT_NAME], { stdio: 'ignore' });
|
|
62
86
|
step = 'restart';
|
|
@@ -66,6 +66,9 @@ function targetPaths(user){
|
|
|
66
66
|
configPath,
|
|
67
67
|
defaultDataDir,
|
|
68
68
|
dataDir,
|
|
69
|
+
// Written by the dashboard's "Update app" button, watched by
|
|
70
|
+
// thub-coordinator-update.path (README §10.2).
|
|
71
|
+
updateRequestFile: path.join(dataDir, 'update-request.json'),
|
|
69
72
|
dataSubdirs: ['artifacts', 'avatars', 'work'].map((d) => path.join(dataDir, d))
|
|
70
73
|
};
|
|
71
74
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file scripts/self-update-helper.js
|
|
3
|
+
* @description Root side of the Coordinator's self-update (README §10.2): run by thub-coordinator-update.service
|
|
4
|
+
* when the dashboard writes an update request, installs the requested version with npm i -g
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs'),
|
|
18
|
+
{ PACKAGES, isValidVersion, compareVersions, npmInstallGlobal } = require('@andrian.yablonskyy/thub-common'),
|
|
19
|
+
{ version: installedVersion } = require('../package.json');
|
|
20
|
+
|
|
21
|
+
// argv: <request file> <user the Coordinator runs as>. The request file
|
|
22
|
+
// is written by the (unprivileged) Coordinator, so only its `version` is
|
|
23
|
+
// used, and only if it's a plain semver — the package is fixed.
|
|
24
|
+
function main(){
|
|
25
|
+
const [requestFile, user] = process.argv.slice(2);
|
|
26
|
+
if (!requestFile || !user){
|
|
27
|
+
console.error('usage: self-update-helper.js <request file> <user>');
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let request;
|
|
32
|
+
try {
|
|
33
|
+
request = JSON.parse(fs.readFileSync(requestFile, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
catch (err){
|
|
36
|
+
// Deleted by a previous run (PathModified= also fires on removal).
|
|
37
|
+
if (err.code === 'ENOENT'){
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
console.error(`thub-coordinator-update: unreadable request ${requestFile}: ${err.message}`);
|
|
41
|
+
fs.rmSync(requestFile, { force: true });
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
fs.rmSync(requestFile, { force: true });
|
|
45
|
+
|
|
46
|
+
const target = request?.version;
|
|
47
|
+
if (!isValidVersion(target)){
|
|
48
|
+
console.error('thub-coordinator-update: ignoring request with an invalid version');
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
if (compareVersions(installedVersion, target) >= 0){
|
|
52
|
+
console.log(`thub-coordinator-update: already on v${installedVersion} (requested v${target})`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(`thub-coordinator-update: v${installedVersion} -> v${target} (requested by ${request.requestedBy || 'the dashboard'})`);
|
|
57
|
+
// SUDO_USER makes the postinstall scripts target the Coordinator's user,
|
|
58
|
+
// as a `sudo npm i -g` by that user would (install-target.js); the
|
|
59
|
+
// postinstall then restarts thub-coordinator on the new version.
|
|
60
|
+
const status = npmInstallGlobal(PACKAGES.coordinator, target, { env: { ...process.env, SUDO_USER: user } });
|
|
61
|
+
if (status !== 0){
|
|
62
|
+
console.error(`thub-coordinator-update: npm i -g failed (exit ${status})`);
|
|
63
|
+
}
|
|
64
|
+
return status;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.exit(main());
|
package/src/services/updates.js
CHANGED
|
@@ -13,15 +13,21 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const fs = require('node:fs'),
|
|
17
|
+
path = require('node:path'),
|
|
18
|
+
{ PACKAGES, fetchLatestVersion, defaultRegistry, isNewer } = require('@andrian.yablonskyy/thub-common'),
|
|
17
19
|
{ version: coordinatorVersion } = require('../../package.json');
|
|
18
20
|
|
|
21
|
+
// Installed by scripts/install-systemd-unit.js; runs the actual `npm i -g`
|
|
22
|
+
// as root when the dashboard writes an update request (README §10.2).
|
|
23
|
+
const UPDATE_PATH_UNIT = '/etc/systemd/system/thub-coordinator-update.path';
|
|
24
|
+
|
|
19
25
|
// Latest published versions, refreshed every `updates.checkIntervalHours`
|
|
20
26
|
// and on demand from the dashboard. Kept in memory only — a restart just
|
|
21
27
|
// checks again.
|
|
22
28
|
function createUpdatesService({ config }){
|
|
23
29
|
const registry = config.updates.registry || defaultRegistry(),
|
|
24
|
-
state = { latest: {}, checkedAt: null, error: null };
|
|
30
|
+
state = { latest: {}, checkedAt: null, error: null, coordinatorPending: null };
|
|
25
31
|
let inFlight = null;
|
|
26
32
|
|
|
27
33
|
async function checkNow(){
|
|
@@ -55,6 +61,28 @@ function createUpdatesService({ config }){
|
|
|
55
61
|
};
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
// Navbar "Update app" button: hand the latest version to the root
|
|
65
|
+
// thub-coordinator-update helper, whose install restarts this process
|
|
66
|
+
// (so `coordinatorPending` only lasts until then).
|
|
67
|
+
function requestCoordinatorUpdate(requestedBy){
|
|
68
|
+
const target = status().coordinatorUpdate;
|
|
69
|
+
if (!target){
|
|
70
|
+
throw Object.assign(new Error(`Already on the latest version (v${coordinatorVersion}).`), { status: 409 });
|
|
71
|
+
}
|
|
72
|
+
if (!fs.existsSync(UPDATE_PATH_UNIT)){
|
|
73
|
+
throw Object.assign(
|
|
74
|
+
new Error(`Self-update isn't installed on this host (${UPDATE_PATH_UNIT}) — update by hand: thub-admin self-update`),
|
|
75
|
+
{ status: 501 }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(config.dataDir, 'update-request.json'),
|
|
80
|
+
JSON.stringify({ version: target, requestedBy, requestedAt: new Date().toISOString() }) + '\n'
|
|
81
|
+
);
|
|
82
|
+
state.coordinatorPending = target;
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
// The version a self-update request targets: the latest known one,
|
|
59
87
|
// checking first if nothing has been fetched yet.
|
|
60
88
|
async function targetVersion(app){
|
|
@@ -77,7 +105,7 @@ function createUpdatesService({ config }){
|
|
|
77
105
|
timer.unref?.();
|
|
78
106
|
}
|
|
79
107
|
|
|
80
|
-
return { checkNow, status, targetVersion, start };
|
|
108
|
+
return { checkNow, status, targetVersion, requestCoordinatorUpdate, start };
|
|
81
109
|
}
|
|
82
110
|
|
|
83
111
|
module.exports = { createUpdatesService };
|
package/src/web/routes.js
CHANGED
|
@@ -60,6 +60,7 @@ function createWebRouter({ services, config }){
|
|
|
60
60
|
// Latest published versions (README §10.2) — navbar, Agents, Resources.
|
|
61
61
|
res.locals.updates = services.updates.status();
|
|
62
62
|
res.locals.isNewer = isNewer;
|
|
63
|
+
res.locals.currentPath = req.originalUrl;
|
|
63
64
|
if (req.session){
|
|
64
65
|
req.session.flash = [];
|
|
65
66
|
}
|
|
@@ -259,6 +260,12 @@ function createWebRouter({ services, config }){
|
|
|
259
260
|
return s.error ? `Version check: ${found || 'nothing found'} (errors: ${s.error})` : `Latest versions: ${found}.`;
|
|
260
261
|
}));
|
|
261
262
|
|
|
263
|
+
router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
|
|
264
|
+
const version = services.updates.requestCoordinatorUpdate(req.session.user.username);
|
|
265
|
+
console.log(`Coordinator self-update to v${version} requested by ${req.session.user.username}`);
|
|
266
|
+
return `Updating the Coordinator to v${version} — the dashboard restarts in a minute or two; reload the page then.`;
|
|
267
|
+
}));
|
|
268
|
+
|
|
262
269
|
router.post('/resources/update-all', requireAdminRole, updateAction(async () => {
|
|
263
270
|
const version = await services.updates.targetVersion('client'),
|
|
264
271
|
n = services.registry.requestUpdateAll(version);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=TestHub Coordinator self-update request watcher
|
|
3
|
+
|
|
4
|
+
# PathModified= is rewritten by scripts/install-systemd-unit.js to the
|
|
5
|
+
# update-request.json in the Coordinator's dataDir. The dashboard's
|
|
6
|
+
# "Update app" button writes it; this starts thub-coordinator-update.service
|
|
7
|
+
# (as root) to install the requested version.
|
|
8
|
+
[Path]
|
|
9
|
+
PathModified=/var/lib/thub/.thub/update-request.json
|
|
10
|
+
Unit=thub-coordinator-update.service
|
|
11
|
+
|
|
12
|
+
[Install]
|
|
13
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=TestHub Coordinator self-update
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
# ExecStart and Environment= are rewritten by scripts/install-systemd-unit.js
|
|
7
|
+
# to this install's real node and helper paths, the request file, and the
|
|
8
|
+
# user the Coordinator runs as. Runs as root: `npm i -g` writes the global
|
|
9
|
+
# node_modules, and its postinstall re-renders these units and restarts
|
|
10
|
+
# thub-coordinator. The helper only ever installs
|
|
11
|
+
# @andrian.yablonskyy/thub-coordinator, at a strictly validated version.
|
|
12
|
+
[Service]
|
|
13
|
+
Type=oneshot
|
|
14
|
+
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
15
|
+
ExecStart=/usr/bin/node /usr/lib/node_modules/@andrian.yablonskyy/thub-coordinator/scripts/self-update-helper.js /var/lib/thub/.thub/update-request.json thub
|
|
16
|
+
TimeoutStartSec=15min
|
package/views/layout.pug
CHANGED
|
@@ -14,11 +14,11 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
14
14
|
span TestHub
|
|
15
15
|
small.text-body-secondary.fw-normal.mt-1(style="font-size: .7rem;")
|
|
16
16
|
| ver. #{coordinatorVersion}
|
|
17
|
-
if user && updates.coordinatorUpdate
|
|
17
|
+
if user && user.role !== 'admin' && updates.coordinatorUpdate
|
|
18
18
|
span.badge.text-bg-warning.ms-1(
|
|
19
19
|
data-bs-toggle="tooltip"
|
|
20
20
|
data-bs-placement="bottom"
|
|
21
|
-
data-bs-title="
|
|
21
|
+
data-bs-title="An admin can update the Coordinator from the navbar"
|
|
22
22
|
)= `v${updates.coordinatorUpdate} available`
|
|
23
23
|
button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
|
|
24
24
|
span.navbar-toggler-icon
|
|
@@ -37,6 +37,27 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
37
37
|
li.nav-item
|
|
38
38
|
a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
|
|
39
39
|
ul.navbar-nav
|
|
40
|
+
if user && user.role === 'admin' && updates.coordinatorUpdate
|
|
41
|
+
li.nav-item.d-flex.align-items-center.me-3
|
|
42
|
+
if updates.coordinatorPending
|
|
43
|
+
button.btn.btn-sm.btn-warning(type="button" disabled)
|
|
44
|
+
span.spinner-border.spinner-border-sm.me-1(aria-hidden="true")
|
|
45
|
+
= `Updating app to ${updates.coordinatorPending}…`
|
|
46
|
+
else
|
|
47
|
+
form(
|
|
48
|
+
method="post"
|
|
49
|
+
action="/updates/coordinator"
|
|
50
|
+
onsubmit=`return confirm('Update the Coordinator to v${updates.coordinatorUpdate}? It restarts, and the dashboard and API are briefly unavailable.')`
|
|
51
|
+
)
|
|
52
|
+
input(type="hidden" name="returnTo" value=currentPath)
|
|
53
|
+
button.btn.btn-sm.btn-warning(
|
|
54
|
+
type="submit"
|
|
55
|
+
data-bs-toggle="tooltip"
|
|
56
|
+
data-bs-placement="bottom"
|
|
57
|
+
data-bs-title=`Installed v${coordinatorVersion} — installs v${updates.coordinatorUpdate} and restarts the Coordinator`
|
|
58
|
+
)
|
|
59
|
+
i.bi.bi-cloud-download.me-1
|
|
60
|
+
= `Update app to ${updates.coordinatorUpdate}`
|
|
40
61
|
li.nav-item.d-flex.align-items-center.me-3
|
|
41
62
|
button#theme-toggle.btn.btn-sm.btn-outline-secondary(
|
|
42
63
|
type="button"
|