@andrian.yablonskyy/thub-common 1.0.26 → 1.0.28

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-common",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Shared job-spec schema, state enums and a thin API client used by the Agent and Client",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -16,10 +16,12 @@
16
16
  'use strict';
17
17
 
18
18
  const Ajv = require('ajv'),
19
- addFormats = require('ajv-formats'),
20
- { DOCKER_IMAGE_PATTERN } = require('./job-spec.schema');
19
+ addFormats = require('ajv-formats');
21
20
 
22
- const MAX_DEVICES = 8,
21
+ // The config file's key for an HW Client's devices. Older files call it
22
+ // `hw`, still read (and rewritten as `hw-devices` on the next save).
23
+ const HW_DEVICES = 'hw-devices',
24
+ MAX_DEVICES = 8,
23
25
  device = (extra = {}) => ({
24
26
  type: 'object',
25
27
  additionalProperties: false,
@@ -44,74 +46,152 @@ const MAX_DEVICES = 8,
44
46
  properties: {
45
47
  stlinks: list(device({ serial: { type: 'string', pattern: '^[A-Za-z0-9]{1,64}$' } })),
46
48
  uarts: list(device({ baudRate: { type: 'integer', minimum: 50, maximum: 4000000 } })),
47
- usbs: list(device()),
48
- relays: list({
49
- type: 'object',
50
- additionalProperties: false,
51
- required: ['channel'],
52
- properties: {
53
- channel: { type: 'integer', minimum: 0, maximum: 7 },
54
- baseUrl: { type: 'string', format: 'uri', pattern: '^https?://' }
55
- }
56
- }),
57
- power: {
58
- type: ['object', 'null'],
59
- additionalProperties: false,
60
- properties: {
61
- method: { enum: ['uhubctl', 'relay'] },
62
- hub: { type: 'string', pattern: '^[A-Za-z0-9.:-]{1,32}$' },
63
- port: { type: 'integer', minimum: 1, maximum: 64 },
64
- baseUrl: { type: 'string', format: 'uri', pattern: '^https?://' }
65
- }
66
- }
67
- }
68
- },
69
- swSchema = {
70
- type: 'object',
71
- additionalProperties: false,
72
- properties: {
73
- image: { type: 'string', maxLength: 255, pattern: DOCKER_IMAGE_PATTERN },
74
- registry: { type: 'string', pattern: '^(https?://)?[A-Za-z0-9.-]+(:[0-9]+)?/?$', maxLength: 255 },
75
- allowDockerHub: { type: 'boolean' },
76
- allowJobImages: { type: 'boolean' },
77
- cpus: { type: 'number', exclusiveMinimum: 0, maximum: 256 },
78
- memory: { type: 'string', pattern: '^[0-9]+[kmgKMG]?$' },
79
- cmd: { type: 'array', maxItems: 32, items: { type: 'string', maxLength: 1024 } }
49
+ usbs: list(device())
80
50
  }
81
51
  },
52
+ // An SW Client has no settings of its own: its editable section is empty.
53
+ swSchema = { type: 'object', additionalProperties: false, properties: {} },
82
54
  ajv = new Ajv({ allErrors: true, strict: false }),
83
55
  validators = { hw: null, sw: null };
84
56
  addFormats(ajv);
85
57
  validators.hw = ajv.compile(hwSchema);
86
58
  validators.sw = ajv.compile(swSchema);
87
59
 
88
- // Fields of a section that never leave the Client (secrets) — kept on the
89
- // Client when a dashboard edit is applied, never reported or accepted.
90
- const PRIVATE_FIELDS = { hw: [], sw: ['registryAuth'] };
60
+ // The hw-devices section of a config file (or its older name, `hw`).
61
+ function hwDevicesOf(file){
62
+ return file?.[HW_DEVICES] !== undefined ? file[HW_DEVICES] : file?.hw;
63
+ }
64
+
65
+ // An hw-devices section without the power control Clients no longer have
66
+ // (relays, power — relay boards and uhubctl), so a config file that still
67
+ // has them reports, saves and imports without them. Returns
68
+ // { section, dropped } (dropped: the names left out).
69
+ function withoutPowerControl(hw){
70
+ if (!hw || typeof hw !== 'object' || Array.isArray(hw)){
71
+ return { section: hw, dropped: [] };
72
+ }
73
+ const { relays, power, ...section } = hw;
74
+ return {
75
+ section,
76
+ dropped: [...(relays !== undefined ? [`${HW_DEVICES}.relays`] : []), ...(power !== undefined ? [`${HW_DEVICES}.power`] : [])]
77
+ };
78
+ }
91
79
 
92
- // { valid, errors } for a Client's `type` ('hw' | 'sw') config section.
80
+ // { valid, errors } for a Client's editable section: an HW Client's
81
+ // hw-devices; an SW Client's is always empty ({}).
93
82
  function validateClientConfig(type, section){
94
83
  const validate = validators[type];
95
84
  if (!validate){
96
85
  return { valid: false, errors: [`unknown Client type "${type}"`] };
97
86
  }
98
87
  if (!section || typeof section !== 'object' || Array.isArray(section)){
99
- return { valid: false, errors: [`the ${type} section must be an object`] };
88
+ return { valid: false, errors: [`the ${type === 'hw' ? HW_DEVICES : type} section must be an object`] };
100
89
  }
101
90
  const valid = validate(section);
102
91
  return {
103
92
  valid,
104
- errors: valid ? [] : validate.errors.map((e) => `${type}${e.instancePath.replace(/\//g, '.')} ${e.message}`)
93
+ errors: valid
94
+ ? []
95
+ : validate.errors.map((e) => (type === 'sw'
96
+ ? 'an SW Client has no settings of its own (the sw section is gone)'
97
+ : `${HW_DEVICES}${e.instancePath.replace(/\//g, '.')} ${e.message}`))
105
98
  };
106
99
  }
107
100
 
108
- // The editable view of a section: without its private fields.
109
- function publicClientConfig(type, section){
110
- const out = { ...(section || {}) };
111
- for (const key of PRIVATE_FIELDS[type] || []){
101
+ // ── The whole config file: dashboard Export / Import (README §10) ──────────
102
+
103
+ // Never taken from an imported file: how this Client reaches and joins its
104
+ // Coordinator, and its name (the dashboard renames it instead).
105
+ const IMPORT_IGNORED_FIELDS = ['joinKey', 'coordinatorUrl', 'name'],
106
+ // Top-level fields a config file may carry, checked on import (anything
107
+ // else is passed through as is).
108
+ strings = { type: 'array', maxItems: 64, items: { type: 'string', minLength: 1, maxLength: 1024 } },
109
+ fileSchema = {
110
+ type: 'object',
111
+ properties: {
112
+ type: { enum: ['hw', 'sw'] },
113
+ labels: strings,
114
+ groups: strings,
115
+ heartbeatIntervalSec: { type: 'integer', minimum: 1, maximum: 3600 },
116
+ longPollWaitSec: { type: 'integer', minimum: 1, maximum: 3600 }
117
+ }
118
+ },
119
+ validateFile = ajv.compile(fileSchema),
120
+
121
+ // Also never imported: what ties a config to its host and instance — the
122
+ // Client's identity (clientId) and every file or directory path (tokenFile,
123
+ // socketPath, workDir, varDir, …): taken from another Client they'd share
124
+ // its token, socket or state.
125
+ isHostBound = (key) => key === 'clientId' || /(File|Path|Dir)$/.test(key),
126
+
127
+ // Sections older Clients had and current ones ignore — dropped from
128
+ // Export (`artifactory` held a token, `sw` a registry password) and
129
+ // listed as ignored on Import.
130
+ LEGACY_SECTIONS = ['artifactory', 'sources', 'sw'];
131
+
132
+ // A config file as it's shared (Export, and what an Import may carry): the
133
+ // legacy sections dropped, an older `hw` section under its new name.
134
+ function shareableClientConfigFile(file){
135
+ const out = JSON.parse(JSON.stringify(file || {})),
136
+ hw = hwDevicesOf(out);
137
+ for (const key of [...LEGACY_SECTIONS, 'hw']){
112
138
  delete out[key];
113
139
  }
140
+ if (hw !== undefined){
141
+ out[HW_DEVICES] = hw;
142
+ }
114
143
  return out;
115
144
  }
116
145
 
117
- module.exports = { validateClientConfig, publicClientConfig, CLIENT_CONFIG_PRIVATE_FIELDS: PRIVATE_FIELDS };
146
+ // Checks an imported config file for a `type` Client and splits it into
147
+ // what gets applied: { valid, errors, section (an HW Client's hw-devices, or
148
+ // null; an SW Client's is always {}), fields (the other top-level fields),
149
+ // ignored (names left out) }.
150
+ function importClientConfigFile(type, file){
151
+ const fail = (...errors) => ({ valid: false, errors, section: null, fields: {}, ignored: [] });
152
+ if (!file || typeof file !== 'object' || Array.isArray(file)){
153
+ return fail('the file must hold a JSON object — a Client config');
154
+ }
155
+ if (file.type !== undefined && file.type !== type){
156
+ return fail(`it's a ${String(file.type).toUpperCase()} Client config, but this is a ${type.toUpperCase()} Client`);
157
+ }
158
+ if (!validateFile(file)){
159
+ return fail(...validateFile.errors.map((e) => `${e.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'config'} ${e.message}`));
160
+ }
161
+ const shared = shareableClientConfigFile(file),
162
+ ignored = [],
163
+ fields = {},
164
+ errors = [];
165
+ ignored.push(...LEGACY_SECTIONS.filter((key) => file[key] !== undefined));
166
+ let section = type === 'sw' ? {} : null;
167
+ if (shared[HW_DEVICES] !== undefined){
168
+ if (type === 'hw'){
169
+ const { section: hw, dropped } = withoutPowerControl(shared[HW_DEVICES]);
170
+ section = hw;
171
+ ignored.push(...dropped);
172
+ errors.push(...validateClientConfig('hw', section).errors);
173
+ }
174
+ else {
175
+ ignored.push(HW_DEVICES); // an SW Client has no devices
176
+ }
177
+ }
178
+ for (const [key, value]of Object.entries(shared)){
179
+ if (IMPORT_IGNORED_FIELDS.includes(key) || isHostBound(key)){
180
+ ignored.push(key);
181
+ }
182
+ else if (key !== 'type' && key !== HW_DEVICES){
183
+ fields[key] = value;
184
+ }
185
+ }
186
+ return { valid: errors.length === 0, errors, section, fields, ignored };
187
+ }
188
+
189
+ module.exports = {
190
+ validateClientConfig,
191
+ shareableClientConfigFile,
192
+ importClientConfigFile,
193
+ withoutPowerControl,
194
+ hwDevicesOf,
195
+ HW_DEVICES_SECTION: HW_DEVICES,
196
+ CLIENT_CONFIG_IMPORT_IGNORED: IMPORT_IGNORED_FIELDS
197
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file packages/shared/src/env-list.js
3
+ * @description Parses the Agent's `--env NAME=value[,NAME=value]` values into a job's env
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
+ // ['A=1,B=2', 'C'] -> { A: '1', B: '2', C: <C from environ> }. A comma only
17
+ // starts a new variable when NAME= follows it, so values may contain commas
18
+ // (`P=a,b` is P="a,b"). A bare NAME — a whole --env of its own — takes its
19
+ // value from `environ`: keeps secrets like DOCKER_PASSWORD off the command
20
+ // line and out of shell history. Throws on a malformed item or a bare NAME
21
+ // that isn't set.
22
+ function parseEnvList(values = [], environ = process.env){
23
+ const env = {};
24
+ for (const value of values){
25
+ for (const item of String(value).split(/,(?=[A-Za-z_][A-Za-z0-9_]*=)/)){
26
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:=(.*))?$/s.exec(item);
27
+ if (!m){
28
+ throw new Error(`--env ${item}: expected NAME=value or NAME (NAME: letters, digits and _, not starting with a digit)`);
29
+ }
30
+ const [, name, val] = m;
31
+ if (val === undefined && environ[name] === undefined){
32
+ throw new Error(`--env ${name}: not set in this shell — give it as ${name}=value or export it first`);
33
+ }
34
+ env[name] = val === undefined ? environ[name] : val;
35
+ }
36
+ }
37
+ return env;
38
+ }
39
+
40
+ module.exports = { parseEnvList };
package/src/index.js CHANGED
@@ -21,5 +21,6 @@ module.exports = {
21
21
  ...require('./cron'),
22
22
  ...require('./client-config'),
23
23
  ...require('./split-args'),
24
+ ...require('./env-list'),
24
25
  ApiClient: require('./api-client').ApiClient
25
26
  };
@@ -23,6 +23,9 @@ const DOCKER_IMAGE_PATTERN =
23
23
  // no spaces or the characters git forbids, not ending in "/", ".lock", ".".
24
24
  GIT_REF_PATTERN = '^(?![-/])(?!.*\\.\\.)(?!.*//)(?!.*(/|\\.lock|\\.)$)[A-Za-z0-9._/+@-]+$',
25
25
 
26
+ // An environment variable name a job may set (`--env NAME=value`).
27
+ ENV_NAME_PATTERN = '^[A-Za-z_][A-Za-z0-9_]*$',
28
+
26
29
  // Matches the job spec shape documented in README.md §4.3. A task is:
27
30
  // optionally files to download and/or a git checkout, optionally (SW only)
28
31
  // a Docker image to run as the DUT, and — always — the shell command that
@@ -76,8 +79,8 @@ const DOCKER_IMAGE_PATTERN =
76
79
  },
77
80
  default: []
78
81
  },
79
- // A Docker image an SW Client runs as the DUT instead of its own
80
- // sw.image, if it allows that (sw.allowJobImages) — `--docker-image`.
82
+ // A Docker image an SW Client runs as the job's DUT container, next to
83
+ // its command — `--docker-image`.
81
84
  image: { type: 'string', maxLength: 255, pattern: DOCKER_IMAGE_PATTERN },
82
85
  // A repository the Client clones before running the command — at `ref`
83
86
  // (branch, tag or commit; default: the default branch), `depth` commits
@@ -98,6 +101,18 @@ const DOCKER_IMAGE_PATTERN =
98
101
  options: { type: 'string', minLength: 1, maxLength: 1024 }
99
102
  }
