@andrian.yablonskyy/thub-common 1.0.21 → 1.0.23
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/cron.js +171 -0
- package/src/index.js +1 -0
- package/src/job-spec.schema.js +24 -1
- package/src/validate-job-spec.js +30 -1
package/package.json
CHANGED
package/src/cron.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/cron.js
|
|
3
|
+
* @description Minimal 5-field cron expressions (minute hour day-of-month month day-of-week) — parsing, matching
|
|
4
|
+
* and next-occurrence — for scheduled Client host reboots
|
|
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
|
+
// Standard crontab(5) semantics: `*`, lists (1,15), ranges (1-5), steps
|
|
18
|
+
// (*/15, 0-30/10), month and weekday names (jan, mon); weekday 0 and 7 are
|
|
19
|
+
// both Sunday; if both day fields are restricted, a day matching either one
|
|
20
|
+
// counts (like cron). No seconds, no @macros except the common ones below.
|
|
21
|
+
const FIELDS = [
|
|
22
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
23
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
24
|
+
{ name: 'day of month', min: 1, max: 31 },
|
|
25
|
+
{ name: 'month', min: 1, max: 12, names: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] },
|
|
26
|
+
{ name: 'day of week', min: 0, max: 7, names: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] }
|
|
27
|
+
],
|
|
28
|
+
MACROS = {
|
|
29
|
+
'@yearly': '0 0 1 1 *',
|
|
30
|
+
'@annually': '0 0 1 1 *',
|
|
31
|
+
'@monthly': '0 0 1 * *',
|
|
32
|
+
'@weekly': '0 0 * * 0',
|
|
33
|
+
'@daily': '0 0 * * *',
|
|
34
|
+
'@midnight': '0 0 * * *',
|
|
35
|
+
'@hourly': '0 * * * *'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function parseValue(text, field){
|
|
39
|
+
const lower = text.toLowerCase(),
|
|
40
|
+
named = field.names ? field.names.indexOf(lower) : -1;
|
|
41
|
+
if (named >= 0){
|
|
42
|
+
return named + (field.name === 'month' ? 1 : 0);
|
|
43
|
+
}
|
|
44
|
+
if (!/^\d+$/.test(text)){
|
|
45
|
+
throw new Error(`${field.name}: "${text}" isn't a number${field.names ? ' or a name' : ''}`);
|
|
46
|
+
}
|
|
47
|
+
const n = Number(text);
|
|
48
|
+
if (n < field.min || n > field.max){
|
|
49
|
+
throw new Error(`${field.name}: ${n} is out of range ${field.min}-${field.max}`);
|
|
50
|
+
}
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseField(text, field){
|
|
55
|
+
const values = new Set();
|
|
56
|
+
for (const part of text.split(',')){
|
|
57
|
+
const [range, stepText] = part.split('/'),
|
|
58
|
+
step = stepText === undefined ? 1 : Number(stepText);
|
|
59
|
+
if (stepText !== undefined && (!/^\d+$/.test(stepText) || step < 1)){
|
|
60
|
+
throw new Error(`${field.name}: bad step "${stepText}"`);
|
|
61
|
+
}
|
|
62
|
+
let lo,
|
|
63
|
+
hi;
|
|
64
|
+
if (range === '*'){
|
|
65
|
+
[lo, hi] = [field.min, field.max];
|
|
66
|
+
}
|
|
67
|
+
else if (range.includes('-')){
|
|
68
|
+
const [a, b] = range.split('-');
|
|
69
|
+
[lo, hi] = [parseValue(a, field), parseValue(b, field)];
|
|
70
|
+
if (lo > hi){
|
|
71
|
+
throw new Error(`${field.name}: range ${range} goes backwards`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
lo = parseValue(range, field);
|
|
76
|
+
hi = stepText === undefined ? lo : field.max; // "5/10" = from 5, every 10
|
|
77
|
+
}
|
|
78
|
+
for (let v = lo; v <= hi; v += step){
|
|
79
|
+
values.add(v);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return values;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parses "m h dom mon dow" (or @daily etc.). Throws with a readable reason.
|
|
86
|
+
function parseCron(expression){
|
|
87
|
+
const text = String(expression ?? '').trim(),
|
|
88
|
+
expanded = MACROS[text.toLowerCase()] || text,
|
|
89
|
+
parts = expanded.split(/\s+/);
|
|
90
|
+
if (parts.length !== 5){
|
|
91
|
+
throw new Error(`expected 5 fields (minute hour day-of-month month day-of-week), got ${parts.length === 1 && !parts[0] ? 0 : parts.length}`);
|
|
92
|
+
}
|
|
93
|
+
const [minute, hour, dom, month, dow] = parts.map((p, i) => parseField(p, FIELDS[i]));
|
|
94
|
+
if (dow.has(7)){
|
|
95
|
+
dow.add(0);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
expression: text,
|
|
99
|
+
minute,
|
|
100
|
+
hour,
|
|
101
|
+
dom,
|
|
102
|
+
month,
|
|
103
|
+
dow,
|
|
104
|
+
domRestricted: parts[2] !== '*',
|
|
105
|
+
dowRestricted: parts[4] !== '*'
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isValidCron(expression){
|
|
110
|
+
try {
|
|
111
|
+
parseCron(expression);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Wall-clock fields of `date` in `timeZone` (omitted: this process's zone).
|
|
120
|
+
function wallClock(date, timeZone){
|
|
121
|
+
if (!timeZone){
|
|
122
|
+
return { minute: date.getMinutes(), hour: date.getHours(), dom: date.getDate(), month: date.getMonth() + 1, dow: date.getDay() };
|
|
123
|
+
}
|
|
124
|
+
const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
|
|
125
|
+
timeZone, hourCycle: 'h23', minute: 'numeric', hour: 'numeric', day: 'numeric', month: 'numeric', weekday: 'short'
|
|
126
|
+
}).formatToParts(date).map(({ type, value }) => [type, value]));
|
|
127
|
+
return {
|
|
128
|
+
minute: Number(p.minute),
|
|
129
|
+
hour: Number(p.hour),
|
|
130
|
+
dom: Number(p.day),
|
|
131
|
+
month: Number(p.month),
|
|
132
|
+
dow: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(p.weekday)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function dayMatches(c, t){
|
|
137
|
+
const dayOk = c.domRestricted && c.dowRestricted ? c.dom.has(t.dom) || c.dow.has(t.dow) : c.dom.has(t.dom) && c.dow.has(t.dow);
|
|
138
|
+
return dayOk && c.month.has(t.month);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Does the minute containing `date` match? `cron`: a parseCron result or text.
|
|
142
|
+
function cronMatches(cron, date, { timeZone } = {}){
|
|
143
|
+
const c = typeof cron === 'string' ? parseCron(cron) : cron,
|
|
144
|
+
t = wallClock(date, timeZone);
|
|
145
|
+
return c.minute.has(t.minute) && c.hour.has(t.hour) && dayMatches(c, t);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The next matching minute strictly after `from`, or null if none within
|
|
149
|
+
// `withinDays` (an impossible date like 31 feb never matches). Skips a whole
|
|
150
|
+
// hour whenever its day or hour can't match — hour steps rather than jumps to
|
|
151
|
+
// midnight, so a 23- or 25-hour DST day can't make it skip past a match.
|
|
152
|
+
function nextCronRun(cron, from = new Date(), { timeZone, withinDays = 366 } = {}){
|
|
153
|
+
const c = typeof cron === 'string' ? parseCron(cron) : cron,
|
|
154
|
+
end = from.getTime() + withinDays * 86400000;
|
|
155
|
+
let t = Math.floor(from.getTime() / 60000) * 60000 + 60000;
|
|
156
|
+
while (t < end){
|
|
157
|
+
const w = wallClock(new Date(t), timeZone);
|
|
158
|
+
if (!dayMatches(c, w) || !c.hour.has(w.hour)){
|
|
159
|
+
t += (60 - w.minute) * 60000; // to the next hour
|
|
160
|
+
}
|
|
161
|
+
else if (!c.minute.has(w.minute)){
|
|
162
|
+
t += 60000;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
return new Date(t);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { parseCron, isValidCron, cronMatches, nextCronRun };
|
package/src/index.js
CHANGED
package/src/job-spec.schema.js
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
const DOCKER_IMAGE_PATTERN =
|
|
19
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})?$',
|
|
20
20
|
|
|
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
|
+
|
|
21
25
|
// Matches the job spec shape documented in README.md §4.3.
|
|
22
26
|
// Deliberately has no field for shell commands: the Client only ever
|
|
23
27
|
// runs the fixed entry point from the downloaded test package — the
|
|
@@ -66,12 +70,31 @@ const DOCKER_IMAGE_PATTERN =
|
|
|
66
70
|
flashAddress: { type: 'string' }
|
|
67
71
|
}
|
|
68
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.
|
|
69
79
|
tests: {
|
|
70
80
|
type: 'object',
|
|
71
81
|
additionalProperties: false,
|
|
72
|
-
required: ['url'],
|
|
73
82
|
properties: {
|
|
74
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 },
|
|
75
98
|
suite: { type: 'string', default: 'default' },
|
|
76
99
|
args: { type: 'array', items: { type: 'string' }, default: [] }
|
|
77
100
|
}
|
package/src/validate-job-spec.js
CHANGED
|
@@ -29,10 +29,39 @@ function validateJobSpec(spec){
|
|
|
29
29
|
const clone = JSON.parse(JSON.stringify(spec ?? {})),
|
|
30
30
|
schemaValid = validateFn(clone),
|
|
31
31
|
errors = schemaValid ? [] : (validateFn.errors || []).map((e) => `${e.instancePath || '/'} ${e.message}`);
|
|
32
|
-
errors.push(...firmwareErrors(clone));
|
|
32
|
+
errors.push(...firmwareErrors(clone), ...testsErrors(clone));
|
|
33
33
|
return { valid: errors.length === 0, spec: clone, errors };
|
|
34
34
|
}
|
|
35
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
|
+
|
|
36
65
|
// firmware.url vs firmware.image — spelled out here rather than as schema
|
|
37
66
|
// oneOf/if-then, whose errors ("must match exactly one schema") say little.
|
|
38
67
|
function firmwareErrors(spec){
|