@andrian.yablonskyy/thub-common 1.0.19 → 1.0.21
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 +85 -0
- package/src/index.js +1 -0
- package/src/job-spec.schema.js +80 -70
- package/src/validate-job-spec.js +26 -6
package/package.json
CHANGED
package/src/datetime.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/datetime.js
|
|
3
|
+
* @description The one human-readable date/time format used everywhere: dd/mm/yyyy HH:MM:SS, 24-hour
|
|
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
|
+
// One formatter per time zone — building an Intl.DateTimeFormat is the
|
|
17
|
+
// expensive part, and a dashboard page formats many timestamps.
|
|
18
|
+
const formatters = new Map();
|
|
19
|
+
|
|
20
|
+
function formatterFor(timeZone){
|
|
21
|
+
const key = timeZone || '';
|
|
22
|
+
if (!formatters.has(key)){
|
|
23
|
+
formatters.set(key, new Intl.DateTimeFormat('en-GB', {
|
|
24
|
+
...(timeZone ? { timeZone } : {}),
|
|
25
|
+
day: '2-digit',
|
|
26
|
+
month: '2-digit',
|
|
27
|
+
year: 'numeric',
|
|
28
|
+
hour: '2-digit',
|
|
29
|
+
minute: '2-digit',
|
|
30
|
+
second: '2-digit',
|
|
31
|
+
hourCycle: 'h23' // 00-23, never "24:00:00" at midnight
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
return formatters.get(key);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// "28/09/2026 14:05:03" for a Date, ISO string or epoch ms, in `timeZone`
|
|
38
|
+
// (IANA name; omitted = this process's local zone). Built from parts, so
|
|
39
|
+
// the result doesn't depend on the ICU version's punctuation. `fallback`
|
|
40
|
+
// for an empty or unparseable value.
|
|
41
|
+
function formatDateTime(value, { timeZone, fallback = '—' } = {}){
|
|
42
|
+
if (value === null || value === undefined || value === ''){
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
46
|
+
if (Number.isNaN(date.getTime())){
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
const p = Object.fromEntries(formatterFor(timeZone).formatToParts(date).map(({ type, value: v }) => [type, v]));
|
|
50
|
+
return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
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/index.js
CHANGED
package/src/job-spec.schema.js
CHANGED
|
@@ -13,74 +13,84 @@
|
|
|
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
|
+
// Matches the job spec shape documented in README.md §4.3.
|
|
22
|
+
// Deliberately has no field for shell commands: the Client only ever
|
|
23
|
+
// runs the fixed entry point from the downloaded test package — the
|
|
24
|
+
// one exception is firmware.image, which a Client must opt in to.
|
|
25
|
+
jobSpecSchema = {
|
|
26
|
+
$id: 'https://thub.example.com/schemas/job-spec.json',
|
|
27
|
+
type: 'object',
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: ['target', 'tests'],
|
|
30
|
+
properties: {
|
|
31
|
+
target: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
required: ['type'],
|
|
35
|
+
properties: {
|
|
36
|
+
type: { enum: ['hw', 'sw'] },
|
|
37
|
+
labels: {
|
|
38
|
+
type: 'array',
|
|
39
|
+
items: { type: 'string', minLength: 1 },
|
|
40
|
+
default: []
|
|
41
|
+
},
|
|
42
|
+
// Constrains scheduling to resources that are members of this
|
|
43
|
+
// group (§13.1, `thub run --group <id>`) — a third targeting
|
|
44
|
+
// dimension alongside type/labels. Omitted: any matching resource
|
|
45
|
+
// in any (or no) group is eligible, same as before groups existed.
|
|
46
|
+
group: { type: 'string', minLength: 1 },
|
|
47
|
+
// Pins the job to one specific Client (`thub run --client <name|id>`):
|
|
48
|
+
// it's queued for that resource alone and waits for it even if other
|
|
49
|
+
// matching resources are idle. Accepts a resource name or id; the
|
|
50
|
+
// Coordinator resolves it to the resource id at submission time, so
|
|
51
|
+
// a later rename of the Client doesn't orphan the queued job.
|
|
52
|
+
client: { type: 'string', minLength: 1 }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
// Exactly one of `url` (a firmware file the Client downloads — HW and
|
|
56
|
+
// SW) or `image` (a Docker image an SW Client runs instead of its own
|
|
57
|
+
// sw.image, if it allows that: sw.allowJobImages). The combinations are
|
|
58
|
+
// checked in validate-job-spec.js, for readable errors.
|
|
59
|
+
firmware: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
properties: {
|
|
63
|
+
url: { type: 'string', format: 'uri' },
|
|
64
|
+
image: { type: 'string', maxLength: 255, pattern: DOCKER_IMAGE_PATTERN },
|
|
65
|
+
sha256: { type: 'string', pattern: '^[a-f0-9]{64}$' },
|
|
66
|
+
flashAddress: { type: 'string' }
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
tests: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
required: ['url'],
|
|
73
|
+
properties: {
|
|
74
|
+
url: { type: 'string', format: 'uri' },
|
|
75
|
+
suite: { type: 'string', default: 'default' },
|
|
76
|
+
args: { type: 'array', items: { type: 'string' }, default: [] }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
|
|
80
|
+
priority: { type: 'integer', minimum: 0, maximum: 100, default: 50 },
|
|
81
|
+
// Set by the Coordinator from the agent token's kind; any value an
|
|
82
|
+
// (older) Agent sends is accepted but overwritten.
|
|
83
|
+
source: { enum: ['ci', 'cli'] },
|
|
84
|
+
// Free-text job owner (`thub run --user <name>`, §7.1) — purely a
|
|
85
|
+
// label shown on the Client and dashboard to tell whose job is whose,
|
|
86
|
+
// not an identity: nothing authenticates or enforces it.
|
|
87
|
+
user: { type: 'string', minLength: 1 },
|
|
88
|
+
meta: { type: 'object' },
|
|
89
|
+
// Exercises the full pipeline (schedule, accept, state transitions,
|
|
90
|
+
// logs, artifact, result) without flashing/running anything for real —
|
|
91
|
+
// see README §7.1 "Dry-run the pipeline".
|
|
92
|
+
dryRun: { type: 'boolean', default: false }
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
module.exports = { jobSpecSchema, DOCKER_IMAGE_PATTERN };
|
package/src/validate-job-spec.js
CHANGED
|
@@ -27,12 +27,32 @@ 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));
|
|
33
|
+
return { valid: errors.length === 0, spec: clone, errors };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// firmware.url vs firmware.image — spelled out here rather than as schema
|
|
37
|
+
// oneOf/if-then, whose errors ("must match exactly one schema") say little.
|
|
38
|
+
function firmwareErrors(spec){
|
|
39
|
+
const fw = spec.firmware;
|
|
40
|
+
if (!fw || typeof fw !== 'object'){
|
|
41
|
+
return ['/firmware is required'];
|
|
42
|
+
}
|
|
43
|
+
if (fw.url && fw.image){
|
|
44
|
+
return ['/firmware give either url (a firmware file) or image (a Docker image), not both'];
|
|
45
|
+
}
|
|
46
|
+
if (!fw.url && !fw.image){
|
|
47
|
+
return ['/firmware needs url (a firmware file) or image (a Docker image, SW jobs only)'];
|
|
48
|
+
}
|
|
49
|
+
if (fw.image && spec.target?.type === 'hw'){
|
|
50
|
+
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
|
+
}
|
|
52
|
+
if (fw.image && fw.sha256){
|
|
53
|
+
return ['/firmware/sha256 applies to a firmware file (url) only — pin a Docker image by digest instead (image@sha256:...)'];
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
module.exports = { validateJobSpec };
|