@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.39

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.
Files changed (66) hide show
  1. package/bin/thub-admin.js +3 -5
  2. package/config.json +1 -6
  3. package/package.json +2 -2
  4. package/public/css/thub.css +129 -0
  5. package/public/js/config-import.js +37 -33
  6. package/public/js/copy-to-clipboard.js +21 -18
  7. package/public/js/help.js +93 -0
  8. package/public/js/list-search.js +51 -0
  9. package/public/js/live.js +287 -0
  10. package/public/js/log-viewer.js +289 -45
  11. package/public/js/remove-resource.js +5 -3
  12. package/public/js/resource-card.js +14 -13
  13. package/public/js/tooltips.js +3 -1
  14. package/scripts/install-default-config.js +1 -1
  15. package/scripts/install-target.js +2 -2
  16. package/src/api/admin.js +1 -1
  17. package/src/api/agent.js +4 -10
  18. package/src/api/resource.js +6 -11
  19. package/src/api/sse.js +13 -10
  20. package/src/config.js +16 -9
  21. package/src/db/migrations/022_add_sessions.sql +10 -0
  22. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  23. package/src/dev/virtual.js +1 -1
  24. package/src/server.js +55 -21
  25. package/src/services/cleanup.js +2 -2
  26. package/src/services/jobs.js +33 -19
  27. package/src/services/list-prefs.js +4 -0
  28. package/src/services/live.js +124 -0
  29. package/src/services/logs.js +12 -14
  30. package/src/services/retention.js +5 -14
  31. package/src/services/search.js +59 -0
  32. package/src/services/session-store.js +127 -0
  33. package/src/web/routes.js +205 -34
  34. package/test/cleanup.test.js +1 -5
  35. package/test/list-prefs.test.js +6 -0
  36. package/test/live.test.js +153 -0
  37. package/test/logs.test.js +150 -0
  38. package/test/scheduler.test.js +10 -12
  39. package/test/search.test.js +97 -0
  40. package/test/session.test.js +132 -0
  41. package/test/views.test.js +30 -0
  42. package/views/admin/agents.pug +28 -10
  43. package/views/groups/list.pug +14 -1
  44. package/views/help/_agent-cli.pug +103 -0
  45. package/views/help/_agent-setup.pug +72 -0
  46. package/views/help/_ci.pug +44 -0
  47. package/views/help/_client-machines.pug +87 -0
  48. package/views/help/_client-setup.pug +122 -0
  49. package/views/help/_coordinator.pug +137 -0
  50. package/views/help/_docker.pug +124 -0
  51. package/views/help/_env.pug +127 -0
  52. package/views/help/_git.pug +130 -0
  53. package/views/help/_mixins.pug +24 -0
  54. package/views/help/_overview.pug +118 -0
  55. package/views/help/_quick-start.pug +37 -0
  56. package/views/help/_troubleshooting.pug +44 -0
  57. package/views/help/index.pug +67 -0
  58. package/views/index.pug +3 -2
  59. package/views/jobs/list.pug +8 -5
  60. package/views/jobs/show.pug +0 -10
  61. package/views/layout.pug +24 -1
  62. package/views/mixins/list-controls.pug +36 -0
  63. package/views/mixins/log-viewer.pug +39 -3
  64. package/views/mixins/resource.pug +1 -1
  65. package/views/resources/list.pug +7 -4
  66. package/src/services/artifacts.js +0 -149