100
103
  },
104
+ // Environment variables (`--env NAME=value`) the Client sets for every
105
+ // command it runs for the job — git, docker login, the job's command.
106
+ // DOCKER_REGISTRY + DOCKER_USERNAME + DOCKER_PASSWORD: the Client logs in
107
+ // to that registry first (README §8.1). Values are secrets as far as
108
+ // the Coordinator is concerned: masked in the Agent API and dropped
109
+ // from its database once the job ends.
110
+ env: {
111
+ type: 'object',
112
+ maxProperties: 64,
113
+ propertyNames: { pattern: ENV_NAME_PATTERN, maxLength: 128 },
114
+ additionalProperties: { type: 'string', maxLength: 8192 }
115
+ },
101
116
  timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
102
117
  priority: { type: 'integer', minimum: 0, maximum: 100, default: 50 },
103
118
  // Set by the Coordinator from the agent token's kind; any value an
@@ -113,6 +128,9 @@ const DOCKER_IMAGE_PATTERN =
113
128
  // see README §7.1 "Dry-run the pipeline".
114
129
  dryRun: { type: 'boolean', default: false }
115
130
  }
116
- };
131
+ },
132
+
133
+ // The job env variables the Client logs in to a Docker registry with.
134
+ DOCKER_LOGIN_ENV = ['DOCKER_REGISTRY', 'DOCKER_USERNAME', 'DOCKER_PASSWORD'];
117
135
 
