@andrian.yablonskyy/thub-common 1.0.24 → 1.0.26
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 +1 -1
- package/src/client-config.js +117 -0
- package/src/index.js +2 -0
- package/src/job-spec.schema.js +7 -1
- package/src/split-args.js +88 -0
- package/src/validate-job-spec.js +10 -1
package/package.json
CHANGED
|
@@ -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
package/src/job-spec.schema.js
CHANGED
|
@@ -89,7 +89,13 @@ const DOCKER_IMAGE_PATTERN =
|
|
|
89
89
|
properties: {
|
|
90
90
|
url: { type: 'string', minLength: 1, maxLength: 2048 },
|
|
91
91
|
ref: { type: 'string', maxLength: 255, pattern: GIT_REF_PATTERN },
|
|
92
|
-
depth: { type: 'integer', minimum: 0, maximum: 100000, default: 1 }
|
|
92
|
+
depth: { type: 'integer', minimum: 0, maximum: 100000, default: 1 },
|
|
93
|
+
// Extra git options (`--git-options`), shell-quoted, inserted
|
|
94
|
+
// between `git` and its subcommand on the Client — e.g.
|
|
95
|
+
// -c core.sshCommand="ssh -i ~/.ssh/lab_key -p 2222". Stored with
|
|
96
|
+
// the job and visible like the rest of it: reference key files on
|
|
97
|
+
// the Client rather than putting secrets here.
|
|
98
|
+
options: { type: 'string', minLength: 1, maxLength: 1024 }
|
|
93
99
|
}
|
|
94
100
|
},
|
|
95
101
|
timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/split-args.js
|
|
3
|
+
* @description Splits a command-line options string into arguments the way a POSIX shell would quote them —
|
|
4
|
+
* without running a shell (for `thub run --git-options`)
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
// Whitespace separates arguments; '...' is literal; "..." keeps spaces, with
|
|
18
|
+
// \" \\ \$ \` escaped; a backslash outside quotes escapes the next
|
|
19
|
+
// character. No expansion of any kind ($VAR, ~, globs) — the result goes
|
|
20
|
+
// straight to execFile. Throws on an unterminated quote or trailing "\".
|
|
21
|
+
function splitArgs(text){
|
|
22
|
+
const args = [],
|
|
23
|
+
s = String(text ?? '');
|
|
24
|
+
let current = null,
|
|
25
|
+
i = 0;
|
|
26
|
+
const push = (ch) => {
|
|
27
|
+
current = (current ?? '') + ch;
|
|
28
|
+
};
|
|
29
|
+
while (i < s.length){
|
|
30
|
+
const ch = s[i];
|
|
31
|
+
if (/\s/.test(ch)){
|
|
32
|
+
if (current !== null){
|
|
33
|
+
args.push(current);
|
|
34
|
+
current = null;
|
|
35
|
+
}
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
else if (ch === '\''){
|
|
39
|
+
const end = s.indexOf('\'', i + 1);
|
|
40
|
+
if (end < 0){
|
|
41
|
+
throw new Error('unterminated \' quote');
|
|
42
|
+
}
|
|
43
|
+
push(s.slice(i + 1, end));
|
|
44
|
+
i = end + 1;
|
|
45
|
+
}
|
|
46
|
+
else if (ch === '"'){
|
|
47
|
+
let j = i + 1,
|
|
48
|
+
closed = false;
|
|
49
|
+
push('');
|
|
50
|
+
while (j < s.length){
|
|
51
|
+
if (s[j] === '\\' && /["\\$`]/.test(s[j + 1] || '')){
|
|
52
|
+
push(s[j + 1]);
|
|
53
|
+
j += 2;
|
|
54
|
+
}
|
|
55
|
+
else if (s[j] === '"'){
|
|
56
|
+
closed = true;
|
|
57
|
+
j++;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
push(s[j]);
|
|
62
|
+
j++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!closed){
|
|
66
|
+
throw new Error('unterminated " quote');
|
|
67
|
+
}
|
|
68
|
+
i = j;
|
|
69
|
+
}
|
|
70
|
+
else if (ch === '\\'){
|
|
71
|
+
if (i + 1 >= s.length){
|
|
72
|
+
throw new Error('trailing \\');
|
|
73
|
+
}
|
|
74
|
+
push(s[i + 1]);
|
|
75
|
+
i += 2;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
push(ch);
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (current !== null){
|
|
83
|
+
args.push(current);
|
|
84
|
+
}
|
|
85
|
+
return args;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { splitArgs };
|
package/src/validate-job-spec.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
const Ajv = require('ajv'),
|
|
17
17
|
addFormats = require('ajv-formats'),
|
|
18
|
-
{ jobSpecSchema } = require('./job-spec.schema')
|
|
18
|
+
{ jobSpecSchema } = require('./job-spec.schema'),
|
|
19
|
+
{ splitArgs } = require('./split-args');
|
|
19
20
|
|
|
20
21
|
const ajv = new Ajv({ useDefaults: true, allErrors: true, strict: false });
|
|
21
22
|
addFormats(ajv);
|
|
@@ -63,6 +64,14 @@ function crossFieldErrors(spec){
|
|
|
63
64
|
if (spec.git && !GIT_URL.test(spec.git.url || '')){
|
|
64
65
|
errors.push('/git/url must be an https://, http://, ssh:// or git:// URL, or user@host:path');
|
|
65
66
|
}
|
|
67
|
+
if (spec.git?.options){
|
|
68
|
+
try {
|
|
69
|
+
splitArgs(spec.git.options);
|
|
70
|
+
}
|
|
71
|
+
catch (err){
|
|
72
|
+
errors.push(`/git/options can't be split into arguments: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
66
75
|
return errors;
|
|
67
76
|
}
|
|
68
77
|
|