@@ -0,0 +1,124 @@
1
+ +section('docker', 'Docker registry integration', 'box-seam')
2
+ p.
3
+ A job can use Docker in two different ways. Keep them apart, because they log in and run differently:
4
+ .row.g-3.mb-3
5
+ .col-md-6
6
+ .card.h-100
7
+ .card-body.small
8
+ h3.h6 #[code --docker-image]: the DUT
9
+ p.mb-0.
10
+ SW jobs only. The Client pulls the image and runs it as the job's #[strong DUT container]
11
+ (an emulator the tests talk to), sandboxed, next to your command. The pull uses the
12
+ #[strong Client host's] Docker login, made once by the service user.
13
+ .col-md-6
14
+ .card.h-100
15
+ .card-body.small
16
+ h3.h6 #[code docker …] inside #[code --command]
17
+ p.mb-0.
18
+ Any job type, on a host with Docker. Your command logs in, pulls and runs containers
19
+ itself, e.g. to run the tests #[em inside] an image. Credentials come from the job
20
+ (#[code --env]) and are scoped to the job.
21
+
22
+ h3.h6 Log in to a registry on the Client host (for --docker-image)
23
+ p.small.
24
+ #[code --docker-image] is pulled with #[code docker pull] as the Client's service user, from the registry the reference names
25
+ (Docker Hub for a short name such as #[code python:3.14]). Log in once per host, as that user:
26
+ +code('Client host').
27
+ # the user that runs thub-client (the one who ran sudo npm i -g), e.g. thub:
28
+ sudo -u thub docker login registry.lab.local:5000
29
+ sudo -u thub docker login ghcr.io -u <github-user> # password: a PAT with read:packages
30
+ sudo -u thub docker pull registry.lab.local:5000/dut-emulator:2026.08 # check it works
31
+ p.small.
32
+ A plain-HTTP registry must be listed in the Docker daemon's #[code insecure-registries]:
33
+ +code('/etc/docker/daemon.json').
34
+ { "insecure-registries": ["registry.lab.local:5000"] }
35
+ p.small.
36
+ Then run #[code sudo systemctl restart docker]. An image that can't be pulled ends the job as #[code ERROR]
37
+ during #[em prepare], naming the registry and Docker's reason. An image already on the host isn't pulled again.
38
+
39
+ h3.h6 How the Client starts the DUT container
40
+ p.small.
41
+ You don't pass #[code docker run] parameters for the DUT. The Client always starts it like this (image default command,
42
+ sandboxed), and removes it and its network when the job ends:
43
+ +code('What the Client runs (equivalent)').
44
+ docker pull registry.lab.local:5000/dut-emulator:2026.08
45
+ docker network create --driver bridge thub-job-<jobId>
46
+ docker run -d --name thub-<jobId> --network thub-job-<jobId> \
47
+ --memory 2g --cpus 2 --read-only \
48
+ -v <workDir>/<jobId>/downloads:/downloads:ro \
49
+ -p 127.0.0.1::5555 registry.lab.local:5000/dut-emulator:2026.08
50
+ ul.small
51
+ li The image must #[strong start the DUT by itself] (its #[code CMD]/#[code ENTRYPOINT]) and should listen on port #[strong 5555].
52
+ li The job's #[code --download-file] files are mounted read-only at #[code /downloads] (e.g. the firmware to emulate).
53
+ li The command reaches it at #[code $THUB_DUT_HOST] (#[code 127.0.0.1:<port>]) and by name as #[code $THUB_DUT_CONTAINER].
54
+ li Its output is shipped as the #[code emulator] log stream. Its root filesystem is read-only: don't install or clone into it.
55
+ li #[code thub run --dry-run] prints the exact docker commands a job would run, without running anything.
56
+
57
+ h3.h6 Execute commands in the DUT container
58
+ +code('SW job: talk to the DUT').
59
+ thub run --type sw \
60
+ --docker-image registry.lab.local:5000/dut-emulator:2026.08 \
61
+ --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.elf \
62
+ --command 'docker exec "$THUB_DUT_CONTAINER" /opt/emu/status &&
63
+ docker logs "$THUB_DUT_CONTAINER" | tail -n 20 &&
64
+ ./ci/test.sh --dut "$THUB_DUT_HOST"' \
65
+ --wait
66
+
67
+ h3.h6 Log in and pull inside a job (credentials scoped to the job)
68
+ p.small.
69
+ Pass the credentials as #[code --env] (masked everywhere, dropped when the job ends). Set #[code DOCKER_CONFIG] to the
70
+ job's work directory so the login is deleted with the workspace instead of staying in the service user's
71
+ #[code ~/.docker] for every later job:
72
+ +code('Any job type').
73
+ export DOCKER_PASSWORD=… # from your CI secret store, never on the command line
74
+ thub run --type hw \
75
+ --env DOCKER_REGISTRY=registry.lab.local:5000,DOCKER_USER=ci --env DOCKER_PASSWORD \
76
+ --command 'export DOCKER_CONFIG="$THUB_WORK_DIR/.docker" &&
77
+ echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USER" --password-stdin &&
78
+ docker pull "$DOCKER_REGISTRY/team/test-runner:1.4"' \
79
+ --wait
80
+
81
+ h3.h6 Run the tests inside a container (docker run parameters)
82
+ p.small.
83
+ The command always starts on the Client host, in #[code $THUB_WORK_DIR] (the #[code --git-repo] checkout). Mount it,
84
+ hand over the variables the tests need with #[code -e NAME], and use #[code --rm]:
85
+ +code('Tests in an image').
86
+ thub run --type sw \
87
+ --env DOCKER_REGISTRY=registry.lab.local:5000,DOCKER_USER=ci --env DOCKER_PASSWORD \
88
+ --env TARGET=staging \
89
+ --git-repo git@github.com:yourorg/web-ui-tests.git main \
90
+ --command 'export DOCKER_CONFIG="$THUB_WORK_DIR/.docker" &&
91
+ echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USER" --password-stdin &&
92
+ docker run --rm \
93
+ -v "$THUB_WORK_DIR:/work" -w /work \
94
+ -e TARGET -e THUB_SUITE -e THUB_JOB_ID \
95
+ --network host \
96
+ "$DOCKER_REGISTRY/python:3.14" ./run-tests.sh --junit results/junit.xml' \
97
+ --wait
98
+ table.table.table-sm.small
99
+ thead
100
+ tr
101
+ th docker run option
102
+ th Why
103
+ tbody
104
+ tr
105
+ td: code --rm
106
+ td Removes the container when the tests end.
107
+ tr
108
+ td: code -v "$THUB_WORK_DIR:/work" -w /work
109
+ td The checkout and downloads, and the #[code results/] and #[code artifacts/] folders, where the Client reads JUnit XML for the test counts.
110
+ tr
111
+ td: code -e NAME
112
+ td Passes a job variable (#[code --env], #[code THUB_*], #[code JOB_*]) into the container.
113
+ tr
114
+ td: code --network host
115
+ td Lets the container reach the DUT at #[code $THUB_DUT_HOST] (#[code 127.0.0.1:<port>]).
116
+ tr
117
+ td: code --device /dev/thub/dut1-uart
118
+ td HW: gives the container the DUT's UART (#[code "$THUB_DUT_UART"]). Use #[code --privileged] only if you must.
119
+ tr
120
+ td: code --user "$(id -u):$(id -g)"
121
+ td Files written to #[code /work] stay owned by the Client user, so the workspace can be cleaned up.
122
+ +note('info').
123
+ Docker access is effectively root access on that host. The Client host must have Docker and the service user must be in the
124
+ #[code docker] group. The resource card's #[strong Capabilities] show whether a Client can use Docker, and why not.
@@ -0,0 +1,127 @@
1
+ +section('env', 'Environment variables on the Client', 'braces')
2
+ p.
3
+ #[code --command] runs on the Client with the service's own environment (#[code PATH], #[code HOME], …) plus three sets of
4
+ variables. Every value is a string. A variable whose option wasn't given is #[strong unset], so use
5
+ #[code ${VAR:-default}] in shell. A repeatable option gives one combined variable plus #[code NAME_1], #[code NAME_2], …
6
+ (1-based, in the order given).
7
+
8
+ h3.h6 Set by the Client: #[code THUB_*]
9
+ -
10
+ const thubVars = [
11
+ ['THUB_JOB_ID', 'The job id, e.g. M-00125'],
12
+ ['THUB_WORK_DIR', 'The directory the command runs in: the --git-repo checkout, else empty. JUnit XML in results/ or artifacts/ gives the test counts; deleted when the job ends'],
13
+ ['THUB_SUITE', '--suite (default: default)'],
14
+ ['THUB_GIT_COMMIT', 'The exact commit checked out for --git-repo'],
15
+ ['THUB_DOWNLOADS_DIR', 'Where the --download-file files are'],
16
+ ['THUB_DOWNLOADS / THUB_DOWNLOAD_<n>', 'Local paths of the downloads: all, one per line / each one'],
17
+ ['THUB_META_<KEY>', '--meta values; camelCase keys become SNAKE_CASE (ciJobId → THUB_META_CI_JOB_ID)'],
18
+ ['THUB_DUT_STLINK / _<n>', 'HW: ST-Link serials (device path if the serial couldn\'t be read); unsuffixed = the first'],
19
+ ['THUB_DUT_UART / _<n>', 'HW: UART device paths (/dev/thub/dut<N>-uart); unsuffixed = the first'],
20
+ ['THUB_DUT_USB / _<n>', 'HW: DUT USB device paths'],
21
+ ['THUB_DUT_HOST', 'SW with --docker-image: 127.0.0.1:<port> of the DUT container\'s port 5555'],
22
+ ['THUB_DUT_CONTAINER', 'SW with --docker-image: the DUT container\'s name (docker exec / docker logs)']
23
+ ]
24
+ .table-responsive
25
+ table.table.table-sm.small
26
+ tbody
27
+ each v in thubVars
28
+ tr
29
+ td(style="width: 34%"): code= v[0]
30
+ td= v[1]
31
+
32
+ h3.h6 Job parameters: #[code JOB_*]
33
+ p.small Each #[code thub run] option that reaches the Client, as given:
34
+ -
35
+ const jobVars = [
36
+ ['JOB_TYPE', '--type', 'hw or sw'],
37
+ ['JOB_BOARD', '--board', 'the board name'],
38
+ ['JOB_LABEL / JOB_LABEL_<n>', '--board, --label', 'all labels, comma-separated / each'],
39
+ ['JOB_GROUP', '--group', 'the group id'],
40
+ ['JOB_CLIENT', '--client', 'this Client\'s name'],
41
+ ['JOB_USER', '--user', 'as given'],
42
+ ['JOB_COMMAND', '--command', 'the command itself'],
43
+ ['JOB_DOWNLOAD_FILE / _<n>', '--download-file', 'the URLs (the local files are THUB_DOWNLOAD_<n>)'],
44
+ ['JOB_DOCKER_IMAGE', '--docker-image', 'the image reference'],
45
+ ['JOB_GIT_REPO_URL', '--git-repo <url>', 'the repository URL'],
46
+ ['JOB_GIT_BRANCH', '--git-repo <url> <ref>', 'branch, tag or commit as given (unset = default branch)'],
47
+ ['JOB_GIT_DEPTH', '--depth', 'commits fetched (1 by default, 0 = full)'],
48
+ ['JOB_GIT_OPTIONS', '--git-options', 'as given'],
49
+ ['JOB_SUITE', '--suite', 'as given, default when not'],
50
+ ['JOB_ARG / JOB_ARG_<n>', '--arg', 'all, space-separated / each (also "$@")'],
51
+ ['JOB_TIMEOUT', '--timeout', 'seconds'],
52
+ ['JOB_PRIORITY', '--priority', '0–100'],
53
+ ['JOB_META_<KEY>', '--meta key=value', 'the value'],
54
+ ['<NAME>', '--env NAME=value', 'your own variables, under the names you chose']
55
+ ]
56
+ .table-responsive
57
+ table.table.table-sm.small
58
+ thead
59
+ tr
60
+ th Variable
61
+ th From
62
+ th Value
63
+ tbody
64
+ each v in jobVars
65
+ tr
66
+ td: code= v[0]
67
+ td: code.text-nowrap= v[1]
68
+ td= v[2]
69
+ p.small.
70
+ #[code --url], #[code --token], #[code --wait], #[code --detach], #[code --json] and #[code --dry-run] only affect the Agent and
71
+ have no variable. The Client's own git commands also get #[code GIT_TERMINAL_PROMPT=0] and #[code GIT_ALLOW_PROTOCOL=http:https:ssh:git].
72
+
73
+ h3.h6 Your own variables: #[code --env]
74
+ ul.small
75
+ li #[code --env NAME=value[,NAME=value]], repeatable. A comma starts a new variable only when #[code NAME=] follows it, so values may contain commas.
76
+ li #[code --env NAME] alone takes the value from the Agent's environment. That keeps secrets off the command line, out of shell history and out of CI logs.
77
+ li Any names except #[code THUB_*], #[code JOB_*], #[code GIT_TERMINAL_PROMPT] and #[code GIT_ALLOW_PROTOCOL].
78
+ li They apply to every command the Client runs for the job: the #[code --git-repo] git commands and #[code --command].
79
+ li
80
+ | Every value is treated as a secret. It's sent only to the Client running the job, shown as #[code ***] by the API,
81
+ | #[code thub status --json] and #[code --dry-run], and wiped from the Coordinator's database when the job ends.
82
+ | The dashboard lists only the names.
83
+ +note('warning').
84
+ The command's own output #[strong is] the job log, which is stored and visible on the dashboard. Never #[code echo] a
85
+ secret, and avoid #[code set -x] in scripts that use one.
86
+
87
+ h3.h6 Using them in your scripts
88
+ +code('ci/test.sh (shell)').
89
+ #!/bin/sh
90
+ set -eu
91
+ echo "Job $THUB_JOB_ID on $(hostname), suite ${THUB_SUITE}, commit ${THUB_GIT_COMMIT:-n/a}"
92
+
93
+ # optional parameters with defaults
94
+ TARGET="${TARGET:-staging}"
95
+ BRANCH="${JOB_GIT_BRANCH:-main}"
96
+
97
+ # every downloaded file
98
+ printf '%s\n' "$THUB_DOWNLOADS" | while read -r f; do echo "downloaded: $f"; done
99
+
100
+ # HW: flash through the first ST-Link, talk to the second UART
101
+ st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000
102
+ python3 -m pytest tests/ --uart "${THUB_DUT_UART_2:-$THUB_DUT_UART}" --junitxml=results/junit.xml
103
+
104
+ # CI metadata passed with --meta ciJobId=… / --meta sha=…
105
+ echo "CI run ${THUB_META_CI_JOB_ID:-local}, sha ${THUB_META_SHA:-unknown}"
106
+ +code('conftest.py (Python)').
107
+ import os
108
+
109
+ DUT_HOST = os.environ.get("THUB_DUT_HOST", "127.0.0.1:5555") # SW DUT container
110
+ UARTS = [v for k, v in sorted(os.environ.items()) if k.startswith("THUB_DUT_UART_")]
111
+ FIRMWARE = os.environ.get("THUB_DOWNLOAD_1")
112
+ RESULTS = os.path.join(os.environ.get("THUB_WORK_DIR", "."), "results")
113
+ API_TOKEN = os.environ["API_TOKEN"] # from --env API_TOKEN
114
+ +code('Into a container: -e NAME copies a variable from the job').
115
+ docker run --rm -v "$THUB_WORK_DIR:/work" -w /work \
116
+ -e THUB_JOB_ID -e THUB_SUITE -e TARGET -e API_TOKEN \
117
+ python:3.14 ./run-tests.sh
118
+ +code('Submitting the job that feeds these').
119
+ export API_TOKEN=…
120
+ thub run --type hw --board nucleo-f401re \
121
+ --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
122
+ --git-repo git@github.com:yourorg/firmware-tests.git main \
123
+ --env TARGET=production --env API_TOKEN \
124
+ --meta ciJobId="$GITHUB_RUN_ID" --meta sha="$GITHUB_SHA" \
125
+ --suite smoke --command ./ci/test.sh --wait
126
+ p.small.text-body-secondary.mb-0.
127
+ Tip: #[code thub run … --dry-run] lists every variable the command would get on the chosen Client, with #[code --env] values as #[code ***].
@@ -0,0 +1,130 @@
1
+ +section('git', 'Git repository integration', 'git')
2
+ p.
3
+ #[code --git-repo] makes the Client clone a repository before the job runs. #[code --command] then runs inside the checkout
4
+ (#[code $THUB_WORK_DIR]). Typically the repository holds the test scripts. The firmware comes as a #[code --download-file]
5
+ or is built by the command.
6
+
7
+ h3.h6 1. Create a test repository
8
+ +code('Your machine').
9
+ mkdir firmware-tests && cd firmware-tests && git init -b main
10
+ mkdir -p ci
11
+ cat > ci/test.sh &lt;&lt;'EOF'
12
+ #!/bin/sh
13
+ set -eu
14
+ echo "job $THUB_JOB_ID, suite $THUB_SUITE, commit $THUB_GIT_COMMIT"
15
+ mkdir -p results
16
+ pytest --junitxml=results/junit.xml tests/ # results/*.xml is uploaded and summarized
17
+ EOF
18
+ chmod +x ci/test.sh
19
+ git add . && git commit -m "TestHub entry point"
20
+ git remote add origin git@github.com:yourorg/firmware-tests.git
21
+ git push -u origin main
22
+ p.small.
23
+ The exit code of #[code --command] is the verdict. JUnit files under #[code results/] or #[code artifacts/] are summed into
24
+ the job's test counts. Nothing is uploaded: the workspace is deleted when the job ends, so publish anything you need to keep
25
+ from the command itself (e.g. #[code curl -T report.html "$ARTIFACTORY/…"] with a token passed as #[code --env]).
26
+
27
+ h3.h6 2. Give Clients read access: SSH deploy key (recommended)
28
+ p.small.
29
+ git never prompts on a Client (#[code GIT_TERMINAL_PROMPT=0]), so credentials must already be on the Client host,
30
+ owned by the #[strong Client's service user]. Create a key on each Client host (or one shared key per lab):
31
+ +code('Client host, as the Client\'s user').
32
+ ssh-keygen -t ed25519 -N "" -C "thub-client@$(hostname)" -f ~/.ssh/thub_deploy
33
+ cat ~/.ssh/thub_deploy.pub # → add as a read-only deploy key (below)
34
+ # trust the git server's host key now: the service can't answer prompts
35
+ # (and can't write ~/.ssh under systemd)
36
+ ssh-keyscan github.com bitbucket.org gitlab.com >> ~/.ssh/known_hosts
37
+ # use the key for that server
38
+ cat >> ~/.ssh/config &lt;&lt;'EOF'
39
+ Host github.com
40
+ IdentityFile ~/.ssh/thub_deploy
41
+ IdentitiesOnly yes
42
+ EOF
43
+ chmod 600 ~/.ssh/config
44
+ ssh -T git@github.com # "…successfully authenticated…"
45
+ table.table.table-sm.small
46
+ thead
47
+ tr
48
+ th Git server
49
+ th Where to add the public key
50
+ tbody
51
+ tr
52
+ td GitHub
53
+ td Repository → Settings → Deploy keys → Add deploy key (leave “Allow write access” off). For many repositories, use a machine user's SSH key instead.
54
+ tr
55
+ td GitLab
56
+ td Project → Settings → Repository → Deploy keys.
57
+ tr
58
+ td Bitbucket
59
+ td Repository settings → Security → Access keys.
60
+ tr
61
+ td Gitea / self-hosted
62
+ td Repository → Settings → Deploy Keys, or the machine user's SSH keys.
63
+ p.small.
64
+ Without #[code ~/.ssh/config], pick the key per job with #[code --git-options] (stored with the job, so reference a key
65
+ #[em file], never inline a secret):
66
+ +code('Per-job SSH key / port').
67
+ thub run --type sw \
68
+ --git-repo ssh://git@git.lab.local:2222/qa/tests.git main \
69
+ --git-options '-c core.sshCommand="ssh -i ~/.ssh/thub_deploy -o IdentitiesOnly=yes"' \
70
+ --command ./ci/test.sh --wait
71
+
72
+ h3.h6 HTTPS with a token
73
+ p.small.
74
+ For #[code https://] repositories, either store a credential helper for the Client's user on the host
75
+ (#[code git config --global credential.helper store]), or pass the token #[strong per job] as a secret. #[code --env]
76
+ variables reach the Client's own git commands, and git reads config from #[code GIT_CONFIG_COUNT]/#[code _KEY_n]/#[code _VALUE_n]:
77
+ +code('Token per job, never stored').
78
+ # GitHub: user x-access-token; GitLab: oauth2; Bitbucket: x-token-auth
79
+ export GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
80
+ thub run --type sw \
81
+ --git-repo https://github.com/yourorg/private-tests.git main \
82
+ --env GIT_CONFIG_COUNT=1,GIT_CONFIG_KEY_0=http.extraHeader --env GIT_CONFIG_VALUE_0 \
83
+ --command ./ci/test.sh --wait
84
+ p.small.
85
+ Don't put a token in the URL, #[code --git-options], #[code --command] or #[code --meta]: those are stored with the job
86
+ and visible to anyone who can read it. Only #[code --env] values are masked.
87
+
88
+ h3.h6 3. Clone options: ref, branch, tag, commit, depth
89
+ +code('--git-repo <url> [<branch>|<tag>|<commit>] [--depth <n>]').
90
+ --git-repo git@github.com:yourorg/tests.git # default branch, depth 1
91
+ --git-repo git@github.com:yourorg/tests.git develop # a branch
92
+ --git-repo git@github.com:yourorg/tests.git v1.4.0 # a tag
93
+ --git-repo git@github.com:yourorg/tests.git a1b2c3d # a commit (may be abbreviated)
94
+ --git-repo git@github.com:yourorg/tests.git main --depth 50 # last 50 commits
95
+ --git-repo git@github.com:yourorg/tests.git main --depth 0 # full history (git describe, changelogs)
96
+ table.table.table-sm.small
97
+ tbody
98
+ tr
99
+ th Transports
100
+ td #[code https://], #[code http://], #[code ssh://], #[code git://], #[code user@host:path]. #[code file://] and #[code ext::] are refused.
101
+ tr
102
+ th Default ref
103
+ td The repository's default branch.
104
+ tr
105
+ th Depth
106
+ td Default #[code 1]. #[code 0] = full history. If the server can't do shallow fetches (or the commit is abbreviated), the Client falls back to a full fetch.
107
+ tr
108
+ th Exact commit
109
+ td Logged, and passed to the command as #[code $THUB_GIT_COMMIT]. The requested ref is #[code $JOB_GIT_BRANCH].
110
+ tr
111
+ th Extra git options
112
+ td #[code --git-options] is inserted between #[code git] and its subcommand on every git call, e.g. #[code '-c http.sslVerify=false'] or #[code '-c core.sshCommand="…"'].
113
+ +code('What the Client runs (see it with --dry-run)').
114
+ git -C &lt;work> init -q
115
+ git -C &lt;work> remote add origin &lt;url>
116
+ git -C &lt;work> fetch -q --depth 1 origin &lt;ref>
117
+ git -C &lt;work> checkout -q --detach FETCH_HEAD
118
+ git -C &lt;work> rev-parse HEAD
119
+
120
+ h3.h6 Submodules, LFS and cloning inside a container
121
+ p.small.
122
+ The Client checks out only the repository itself. Do anything more in the command, which runs in the checkout with the same #[code --env] variables:
123
+ +code('Submodules / LFS').
124
+ thub run --type sw --git-repo git@github.com:yourorg/tests.git main \
125
+ --command 'git submodule update --init --recursive --depth 1 && git lfs pull && ./ci/test.sh' --wait
126
+ +code('Clone yourself, e.g. inside a container (JOB_GIT_* carry the --git-repo values)').
127
+ thub run --type sw --git-repo git@github.com:yourorg/tests.git main --depth 1 \
128
+ --command 'docker run --rm -v "$HOME/.ssh:/root/.ssh:ro" -e JOB_GIT_REPO_URL -e JOB_GIT_BRANCH -e JOB_GIT_DEPTH \
129
+ --entrypoint sh alpine/git -c "git clone --depth \$JOB_GIT_DEPTH --branch \$JOB_GIT_BRANCH \$JOB_GIT_REPO_URL /src && ls /src"' \
130
+ --wait
@@ -0,0 +1,24 @@
1
+ //- Help page building blocks. Code blocks are plain text: escape `<` as
2
+ //- `&lt;` inside them (`>` and `&&` are fine). public/js/help.js adds a copy
3
+ //- button to each, which copies the decoded text.
4
+ mixin code(caption)
5
+ figure.thub-code.mb-3
6
+ if caption
7
+ figcaption.thub-code-caption= caption
8
+ pre
9
+ code
10
+ block
11
+
12
+ //- One section of the help page: an anchor for the table of contents and a
13
+ //- heading. `data-help-section` is what the filter box searches.
14
+ mixin section(id, heading, icon)
15
+ section.thub-help-section.mb-5(id=id data-help-section)
16
+ h2.h4.border-bottom.pb-2.mb-3
17
+ if icon
18
+ i.bi.me-2.text-body-secondary(class=`bi-${icon}`)
19
+ = heading
20
+ block
21
+
22
+ mixin note(tone)
23
+ .alert.small.py-2(class=`alert-${tone || 'info'}`)
24
+ block
@@ -0,0 +1,118 @@
1
+ +section('overview', 'Product overview', 'info-circle')
2
+ p.
3
+ #[strong TestHub] is a small, self-hosted job network. It lets CI/CD pipelines and individual
4
+ developers run firmware and software tests on #[strong real hardware] or #[strong emulators]
5
+ that sit in a private lab behind a firewall. Nothing in the lab has to be reachable from outside.
6
+
7
+ .row.g-3.mb-3
8
+ .col-md-4
9
+ .card.h-100
10
+ .card-body
11
+ h3.h6.card-title
12
+ i.bi.bi-hdd-network.me-1
13
+ | Coordinator
14
+ p.card-text.small.mb-0.
15
+ This service. It holds the job queue and the resource registry, and runs the scheduler and
16
+ the heartbeat monitor. It stores job state and logs in SQLite,
17
+ and serves this dashboard. It's the only thing that needs a public HTTPS port.
18
+ .col-md-4
19
+ .card.h-100
20
+ .card-body
21
+ h3.h6.card-title
22
+ i.bi.bi-terminal.me-1
23
+ | Agent (#[code thub])
24
+ p.card-text.small.mb-0.
25
+ A CLI, and the only way to create jobs. The same #[code thub run] command works in a
26
+ GitHub Actions step and on a developer's laptop, so a CI failure can be reproduced exactly.
27
+ .col-md-4
28
+ .card.h-100
29
+ .card-body
30
+ h3.h6.card-title
31
+ i.bi.bi-motherboard.me-1
32
+ | Client (#[code thub-client])
33
+ p.card-text.small.mb-0.
34
+ A daemon (a systemd service) on a lab machine. Each instance owns one DUT slot. It connects
35
+ #[em outbound] to the Coordinator, picks up jobs, runs them and streams logs back.
36
+ Type #[strong HW] drives a physical board; type #[strong SW] runs a DUT emulator in Docker.
37
+
38
+ h3.h6 How a job flows
39
+ ol.small
40
+ li #[code thub run …] submits a job spec (type, labels, command, inputs) with an agent token.
41
+ li The Coordinator queues it (#[code QUEUED]) and the scheduler assigns it to an idle Client whose type, labels and group match.
42
+ li The Client accepts it, clones #[code --git-repo], downloads #[code --download-file] files and prepares the DUT (#[code PREPARING]).
43
+ li It runs #[code --command] with #[code sh -c], and output streams live to the Agent and the dashboard (#[code RUNNING]).
44
+ li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. The Client reports the JUnit test counts; the job's files stay on the Client and are deleted with its workspace.
45
+ li The Agent exits with the verdict code, so a CI step passes or fails with it.
46
+
47
+ h3.h6 Key design rules
48
+ ul.small
49
+ li #[strong Clients only connect outbound] over HTTPS: registration, heartbeat, long-poll, logs, results. The lab firewall allows no inbound traffic.
50
+ li #[strong The Agent is the only way to create jobs.] CI and manual runs are identical.
51
+ li #[strong The Coordinator is the single source of truth.] It assigns a job inside a single SQLite transaction, so a job can never be assigned twice.
52
+ li #[strong A job is a command, not a script upload.] The Client runs your #[code --command] as its service user, never as root. Issue agent tokens only to trusted pipelines and people.
53
+
54
+ h3.h6 Resource and job states
55
+ .row.g-3.small
56
+ .col-md-6
57
+ table.table.table-sm.mb-0
58
+ thead
59
+ tr
60
+ th Resource
61
+ th Meaning
62
+ tbody
63
+ tr
64
+ td: code REGISTERED
65
+ td Just registered, no heartbeat yet
66
+ tr
67
+ td: code IDLE
68
+ td Online and free: schedulable
69
+ tr
70
+ td: code BUSY
71
+ td Running a job (#[code ci] / #[code cli]) or locked by hand (#[code local])
72
+ tr
73
+ td: code OUT_OF_SERVICE
74
+ td 3 heartbeats missed
75
+ tr
76
+ td: code MAINTENANCE
77
+ td Disabled by an admin
78
+ .col-md-6
79
+ table.table.table-sm.mb-0
80
+ thead
81
+ tr
82
+ th Job
83
+ th Meaning
84
+ tbody
85
+ tr
86
+ td: code QUEUED → ASSIGNED
87
+ td Waiting for, then bound to, a Client
88
+ tr
89
+ td: code PREPARING → RUNNING
90
+ td Inputs and DUT being prepared, then the command runs
91
+ tr
92
+ td: code PASSED / FAILED
93
+ td The command's exit code: 0, or non-zero
94
+ tr
95
+ td: code ERROR / TIMEOUT
96
+ td Infrastructure problem, or #[code --timeout] exceeded
97
+ tr
98
+ td: code CANCELED / LOST
99
+ td Canceled, or the Client went offline mid-job
100
+
101
+ +section('use-cases', 'Use cases', 'lightbulb')
102
+ -
103
+ const useCases = [
104
+ { icon: 'cpu', title: 'Hardware-in-the-loop CI', text: 'Every push builds firmware in the cloud, then a GitHub Actions test job runs `thub run --type hw --wait`: a lab Client flashes a real board through ST-Link, captures its UART, runs the tests and fails the pipeline on a non-zero exit.' },
105
+ { icon: 'pc-display', title: 'Emulator (SW) test farms', text: 'SW Clients on real or virtual Linux machines run each job’s DUT image (QEMU, Renode, a simulator) in a sandboxed Docker container. The tests talk to it at $THUB_DUT_HOST. Scale out by adding VMs.' },
106
+ { icon: 'person-workspace', title: 'Remote access for developers', text: 'Developers reproduce a CI failure on the exact bench it happened on (`--client lab-hw-01`) from home, with the same command CI ran, and stream the board’s console live.' },
107
+ { icon: 'lock', title: 'Shared benches without collisions', text: 'The scheduler hands out one job per bench. An engineer who needs a board by hand runs `thub-client lock` so nothing is scheduled there until `unlock`.' },
108
+ { icon: 'collection', title: 'Dedicated pools', text: 'Groups and labels (`board:nucleo-f401re`, `uart`) route jobs to the right hardware, e.g. a nightly pool separate from the PR pool.' },
109
+ { icon: 'box-seam', title: 'Any command-line test suite', text: 'Anything that runs from a shell and reports through its exit code: pytest, ctest, Robot Framework, a Dockerized test runner, a flashing and smoke-test script.' }
110
+ ]
111
+ .row.g-3
112
+ each uc in useCases
113
+ .col-md-6
114
+ .d-flex.gap-3
115
+ i.bi.fs-4.text-primary(class=`bi-${uc.icon}`)
116
+ div
117
+ h3.h6.mb-1= uc.title
118
+ p.small.text-body-secondary.mb-0!= uc.text.replace(/`([^`]+)`/g, '<code>$1</code>')
@@ -0,0 +1,37 @@
1
+ +section('quick-start', 'Quick start', 'rocket-takeoff')
2
+ p Five steps from nothing to a first job. Every step links to its full section.
3
+ ol.thub-help-steps
4
+ li
5
+ strong Install the Coordinator
6
+ | on a cloud VM behind an HTTPS reverse proxy (#[a(href="#coordinator") Coordinator setup]):
7
+ +code('Coordinator host').
8
+ sudo npm i -g @andrian.yablonskyy/thub-coordinator
9
+ thub-admin join-key generate # → put it in clientJoinKey
10
+ nano ~/.config/thub/coordinator.json # publicUrl, clientJoinKey
11
+ sudo systemctl restart thub-coordinator
12
+ thub-admin create-admin admin 'a-strong-password'
13
+ li
14
+ strong Install a Client
15
+ | on each lab machine, with the same join key (#[a(href="#client-setup") Client setup]):
16
+ +code('Lab machine (Ubuntu 26.04)').
17
+ sudo apt install -y nodejs npm docker.io # + stlink-tools openocd for HW
18
+ sudo npm i -g @andrian.yablonskyy/thub-client
19
+ nano ~/.config/thub/client.json # coordinatorUrl, type, joinKey
20
+ sudo systemctl enable --now thub-client@client
21
+ | It shows up on #[a(href="/resources") Resources] by itself, usually within 10 s.
22
+ li
23
+ strong Issue an agent token
24
+ | on #[a(href="/admin/agents") Agents] (admins), or on the Coordinator host with
25
+ | #[code thub-admin agent add &lt;name&gt; --kind ci|cli]. The token is shown once.
26
+ li
27
+ strong Install and configure the Agent
28
+ | (#[a(href="#agent-setup") Agent setup]):
29
+ +code('Developer machine').
30
+ npm i -g @andrian.yablonskyy/thub-agent
31
+ thub config set url #{coordinatorUrl}
32
+ thub config set token agt_…
33
+ li
34
+ strong Run a job
35
+ | (#[a(href="#agent-cli") Agent CLI reference]):
36
+ +code('Developer machine').
37
+ thub run --type sw --command 'uname -a && echo hello from the lab' --wait
@@ -0,0 +1,44 @@
1
+ +section('troubleshooting', 'Troubleshooting', 'life-preserver')
2
+ //- `…` in a question or answer marks a command or parameter: rendered as
3
+ //- <code>, with everything else HTML-escaped first (so `<instance>` shows as text).
4
+ -
5
+ const faq = [
6
+ ['A Client doesn\'t appear on Resources', 'Check `journalctl -u thub-client@<instance> -f`. A `503` at registration means the Coordinator has no `clientJoinKey`; a `401` means the `joinKey` differs. Also check that `coordinatorUrl` is reachable from the lab (`curl -sI <url>/login`).'],
7
+ ['A Client flaps or two machines show as one resource', 'Both use the same `.client-id` (a cloned VM or a shared config file). Delete `<varDir>/<instance>/.client-id` on one of them and restart it, or set a distinct `clientId`.'],
8
+ ['Name already in use (`409`)', 'Another live Client holds that name. Rename one of them in its config or from the resource card. A name held by an `OUT_OF_SERVICE` resource is reclaimed automatically.'],
9
+ ['The job is rejected with `422`', 'No registered Client can ever match its type, labels, group or client. Compare `thub resources` with your `--type`, `--board`/`--label` and `--group`.'],
10
+ ['The job stays `QUEUED`', 'Matching Clients are all `BUSY`, locked (`local`), in `MAINTENANCE` or offline, or the job is pinned with `--client`. It times out after `--timeout`.'],
11
+ ['`ERROR` during prepare', 'The clone, download or DUT image pull failed; the job log names the reason. For git, check the Client user\'s keys and `known_hosts` (`ssh -T git@<host>` as that user). For images, check the Client user\'s `docker login`.'],
12
+ ['Permission denied on `/dev/ttyUSB*` or `docker.sock`', 'Docker or the device groups were added after the Client was installed. Re-run `sudo npm i -g @andrian.yablonskyy/thub-client` so the unit gets the `dialout`/`plugdev`/`docker` groups, then restart.'],
13
+ ['A configured device is shown as missing', 'Check the `devpath` with `udevadm info -a -n <dev>`, then `thub-client udev --print`; restart the Client to regenerate the udev rules.'],
14
+ ['Job `LOST`', 'The Client missed 3 heartbeats (network, reboot, crash). With `requeueOnLost` the job is retried once on another Client.'],
15
+ ['A job can\'t write to `~` (read-only file system)', 'Under systemd the Client can only write to its state directory. Write to `$THUB_WORK_DIR`, and set `DOCKER_CONFIG` there for `docker login`.'],
16
+ ['Live logs don\'t stream through the proxy', 'Turn off response buffering for the Coordinator (nginx: `proxy_buffering off;`, Caddy: `flush_interval -1`).'],
17
+ ['Finding a job, resource, group or agent', 'Use the search box on its list page (press `/` to jump to it). Every word must match, e.g. `lab-hw-01 FAILED` or a git ref. Job search covers ids, users, resources, agents, commands, repos and `--meta`, but not `--env` values (secrets). On a job page, search the log above the log viewer: `Enter`/`Shift+Enter` step through matches.'],
18
+ ['Lists don\'t update by themselves (navbar shows Offline or Paused)', '`Paused`: you\'re editing something on the page (a field, the inline rename, a dialog); it resumes when you\'re done. `Offline`: the browser can\'t hold the `/live` stream open, often a proxy buffering it (nginx: `proxy_buffering off;`); reloading the page always shows current data. `Session ended`: sign in again. Background updates never extend the session\'s idle timeout.'],
19
+ ['Sign-in says it needs HTTPS', 'The session cookie is HTTPS-only because `publicUrl` is `https://`, but the request arrived as plain HTTP. Open the dashboard through its https:// address; behind a reverse proxy, make it send `X-Forwarded-Proto: https` (nginx: `proxy_set_header X-Forwarded-Proto $scheme;`) and check `trustProxy`. To allow plain HTTP, set `session.secureCookie` to `false`.'],
20
+ ['Where are a job\'s test reports and output files?', 'The Coordinator keeps only the log and the result (verdict, exit code, JUnit test counts). Files the job creates stay in its workspace on the Client and are deleted when the job ends. To keep them, publish them from `--command`, e.g. `curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/report.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/report.xml"` with `--env ART_TOKEN`. The full log is always available from the job page\'s Raw button.'],
21
+ ['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
22
+ ]
23
+ const withCode = (text) => text
24
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
25
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
26
+ .accordion#help-faq
27
+ each item, i in faq
28
+ .accordion-item
29
+ h3.accordion-header
30
+ button.accordion-button.collapsed.small(
31
+ type="button"
32
+ data-bs-toggle="collapse"
33
+ data-bs-target=`#faq-${i}`
34
+ aria-expanded="false"
35
+ aria-controls=`faq-${i}`
36
+ )
37
+ //- One inline box: .accordion-button is flex, which would drop
38
+ //- the spaces around <code>.
39
+ span!= withCode(item[0])
40
+ .accordion-collapse.collapse(id=`faq-${i}` data-bs-parent="#help-faq")
41
+ .accordion-body.small!= withCode(item[1])
42
+ p.small.text-body-secondary.mt-3.mb-0.
43
+ The full design reference (API, state machines, database schema) is in the project README. Everything
44
+ that happens is also recorded per job: open it from #[a(href="/jobs") Jobs] for its spec, timeline and log.