118
- module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN };
136
+ module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN, ENV_NAME_PATTERN, DOCKER_LOGIN_ENV };
package/src/states.js CHANGED
@@ -72,6 +72,8 @@ const RESOURCE_STATES = Object.freeze({
72
72
  INFRA: 2, // ERROR, TIMEOUT, LOST
73
73
  CANCELED: 3,
74
74
  USAGE: 4,
75
+ // `thub status <id> --json` on a job still queued or running.
76
+ ACTIVE: 5,
75
77
  DETACHED: 130
76
78
  });
77
79
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  const Ajv = require('ajv'),
17
17
  addFormats = require('ajv-formats'),
18
- { jobSpecSchema } = require('./job-spec.schema'),
18
+ { jobSpecSchema, DOCKER_LOGIN_ENV } = require('./job-spec.schema'),
19
19
  { splitArgs } = require('./split-args');
20
20
 
21
21
  const ajv = new Ajv({ useDefaults: true, allErrors: true, strict: false });
@@ -52,7 +52,11 @@ function legacyShapeError(spec){
52
52
  // Git transports a Client may fetch sources over. Never `ext::` (runs a
53
53
  // command), `file://` or a local path: the Client also sets
54
54
  // GIT_ALLOW_PROTOCOL to the same list (downloader.js).
55
- const GIT_URL = /^(?:(?:https?|ssh|git):\/\/[^\s]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)$/;
55
+ const GIT_URL = /^(?:(?:https?|ssh|git):\/\/[^\s]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)$/,
56
+
57
+ // Job env names the Client sets itself for a job's commands (besides
58
+ // THUB_*): git's safety settings, and the job's own Docker config dir.
59
+ RESERVED_ENV = ['GIT_TERMINAL_PROMPT', 'GIT_ALLOW_PROTOCOL', 'DOCKER_CONFIG'];
56
60
 
57
61
  // Rules across fields, spelled out here rather than as schema if/then,
58
62
  // whose errors ("must match a schema in then") say little.
@@ -64,6 +68,16 @@ function crossFieldErrors(spec){
64
68
  if (spec.git && !GIT_URL.test(spec.git.url || '')){
65
69
  errors.push('/git/url must be an https://, http://, ssh:// or git:// URL, or user@host:path');
66
70
  }
71
+ const envNames = Object.keys(spec.env && typeof spec.env === 'object' ? spec.env : {}),
72
+ reserved = envNames.filter((n) => /^THUB_/.test(n) || RESERVED_ENV.includes(n)),
73
+ login = DOCKER_LOGIN_ENV.filter((n) => envNames.includes(n));
74
+ if (reserved.length){
75
+ errors.push(`/env ${reserved.join(', ')}: set by the Client itself (THUB_*, ${RESERVED_ENV.join(', ')}) — use other names`);
76
+ }
77
+ if (login.length && login.length < DOCKER_LOGIN_ENV.length){
78
+ errors.push(`/env a Docker registry login needs all of ${DOCKER_LOGIN_ENV.join(', ')} — missing ` +
79
+ DOCKER_LOGIN_ENV.filter((n) => !login.includes(n)).join(', '));
80
+ }
67
81
  if (spec.git?.options){
68
82
  try {
69
83
  splitArgs(spec.git.options);
@@ -75,4 +89,12 @@ function crossFieldErrors(spec){
75
89
  return errors;
76
90
  }
77
91
 
78
- module.exports = { validateJobSpec };
92
+ // A job's `env` with its values hidden — for anything but the Client that
93
+ // runs the job (Agent API, logs, dry-run output).
94
+ function maskEnv(env){
95
+ return env && typeof env === 'object' ? Object.fromEntries(Object.keys(env).map((k) => [k, MASKED])) : env;
96
+ }
97
+
98
+ const MASKED = '***';
99
+
100
+ module.exports = { validateJobSpec, maskEnv, DOCKER_LOGIN_ENV, JOB_ENV_MASK: MASKED };