@andrian.yablonskyy/thub-common 1.0.27 → 1.0.29
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 +127 -47
- package/src/job-spec.schema.js +8 -12
- package/src/states.js +2 -0
- package/src/validate-job-spec.js +5 -10
package/package.json
CHANGED
package/src/client-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/job-spec.schema.js
CHANGED
|
@@ -79,8 +79,8 @@ const DOCKER_IMAGE_PATTERN =
|
|
|
79
79
|
},
|
|
80
80
|
default: []
|
|
81
81
|
},
|
|
82
|
-
// A Docker image an SW Client runs as the DUT
|
|
83
|
-
//
|
|
82
|
+
// A Docker image an SW Client runs as the job's DUT container, next to
|
|
83
|
+
// its command — `--docker-image`.
|
|
84
84
|
image: { type: 'string', maxLength: 255, pattern: DOCKER_IMAGE_PATTERN },
|
|
85
85
|
// A repository the Client clones before running the command — at `ref`
|
|
86
86
|
// (branch, tag or commit; default: the default branch), `depth` commits
|
|
@@ -102,11 +102,10 @@ const DOCKER_IMAGE_PATTERN =
|
|
|
102
102
|
}
|
|
103
103
|
},
|
|
104
104
|
// Environment variables (`--env NAME=value`) the Client sets for every
|
|
105
|
-
// command it runs for the job — git
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// from its database once the job ends.
|
|
105
|
+
// command it runs for the job — git and the job's command. No name
|
|
106
|
+
// means anything special. Values are secrets as far as the Coordinator
|
|
107
|
+
// is concerned: masked in the Agent API and dropped from its database
|
|
108
|
+
// once the job ends.
|
|
110
109
|
env: {
|
|
111
110
|
type: 'object',
|
|
112
111
|
maxProperties: 64,
|
|
@@ -128,9 +127,6 @@ const DOCKER_IMAGE_PATTERN =
|
|
|
128
127
|
// see README §7.1 "Dry-run the pipeline".
|
|
129
128
|
dryRun: { type: 'boolean', default: false }
|
|
130
129
|
}
|
|
131
|
-
}
|
|
130
|
+
};
|
|
132
131
|
|
|
133
|
-
|
|
134
|
-
DOCKER_LOGIN_ENV = ['DOCKER_REGISTRY', 'DOCKER_USERNAME', 'DOCKER_PASSWORD'];
|
|
135
|
-
|
|
136
|
-
module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN, ENV_NAME_PATTERN, DOCKER_LOGIN_ENV };
|
|
132
|
+
module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN, ENV_NAME_PATTERN };
|
package/src/states.js
CHANGED
package/src/validate-job-spec.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
const Ajv = require('ajv'),
|
|
17
17
|
addFormats = require('ajv-formats'),
|
|
18
|
-
{ jobSpecSchema
|
|
18
|
+
{ jobSpecSchema } = require('./job-spec.schema'),
|
|
19
19
|
{ splitArgs } = require('./split-args');
|
|
20
20
|
|
|
21
21
|
const ajv = new Ajv({ useDefaults: true, allErrors: true, strict: false });
|
|
@@ -55,8 +55,8 @@ function legacyShapeError(spec){
|
|
|
55
55
|
const GIT_URL = /^(?:(?:https?|ssh|git):\/\/[^\s]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)$/,
|
|
56
56
|
|
|
57
57
|
// Job env names the Client sets itself for a job's commands (besides
|
|
58
|
-
// THUB_*): git's safety settings
|
|
59
|
-
RESERVED_ENV = ['GIT_TERMINAL_PROMPT', 'GIT_ALLOW_PROTOCOL'
|
|
58
|
+
// THUB_*): git's safety settings.
|
|
59
|
+
RESERVED_ENV = ['GIT_TERMINAL_PROMPT', 'GIT_ALLOW_PROTOCOL'];
|
|
60
60
|
|
|
61
61
|
// Rules across fields, spelled out here rather than as schema if/then,
|
|
62
62
|
// whose errors ("must match a schema in then") say little.
|
|
@@ -69,15 +69,10 @@ function crossFieldErrors(spec){
|
|
|
69
69
|
errors.push('/git/url must be an https://, http://, ssh:// or git:// URL, or user@host:path');
|
|
70
70
|
}
|
|
71
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));
|
|
72
|
+
reserved = envNames.filter((n) => /^THUB_/.test(n) || RESERVED_ENV.includes(n));
|
|
74
73
|
if (reserved.length){
|
|
75
74
|
errors.push(`/env ${reserved.join(', ')}: set by the Client itself (THUB_*, ${RESERVED_ENV.join(', ')}) — use other names`);
|
|
76
75
|
}
|
|
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
76
|
if (spec.git?.options){
|
|
82
77
|
try {
|
|
83
78
|
splitArgs(spec.git.options);
|
|
@@ -97,4 +92,4 @@ function maskEnv(env){
|
|
|
97
92
|
|
|
98
93
|
const MASKED = '***';
|
|
99
94
|
|
|
100
|
-
module.exports = { validateJobSpec, maskEnv,
|
|
95
|
+
module.exports = { validateJobSpec, maskEnv, JOB_ENV_MASK: MASKED };
|