@andrian.yablonskyy/thub-agent 1.1.4 → 1.1.6
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 +149 -25
- package/package.json +2 -2
- package/src/cli.js +68 -5
- package/src/streaming.js +7 -1
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ thub key rotate # a new key; the old one stops at once (saved
|
|
|
32
32
|
thub run [options] Submit a test job and follow its log
|
|
33
33
|
thub status <jobId> Show status; follow log if running, verdict, test counts and artifacts if done
|
|
34
34
|
thub cancel <jobId> Cancel a job
|
|
35
|
+
thub power on|off|reset <jobId> [--delay <sec>] [--port <n>] Switch the USB power of the Client running your job (owner only)
|
|
35
36
|
thub resources List resources and their status
|
|
36
37
|
thub jobs [--mine] [--state <s>] List recent jobs (a cli token: only its own; a ci token: all, or its own with --mine)
|
|
37
38
|
thub config set <key> <value> Save coordinator URL / key / default user locally
|
|
@@ -52,12 +53,15 @@ Key options for `thub run`. **On the Client** names the environment variable the
|
|
|
52
53
|
| `--command <string>` | **Required.** The task's entry point: a shell command the Client runs (`sh -c`) in the job's work directory, after the downloads. It clones repositories and runs containers itself (see Git and Docker below), with credentials from `--env`. Exit code 0 = PASSED. | `JOB_COMMAND` | `JOB_COMMAND` |
|
|
53
54
|
| `--download-file <url>` | A file the Client downloads before running the command (repeatable, `http(s)`), into the job's `downloads/` directory. | `JOB_DOWNLOAD_FILE`, `JOB_DOWNLOAD_FILE_<n>` (URLs); `THUB_DOWNLOAD_<n>` (local paths) |
|
|
54
55
|
| `--env <vars>` | Environment variables for every command the Client runs for the job (git and `--command`): `NAME=value[,NAME=value]`, repeatable; `--env NAME` alone takes the value from your shell. Any names — none means anything to the Agent or the Client (only `THUB_*`, `JOB_*`, `GIT_TERMINAL_PROMPT`, `GIT_ALLOW_PROTOCOL` are refused). Every value is a secret: it reaches only the Client running the job; the Coordinator masks it and drops it when the job ends. | each `NAME` itself |
|
|
55
|
-
| `--suite <name>` |
|
|
56
|
+
| `--suite <name>` | Test suite name for the command. | `JOB_SUITE` |
|
|
56
57
|
| `--arg <value>` | Extra argument for the command, as `"$@"` (repeatable). | `JOB_ARG`, `JOB_ARG_<n>` (and `"$@"`) |
|
|
57
58
|
| `--timeout <dur>` | e.g. `30m`, default `30m`. | `JOB_TIMEOUT` (seconds) |
|
|
58
59
|
| `--priority <n>` | 0–100; CI defaults to 50, CLI to 60 so a developer is not starved by a busy pipeline. | `JOB_PRIORITY` |
|
|
59
60
|
| `--meta <key=value>` | Arbitrary metadata stored on the job (repeatable) — CI job ids, git coordinates, anything else worth attaching to the run. | `JOB_META_<KEY>` (also `THUB_META_<KEY>`) |
|
|
60
61
|
| `--dry-run` | Exercise the full pipeline without the Client executing anything for real. | — (the command doesn't run) |
|
|
62
|
+
| `--power-on-start on\|off\|reset` | HW only: switch the Client's USB power ports (uhubctl) before the DUT is prepared; a failure ends the job in `ERROR`. | `JOB_POWER_ON_START` |
|
|
63
|
+
| `--power-on-end on\|off\|reset` | HW only: switch them when the job ends, whatever its verdict. | `JOB_POWER_ON_END` |
|
|
64
|
+
| `--power-reset-delay <sec>` | How long a `reset` keeps the power off, 0–60 s; default `1`. | `JOB_POWER_RESET_DELAY` |
|
|
61
65
|
| `--wait` | Do not detach on job end; exit with the job's verdict code (used in CI). | — (Agent only) |
|
|
62
66
|
| `--detach` | Print the job id and exit immediately. | — (Agent only) |
|
|
63
67
|
| `--json` | Machine-readable output. | — (Agent only) |
|
|
@@ -77,6 +81,45 @@ Exit codes make the Agent usable as a CI step:
|
|
|
77
81
|
| `5` | `thub status <jobId> --json`: the job is still queued or running |
|
|
78
82
|
| `130` | Detached with Ctrl-C (job still running) |
|
|
79
83
|
|
|
84
|
+
### USB port power (HW)
|
|
85
|
+
|
|
86
|
+
On a Client with USB power ports (uhubctl, see the main README §8.7), a job can cold-boot its board and switch it off at the end. Its owner can also power-cycle it while it runs, without canceling the job:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
thub run --type hw --label board:nucleo-f401re \
|
|
90
|
+
--download-file "$IMAGE_URL" --command './ci/flash-and-test.sh' \
|
|
91
|
+
--power-on-start reset --power-reset-delay 2 --power-on-end off --wait
|
|
92
|
+
|
|
93
|
+
thub power reset M-00131 # every port, 1 s off (the default delay)
|
|
94
|
+
thub power reset M-00131 --port 2 --delay 3 # the Client's second port, 3 s off
|
|
95
|
+
thub power off M-00131
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`thub power` works only for your own job, while it's `PREPARING` or `RUNNING`; otherwise the Coordinator says why it refused. The job's log shows each action:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
[runner] USB power reset (port 2) requested by alice (thub power)
|
|
102
|
+
[runner] USB power reset: 1-1.4:3 (off 3 s)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Smart sockets, PDUs and other lab devices
|
|
106
|
+
|
|
107
|
+
Boards on a smart socket (Shelly, Tasmota, Kasa, Home Assistant) or a PDU outlet are switched by the job itself. Its `--command`, or a script from the repository it clones, calls the device with `curl`, `snmpset` or the device's own CLI, with credentials passed as `--env`. Each Client instance names its bench's device in its environment (e.g. `BENCH_POWER=shelly:10.0.20.11`, set with a systemd drop-in):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# inline: Shelly Gen2 off, 2 s, on, then the tests
|
|
111
|
+
thub run --type hw --label board:nucleo-f401re --env SHELLY_PASSWORD \
|
|
112
|
+
--command 'S="http://$BENCH_IP/rpc/Switch.Set?id=0"
|
|
113
|
+
curl -fsS --digest -u "admin:$SHELLY_PASSWORD" "$S&on=false" && sleep 2 &&
|
|
114
|
+
curl -fsS --digest -u "admin:$SHELLY_PASSWORD" "$S&on=true" && ./ci/test.sh' --wait
|
|
115
|
+
|
|
116
|
+
# from the test repository: ci/run.sh resets the bench's socket or PDU outlet, tests, and always powers it off
|
|
117
|
+
thub run --type hw --env GH_TOKEN --env POWER_PASSWORD \
|
|
118
|
+
--command 'git clone --depth 1 "https://x-access-token:$GH_TOKEN@github.com/yourorg/firmware-tests.git" src && cd src && exec ci/run.sh' --wait
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Use `exec` so a canceled job's `SIGTERM` reaches the script's trap. The full `ci/power.sh` and `ci/run.sh` examples (Shelly, Tasmota, Home Assistant, Kasa, APC PDU over SNMP) are in the main README, §8.8, and in the dashboard's Help.
|
|
122
|
+
|
|
80
123
|
## Examples
|
|
81
124
|
|
|
82
125
|
**Run a task** — download the firmware, clone the tests at a tag, flash and test (HW):
|
|
@@ -85,8 +128,8 @@ Exit codes make the Agent usable as a CI step:
|
|
|
85
128
|
export GH_TOKEN=… # read access to the tests repository
|
|
86
129
|
thub run --type hw --label board:nucleo-f401re \
|
|
87
130
|
--download-file "$IMAGE_URL" --env GH_TOKEN \
|
|
88
|
-
--command 'git clone --depth 1 --branch v1.4.0 "https://x-access-token:$GH_TOKEN@github.com/yourorg/firmware-tests.git"
|
|
89
|
-
st-flash --
|
|
131
|
+
--command 'git clone --depth 1 --branch v1.4.0 "https://x-access-token:$GH_TOKEN@github.com/yourorg/firmware-tests.git" src && cd src &&
|
|
132
|
+
st-flash --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh "$@"' \
|
|
90
133
|
--arg --junit --wait
|
|
91
134
|
```
|
|
92
135
|
|
|
@@ -147,7 +190,7 @@ The Client doesn't clone anything (and doesn't need git itself): the command doe
|
|
|
147
190
|
```bash
|
|
148
191
|
export GH_TOKEN=…
|
|
149
192
|
thub run --type sw --env GH_TOKEN,REF=main \
|
|
150
|
-
--command 'git clone --depth 1 --branch "$REF" "https://x-access-token:$GH_TOKEN@github.com/yourorg/tests.git"
|
|
193
|
+
--command 'git clone --depth 1 --branch "$REF" "https://x-access-token:$GH_TOKEN@github.com/yourorg/tests.git" src && cd src && ./ci/test.sh' --wait
|
|
151
194
|
```
|
|
152
195
|
|
|
153
196
|
For SSH, pass the private key itself as `--env` (base64) and use it via `GIT_SSH_COMMAND` from a file in the job directory, which is deleted with the job (main README, §7.2).
|
|
@@ -162,48 +205,53 @@ The Client doesn't pull images or start containers (and doesn't need Docker itse
|
|
|
162
205
|
export DOCKER_PASSWORD=…
|
|
163
206
|
thub run --type hw \
|
|
164
207
|
--env DOCKER_REGISTRY=registry.lab.local:5000,DOCKER_USER=ci --env DOCKER_PASSWORD \
|
|
165
|
-
--command '
|
|
166
|
-
echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" --username "$DOCKER_USER" --password-stdin &&
|
|
208
|
+
--command 'echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" --username "$DOCKER_USER" --password-stdin &&
|
|
167
209
|
docker pull "$DOCKER_REGISTRY/team/test-runner:1.4"' \
|
|
168
210
|
--wait
|
|
169
211
|
```
|
|
170
212
|
|
|
171
|
-
**Run the command inside a container:** `--command` starts on the Client host in the
|
|
213
|
+
**Run the command inside a container:** `--command` starts on the Client host in the job's directory (`$THUB_WORK_DIR`). Clone into `src/`, then start the container mounting only `src/` (the directory also holds `.docker/`, the job's registry login):
|
|
172
214
|
|
|
173
215
|
```bash
|
|
174
216
|
thub run --type sw \
|
|
175
217
|
--env DOCKER_REGISTRY=registry.lab.local:5000,DOCKER_USER=ci --env DOCKER_PASSWORD --env GH_TOKEN \
|
|
176
|
-
--command 'git clone --depth 1 "https://x-access-token:$GH_TOKEN@github.com/yourorg/web-ui-tests.git"
|
|
177
|
-
export DOCKER_CONFIG="$THUB_WORK_DIR/.docker" &&
|
|
218
|
+
--command 'git clone --depth 1 "https://x-access-token:$GH_TOKEN@github.com/yourorg/web-ui-tests.git" src && cd src &&
|
|
178
219
|
echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" --username "$DOCKER_USER" --password-stdin &&
|
|
179
|
-
docker run --rm -v "$THUB_WORK_DIR:/work" -w /work "$DOCKER_REGISTRY/python:3.14" ./run-tests.sh' \
|
|
220
|
+
docker run --rm --user "$(id -u):$(id -g)" -v "$THUB_WORK_DIR/src:/work" -w /work "$DOCKER_REGISTRY/python:3.14" ./run-tests.sh' \
|
|
180
221
|
--wait
|
|
181
222
|
```
|
|
182
223
|
|
|
183
|
-
**Clone
|
|
224
|
+
**Clone and test inside a container, with a deploy key and a registry login from `--env`** (the reference example for passing data and secrets to a job):
|
|
184
225
|
|
|
185
226
|
```bash
|
|
227
|
+
# In CI, from its secret store — never typed on the command line:
|
|
228
|
+
export THUB_KEY=… # a CI token (dashboard → CI tokens)
|
|
229
|
+
export DOCKER_PASSWORD=… # the registry password
|
|
230
|
+
export GIT_KEY="$(cat ~/.ssh/thub_deploy)" # a private deploy key with read access to the repository
|
|
231
|
+
|
|
186
232
|
thub run --type sw \
|
|
187
|
-
--env DOCKER_REGISTRY=registry.lab
|
|
188
|
-
--env
|
|
189
|
-
--
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
"
|
|
233
|
+
--env DOCKER_REGISTRY=registry.lab:5000,DOCKER_USERNAME=ci-reader \
|
|
234
|
+
--env DOCKER_PASSWORD --env GIT_KEY \
|
|
235
|
+
--command 'echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" --username "$DOCKER_USERNAME" --password-stdin &&
|
|
236
|
+
mkdir -p "$THUB_WORK_DIR/src" &&
|
|
237
|
+
docker run --rm -e GIT_KEY -e HOME=/tmp --user "$(id -u):$(id -g)" \
|
|
238
|
+
-v "$THUB_WORK_DIR/src:/work" -w /work --entrypoint sh alpine/git -c "
|
|
239
|
+
eval \$(ssh-agent -s) > /dev/null &&
|
|
240
|
+
printf \"%s\n\" \"\$GIT_KEY\" | ssh-add - &&
|
|
241
|
+
GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=accept-new\" git clone --depth 1 git@bitbucket.org:yourorg/web-ui-tests.git . &&
|
|
242
|
+
./run-tests.sh"' \
|
|
196
243
|
--wait
|
|
197
244
|
```
|
|
198
245
|
|
|
246
|
+
Plain values go as `--env NAME=value`, secrets as `--env NAME` (taken from your environment, so never on the command line; multi-line values arrive intact). The inner script's `\$` is expanded in the container; `ssh-agent` keeps the key in memory only; `--entrypoint sh` because `alpine/git`'s entrypoint is `git`; `--user` keeps the clone deletable by the Client. Only `src/` is mounted, so the registry login (the Client's `DOCKER_CONFIG`, `$THUB_WORK_DIR/.docker`) stays out of the container. More in the main README, §7.2.
|
|
247
|
+
|
|
199
248
|
The Client host needs Docker and the Client's user in the `docker` group for these (install Docker before the Client). Add `--dry-run` to see every command a job would run on the Client (every `--env` value shown as `***`) without running any. More in the main README, §7.2.
|
|
200
249
|
|
|
201
250
|
## Client environment variables
|
|
202
251
|
|
|
203
252
|
On the Client, the job's `--command` gets every option above as a `JOB_<NAME>` variable (the **On the Client** column). A variable whose option wasn't given is unset. A repeatable option gives `<NAME>` with all values plus `<NAME>_<n>` for each one. It also gets:
|
|
204
253
|
- its `--env` variables, under their own names;
|
|
205
|
-
- `THUB_JOB_ID`, `THUB_WORK_DIR` (where it runs), `THUB_DOWNLOADS_DIR`, `THUB_DOWNLOADS`, `THUB_DOWNLOAD_<n>` (local paths)
|
|
206
|
-
- the DUT's `THUB_DUT_UART[_<n>]`, `THUB_DUT_USB[_<n>]`, `THUB_DUT_STLINK[_<n>]` (HW).
|
|
254
|
+
- `THUB_JOB_ID`, `THUB_WORK_DIR` (where it runs), `THUB_DOWNLOADS_DIR`, `THUB_DOWNLOADS`, `THUB_DOWNLOAD_<n>` (local paths) and `THUB_META_<KEY>`. HW devices are used by their `/dev/thub/dut<N>-uart|usb|stlink` paths; no device variables are passed.
|
|
207
255
|
|
|
208
256
|
`THUB_*` and `JOB_*` names can't be set with `--env`. The full list is in the main README, §7.4.
|
|
209
257
|
|
|
@@ -232,7 +280,11 @@ while thub status "$JOB" --json > job.json; [ $? -eq 5 ]; do sleep 10; done
|
|
|
232
280
|
jq -r '"\(.state) exit=\(.exit_code) failed=\(.summary.failed // 0)"' job.json # PASSED exit=0 failed=0
|
|
233
281
|
```
|
|
234
282
|
|
|
235
|
-
## GitHub Actions
|
|
283
|
+
## CI/CD: GitHub Actions, GitLab, Bitbucket, Jenkins
|
|
284
|
+
|
|
285
|
+
Every CI system works the same way: store a CI token (dashboard → CI tokens) as the secret `THUB_KEY`, set `THUB_URL`, and run `thub run … --wait` on a runner with Node.js 24. The step's exit code is the verdict. Set `THUB_NO_SELF_UPDATE=1` on short-lived runners.
|
|
286
|
+
|
|
287
|
+
### GitHub Actions
|
|
236
288
|
|
|
237
289
|
```yaml
|
|
238
290
|
test-sw:
|
|
@@ -246,11 +298,83 @@ test-sw:
|
|
|
246
298
|
npx -y @andrian.yablonskyy/thub-agent run --type sw \
|
|
247
299
|
--download-file "${{ needs.build.outputs.image_url }}" \
|
|
248
300
|
--env GH_TOKEN="${{ secrets.TESTS_READ_TOKEN }}",REPO="$GITHUB_REPOSITORY",SHA="$GITHUB_SHA" \
|
|
249
|
-
--command 'git init -q
|
|
301
|
+
--command 'git init -q src && cd src && git fetch -q --depth 1 "https://x-access-token:$GH_TOKEN@github.com/$REPO.git" "$SHA" &&
|
|
250
302
|
git checkout -q FETCH_HEAD && ./ci/sw-tests.sh' --suite full --wait
|
|
251
303
|
```
|
|
252
304
|
|
|
253
|
-
|
|
305
|
+
In `--wait` mode, `SIGINT` and `SIGTERM` (how GitHub, GitLab and Jenkins stop a canceled step) make the Agent cancel the job (`POST /jobs/:id/cancel`) before exiting, so abandoned CI jobs don't hold hardware. In interactive mode, Ctrl-C only detaches.
|
|
306
|
+
|
|
307
|
+
### GitLab CI/CD
|
|
308
|
+
|
|
309
|
+
```yaml
|
|
310
|
+
test-hw:
|
|
311
|
+
image: node:24
|
|
312
|
+
variables: { THUB_URL: https://thub.example.com, THUB_NO_SELF_UPDATE: "1" } # THUB_KEY: a masked CI/CD variable
|
|
313
|
+
script:
|
|
314
|
+
- |
|
|
315
|
+
npx -y @andrian.yablonskyy/thub-agent run --type hw --label board:nucleo-f401re --download-file "$IMAGE_URL" \
|
|
316
|
+
--env CI_JOB_TOKEN --env CI_SERVER_HOST --env CI_PROJECT_PATH --env CI_COMMIT_SHA \
|
|
317
|
+
--command 'git init -q src && cd src &&
|
|
318
|
+
git fetch -q --depth 1 "https://gitlab-ci-token:$CI_JOB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git" "$CI_COMMIT_SHA" &&
|
|
319
|
+
git checkout -q FETCH_HEAD && ./ci/hw-tests.sh' \
|
|
320
|
+
--wait --meta pipelineUrl="$CI_PIPELINE_URL"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Bitbucket Pipelines
|
|
324
|
+
|
|
325
|
+
```yaml
|
|
326
|
+
- step:
|
|
327
|
+
name: HW tests
|
|
328
|
+
image: node:24
|
|
329
|
+
script: # repository variables: THUB_URL, THUB_KEY (secured), TESTS_TOKEN (a repository access token)
|
|
330
|
+
- >-
|
|
331
|
+
npx -y @andrian.yablonskyy/thub-agent run --type hw --download-file "$IMAGE_URL"
|
|
332
|
+
--env TESTS_TOKEN --env BITBUCKET_REPO_FULL_NAME --env BITBUCKET_COMMIT
|
|
333
|
+
--command 'git init -q src && cd src &&
|
|
334
|
+
git fetch -q --depth 1 "https://x-token-auth:$TESTS_TOKEN@bitbucket.org/$BITBUCKET_REPO_FULL_NAME.git" "$BITBUCKET_COMMIT" &&
|
|
335
|
+
git checkout -q FETCH_HEAD && ./ci/hw-tests.sh' --wait
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Jenkins
|
|
339
|
+
|
|
340
|
+
```groovy
|
|
341
|
+
stage('HW tests') {
|
|
342
|
+
agent { docker { image 'node:24' } }
|
|
343
|
+
environment {
|
|
344
|
+
THUB_URL = 'https://thub.example.com'
|
|
345
|
+
THUB_KEY = credentials('thub-ci-token') // Secret text
|
|
346
|
+
NPM_CONFIG_CACHE = "${env.WORKSPACE}/.npm"
|
|
347
|
+
}
|
|
348
|
+
steps {
|
|
349
|
+
sh '''
|
|
350
|
+
npx -y @andrian.yablonskyy/thub-agent run --type hw --download-file "$IMAGE_URL" \
|
|
351
|
+
--command './ci/hw-tests.sh' --wait --meta buildUrl="$BUILD_URL"
|
|
352
|
+
'''
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Full pipelines — build, test, and bringing the JUnit report back into GitLab, Bitbucket or Jenkins — are in the main README, §11, and in the dashboard's Help.
|
|
358
|
+
|
|
359
|
+
## Artifact storage
|
|
360
|
+
|
|
361
|
+
`--download-file` is a plain, anonymous GET. For private storage, either pass a short-lived signed URL (`aws s3 presign …`), or fetch the file in `--command` with credentials passed as `--env`. Outputs are uploaded by the command and listed in `$THUB_ARTIFACTS_FILE`:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
thub run --type sw --env ART_TOKEN \
|
|
365
|
+
--command './ci/test.sh; rc=$?
|
|
366
|
+
curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/junit.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/junit.xml"
|
|
367
|
+
exit $rc' --wait
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The main README, §7.5, and the dashboard's Help have examples for Artifactory, AWS S3, Google Drive (rclone), FTP/FTPS/SFTP, and custom HTTP authentication (bearer, API key, basic, `.netrc`, mutual TLS, OAuth2).
|
|
371
|
+
|
|
372
|
+
More authentication how-tos, in the main README and the dashboard's Help:
|
|
373
|
+
|
|
374
|
+
- **Downloads over HTTPS:** a private CA (`NODE_EXTRA_CA_CERTS` on the Client), and client certificates (`curl --cert` in `--command`; `--download-file` can't present one). See §7.5.
|
|
375
|
+
- **Artifactory:** scoped, short-lived access tokens minted per CI run, the JFrog CLI, and signed URLs. See §7.5.
|
|
376
|
+
- **git over SSH:** a deploy key plus a pinned `known_hosts`, both passed with `--env`. See §7.2.
|
|
377
|
+
- **`docker login`:** your own registry, Artifactory, ghcr, GitLab, Docker Hub, ECR, Google Artifact Registry and ACR. See §7.2.
|
|
254
378
|
|
|
255
379
|
## Development
|
|
256
380
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "TestHub Agent CLI — the single entry point CI/CD and developers use to submit and follow test jobs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"thub": "./src/cli.js"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"lint:fix": "eslint . --fix"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@andrian.yablonskyy/thub-common": "^1.1.
|
|
15
|
+
"@andrian.yablonskyy/thub-common": "^1.1.5",
|
|
16
16
|
"commander": "^13.1.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file packages/agent/src/cli.js
|
|
5
|
-
* @description thub CLI entry point: run/status/cancel/resources/jobs/config commands (README §7)
|
|
5
|
+
* @description thub CLI entry point: run/status/cancel/power/resources/jobs/config commands (README §7)
|
|
6
6
|
*
|
|
7
7
|
* @author Andrian Yablonskyy
|
|
8
8
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
const { Command, Option } = require('commander'),
|
|
19
19
|
{
|
|
20
20
|
ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime,
|
|
21
|
-
parseEnvList
|
|
21
|
+
parseEnvList, POWER_ACTIONS, DEFAULT_RESET_DELAY_SEC, MAX_RESET_DELAY_SEC, powerRequestErrors
|
|
22
22
|
} = require('@andrian.yablonskyy/thub-common'),
|
|
23
23
|
{ resolveConnection, writeConfigFile, readConfigFile, CONFIG_PATH } = require('./config'),
|
|
24
24
|
{ parseDurationSec } = require('./duration'),
|
|
@@ -81,6 +81,34 @@ function taskFromOptions(opts){
|
|
|
81
81
|
return task;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// --power-on-start / --power-on-end / --power-reset-delay -> the job spec's
|
|
85
|
+
// `power` (README §8.7), checked here so a typo fails before it's sent.
|
|
86
|
+
function powerFromOptions(opts){
|
|
87
|
+
const power = {};
|
|
88
|
+
for (const [option, key]of [['powerOnStart', 'onStart'], ['powerOnEnd', 'onEnd']]){
|
|
89
|
+
if (opts[option] !== undefined){
|
|
90
|
+
if (!POWER_ACTIONS.includes(opts[option])){
|
|
91
|
+
throw usageError(`--${option.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} ${opts[option]}: must be one of ${POWER_ACTIONS.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
power[key] = opts[option];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (opts.powerResetDelay !== undefined){
|
|
97
|
+
const errors = powerRequestErrors({ action: 'reset', delaySec: opts.powerResetDelay });
|
|
98
|
+
if (errors.length){
|
|
99
|
+
throw usageError(`--power-reset-delay: ${errors.join('; ')}`);
|
|
100
|
+
}
|
|
101
|
+
if (power.onStart !== 'reset' && power.onEnd !== 'reset'){
|
|
102
|
+
throw usageError('--power-reset-delay needs --power-on-start reset or --power-on-end reset');
|
|
103
|
+
}
|
|
104
|
+
power.resetDelaySec = opts.powerResetDelay;
|
|
105
|
+
}
|
|
106
|
+
if (Object.keys(power).length && opts.type !== 'hw'){
|
|
107
|
+
throw usageError('--power-on-start / --power-on-end are for HW jobs only (--type hw)');
|
|
108
|
+
}
|
|
109
|
+
return Object.keys(power).length ? power : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
function usageError(message){
|
|
85
113
|
return Object.assign(new Error(message), { status: EXIT_CODES.USAGE });
|
|
86
114
|
}
|
|
@@ -131,7 +159,7 @@ program
|
|
|
131
159
|
'--command <string>',
|
|
132
160
|
'The task\'s entry point: a shell command the Client runs (sh -c) in the job\'s work directory, after downloading ' +
|
|
133
161
|
'--download-file files. --arg values arrive as "$@". Anything else the job needs it does itself — e.g. ' +
|
|
134
|
-
'`git clone "https://x-access-token:$GH_TOKEN@github.com/org/tests.git"
|
|
162
|
+
'`git clone "https://x-access-token:$GH_TOKEN@github.com/org/tests.git" src && cd src && ./ci/test.sh` or ' +
|
|
135
163
|
'`docker run --rm "$IMAGE" ./run.sh` — with credentials passed by --env'
|
|
136
164
|
)
|
|
137
165
|
.option(
|
|
@@ -150,7 +178,7 @@ program
|
|
|
150
178
|
collectRepeatable,
|
|
151
179
|
[]
|
|
152
180
|
)
|
|
153
|
-
.option('--suite <name>', 'Test suite name, passed to --command as
|
|
181
|
+
.option('--suite <name>', 'Test suite name, passed to --command as JOB_SUITE', 'default')
|
|
154
182
|
.option('--arg <value>', 'Extra argument for --command, as "$@" (repeatable)', collectRepeatable, [])
|
|
155
183
|
.option('--timeout <duration>', 'e.g. 30m, 1h', '30m')
|
|
156
184
|
.option('--priority <n>', 'Priority 0-100', (v) => Number(v))
|
|
@@ -170,9 +198,13 @@ program
|
|
|
170
198
|
'without the Client flashing/running anything for real',
|
|
171
199
|
false
|
|
172
200
|
)
|
|
201
|
+
.option('--power-on-start <action>', `USB power on the Client (uhubctl) before the DUT is prepared: ${POWER_ACTIONS.join(' | ')} (HW only)`)
|
|
202
|
+
.option('--power-on-end <action>', `USB power on the Client when the job ends, whatever its verdict: ${POWER_ACTIONS.join(' | ')} (HW only)`)
|
|
203
|
+
.option('--power-reset-delay <sec>', `Seconds a reset keeps the power off (default ${DEFAULT_RESET_DELAY_SEC}, max ${MAX_RESET_DELAY_SEC})`, (v) => Number(v))
|
|
173
204
|
.action(async (opts) => {
|
|
174
205
|
try {
|
|
175
206
|
const task = taskFromOptions(opts),
|
|
207
|
+
power = powerFromOptions(opts),
|
|
176
208
|
c = client(),
|
|
177
209
|
labels = requiredLabels(opts),
|
|
178
210
|
meta = Object.fromEntries(opts.meta.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
|
|
@@ -184,7 +216,8 @@ program
|
|
|
184
216
|
timeoutSec: parseDurationSec(opts.timeout),
|
|
185
217
|
...(opts.priority !== undefined ? { priority: opts.priority } : {}),
|
|
186
218
|
...(Object.keys(meta).length ? { meta } : {}),
|
|
187
|
-
...(opts.dryRun ? { dryRun: true } : {})
|
|
219
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
220
|
+
...(power ? { power } : {})
|
|
188
221
|
},
|
|
189
222
|
|
|
190
223
|
result = await c.post('/jobs', spec);
|
|
@@ -267,6 +300,36 @@ program
|
|
|
267
300
|
}
|
|
268
301
|
});
|
|
269
302
|
|
|
303
|
+
// README §8.7: the owner of a running job switches its Client's USB power
|
|
304
|
+
// right away — no need to wait for the job to end or to cancel it.
|
|
305
|
+
program
|
|
306
|
+
.command('power')
|
|
307
|
+
.description('Switch the USB power of the Client running your job (uhubctl): on, off or reset — while it runs')
|
|
308
|
+
.argument('<action>', POWER_ACTIONS.join(' | '))
|
|
309
|
+
.argument('<jobId>')
|
|
310
|
+
.option('--delay <sec>', `reset: seconds the power stays off (default ${DEFAULT_RESET_DELAY_SEC}, max ${MAX_RESET_DELAY_SEC})`, (v) => Number(v))
|
|
311
|
+
.option('--port <n>', 'Only this port of the Client: its number in the Client\'s hw-devices.usbPower.ports (default: all of them)', (v) => Number(v))
|
|
312
|
+
.option('--json', 'Machine-readable output', false)
|
|
313
|
+
.action(async (action, jobId, opts) => {
|
|
314
|
+
try {
|
|
315
|
+
const errors = powerRequestErrors({ action, delaySec: opts.delay, port: opts.port });
|
|
316
|
+
if (errors.length){
|
|
317
|
+
throw usageError(errors.join('; ').replace('the reset delay (delaySec)', '--delay').replace(/^port/, '--port'));
|
|
318
|
+
}
|
|
319
|
+
const res = await client().post(`/jobs/${jobId}/power`, {
|
|
320
|
+
action, ...(opts.delay !== undefined ? { delaySec: opts.delay } : {}), ...(opts.port !== undefined ? { port: opts.port } : {})
|
|
321
|
+
});
|
|
322
|
+
if (opts.json){
|
|
323
|
+
return console.log(JSON.stringify(res));
|
|
324
|
+
}
|
|
325
|
+
const what = `USB power ${res.action}${res.port ? ` (port ${res.port})` : ''}${res.action === 'reset' ? `, ${res.delaySec} s off` : ''}`;
|
|
326
|
+
console.log(`${what} sent to ${res.resource.name} for job ${res.jobId} — the job's log shows when it's done.`);
|
|
327
|
+
}
|
|
328
|
+
catch (err){
|
|
329
|
+
fail(err);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
270
333
|
program
|
|
271
334
|
.command('resources')
|
|
272
335
|
.description('List resources and their status')
|
package/src/streaming.js
CHANGED
|
@@ -21,7 +21,9 @@ const { exitCodeForJobState, EXIT_CODES } = require('@andrian.yablonskyy/thub-co
|
|
|
21
21
|
* last seen `seq` on network drops (§7).
|
|
22
22
|
*
|
|
23
23
|
* In --wait mode (used by CI, §11) Ctrl-C/SIGINT is treated as a cancel
|
|
24
|
-
* request
|
|
24
|
+
* request — and so is SIGTERM, which is how GitLab CI, Jenkins and most CI
|
|
25
|
+
* runners stop a canceled step; otherwise it only detaches and the job
|
|
26
|
+
* keeps running (SIGTERM then just ends the Agent, as before).
|
|
25
27
|
*/
|
|
26
28
|
async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print = console.log } = {}){
|
|
27
29
|
let lastEventId = fromSeq,
|
|
@@ -60,6 +62,9 @@ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print =
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
process.on('SIGINT', onSigint);
|
|
65
|
+
if (waitMode){
|
|
66
|
+
process.on('SIGTERM', onSigint);
|
|
67
|
+
}
|
|
63
68
|
|
|
64
69
|
(async () => {
|
|
65
70
|
while (!finished){
|
|
@@ -107,6 +112,7 @@ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print =
|
|
|
107
112
|
}
|
|
108
113
|
finally {
|
|
109
114
|
process.off('SIGINT', onSigint);
|
|
115
|
+
process.off('SIGTERM', onSigint);
|
|
110
116
|
controller.abort();
|
|
111
117
|
}
|
|
112
118
|
}
|