@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 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 --image "$IMAGE_URL" --tests "$TESTS_URL" --wait
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
- | `--image <url>` | Firmware/build image URL, fetched by the Client. |
54
- | `--sha256 <hex>` | Expected sha256 of `--image`; the Client verifies it before flashing/running. |
55
- | `--tests <url>` / `--suite <name>` | Test package and suite. |
56
- | `--arg <value>` | Extra argument passed through to `run-tests.sh` on the Client (repeatable). |
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
- **Associate a CI/CD job id with the internal job id:**
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 sw --image "$IMAGE_URL" --tests "$TESTS_URL" \
85
- --meta ciJobId="$GITHUB_RUN_ID" --wait
86
- thub status A-00123 --json | jq '.spec.meta.ciJobId'
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
- **Pass Git repo/branch/hash/tag to the Client** — these travel as `--meta key=value` and the Client exposes each one to `run-tests.sh` as `THUB_META_<KEY>` (`ciJobId` → `THUB_META_CI_JOB_ID`):
95
+ **SW task in a Docker image of your own:**
90
96
 
91
97
  ```bash
92
- thub run --type hw --board nucleo-f401re \
93
- --image "$IMAGE_URL" --tests "$TESTS_URL" --suite smoke \
94
- --meta repo=yourorg/firmware --meta branch=main --meta sha=a1b2c3d --wait
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
- **Dry-run the pipeline** — proves the Coordinator↔Client plumbing works without real hardware, a real emulator image, or a reachable Artifactory:
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 --image https://does-not-exist.invalid/app.bin \
101
- --tests https://does-not-exist.invalid/tests.tar.gz --suite smoke \
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 --image "$IMAGE_URL" --tests "$TESTS_URL" \
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
- --image "$IMAGE_URL" --tests "$TESTS_URL" --user "Your Name" --wait
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
- --image "${{ needs.build.outputs.image_url }}" \
132
- --tests "${{ needs.build.outputs.tests_url }}" --suite full --wait
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-agent",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
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"
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
- // `--image`: an http(s) URL is a firmware file to download; anything else
58
- // is taken as a Docker image reference, which only an SW job can run. Caught
59
- // here so `--type hw --image alpine` explains itself instead of failing
60
- // the Coordinator's schema check.
61
- function firmwareFromImage(opts){
62
- if (/^https?:\/\//i.test(opts.image)){
63
- return { url: opts.image, ...(opts.sha256 ? { sha256: opts.sha256 } : {}) };
64
- }
65
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(opts.image)){
66
- throw usageError(`--image ${opts.image}: only http(s) URLs can be downloaded as firmware`);
67
- }
68
- if (opts.type !== 'sw'){
69
- throw usageError(
70
- `--image ${opts.image} looks like a Docker image, which only an SW job can run (--type sw). ` +
71
- 'An HW job flashes a firmware file: pass its http(s) URL.'
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
- // --tests (archive URL) or --tests-git (+ at most one of branch/tag/commit).
81
- function testsFromOptions(opts){
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 (!opts.tests && !opts.testsGit){
87
- throw usageError('missing test sources: --tests <archive-url> or --tests-git <repo> [--tests-branch|--tests-tag|--tests-commit]');
80
+ if (opts.gitOptions !== undefined && !opts.gitRepo){
81
+ throw usageError('--git-options only applies to --git-repo');
88
82
  }
89
- if (opts.tests){
90
- if (refs.length){
91
- throw usageError(`--tests-${refs[0]} only applies to --tests-git`);
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
- return {
99
- git: {
100
- url: opts.testsGit,
101
- ...(opts.testsBranch ? { branch: opts.testsBranch } : {}),
102
- ...(opts.testsTag ? { tag: opts.testsTag } : {}),
103
- ...(opts.testsCommit ? { commit: opts.testsCommit } : {})
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
- '--image <url|docker-image>',
140
- 'What the DUT runs: a firmware file URL (http/https, e.g. Artifactory) the Client downloads — HW and SW jobs — ' +
141
- 'or, for --type sw only, a Docker image to run as the DUT (e.g. alpine, alpine:3.20, registry.lab:5000/emu:1), ' +
142
- 'pulled from the Client\'s registry or Docker Hub; the Client must allow it (sw.allowJobImages)'
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
- '--tests <url>',
147
- 'Test sources as an archive URL (tar, tar.gz/.tgz/.bz2/.xz, or zip) the Client downloads and unpacks. ' +
148
- 'Give this or --tests-git'
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('--tests-git <repo>', 'Test sources as a git repository (https://, ssh://, git:// or user@host:path) the Client fetches')
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
- '--run <command>',
156
- 'Shell command that starts the tests, run in the test sources on the Client (--arg values arrive as "$@"). ' +
157
- 'Default: the sources\' own run-tests.sh. The Client must allow it (allowJobCommands)'
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 passed through to run-tests.sh / --run on the Client (repeatable)', collectRepeatable, [])
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 firmware = firmwareFromImage(opts),
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
- firmware,
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 } : {}),