@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-common",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "Shared job-spec schema, state enums and a thin API client used by the Agent and Client",
5
5
  "main": "src/index.js",
6
6
  "files": [
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
- module.exports = { formatDateTime };
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 };
@@ -13,74 +13,107 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- // Matches the job spec shape documented in README.md §4.3.
17
- // Deliberately has no field for shell commands: the Client only ever
18
- // runs the fixed entry point from the downloaded test package.
19
- const jobSpecSchema = {
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
- module.exports = { jobSpecSchema };
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 };
@@ -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
- valid = validateFn(clone);
31
- return {
32
- valid,
33
- spec: clone,
34
- errors: valid ? [] : (validateFn.errors || []).map((e) => `${e.instancePath || '/'} ${e.message}`)
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 };