fhirsmith 0.13.4 → 0.14.0

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 (46) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +16 -3
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +22 -3
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/operation-outcome.js +3 -1
  31. package/tx/library/renderer.js +42 -5
  32. package/tx/operation-context.js +80 -17
  33. package/tx/provider.js +85 -0
  34. package/tx/sct/expressions.js +128 -44
  35. package/tx/tx-html.js +426 -44
  36. package/tx/tx.js +117 -9
  37. package/tx/workers/cache-control.js +2 -2
  38. package/tx/workers/closure.js +483 -13
  39. package/tx/workers/metadata.js +8 -3
  40. package/tx/workers/read.js +18 -3
  41. package/tx/workers/subsumes.js +18 -2
  42. package/tx/workers/translate.js +167 -20
  43. package/tx/workers/validate.js +61 -182
  44. package/tx/workers/worker.js +9 -0
  45. package/tx/xversion/xv-conceptmap.js +17 -8
  46. package/utilities/submit-test-report.js +150 -0
package/nginx.md ADDED
@@ -0,0 +1,391 @@
1
+ # Running FHIRsmith behind nginx
2
+
3
+ FHIRsmith has no built-in TLS, rate limiting or access control. In production it always runs
4
+ behind a reverse proxy, which handles those things. This page covers nginx, which is what the
5
+ public servers (tx.fhir.org, packages.fhir.org and so on) use.
6
+
7
+ * [General configuration](#general-configuration) - what nginx should hand across to the server
8
+ * [Rate limiting](#rate-limiting) - approaches that suit terminology traffic
9
+ * [Routing to multiple servers](#routing-to-multiple-servers) - running more than one instance behind a single front door
10
+ * [Managing closure](#managing-closure) - where `$closure` requests go
11
+
12
+ ## General configuration
13
+
14
+ ### Headers to pass to the server
15
+
16
+ FHIRsmith runs Express with `trust proxy` enabled (`server.trustProxy` in config.json,
17
+ default `true`). That means it takes the client address, scheme and host from the
18
+ `X-Forwarded-*` headers, so nginx has to set them:
19
+
20
+ | Header | nginx value | What FHIRsmith uses it for |
21
+ |---|---|---|
22
+ | `Host` | `$host` | The base URL in the CapabilityStatement, TerminologyCapabilities and other self-references. It is taken from the host the client asked for |
23
+ | `X-Forwarded-Proto` | `$scheme` | The scheme of those same base URLs, and the `Secure` session cookie used by the publisher and token modules. If this header is missing, an HTTPS server advertises `http://` URLs and publisher login fails silently: you are sent back to the login page, still logged out |
24
+ | `X-Forwarded-For` | `$proxy_add_x_forwarded_for` | The client IP address in audit and security logs (`req.ip`). If this header is missing, every request is logged as coming from `127.0.0.1` |
25
+ | `X-Request-Id` | `$request_id` | Optional. FHIRsmith echoes it back on the response, so a client's report can be matched to a line in the nginx log (add `$request_id` to your `log_format`) |
26
+
27
+ `X-Real-IP` is harmless but FHIRsmith does not read it. Only `X-Forwarded-For` reaches `req.ip`.
28
+ FHIRsmith does not read client-certificate headers either.
29
+
30
+ `proxy_set_header` directives are **not** inherited into a location that sets any of its
31
+ own. So put the common set in a file and include it in every proxied location:
32
+
33
+ ```nginx
34
+ # /etc/nginx/fhirsmith-proxy.conf
35
+ proxy_http_version 1.1;
36
+ proxy_set_header Connection ""; # allow upstream keepalive
37
+ proxy_set_header Host $host;
38
+ proxy_set_header X-Forwarded-Proto $scheme;
39
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
40
+ proxy_set_header X-Request-Id $request_id;
41
+ ```
42
+
43
+ The other headers FHIRsmith relies on pass through without any configuration. Don't strip
44
+ them:
45
+
46
+ * `Accept` and `Content-Type` - format negotiation (JSON/XML, FHIR versions)
47
+ * `Accept-Language` - the display language and the language of error messages
48
+ * `X-Cache-Id` - the terminology cache protocol (see [Routing to multiple servers](#routing-to-multiple-servers))
49
+
50
+ ### Trusting the proxy
51
+
52
+ With `trustProxy: true`, anyone who can reach the FHIRsmith port directly can forge
53
+ `X-Forwarded-For`. FHIRsmith listens on all interfaces, so firewall the port so that only
54
+ nginx can reach it. You can also set `server.trustProxy` to `"loopback"`, or to the proxy's
55
+ address; any value that Express's `trust proxy` setting accepts will work. If you run
56
+ FHIRsmith without a proxy, set it to `false`.
57
+
58
+ ### Upstream connections
59
+
60
+ Declare the server as an `upstream` with `keepalive`, and `proxy_pass` to the upstream name.
61
+ Pointing `proxy_pass` straight at `127.0.0.1:3000` skips the upstream block, so its keepalive
62
+ setting never takes effect. Keepalive also needs the `proxy_http_version 1.1` and empty
63
+ `Connection` header from the include above.
64
+
65
+ ```nginx
66
+ upstream fhirsmith {
67
+ server 127.0.0.1:3000;
68
+ keepalive 32;
69
+ }
70
+ ```
71
+
72
+ ### Sizes and timeouts
73
+
74
+ * **Body size.** FHIRsmith accepts request bodies up to 50 MB. Set `client_max_body_size`
75
+ to match: if nginx allows more, oversized requests get past nginx and then fail with an
76
+ unhelpful error from the server.
77
+ * **Timeouts.** Some terminology operations are slow, particularly large SNOMED CT
78
+ expansions and batch validations. The public servers use 600s for
79
+ `proxy_read_timeout`/`proxy_send_timeout`. If you set a shorter timeout, nginx returns
80
+ 504 while the server is still working, and the client sees a failure for a request that
81
+ would have succeeded.
82
+ * **Compression.** FHIRsmith does not compress responses, and FHIR JSON compresses very
83
+ well. nginx only gzips `text/html` unless told otherwise:
84
+
85
+ ```nginx
86
+ gzip on;
87
+ gzip_types application/fhir+json application/fhir+xml application/json application/xml text/css application/javascript;
88
+ gzip_min_length 1024;
89
+ ```
90
+
91
+ ### Things to leave to FHIRsmith
92
+
93
+ * **CORS** is handled by the server (`server.cors`). Don't add `Access-Control-*` headers
94
+ in nginx as well: browsers reject responses that carry duplicate CORS headers.
95
+ * **HTTP to HTTPS redirects.** FHIR clients are often configured with `http://` endpoints,
96
+ and many HTTP clients turn a redirected POST into a GET. Serve the API on both ports, or
97
+ redirect GETs only. Don't return a blanket `301`.
98
+
99
+ ### A minimal server block
100
+
101
+ ```nginx
102
+ server {
103
+ listen 443 ssl;
104
+ server_name tx.example.org;
105
+
106
+ ssl_certificate /etc/letsencrypt/live/tx.example.org/fullchain.pem;
107
+ ssl_certificate_key /etc/letsencrypt/live/tx.example.org/privkey.pem;
108
+
109
+ client_max_body_size 50M;
110
+ proxy_read_timeout 600;
111
+ proxy_send_timeout 600;
112
+ server_tokens off;
113
+
114
+ location /.well-known { root /var/www/html; } # certbot
115
+
116
+ location / {
117
+ include fhirsmith-proxy.conf;
118
+ proxy_pass http://fhirsmith;
119
+ }
120
+ }
121
+ ```
122
+
123
+ ## Rate limiting
124
+
125
+ nginx offers two kinds of limit, and they suit different traffic:
126
+
127
+ * **`limit_conn`** caps the number of requests *in flight* at once for a key.
128
+ * **`limit_req`** caps the *rate* at which requests arrive, using a leaky bucket.
129
+
130
+ Terminology traffic varies enormously in cost. A `$validate-code` against a small value set
131
+ takes milliseconds; a SNOMED CT `$expand` can hold a connection for minutes. `limit_conn`
132
+ matches that well: it limits how much of the server a client is using, not how often it
133
+ asks. It is the primary control on tx.fhir.org.
134
+
135
+ ### Concurrent connections
136
+
137
+ ```nginx
138
+ http {
139
+ limit_conn_zone $binary_remote_addr zone=perip:10m;
140
+ limit_conn_zone $server_name zone=perserver:10m;
141
+ limit_conn_status 429; # not the default 503 - see below
142
+ limit_conn_log_level warn;
143
+ error_log /var/log/nginx/error.log warn; # rejections are invisible at 'crit'
144
+
145
+ server {
146
+ limit_conn perip 50;
147
+ limit_conn perserver 400;
148
+ ...
149
+ }
150
+ }
151
+ ```
152
+
153
+ Sizing:
154
+
155
+ * **Per IP needs headroom.** One IG Publisher build legitimately sends many terminology
156
+ requests in parallel, and several users can share an IP behind a NAT or a CI provider.
157
+ On tx.fhir.org, limits in the 5-40 range tripped on ordinary builds.
158
+ * **Per server is shared by everyone**, so it has to be well above any single client's
159
+ burst. It is a guard against overload, not a fairness mechanism.
160
+ * **Return 429, not 503.** Clients can back off and retry on 429, and it shows up in their
161
+ logs as something other than an outage.
162
+
163
+ ### Request rate
164
+
165
+ `limit_req` works best for cheap, easily abused traffic: crawlers walking the HTML pages,
166
+ or scripts polling one URL. Use `burst` to absorb normal spikes. `nodelay` serves the burst
167
+ immediately instead of spacing it out:
168
+
169
+ ```nginx
170
+ limit_req_zone $binary_remote_addr zone=browse:10m rate=10r/s;
171
+ limit_req_status 429;
172
+
173
+ location / {
174
+ limit_req zone=browse burst=50 nodelay;
175
+ ...
176
+ }
177
+ ```
178
+
179
+ A rate limit tight enough to stop a crawler will also catch a busy build. If you use one,
180
+ key it narrowly (by location or user agent), or give it a generous burst.
181
+
182
+ ### Exempting known clients
183
+
184
+ nginx doesn't count a request whose limit key is empty. Use `geo` and `map` to give
185
+ trusted clients an empty key:
186
+
187
+ ```nginx
188
+ geo $limit_exempt {
189
+ default 0;
190
+ 10.0.0.0/8 1; # internal build infrastructure
191
+ 203.0.113.7 1;
192
+ }
193
+ map $limit_exempt $limit_key {
194
+ 0 $binary_remote_addr;
195
+ 1 "";
196
+ }
197
+ limit_conn_zone $limit_key zone=perip:10m;
198
+ ```
199
+
200
+ ### Blocking abusive clients
201
+
202
+ * **IP addresses:** `deny` lines, kept in an included file so the list can be edited
203
+ without touching the main config.
204
+ * **User agents:** classify with a `map`, then reject in the location with `return 444`.
205
+ 444 is nginx's code for closing the connection without sending a response. Keep the
206
+ patterns narrow. A bare `bot` also matches legitimate tools that have "bot" in their
207
+ name.
208
+
209
+ ```nginx
210
+ map $http_user_agent $blocked_agent {
211
+ default 0;
212
+ ~*(Bytespider|facebookexternalhit) 1;
213
+ }
214
+ # in the location:
215
+ if ($blocked_agent) { return 444; }
216
+ ```
217
+
218
+ * **Bandwidth:** for large downloads, such as packages, `limit_rate` caps bytes per second
219
+ per connection. `limit_rate_after` lets the first part of a download through at full
220
+ speed.
221
+
222
+ ### Notes
223
+
224
+ * Limits apply per nginx instance. The zone is shared by that instance's worker processes,
225
+ but two front doors keep separate counts.
226
+ * If nginx itself sits behind a CDN or another load balancer, `$binary_remote_addr` is that
227
+ proxy's address. Use the `real_ip` module (`set_real_ip_from` and `real_ip_header`) to
228
+ recover the client address before limiting.
229
+
230
+ ## Routing to multiple servers
231
+
232
+ Most terminology operations keep no state between requests, so a pool of identical
233
+ FHIRsmith instances could share traffic in any order. The exception is the **terminology
234
+ cache** (`$cache-control`). A client starts a cache on one server, loads resources into it,
235
+ and then refers to it by id in the `X-Cache-Id` header on later requests. The cache exists
236
+ only in the memory of the instance that created it. If another instance receives the
237
+ request, it answers `cache-id-unknown` (HTTP 404).
238
+
239
+ That rules out DNS round-robin. Clients re-resolve the name and reconnect during a session,
240
+ so a long build drifts from one server to another. You need a single front door that routes
241
+ each cached request back to the instance that owns the cache.
242
+
243
+ ### Instance codes
244
+
245
+ Give each instance a short code in its config.json:
246
+
247
+ ```json
248
+ "modules": {
249
+ "tx": {
250
+ "instanceCode": "tx1",
251
+ ...
252
+ }
253
+ }
254
+ ```
255
+
256
+ The server prefixes every cache-id it issues with its code and a dot, e.g.
257
+ `tx1.4f0c2d1e-…`. Codes must be unique within the pool, and 1-16 letters and digits.
258
+ A client never has to understand the prefix: to the client, the cache-id stays an opaque
259
+ string.
260
+
261
+ nginx can then route without holding any state:
262
+
263
+ * A request **without** `X-Cache-Id` can go to any instance. That includes
264
+ `$cache-control?mode=start`: whichever instance answers issues an id carrying its own
265
+ code.
266
+ * A request **with** `X-Cache-Id` goes to the instance named by the prefix.
267
+
268
+ ```nginx
269
+ # Keep this list explicit. Don't pass a captured prefix straight into proxy_pass:
270
+ # a client could send any prefix, and nginx would try to resolve it as a host name.
271
+ map $http_x_cache_id $tx_backend {
272
+ default tx_pool;
273
+ ~^tx1\. tx_tx1;
274
+ ~^tx2\. tx_tx2;
275
+ }
276
+
277
+ upstream tx_pool {
278
+ least_conn; # request cost varies widely; balance by load, not turns
279
+ server 10.0.0.11:3000;
280
+ server 10.0.0.12:3000;
281
+ keepalive 32;
282
+ }
283
+
284
+ # One upstream per instance. The backup is not there to serve the cache (it can't).
285
+ # It is there so that when the owner is down, the client gets FHIRsmith's coded
286
+ # cache-id-unknown error instead of an nginx 502, and can start a new cache.
287
+ upstream tx_tx1 {
288
+ server 10.0.0.11:3000;
289
+ server 10.0.0.12:3000 backup;
290
+ keepalive 16;
291
+ }
292
+ upstream tx_tx2 {
293
+ server 10.0.0.12:3000;
294
+ server 10.0.0.11:3000 backup;
295
+ keepalive 16;
296
+ }
297
+
298
+ server {
299
+ ...
300
+ location / {
301
+ include fhirsmith-proxy.conf;
302
+ proxy_pass http://$tx_backend;
303
+ }
304
+ }
305
+ ```
306
+
307
+ Notes:
308
+
309
+ * When `proxy_pass` contains a variable, nginx first looks the value up among the
310
+ `upstream` names defined in the config. That is why the map returns upstream names and
311
+ not addresses.
312
+ * An id with an unknown prefix falls through to the pool. The server that receives it
313
+ answers `cache-id-unknown`, and its message names the instance that issued the id, so
314
+ a routing mistake is recognisable as one. The same happens when a request falls through
315
+ to a `backup` server because its owner is down.
316
+ * Routing only works on the **header**. The older `cache-id` request parameter sits in the
317
+ request body, where nginx can't see it. Current clients (fhir-core) send the header.
318
+ * A restarted instance loses its caches. Clients recover by starting a new one, just as
319
+ they do after a cache expires.
320
+
321
+ ### What must be the same on every instance
322
+
323
+ Clients can land on any instance, so every instance needs the same `library.yml`, the same
324
+ terminology data (the same SNOMED CT, LOINC and other editions) and the same tx
325
+ configuration. Otherwise answers depend on which instance served the request. The following
326
+ are kept per instance, which is fine:
327
+
328
+ * expansion caches (each instance warms its own)
329
+ * usage statistics (each instance reports its own share)
330
+
331
+ Only the terminology module should run in the pool. Modules that keep their own state
332
+ (`publisher`, `packages`, `shl`, `token`) belong on a single instance, with their own
333
+ location or server name routed to it.
334
+
335
+ ## Managing closure
336
+
337
+ `$closure` stores state on the server between client sessions. (It is defined on ConceptMap,
338
+ but it is a system-level operation: the URL is `[base]/$closure`.) A client creates
339
+ a named closure table, adds concepts to it over days or months, and asks for the changes
340
+ since the version it last saw. The table lives in the database of the instance that holds
341
+ it.
342
+
343
+ **Route every `$closure` request to one designated instance.**
344
+
345
+ ```nginx
346
+ upstream tx_closure {
347
+ server 10.0.0.11:3000;
348
+ keepalive 8;
349
+ }
350
+
351
+ server {
352
+ ...
353
+ # A regex location takes precedence over the plain "location /" prefix.
354
+ # nginx matches locations against the decoded URI, so %24closure is caught as well.
355
+ location ~ /\$closure$ {
356
+ include fhirsmith-proxy.conf;
357
+ proxy_set_header X-Cache-Id ""; # see below
358
+ proxy_pass http://tx_closure;
359
+ }
360
+
361
+ location / {
362
+ include fhirsmith-proxy.conf;
363
+ proxy_pass http://$tx_backend;
364
+ }
365
+ }
366
+ ```
367
+
368
+ `$closure` traffic is light, so a single instance is not a bottleneck.
369
+
370
+ ### Why this doesn't conflict with cache routing
371
+
372
+ It might look as if a `$closure` request could carry an `X-Cache-Id` that belongs to a
373
+ different instance. That doesn't cause a problem, because closure can't depend on the
374
+ cache.
375
+
376
+ A closure table has to mean the same thing on every call, across sessions that may be
377
+ months apart. Its content can therefore come only from the terminology the server itself
378
+ holds. It can never depend on resources a client supplied in a cache: those disappear when
379
+ the cache ends, and the table would silently change meaning. So `$closure` ignores
380
+ `X-Cache-Id`. Clearing the header in nginx, as above, is a second safeguard: an empty
381
+ `proxy_set_header` value stops nginx sending the header at all.
382
+
383
+ ### Operating the closure instance
384
+
385
+ * **Back up** its database. It is the only state in the pool that clients can't rebuild
386
+ from their side.
387
+ * **Don't configure a `backup` server** for `tx_closure`. A backup instance would see none
388
+ of the existing tables, and would start new, diverging ones for any name it was asked
389
+ about. An outage is better than that: clients see a failure and retry later.
390
+ * **Moving the designated instance** means moving its closure database with it, then
391
+ changing the upstream.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "txVersion": "1.9.5-SNAPSHOT",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -27,7 +27,8 @@
27
27
  "install-global": "npm link",
28
28
  "list": "node tx-import.js list",
29
29
  "import": "node tx-import.js",
30
- "tx-import": "node tx/importers/tx-import.js"
30
+ "tx-import": "node tx/importers/tx-import.js",
31
+ "submit-test-report": "node utilities/submit-test-report.js"
31
32
  },
32
33
  "dependencies": {
33
34
  "axios": "^1.13.4",
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Talking to a GitHub remote over anonymous HTTPS, for the publisher.
3
+ *
4
+ * The trap this exists for: GitHub answers a request for a repository that
5
+ * doesn't exist exactly as it answers one for a private repository - with an
6
+ * authentication challenge, so as not to leak which private repositories
7
+ * exist. git responds to that by prompting for a username. The publisher runs
8
+ * git with no terminal, so the prompt fails with
9
+ *
10
+ * fatal: could not read Username for 'https://github.com': No such device or address
11
+ *
12
+ * which says nothing about the actual problem (almost always a mistyped org or
13
+ * repo). And where git does have a terminal, it blocks waiting for input.
14
+ *
15
+ * So git is always run here with GIT_TERMINAL_PROMPT=0, which turns the prompt
16
+ * into an immediate failure, and those failures are translated into something
17
+ * a person can act on.
18
+ */
19
+
20
+ const { spawn } = require('child_process');
21
+
22
+ const LS_REMOTE_TIMEOUT_MS = 20000;
23
+
24
+ /**
25
+ * The environment to run git in: the caller's, but never prompting.
26
+ */
27
+ function gitEnv(base = process.env) {
28
+ return { ...base, GIT_TERMINAL_PROMPT: '0' };
29
+ }
30
+
31
+ function githubUrl(org, repo) {
32
+ return 'https://github.com/' + org + '/' + repo + '.git';
33
+ }
34
+
35
+ /**
36
+ * Does git's stderr say the repository wasn't there (or wasn't visible to us)?
37
+ */
38
+ function isRepoNotFound(stderr) {
39
+ return /terminal prompts disabled/i.test(stderr) ||
40
+ /could not read Username/i.test(stderr) ||
41
+ /Repository not found/i.test(stderr) ||
42
+ /Authentication failed/i.test(stderr);
43
+ }
44
+
45
+ /**
46
+ * Does git's stderr say the repository was there but the branch wasn't?
47
+ * (`git clone --branch x` on a missing branch)
48
+ */
49
+ function isBranchNotFound(stderr) {
50
+ return /Remote branch .* not found/i.test(stderr);
51
+ }
52
+
53
+ /**
54
+ * Turn a failed clone's stderr into a message a person can act on, or null if
55
+ * it isn't one of the failures we recognise (the caller then reports the raw
56
+ * git output).
57
+ */
58
+ function explainCloneFailure(stderr, org, repo, branch) {
59
+ if (isBranchNotFound(stderr)) {
60
+ return 'Branch "' + branch + '" does not exist in https://github.com/' + org + '/' + repo;
61
+ }
62
+ if (isRepoNotFound(stderr)) {
63
+ return 'Repository https://github.com/' + org + '/' + repo +
64
+ ' was not found. Check the org and repo names - the publisher can only clone public repositories';
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Check that a public GitHub repository exists and has the given branch,
71
+ * with `git ls-remote`.
72
+ *
73
+ * @returns {Promise<{ok: boolean, error?: string, unreachable?: boolean}>}
74
+ * ok: the repo and branch both exist.
75
+ * error: why not, when we know.
76
+ * unreachable: the check itself couldn't be made (network, timeout, git
77
+ * missing). The caller should not reject the task on that basis - the
78
+ * clone will fail later with a proper message if there really is a problem.
79
+ */
80
+ function checkRemoteBranch(org, repo, branch, { timeoutMs = LS_REMOTE_TIMEOUT_MS, spawnFn = spawn } = {}) {
81
+ return new Promise((resolve) => {
82
+ let settled = false;
83
+ const finish = (result) => {
84
+ if (!settled) {
85
+ settled = true;
86
+ clearTimeout(timer);
87
+ resolve(result);
88
+ }
89
+ };
90
+
91
+ let proc;
92
+ try {
93
+ // '--' so a branch can never be taken as an option (validation already
94
+ // refuses a leading hyphen, but this doesn't depend on that)
95
+ proc = spawnFn('git', ['ls-remote', '--heads', githubUrl(org, repo), '--', 'refs/heads/' + branch], {
96
+ stdio: ['ignore', 'pipe', 'pipe'],
97
+ env: gitEnv()
98
+ });
99
+ } catch (e) {
100
+ resolve({ ok: false, unreachable: true, error: 'Unable to run git: ' + e.message });
101
+ return;
102
+ }
103
+
104
+ const timer = setTimeout(() => {
105
+ try { proc.kill(); } catch (e) { /* already gone */ }
106
+ finish({ ok: false, unreachable: true, error: 'Timed out checking https://github.com/' + org + '/' + repo });
107
+ }, timeoutMs);
108
+
109
+ let stdout = '';
110
+ let stderr = '';
111
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
112
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
113
+
114
+ proc.on('error', (e) => {
115
+ finish({ ok: false, unreachable: true, error: 'Unable to run git: ' + e.message });
116
+ });
117
+
118
+ proc.on('close', (code) => {
119
+ if (code === 0) {
120
+ // ls-remote succeeds with no output when the repo exists but the ref doesn't
121
+ const found = stdout.split('\n').some(line => line.trim().endsWith('\trefs/heads/' + branch));
122
+ if (found) {
123
+ finish({ ok: true });
124
+ } else {
125
+ finish({ ok: false, error: 'Branch "' + branch + '" does not exist in https://github.com/' + org + '/' + repo });
126
+ }
127
+ } else if (isRepoNotFound(stderr)) {
128
+ finish({ ok: false, error: explainCloneFailure(stderr, org, repo, branch) });
129
+ } else {
130
+ finish({ ok: false, unreachable: true, error: 'Unable to check https://github.com/' + org + '/' + repo + ': ' + stderr.trim() });
131
+ }
132
+ });
133
+ });
134
+ }
135
+
136
+ module.exports = {
137
+ gitEnv,
138
+ githubUrl,
139
+ explainCloneFailure,
140
+ checkRemoteBranch
141
+ };