fhirsmith 0.9.6 → 0.10.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 +51 -0
- package/library/folder-content-loader.js +91 -0
- package/npmprojector/npmprojector.js +2 -6
- package/package.json +1 -1
- package/packages/package-crawler.js +123 -6
- package/packages/packages.js +104 -0
- package/publisher/publisher.js +290 -12
- package/registry/crawler.js +85 -6
- package/registry/registry.js +24 -7
- package/server.js +11 -2
- package/stats.js +26 -18
- package/translations/Messages.properties +2 -1
- package/tx/cs/cs-cs.js +8 -0
- package/tx/cs/cs-loinc.js +1 -0
- package/tx/cs/cs-provider-list.js +2 -1
- package/tx/cs/cs-snomed.js +142 -59
- package/tx/data/snomed-testing.cache +0 -0
- package/tx/html/home-metrics.liquid +10 -10
- package/tx/library/canonical-resource.js +4 -2
- package/tx/library/codesystem.js +31 -0
- package/tx/library/conceptmap.js +24 -0
- package/tx/library/designations.js +27 -20
- package/tx/library/renderer.js +303 -22
- package/tx/library/ucum-types.js +4 -1
- package/tx/library/valueset.js +46 -0
- package/tx/library.js +65 -21
- package/tx/operation-context.js +122 -27
- package/tx/params.js +36 -8
- package/tx/provider.js +6 -3
- package/tx/tx-html.js +34 -0
- package/tx/tx.js +92 -30
- package/tx/vs/vs-vsac.js +157 -9
- package/tx/workers/cache-control.js +186 -0
- package/tx/workers/{related.js → compare.js} +27 -27
- package/tx/workers/expand.js +100 -96
- package/tx/workers/lookup.js +6 -0
- package/tx/workers/metadata.js +11 -6
- package/tx/workers/read.js +1 -1
- package/tx/workers/translate.js +20 -29
- package/tx/workers/validate.js +18 -10
- package/tx/workers/worker.js +134 -47
- package/tx/xversion/xv-bundle.js +1 -2
- package/tx/xversion/xv-codesystem.js +5 -2
- package/tx/xversion/xv-parameters.js +4 -4
- package/tx/xversion/xv-resource.js +2 -2
- package/tx/xversion/xv-terminologyCapabilities.js +11 -6
- package/tx/xversion/xv-valueset.js +7 -7
- package/publisher/task-draft.js +0 -463
- package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
package/publisher/publisher.js
CHANGED
|
@@ -17,6 +17,7 @@ class PublisherModule {
|
|
|
17
17
|
this.logger = null;
|
|
18
18
|
this.taskProcessor = null;
|
|
19
19
|
this.isProcessing = false;
|
|
20
|
+
this.activeTaskIds = new Set();
|
|
20
21
|
this.shutdownRequested = false;
|
|
21
22
|
this.stats = stats;
|
|
22
23
|
}
|
|
@@ -199,6 +200,17 @@ class PublisherModule {
|
|
|
199
200
|
});
|
|
200
201
|
});
|
|
201
202
|
}
|
|
203
|
+
if (!columnNames.includes('publisher_version')) {
|
|
204
|
+
await new Promise((resolve, reject) => {
|
|
205
|
+
this.db.run('ALTER TABLE tasks ADD COLUMN publisher_version TEXT', (err) => {
|
|
206
|
+
if (err) reject(err);
|
|
207
|
+
else {
|
|
208
|
+
this.logger.info('Migration: added publisher_version column to tasks table');
|
|
209
|
+
resolve();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
202
214
|
const websiteColumns = await new Promise((resolve, reject) => {
|
|
203
215
|
this.db.all("PRAGMA table_info(websites)", (err, rows) => {
|
|
204
216
|
if (err) reject(err);
|
|
@@ -308,13 +320,19 @@ class PublisherModule {
|
|
|
308
320
|
if (this.shutdownRequested) return;
|
|
309
321
|
|
|
310
322
|
if (this.isProcessing) {
|
|
323
|
+
// A publication can legitimately run for longer than an hour (go-publish on a
|
|
324
|
+
// large IG), so never reset isProcessing while a task is in flight — doing so
|
|
325
|
+
// lets the poller start a second, concurrent run of the same task, which then
|
|
326
|
+
// fails on 'ig-registry already exists' and stomps the real run's status.
|
|
327
|
+
// Recovery from a genuinely hung IG Publisher is handled by the
|
|
328
|
+
// igPublisherTimeoutMinutes kill in runIgPublisher/runPublisherGoPublish.
|
|
311
329
|
const stuckMs = this.isProcessingStarted ? Date.now() - this.isProcessingStarted : 0;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
this.
|
|
315
|
-
|
|
316
|
-
return;
|
|
330
|
+
const warnAfterMs = ((this.config.igPublisherTimeoutMinutes || 60) + 30) * 60 * 1000;
|
|
331
|
+
if (stuckMs > warnAfterMs) {
|
|
332
|
+
this.logger.warn('Task processor still busy after ' + Math.round(stuckMs / 60000) + ' min (tasks: ' +
|
|
333
|
+
Array.from(this.activeTaskIds).join(', ') + ') — possible hang, not starting new work');
|
|
317
334
|
}
|
|
335
|
+
return;
|
|
318
336
|
}
|
|
319
337
|
|
|
320
338
|
await this.processNextTask();
|
|
@@ -325,10 +343,14 @@ class PublisherModule {
|
|
|
325
343
|
this.isProcessing = true;
|
|
326
344
|
this.isProcessingStarted = Date.now();
|
|
327
345
|
|
|
346
|
+
let claimedTaskId = null;
|
|
328
347
|
try {
|
|
329
348
|
// Look for queued tasks first (draft builds)
|
|
330
349
|
let task = await this.getNextQueuedTask();
|
|
331
350
|
if (task) {
|
|
351
|
+
if (this.activeTaskIds.has(task.id)) return; // already being processed
|
|
352
|
+
claimedTaskId = task.id;
|
|
353
|
+
this.activeTaskIds.add(task.id);
|
|
332
354
|
this.stats.task('Publisher', 'Building ' + task.npm_package_id + '#' + task.version);
|
|
333
355
|
await this.processDraftBuild(task);
|
|
334
356
|
this.stats.taskDone('Publisher', 'Built ' + task.npm_package_id + '#' + task.version);
|
|
@@ -338,6 +360,9 @@ class PublisherModule {
|
|
|
338
360
|
// Then look for approved tasks (publishing)
|
|
339
361
|
task = await this.getNextApprovedTask();
|
|
340
362
|
if (task) {
|
|
363
|
+
if (this.activeTaskIds.has(task.id)) return; // already being processed
|
|
364
|
+
claimedTaskId = task.id;
|
|
365
|
+
this.activeTaskIds.add(task.id);
|
|
341
366
|
this.stats.task('Publisher', 'Publishing ' + task.npm_package_id + '#' + task.version);
|
|
342
367
|
await this.processPublication(task);
|
|
343
368
|
this.stats.taskDone('Publisher', 'Published ' + task.npm_package_id + '#' + task.version);
|
|
@@ -349,6 +374,9 @@ class PublisherModule {
|
|
|
349
374
|
this.logger.error('Error in task processor:', error);
|
|
350
375
|
this.stats.taskError('Publisher', 'Error: ' + error.message);
|
|
351
376
|
} finally {
|
|
377
|
+
if (claimedTaskId !== null) {
|
|
378
|
+
this.activeTaskIds.delete(claimedTaskId);
|
|
379
|
+
}
|
|
352
380
|
this.isProcessing = false;
|
|
353
381
|
}
|
|
354
382
|
}
|
|
@@ -390,6 +418,9 @@ class PublisherModule {
|
|
|
390
418
|
fields.push('waiting_approval_at = CURRENT_TIMESTAMP');
|
|
391
419
|
} else if (status === 'complete') {
|
|
392
420
|
fields.push('completed_at = CURRENT_TIMESTAMP');
|
|
421
|
+
// A successful completion invalidates any earlier failure (e.g. from a
|
|
422
|
+
// superseded duplicate run) — don't let a stale message outlive success.
|
|
423
|
+
fields.push('failure_reason = NULL');
|
|
393
424
|
} else if (status === 'failed') {
|
|
394
425
|
fields.push('failed_at = CURRENT_TIMESTAMP');
|
|
395
426
|
}
|
|
@@ -414,6 +445,28 @@ class PublisherModule {
|
|
|
414
445
|
});
|
|
415
446
|
}
|
|
416
447
|
|
|
448
|
+
// Update arbitrary task fields WITHOUT touching status. Use this from long-running
|
|
449
|
+
// work (e.g. saving the announcement mid-publication) — writing back the in-memory
|
|
450
|
+
// task.status can resurrect a stale status and cause the poller to re-run the task.
|
|
451
|
+
async updateTaskFields(taskId, fields) {
|
|
452
|
+
const keys = Object.keys(fields);
|
|
453
|
+
if (keys.length === 0) return;
|
|
454
|
+
const assignments = keys.map(key => key + ' = ?');
|
|
455
|
+
const values = keys.map(key => fields[key]);
|
|
456
|
+
values.push(taskId);
|
|
457
|
+
|
|
458
|
+
return new Promise((resolve, reject) => {
|
|
459
|
+
this.db.run(
|
|
460
|
+
'UPDATE tasks SET ' + assignments.join(', ') + ' WHERE id = ?',
|
|
461
|
+
values,
|
|
462
|
+
(err) => {
|
|
463
|
+
if (err) reject(err);
|
|
464
|
+
else resolve();
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
417
470
|
async logTaskMessage(taskId, level, message) {
|
|
418
471
|
return new Promise((resolve) => {
|
|
419
472
|
this.db.run(
|
|
@@ -500,6 +553,11 @@ class PublisherModule {
|
|
|
500
553
|
// Step 3: Clone GitHub repository
|
|
501
554
|
await this.cloneRepository(task, draftDir);
|
|
502
555
|
|
|
556
|
+
// Step 3b: Update SUSHI to the latest release. The IG Publisher runs SUSHI with
|
|
557
|
+
// --require-latest, so a SUSHI that has fallen behind npm's latest makes the build
|
|
558
|
+
// fail. Refreshing it here keeps draft builds working.
|
|
559
|
+
await this.ensureLatestSushi(task.id);
|
|
560
|
+
|
|
503
561
|
// Step 4: Run IG publisher
|
|
504
562
|
await this.runIGPublisher(publisherJar, draftDir, logFile, task.id);
|
|
505
563
|
|
|
@@ -536,6 +594,16 @@ class PublisherModule {
|
|
|
536
594
|
throw new Error('Could not find publisher.jar in latest release');
|
|
537
595
|
}
|
|
538
596
|
|
|
597
|
+
// Record which IG Publisher version this task is using. The same jar is reused
|
|
598
|
+
// for the publication run, so this is the version that produced the output.
|
|
599
|
+
const publisherVersion = releaseResponse.data.tag_name || releaseResponse.data.name || 'unknown';
|
|
600
|
+
try {
|
|
601
|
+
await this.updateTaskFields(taskId, { publisher_version: publisherVersion });
|
|
602
|
+
} catch (e) {
|
|
603
|
+
this.logger.warn('Failed to record publisher_version for task ' + taskId + ': ' + e.message);
|
|
604
|
+
}
|
|
605
|
+
await this.logTaskMessage(taskId, 'info', 'Using IG Publisher version ' + publisherVersion);
|
|
606
|
+
|
|
539
607
|
await this.logTaskMessage(taskId, 'info', 'Downloading from: ' + downloadUrl);
|
|
540
608
|
|
|
541
609
|
// Download the file
|
|
@@ -561,6 +629,62 @@ class PublisherModule {
|
|
|
561
629
|
}
|
|
562
630
|
}
|
|
563
631
|
|
|
632
|
+
// Directory holding FHIRsmith's own copy of SUSHI. Installing here (rather than -g)
|
|
633
|
+
// needs no root: on the server the global npm prefix (/usr) is root-owned.
|
|
634
|
+
sushiDir() {
|
|
635
|
+
return folders.filePath('publisher', 'sushi');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Environment for spawning the IG Publisher so it finds our managed SUSHI on PATH.
|
|
639
|
+
// The publisher resolves the `sushi` command from PATH, so prepending our bin dir
|
|
640
|
+
// makes it use the version we just installed.
|
|
641
|
+
publisherEnv() {
|
|
642
|
+
const binDir = path.join(this.sushiDir(), 'bin');
|
|
643
|
+
return Object.assign({}, process.env, {
|
|
644
|
+
PATH: binDir + path.delimiter + (process.env.PATH || '')
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Update FHIRsmith's managed SUSHI to the latest npm release before a run. The IG
|
|
649
|
+
// Publisher invokes SUSHI with --require-latest, so a SUSHI that has fallen behind
|
|
650
|
+
// npm's latest makes the build abort. We install into a FHIRsmith-owned prefix
|
|
651
|
+
// (sushiDir) to avoid needing root for a global install, and the publisher picks it
|
|
652
|
+
// up via publisherEnv(). Best-effort: a failure is logged but not fatal - the
|
|
653
|
+
// corrected runBuild check in the IG Publisher now turns a stale SUSHI into a loud
|
|
654
|
+
// publication failure rather than a silently-published draft.
|
|
655
|
+
async ensureLatestSushi(taskId) {
|
|
656
|
+
const { spawn } = require('child_process');
|
|
657
|
+
const dir = this.sushiDir();
|
|
658
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
659
|
+
await this.logTaskMessage(taskId, 'info', 'Ensuring SUSHI is up to date in ' + dir + ' ...');
|
|
660
|
+
await new Promise((resolve) => {
|
|
661
|
+
// -g with --prefix makes npm treat `dir` as the global prefix, so the binary
|
|
662
|
+
// lands at dir/bin/sushi (a FHIRsmith-owned, writable location).
|
|
663
|
+
const npm = spawn('npm', ['install', '-g', 'fsh-sushi@latest', '--prefix', dir], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
664
|
+
let err = '';
|
|
665
|
+
npm.stdout.on('data', () => { /* ignore */ });
|
|
666
|
+
npm.stderr.on('data', (d) => { err += d.toString(); });
|
|
667
|
+
npm.on('error', async (e) => {
|
|
668
|
+
await this.logTaskMessage(taskId, 'warn', 'Could not run npm to update SUSHI: ' + e.message);
|
|
669
|
+
resolve();
|
|
670
|
+
});
|
|
671
|
+
npm.on('close', async (code) => {
|
|
672
|
+
if (code === 0) {
|
|
673
|
+
let version = '';
|
|
674
|
+
try {
|
|
675
|
+
version = require('child_process').execSync('sushi --version', { env: this.publisherEnv(), encoding: 'utf8' }).trim();
|
|
676
|
+
} catch (e) {
|
|
677
|
+
version = '(version check failed)';
|
|
678
|
+
}
|
|
679
|
+
await this.logTaskMessage(taskId, 'info', 'SUSHI is up to date: ' + version);
|
|
680
|
+
} else {
|
|
681
|
+
await this.logTaskMessage(taskId, 'warn', 'SUSHI update exited with code ' + code + (err ? ': ' + err.trim().slice(-400) : ''));
|
|
682
|
+
}
|
|
683
|
+
resolve();
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
564
688
|
async cloneRepository(task, draftDir) {
|
|
565
689
|
const { spawn } = require('child_process');
|
|
566
690
|
const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
|
|
@@ -616,20 +740,45 @@ class PublisherModule {
|
|
|
616
740
|
'.'
|
|
617
741
|
], {
|
|
618
742
|
cwd: draftDir,
|
|
619
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
743
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
744
|
+
env: this.publisherEnv()
|
|
620
745
|
});
|
|
621
746
|
|
|
622
747
|
// Create log file stream
|
|
623
748
|
const logStream = fs.createWriteStream(logFile);
|
|
624
749
|
|
|
750
|
+
const buildStart = Date.now();
|
|
751
|
+
let lastDataAt = Date.now();
|
|
752
|
+
|
|
625
753
|
java.stdout.on('data', (data) => {
|
|
754
|
+
lastDataAt = Date.now();
|
|
626
755
|
logStream.write(data);
|
|
627
756
|
});
|
|
628
757
|
|
|
629
758
|
java.stderr.on('data', (data) => {
|
|
759
|
+
lastDataAt = Date.now();
|
|
630
760
|
logStream.write(data);
|
|
631
761
|
});
|
|
632
762
|
|
|
763
|
+
// Heartbeat: emit a status line every 60s regardless of stdout activity,
|
|
764
|
+
// so silent phases of the Publisher (e.g. "Validating Resources") still
|
|
765
|
+
// surface a signal-of-life in the task log.
|
|
766
|
+
const heartbeat = setInterval(async () => {
|
|
767
|
+
const elapsedMs = Date.now() - buildStart;
|
|
768
|
+
const sinceDataMs = Date.now() - lastDataAt;
|
|
769
|
+
let logKb = 0;
|
|
770
|
+
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
|
|
771
|
+
const elapsedMin = Math.floor(elapsedMs / 60000);
|
|
772
|
+
const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
|
|
773
|
+
const idleSec = Math.floor(sinceDataMs / 1000);
|
|
774
|
+
await this.logTaskMessage(
|
|
775
|
+
taskId,
|
|
776
|
+
'info',
|
|
777
|
+
'IG Publisher heartbeat: elapsed ' + elapsedMin + 'm' + elapsedSec + 's, ' +
|
|
778
|
+
'log ' + logKb + ' KB, last output ' + idleSec + 's ago'
|
|
779
|
+
);
|
|
780
|
+
}, 60 * 1000);
|
|
781
|
+
|
|
633
782
|
java.on('close', async (code) => {
|
|
634
783
|
logStream.end();
|
|
635
784
|
|
|
@@ -660,6 +809,7 @@ class PublisherModule {
|
|
|
660
809
|
|
|
661
810
|
java.on('close', () => {
|
|
662
811
|
clearTimeout(timeout);
|
|
812
|
+
clearInterval(heartbeat);
|
|
663
813
|
});
|
|
664
814
|
});
|
|
665
815
|
}
|
|
@@ -693,6 +843,91 @@ class PublisherModule {
|
|
|
693
843
|
await this.logTaskMessage(task.id, 'info', 'Build output verified: package-id=' + qaData['package-id'] + ', version=' + qaData['ig-ver']);
|
|
694
844
|
}
|
|
695
845
|
|
|
846
|
+
// Read package/package.json out of a .tgz without unpacking the whole archive.
|
|
847
|
+
inspectPackageTgz(tgzPath) {
|
|
848
|
+
const { spawn } = require('child_process');
|
|
849
|
+
return new Promise((resolve, reject) => {
|
|
850
|
+
const tar = spawn('tar', ['-xzOf', tgzPath, 'package/package.json']);
|
|
851
|
+
let out = '';
|
|
852
|
+
let err = '';
|
|
853
|
+
tar.stdout.on('data', (d) => { out += d.toString(); });
|
|
854
|
+
tar.stderr.on('data', (d) => { err += d.toString(); });
|
|
855
|
+
tar.on('error', reject);
|
|
856
|
+
tar.on('close', (code) => {
|
|
857
|
+
if (code !== 0) {
|
|
858
|
+
reject(new Error('Could not read ' + tgzPath + ': ' + (err.trim() || ('tar exit ' + code))));
|
|
859
|
+
} else {
|
|
860
|
+
try {
|
|
861
|
+
resolve(JSON.parse(out));
|
|
862
|
+
} catch (e) {
|
|
863
|
+
reject(new Error('Invalid package.json in ' + tgzPath + ': ' + e.message));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Confirm the package.tgz files that landed in the web tree are publication builds,
|
|
871
|
+
// not the draft. Throws (failing the task) if a draft package was published.
|
|
872
|
+
async verifyPublishedPackage(task, website, draftDir) {
|
|
873
|
+
await this.logTaskMessage(task.id, 'info', 'Verifying published package(s) are publication builds...');
|
|
874
|
+
|
|
875
|
+
// The intended publication path comes from the IG's publication-request.json.
|
|
876
|
+
const prPath = path.join(draftDir, 'publication-request.json');
|
|
877
|
+
if (!fs.existsSync(prPath)) {
|
|
878
|
+
await this.logTaskMessage(task.id, 'warn', 'No publication-request.json found at ' + prPath + ' - skipping published-package check');
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const pr = JSON.parse(fs.readFileSync(prPath, 'utf8'));
|
|
882
|
+
const pubPath = pr.path;
|
|
883
|
+
|
|
884
|
+
// The website base url lets us map a canonical url to a folder in the web tree.
|
|
885
|
+
const setupPath = path.join(website.local_folder, 'publish-setup.json');
|
|
886
|
+
if (!pubPath || !fs.existsSync(setupPath)) {
|
|
887
|
+
await this.logTaskMessage(task.id, 'warn', 'Cannot resolve web path (path or publish-setup.json missing) - skipping published-package check');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const setup = JSON.parse(fs.readFileSync(setupPath, 'utf8'));
|
|
891
|
+
const baseUrl = setup.website && setup.website.url;
|
|
892
|
+
if (!baseUrl || !pubPath.startsWith(baseUrl)) {
|
|
893
|
+
await this.logTaskMessage(task.id, 'warn', 'Publication path ' + pubPath + ' is not under website url ' + baseUrl + ' - skipping published-package check');
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const relVer = pubPath.substring(baseUrl.length).replace(/^\/+/, '');
|
|
898
|
+
const relCur = relVer.replace(/\/[^/]+\/?$/, ''); // strip the version segment for the "current" copy
|
|
899
|
+
const candidates = [
|
|
900
|
+
path.join(website.local_folder, relVer, 'package.tgz'),
|
|
901
|
+
path.join(website.local_folder, relCur, 'package.tgz')
|
|
902
|
+
];
|
|
903
|
+
|
|
904
|
+
let checked = 0;
|
|
905
|
+
for (const pkgPath of candidates) {
|
|
906
|
+
if (!fs.existsSync(pkgPath)) {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
checked++;
|
|
910
|
+
const json = await this.inspectPackageTgz(pkgPath);
|
|
911
|
+
const problems = [];
|
|
912
|
+
if (json.notForPublication) {
|
|
913
|
+
problems.push('notForPublication is set');
|
|
914
|
+
}
|
|
915
|
+
if (typeof json.url === 'string' && json.url.startsWith('file:')) {
|
|
916
|
+
problems.push('url is a local file path (' + json.url + ')');
|
|
917
|
+
}
|
|
918
|
+
if (problems.length > 0) {
|
|
919
|
+
throw new Error('Published package ' + pkgPath + ' is a draft build, not a publication build (' +
|
|
920
|
+
problems.join('; ') + '). The IG Publisher publication run likely skipped package ' +
|
|
921
|
+
'regeneration (e.g. a Jekyll/template failure). Not committing.');
|
|
922
|
+
}
|
|
923
|
+
await this.logTaskMessage(task.id, 'info', 'Verified publication package: ' + pkgPath);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (checked === 0) {
|
|
927
|
+
await this.logTaskMessage(task.id, 'warn', 'No published package.tgz found to verify under ' + relVer + ' or ' + relCur);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
696
931
|
async runPublication(task) {
|
|
697
932
|
const website = await this.getWebsite(task.website_id);
|
|
698
933
|
if (!website) {
|
|
@@ -757,6 +992,12 @@ class PublisherModule {
|
|
|
757
992
|
// Step 3: Pull latest web folder before publishing into it
|
|
758
993
|
await this.runCommand('git', ['pull'], { cwd: website.git_root }, task.id, 'Pulling latest web folder');
|
|
759
994
|
|
|
995
|
+
// Step 3b: Update SUSHI to the latest release before the publication run. This is the
|
|
996
|
+
// step that previously failed silently: the publication build runs SUSHI with
|
|
997
|
+
// --require-latest, and a draft approved days earlier may now face a newer SUSHI on
|
|
998
|
+
// npm. Refresh it so the publication build doesn't abort on a version mismatch.
|
|
999
|
+
await this.ensureLatestSushi(task.id);
|
|
1000
|
+
|
|
760
1001
|
// Step 4: Run the IG publisher in go-publish mode
|
|
761
1002
|
await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
|
|
762
1003
|
registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
|
|
@@ -769,6 +1010,12 @@ class PublisherModule {
|
|
|
769
1010
|
}
|
|
770
1011
|
await this.logTaskMessage(task.id, 'info', 'Publication run verified: ' + pubLogName + ' found');
|
|
771
1012
|
|
|
1013
|
+
// Step 5b: Verify the published package is actually a publication build.
|
|
1014
|
+
// If the IG Publisher's publication run skipped package regeneration (e.g. a Jekyll
|
|
1015
|
+
// or template failure), the draft package - flagged notForPublication with a file://
|
|
1016
|
+
// url - can survive into the web tree. Catch that here, before anything is committed.
|
|
1017
|
+
await this.verifyPublishedPackage(task, website, draftDir);
|
|
1018
|
+
|
|
772
1019
|
// Step 6: Commit and push the web folder
|
|
773
1020
|
await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
|
|
774
1021
|
const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
|
|
@@ -789,7 +1036,7 @@ class PublisherModule {
|
|
|
789
1036
|
if (fs.existsSync(announcementPath)) {
|
|
790
1037
|
try {
|
|
791
1038
|
const announcement = fs.readFileSync(announcementPath, 'utf8');
|
|
792
|
-
await this.
|
|
1039
|
+
await this.updateTaskFields(task.id, { announcement: announcement });
|
|
793
1040
|
await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
|
|
794
1041
|
} catch (err) {
|
|
795
1042
|
await this.logTaskMessage(task.id, 'warn', 'Failed to read announcement file: ' + err.message);
|
|
@@ -825,19 +1072,43 @@ class PublisherModule {
|
|
|
825
1072
|
|
|
826
1073
|
return new Promise((resolve, reject) => {
|
|
827
1074
|
const java = spawn('java', args, {
|
|
828
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
1075
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1076
|
+
env: this.publisherEnv()
|
|
829
1077
|
});
|
|
830
1078
|
|
|
831
1079
|
const logStream = fs.createWriteStream(logFile);
|
|
832
1080
|
|
|
1081
|
+
const buildStart = Date.now();
|
|
1082
|
+
let lastDataAt = Date.now();
|
|
1083
|
+
|
|
833
1084
|
java.stdout.on('data', (data) => {
|
|
1085
|
+
lastDataAt = Date.now();
|
|
834
1086
|
logStream.write(data);
|
|
835
1087
|
});
|
|
836
1088
|
|
|
837
1089
|
java.stderr.on('data', (data) => {
|
|
1090
|
+
lastDataAt = Date.now();
|
|
838
1091
|
logStream.write(data);
|
|
839
1092
|
});
|
|
840
1093
|
|
|
1094
|
+
// Heartbeat: emit a status line every 60s regardless of stdout activity,
|
|
1095
|
+
// so silent phases of the Publisher still surface a signal-of-life.
|
|
1096
|
+
const heartbeat = setInterval(async () => {
|
|
1097
|
+
const elapsedMs = Date.now() - buildStart;
|
|
1098
|
+
const sinceDataMs = Date.now() - lastDataAt;
|
|
1099
|
+
let logKb = 0;
|
|
1100
|
+
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
|
|
1101
|
+
const elapsedMin = Math.floor(elapsedMs / 60000);
|
|
1102
|
+
const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
|
|
1103
|
+
const idleSec = Math.floor(sinceDataMs / 1000);
|
|
1104
|
+
await this.logTaskMessage(
|
|
1105
|
+
taskId,
|
|
1106
|
+
'info',
|
|
1107
|
+
'IG Publisher go-publish heartbeat: elapsed ' + elapsedMin + 'm' + elapsedSec + 's, ' +
|
|
1108
|
+
'log ' + logKb + ' KB, last output ' + idleSec + 's ago'
|
|
1109
|
+
);
|
|
1110
|
+
}, 60 * 1000);
|
|
1111
|
+
|
|
841
1112
|
java.on('close', async (code) => {
|
|
842
1113
|
logStream.end();
|
|
843
1114
|
if (code === 0) {
|
|
@@ -856,16 +1127,18 @@ class PublisherModule {
|
|
|
856
1127
|
reject(error);
|
|
857
1128
|
});
|
|
858
1129
|
|
|
859
|
-
// Timeout
|
|
1130
|
+
// Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
|
|
1131
|
+
const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
|
|
860
1132
|
const timeout = setTimeout(async () => {
|
|
861
1133
|
java.kill();
|
|
862
1134
|
logStream.end();
|
|
863
|
-
await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after
|
|
1135
|
+
await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after ' + timeoutMinutes + ' minutes');
|
|
864
1136
|
reject(new Error('IG Publisher go-publish timed out'));
|
|
865
|
-
},
|
|
1137
|
+
}, timeoutMinutes * 60 * 1000);
|
|
866
1138
|
|
|
867
1139
|
java.on('close', () => {
|
|
868
1140
|
clearTimeout(timeout);
|
|
1141
|
+
clearInterval(heartbeat);
|
|
869
1142
|
});
|
|
870
1143
|
});
|
|
871
1144
|
}
|
|
@@ -1125,7 +1398,7 @@ class PublisherModule {
|
|
|
1125
1398
|
} else {
|
|
1126
1399
|
content += '<div class="table-responsive">';
|
|
1127
1400
|
content += '<table class="table table-striped">';
|
|
1128
|
-
content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
|
|
1401
|
+
content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>IG Publisher</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
|
|
1129
1402
|
content += '<tbody>';
|
|
1130
1403
|
|
|
1131
1404
|
for (const task of tasks) {
|
|
@@ -1146,6 +1419,7 @@ class PublisherModule {
|
|
|
1146
1419
|
content += '<td>' + task.version + '</td>';
|
|
1147
1420
|
content += '<td>' + task.website_name + '</td>';
|
|
1148
1421
|
content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
|
|
1422
|
+
content += '<td>' + (task.publisher_version ? '<code>' + escape(task.publisher_version) + '</code>' : '<span class="text-muted">—</span>') + '</td>';
|
|
1149
1423
|
content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
|
|
1150
1424
|
content += '<td>' + task.user_name + '</td>';
|
|
1151
1425
|
content += '<td class="task-actions">';
|
|
@@ -1450,6 +1724,10 @@ class PublisherModule {
|
|
|
1450
1724
|
content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
|
|
1451
1725
|
content += '<p><strong>GitHub:</strong> ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')</p>';
|
|
1452
1726
|
|
|
1727
|
+
if (task.publisher_version) {
|
|
1728
|
+
content += '<p><strong>IG Publisher:</strong> <code>' + escape(task.publisher_version) + '</code></p>';
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1453
1731
|
if (task.local_folder) {
|
|
1454
1732
|
content += '<p><strong>Local Folder:</strong> <code>' + task.local_folder + '</code></p>';
|
|
1455
1733
|
}
|
package/registry/crawler.js
CHANGED
|
@@ -2,15 +2,34 @@
|
|
|
2
2
|
// Crawler for gathering server information from terminology servers
|
|
3
3
|
|
|
4
4
|
const axios = require('axios');
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const dns = require('dns');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const https = require('https');
|
|
8
|
+
const ipaddr = require('ipaddr.js');
|
|
9
|
+
const {
|
|
10
|
+
ServerRegistries,
|
|
11
|
+
ServerRegistry,
|
|
12
|
+
ServerInformation,
|
|
9
13
|
ServerVersionInformation,
|
|
10
14
|
} = require('./model');
|
|
11
15
|
const {Extensions} = require("../tx/library/extensions");
|
|
12
16
|
const {debugLog} = require("../tx/operation-context");
|
|
13
17
|
|
|
18
|
+
// True if an IP literal is anything other than a normal public (unicast) address:
|
|
19
|
+
// loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
|
|
20
|
+
// CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
|
|
21
|
+
function isNonPublicAddress(ip) {
|
|
22
|
+
try {
|
|
23
|
+
let addr = ipaddr.parse(ip);
|
|
24
|
+
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
|
|
25
|
+
addr = addr.toIPv4Address();
|
|
26
|
+
}
|
|
27
|
+
return addr.range() !== 'unicast';
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return true; // unparseable - treat as unsafe
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
const MASTER_URL = 'https://fhir.github.io/ig-registry/tx-servers.json';
|
|
15
34
|
|
|
16
35
|
class RegistryCrawler {
|
|
@@ -22,7 +41,8 @@ class RegistryCrawler {
|
|
|
22
41
|
masterUrl: config.masterUrl || MASTER_URL,
|
|
23
42
|
userAgent: config.userAgent || 'HealthIntersections/FhirServer',
|
|
24
43
|
crawlInterval: config.crawlInterval || 5 * 60 * 1000, // 5 minutes default
|
|
25
|
-
apiKeys: config.apiKeys || {} // Map of server URL or code to API key
|
|
44
|
+
apiKeys: config.apiKeys || {}, // Map of server URL or code to API key
|
|
45
|
+
allowPrivateAddresses: config.allowPrivateAddresses || false // SSRF opt-out for local test servers
|
|
26
46
|
};
|
|
27
47
|
this.stats = stats;
|
|
28
48
|
|
|
@@ -39,6 +59,63 @@ class RegistryCrawler {
|
|
|
39
59
|
this.log = logv;
|
|
40
60
|
}
|
|
41
61
|
|
|
62
|
+
// A DNS lookup wrapper that rejects any host resolving to a non-public address.
|
|
63
|
+
// Enforced at connection time (so it also covers redirect targets and defeats
|
|
64
|
+
// DNS-rebinding), this is the SSRF guard for outbound fetches. The registry is
|
|
65
|
+
// crawled from server-supplied "next" links, so the target host is not trusted.
|
|
66
|
+
// Set config.allowPrivateAddresses = true to disable (e.g. for local test servers).
|
|
67
|
+
ssrfLookup() {
|
|
68
|
+
const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
|
|
69
|
+
return (hostname, options, callback) => {
|
|
70
|
+
if (typeof options === 'function') {
|
|
71
|
+
callback = options;
|
|
72
|
+
options = {};
|
|
73
|
+
}
|
|
74
|
+
const wantAll = !!(options && options.all);
|
|
75
|
+
dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
|
|
76
|
+
if (err) {
|
|
77
|
+
callback(err);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!allowPrivate) {
|
|
81
|
+
for (const a of addresses) {
|
|
82
|
+
if (isNonPublicAddress(a.address)) {
|
|
83
|
+
callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (wantAll) {
|
|
89
|
+
callback(null, addresses);
|
|
90
|
+
} else {
|
|
91
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// http/https agents that route every connection through the SSRF lookup.
|
|
98
|
+
guardedAgents() {
|
|
99
|
+
if (!this._guardedAgents) {
|
|
100
|
+
const lookup = this.ssrfLookup();
|
|
101
|
+
class GuardedHttpAgent extends http.Agent {
|
|
102
|
+
createConnection(options, cb) {
|
|
103
|
+
return super.createConnection(Object.assign({}, options, { lookup }), cb);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
class GuardedHttpsAgent extends https.Agent {
|
|
107
|
+
createConnection(options, cb) {
|
|
108
|
+
return super.createConnection(Object.assign({}, options, { lookup }), cb);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this._guardedAgents = {
|
|
112
|
+
httpAgent: new GuardedHttpAgent({ keepAlive: true }),
|
|
113
|
+
httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return this._guardedAgents;
|
|
117
|
+
}
|
|
118
|
+
|
|
42
119
|
|
|
43
120
|
/**
|
|
44
121
|
* Main entry point - crawl the registry starting from the master URL
|
|
@@ -455,7 +532,9 @@ class RegistryCrawler {
|
|
|
455
532
|
timeout: this.config.timeout,
|
|
456
533
|
headers: headers,
|
|
457
534
|
signal: this.abortController?.signal,
|
|
458
|
-
validateStatus: (status) => status < 500 // Don't throw on 4xx
|
|
535
|
+
validateStatus: (status) => status < 500, // Don't throw on 4xx
|
|
536
|
+
httpAgent: this.guardedAgents().httpAgent,
|
|
537
|
+
httpsAgent: this.guardedAgents().httpsAgent
|
|
459
538
|
});
|
|
460
539
|
|
|
461
540
|
if (response.status >= 400) {
|