@andrian.yablonskyy/thub-common 1.0.25 → 1.0.27
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/env-list.js +40 -0
- package/src/index.js +2 -0
- package/src/job-spec.schema.js +27 -3
- package/src/split-args.js +88 -0
- package/src/validate-job-spec.js +34 -3
package/package.json
CHANGED
package/src/env-list.js
ADDED
|
@@ -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
package/src/job-spec.schema.js
CHANGED
|
@@ -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
|
|
@@ -89,9 +92,27 @@ const DOCKER_IMAGE_PATTERN =
|
|
|
89
92
|
properties: {
|
|
90
93
|
url: { type: 'string', minLength: 1, maxLength: 2048 },
|
|
91
94
|
ref: { type: 'string', maxLength: 255, pattern: GIT_REF_PATTERN },
|
|
92
|
-
depth: { type: 'integer', minimum: 0, maximum: 100000, default: 1 }
|
|
95
|
+
depth: { type: 'integer', minimum: 0, maximum: 100000, default: 1 },
|
|
96
|
+
// Extra git options (`--git-options`), shell-quoted, inserted
|
|
97
|
+
// between `git` and its subcommand on the Client — e.g.
|
|
98
|
+
// -c core.sshCommand="ssh -i ~/.ssh/lab_key -p 2222". Stored with
|
|
99
|
+
// the job and visible like the rest of it: reference key files on
|
|
100
|
+
// the Client rather than putting secrets here.
|
|
101
|
+
options: { type: 'string', minLength: 1, maxLength: 1024 }
|
|
93
102
|
}
|
|
94
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
|
+
},
|
|
95
116
|
timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
|
|
96
117
|
priority: { type: 'integer', minimum: 0, maximum: 100, default: 50 },
|
|
97
118
|
// Set by the Coordinator from the agent token's kind; any value an
|
|
@@ -107,6 +128,9 @@ const DOCKER_IMAGE_PATTERN =
|
|
|
107
128
|
// see README §7.1 "Dry-run the pipeline".
|
|
108
129
|
dryRun: { type: 'boolean', default: false }
|
|
109
130
|
}
|
|
110
|
-
}
|
|
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'];
|
|
111
135
|
|
|
112
|
-
module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN };
|
|
136
|
+
module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN, ENV_NAME_PATTERN, DOCKER_LOGIN_ENV };
|
|
@@ -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, DOCKER_LOGIN_ENV } = 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);
|
|
@@ -51,7 +52,11 @@ function legacyShapeError(spec){
|
|
|
51
52
|
// Git transports a Client may fetch sources over. Never `ext::` (runs a
|
|
52
53
|
// command), `file://` or a local path: the Client also sets
|
|
53
54
|
// 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
|
+
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'];
|
|
55
60
|
|
|
56
61
|
// Rules across fields, spelled out here rather than as schema if/then,
|
|
57
62
|
// whose errors ("must match a schema in then") say little.
|
|
@@ -63,7 +68,33 @@ function crossFieldErrors(spec){
|
|
|
63
68
|
if (spec.git && !GIT_URL.test(spec.git.url || '')){
|
|
64
69
|
errors.push('/git/url must be an https://, http://, ssh:// or git:// URL, or user@host:path');
|
|
65
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
|
+
}
|
|
81
|
+
if (spec.git?.options){
|
|
82
|
+
try {
|
|
83
|
+
splitArgs(spec.git.options);
|
|
84
|
+
}
|
|
85
|
+
catch (err){
|
|
86
|
+
errors.push(`/git/options can't be split into arguments: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
66
89
|
return errors;
|
|
67
90
|
}
|
|
68
91
|
|
|
69
|
-
|
|
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 };
|