@andrian.yablonskyy/thub-common 1.0.23 → 1.0.25

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
@@ -21,12 +21,13 @@ const { validateJobSpec } = require('@andrian.yablonskyy/thub-common');
21
21
 
22
22
  const { valid, spec, errors } = validateJobSpec({
23
23
  target: { type: 'hw', labels: ['board:nucleo-f401re'] },
24
- firmware: { url: 'https://artifactory.example.com/app.bin' },
25
- tests: { url: 'https://artifactory.example.com/tests.tar.gz' }
24
+ command: './ci/test.sh',
25
+ downloads: [{ url: 'https://artifactory.example.com/app.bin' }],
26
+ git: { url: 'https://github.com/yourorg/firmware-tests.git', ref: 'v1.4.0' }
26
27
  });
27
28
  ```
28
29
 
29
- The schema deliberately has no field for shell commands — a job only ever names a firmware image and a test package; the Client runs a fixed entry point (`run-tests.sh`) from that package, never arbitrary code from the spec itself. Top-level fields: `target` (`type`, `labels`, optional `group`), `firmware` (`url`, optional `sha256`/`flashAddress`), `tests` (`url`, `suite`, `args`), `timeoutSec`, `priority`, `source` (`ci`/`cli`), `user` (a free-text job-owner label), `meta` (arbitrary key/value metadata), and `dryRun`.
30
+ A job is a task: `command` (required — the shell command the Client runs as its entry point, `args` as `"$@"`), with optional inputs `downloads` (`[{ url }]`), `git` (`url`, optional `ref` — branch, tag or commit — and `depth`, default 1) and, for SW jobs, `image` (a Docker image to run as the DUT). Other top-level fields: `target` (`type`, `labels`, optional `group`/`client`), `suite`, `timeoutSec`, `priority`, `source` (`ci`/`cli`), `user` (a free-text job-owner label), `meta` (arbitrary key/value metadata), and `dryRun`. A spec in the old shape (`firmware`/`tests`, from an Agent older than `--command`) is refused with a message to update the Agent.
30
31
 
31
32
  ### State enums and exit codes
32
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-common",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
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": [
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file packages/shared/src/client-config.js
3
+ * @description Schema and validation for the part of a Client's config that can be edited from the dashboard
4
+ * (its capabilities: the `hw` or `sw` section) — used by the Coordinator on Save and by the Client
5
+ * before applying it
6
+ *
7
+ * @author Andrian Yablonskyy
8
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
9
+ *
10
+ * This file is part of TestHub and is proprietary and confidential.
11
+ * Unauthorized copying, modification, distribution, or use of this file,
12
+ * via any medium, is strictly prohibited without prior written permission
13
+ * from AdSystem.PRO.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const Ajv = require('ajv'),
19
+ addFormats = require('ajv-formats'),
20
+ { DOCKER_IMAGE_PATTERN } = require('./job-spec.schema');
21
+
22
+ const MAX_DEVICES = 8,
23
+ device = (extra = {}) => ({
24
+ type: 'object',
25
+ additionalProperties: false,
26
+ properties: {
27
+ // udev index N -> /dev/thub/dut<N>-<kind>, or an explicit path.
28
+ index: { type: 'integer', minimum: 1, maximum: MAX_DEVICES },
29
+ path: { type: 'string', pattern: '^/dev/[A-Za-z0-9._/+-]+$', maxLength: 128 },
30
+ // udev rule for the symlink (README §8.2): the USB port path, plus
31
+ // optional id/subsystem overrides.
32
+ devpath: { type: 'string', pattern: '^[A-Za-z0-9.:+/-]+$', maxLength: 64 },
33
+ vendorId: { type: 'string', pattern: '^[0-9a-fA-F]{4}$' },
34
+ productId: { type: 'string', pattern: '^[0-9a-fA-F]{4}$' },
35
+ subsystem: { enum: ['usb', 'tty'] },
36
+ ...extra
37
+ },
38
+ anyOf: [{ required: ['index'] }, { required: ['path'] }, ...(extra.serial ? [{ required: ['serial'] }] : [])]
39
+ }),
40
+ list = (item) => ({ type: 'array', maxItems: MAX_DEVICES, items: item }),
41
+ hwSchema = {
42
+ type: 'object',
43
+ additionalProperties: false,
44
+ properties: {
45
+ stlinks: list(device({ serial: { type: 'string', pattern: '^[A-Za-z0-9]{1,64}$' } })),
46
+ 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 } }
80
+ }
81
+ },
82
+ ajv = new Ajv({ allErrors: true, strict: false }),
83
+ validators = { hw: null, sw: null };
84
+ addFormats(ajv);
85
+ validators.hw = ajv.compile(hwSchema);
86
+ validators.sw = ajv.compile(swSchema);
87
+
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'] };
91
+
92
+ // { valid, errors } for a Client's `type` ('hw' | 'sw') config section.
93
+ function validateClientConfig(type, section){
94
+ const validate = validators[type];
95
+ if (!validate){
96
+ return { valid: false, errors: [`unknown Client type "${type}"`] };
97
+ }
98
+ if (!section || typeof section !== 'object' || Array.isArray(section)){
99
+ return { valid: false, errors: [`the ${type} section must be an object`] };
100
+ }
101
+ const valid = validate(section);
102
+ return {
103
+ valid,
104
+ errors: valid ? [] : validate.errors.map((e) => `${type}${e.instancePath.replace(/\//g, '.')} ${e.message}`)
105
+ };
106
+ }
107
+
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] || []){
112
+ delete out[key];
113
+ }
114
+ return out;
115
+ }
116
+
117
+ module.exports = { validateClientConfig, publicClientConfig, CLIENT_CONFIG_PRIVATE_FIELDS: PRIVATE_FIELDS };
package/src/index.js CHANGED
@@ -19,5 +19,6 @@ module.exports = {
19
19
  ...require('./updates'),
20
20
  ...require('./datetime'),
21
21
  ...require('./cron'),
22
+ ...require('./client-config'),
22
23
  ApiClient: require('./api-client').ApiClient
23
24
  };
