fhirsmith 0.13.4 → 0.14.1
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/CHANGELOG.md +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
package/nginx.md
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
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; by
|
|
17
|
+
default it trusts proxies on the same host or a private network). That means it takes the
|
|
18
|
+
client address, scheme and host from the `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. The publisher and testing modules also check the `Origin` of every form post against it, and refuse posts from anywhere else - so if nginx sends a different `Host` (e.g. `proxy_pass` with the default `$proxy_host`), every login and form fails with a 403 |
|
|
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
|
+
By default FHIRsmith believes the `X-Forwarded-*` headers only when the request comes from
|
|
53
|
+
a loopback, link-local or private address (`"loopback, linklocal, uniquelocal"`) - nginx on
|
|
54
|
+
the same machine, or on the same docker or private network. A request straight from the
|
|
55
|
+
internet can't set its own client address, so it can't get round the rate limits on logins,
|
|
56
|
+
report submission and the publisher's task actions.
|
|
57
|
+
|
|
58
|
+
If your proxy is somewhere else (a cloud load balancer with a public address, say), set
|
|
59
|
+
`server.trustProxy` to its address, or to the number of proxy hops; any value Express's
|
|
60
|
+
`trust proxy` setting accepts will work. If you run FHIRsmith without a proxy, set it to
|
|
61
|
+
`false`. Avoid `true`: it believes any `X-Forwarded-For`, so anyone who can reach the
|
|
62
|
+
FHIRsmith port directly can name their own IP address, and express-rate-limit logs
|
|
63
|
+
`ERR_ERL_PERMISSIVE_TRUST_PROXY` to say so. Either way, firewalling the port so that only
|
|
64
|
+
nginx can reach it is still a good idea.
|
|
65
|
+
|
|
66
|
+
### Upstream connections
|
|
67
|
+
|
|
68
|
+
Declare the server as an `upstream` with `keepalive`, and `proxy_pass` to the upstream name.
|
|
69
|
+
Pointing `proxy_pass` straight at `127.0.0.1:3000` skips the upstream block, so its keepalive
|
|
70
|
+
setting never takes effect. Keepalive also needs the `proxy_http_version 1.1` and empty
|
|
71
|
+
`Connection` header from the include above.
|
|
72
|
+
|
|
73
|
+
```nginx
|
|
74
|
+
upstream fhirsmith {
|
|
75
|
+
server 127.0.0.1:3000;
|
|
76
|
+
keepalive 32;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Sizes and timeouts
|
|
81
|
+
|
|
82
|
+
* **Body size.** FHIRsmith accepts request bodies up to 50 MB. Set `client_max_body_size`
|
|
83
|
+
to match: if nginx allows more, oversized requests get past nginx and then fail with an
|
|
84
|
+
unhelpful error from the server.
|
|
85
|
+
* **Timeouts.** Some terminology operations are slow, particularly large SNOMED CT
|
|
86
|
+
expansions and batch validations. The public servers use 600s for
|
|
87
|
+
`proxy_read_timeout`/`proxy_send_timeout`. If you set a shorter timeout, nginx returns
|
|
88
|
+
504 while the server is still working, and the client sees a failure for a request that
|
|
89
|
+
would have succeeded.
|
|
90
|
+
* **Compression.** FHIRsmith does not compress responses, and FHIR JSON compresses very
|
|
91
|
+
well. nginx only gzips `text/html` unless told otherwise:
|
|
92
|
+
|
|
93
|
+
```nginx
|
|
94
|
+
gzip on;
|
|
95
|
+
gzip_types application/fhir+json application/fhir+xml application/json application/xml text/css application/javascript;
|
|
96
|
+
gzip_min_length 1024;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Things to leave to FHIRsmith
|
|
100
|
+
|
|
101
|
+
* **CORS** is handled by the server (`server.cors`). Don't add `Access-Control-*` headers
|
|
102
|
+
in nginx as well: browsers reject responses that carry duplicate CORS headers.
|
|
103
|
+
* **HTTP to HTTPS redirects.** FHIR clients are often configured with `http://` endpoints,
|
|
104
|
+
and many HTTP clients turn a redirected POST into a GET. Serve the API on both ports, or
|
|
105
|
+
redirect GETs only. Don't return a blanket `301`.
|
|
106
|
+
|
|
107
|
+
### A minimal server block
|
|
108
|
+
|
|
109
|
+
```nginx
|
|
110
|
+
server {
|
|
111
|
+
listen 443 ssl;
|
|
112
|
+
server_name tx.example.org;
|
|
113
|
+
|
|
114
|
+
ssl_certificate /etc/letsencrypt/live/tx.example.org/fullchain.pem;
|
|
115
|
+
ssl_certificate_key /etc/letsencrypt/live/tx.example.org/privkey.pem;
|
|
116
|
+
|
|
117
|
+
client_max_body_size 50M;
|
|
118
|
+
proxy_read_timeout 600;
|
|
119
|
+
proxy_send_timeout 600;
|
|
120
|
+
server_tokens off;
|
|
121
|
+
|
|
122
|
+
location /.well-known { root /var/www/html; } # certbot
|
|
123
|
+
|
|
124
|
+
location / {
|
|
125
|
+
include fhirsmith-proxy.conf;
|
|
126
|
+
proxy_pass http://fhirsmith;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Rate limiting
|
|
132
|
+
|
|
133
|
+
nginx offers two kinds of limit, and they suit different traffic:
|
|
134
|
+
|
|
135
|
+
* **`limit_conn`** caps the number of requests *in flight* at once for a key.
|
|
136
|
+
* **`limit_req`** caps the *rate* at which requests arrive, using a leaky bucket.
|
|
137
|
+
|
|
138
|
+
Terminology traffic varies enormously in cost. A `$validate-code` against a small value set
|
|
139
|
+
takes milliseconds; a SNOMED CT `$expand` can hold a connection for minutes. `limit_conn`
|
|
140
|
+
matches that well: it limits how much of the server a client is using, not how often it
|
|
141
|
+
asks. It is the primary control on tx.fhir.org.
|
|
142
|
+
|
|
143
|
+
### Concurrent connections
|
|
144
|
+
|
|
145
|
+
```nginx
|
|
146
|
+
http {
|
|
147
|
+
limit_conn_zone $binary_remote_addr zone=perip:10m;
|
|
148
|
+
limit_conn_zone $server_name zone=perserver:10m;
|
|
149
|
+
limit_conn_status 429; # not the default 503 - see below
|
|
150
|
+
limit_conn_log_level warn;
|
|
151
|
+
error_log /var/log/nginx/error.log warn; # rejections are invisible at 'crit'
|
|
152
|
+
|
|
153
|
+
server {
|
|
154
|
+
limit_conn perip 50;
|
|
155
|
+
limit_conn perserver 400;
|
|
156
|
+
...
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Sizing:
|
|
162
|
+
|
|
163
|
+
* **Per IP needs headroom.** One IG Publisher build legitimately sends many terminology
|
|
164
|
+
requests in parallel, and several users can share an IP behind a NAT or a CI provider.
|
|
165
|
+
On tx.fhir.org, limits in the 5-40 range tripped on ordinary builds.
|
|
166
|
+
* **Per server is shared by everyone**, so it has to be well above any single client's
|
|
167
|
+
burst. It is a guard against overload, not a fairness mechanism.
|
|
168
|
+
* **Return 429, not 503.** Clients can back off and retry on 429, and it shows up in their
|
|
169
|
+
logs as something other than an outage.
|
|
170
|
+
|
|
171
|
+
### Request rate
|
|
172
|
+
|
|
173
|
+
`limit_req` works best for cheap, easily abused traffic: crawlers walking the HTML pages,
|
|
174
|
+
or scripts polling one URL. Use `burst` to absorb normal spikes. `nodelay` serves the burst
|
|
175
|
+
immediately instead of spacing it out:
|
|
176
|
+
|
|
177
|
+
```nginx
|
|
178
|
+
limit_req_zone $binary_remote_addr zone=browse:10m rate=10r/s;
|
|
179
|
+
limit_req_status 429;
|
|
180
|
+
|
|
181
|
+
location / {
|
|
182
|
+
limit_req zone=browse burst=50 nodelay;
|
|
183
|
+
...
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A rate limit tight enough to stop a crawler will also catch a busy build. If you use one,
|
|
188
|
+
key it narrowly (by location or user agent), or give it a generous burst.
|
|
189
|
+
|
|
190
|
+
### Exempting known clients
|
|
191
|
+
|
|
192
|
+
nginx doesn't count a request whose limit key is empty. Use `geo` and `map` to give
|
|
193
|
+
trusted clients an empty key:
|
|
194
|
+
|
|
195
|
+
```nginx
|
|
196
|
+
geo $limit_exempt {
|
|
197
|
+
default 0;
|
|
198
|
+
10.0.0.0/8 1; # internal build infrastructure
|
|
199
|
+
203.0.113.7 1;
|
|
200
|
+
}
|
|
201
|
+
map $limit_exempt $limit_key {
|
|
202
|
+
0 $binary_remote_addr;
|
|
203
|
+
1 "";
|
|
204
|
+
}
|
|
205
|
+
limit_conn_zone $limit_key zone=perip:10m;
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Blocking abusive clients
|
|
209
|
+
|
|
210
|
+
* **IP addresses:** `deny` lines, kept in an included file so the list can be edited
|
|
211
|
+
without touching the main config.
|
|
212
|
+
* **User agents:** classify with a `map`, then reject in the location with `return 444`.
|
|
213
|
+
444 is nginx's code for closing the connection without sending a response. Keep the
|
|
214
|
+
patterns narrow. A bare `bot` also matches legitimate tools that have "bot" in their
|
|
215
|
+
name.
|
|
216
|
+
|
|
217
|
+
```nginx
|
|
218
|
+
map $http_user_agent $blocked_agent {
|
|
219
|
+
default 0;
|
|
220
|
+
~*(Bytespider|facebookexternalhit) 1;
|
|
221
|
+
}
|
|
222
|
+
# in the location:
|
|
223
|
+
if ($blocked_agent) { return 444; }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
* **Bandwidth:** for large downloads, such as packages, `limit_rate` caps bytes per second
|
|
227
|
+
per connection. `limit_rate_after` lets the first part of a download through at full
|
|
228
|
+
speed.
|
|
229
|
+
|
|
230
|
+
### Notes
|
|
231
|
+
|
|
232
|
+
* Limits apply per nginx instance. The zone is shared by that instance's worker processes,
|
|
233
|
+
but two front doors keep separate counts.
|
|
234
|
+
* If nginx itself sits behind a CDN or another load balancer, `$binary_remote_addr` is that
|
|
235
|
+
proxy's address. Use the `real_ip` module (`set_real_ip_from` and `real_ip_header`) to
|
|
236
|
+
recover the client address before limiting.
|
|
237
|
+
|
|
238
|
+
## Routing to multiple servers
|
|
239
|
+
|
|
240
|
+
Most terminology operations keep no state between requests, so a pool of identical
|
|
241
|
+
FHIRsmith instances could share traffic in any order. The exception is the **terminology
|
|
242
|
+
cache** (`$cache-control`). A client starts a cache on one server, loads resources into it,
|
|
243
|
+
and then refers to it by id in the `X-Cache-Id` header on later requests. The cache exists
|
|
244
|
+
only in the memory of the instance that created it. If another instance receives the
|
|
245
|
+
request, it answers `cache-id-unknown` (HTTP 404).
|
|
246
|
+
|
|
247
|
+
That rules out DNS round-robin. Clients re-resolve the name and reconnect during a session,
|
|
248
|
+
so a long build drifts from one server to another. You need a single front door that routes
|
|
249
|
+
each cached request back to the instance that owns the cache.
|
|
250
|
+
|
|
251
|
+
### Instance codes
|
|
252
|
+
|
|
253
|
+
Give each instance a short code in its config.json:
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
"modules": {
|
|
257
|
+
"tx": {
|
|
258
|
+
"instanceCode": "tx1",
|
|
259
|
+
...
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The server prefixes every cache-id it issues with its code and a dot, e.g.
|
|
265
|
+
`tx1.4f0c2d1e-…`. Codes must be unique within the pool, and 1-16 letters and digits.
|
|
266
|
+
A client never has to understand the prefix: to the client, the cache-id stays an opaque
|
|
267
|
+
string.
|
|
268
|
+
|
|
269
|
+
nginx can then route without holding any state:
|
|
270
|
+
|
|
271
|
+
* A request **without** `X-Cache-Id` can go to any instance. That includes
|
|
272
|
+
`$cache-control?mode=start`: whichever instance answers issues an id carrying its own
|
|
273
|
+
code.
|
|
274
|
+
* A request **with** `X-Cache-Id` goes to the instance named by the prefix.
|
|
275
|
+
|
|
276
|
+
```nginx
|
|
277
|
+
# Keep this list explicit. Don't pass a captured prefix straight into proxy_pass:
|
|
278
|
+
# a client could send any prefix, and nginx would try to resolve it as a host name.
|
|
279
|
+
map $http_x_cache_id $tx_backend {
|
|
280
|
+
default tx_pool;
|
|
281
|
+
~^tx1\. tx_tx1;
|
|
282
|
+
~^tx2\. tx_tx2;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
upstream tx_pool {
|
|
286
|
+
least_conn; # request cost varies widely; balance by load, not turns
|
|
287
|
+
server 10.0.0.11:3000;
|
|
288
|
+
server 10.0.0.12:3000;
|
|
289
|
+
keepalive 32;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# One upstream per instance. The backup is not there to serve the cache (it can't).
|
|
293
|
+
# It is there so that when the owner is down, the client gets FHIRsmith's coded
|
|
294
|
+
# cache-id-unknown error instead of an nginx 502, and can start a new cache.
|
|
295
|
+
upstream tx_tx1 {
|
|
296
|
+
server 10.0.0.11:3000;
|
|
297
|
+
server 10.0.0.12:3000 backup;
|
|
298
|
+
keepalive 16;
|
|
299
|
+
}
|
|
300
|
+
upstream tx_tx2 {
|
|
301
|
+
server 10.0.0.12:3000;
|
|
302
|
+
server 10.0.0.11:3000 backup;
|
|
303
|
+
keepalive 16;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
server {
|
|
307
|
+
...
|
|
308
|
+
location / {
|
|
309
|
+
include fhirsmith-proxy.conf;
|
|
310
|
+
proxy_pass http://$tx_backend;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Notes:
|
|
316
|
+
|
|
317
|
+
* When `proxy_pass` contains a variable, nginx first looks the value up among the
|
|
318
|
+
`upstream` names defined in the config. That is why the map returns upstream names and
|
|
319
|
+
not addresses.
|
|
320
|
+
* An id with an unknown prefix falls through to the pool. The server that receives it
|
|
321
|
+
answers `cache-id-unknown`, and its message names the instance that issued the id, so
|
|
322
|
+
a routing mistake is recognisable as one. The same happens when a request falls through
|
|
323
|
+
to a `backup` server because its owner is down.
|
|
324
|
+
* Routing only works on the **header**. The older `cache-id` request parameter sits in the
|
|
325
|
+
request body, where nginx can't see it. Current clients (fhir-core) send the header.
|
|
326
|
+
* A restarted instance loses its caches. Clients recover by starting a new one, just as
|
|
327
|
+
they do after a cache expires.
|
|
328
|
+
|
|
329
|
+
### What must be the same on every instance
|
|
330
|
+
|
|
331
|
+
Clients can land on any instance, so every instance needs the same `library.yml`, the same
|
|
332
|
+
terminology data (the same SNOMED CT, LOINC and other editions) and the same tx
|
|
333
|
+
configuration. Otherwise answers depend on which instance served the request. The following
|
|
334
|
+
are kept per instance, which is fine:
|
|
335
|
+
|
|
336
|
+
* expansion caches (each instance warms its own)
|
|
337
|
+
* usage statistics (each instance reports its own share)
|
|
338
|
+
|
|
339
|
+
Only the terminology module should run in the pool. Modules that keep their own state
|
|
340
|
+
(`publisher`, `packages`, `shl`, `token`) belong on a single instance, with their own
|
|
341
|
+
location or server name routed to it.
|
|
342
|
+
|
|
343
|
+
## Managing closure
|
|
344
|
+
|
|
345
|
+
`$closure` stores state on the server between client sessions. (It is defined on ConceptMap,
|
|
346
|
+
but it is a system-level operation: the URL is `[base]/$closure`.) A client creates
|
|
347
|
+
a named closure table, adds concepts to it over days or months, and asks for the changes
|
|
348
|
+
since the version it last saw. The table lives in the database of the instance that holds
|
|
349
|
+
it.
|
|
350
|
+
|
|
351
|
+
**Route every `$closure` request to one designated instance.**
|
|
352
|
+
|
|
353
|
+
```nginx
|
|
354
|
+
upstream tx_closure {
|
|
355
|
+
server 10.0.0.11:3000;
|
|
356
|
+
keepalive 8;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
server {
|
|
360
|
+
...
|
|
361
|
+
# A regex location takes precedence over the plain "location /" prefix.
|
|
362
|
+
# nginx matches locations against the decoded URI, so %24closure is caught as well.
|
|
363
|
+
location ~ /\$closure$ {
|
|
364
|
+
include fhirsmith-proxy.conf;
|
|
365
|
+
proxy_set_header X-Cache-Id ""; # see below
|
|
366
|
+
proxy_pass http://tx_closure;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
location / {
|
|
370
|
+
include fhirsmith-proxy.conf;
|
|
371
|
+
proxy_pass http://$tx_backend;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`$closure` traffic is light, so a single instance is not a bottleneck.
|
|
377
|
+
|
|
378
|
+
### Why this doesn't conflict with cache routing
|
|
379
|
+
|
|
380
|
+
It might look as if a `$closure` request could carry an `X-Cache-Id` that belongs to a
|
|
381
|
+
different instance. That doesn't cause a problem, because closure can't depend on the
|
|
382
|
+
cache.
|
|
383
|
+
|
|
384
|
+
A closure table has to mean the same thing on every call, across sessions that may be
|
|
385
|
+
months apart. Its content can therefore come only from the terminology the server itself
|
|
386
|
+
holds. It can never depend on resources a client supplied in a cache: those disappear when
|
|
387
|
+
the cache ends, and the table would silently change meaning. So `$closure` ignores
|
|
388
|
+
`X-Cache-Id`. Clearing the header in nginx, as above, is a second safeguard: an empty
|
|
389
|
+
`proxy_set_header` value stops nginx sending the header at all.
|
|
390
|
+
|
|
391
|
+
### Operating the closure instance
|
|
392
|
+
|
|
393
|
+
* **Back up** its database. It is the only state in the pool that clients can't rebuild
|
|
394
|
+
from their side.
|
|
395
|
+
* **Don't configure a `backup` server** for `tx_closure`. A backup instance would see none
|
|
396
|
+
of the existing tables, and would start new, diverging ones for any name it was asked
|
|
397
|
+
about. An outage is better than that: clients see a failure and retry later.
|
|
398
|
+
* **Moving the designated instance** means moving its closure database with it, then
|
|
399
|
+
changing the upstream.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
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
|
+
};
|