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.
- package/CHANGELOG.md +32 -1
- package/README.md +16 -3
- package/config-template.json +70 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/nginx.md +391 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher.js +146 -14
- package/server.js +51 -14
- package/stats.js +19 -13
- package/testing/readme.md +112 -0
- package/testing/render.js +482 -0
- package/testing/search.js +378 -0
- package/testing/store.js +266 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +486 -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 +22 -3
- 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/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/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +167 -20
- package/tx/workers/validate.js +61 -182
- package/tx/workers/worker.js +9 -0
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Tagging an IG source repository and publishing a GitHub release for it, at the end of a
|
|
2
|
+
// successful publication run.
|
|
3
|
+
//
|
|
4
|
+
// Authentication is a GitHub App rather than a personal access token. The reason is scope: the
|
|
5
|
+
// publisher only ever publishes from the HL7 and FHIR organisations, and an App installed on
|
|
6
|
+
// exactly those two - with Contents: Read and write, which covers both tags and releases - can be
|
|
7
|
+
// revoked, audited and attributed on its own. A fine-grained PAT cannot span two organisations
|
|
8
|
+
// (each is limited to a single resource owner), and a classic PAT would act as whoever minted it.
|
|
9
|
+
//
|
|
10
|
+
// The GitHub CLI is deliberately not used. gh cannot authenticate as an App - it only accepts
|
|
11
|
+
// access tokens - so an installation token has to be minted here regardless, and once that code
|
|
12
|
+
// exists the three remaining REST calls are less machinery than requiring gh to be installed and
|
|
13
|
+
// kept current on the server. Nothing in this file needs the database, which also keeps it
|
|
14
|
+
// testable without sqlite3.
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const axios = require('axios');
|
|
19
|
+
|
|
20
|
+
const API_ROOT = 'https://api.github.com';
|
|
21
|
+
const API_VERSION = '2022-11-28';
|
|
22
|
+
|
|
23
|
+
// GitHub rejects an App JWT whose exp is more than 10 minutes out, measured against ITS clock, so
|
|
24
|
+
// a token minted right on the 10 minute mark fails whenever this server's clock runs slow. Backdate
|
|
25
|
+
// iat by a minute against skew the other way, and take 8 minutes rather than the full 10 - the
|
|
26
|
+
// token is spent within seconds of being minted, so the headroom costs nothing.
|
|
27
|
+
const JWT_BACKDATE_SECONDS = 60;
|
|
28
|
+
const JWT_LIFETIME_SECONDS = 480;
|
|
29
|
+
|
|
30
|
+
function isConfigured(cfg) {
|
|
31
|
+
return !!(cfg && cfg['app-id'] && cfg['private-key'] && cfg.installations);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// task.github_org carries whatever case the requester typed, and GitHub org names are not
|
|
35
|
+
// case-sensitive, so the config's spelling should not have to match it.
|
|
36
|
+
function installationIdFor(cfg, org) {
|
|
37
|
+
const map = (cfg && cfg.installations) || {};
|
|
38
|
+
const wanted = String(org || '').toLowerCase();
|
|
39
|
+
const key = Object.keys(map).find((k) => k.toLowerCase() === wanted);
|
|
40
|
+
return key ? map[key] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base64url(input) {
|
|
44
|
+
return Buffer.from(input).toString('base64')
|
|
45
|
+
.replace(/\+/g, '-')
|
|
46
|
+
.replace(/\//g, '_')
|
|
47
|
+
.replace(/=+$/, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appJwt(appId, privateKeyPem, nowSeconds) {
|
|
51
|
+
const now = nowSeconds || Math.floor(Date.now() / 1000);
|
|
52
|
+
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
53
|
+
const payload = base64url(JSON.stringify({
|
|
54
|
+
iat: now - JWT_BACKDATE_SECONDS,
|
|
55
|
+
exp: now + JWT_LIFETIME_SECONDS,
|
|
56
|
+
iss: String(appId)
|
|
57
|
+
}));
|
|
58
|
+
const signer = crypto.createSign('RSA-SHA256');
|
|
59
|
+
signer.update(header + '.' + payload);
|
|
60
|
+
signer.end();
|
|
61
|
+
return header + '.' + payload + '.' + base64url(signer.sign(privateKeyPem));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function apiHeaders(authorization) {
|
|
65
|
+
return {
|
|
66
|
+
'Authorization': authorization,
|
|
67
|
+
'Accept': 'application/vnd.github+json',
|
|
68
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
69
|
+
'User-Agent': 'FHIRsmith-publisher'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function installationToken(cfg, org, http) {
|
|
74
|
+
const installationId = installationIdFor(cfg, org);
|
|
75
|
+
if (!installationId) {
|
|
76
|
+
throw new Error('No GitHub App installation is configured for the organisation ' + org);
|
|
77
|
+
}
|
|
78
|
+
const pem = fs.readFileSync(cfg['private-key'], 'utf8');
|
|
79
|
+
const jwt = appJwt(cfg['app-id'], pem);
|
|
80
|
+
const res = await http.post(
|
|
81
|
+
API_ROOT + '/app/installations/' + installationId + '/access_tokens',
|
|
82
|
+
{},
|
|
83
|
+
{ headers: apiHeaders('Bearer ' + jwt) });
|
|
84
|
+
if (!res.data || !res.data.token) {
|
|
85
|
+
throw new Error('GitHub returned no installation token for organisation ' + org);
|
|
86
|
+
}
|
|
87
|
+
return res.data.token;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Create the tag if it is not already there. A re-publication of the same version finds it
|
|
91
|
+
// present: the tag is left alone (it is a public ref others may have resolved), but if it points
|
|
92
|
+
// somewhere other than what was just published, say so - a release attached to a commit that is
|
|
93
|
+
// not the published one is exactly the sort of thing nobody notices for a year.
|
|
94
|
+
async function ensureTag(repoUrl, tag, sha, headers, http, log) {
|
|
95
|
+
const existing = await http.get(repoUrl + '/git/ref/' + encodeURIComponent('tags/' + tag),
|
|
96
|
+
{ headers, validateStatus: (s) => s === 200 || s === 404 });
|
|
97
|
+
if (existing.status === 200) {
|
|
98
|
+
const existingSha = existing.data && existing.data.object && existing.data.object.sha;
|
|
99
|
+
if (existingSha && existingSha !== sha) {
|
|
100
|
+
log('warn', 'Tag ' + tag + ' already exists and points at ' + existingSha.substring(0, 8) +
|
|
101
|
+
', not the commit just published (' + sha.substring(0, 8) + '). Leaving the tag as it is.');
|
|
102
|
+
} else {
|
|
103
|
+
log('info', 'Tag ' + tag + ' already exists and is correct - leaving it alone');
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
await http.post(repoUrl + '/git/refs', { ref: 'refs/tags/' + tag, sha: sha }, { headers });
|
|
108
|
+
log('info', 'Created tag ' + tag + ' at ' + sha.substring(0, 8));
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A re-publication should end up with the current build's zip attached, so an existing release for
|
|
113
|
+
// this tag is removed and rebuilt. The tag itself survives that - deleting a release does not
|
|
114
|
+
// delete its ref.
|
|
115
|
+
async function replaceExistingRelease(repoUrl, tag, headers, http, log) {
|
|
116
|
+
const existing = await http.get(repoUrl + '/releases/tags/' + encodeURIComponent(tag),
|
|
117
|
+
{ headers, validateStatus: (s) => s === 200 || s === 404 });
|
|
118
|
+
if (existing.status !== 200) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
await http.delete(repoUrl + '/releases/' + existing.data.id, { headers });
|
|
122
|
+
log('info', 'Removed the previous release for ' + tag + ' so it can be replaced');
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function uploadAsset(uploadUrlTemplate, assetName, zipPath, headers, http) {
|
|
127
|
+
// upload_url comes back as an RFC 6570 template, e.g. https://…/assets{?name,label}
|
|
128
|
+
const base = uploadUrlTemplate.replace(/\{[^}]*\}$/, '');
|
|
129
|
+
const url = base + '?name=' + encodeURIComponent(assetName);
|
|
130
|
+
const body = fs.readFileSync(zipPath);
|
|
131
|
+
const res = await http.post(url, body, {
|
|
132
|
+
headers: Object.assign({}, headers, {
|
|
133
|
+
'Content-Type': 'application/zip',
|
|
134
|
+
'Content-Length': body.length
|
|
135
|
+
}),
|
|
136
|
+
maxBodyLength: Infinity,
|
|
137
|
+
maxContentLength: Infinity
|
|
138
|
+
});
|
|
139
|
+
return res.data;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Tag the source repository and publish a release carrying the source zip.
|
|
144
|
+
*
|
|
145
|
+
* Resolves with a summary; throws on failure. Callers are expected to treat a failure as a
|
|
146
|
+
* warning: by the time this runs the IG itself is already published.
|
|
147
|
+
*/
|
|
148
|
+
async function publishRelease(opts) {
|
|
149
|
+
const {
|
|
150
|
+
config, org, repo, tag, sha, releaseName, body, zipPath, assetName
|
|
151
|
+
} = opts;
|
|
152
|
+
const http = opts.http || axios;
|
|
153
|
+
const log = opts.log || function () {};
|
|
154
|
+
|
|
155
|
+
if (!isConfigured(config)) {
|
|
156
|
+
throw new Error('GitHub App is not configured');
|
|
157
|
+
}
|
|
158
|
+
if (!fs.existsSync(zipPath)) {
|
|
159
|
+
throw new Error('Source zip not found at ' + zipPath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const token = await installationToken(config, org, http);
|
|
163
|
+
const headers = apiHeaders('Bearer ' + token);
|
|
164
|
+
const repoUrl = API_ROOT + '/repos/' + org + '/' + repo;
|
|
165
|
+
|
|
166
|
+
const tagCreated = await ensureTag(repoUrl, tag, sha, headers, http, log);
|
|
167
|
+
const releaseReplaced = await replaceExistingRelease(repoUrl, tag, headers, http, log);
|
|
168
|
+
|
|
169
|
+
const created = await http.post(repoUrl + '/releases', {
|
|
170
|
+
tag_name: tag,
|
|
171
|
+
name: releaseName,
|
|
172
|
+
body: body || '',
|
|
173
|
+
draft: false,
|
|
174
|
+
prerelease: false
|
|
175
|
+
}, { headers });
|
|
176
|
+
|
|
177
|
+
await uploadAsset(created.data.upload_url, assetName, zipPath, headers, http);
|
|
178
|
+
|
|
179
|
+
log('info', (releaseReplaced ? 'Replaced' : 'Created') + ' release ' + tag + ' on ' + org + '/' +
|
|
180
|
+
repo + ' with ' + assetName + ' attached: ' + created.data.html_url);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
tagCreated: tagCreated,
|
|
184
|
+
releaseReplaced: releaseReplaced,
|
|
185
|
+
url: created.data.html_url
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
isConfigured,
|
|
191
|
+
installationIdFor,
|
|
192
|
+
appJwt,
|
|
193
|
+
installationToken,
|
|
194
|
+
publishRelease
|
|
195
|
+
};
|
package/publisher/publisher.js
CHANGED
|
@@ -2,6 +2,8 @@ const express = require('express');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const validation = require('./validation');
|
|
5
|
+
const gitRemote = require('./git-remote');
|
|
6
|
+
const githubRelease = require('./github-release');
|
|
5
7
|
const Database = require('sqlite3').Database;
|
|
6
8
|
const bcrypt = require('bcrypt');
|
|
7
9
|
const session = require('express-session');
|
|
@@ -150,6 +152,7 @@ class PublisherModule {
|
|
|
150
152
|
build_output_path TEXT,
|
|
151
153
|
failure_reason TEXT,
|
|
152
154
|
announcement TEXT,
|
|
155
|
+
release_url TEXT,
|
|
153
156
|
approved_by INTEGER,
|
|
154
157
|
queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
155
158
|
building_at DATETIME,
|
|
@@ -235,6 +238,17 @@ class PublisherModule {
|
|
|
235
238
|
});
|
|
236
239
|
});
|
|
237
240
|
}
|
|
241
|
+
if (!columnNames.includes('release_url')) {
|
|
242
|
+
await new Promise((resolve, reject) => {
|
|
243
|
+
this.db.run('ALTER TABLE tasks ADD COLUMN release_url TEXT', (err) => {
|
|
244
|
+
if (err) reject(err);
|
|
245
|
+
else {
|
|
246
|
+
this.logger.info('Migration: added release_url column to tasks table');
|
|
247
|
+
resolve();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
238
252
|
const websiteColumns = await new Promise((resolve, reject) => {
|
|
239
253
|
this.db.all("PRAGMA table_info(websites)", (err, rows) => {
|
|
240
254
|
if (err) reject(err);
|
|
@@ -722,7 +736,7 @@ class PublisherModule {
|
|
|
722
736
|
|
|
723
737
|
async cloneRepository(task, draftDir) {
|
|
724
738
|
const { spawn } = require('child_process');
|
|
725
|
-
const gitUrl =
|
|
739
|
+
const gitUrl = gitRemote.githubUrl(task.github_org, task.github_repo);
|
|
726
740
|
|
|
727
741
|
await this.logTaskMessage(task.id, 'info', 'Cloning repository: ' + gitUrl + ' (branch: ' + task.git_branch + ')');
|
|
728
742
|
|
|
@@ -734,7 +748,10 @@ class PublisherModule {
|
|
|
734
748
|
gitUrl,
|
|
735
749
|
draftDir
|
|
736
750
|
], {
|
|
737
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
751
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
752
|
+
// no terminal prompt: a missing repo looks like a private one to GitHub, and
|
|
753
|
+
// git would otherwise ask for a username (see git-remote.js)
|
|
754
|
+
env: gitRemote.gitEnv()
|
|
738
755
|
});
|
|
739
756
|
|
|
740
757
|
let stderr = '';
|
|
@@ -748,7 +765,10 @@ class PublisherModule {
|
|
|
748
765
|
await this.logTaskMessage(task.id, 'info', 'Repository cloned successfully');
|
|
749
766
|
resolve();
|
|
750
767
|
} else {
|
|
751
|
-
const
|
|
768
|
+
const explained = gitRemote.explainCloneFailure(stderr, task.github_org, task.github_repo, task.git_branch);
|
|
769
|
+
const error = explained
|
|
770
|
+
? 'Git clone failed: ' + explained
|
|
771
|
+
: 'Git clone failed with code ' + code + ': ' + stderr;
|
|
752
772
|
await this.logTaskMessage(task.id, 'error', error);
|
|
753
773
|
reject(new Error(error));
|
|
754
774
|
}
|
|
@@ -868,6 +888,28 @@ class PublisherModule {
|
|
|
868
888
|
errors.push('Version mismatch: task specifies "' + task.version + '" but build produced "' + qaData['ig-ver'] + '"');
|
|
869
889
|
}
|
|
870
890
|
|
|
891
|
+
// publication-request.json is what the publication run actually follows - it decides the
|
|
892
|
+
// version folder the IG is published into. If it wasn't updated along with the IG, the run
|
|
893
|
+
// publishes the new content over the previous release's folder, so catch it here, before the
|
|
894
|
+
// task can be approved.
|
|
895
|
+
const prPath = path.join(draftDir, 'publication-request.json');
|
|
896
|
+
if (fs.existsSync(prPath)) {
|
|
897
|
+
let pr = null;
|
|
898
|
+
try {
|
|
899
|
+
pr = JSON.parse(fs.readFileSync(prPath, 'utf8'));
|
|
900
|
+
} catch (e) {
|
|
901
|
+
errors.push('publication-request.json could not be parsed: ' + e.message);
|
|
902
|
+
}
|
|
903
|
+
if (pr) {
|
|
904
|
+
if (pr['package-id'] !== task.npm_package_id) {
|
|
905
|
+
errors.push('publication-request.json package-id is "' + pr['package-id'] + '" but the task specifies "' + task.npm_package_id + '"');
|
|
906
|
+
}
|
|
907
|
+
if (pr.version !== task.version) {
|
|
908
|
+
errors.push('publication-request.json version is "' + pr.version + '" but the task specifies "' + task.version + '" - update publication-request.json in the IG');
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
871
913
|
if (errors.length > 0) {
|
|
872
914
|
for (const err of errors) {
|
|
873
915
|
await this.logTaskMessage(task.id, 'error', err);
|
|
@@ -950,10 +992,14 @@ class PublisherModule {
|
|
|
950
992
|
if (typeof json.url === 'string' && json.url.startsWith('file:')) {
|
|
951
993
|
problems.push('url is a local file path (' + json.url + ')');
|
|
952
994
|
}
|
|
995
|
+
if (json.version !== task.version) {
|
|
996
|
+
problems.push('its version is ' + json.version + ', not ' + task.version);
|
|
997
|
+
}
|
|
953
998
|
if (problems.length > 0) {
|
|
954
|
-
throw new Error('Published package ' + pkgPath + ' is
|
|
955
|
-
problems.join('; ') + ').
|
|
956
|
-
'regeneration (e.g. a Jekyll/template
|
|
999
|
+
throw new Error('Published package ' + pkgPath + ' is not the publication build of ' +
|
|
1000
|
+
task.npm_package_id + '#' + task.version + ' (' + problems.join('; ') + '). ' +
|
|
1001
|
+
'Either the IG Publisher publication run skipped package regeneration (e.g. a Jekyll/template ' +
|
|
1002
|
+
'failure), or publication-request.json points at another version. Not committing.');
|
|
957
1003
|
}
|
|
958
1004
|
await this.logTaskMessage(task.id, 'info', 'Verified publication package: ' + pkgPath);
|
|
959
1005
|
}
|
|
@@ -1203,15 +1249,24 @@ class PublisherModule {
|
|
|
1203
1249
|
// Step 7: Commit and push the ig-registry
|
|
1204
1250
|
await this.logTaskMessage(task.id, 'info', 'Committing changes to ig-registry...');
|
|
1205
1251
|
const registryCommitMsg = 'publish ' + task.npm_package_id + '#' + task.version;
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
await this.runCommand('git', ['
|
|
1252
|
+
// A republication of a version the registry already lists (a technical correction) leaves the
|
|
1253
|
+
// registry as it was, and 'git commit' fails on a clean tree - which is not an error here.
|
|
1254
|
+
const registryStatus = await this.runCommand('git', ['status', '--porcelain', '--untracked-files=no'],
|
|
1255
|
+
{ cwd: registryDir }, task.id, 'Checking for ig-registry changes');
|
|
1256
|
+
if (registryStatus.trim() === '') {
|
|
1257
|
+
await this.logTaskMessage(task.id, 'warn', 'The publication run made no changes to the ig-registry - nothing to commit');
|
|
1258
|
+
} else {
|
|
1259
|
+
await this.runCommand('git', ['commit', '-a', '-m', registryCommitMsg], { cwd: registryDir }, task.id, 'Committing ig-registry changes');
|
|
1260
|
+
await this.runCommand('git', ['pull'], { cwd: registryDir }, task.id, 'Pulling latest ig-registry');
|
|
1261
|
+
await this.runCommand('git', ['push'], { cwd: registryDir }, task.id, 'Pushing ig-registry changes');
|
|
1262
|
+
}
|
|
1209
1263
|
|
|
1210
1264
|
// Step 8: Read the announcement text and store it in the database
|
|
1265
|
+
let announcement = null;
|
|
1211
1266
|
const announcementPath = path.join(zipsDir, task.npm_package_id + '#' + task.version + '-announcement.txt');
|
|
1212
1267
|
if (fs.existsSync(announcementPath)) {
|
|
1213
1268
|
try {
|
|
1214
|
-
|
|
1269
|
+
announcement = fs.readFileSync(announcementPath, 'utf8');
|
|
1215
1270
|
await this.updateTaskFields(task.id, { announcement: announcement });
|
|
1216
1271
|
await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
|
|
1217
1272
|
} catch (err) {
|
|
@@ -1221,6 +1276,9 @@ class PublisherModule {
|
|
|
1221
1276
|
await this.logTaskMessage(task.id, 'warn', 'No announcement file found at ' + announcementPath);
|
|
1222
1277
|
}
|
|
1223
1278
|
|
|
1279
|
+
// Step 8b: Tag the source repository and publish a GitHub release carrying its source zip
|
|
1280
|
+
await this.createSourceRelease(task, draftDir, zipsDir, announcement);
|
|
1281
|
+
|
|
1224
1282
|
// Step 9: Run the website update script
|
|
1225
1283
|
if (website.server_update_script) {
|
|
1226
1284
|
await this.logTaskMessage(task.id, 'info', 'Running website update script: ' + website.server_update_script);
|
|
@@ -1228,6 +1286,60 @@ class PublisherModule {
|
|
|
1228
1286
|
}
|
|
1229
1287
|
}
|
|
1230
1288
|
|
|
1289
|
+
// Tag the IG source repository and publish a GitHub release with a zip of the source at the
|
|
1290
|
+
// commit that was published. Runs only when a GitHub App is configured; without one there is no
|
|
1291
|
+
// credential that can write to the source repository, and the step is skipped.
|
|
1292
|
+
//
|
|
1293
|
+
// Nothing here is allowed to fail the task. By the time this runs the IG is published, the web
|
|
1294
|
+
// folder is pushed and the registry is updated - a release that did not get made is worth a
|
|
1295
|
+
// warning in the log and nothing more.
|
|
1296
|
+
async createSourceRelease(task, draftDir, zipsDir, announcement) {
|
|
1297
|
+
const appConfig = this.config && this.config['github-app'];
|
|
1298
|
+
if (!githubRelease.isConfigured(appConfig)) {
|
|
1299
|
+
await this.logTaskMessage(task.id, 'info',
|
|
1300
|
+
'No GitHub App configured - skipping the source tag and release');
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
try {
|
|
1305
|
+
// The draft clone is the exact tree that was published, so its HEAD is the commit the
|
|
1306
|
+
// release should point at.
|
|
1307
|
+
const sha = (await this.runCommand('git', ['rev-parse', 'HEAD'], { cwd: draftDir },
|
|
1308
|
+
task.id, 'Reading the published commit')).trim();
|
|
1309
|
+
|
|
1310
|
+
// git archive takes the committed state, not the working directory: by now the publisher has
|
|
1311
|
+
// built in this folder twice and left output/, temp/, template/ and fsh-generated/ behind,
|
|
1312
|
+
// none of which are in the archive. It is also exactly reproducible from the tag.
|
|
1313
|
+
const stem = task.npm_package_id + '-' + task.version;
|
|
1314
|
+
const assetName = stem + '-source.zip';
|
|
1315
|
+
const zipPath = path.join(zipsDir, assetName);
|
|
1316
|
+
await this.runCommand('git',
|
|
1317
|
+
['archive', '--format=zip', '--prefix=' + stem + '/', '-o', zipPath, 'HEAD'],
|
|
1318
|
+
{ cwd: draftDir }, task.id, 'Building the source zip');
|
|
1319
|
+
|
|
1320
|
+
const result = await githubRelease.publishRelease({
|
|
1321
|
+
config: appConfig,
|
|
1322
|
+
org: task.github_org,
|
|
1323
|
+
repo: task.github_repo,
|
|
1324
|
+
tag: 'v' + task.version,
|
|
1325
|
+
sha: sha,
|
|
1326
|
+
releaseName: task.npm_package_id + '#' + task.version,
|
|
1327
|
+
body: announcement || '',
|
|
1328
|
+
zipPath: zipPath,
|
|
1329
|
+
assetName: assetName,
|
|
1330
|
+
// logTaskMessage never rejects, and github-release logs without waiting
|
|
1331
|
+
log: (level, message) => { void this.logTaskMessage(task.id, level, message); }
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
await this.updateTaskFields(task.id, { release_url: result.url });
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
const detail = (err.response && err.response.data && err.response.data.message) || err.message;
|
|
1337
|
+
await this.logTaskMessage(task.id, 'warn',
|
|
1338
|
+
'Could not tag and release the source repository: ' + detail +
|
|
1339
|
+
'. The IG itself is published - this does not affect it.');
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1231
1343
|
async runPublisherGoPublish(taskId, publisherJar, sourceDir, webDir, registryDir, historyDir, templatesDir, zipsDir, logFile) {
|
|
1232
1344
|
const { spawn } = require('child_process');
|
|
1233
1345
|
|
|
@@ -1692,6 +1804,18 @@ class PublisherModule {
|
|
|
1692
1804
|
return res.status(400).send('An active task for this package and version is already in progress. Wait for it to complete or fail before resubmitting.');
|
|
1693
1805
|
}
|
|
1694
1806
|
|
|
1807
|
+
// Check the repo and branch actually exist before queueing, so a typo is
|
|
1808
|
+
// reported now rather than when the build starts. If GitHub can't be
|
|
1809
|
+
// reached, don't block - the clone will report any real problem.
|
|
1810
|
+
const remote = await gitRemote.checkRemoteBranch(github_org, github_repo, git_branch);
|
|
1811
|
+
if (!remote.ok) {
|
|
1812
|
+
if (remote.unreachable) {
|
|
1813
|
+
this.logger.warn('Could not verify ' + github_org + '/' + github_repo + '#' + git_branch + ' before queueing: ' + remote.error);
|
|
1814
|
+
} else {
|
|
1815
|
+
return res.status(400).send('Invalid task details: ' + remote.error);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1695
1819
|
// Insert task (ID will be auto-generated)
|
|
1696
1820
|
const result = await new Promise((resolve, reject) => {
|
|
1697
1821
|
this.db.run(
|
|
@@ -1908,6 +2032,9 @@ class PublisherModule {
|
|
|
1908
2032
|
let content = '<h3>Task Output: #' + task.id + ' - ' + escape(task.npm_package_id) + '#' + escape(task.version) + '</h3>';
|
|
1909
2033
|
content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + escape(task.status) + '</span></p>';
|
|
1910
2034
|
content += '<p><strong>GitHub:</strong> ' + escape(task.github_org) + '/' + escape(task.github_repo) + ' (' + escape(task.git_branch) + ')</p>';
|
|
2035
|
+
if (task.release_url) {
|
|
2036
|
+
content += '<p><strong>Release:</strong> <a href="' + escape(task.release_url) + '" target="_blank" rel="noopener noreferrer">' + escape(task.release_url) + '</a></p>';
|
|
2037
|
+
}
|
|
1911
2038
|
|
|
1912
2039
|
if (task.publisher_version) {
|
|
1913
2040
|
content += '<p><strong>IG Publisher:</strong> <code>' + escape(task.publisher_version) + '</code></p>';
|
|
@@ -2053,6 +2180,9 @@ class PublisherModule {
|
|
|
2053
2180
|
content += '</div>';
|
|
2054
2181
|
content += '<div class="col-md-6">';
|
|
2055
2182
|
content += '<p><strong>GitHub:</strong> ' + escape(task.github_org) + '/' + escape(task.github_repo) + ' (' + escape(task.git_branch) + ')</p>';
|
|
2183
|
+
if (task.release_url) {
|
|
2184
|
+
content += '<p><strong>Release:</strong> <a href="' + escape(task.release_url) + '" target="_blank" rel="noopener noreferrer">' + escape(task.release_url) + '</a></p>';
|
|
2185
|
+
}
|
|
2056
2186
|
content += '<p><strong>Created by:</strong> ' + escape(task.user_name) + ' (' + escape(task.user_login) + ')</p>';
|
|
2057
2187
|
if (task.approved_by_name) {
|
|
2058
2188
|
content += '<p><strong>Approved by:</strong> ' + escape(task.approved_by_name) + '</p>';
|
|
@@ -2747,10 +2877,12 @@ class PublisherModule {
|
|
|
2747
2877
|
await this.logTaskMessage(taskId, 'info', description);
|
|
2748
2878
|
|
|
2749
2879
|
return new Promise((resolve, reject) => {
|
|
2750
|
-
const
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2880
|
+
const spawnOptions = { stdio: ['pipe', 'pipe', 'pipe'], ...options };
|
|
2881
|
+
if (command === 'git') {
|
|
2882
|
+
// never let git block on (or fail obscurely at) a credentials prompt
|
|
2883
|
+
spawnOptions.env = gitRemote.gitEnv(spawnOptions.env || process.env);
|
|
2884
|
+
}
|
|
2885
|
+
const proc = spawn(command, args, spawnOptions);
|
|
2754
2886
|
|
|
2755
2887
|
let stdout = '';
|
|
2756
2888
|
let stderr = '';
|
package/server.js
CHANGED
|
@@ -16,6 +16,7 @@ const folders = require('./library/folder-setup'); // <-- ADD: load early
|
|
|
16
16
|
const { statSync, readdirSync } = require('fs');
|
|
17
17
|
const escape = require('escape-html');
|
|
18
18
|
const { resolveWithin } = require('./library/path-safety');
|
|
19
|
+
const { readCgroupMemoryLimit } = require('./library/cgroup-memory');
|
|
19
20
|
|
|
20
21
|
// Load configuration BEFORE logger
|
|
21
22
|
let config;
|
|
@@ -39,6 +40,12 @@ serverLog.info(`========================================`);
|
|
|
39
40
|
serverLog.info(`FHIRsmith v${packageJson.version} starting (PID ${process.pid})`);
|
|
40
41
|
serverLog.info(`Node.js ${process.version} on ${os.type()} ${os.release()} (${os.arch()})`);
|
|
41
42
|
serverLog.info(`Memory: ${freeMemGB} GB free / ${totalMemGB} GB total`);
|
|
43
|
+
{
|
|
44
|
+
const cg = readCgroupMemoryLimit();
|
|
45
|
+
serverLog.info(cg.limit > 0
|
|
46
|
+
? `Memory limit: ${(cg.limit / 1024 / 1024 / 1024).toFixed(1)} GB (from ${cg.source})`
|
|
47
|
+
: 'Memory limit: none found in cgroup - RSS guard disabled');
|
|
48
|
+
}
|
|
42
49
|
serverLog.info(`Data directory: ${folders.dataDir()}`);
|
|
43
50
|
serverLog.info(`========================================`);
|
|
44
51
|
|
|
@@ -57,6 +64,7 @@ const PublisherModule = require('./publisher/publisher.js');
|
|
|
57
64
|
const TokenModule = require('./token/token.js');
|
|
58
65
|
const NpmProjectorModule = require('./npmprojector/npmprojector.js');
|
|
59
66
|
const TXModule = require('./tx/tx.js');
|
|
67
|
+
const TestingModule = require('./testing/testing.js');
|
|
60
68
|
|
|
61
69
|
const htmlServer = require('./library/html-server');
|
|
62
70
|
const ServerStats = require("./stats");
|
|
@@ -219,6 +227,18 @@ async function initializeModules() {
|
|
|
219
227
|
throw error;
|
|
220
228
|
}
|
|
221
229
|
}
|
|
230
|
+
if (config.modules?.testing?.enabled) {
|
|
231
|
+
try {
|
|
232
|
+
serverLog.info('Initializing module: testing...');
|
|
233
|
+
modules.testing = new TestingModule(stats.forModule('testing'));
|
|
234
|
+
await modules.testing.initialize(config.modules.testing);
|
|
235
|
+
app.use('/testing', modules.testing.router);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
serverLog.error('Failed to initialize testing module:', error);
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
222
242
|
// Initialize TX module
|
|
223
243
|
// Note: TX module registers its own endpoints directly on the app
|
|
224
244
|
// because it supports multiple endpoints at different paths
|
|
@@ -346,6 +366,13 @@ async function buildRootPageContent() {
|
|
|
346
366
|
content += '</li>';
|
|
347
367
|
}
|
|
348
368
|
|
|
369
|
+
if (config.modules?.testing?.enabled) {
|
|
370
|
+
content += '<li class="list-group-item">';
|
|
371
|
+
content += '<a href="/testing" class="text-decoration-none">Test Reports</a>: ';
|
|
372
|
+
content += 'TestReports submitted by TxTester and other test tools';
|
|
373
|
+
content += '</li>';
|
|
374
|
+
}
|
|
375
|
+
|
|
349
376
|
if (config.modules.folder && config.modules.folder.enabled) {
|
|
350
377
|
content += '<li class="list-group-item">';
|
|
351
378
|
content += '<strong>Cache Folder</strong>: ';
|
|
@@ -405,13 +432,7 @@ async function buildRootPageContent() {
|
|
|
405
432
|
|
|
406
433
|
// Process RSS vs cgroup limit (or system total)
|
|
407
434
|
const rssMB = (memUsage.rss / 1024 / 1024).toFixed(0);
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
const raw = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
|
|
411
|
-
memLimit = raw === 'max' ? os.totalmem() : parseInt(raw);
|
|
412
|
-
} catch {
|
|
413
|
-
memLimit = os.totalmem();
|
|
414
|
-
}
|
|
435
|
+
const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
|
|
415
436
|
const memLimitMB = (memLimit / 1024 / 1024).toFixed(0);
|
|
416
437
|
const processPCT = (memUsage.rss * 100) / memLimit;
|
|
417
438
|
|
|
@@ -440,6 +461,15 @@ async function buildRootPageContent() {
|
|
|
440
461
|
content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
|
|
441
462
|
content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
|
|
442
463
|
content += '</tr>';
|
|
464
|
+
const closure = stats.closureStats();
|
|
465
|
+
if (closure) {
|
|
466
|
+
content += '<tr>';
|
|
467
|
+
content += `<td><strong>Closure Tables:</strong> ${closure.tables} tables` +
|
|
468
|
+
(closure.retentionDays ? ` (unused for ${closure.retentionDays} days = deleted)` : '') + `</td>`;
|
|
469
|
+
content += `<td><strong>Closure Entries:</strong> ${closure.entries} entries / ${closure.concepts} concepts</td>`;
|
|
470
|
+
content += `<td><strong>Closure Database:</strong> ${closure.bytes === null ? 'in memory' : formatBytes(closure.bytes)}</td>`;
|
|
471
|
+
content += '</tr>';
|
|
472
|
+
}
|
|
443
473
|
content += getLogStats();
|
|
444
474
|
content += '</table>';
|
|
445
475
|
|
|
@@ -591,13 +621,7 @@ app.get('/dashboard', async (req, res) => {
|
|
|
591
621
|
const v8PCT = (memUsage.heapUsed * 100) / heapStats.heap_size_limit; // % of V8 heap limit used
|
|
592
622
|
|
|
593
623
|
// Process RSS as % of cgroup memory limit (or system total as fallback)
|
|
594
|
-
|
|
595
|
-
try {
|
|
596
|
-
const raw = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
|
|
597
|
-
memLimit = raw === 'max' ? os.totalmem() : parseInt(raw);
|
|
598
|
-
} catch {
|
|
599
|
-
memLimit = os.totalmem();
|
|
600
|
-
}
|
|
624
|
+
const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
|
|
601
625
|
const processPCT = (memUsage.rss * 100) / memLimit;
|
|
602
626
|
|
|
603
627
|
const totalMemBytes = os.totalmem();
|
|
@@ -649,6 +673,19 @@ app.get('/dashboard', async (req, res) => {
|
|
|
649
673
|
}
|
|
650
674
|
});
|
|
651
675
|
|
|
676
|
+
function formatBytes(bytes) {
|
|
677
|
+
if (bytes < 1024) {
|
|
678
|
+
return `${bytes} bytes`;
|
|
679
|
+
}
|
|
680
|
+
if (bytes < 1024 * 1024) {
|
|
681
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
682
|
+
}
|
|
683
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
684
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
685
|
+
}
|
|
686
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
687
|
+
}
|
|
688
|
+
|
|
652
689
|
function pctColor(pct) {
|
|
653
690
|
// Gradient from green (#deffe0) at 0% to red (#ffd3d1) at 100%
|
|
654
691
|
const t = Math.max(0, Math.min(100, pct)) / 100;
|
package/stats.js
CHANGED
|
@@ -124,20 +124,13 @@ class ServerStats {
|
|
|
124
124
|
// event-loop block into noise; the max is what shows request-freezing
|
|
125
125
|
// stalls (see the 2026-07-30 tx.fhir.org incident).
|
|
126
126
|
const loopMax = this.eventLoopMonitor.max / 1e6;
|
|
127
|
-
//
|
|
128
|
-
// (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (typeof m.expansionItemCount === 'function') {
|
|
133
|
-
expansionItems = expansionItems + m.expansionItemCount();
|
|
134
|
-
}
|
|
135
|
-
if (typeof m.clientConceptCount === 'function') {
|
|
136
|
-
clientConcepts = clientConcepts + m.clientConceptCount();
|
|
137
|
-
}
|
|
138
|
-
}
|
|
127
|
+
// The client (resource) cache, as concepts held - a sense of how much it's carrying.
|
|
128
|
+
// (The expansion cache isn't tracked over time: it fills to its limit over a few
|
|
129
|
+
// days and then sits there, so a history of it says nothing. The home page still
|
|
130
|
+
// shows its current size.)
|
|
131
|
+
const clientConcepts = this._sumCachingModules('clientConceptCount');
|
|
139
132
|
|
|
140
|
-
this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, blockMax: loopMax,
|
|
133
|
+
this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, blockMax: loopMax, clientConcepts: clientConcepts});
|
|
141
134
|
|
|
142
135
|
this.eventLoopMonitor.reset();
|
|
143
136
|
this.requestCountSnapshot = combinedCount;
|
|
@@ -328,6 +321,19 @@ class ServerStats {
|
|
|
328
321
|
maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); }
|
|
329
322
|
maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); }
|
|
330
323
|
|
|
324
|
+
// $closure usage, from whichever module keeps closure tables; null if none does
|
|
325
|
+
closureStats() {
|
|
326
|
+
for (let m of this.cachingModules) {
|
|
327
|
+
if (typeof m.closureStats === 'function') {
|
|
328
|
+
const s = m.closureStats();
|
|
329
|
+
if (s) {
|
|
330
|
+
return s;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
331
337
|
getTaskColor(status) {
|
|
332
338
|
switch (status) {
|
|
333
339
|
case "started": return "LightGrey";
|