@@ -18,19 +18,20 @@
18
18
  const DOCKER_IMAGE_PATTERN =
19
19
  '^[A-Za-z0-9][A-Za-z0-9.-]*(:[0-9]+)?(/[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*)*(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(@sha256:[a-f0-9]{64})?$',
20
20
 
21
- // A git branch or tag name: no leading "-" or "/", no "..", no spaces or
22
- // the characters git forbids (~^:?*[\), not ending in "/", ".lock" or ".".
21
+ // A git ref to check out — a branch, a tag or a commit (hex) — as git
22
+ // allows them: no leading "-" (never taken for an option) or "/", no "..",
23
+ // no spaces or the characters git forbids, not ending in "/", ".lock", ".".
23
24
  GIT_REF_PATTERN = '^(?![-/])(?!.*\\.\\.)(?!.*//)(?!.*(/|\\.lock|\\.)$)[A-Za-z0-9._/+@-]+$',
24
25
 
25
- // Matches the job spec shape documented in README.md §4.3.
26
- // Deliberately has no field for shell commands: the Client only ever
27
- // runs the fixed entry point from the downloaded test package — the
28
- // one exception is firmware.image, which a Client must opt in to.
26
+ // Matches the job spec shape documented in README.md §4.3. A task is:
27
+ // optionally files to download and/or a git checkout, optionally (SW only)
28
+ // a Docker image to run as the DUT, and — always — the shell command that
29
+ // is its entry point, run on the Client in the checkout / work directory.
29
30
  jobSpecSchema = {
30
31
  $id: 'https://thub.example.com/schemas/job-spec.json',
31
32
  type: 'object',
32
33
  additionalProperties: false,
33
- required: ['target', 'tests'],
34
+ required: ['target', 'command'],
34
35
  properties: {
35
36
  target: {
36
37
  type: 'object',
@@ -56,47 +57,39 @@ const DOCKER_IMAGE_PATTERN =
56
57
  client: { type: 'string', minLength: 1 }
57
58
  }
58
59
  },
59
- // Exactly one of `url` (a firmware file the Client downloads — HW and
60
- // SW) or `image` (a Docker image an SW Client runs instead of its own
61
- // sw.image, if it allows that: sw.allowJobImages). The combinations are
62
- // checked in validate-job-spec.js, for readable errors.
63
- firmware: {
64
- type: 'object',
65
- additionalProperties: false,
66
- properties: {
67
- url: { type: 'string', format: 'uri' },
68
- image: { type: 'string', maxLength: 255, pattern: DOCKER_IMAGE_PATTERN },
69
- sha256: { type: 'string', pattern: '^[a-f0-9]{64}$' },
70
- flashAddress: { type: 'string' }
71
- }
60
+ // The task's entry point (`thub run --command`): a shell command the
61
+ // Client runs with `sh -c`, `args` as "$@" (`--arg`, repeatable).
62
+ command: { type: 'string', minLength: 1, maxLength: 4096 },
63
+ args: { type: 'array', items: { type: 'string' }, default: [] },
64
+ // Passed to the command as THUB_SUITE (`--suite`).
65
+ suite: { type: 'string', default: 'default' },
66
+ // Files the Client downloads into the task's work directory before
67
+ // running the command (`--download-file`, repeatable).
68
+ downloads: {
69
+ type: 'array',
70
+ maxItems: 32,
71
+ items: {
72
+ type: 'object',
73
+ additionalProperties: false,
74
+ required: ['url'],
75
+ properties: { url: { type: 'string', format: 'uri' } }
76
+ },
77
+ default: []
72
78
  },
73
- // Where the tests come from — exactly one of `url` (an archive: tar in
74
- // any compression, or zip) or `git` (a repository at a branch, tag or
75
- // commit; default: its default branch) — and how they start: `command`
76
- // (a shell command run in the sources, if the Client allows it:
77
- // allowJobCommands), else the package's own run-tests.sh. Combinations
78
- // are checked in validate-job-spec.js.
79
- tests: {
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`.
81
+ image: { type: 'string', maxLength: 255, pattern: DOCKER_IMAGE_PATTERN },
82
+ // A repository the Client clones before running the command — at `ref`
83
+ // (branch, tag or commit; default: the default branch), `depth` commits
84
+ // deep (0 = full history). `--git-repo <url> [ref] [--depth <n>]`.
85
+ git: {
80
86
  type: 'object',
81
87
  additionalProperties: false,
88
+ required: ['url'],
82
89
  properties: {
83
- url: { type: 'string', format: 'uri' },
84
- git: {
85
- type: 'object',
86
- additionalProperties: false,
87
- required: ['url'],
88
- properties: {
89
- url: { type: 'string', minLength: 1, maxLength: 2048 },
90
- // Ref names as git allows them — never starting with "-",
91
- // so none can be taken for a git option.
92
- branch: { type: 'string', maxLength: 255, pattern: GIT_REF_PATTERN },
93
- tag: { type: 'string', maxLength: 255, pattern: GIT_REF_PATTERN },
94
- commit: { type: 'string', pattern: '^[0-9a-fA-F]{7,40}$' }
95
- }
96
- },
97
- command: { type: 'string', minLength: 1, maxLength: 4096 },
98
- suite: { type: 'string', default: 'default' },
99
- args: { type: 'array', items: { type: 'string' }, default: [] }
90
+ url: { type: 'string', minLength: 1, maxLength: 2048 },
91
+ ref: { type: 'string', maxLength: 255, pattern: GIT_REF_PATTERN },
92
+ depth: { type: 'integer', minimum: 0, maximum: 100000, default: 1 }
100
93
  }
101
94
  },
102
95
  timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
@@ -26,62 +26,44 @@ const validateFn = ajv.compile(jobSpecSchema);
26
26
  * Returns { valid, spec, errors }.
27
27
  */
28
28
  function validateJobSpec(spec){
29
+ const legacy = legacyShapeError(spec);
30
+ if (legacy){
31
+ return { valid: false, spec: JSON.parse(JSON.stringify(spec ?? {})), errors: [legacy] };
32
+ }
29
33
  const clone = JSON.parse(JSON.stringify(spec ?? {})),
30
34
  schemaValid = validateFn(clone),
31
35
  errors = schemaValid ? [] : (validateFn.errors || []).map((e) => `${e.instancePath || '/'} ${e.message}`);
32
- errors.push(...firmwareErrors(clone), ...testsErrors(clone));
36
+ errors.push(...crossFieldErrors(clone));
33
37
  return { valid: errors.length === 0, spec: clone, errors };
34
38
  }
35
39
 
36
- // Git transports a Client may fetch test sources over. Never `ext::` (runs
37
- // a command), `file://` or a local path: the Client also sets
38
- // GIT_ALLOW_PROTOCOL to the same list (downloader.js).
39
- const GIT_URL = /^(?:(?:https?|ssh|git):\/\/[^\s]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)$/;
40
-
41
- // tests.url (archive) vs tests.git (repo + at most one of branch/tag/commit).
42
- function testsErrors(spec){
43
- const tests = spec.tests;
44
- if (!tests || typeof tests !== 'object'){
45
- return [];
46
- }
47
- if (tests.url && tests.git){
48
- return ['/tests give either url (an archive) or git (a repository), not both'];
49
- }
50
- if (!tests.url && !tests.git){
51
- return ['/tests needs url (an archive: tar/tar.gz/zip) or git (a repository)'];
52
- }
53
- if (tests.git){
54
- if (!GIT_URL.test(tests.git.url || '')){
55
- return ['/tests/git/url must be an https://, http://, ssh:// or git:// URL, or user@host:path'];
56
- }
57
- const refs = ['branch', 'tag', 'commit'].filter((k) => tests.git[k]);
58
- if (refs.length > 1){
59
- return [`/tests/git give at most one of branch, tag or commit (got ${refs.join(', ')})`];
60
- }
40
+ // Specs from an Agent older than --command (firmware/tests fields) can't be
41
+ // translated faithfully (they relied on the Client flashing and on
42
+ // run-tests.sh) — say what to do instead of listing unknown fields.
43
+ function legacyShapeError(spec){
44
+ if (spec && typeof spec === 'object' && ('firmware' in spec || 'tests' in spec) && !('command' in spec)){
45
+ return '/ this job spec is from an older Agent (firmware/tests fields) — update the Agent (thub self-update) ' +
46
+ 'and use --command, --download-file, --docker-image and --git-repo';
61
47
  }
62
- return [];
48
+ return null;
63
49
  }
64
50
 
65
- // firmware.url vs firmware.image — spelled out here rather than as schema
66
- // oneOf/if-then, whose errors ("must match exactly one schema") say little.
67
- function firmwareErrors(spec){
68
- const fw = spec.firmware;
69
- if (!fw || typeof fw !== 'object'){
70
- return ['/firmware is required'];
71
- }
72
- if (fw.url && fw.image){
73
- return ['/firmware give either url (a firmware file) or image (a Docker image), not both'];
74
- }
75
- if (!fw.url && !fw.image){
76
- return ['/firmware needs url (a firmware file) or image (a Docker image, SW jobs only)'];
77
- }
78
- if (fw.image && spec.target?.type === 'hw'){
79
- return ['/firmware/image a Docker image only works for SW jobs (target.type "sw") — an HW job flashes a firmware file: use firmware.url'];
51
+ // Git transports a Client may fetch sources over. Never `ext::` (runs a
52
+ // command), `file://` or a local path: the Client also sets
53
+ // GIT_ALLOW_PROTOCOL to the same list (downloader.js).
54
+ const GIT_URL = /^(?:(?:https?|ssh|git):\/\/[^\s]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)$/;
55
+
56
+ // Rules across fields, spelled out here rather than as schema if/then,
57
+ // whose errors ("must match a schema in then") say little.
58
+ function crossFieldErrors(spec){
59
+ const errors = [];
60
+ if (spec.image && spec.target?.type === 'hw'){
61
+ errors.push('/image a Docker image only works for SW jobs (target.type "sw")');
80
62
  }
81
- if (fw.image && fw.sha256){
82
- return ['/firmware/sha256 applies to a firmware file (url) only — pin a Docker image by digest instead (image@sha256:...)'];
63
+ if (spec.git && !GIT_URL.test(spec.git.url || '')){
64
+ errors.push('/git/url must be an https://, http://, ssh:// or git:// URL, or user@host:path');
83
65
  }
84
- return [];
66
+ return errors;
85
67
  }
86
68
 
87
69
  module.exports = { validateJobSpec };