@andrian.yablonskyy/thub-agent 1.0.23 → 1.0.25
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/README.md +37 -21
- package/README.pdf +0 -0
- package/package.json +1 -1
- package/src/cli.js +66 -64
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ See the [main TestHub repo](https://github.com/andrianyablonskyy/thub) for the f
|
|
|
9
9
|
```bash
|
|
10
10
|
npm i -g @andrian.yablonskyy/thub-agent
|
|
11
11
|
# or, one-off in CI:
|
|
12
|
-
npx -y @andrian.yablonskyy/thub-agent run --type sw --
|
|
12
|
+
npx -y @andrian.yablonskyy/thub-agent run --type sw --download-file "$IMAGE_URL" --git-repo "$TESTS_REPO" --command ./ci/test.sh --wait
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
## Configuration
|
|
@@ -50,10 +50,14 @@ Key options for `thub run`:
|
|
|
50
50
|
| `--group <groupId>` | Restrict scheduling to resources that are members of this group. Falls back to `THUB_GROUP` / `thub config set group <id>`. |
|
|
51
51
|
| `--client <name\|id>` | Run on this specific Client (resource name or id) only; the job waits in that Client's queue even if other matching resources are idle. |
|
|
52
52
|
| `--user <name>` | Free-text job owner — a label, not an identity. Falls back to `THUB_USER` / `thub config set user <name>`. |
|
|
53
|
-
| `--
|
|
54
|
-
| `--
|
|
55
|
-
| `--
|
|
56
|
-
| `--
|
|
53
|
+
| `--command <string>` | **Required.** The task's entry point: a shell command the Client runs (`sh -c`) in the task's work directory — the `--git-repo` checkout, else an empty directory — after preparing its inputs. Its exit code is the verdict. On HW it flashes the board itself (the Client doesn't); it gets `THUB_DUT_STLINK`/`_UART`/`_USB`/`_HOST`/`_CONTAINER`, `THUB_DOWNLOAD_<n>`, `THUB_DOWNLOADS_DIR`, `THUB_GIT_COMMIT`, `THUB_SUITE`, `THUB_META_*`. |
|
|
54
|
+
| `--download-file <url>` | A file the Client downloads before running the command (repeatable, `http(s)`), into the job's `downloads/` directory. |
|
|
55
|
+
| `--docker-image <name>` | SW only: a Docker image the Client runs as the DUT instead of its own `sw.image` (the Client must allow it: `sw.allowJobImages`). |
|
|
56
|
+
| `--git-repo <url> [<branch>\|<tag>\|<commit>]` | A git repository the Client clones (default ref: the default branch); the command runs in the checkout. |
|
|
57
|
+
| `--depth <n>` | With `--git-repo`: commits to fetch, default `1`; `0` = full history. |
|
|
58
|
+
| `--git-options <string>` | With `--git-repo`: extra git options placed between `git` and its subcommand on the Client, e.g. `'-c core.sshCommand="ssh -i ~/.ssh/lab_key -p 2222"'`. Shell-quoted (no shell run). Stored with the job, so reference key files rather than inlining secrets. |
|
|
59
|
+
| `--suite <name>` | Passed to the command as `THUB_SUITE`. |
|
|
60
|
+
| `--arg <value>` | Extra argument for the command, as `"$@"` (repeatable). |
|
|
57
61
|
| `--timeout <dur>` | e.g. `30m`, default `30m`. |
|
|
58
62
|
| `--priority <n>` | 0–100; CI defaults to 50, CLI to 60 so a developer is not starved by a busy pipeline. |
|
|
59
63
|
| `--meta <key=value>` | Arbitrary metadata stored on the job (repeatable) — CI job ids, git coordinates, anything else worth attaching to the run. |
|
|
@@ -78,34 +82,45 @@ Exit codes make the Agent usable as a CI step:
|
|
|
78
82
|
|
|
79
83
|
## Examples
|
|
80
84
|
|
|
81
|
-
**
|
|
85
|
+
**Run a task** — download the firmware, check out the tests at a tag, flash and test (HW):
|
|
82
86
|
|
|
83
87
|
```bash
|
|
84
|
-
thub run --type
|
|
85
|
-
--
|
|
86
|
-
|
|
88
|
+
thub run --type hw --board nucleo-f401re \
|
|
89
|
+
--download-file "$IMAGE_URL" \
|
|
90
|
+
--git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
|
|
91
|
+
--command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh "$@"' \
|
|
92
|
+
--arg --junit --wait
|
|
87
93
|
```
|
|
88
94
|
|
|
89
|
-
**
|
|
95
|
+
**SW task in a Docker image of your own:**
|
|
90
96
|
|
|
91
97
|
```bash
|
|
92
|
-
thub run --type
|
|
93
|
-
--
|
|
94
|
-
--
|
|
98
|
+
thub run --type sw --docker-image alpine:3.20 \
|
|
99
|
+
--git-repo git@github.com:yourorg/firmware-tests.git main --depth 20 \
|
|
100
|
+
--command 'make test' --wait
|
|
95
101
|
```
|
|
96
102
|
|
|
97
|
-
**
|
|
103
|
+
**Associate a CI/CD job id with the internal job id:**
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
thub run --type sw --download-file "$IMAGE_URL" --git-repo "$TESTS_REPO" --command ./ci/test.sh \
|
|
107
|
+
--meta ciJobId="$GITHUB_RUN_ID" --wait
|
|
108
|
+
thub status A-00123 --json | jq '.spec.meta.ciJobId'
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Pass extra metadata** — `--meta key=value` reaches the command as `THUB_META_<KEY>` (`ciJobId` → `THUB_META_CI_JOB_ID`).
|
|
112
|
+
|
|
113
|
+
**Dry-run the pipeline** — proves the Coordinator↔Client plumbing works without real hardware, a real emulator image, or reachable downloads:
|
|
98
114
|
|
|
99
115
|
```bash
|
|
100
|
-
thub run --type sw --
|
|
101
|
-
--
|
|
102
|
-
--dry-run --wait
|
|
116
|
+
thub run --type sw --download-file https://does-not-exist.invalid/app.bin \
|
|
117
|
+
--command ./ci/test.sh --dry-run --wait
|
|
103
118
|
```
|
|
104
119
|
|
|
105
120
|
**Run on a specific resource group only:**
|
|
106
121
|
|
|
107
122
|
```bash
|
|
108
|
-
thub run --type sw --
|
|
123
|
+
thub run --type sw --git-repo "$TESTS_REPO" --command ./ci/test.sh \
|
|
109
124
|
--group 548ae4ae-ac5b-401f-acaa-24bbe790e62d --wait
|
|
110
125
|
```
|
|
111
126
|
|
|
@@ -113,7 +128,7 @@ thub run --type sw --image "$IMAGE_URL" --tests "$TESTS_URL" \
|
|
|
113
128
|
|
|
114
129
|
```bash
|
|
115
130
|
thub run --type hw --board nucleo-f401re \
|
|
116
|
-
--
|
|
131
|
+
--git-repo "$TESTS_REPO" --command ./ci/test.sh --user "Your Name" --wait
|
|
117
132
|
```
|
|
118
133
|
|
|
119
134
|
## GitHub Actions
|
|
@@ -128,8 +143,9 @@ test-sw:
|
|
|
128
143
|
steps:
|
|
129
144
|
- run: |
|
|
130
145
|
npx -y @andrian.yablonskyy/thub-agent run --type sw \
|
|
131
|
-
--
|
|
132
|
-
--
|
|
146
|
+
--download-file "${{ needs.build.outputs.image_url }}" \
|
|
147
|
+
--git-repo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_SHA" \
|
|
148
|
+
--command ./ci/sw-tests.sh --suite full --wait
|
|
133
149
|
```
|
|
134
150
|
|
|
135
151
|
If the GitHub job is canceled, the runner sends `SIGINT` to the Agent. In `--wait` mode (CI), that's treated as a cancel request (`POST /jobs/:id/cancel`) before exiting, so abandoned CI jobs don't hold hardware. In interactive mode, Ctrl-C only detaches.
|
package/README.pdf
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
const { Command } = require('commander'),
|
|
19
19
|
{
|
|
20
|
-
ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime
|
|
20
|
+
ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime,
|
|
21
|
+
splitArgs
|
|
21
22
|
} = require('@andrian.yablonskyy/thub-common'),
|
|
22
23
|
{ resolveConnection, resolveGroup, resolveUser, writeConfigFile, readConfigFile } = require('./config'),
|
|
23
24
|
{ parseDurationSec } = require('./duration'),
|
|
@@ -54,55 +55,51 @@ function client(){
|
|
|
54
55
|
return new ApiClient({ baseUrl: url, token, userAgent: `thub-agent/${version}` });
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// here so
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (opts.sha256){
|
|
75
|
-
throw usageError('--sha256 applies to a firmware URL only; pin a Docker image by digest instead (image@sha256:...)');
|
|
58
|
+
// --command (mandatory), --download-file (repeatable), --docker-image (SW
|
|
59
|
+
// only) and --git-repo <url> [ref] --depth <n> -> the job spec's task fields.
|
|
60
|
+
// Checked here too, so mistakes explain themselves before anything is sent.
|
|
61
|
+
function taskFromOptions(opts){
|
|
62
|
+
const downloads = opts.downloadFile.map((url) => {
|
|
63
|
+
if (!/^https?:\/\//i.test(url)){
|
|
64
|
+
throw usageError(`--download-file ${url}: must be an http(s) URL`);
|
|
65
|
+
}
|
|
66
|
+
return { url };
|
|
67
|
+
}),
|
|
68
|
+
task = { command: opts.command, args: opts.arg, suite: opts.suite, ...(downloads.length ? { downloads } : {}) };
|
|
69
|
+
|
|
70
|
+
if (opts.dockerImage){
|
|
71
|
+
if (opts.type !== 'sw'){
|
|
72
|
+
throw usageError('--docker-image only works for SW jobs (--type sw): an HW job runs on the physical board');
|
|
73
|
+
}
|
|
74
|
+
task.image = opts.dockerImage;
|
|
76
75
|
}
|
|
77
|
-
return { image: opts.image };
|
|
78
|
-
}
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const refs = ['branch', 'tag', 'commit'].filter((k) => opts[`tests${k[0].toUpperCase()}${k.slice(1)}`]);
|
|
83
|
-
if (opts.tests && opts.testsGit){
|
|
84
|
-
throw usageError('give either --tests (an archive URL) or --tests-git (a repository), not both');
|
|
77
|
+
if (opts.depth !== undefined && !opts.gitRepo){
|
|
78
|
+
throw usageError('--depth only applies to --git-repo');
|
|
85
79
|
}
|
|
86
|
-
if (
|
|
87
|
-
throw usageError('
|
|
80
|
+
if (opts.gitOptions !== undefined && !opts.gitRepo){
|
|
81
|
+
throw usageError('--git-options only applies to --git-repo');
|
|
88
82
|
}
|
|
89
|
-
if (opts.
|
|
90
|
-
|
|
91
|
-
|
|
83
|
+
if (opts.gitOptions){
|
|
84
|
+
try {
|
|
85
|
+
splitArgs(opts.gitOptions);
|
|
86
|
+
}
|
|
87
|
+
catch (err){
|
|
88
|
+
throw usageError(`--git-options: ${err.message}`);
|
|
92
89
|
}
|
|
93
|
-
return { url: opts.tests };
|
|
94
|
-
}
|
|
95
|
-
if (refs.length > 1){
|
|
96
|
-
throw usageError(`give at most one of --tests-branch, --tests-tag, --tests-commit (got ${refs.map((r) => `--tests-${r}`).join(', ')})`);
|
|
97
90
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
91
|
+
if (opts.gitRepo){
|
|
92
|
+
const [url, ref, ...extra] = opts.gitRepo;
|
|
93
|
+
if (extra.length){
|
|
94
|
+
throw usageError(`--git-repo takes a URL and at most one branch, tag or commit (got: ${opts.gitRepo.join(' ')})`);
|
|
95
|
+
}
|
|
96
|
+
const depth = opts.depth === undefined ? 1 : Number(opts.depth);
|
|
97
|
+
if (!Number.isInteger(depth) || depth < 0){
|
|
98
|
+
throw usageError(`--depth ${opts.depth}: must be a whole number (0 = full history)`);
|
|
104
99
|
}
|
|
105
|
-
|
|
100
|
+
task.git = { url, ...(ref ? { ref } : {}), depth, ...(opts.gitOptions ? { options: opts.gitOptions } : {}) };
|
|
101
|
+
}
|
|
102
|
+
return task;
|
|
106
103
|
}
|
|
107
104
|
|
|
108
105
|
function usageError(message){
|
|
@@ -136,28 +133,35 @@ program
|
|
|
136
133
|
'— purely a label, not an identity. Overrides THUB_USER / config file.'
|
|
137
134
|
)
|
|
138
135
|
.requiredOption(
|
|
139
|
-
'--
|
|
140
|
-
'
|
|
141
|
-
'
|
|
142
|
-
|
|
136
|
+
'--command <string>',
|
|
137
|
+
'The task\'s entry point: a shell command the Client runs (sh -c) in the task\'s work directory — the --git-repo ' +
|
|
138
|
+
'checkout if given — after downloading --download-file files. --arg values arrive as "$@"'
|
|
139
|
+
)
|
|
140
|
+
.option(
|
|
141
|
+
'--download-file <url>',
|
|
142
|
+
'A file the Client downloads into the work directory before running --command (repeatable; http/https). ' +
|
|
143
|
+
'Paths are passed as THUB_DOWNLOAD_1.. / THUB_DOWNLOADS_DIR',
|
|
144
|
+
collectRepeatable,
|
|
145
|
+
[]
|
|
146
|
+
)
|
|
147
|
+
.option(
|
|
148
|
+
'--docker-image <name>',
|
|
149
|
+
'SW jobs only: a Docker image the Client runs as the DUT (e.g. alpine, alpine:3.20, registry.lab:5000/emu:1), ' +
|
|
150
|
+
'instead of its own sw.image; pulled from its registry or Docker Hub. The Client must allow it (sw.allowJobImages)'
|
|
143
151
|
)
|
|
144
|
-
.option('--sha256 <hex>', 'Expected sha256 of a firmware --image URL; the Client verifies it before flashing/running')
|
|
145
152
|
.option(
|
|
146
|
-
'--
|
|
147
|
-
'
|
|
148
|
-
'
|
|
153
|
+
'--git-repo <url...>',
|
|
154
|
+
'A git repository the Client clones before running --command, then runs it there: <url> [<branch>|<tag>|<commit>] ' +
|
|
155
|
+
'(https://, ssh://, git:// or user@host:path; default ref: the default branch)'
|
|
149
156
|
)
|
|
150
|
-
.option('--
|
|
151
|
-
.option('--tests-branch <name>', 'With --tests-git: the branch to check out (default: the repository\'s default branch)')
|
|
152
|
-
.option('--tests-tag <name>', 'With --tests-git: the tag to check out')
|
|
153
|
-
.option('--tests-commit <sha>', 'With --tests-git: the commit to check out (7-40 hex digits)')
|
|
157
|
+
.option('--depth <n>', 'With --git-repo: how many commits to fetch (default 1; 0 = full history)')
|
|
154
158
|
.option(
|
|
155
|
-
'--
|
|
156
|
-
'
|
|
157
|
-
'
|
|
159
|
+
'--git-options <string>',
|
|
160
|
+
'With --git-repo: extra git options, placed between `git` and its subcommand on the Client (shell-quoted, no shell run), ' +
|
|
161
|
+
'e.g. \'-c core.sshCommand="ssh -i ~/.ssh/lab_key -p 2222"\'. Stored with the job — reference key files, don\'t inline secrets'
|
|
158
162
|
)
|
|
159
|
-
.option('--suite <name>', 'Test suite name', 'default')
|
|
160
|
-
.option('--arg <value>', 'Extra argument
|
|
163
|
+
.option('--suite <name>', 'Test suite name, passed to --command as THUB_SUITE', 'default')
|
|
164
|
+
.option('--arg <value>', 'Extra argument for --command, as "$@" (repeatable)', collectRepeatable, [])
|
|
161
165
|
.option('--timeout <duration>', 'e.g. 30m, 1h', '30m')
|
|
162
166
|
.option('--priority <n>', 'Priority 0-100', (v) => Number(v))
|
|
163
167
|
.option('--wait', 'Do not detach on job end; exit with the verdict code (used in CI)', false)
|
|
@@ -178,8 +182,7 @@ program
|
|
|
178
182
|
)
|
|
179
183
|
.action(async (opts) => {
|
|
180
184
|
try {
|
|
181
|
-
const
|
|
182
|
-
testSources = testsFromOptions(opts),
|
|
185
|
+
const task = taskFromOptions(opts),
|
|
183
186
|
c = client(),
|
|
184
187
|
labels = [...(opts.board ? [`board:${opts.board}`] : []), ...opts.label],
|
|
185
188
|
meta = Object.fromEntries(opts.meta.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
|
|
@@ -188,8 +191,7 @@ program
|
|
|
188
191
|
|
|
189
192
|
spec = {
|
|
190
193
|
target: { type: opts.type, labels, ...(group ? { group } : {}), ...(opts.client ? { client: opts.client } : {}) },
|
|
191
|
-
|
|
192
|
-
tests: { ...testSources, ...(opts.run ? { command: opts.run } : {}), suite: opts.suite, args: opts.arg },
|
|
194
|
+
...task,
|
|
193
195
|
timeoutSec: parseDurationSec(opts.timeout),
|
|
194
196
|
...(opts.priority !== undefined ? { priority: opts.priority } : {}),
|
|
195
197
|
...(user ? { user } : {}),
|