@andrian.yablonskyy/thub-common 1.0.20 → 1.0.22
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/datetime.js +33 -1
- package/src/job-spec.schema.js +103 -70
- package/src/validate-job-spec.js +55 -6
package/package.json
CHANGED
package/src/datetime.js
CHANGED
|
@@ -50,4 +50,36 @@ function formatDateTime(value, { timeZone, fallback = '—' } = {}){
|
|
|
50
50
|
return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// Minutes `timeZone` is ahead of UTC at `date` (e.g. 180 for Kyiv in summer).
|
|
54
|
+
function zoneOffsetMinutes(date, timeZone){
|
|
55
|
+
const p = Object.fromEntries(formatterFor(timeZone).formatToParts(date).map(({ type, value }) => [type, value])),
|
|
56
|
+
asUtc = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second));
|
|
57
|
+
return Math.round((asUtc - Math.floor(date.getTime() / 1000) * 1000) / 60000);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The inverse of formatDateTime: "28/09/2026 14:05[:03]" read as wall-clock
|
|
61
|
+
// time in `timeZone` (omitted = this process's local zone). Returns a Date,
|
|
62
|
+
// or null if the text isn't a valid date/time in that format.
|
|
63
|
+
function parseDateTime(text, { timeZone } = {}){
|
|
64
|
+
const m = /^\s*(\d{1,2})\/(\d{1,2})\/(\d{4})(?:[ T]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?\s*$/.exec(String(text ?? ''));
|
|
65
|
+
if (!m){
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const [day, month, year, hour = 0, minute = 0, second = 0] = [m[1], m[2], m[3], m[4], m[5], m[6]].map((v) => (v === undefined ? undefined : Number(v))),
|
|
69
|
+
wall = Date.UTC(year, month - 1, day, hour, minute, second),
|
|
70
|
+
check = new Date(wall);
|
|
71
|
+
// Reject 31/02, 25:00 and the like rather than letting Date roll them over.
|
|
72
|
+
if (check.getUTCDate() !== day || check.getUTCMonth() !== month - 1 || check.getUTCHours() !== hour || minute > 59 || second > 59){
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!timeZone){
|
|
76
|
+
return new Date(year, month - 1, day, hour, minute, second);
|
|
77
|
+
}
|
|
78
|
+
// Wall time -> instant: subtract the zone's offset, then re-check it at
|
|
79
|
+
// the result (the offset can differ across a DST change).
|
|
80
|
+
let at = wall - zoneOffsetMinutes(new Date(wall), timeZone) * 60000;
|
|
81
|
+
at = wall - zoneOffsetMinutes(new Date(at), timeZone) * 60000;
|
|
82
|
+
return new Date(at);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { formatDateTime, parseDateTime };
|
package/src/job-spec.schema.js
CHANGED
|
@@ -13,74 +13,107 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
$id: 'https://thub.example.com/schemas/job-spec.json',
|
|
21
|
-
type: 'object',
|
|
22
|
-
additionalProperties: false,
|
|
23
|
-
required: ['target', 'firmware', 'tests'],
|
|
24
|
-
properties: {
|
|
25
|
-
target: {
|
|
26
|
-
type: 'object',
|
|
27
|
-
additionalProperties: false,
|
|
28
|
-
required: ['type'],
|
|
29
|
-
properties: {
|
|
30
|
-
type: { enum: ['hw', 'sw'] },
|
|
31
|
-
labels: {
|
|
32
|
-
type: 'array',
|
|
33
|
-
items: { type: 'string', minLength: 1 },
|
|
34
|
-
default: []
|
|
35
|
-
},
|
|
36
|
-
// Constrains scheduling to resources that are members of this
|
|
37
|
-
// group (§13.1, `thub run --group <id>`) — a third targeting
|
|
38
|
-
// dimension alongside type/labels. Omitted: any matching resource
|
|
39
|
-
// in any (or no) group is eligible, same as before groups existed.
|
|
40
|
-
group: { type: 'string', minLength: 1 },
|
|
41
|
-
// Pins the job to one specific Client (`thub run --client <name|id>`):
|
|
42
|
-
// it's queued for that resource alone and waits for it even if other
|
|
43
|
-
// matching resources are idle. Accepts a resource name or id; the
|
|
44
|
-
// Coordinator resolves it to the resource id at submission time, so
|
|
45
|
-
// a later rename of the Client doesn't orphan the queued job.
|
|
46
|
-
client: { type: 'string', minLength: 1 }
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
firmware: {
|
|
50
|
-
type: 'object',
|
|
51
|
-
additionalProperties: false,
|
|
52
|
-
required: ['url'],
|
|
53
|
-
properties: {
|
|
54
|
-
url: { type: 'string', format: 'uri' },
|
|
55
|
-
sha256: { type: 'string', pattern: '^[a-f0-9]{64}$' },
|
|
56
|
-
flashAddress: { type: 'string' }
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
tests: {
|
|
60
|
-
type: 'object',
|
|
61
|
-
additionalProperties: false,
|
|
62
|
-
required: ['url'],
|
|
63
|
-
properties: {
|
|
64
|
-
url: { type: 'string', format: 'uri' },
|
|
65
|
-
suite: { type: 'string', default: 'default' },
|
|
66
|
-
args: { type: 'array', items: { type: 'string' }, default: [] }
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
|
|
70
|
-
priority: { type: 'integer', minimum: 0, maximum: 100, default: 50 },
|
|
71
|
-
// Set by the Coordinator from the agent token's kind; any value an
|
|
72
|
-
// (older) Agent sends is accepted but overwritten.
|
|
73
|
-
source: { enum: ['ci', 'cli'] },
|
|
74
|
-
// Free-text job owner (`thub run --user <name>`, §7.1) — purely a
|
|
75
|
-
// label shown on the Client and dashboard to tell whose job is whose,
|
|
76
|
-
// not an identity: nothing authenticates or enforces it.
|
|
77
|
-
user: { type: 'string', minLength: 1 },
|
|
78
|
-
meta: { type: 'object' },
|
|
79
|
-
// Exercises the full pipeline (schedule, accept, state transitions,
|
|
80
|
-
// logs, artifact, result) without flashing/running anything for real —
|
|
81
|
-
// see README §7.1 "Dry-run the pipeline".
|
|
82
|
-
dryRun: { type: 'boolean', default: false }
|
|
83
|
-
}
|
|
84
|
-
};
|
|
16
|
+
// A Docker image reference: [host[:port]/]path[:tag][@sha256:digest], e.g.
|
|
17
|
+
// `alpine`, `alpine:3.20`, `library/ubuntu:24.04`, `registry.lab:5000/emu:1`.
|
|
18
|
+
const DOCKER_IMAGE_PATTERN =
|
|
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})?$',
|
|
85
20
|
|
|
86
|
-
|
|
21
|
+
// A git branch or tag name: no leading "-" or "/", no "..", no spaces or
|
|
22
|
+
// the characters git forbids (~^:?*[\), not ending in "/", ".lock" or ".".
|
|
23
|
+
GIT_REF_PATTERN = '^(?![-/])(?!.*\\.\\.)(?!.*//)(?!.*(/|\\.lock|\\.)$)[A-Za-z0-9._/+@-]+$',
|
|
24
|
+
|
|
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.
|
|
29
|
+
jobSpecSchema = {
|
|
30
|
+
$id: 'https://thub.example.com/schemas/job-spec.json',
|
|
31
|
+
type: 'object',
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
required: ['target', 'tests'],
|
|
34
|
+
properties: {
|
|
35
|
+
target: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
required: ['type'],
|
|
39
|
+
properties: {
|
|
40
|
+
type: { enum: ['hw', 'sw'] },
|
|
41
|
+
labels: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: { type: 'string', minLength: 1 },
|
|
44
|
+
default: []
|
|
45
|
+
},
|
|
46
|
+
// Constrains scheduling to resources that are members of this
|
|
47
|
+
// group (§13.1, `thub run --group <id>`) — a third targeting
|
|
48
|
+
// dimension alongside type/labels. Omitted: any matching resource
|
|
49
|
+
// in any (or no) group is eligible, same as before groups existed.
|
|
50
|
+
group: { type: 'string', minLength: 1 },
|
|
51
|
+
// Pins the job to one specific Client (`thub run --client <name|id>`):
|
|
52
|
+
// it's queued for that resource alone and waits for it even if other
|
|
53
|
+
// matching resources are idle. Accepts a resource name or id; the
|
|
54
|
+
// Coordinator resolves it to the resource id at submission time, so
|
|
55
|
+
// a later rename of the Client doesn't orphan the queued job.
|
|
56
|
+
client: { type: 'string', minLength: 1 }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
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
|
+
}
|
|
72
|
+
},
|
|
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: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
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: [] }
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
|
|
103
|
+
priority: { type: 'integer', minimum: 0, maximum: 100, default: 50 },
|
|
104
|
+
// Set by the Coordinator from the agent token's kind; any value an
|
|
105
|
+
// (older) Agent sends is accepted but overwritten.
|
|
106
|
+
source: { enum: ['ci', 'cli'] },
|
|
107
|
+
// Free-text job owner (`thub run --user <name>`, §7.1) — purely a
|
|
108
|
+
// label shown on the Client and dashboard to tell whose job is whose,
|
|
109
|
+
// not an identity: nothing authenticates or enforces it.
|
|
110
|
+
user: { type: 'string', minLength: 1 },
|
|
111
|
+
meta: { type: 'object' },
|
|
112
|
+
// Exercises the full pipeline (schedule, accept, state transitions,
|
|
113
|
+
// logs, artifact, result) without flashing/running anything for real —
|
|
114
|
+
// see README §7.1 "Dry-run the pipeline".
|
|
115
|
+
dryRun: { type: 'boolean', default: false }
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN };
|
package/src/validate-job-spec.js
CHANGED
|
@@ -27,12 +27,61 @@ const validateFn = ajv.compile(jobSpecSchema);
|
|
|
27
27
|
*/
|
|
28
28
|
function validateJobSpec(spec){
|
|
29
29
|
const clone = JSON.parse(JSON.stringify(spec ?? {})),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
schemaValid = validateFn(clone),
|
|
31
|
+
errors = schemaValid ? [] : (validateFn.errors || []).map((e) => `${e.instancePath || '/'} ${e.message}`);
|
|
32
|
+
errors.push(...firmwareErrors(clone), ...testsErrors(clone));
|
|
33
|
+
return { valid: errors.length === 0, spec: clone, errors };
|
|
34
|
+
}
|
|
35
|
+
|
|
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
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
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'];
|
|
80
|
+
}
|
|
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:...)'];
|
|
83
|
+
}
|
|
84
|
+
return [];
|
|
36
85
|
}
|
|
37
86
|
|
|
38
87
|
module.exports = { validateJobSpec };
|