@andrian.yablonskyy/thub-coordinator 1.1.14 → 1.1.16

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 CHANGED
@@ -50,6 +50,12 @@ The Coordinator loads a plain **JSON** config file (no YAML support). Resolution
50
50
 
51
51
  `sessionSecret` signs the dashboard's session cookie and the HMAC on artifact download links; `clientJoinKey` is the shared secret Clients self-register with — omit or leave `null` to disable auto-registration entirely. `trustProxy` is Express's [`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) setting (default `loopback`): which reverse proxies' `X-Forwarded-For` to believe for a Client's external address on the resource card — e.g. `"10.0.0.0/8"` or `1` for a proxy on another host, `false` to always use the socket address. Individual `THUB_LISTEN` / `THUB_PUBLIC_URL` / `THUB_DATA_DIR` / `THUB_SESSION_SECRET` / `THUB_CLIENT_JOIN_KEY` env vars override whatever the file set.
52
52
 
53
+ **Kubernetes:** a Dockerfile, manifests (one replica, a volume for SQLite, ingress with SSE-friendly settings) and the upgrade, backup and HTTPS notes are in the main README, §13.3, and on the dashboard's Help page (*Running in Kubernetes*). The image starts without `--self-update`: update by deploying a new image.
54
+
55
+ **Database file:** `dbPath` (or `THUB_DB_PATH`). The default is `<dataDir>/thub.db`; a relative path is taken from `dataDir`. Its directory must be writable, because SQLite keeps `-wal`/`-shm` files next to it. Details: main README §13.
56
+
57
+ **HTTPS without a reverse proxy:** set `tls.certFile` / `tls.keyFile` (PEM, readable by the service user; optional `tls.caFile`, `tls.passphrase`), or `THUB_TLS_CERT` / `THUB_TLS_KEY` / `THUB_TLS_CA` / `THUB_TLS_PASSPHRASE`, and an `https://` `publicUrl`. The Coordinator then serves HTTPS itself. Bad files stop the start with the reason, and renewed files are picked up within a minute or on `SIGHUP`. For a self-signed or private CA, start Clients and Agents with `NODE_EXTRA_CA_CERTS`. Details: main README §13.
58
+
53
59
  `npm install -g` creates `~/.config/thub/coordinator.json` for you if it doesn't already exist, with all the options above, `dataDir` set to `~/var/lib/thub` and a freshly generated random `sessionSecret` (not the placeholder above), readable only by its owner — a re-install never overwrites it or regenerates the secret. Set `publicUrl` and `clientJoinKey` yourself before relying on auto-registration.
54
60
 
55
61
  ## Running it
@@ -79,9 +85,9 @@ Talks to the Coordinator's SQLite database directly — no running server requir
79
85
  # THUB_BOOTSTRAP_ADMIN_PASSWORD above).
80
86
  thub-admin create-admin alice s3cret --role admin
81
87
 
82
- # Register a CI or developer identity — the only credential still issued
83
- # by an admin; the token is shown once.
84
- thub-admin agent add ci-firmware --kind ci
88
+ # A CI token for a pipeline (people get their key with: thub-admin user key);
89
+ # the token is shown once.
90
+ thub-admin agent add ci-firmware
85
91
  # -> Agent agt_... created. Token (shown once): agt_...
86
92
 
87
93
  # Mint the shared secret Clients use to self-register, and put it in both
@@ -102,7 +108,7 @@ thub-admin group add ci-nightly --comment "shared CI pool"
102
108
  thub-admin group list
103
109
  thub-admin group remove <groupId>
104
110
 
105
- # Update the Coordinator by hand (the dashboard's "Update app" button does the same through a root helper).
111
+ # Update the Coordinator by hand (with --self-update, the dashboard's "Update app" button does the same through a root helper).
106
112
  thub-admin check-update # installed vs latest published version
107
113
  thub-admin self-update [--to <x.y.z>] # sudo npm i -g; the postinstall restarts the service
108
114
  ```
@@ -150,7 +156,7 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
150
156
  | Page | Content |
151
157
  |---|---|
152
158
  | `/` | Runner cards by status, queue length, jobs in the last 24 h. Click a card for its runner card (below). |
153
- | `/runners` | **Runners** (formerly Resources; `/resources` redirects): Type, Client version, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. Click a row for its resource card (below). |
159
+ | `/runners` | **Runners** (formerly Resources; `/resources` redirects): Type, Client version, labels, groups, status (with the last heartbeat under it), busy source/reason; admin maintenance/rotate-token actions. Click a row for its resource card (below). |
154
160
  | `/groups` | Resource groups — id/name/comment, member count; admin create/rename/delete. Membership itself is set per-resource in the Client's own config. |
155
161
  | `/jobs` | Filterable job list (state, source, resource), with the job's `user` label if set; admin **Reset queue** / **Clean history**. |
156
162
  | `/jobs/:id` | Spec, timeline, live log viewer with stream filter, artifact downloads, cancel button. |
@@ -166,7 +172,7 @@ The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, to
166
172
  **Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalMin` (default 15 minutes, `0` disables; the older `checkIntervalHours` is ignored; `updates.registry` for a private mirror) and immediately with the navbar's **check for updates** button (admins; also on the Agents and Resources pages), which reports in a toast whether the Coordinator is current and which connected resources can update their Client. 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:
167
173
  - 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.
168
174
  - 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.
169
- - 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.
175
+ - The Coordinator itself, only when started with `--self-update` (off by default; under systemd add it to the unit's `ExecStart`, a re-install keeps it): 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.
170
176
 
171
177
  A request is cleared when the Agent/Client reports the new version.
172
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
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": {
@@ -457,3 +457,20 @@ fieldset:disabled .thub-tags-box {
457
457
  white-space: nowrap;
458
458
  }
459
459
  }
460
+
461
+ /* A second, smaller line in a table cell: the version under a name (Runners,
462
+ CI tokens), the last heartbeat under a status (Runners). */
463
+ .thub-subline {
464
+ font-size: 0.7rem;
465
+ margin-top: 0.125rem;
466
+ }
467
+
468
+ .thub-subline .badge {
469
+ font-size: 0.65rem;
470
+ }
471
+
472
+ .thub-subline .btn {
473
+ --bs-btn-padding-y: 0;
474
+ --bs-btn-padding-x: 0.3rem;
475
+ --bs-btn-font-size: 0.7rem;
476
+ }
@@ -81,7 +81,9 @@ function main(){
81
81
  paths = targetPaths(user);
82
82
  try {
83
83
  mkdirOwned(paths.configDir, user);
84
- for (const dir of [paths.dataDir, ...paths.dataSubdirs]){
84
+ // The database's own directory too, when dbPath puts it elsewhere: the
85
+ // systemd unit's ReadWritePaths needs it to exist.
86
+ for (const dir of [paths.dataDir, ...paths.dataSubdirs, ...(paths.dbDir ? [paths.dbDir] : [])]){
85
87
  mkdirOwned(dir, user);
86
88
  }
87
89
  if (!fs.existsSync(paths.configPath)){
@@ -34,13 +34,24 @@ const fs = require('node:fs'),
34
34
  // The checked-in unit only has placeholders — fill in the target user and
35
35
  // wherever *this* install's node and server.js actually are, so it works
36
36
  // regardless of npm prefix or an nvm-managed Node.
37
+ // `--self-update` (README §10.2) is off by default; once added to the
38
+ // installed unit's ExecStart, a re-install keeps it there.
39
+ function selfUpdateFlag(existingUnit = UNIT_DEST){
40
+ try {
41
+ return /^ExecStart=.*\s--self-update(\s|$)/m.test(fs.readFileSync(existingUnit, 'utf8')) ? ' --self-update' : '';
42
+ }
43
+ catch {
44
+ return '';
45
+ }
46
+ }
47
+
37
48
  function renderUnit(user, paths){
38
49
  return fs.readFileSync(UNIT_SRC, 'utf8')
39
50
  .replace(/^User=.*$/m, `User=${user.name}`)
40
51
  .replace(/^Group=.*$/m, `Group=${user.gid}`)
41
52
  .replace(/^Environment=THUB_COORDINATOR_CONFIG=.*$/m, `Environment=THUB_COORDINATOR_CONFIG=${paths.configPath}`)
42
- .replace(/^ExecStart=.*$/m, `ExecStart=${process.execPath} ${SERVER_PATH}`)
43
- .replace(/^ReadWritePaths=.*$/m, `ReadWritePaths=${paths.dataDir}`);
53
+ .replace(/^ExecStart=.*$/m, `ExecStart=${process.execPath} ${SERVER_PATH}${selfUpdateFlag()}`)
54
+ .replace(/^ReadWritePaths=.*$/m, `ReadWritePaths=${[paths.dataDir, paths.dbDir].filter(Boolean).join(' ')}`);
44
55
  }
45
56
 
46
57
  function renderUpdateUnits(user, paths){
@@ -66,6 +66,9 @@ function targetPaths(user){
66
66
  configPath,
67
67
  defaultDataDir,
68
68
  dataDir,
69
+ // The database's directory, when the config's dbPath puts it outside
70
+ // dataDir: the systemd unit must allow writes there too.
71
+ dbDir: configuredDbDir(configPath, dataDir),
69
72
  // Written by the dashboard's "Update app" button, watched by
70
73
  // thub-coordinator-update.path (README §10.2).
71
74
  updateRequestFile: path.join(dataDir, 'update-request.json'),
@@ -73,6 +76,21 @@ function targetPaths(user){
73
76
  };
74
77
  }
75
78
 
79
+ function configuredDbDir(configPath, dataDir){
80
+ try {
81
+ const dbPath = JSON.parse(fs.readFileSync(configPath, 'utf8'))?.dbPath;
82
+ if (typeof dbPath !== 'string' || !dbPath.trim()){
83
+ return null;
84
+ }
85
+ const dir = path.dirname(path.resolve(dataDir, dbPath)),
86
+ inside = path.relative(dataDir, dir);
87
+ return inside === '' || (!inside.startsWith('..') && !path.isAbsolute(inside)) ? null : dir;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+
76
94
  function configuredDataDir(configPath){
77
95
  try {
78
96
  const dataDir = JSON.parse(fs.readFileSync(configPath, 'utf8'))?.dataDir;
package/src/config.js CHANGED
@@ -27,6 +27,10 @@ const DEFAULTS = {
27
27
  // default setup — listening on 127.0.0.1 behind a local reverse proxy.
28
28
  trustProxy: 'loopback',
29
29
  dataDir: path.join(process.cwd(), '.data'),
30
+ // The SQLite database file. null = <dataDir>/thub.db; a relative path is
31
+ // taken from dataDir. Its directory must be writable: SQLite keeps its
32
+ // -wal/-shm files next to it.
33
+ dbPath: null,
30
34
  sessionSecret: 'dev-only-change-me',
31
35
  session: {
32
36
  // The dashboard session cookie's Secure flag (server.js cookieSecure):
@@ -34,6 +38,16 @@ const DEFAULTS = {
34
38
  // Secure only on a connection that is itself HTTPS); true / false force it.
35
39
  secureCookie: 'auto'
36
40
  },
41
+ // HTTPS served by the Coordinator itself (tls.js): PEM files, readable by
42
+ // its user. No certFile/keyFile = plain HTTP, e.g. behind a reverse proxy
43
+ // that terminates TLS. caFile: intermediate certificates, if not already
44
+ // in certFile; passphrase: for an encrypted key.
45
+ tls: {
46
+ certFile: null,
47
+ keyFile: null,
48
+ caFile: null,
49
+ passphrase: null
50
+ },
37
51
  // Shared secret Clients present to self-register (see api/resource.js).
38
52
  // null disables auto-registration entirely — set it explicitly to turn it on.
39
53
  clientJoinKey: null,
@@ -127,12 +141,20 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
127
141
  if (process.env.THUB_DATA_DIR){
128
142
  config.dataDir = process.env.THUB_DATA_DIR;
129
143
  }
144
+ if (process.env.THUB_DB_PATH){
145
+ config.dbPath = process.env.THUB_DB_PATH;
146
+ }
130
147
  if (process.env.THUB_SESSION_SECRET){
131
148
  config.sessionSecret = process.env.THUB_SESSION_SECRET;
132
149
  }
133
150
  if (process.env.THUB_CLIENT_JOIN_KEY){
134
151
  config.clientJoinKey = process.env.THUB_CLIENT_JOIN_KEY;
135
152
  }
153
+ for (const [env, key]of [['THUB_TLS_CERT', 'certFile'], ['THUB_TLS_KEY', 'keyFile'], ['THUB_TLS_CA', 'caFile'], ['THUB_TLS_PASSPHRASE', 'passphrase']]){
154
+ if (process.env[env]){
155
+ config.tls[key] = process.env[env];
156
+ }
157
+ }
136
158
 
137
159
  for (const key of ['requestsPerMinute', 'loginPerMinute']){
138
160
  const v = config.rateLimit[key];
@@ -160,20 +182,24 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
160
182
  const [host, port] = config.listen.split(':');
161
183
  config.host = host;
162
184
  config.port = Number(port);
163
- config.dbPath = path.join(config.dataDir, 'thub.db');
185
+ if (config.dbPath !== null && (typeof config.dbPath !== 'string' || !config.dbPath.trim())){
186
+ throw new Error(`dbPath must be a file path, or null for <dataDir>/thub.db (got ${JSON.stringify(config.dbPath)}) in ${candidate || 'the config'}`);
187
+ }
188
+ config.dbPath = config.dbPath ? path.resolve(config.dataDir, config.dbPath) : path.join(config.dataDir, 'thub.db');
164
189
  config.workDir = path.join(config.dataDir, 'work');
165
190
  config.avatarsDir = path.join(config.dataDir, 'avatars');
166
191
 
167
192
  ensureWritableDir(config.dataDir, config.configPath);
168
193
  ensureWritableDir(config.avatarsDir, config.configPath);
194
+ ensureWritableDir(path.dirname(config.dbPath), config.configPath, 'dbPath / THUB_DB_PATH');
169
195
 
170
196
  return config;
171
197
  }
172
198
 
173
- // SQLite opens thub.db (plus its -wal/-shm files) inside dataDir, so an
199
+ // SQLite opens thub.db (plus its -wal/-shm files) in its directory, so an
174
200
  // existing but unwritable dataDir — typically one created by an earlier
175
201
  // run under sudo — would otherwise fail later as a bare SQLITE_CANTOPEN.
176
- function ensureWritableDir(dir, configPath){
202
+ function ensureWritableDir(dir, configPath, setting = 'dataDir / THUB_DATA_DIR'){
177
203
  try {
178
204
  fs.mkdirSync(dir, { recursive: true });
179
205
  fs.accessSync(dir, fs.constants.W_OK);
@@ -181,7 +207,7 @@ function ensureWritableDir(dir, configPath){
181
207
  catch (err){
182
208
  throw new Error(
183
209
  `Coordinator data directory ${dir} is not writable by this user (${err.code || err.message}). ` +
184
- `Fix its ownership, or set dataDir in ${configPath || 'the config'} / THUB_DATA_DIR to a writable path.`
210
+ `Fix its ownership, or set ${setting} (in ${configPath || 'the config'}) to a writable path.`
185
211
  );
186
212
  }
187
213
  }
package/src/server.js CHANGED
@@ -21,6 +21,7 @@ const fs = require('node:fs'),
21
21
  session = require('express-session'),
22
22
 
23
23
  { loadConfig } = require('./config'),
24
+ { createServer, readTlsOptions } = require('./tls'),
24
25
  { openDb } = require('./db'),
25
26
  { bus } = require('./services/bus'),
26
27
  { createEventsService } = require('./services/events'),
@@ -209,9 +210,16 @@ function createApp(config, services){
209
210
  return app;
210
211
  }
211
212
 
212
- function start(configPath){
213
- const config = loadConfig(configPath),
214
- services = buildServices(config),
213
+ // `node server.js [--self-update]`. --self-update turns on the dashboard's
214
+ // "Update app" (an admin installs a newer Coordinator through the root
215
+ // thub-coordinator-update helper, README §10.2). Off by default — e.g. in a
216
+ // container, where a new image is the update.
217
+ function start(configPath, argv = process.argv.slice(2)){
218
+ const config = loadConfig(configPath);
219
+ config.selfUpdate = argv.includes('--self-update');
220
+ // Certificate problems stop the Coordinator before anything else starts.
221
+ readTlsOptions(config.tls, config.configPath || 'the config');
222
+ const services = buildServices(config),
215
223
  app = createApp(config, services);
216
224
 
217
225
  ensureBootstrapAdmin(services);
@@ -219,10 +227,15 @@ function start(configPath){
219
227
  // Clients and jobs to work on the dashboard with (src/dev/virtual.js).
220
228
  services.dev = startDevMode(services, config);
221
229
 
222
- const server = app.listen(config.port, config.host, () => {
223
- console.log(`Config: ${config.configPath || '(built-in defaults)'}; data: ${config.dataDir}`);
224
- console.log(`TestHub Coordinator listening on http://${config.host}:${config.port}`);
230
+ const { server, scheme } = createServer(app, config);
231
+ server.listen(config.port, config.host, () => {
232
+ console.log(`Config: ${config.configPath || '(built-in defaults)'}; data: ${config.dataDir}; database: ${config.dbPath}`);
233
+ console.log(`TestHub Coordinator listening on ${scheme}://${config.host}:${config.port}`);
234
+ if (scheme === 'https' && !/^https:/.test(config.publicUrl)){
235
+ console.warn(`tls: serving HTTPS, but publicUrl is ${config.publicUrl} — set it to the https:// address Clients and Agents use`);
236
+ }
225
237
  console.log(`Coordinator v${require('../package.json').version}, thub-common v${commonVersion()} (${commonPath()})`);
238
+ console.log(config.selfUpdate ? 'Self-update: on (--self-update)' : 'Self-update: off (start with --self-update to update from the dashboard)');
226
239
  });
227
240
 
228
241
  return { app, server, services, config };
@@ -120,6 +120,8 @@ function createUpdatesService({ config, pathUnit = UPDATE_PATH_UNIT, fetchLatest
120
120
  return {
121
121
  ...state,
122
122
  coordinatorVersion,
123
+ // The dashboard's "Update app": only with `--self-update` (server.js).
124
+ selfUpdate: Boolean(config.selfUpdate),
123
125
  coordinatorUpdate: isNewer(state.latest.coordinator, coordinatorVersion) ? state.latest.coordinator : null
124
126
  };
125
127
  }
@@ -128,6 +130,10 @@ function createUpdatesService({ config, pathUnit = UPDATE_PATH_UNIT, fetchLatest
128
130
  // thub-coordinator-update helper, whose install restarts this process
129
131
  // (so `coordinatorPending` only lasts until then).
130
132
  function requestCoordinatorUpdate(requestedBy){
133
+ if (!config.selfUpdate){
134
+ throw Object.assign(new Error('Self-update is off: start the Coordinator with --self-update to update it from the dashboard ' +
135
+ '(README §10.2), or update it by hand — thub-admin self-update, or a new container image'), { status: 403 });
136
+ }
131
137
  const target = status().coordinatorUpdate;
132
138
  if (!target){
133
139
  throw Object.assign(new Error(`Already on the latest version (v${coordinatorVersion}).`), { status: 409 });
package/src/tls.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file packages/coordinator/src/tls.js
3
+ * @description HTTPS for the Coordinator itself: certificate files from the config, checked at start, reloaded when renewed (README §13)
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
+ http = require('node:http'),
18
+ https = require('node:https'),
19
+ tls = require('node:tls');
20
+
21
+ // How often renewed certificate files are looked for (Let's Encrypt,
22
+ // cert-manager rotate them in place). SIGHUP reloads at once.
23
+ const WATCH_INTERVAL_MS = 60_000;
24
+
25
+ // The `tls` config section as Node TLS options — or null for plain HTTP
26
+ // (no certFile/keyFile). Throws with a readable reason if the files can't be
27
+ // read or don't make a usable certificate/key pair, so a mistake stops the
28
+ // Coordinator at start instead of failing every connection later.
29
+ function readTlsOptions(cfg, where = 'the config'){
30
+ if (!cfg || (!cfg.certFile && !cfg.keyFile)){
31
+ return null;
32
+ }
33
+ if (!cfg.certFile || !cfg.keyFile){
34
+ throw new Error(`tls: set both certFile and keyFile (or neither, for plain HTTP) in ${where}`);
35
+ }
36
+ const read = (key) => {
37
+ try {
38
+ return fs.readFileSync(cfg[key]);
39
+ }
40
+ catch (err){
41
+ throw new Error(`tls.${key} ${cfg[key]} can't be read by this user (${err.code || err.message}) — check the path and its permissions`);
42
+ }
43
+ },
44
+ options = {
45
+ cert: read('certFile'),
46
+ key: read('keyFile'),
47
+ ...(cfg.caFile ? { ca: read('caFile') } : {}),
48
+ ...(cfg.passphrase ? { passphrase: cfg.passphrase } : {})
49
+ };
50
+ try {
51
+ tls.createSecureContext(options); // a cert that doesn't match its key, a wrong passphrase…
52
+ }
53
+ catch (err){
54
+ throw new Error(`tls: ${cfg.certFile} / ${cfg.keyFile} aren't a usable certificate and key: ${err.message}`);
55
+ }
56
+ return options;
57
+ }
58
+
59
+ // The Coordinator's server: HTTPS with the configured certificate, else
60
+ // plain HTTP (behind a TLS-terminating proxy, as before). With HTTPS, a
61
+ // renewed certificate is picked up without a restart: its files are checked
62
+ // every minute, and on SIGHUP.
63
+ function createServer(app, config, { log = console } = {}){
64
+ const options = readTlsOptions(config.tls);
65
+ if (!options){
66
+ return { server: http.createServer(app), scheme: 'http' };
67
+ }
68
+ const server = https.createServer(options, app),
69
+ files = [config.tls.certFile, config.tls.keyFile, config.tls.caFile].filter(Boolean),
70
+ reload = (why) => {
71
+ try {
72
+ server.setSecureContext(readTlsOptions(config.tls));
73
+ log.log(`tls: certificate reloaded (${why})`);
74
+ }
75
+ catch (err){
76
+ log.error(`tls: keeping the current certificate — ${err.message}`);
77
+ }
78
+ },
79
+ onChange = (cur, prev) => {
80
+ if (cur.mtimeMs !== prev.mtimeMs){
81
+ reload('file changed');
82
+ }
83
+ },
84
+ onHup = () => reload('SIGHUP');
85
+ for (const f of files){
86
+ fs.watchFile(f, { interval: WATCH_INTERVAL_MS, persistent: false }, onChange);
87
+ }
88
+ process.on('SIGHUP', onHup);
89
+ server.on('close', () => {
90
+ files.forEach((f) => fs.unwatchFile(f, onChange));
91
+ process.off('SIGHUP', onHup);
92
+ });
93
+ return { server, scheme: 'https' };
94
+ }
95
+
96
+ module.exports = { readTlsOptions, createServer };
@@ -3,6 +3,8 @@ Description=TestHub Coordinator
3
3
  After=network-online.target
4
4
  Wants=network-online.target
5
5
 
6
+ # Add --self-update to ExecStart to let admins update the Coordinator from
7
+ # the dashboard ("Update app", README §10.2); a re-install keeps it.
6
8
  # User, Group, THUB_COORDINATOR_CONFIG, ReadWritePaths and ExecStart are
7
9
  # rewritten by scripts/install-systemd-unit.js at install time to the user
8
10
  # who ran `sudo npm i -g` and this install's real node/server.js paths.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @file packages/coordinator/test/config.test.js
3
+ * @description Tests: the Coordinator config — database file location
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 test = require('node:test'),
17
+ assert = require('node:assert/strict'),
18
+ fs = require('node:fs'),
19
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { loadConfig } = require('../src/config'),
22
+ { buildServices } = require('../src/server');
23
+
24
+ function withConfig(content){
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-cfg-')),
26
+ file = path.join(dir, 'coordinator.json');
27
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 }, ...content }));
28
+ return { dir, file };
29
+ }
30
+
31
+ test('dbPath: <dataDir>/thub.db by default; absolute, relative to dataDir, or THUB_DB_PATH; the database is opened there', () => {
32
+ const plain = withConfig({});
33
+ assert.equal(loadConfig(plain.file).dbPath, path.join(plain.dir, 'data', 'thub.db'));
34
+
35
+ const rel = withConfig({ dbPath: 'db/testhub.sqlite' });
36
+ assert.equal(loadConfig(rel.file).dbPath, path.join(rel.dir, 'data', 'db', 'testhub.sqlite'));
37
+
38
+ const abs = withConfig({}),
39
+ elsewhere = path.join(abs.dir, 'fast-disk', 'thub.db');
40
+ fs.writeFileSync(abs.file, JSON.stringify({ dataDir: path.join(abs.dir, 'data'), dbPath: elsewhere, updates: { checkIntervalMin: 0 } }));
41
+ const config = loadConfig(abs.file);
42
+ assert.equal(config.dbPath, elsewhere);
43
+ buildServices(config).db.close();
44
+ assert.ok(fs.existsSync(elsewhere), 'the database file is created at dbPath');
45
+ assert.ok(!fs.existsSync(path.join(abs.dir, 'data', 'thub.db')));
46
+
47
+ const env = withConfig({ dbPath: 'from-file.db' }),
48
+ saved = process.env.THUB_DB_PATH;
49
+ process.env.THUB_DB_PATH = path.join(env.dir, 'from-env.db');
50
+ try {
51
+ assert.equal(loadConfig(env.file).dbPath, path.join(env.dir, 'from-env.db'));
52
+ }
53
+ finally {
54
+ if (saved === undefined){
55
+ delete process.env.THUB_DB_PATH;
56
+ }
57
+ else {
58
+ process.env.THUB_DB_PATH = saved;
59
+ }
60
+ }
61
+
62
+ assert.throws(() => loadConfig(withConfig({ dbPath: 42 }).file), /dbPath must be a file path, or null/);
63
+ });
64
+
65
+ test('dbPath in a directory this user can\'t write: stops the start, naming the setting', { skip: process.getuid?.() === 0 }, () => {
66
+ const { dir, file } = withConfig({}),
67
+ locked = path.join(dir, 'locked');
68
+ fs.mkdirSync(locked, { mode: 0o500 });
69
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), dbPath: path.join(locked, 'thub.db') }));
70
+ try {
71
+ assert.throws(() => loadConfig(file), /locked is not writable by this user .*set dbPath \/ THUB_DB_PATH/);
72
+ }
73
+ finally {
74
+ fs.chmodSync(locked, 0o700);
75
+ }
76
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @file packages/coordinator/test/tls.test.js
3
+ * @description Tests: HTTPS served by the Coordinator itself — certificate checks, serving, renewal
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 test = require('node:test'),
17
+ assert = require('node:assert/strict'),
18
+ fs = require('node:fs'),
19
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ https = require('node:https'),
22
+ { spawnSync } = require('node:child_process'),
23
+ { loadConfig } = require('../src/config'),
24
+ { buildServices, createApp } = require('../src/server'),
25
+ { readTlsOptions, createServer } = require('../src/tls');
26
+
27
+ const hasOpenssl = spawnSync('openssl', ['version']).status === 0;
28
+
29
+ // A self-signed certificate for 127.0.0.1 into `dir`, as <name>.crt / <name>.key.
30
+ function makeCert(dir, name){
31
+ const crt = path.join(dir, `${name}.crt`),
32
+ key = path.join(dir, `${name}.key`),
33
+ r = spawnSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', '-subj', `/CN=${name}`,
34
+ '-keyout', key, '-out', crt], { encoding: 'utf8' });
35
+ assert.equal(r.status, 0, r.stderr);
36
+ return { crt, key };
37
+ }
38
+
39
+ // GET over HTTPS, trusting only `ca`; resolves { status, headers, subject }.
40
+ function get(port, urlPath, ca, headers = {}){
41
+ return new Promise((resolve, reject) => {
42
+ const req = https.request({ host: '127.0.0.1', port, path: urlPath, ca, headers, checkServerIdentity: () => undefined }, (res) => {
43
+ const subject = res.socket.getPeerCertificate().subject.CN;
44
+ res.resume();
45
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, subject }));
46
+ });
47
+ req.on('error', reject);
48
+ req.end();
49
+ });
50
+ }
51
+
52
+ test('tls config: none = plain HTTP; half a pair, an unreadable file or a mismatched key stop the start, saying why', { skip: !hasOpenssl }, () => {
53
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-tls-')),
54
+ a = makeCert(dir, 'a'),
55
+ b = makeCert(dir, 'b');
56
+ assert.equal(readTlsOptions({}), null);
57
+ assert.equal(readTlsOptions({ certFile: null, keyFile: null }), null);
58
+ assert.throws(() => readTlsOptions({ certFile: a.crt }), /set both certFile and keyFile/);
59
+ assert.throws(() => readTlsOptions({ certFile: a.crt, keyFile: path.join(dir, 'missing.key') }),
60
+ /tls.keyFile .*missing.key can't be read by this user \(ENOENT\)/);
61
+ assert.throws(() => readTlsOptions({ certFile: a.crt, keyFile: b.key }), /aren't a usable certificate and key/);
62
+ assert.ok(readTlsOptions({ certFile: a.crt, keyFile: a.key }).cert);
63
+ });
64
+
65
+ test('HTTPS: the dashboard and API over TLS, a Secure session cookie, a renewed certificate picked up on SIGHUP', { skip: !hasOpenssl }, async (t) => {
66
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-tls-')),
67
+ first = makeCert(dir, 'first'),
68
+ renewed = makeCert(dir, 'renewed'),
69
+ live = { crt: path.join(dir, 'live.crt'), key: path.join(dir, 'live.key') },
70
+ file = path.join(dir, 'coordinator.json');
71
+ fs.copyFileSync(first.crt, live.crt);
72
+ fs.copyFileSync(first.key, live.key);
73
+ fs.writeFileSync(file, JSON.stringify({
74
+ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 }, publicUrl: 'https://thub.example.com',
75
+ tls: { certFile: live.crt, keyFile: live.key }
76
+ }));
77
+ const config = loadConfig(file),
78
+ services = buildServices(config),
79
+ logs = [],
80
+ { server, scheme } = createServer(createApp(config, services), config, { log: { log: (m) => logs.push(m), error: (m) => logs.push(m) } });
81
+ assert.equal(scheme, 'https');
82
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
83
+ t.after(() => new Promise((r) => {
84
+ server.closeAllConnections?.();
85
+ server.close(r);
86
+ }));
87
+ const port = server.address().port,
88
+ login = await get(port, '/login', fs.readFileSync(first.crt));
89
+ assert.equal(login.status, 200);
90
+ assert.equal(login.subject, 'first');
91
+
92
+ services.adminUsers.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' }, { by: 't' });
93
+ const signIn = await new Promise((resolve, reject) => {
94
+ const req = https.request({
95
+ host: '127.0.0.1', port, path: '/login', method: 'POST', ca: fs.readFileSync(first.crt), checkServerIdentity: () => undefined,
96
+ headers: { 'content-type': 'application/x-www-form-urlencoded' }
97
+ }, (res) => {
98
+ res.resume();
99
+ res.on('end', () => resolve(res));
100
+ });
101
+ req.on('error', reject);
102
+ req.end('username=root&password=pw');
103
+ });
104
+ assert.equal(signIn.statusCode, 302);
105
+ assert.match(String(signIn.headers['set-cookie']), /;\s*Secure/i); // the cookie only travels over HTTPS
106
+
107
+ // Renewed in place (cert-manager, certbot): picked up without a restart.
108
+ fs.copyFileSync(renewed.crt, live.crt);
109
+ fs.copyFileSync(renewed.key, live.key);
110
+ process.emit('SIGHUP');
111
+ const after = await get(port, '/login', fs.readFileSync(renewed.crt));
112
+ assert.equal(after.subject, 'renewed');
113
+ assert.ok(logs.some((l) => /certificate reloaded \(SIGHUP\)/.test(l)), logs.join('\n'));
114
+
115
+ // A broken renewal keeps the working certificate.
116
+ fs.writeFileSync(live.key, 'not a key');
117
+ process.emit('SIGHUP');
118
+ assert.equal((await get(port, '/login', fs.readFileSync(renewed.crt))).subject, 'renewed');
119
+ assert.ok(logs.some((l) => /keeping the current certificate/.test(l)));
120
+ });
121
+
122
+ test('tls settings from the environment (THUB_TLS_*) win over the file', () => {
123
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-tls-')),
124
+ file = path.join(dir, 'coordinator.json'),
125
+ saved = { ...process.env };
126
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), tls: { certFile: '/file.crt', keyFile: '/file.key' } }));
127
+ Object.assign(process.env, { THUB_TLS_CERT: '/env.crt', THUB_TLS_KEY: '/env.key', THUB_TLS_CA: '/env-ca.crt', THUB_TLS_PASSPHRASE: 'pp' });
128
+ try {
129
+ assert.deepEqual(loadConfig(file).tls, { certFile: '/env.crt', keyFile: '/env.key', caFile: '/env-ca.crt', passphrase: 'pp' });
130
+ }
131
+ finally {
132
+ for (const k of ['THUB_TLS_CERT', 'THUB_TLS_KEY', 'THUB_TLS_CA', 'THUB_TLS_PASSPHRASE']){
133
+ if (saved[k] === undefined){
134
+ delete process.env[k];
135
+ }
136
+ else {
137
+ process.env[k] = saved[k];
138
+ }
139
+ }
140
+ }
141
+ });
@@ -29,7 +29,7 @@ async function setup(){
29
29
  clock = { t: Date.parse('2026-09-28T12:00:00Z') };
30
30
  fs.writeFileSync(pathUnit, '');
31
31
  const updates = createUpdatesService({
32
- config: { dataDir, updates: {} },
32
+ config: { dataDir, updates: {}, selfUpdate: true }, // started with --self-update
33
33
  pathUnit,
34
34
  fetchLatest: async () => NEWER,
35
35
  now: () => clock.t
@@ -104,3 +104,40 @@ test('on startup, a finished update\'s status is cleared; a failure is kept for
104
104
  make().start();
105
105
  assert.equal(fs.existsSync(statusFile), true);
106
106
  });
107
+
108
+ test('self-update is off unless the Coordinator was started with --self-update: no request, no button', async (t) => {
109
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-updates-')),
110
+ updates = createUpdatesService({ config: { dataDir, updates: {} }, pathUnit: path.join(dataDir, 'fake.path'), fetchLatest: async () => '99.0.0' });
111
+ fs.writeFileSync(path.join(dataDir, 'fake.path'), '');
112
+ await updates.checkNow();
113
+ assert.equal(updates.status().selfUpdate, false);
114
+ assert.ok(updates.status().coordinatorUpdate); // the notice still shows a newer version
115
+ assert.throws(() => updates.requestCoordinatorUpdate('admin'), /Self-update is off: start the Coordinator with --self-update/);
116
+ assert.ok(!fs.existsSync(path.join(dataDir, 'update-request.json')));
117
+
118
+ // The dashboard: a notice, no "Update app" button.
119
+ const { loadConfig } = require('../src/config'),
120
+ { buildServices, createApp } = require('../src/server'),
121
+ file = path.join(dataDir, 'coordinator.json');
122
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dataDir, 'data'), updates: { checkIntervalMin: 0 } }));
123
+ for (const selfUpdate of [false, true]){
124
+ const config = { ...loadConfig(file), selfUpdate },
125
+ services = buildServices(config);
126
+ services.updates.checkNow = async () => {};
127
+ Object.assign(services.updates, { status: () => ({ latest: {}, coordinatorVersion: '1.0.0', coordinatorUpdate: '99.0.0', selfUpdate }) });
128
+ services.adminUsers.create({ username: `root${selfUpdate}`, email: `r${selfUpdate}@example.com`, role: 'admin', password: 'pw' }, { by: 't' });
129
+ const server = createApp(config, services).listen(0, '127.0.0.1');
130
+ await new Promise((r) => server.on('listening', r));
131
+ t.after(() => new Promise((r) => {
132
+ server.closeAllConnections?.();
133
+ server.close(r);
134
+ }));
135
+ const base = `http://127.0.0.1:${server.address().port}`,
136
+ cookie = (await fetch(`${base}/login`, {
137
+ method: 'POST', body: new URLSearchParams({ username: `root${selfUpdate}`, password: 'pw' }), redirect: 'manual'
138
+ })).headers.get('set-cookie').split(';')[0],
139
+ html = await (await fetch(`${base}/runners`, { headers: { cookie } })).text();
140
+ assert.equal(/action="\/updates\/coordinator"/.test(html), selfUpdate, `Update app button with selfUpdate=${selfUpdate}`);
141
+ assert.match(html, /v99\.0\.0 available|Update app to 99\.0\.0/);
142
+ }
143
+ });
@@ -31,7 +31,6 @@ block content
31
31
  tr
32
32
  +sortHeader(list, 'name', 'Name')
33
33
  +sortHeader(list, 'group', 'Group')
34
- +sortHeader(list, 'version', 'Version', 'desc')
35
34
  +sortHeader(list, 'created', 'Created', 'desc')
36
35
  +sortHeader(list, 'used', 'Last used', 'desc')
37
36
  +sortHeader(list, 'status', 'Status')
@@ -42,13 +41,14 @@ block content
42
41
  //- The name opens Edit (name and group).
43
42
  td
44
43
  a.fw-medium(href=`#editAgentModal-${a.id}` data-bs-toggle="modal" role="button")= a.name
44
+ //- Its Agent's last reported version and update controls (none for a revoked token).
45
+ .thub-subline.text-body-secondary
46
+ +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
45
47
  td
46
48
  if a.group_id && groupsById[a.group_id]
47
49
  span.badge.text-bg-info= groupsById[a.group_id].name
48
50
  else
49
51
  span.text-body-secondary any
50
- //- A revoked token can't pick an update up: no button.
51
- td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
52
52
  td= fmtDate(a.created_at)
53
53
  td= fmtDate(a.last_used_at, 'never')
54
54
  td= a.revoked_at ? 'revoked' : 'active'
@@ -89,7 +89,7 @@ block content
89
89
 
90
90
  else
91
91
  tr
92
- td.text-body-secondary(colspan="7")
92
+ td.text-body-secondary(colspan="6")
93
93
  if q
94
94
  | No agent matches “#{q}”.
95
95
  a(href=list.urlFor({ q: '' })) Show all agents
@@ -12,7 +12,8 @@
12
12
  #[code sudo npm i -g] does the whole setup for the user who ran #[code sudo]. It creates
13
13
  #[code ~/.config/thub/coordinator.json] (with a random #[code sessionSecret]) and
14
14
  #[code ~/var/lib/thub] (database, avatars). It installs, enables and starts
15
- #[code thub-coordinator.service], plus the root helper behind the navbar's #[strong Update app] button.
15
+ #[code thub-coordinator.service], plus the root helper behind the navbar's #[strong Update app] button
16
+ (off unless the Coordinator runs with #[code --self-update]; see Updates below).
16
17
  The unit uses #[code ProtectSystem=strict] with #[code PrivateTmp=yes], so the data directory and a
17
18
  private #[code /tmp] (SQLite's temp files) are the only places the service can write.
18
19
 
@@ -58,6 +59,12 @@
58
59
  tr
59
60
  td: code session.secureCookie
60
61
  td #[code "auto"] (default): the session cookie is Secure (HTTPS-only) whenever #[code publicUrl] is https://. #[code true]/#[code false] force it. Sessions are stored in the database and survive restarts.
62
+ tr
63
+ td: code dbPath
64
+ td The SQLite database file. Default: #[code &lt;dataDir&gt;/thub.db]; a relative path is taken from #[code dataDir]. Its directory must be writable (SQLite's #[code -wal]/#[code -shm] files sit next to it). To move a database, stop the Coordinator and move #[code thub.db] with its #[code -wal]/#[code -shm] files.
65
+ tr
66
+ td: code tls.*
67
+ td #[code certFile] / #[code keyFile] (PEM): HTTPS served by the Coordinator itself, without a reverse proxy. #[code caFile]: intermediates, if not in the cert file; #[code passphrase]: for an encrypted key. Unset (default): plain HTTP. See #[strong HTTPS without a proxy] below.
61
68
  tr
62
69
  td: code clientJoinKey
63
70
  td The shared secret Clients self-register with. Unset or #[code null] turns registration off (#[code 503]).
@@ -86,7 +93,8 @@
86
93
  p.small.
87
94
  Resolution order: #[code THUB_COORDINATOR_CONFIG] (a path) → #[code ~/.config/thub/coordinator.json] → the bundled
88
95
  default. These environment variables override the file: #[code THUB_LISTEN], #[code THUB_PUBLIC_URL],
89
- #[code THUB_DATA_DIR], #[code THUB_SESSION_SECRET] and #[code THUB_CLIENT_JOIN_KEY].
96
+ #[code THUB_DATA_DIR], #[code THUB_DB_PATH], #[code THUB_SESSION_SECRET], #[code THUB_CLIENT_JOIN_KEY] and #[code THUB_TLS_CERT] /
97
+ #[code THUB_TLS_KEY] / #[code THUB_TLS_CA] / #[code THUB_TLS_PASSPHRASE].
90
98
 
91
99
  h3.h6 3. HTTPS reverse proxy
92
100
  p.small.
@@ -112,6 +120,23 @@
112
120
  client_max_body_size 10m; # log batches, avatars
113
121
  }
114
122
 
123
+ h3.h6 Or: HTTPS without a proxy
124
+ p.small.
125
+ The Coordinator can serve HTTPS itself, e.g. in Kubernetes or on a single host. Give it the certificate files
126
+ (readable by its user) and an #[code https://] #[code publicUrl]:
127
+ +code('coordinator.json').
128
+ {
129
+ "listen": "0.0.0.0:8443",
130
+ "publicUrl": "https://thub.example.com:8443",
131
+ "tls": { "certFile": "/etc/thub/tls/tls.crt", "keyFile": "/etc/thub/tls/tls.key" }
132
+ }
133
+ ul.small
134
+ li A bad path, half a pair or a key that doesn't match the certificate stops the start with the reason.
135
+ li Renewed files are picked up within a minute, or at once on #[code sudo systemctl kill -s HUP thub-coordinator]; a broken renewal keeps the working certificate.
136
+ li Port 443 under systemd needs #[code AmbientCapabilities=CAP_NET_BIND_SERVICE] (a drop-in), or use 8443. certbot's private key is root-only: copy it for the service user in a #[code --deploy-hook].
137
+ li Self-signed or private CA: start Clients and Agents with #[code NODE_EXTRA_CA_CERTS=/path/to/ca.pem] (a Client: #[code sudo systemctl edit thub-client@&lt;instance&gt;] → #[code Environment=NODE_EXTRA_CA_CERTS=…]).
138
+ li Kubernetes: mount the TLS secret and set #[code THUB_TLS_CERT] / #[code THUB_TLS_KEY] to its #[code tls.crt] / #[code tls.key].
139
+
115
140
  h3.h6 4. Users, agents and the join key
116
141
  p.small.
117
142
  #[code thub-admin] works directly on the database. Run it on the Coordinator host as the service user.
@@ -150,7 +175,12 @@
150
175
 
151
176
  h3.h6 Updates
152
177
  p.small.mb-0.
153
- The Coordinator checks npm for new Coordinator, Agent and Client versions. Admins update the Coordinator
154
- with #[strong Update app] in the navbar, and Agents and Clients from the #[a(href="/admin/agents") Agents]
155
- and #[a(href="/runners") Runners] pages. Clients install an update only between jobs.
156
- Manual alternatives: #[code thub-admin self-update], #[code thub self-update] and #[code thub-client self-update].
178
+ The Coordinator checks npm for new Coordinator, Agent and Client versions. Admins update Agents and Clients
179
+ from the #[a(href="/admin/agents") Agents] and #[a(href="/runners") Runners] pages; Clients install an update
180
+ only between jobs. Updating the #[strong Coordinator] from the dashboard (#[strong Update app] in the navbar)
181
+ is #[strong off by default]: start it with #[code --self-update] to turn it on. Under systemd:
182
+ #[code sudo sed -i 's|server.js$|server.js --self-update|' /etc/systemd/system/thub-coordinator.service],
183
+ then #[code sudo systemctl daemon-reload &amp;&amp; sudo systemctl restart thub-coordinator] (a re-install keeps it).
184
+ Without it, a #[strong vX.Y.Z available] badge shows; update by hand — #[code thub-admin self-update],
185
+ #[code sudo npm i -g …], or a new container image. Manual alternatives for the others:
186
+ #[code thub self-update] and #[code thub-client self-update].
@@ -0,0 +1,169 @@
1
+ +section('kubernetes', 'Running in Kubernetes', 'boxes')
2
+ p.
3
+ The Coordinator runs as one container: the published package in a Node.js image, its SQLite database on a
4
+ persistent volume, TLS at the ingress. Clients and Agents connect from outside at #[code publicUrl].
5
+ Self-update stays off: the image starts the Coordinator without #[code --self-update], and you update it by
6
+ deploying a new image tag.
7
+
8
+ h3.h6 1. Build the image
9
+ +code('Dockerfile').
10
+ # syntax=docker/dockerfile:1
11
+ # docker build --build-arg THUB_VERSION=&lt;version> -t registry.example.com/thub-coordinator:&lt;version> .
12
+ ARG NODE_VERSION=22
13
+
14
+ FROM node:${NODE_VERSION}-bookworm-slim AS build
15
+ ARG THUB_VERSION
16
+ # Only needed if better-sqlite3 has no prebuilt binary for the platform.
17
+ RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
18
+ && rm -rf /var/lib/apt/lists/*
19
+ WORKDIR /app
20
+ # A local (not global) install: the package's systemd and ~/.config setup is skipped.
21
+ RUN npm pack "@andrian.yablonskyy/thub-coordinator@${THUB_VERSION}" \
22
+ && tar xzf andrian.yablonskyy-thub-coordinator-*.tgz --strip-components=1 && rm -f *.tgz \
23
+ && npm install --omit=dev --no-audit --no-fund && npm cache clean --force \
24
+ && rm -rf test systemd
25
+
26
+ FROM node:${NODE_VERSION}-bookworm-slim
27
+ # tini as PID 1: forwards SIGTERM and reaps zombies.
28
+ RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
29
+ COPY --from=build /app /app
30
+ RUN ln -s /app/bin/thub-admin.js /usr/local/bin/thub-admin \
31
+ && mkdir -p /var/lib/thub && chown node:node /var/lib/thub
32
+ ENV NODE_ENV=production THUB_LISTEN=0.0.0.0:8080 THUB_DATA_DIR=/var/lib/thub
33
+ USER 1000:1000
34
+ WORKDIR /var/lib/thub
35
+ EXPOSE 8080
36
+ ENTRYPOINT ["/usr/bin/tini", "--"]
37
+ # No --self-update: a new image is how this Coordinator is updated.
38
+ CMD ["node", "/app/src/server.js"]
39
+ +code('Build and push').
40
+ docker build --build-arg THUB_VERSION=1.1.15 -t registry.example.com/thub-coordinator:1.1.15 .
41
+ docker push registry.example.com/thub-coordinator:1.1.15
42
+
43
+ h3.h6 2. Create the Secret
44
+ +code('Secrets as environment variables (they win over the config file and Settings)').
45
+ kubectl create namespace thub
46
+ kubectl -n thub create secret generic thub-coordinator-secrets \
47
+ --from-literal=THUB_SESSION_SECRET="$(openssl rand -hex 32)" \
48
+ --from-literal=THUB_CLIENT_JOIN_KEY="$(openssl rand -hex 24)" \
49
+ --from-literal=THUB_BOOTSTRAP_ADMIN_PASSWORD='choose-a-strong-password'
50
+
51
+ h3.h6 3. Apply the manifests
52
+ p.small.
53
+ Replace #[code thub.example.com], which must match #[code publicUrl], and the image.
54
+ +code('thub.yaml').
55
+ apiVersion: v1
56
+ kind: Namespace
57
+ metadata: { name: thub }
58
+ ---
59
+ # Non-secret settings, mounted as the config file. Settings changed on the
60
+ # dashboard are stored in the database, so this mount can be read-only.
61
+ apiVersion: v1
62
+ kind: ConfigMap
63
+ metadata: { name: thub-coordinator-config, namespace: thub }
64
+ data:
65
+ coordinator.json: |
66
+ {
67
+ "publicUrl": "https://thub.example.com",
68
+ "trustProxy": "loopback, uniquelocal",
69
+ "updates": { "checkIntervalMin": 15 }
70
+ }
71
+ ---
72
+ # SQLite needs block storage (ReadWriteOnce); avoid NFS-backed classes.
73
+ apiVersion: v1
74
+ kind: PersistentVolumeClaim
75
+ metadata: { name: thub-coordinator-data, namespace: thub }
76
+ spec:
77
+ accessModes: ["ReadWriteOnce"]
78
+ resources: { requests: { storage: 10Gi } }
79
+ ---
80
+ # One replica only: one SQLite file, scheduler in-process. Recreate frees
81
+ # the volume before the new pod opens the database.
82
+ apiVersion: apps/v1
83
+ kind: Deployment
84
+ metadata: { name: thub-coordinator, namespace: thub }
85
+ spec:
86
+ replicas: 1
87
+ strategy: { type: Recreate }
88
+ selector: { matchLabels: { app: thub-coordinator } }
89
+ template:
90
+ metadata: { labels: { app: thub-coordinator } }
91
+ spec:
92
+ securityContext: { runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000 }
93
+ containers:
94
+ - name: coordinator
95
+ image: registry.example.com/thub-coordinator:&lt;version>
96
+ ports: [{ name: http, containerPort: 8080 }]
97
+ env:
98
+ - { name: THUB_COORDINATOR_CONFIG, value: /etc/thub/coordinator.json }
99
+ envFrom:
100
+ - secretRef: { name: thub-coordinator-secrets } # THUB_SESSION_SECRET, THUB_CLIENT_JOIN_KEY, …
101
+ volumeMounts:
102
+ - { name: data, mountPath: /var/lib/thub }
103
+ - { name: config, mountPath: /etc/thub, readOnly: true }
104
+ - { name: tmp, mountPath: /tmp } # SQLite temp files (read-only root FS)
105
+ startupProbe: { httpGet: { path: /login, port: http }, periodSeconds: 5, failureThreshold: 24 }
106
+ readinessProbe: { httpGet: { path: /login, port: http }, periodSeconds: 10 }
107
+ livenessProbe: { tcpSocket: { port: http }, periodSeconds: 20 }
108
+ resources: { requests: { cpu: 100m, memory: 192Mi }, limits: { memory: 512Mi } }
109
+ securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: ["ALL"] } }
110
+ volumes:
111
+ - { name: data, persistentVolumeClaim: { claimName: thub-coordinator-data } }
112
+ - { name: config, configMap: { name: thub-coordinator-config } }
113
+ - { name: tmp, emptyDir: { sizeLimit: 256Mi } }
114
+ ---
115
+ apiVersion: v1
116
+ kind: Service
117
+ metadata: { name: thub-coordinator, namespace: thub }
118
+ spec:
119
+ selector: { app: thub-coordinator }
120
+ ports: [{ name: http, port: 80, targetPort: http }]
121
+ ---
122
+ # TLS at the ingress. Long polls and log streams (SSE) need long timeouts and
123
+ # no buffering; log batches and avatars stay under 10 MB.
124
+ apiVersion: networking.k8s.io/v1
125
+ kind: Ingress
126
+ metadata:
127
+ name: thub-coordinator
128
+ namespace: thub
129
+ annotations:
130
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
131
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
132
+ nginx.ingress.kubernetes.io/proxy-buffering: "off"
133
+ nginx.ingress.kubernetes.io/proxy-body-size: "10m"
134
+ # cert-manager.io/cluster-issuer: letsencrypt
135
+ spec:
136
+ ingressClassName: nginx
137
+ tls: [{ hosts: ["thub.example.com"], secretName: thub-coordinator-tls }]
138
+ rules:
139
+ - host: thub.example.com
140
+ http:
141
+ paths:
142
+ - path: /
143
+ pathType: Prefix
144
+ backend: { service: { name: thub-coordinator, port: { name: http } } }
145
+ +code('Deploy').
146
+ kubectl apply -f thub.yaml
147
+ kubectl -n thub rollout status deploy/thub-coordinator
148
+ kubectl -n thub logs deploy/thub-coordinator # listening on http://0.0.0.0:8080 · Self-update: off
149
+
150
+ h3.h6 4. First sign-in, then remove the bootstrap password
151
+ +code('It resets the admin password on every start while it\'s set').
152
+ kubectl -n thub patch secret thub-coordinator-secrets --type=json \
153
+ -p='[{"op":"remove","path":"/data/THUB_BOOTSTRAP_ADMIN_PASSWORD"}]'
154
+ kubectl -n thub rollout restart deploy/thub-coordinator
155
+ +code('thub-admin works inside the pod, on the same database').
156
+ kubectl -n thub exec deploy/thub-coordinator -- thub-admin user add alice --email alice@example.com --role admin --password '…'
157
+ kubectl -n thub exec deploy/thub-coordinator -- thub-admin agent add ci-firmware
158
+ kubectl -n thub exec deploy/thub-coordinator -- thub-admin join-key generate
159
+
160
+ h3.h6 Things to know
161
+ ul.small
162
+ li #[strong One replica.] One SQLite file and an in-process scheduler: no scale-out, no HPA. #[code Recreate] means a few seconds of downtime per upgrade; Clients and Agents reconnect by themselves.
163
+ li #[strong Updating:] deploy a new image tag. Migrations run on the new pod's first start. #[strong Update app] stays off (no #[code --self-update]); admins see a #[strong vX.Y.Z available] badge. Agents and Clients still update from the dashboard.
164
+ li #[strong Storage:] block storage (ReadWriteOnce), not NFS. A separate database volume: mount it and set #[code THUB_DB_PATH].
165
+ li #[strong /tmp] is an #[code emptyDir]: SQLite needs a writable temp directory and the root filesystem is read-only.
166
+ li #[strong Backups:] #[code kubectl -n thub exec deploy/thub-coordinator -- node -e "new (require('/app/node_modules/better-sqlite3'))('/var/lib/thub/thub.db').backup('/var/lib/thub/backup.db')"], then #[code kubectl cp].
167
+ li #[strong trustProxy] #[code "loopback, uniquelocal"] trusts the ingress from private ranges; set your pod CIDR if it's elsewhere.
168
+ li #[strong Other ingress controllers:] a read timeout well above 30 s, no response buffering, bodies up to 10 MB.
169
+ li #[strong HTTPS in the pod:] mount the TLS secret, set #[code THUB_TLS_CERT] / #[code THUB_TLS_KEY], listen on 8443, #[code scheme: HTTPS] in the probes, and an HTTPS backend (#[code nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"]) or a #[code LoadBalancer] on 443.
@@ -8,6 +8,7 @@ block content
8
8
  { id: 'use-cases', label: 'Use cases' },
9
9
  { id: 'quick-start', label: 'Quick start' },
10
10
  { id: 'coordinator', label: 'Coordinator setup' },
11
+ { id: 'kubernetes', label: 'Running in Kubernetes' },
11
12
  { id: 'agent-setup', label: 'Agent setup' },
12
13
  { id: 'client-setup', label: 'Client setup' },
13
14
  { id: 'client-machines', label: 'Client machines (HW / SW)' },
@@ -53,6 +54,7 @@ block content
53
54
  include _overview
54
55
  include _quick-start
55
56
  include _coordinator
57
+ include _kubernetes
56
58
  include _agent-setup
57
59
  include _client-setup
58
60
  include _client-machines
package/views/layout.pug CHANGED
@@ -29,11 +29,12 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
29
29
  data-bs-title=`Coordinator v${coordinatorVersion} · thub-common v${commonVersion} (validates job specs)`
30
30
  )
31
31
  | ver. #{coordinatorVersion}
32
- if user && !can.admin && updates.coordinatorUpdate
32
+ //- Without --self-update (or for non-admins): just the notice.
33
+ if user && updates.coordinatorUpdate && !(can.admin && updates.selfUpdate)
33
34
  span.badge.text-bg-warning.ms-1(
34
35
  data-bs-toggle="tooltip"
35
36
  data-bs-placement="bottom"
36
- data-bs-title="An admin can update the Coordinator from the navbar"
37
+ data-bs-title=updates.selfUpdate ? 'An admin can update the Coordinator from the navbar' : 'Update the Coordinator by hand (thub-admin self-update, npm i -g, or a new container image); self-update from the dashboard is off'
37
38
  )= `v${updates.coordinatorUpdate} available`
38
39
  button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
39
40
  span.navbar-toggler-icon
@@ -91,7 +92,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
91
92
  data-bs-title=updates.checkedAt ? `Check for updates now — the Coordinator and connected runners (last checked ${fmtDate(updates.checkedAt)})` : 'Check for updates now — the Coordinator and connected runners'
92
93
  )
93
94
  i.bi.bi-arrow-repeat
94
- if user && can.admin && updates.coordinatorUpdate
95
+ if user && can.admin && updates.selfUpdate && updates.coordinatorUpdate
95
96
  li.nav-item.d-flex.align-items-center.me-3
96
97
  if updates.coordinatorPending
97
98
  //- public/js/update-watch.js polls /updates/status and
@@ -24,21 +24,25 @@ block content
24
24
  tr
25
25
  +sortHeader(list, 'name', 'Name')
26
26
  +sortHeader(list, 'type', 'Type')
27
- +sortHeader(list, 'version', 'Version', 'desc')
28
27
  +sortHeader(list, 'status', 'Status')
29
28
  th Labels
30
29
  th Groups
31
30
  th Busy source / reason
32
- +sortHeader(list, 'heartbeat', 'Last heartbeat', 'desc')
33
31
  if can.operate
34
32
  th Actions
35
33
  tbody(data-live="rows")
36
34
  each r in resources
37
35
  tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
38
- td= r.name
36
+ //- Name, and under it the Client's version (with its update controls).
37
+ td
38
+ div= r.name
39
+ .thub-subline.text-body-secondary
40
+ +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/runners/${r.id}/update`, list.pageUrl(), true, r.activity && r.activity.state === 'update-hold')
39
41
  td.text-uppercase= r.type
40
- td: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/runners/${r.id}/update`, list.pageUrl(), true, r.activity && r.activity.state === 'update-hold')
41
- td: +resourceBadge(r.status, r.remove_requested_at)
42
+ //- Status, and under it when the Client last heartbeated.
43
+ td
44
+ +resourceBadge(r.status, r.remove_requested_at)
45
+ .thub-subline.text-body-secondary.text-nowrap(title="Last heartbeat")= fmtDate(r.last_heartbeat_at, 'never')
42
46
  //- One label per line.
43
47
  td
44
48
  if r.labels.length
@@ -52,7 +56,6 @@ block content
52
56
  else
53
57
  span.text-body-secondary —
54
58
  td= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
55
- td= fmtDate(r.last_heartbeat_at, 'never')
56
59
  if can.operate
57
60
  td
58
61
  +resourceActions(r, list.pageUrl())