@onlineapps/conn-orch-validator 3.3.2 → 4.0.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 +358 -0
- package/README.md +46 -6
- package/TESTING_STRATEGY.md +1 -1
- package/docs/DESIGN.md +18 -11
- package/package.json +2 -3
- package/src/CookbookTestRunner.js +155 -26
- package/src/CookbookTestUtils.js +12 -4
- package/src/ServiceReadinessValidator.js +35 -18
- package/src/ValidationOrchestrator.js +223 -126
- package/src/cli/biz-ci-gate.js +356 -7
- package/src/helpers/README.md +3 -55
- package/src/helpers/createServiceReadinessTests.js +5 -1
- package/src/index.js +5 -2
- package/src/mocks/MockMQClient.js +100 -15
- package/src/utils/bizCiGateContract.js +110 -21
- package/src/utils/connectorContract.js +96 -0
- package/src/utils/cookbookFormat.js +95 -0
- package/src/utils/deployContract.js +488 -0
- package/src/utils/envContract.js +417 -0
- package/src/utils/installContract.js +142 -0
- package/src/utils/integrationRun.js +297 -0
- package/src/utils/libCompat.js +158 -0
- package/src/utils/preValidation.js +137 -0
- package/src/utils/setupDatabase.js +154 -0
- package/src/utils/stepFailure.js +73 -0
- package/src/utils/testNamespace.js +104 -0
- package/src/validators/ServiceStructureValidator.js +195 -48
- package/src/config.js +0 -32
- package/src/defaults.js +0 -11
- package/src/helpers/createPreValidationTests.js +0 -326
- package/test-mq-flow.js +0 -72
- package/test-orchestrator.js +0 -95
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proof that the integration suite RAN — not that files exist on disk.
|
|
5
|
+
*
|
|
6
|
+
* The file-count check (`verifyIntegrationMinimum`) answers "is there a suite?".
|
|
7
|
+
* It cannot answer "did it execute?", and the difference is the whole defect:
|
|
8
|
+
* a suite hidden behind an env flag that CI never sets makes the pipeline start
|
|
9
|
+
* mariadb/redis/rabbitmq, pay for `ci:gate:wait` and `ci:gate:setup`, run nothing
|
|
10
|
+
* against them — and jest still exits 0. Measured: a fully skipped run reports
|
|
11
|
+
* `"success": true`, so the runner's exit code proves nothing either. The only
|
|
12
|
+
* usable evidence is the machine-readable report (`jest --json --outputFile`).
|
|
13
|
+
*
|
|
14
|
+
* This module is deliberately free of any dependency on bizCiGateContract: the
|
|
15
|
+
* CLI is the composition root that resolves the contract, the file list and the
|
|
16
|
+
* runner, and hands them in. Everything here is injected (principle 1) and
|
|
17
|
+
* validated at entry (principle 4).
|
|
18
|
+
*
|
|
19
|
+
* @see .claude/rules/automation-gates.md §5 — a mechanism that checks nothing
|
|
20
|
+
* is a false guarantee, of the same severity as a wrong result.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
/** Every counter the verdict rests on. A report missing one is not a report. */
|
|
27
|
+
const REQUIRED_COUNTERS = {
|
|
28
|
+
totalTests: 'numTotalTests',
|
|
29
|
+
passedTests: 'numPassedTests',
|
|
30
|
+
failedTests: 'numFailedTests',
|
|
31
|
+
pendingTests: 'numPendingTests',
|
|
32
|
+
todoTests: 'numTodoTests',
|
|
33
|
+
totalSuites: 'numTotalTestSuites',
|
|
34
|
+
runtimeErrorSuites: 'numRuntimeErrorTestSuites',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH = path.join('ci', 'integration-run.json');
|
|
38
|
+
const DEFAULT_REPORT_DIR_RELATIVE_PATH = path.join('ci', 'integration-run');
|
|
39
|
+
|
|
40
|
+
function parseJestReport(rawText, sourcePath) {
|
|
41
|
+
if (typeof sourcePath !== 'string' || sourcePath === '') {
|
|
42
|
+
throw new Error('[BizCiGate] Missing sourcePath for runner report - Expected the path the report was read from. '
|
|
43
|
+
+ 'Fix: pass the report path so a violation can name the failing process.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(rawText);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(`[BizCiGate] Unreadable runner report - ${sourcePath}: ${error.message}. `
|
|
51
|
+
+ 'Expected the JSON document written by "jest --json --outputFile". '
|
|
52
|
+
+ 'Fix: a truncated report means the runner was killed mid-write — rerun it and check the job for OOM.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
56
|
+
throw new Error(`[BizCiGate] Invalid runner report - ${sourcePath} is not a jest report object. `
|
|
57
|
+
+ 'Fix: point the gate at the file produced by "jest --json --outputFile".');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const report = { sourcePath };
|
|
61
|
+
for (const [field, counter] of Object.entries(REQUIRED_COUNTERS)) {
|
|
62
|
+
const value = parsed[counter];
|
|
63
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
64
|
+
throw new Error(`[BizCiGate] Invalid runner report - ${sourcePath} has no usable "${counter}" `
|
|
65
|
+
+ `(found ${JSON.stringify(value)}). Expected a non-negative integer. `
|
|
66
|
+
+ 'Fix: the report must come from jest >= 29 run with --json; do not hand-edit it.');
|
|
67
|
+
}
|
|
68
|
+
report[field] = value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return report;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readJestReport(filePath) {
|
|
75
|
+
const resolvedPath = path.resolve(filePath);
|
|
76
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
77
|
+
throw new Error(`[BizCiGate] Missing runner report - ${resolvedPath} was not written. `
|
|
78
|
+
+ 'Expected "jest --json --outputFile" to produce it. '
|
|
79
|
+
+ 'Fix: the runner died before writing — read the job log for the crash; a missing report is never a pass.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return parseJestReport(fs.readFileSync(resolvedPath, 'utf8'), resolvedPath);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function aggregateJestReports(reports) {
|
|
86
|
+
if (!Array.isArray(reports) || reports.length === 0) {
|
|
87
|
+
throw new Error('[BizCiGate] No runner reports - Expected at least one jest report to aggregate. '
|
|
88
|
+
+ 'Fix: an empty batch means no process ran; that is a failure, not a clean zero.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const aggregate = { reportCount: reports.length, sourcePaths: [] };
|
|
92
|
+
for (const field of Object.keys(REQUIRED_COUNTERS)) {
|
|
93
|
+
aggregate[field] = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const report of reports) {
|
|
97
|
+
aggregate.sourcePaths.push(report.sourcePath);
|
|
98
|
+
for (const field of Object.keys(REQUIRED_COUNTERS)) {
|
|
99
|
+
aggregate[field] += report[field];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Executed = what the runner actually carried out. Pending (a skipped describe
|
|
104
|
+
// or it) and todo are declarations, not execution — counting them is exactly
|
|
105
|
+
// the lie this module exists to remove.
|
|
106
|
+
aggregate.executedTests = aggregate.passedTests + aggregate.failedTests;
|
|
107
|
+
aggregate.notExecutedTests = aggregate.pendingTests + aggregate.todoTests;
|
|
108
|
+
|
|
109
|
+
return aggregate;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every violation is reported, not only the first: a job that skipped half its
|
|
114
|
+
* suite AND failed a test must show both, or the second one surfaces only after
|
|
115
|
+
* the first is fixed.
|
|
116
|
+
*/
|
|
117
|
+
function evaluateIntegrationRun(aggregate) {
|
|
118
|
+
if (!aggregate || typeof aggregate !== 'object') {
|
|
119
|
+
throw new Error('[BizCiGate] Missing aggregate - Expected the object returned by aggregateJestReports. '
|
|
120
|
+
+ 'Fix: aggregate the reports before evaluating them.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const violations = [];
|
|
124
|
+
|
|
125
|
+
if (aggregate.runtimeErrorSuites > 0) {
|
|
126
|
+
violations.push({
|
|
127
|
+
code: 'RUNTIME_ERROR_SUITES',
|
|
128
|
+
message: `[BizCiGate] Integration suite failed to load - ${aggregate.runtimeErrorSuites} suite(s) `
|
|
129
|
+
+ 'threw before any test ran. Expected every suite to load against the real sidecars. '
|
|
130
|
+
+ 'Fix: read the runner output above; a suite that cannot load fails 0 tests and proves nothing.',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (aggregate.failedTests > 0) {
|
|
135
|
+
violations.push({
|
|
136
|
+
code: 'FAILED_TESTS',
|
|
137
|
+
message: `[BizCiGate] Failing integration test(s) - ${aggregate.failedTests} of `
|
|
138
|
+
+ `${aggregate.executedTests} executed test(s) failed. Expected all to pass. `
|
|
139
|
+
+ 'Fix: fix the service or the test; never loosen the assertion.',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (aggregate.executedTests === 0) {
|
|
144
|
+
violations.push({
|
|
145
|
+
code: 'NO_TESTS_EXECUTED',
|
|
146
|
+
message: `[BizCiGate] No integration test was executed - ${aggregate.totalTests} test(s) discovered, `
|
|
147
|
+
+ `0 executed, ${aggregate.notExecutedTests} skipped. `
|
|
148
|
+
+ 'The pipeline started the sidecars and ran nothing against them. '
|
|
149
|
+
+ 'Fix: remove the condition that disables the suite, or delete the suite.',
|
|
150
|
+
});
|
|
151
|
+
} else if (aggregate.notExecutedTests > 0) {
|
|
152
|
+
violations.push({
|
|
153
|
+
code: 'NOT_EXECUTED_TESTS',
|
|
154
|
+
message: `[BizCiGate] Skipped integration test(s) - ${aggregate.pendingTests} skipped, `
|
|
155
|
+
+ `${aggregate.todoTests} todo, out of ${aggregate.totalTests}. `
|
|
156
|
+
+ 'Expected: every integration test runs against the real sidecars. '
|
|
157
|
+
+ 'Fix: remove the skip/todo, or delete the test.',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
ok: violations.length === 0,
|
|
163
|
+
violations,
|
|
164
|
+
executedTests: aggregate.executedTests,
|
|
165
|
+
notExecutedTests: aggregate.notExecutedTests,
|
|
166
|
+
totalTests: aggregate.totalTests,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertNonEmptyString(value, name, fix) {
|
|
171
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
172
|
+
throw new Error(`[BizCiGate] Missing ${name} - Expected a non-empty path. Fix: ${fix}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function assertExistingPath(value, name, fix) {
|
|
177
|
+
if (!fs.existsSync(value)) {
|
|
178
|
+
throw new Error(`[BizCiGate] Missing ${name} - ${value} does not exist. Fix: ${fix}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One runner process per integration test file.
|
|
184
|
+
*
|
|
185
|
+
* The service declares WHAT its integration suite is; the library decides HOW it
|
|
186
|
+
* is executed (docs/biz/00-model/uniformity-principle.md). Per-file processes are
|
|
187
|
+
* the uniform HOW because they are the only shape that also fits biz-invoicing,
|
|
188
|
+
* whose suite was OOM-killed in a single process — and they bound the blast radius
|
|
189
|
+
* of one leaking suite for everybody else. No new declaration is needed for it.
|
|
190
|
+
*/
|
|
191
|
+
function runIntegrationSuite(options) {
|
|
192
|
+
const { serviceRoot, testFiles, jestBin, jestConfig, reportDir, spawn } = options || {};
|
|
193
|
+
|
|
194
|
+
assertNonEmptyString(serviceRoot, 'serviceRoot', 'pass the resolved service repository root.');
|
|
195
|
+
assertNonEmptyString(jestBin, 'jestBin', 'pass the resolved path to the runner executable.');
|
|
196
|
+
assertNonEmptyString(jestConfig, 'jestConfig', 'pass the resolved path to the runner config.');
|
|
197
|
+
assertNonEmptyString(reportDir, 'reportDir', 'pass the directory the per-process reports are written to.');
|
|
198
|
+
|
|
199
|
+
if (typeof spawn !== 'function') {
|
|
200
|
+
throw new Error('[BizCiGate] Missing spawn - Expected the process runner to be injected. '
|
|
201
|
+
+ 'Fix: pass child_process.spawnSync (or an equivalent) from the composition root.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!Array.isArray(testFiles) || testFiles.length === 0) {
|
|
205
|
+
throw new Error('[BizCiGate] No integration test files - Expected at least one file to run. '
|
|
206
|
+
+ 'Fix: this is the file-count precondition; run verify-integration-minimum first.');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
assertExistingPath(jestBin, 'runner executable',
|
|
210
|
+
'install the service dependencies (npm ci) so node_modules/.bin/jest exists.');
|
|
211
|
+
assertExistingPath(jestConfig, 'runner config',
|
|
212
|
+
'the service must ship jest.config.js at its repository root.');
|
|
213
|
+
|
|
214
|
+
fs.rmSync(reportDir, { recursive: true, force: true });
|
|
215
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
216
|
+
|
|
217
|
+
const processes = [];
|
|
218
|
+
for (const [index, testFile] of testFiles.entries()) {
|
|
219
|
+
const relativeTestFile = path.relative(serviceRoot, path.resolve(serviceRoot, testFile));
|
|
220
|
+
const reportPath = path.join(reportDir, `${index}-${path.basename(testFile)}.json`);
|
|
221
|
+
const args = [
|
|
222
|
+
'--config', jestConfig,
|
|
223
|
+
relativeTestFile,
|
|
224
|
+
'--runInBand',
|
|
225
|
+
'--json',
|
|
226
|
+
'--outputFile', reportPath,
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
const spawnResult = spawn(jestBin, args, { cwd: serviceRoot, stdio: 'inherit', env: process.env });
|
|
230
|
+
|
|
231
|
+
processes.push({
|
|
232
|
+
testFile: relativeTestFile,
|
|
233
|
+
reportPath,
|
|
234
|
+
exitCode: spawnResult && Number.isInteger(spawnResult.status) ? spawnResult.status : null,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Read every report before judging any of them: a missing or truncated report
|
|
239
|
+
// throws here, which is the intended outcome — it can never be read as "0 failures".
|
|
240
|
+
const reports = processes.map((entry) => readJestReport(entry.reportPath));
|
|
241
|
+
const aggregate = aggregateJestReports(reports);
|
|
242
|
+
const evaluation = evaluateIntegrationRun(aggregate);
|
|
243
|
+
|
|
244
|
+
const artefact = {
|
|
245
|
+
generatedAt: new Date().toISOString(),
|
|
246
|
+
serviceRoot,
|
|
247
|
+
processes,
|
|
248
|
+
executedTests: aggregate.executedTests,
|
|
249
|
+
notExecutedTests: aggregate.notExecutedTests,
|
|
250
|
+
totalTests: aggregate.totalTests,
|
|
251
|
+
passedTests: aggregate.passedTests,
|
|
252
|
+
failedTests: aggregate.failedTests,
|
|
253
|
+
pendingTests: aggregate.pendingTests,
|
|
254
|
+
todoTests: aggregate.todoTests,
|
|
255
|
+
totalSuites: aggregate.totalSuites,
|
|
256
|
+
runtimeErrorSuites: aggregate.runtimeErrorSuites,
|
|
257
|
+
verdict: evaluation.ok ? 'pass' : 'fail',
|
|
258
|
+
violations: evaluation.violations,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const artefactPath = path.join(serviceRoot, INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH);
|
|
262
|
+
fs.mkdirSync(path.dirname(artefactPath), { recursive: true });
|
|
263
|
+
fs.writeFileSync(artefactPath, JSON.stringify(artefact, null, 2));
|
|
264
|
+
|
|
265
|
+
return { aggregate, evaluation, processes, artefact, artefactPath };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The run artefact is the only honest source of "how many integration tests
|
|
270
|
+
* executed". Absent means unknown — the caller must say so, never substitute a
|
|
271
|
+
* count of files on disk.
|
|
272
|
+
*/
|
|
273
|
+
function readIntegrationRunArtefact(serviceRoot) {
|
|
274
|
+
const artefactPath = path.join(path.resolve(serviceRoot), INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH);
|
|
275
|
+
if (!fs.existsSync(artefactPath)) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
return { artefactPath, artefact: JSON.parse(fs.readFileSync(artefactPath, 'utf8')) };
|
|
281
|
+
} catch (error) {
|
|
282
|
+
throw new Error(`[BizCiGate] Unreadable integration run artefact - ${artefactPath}: ${error.message}. `
|
|
283
|
+
+ 'Fix: delete it and rerun run-integration; a corrupt artefact must not be summarised as a result.');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
REQUIRED_COUNTERS,
|
|
289
|
+
INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH,
|
|
290
|
+
DEFAULT_REPORT_DIR_RELATIVE_PATH,
|
|
291
|
+
parseJestReport,
|
|
292
|
+
readJestReport,
|
|
293
|
+
aggregateJestReports,
|
|
294
|
+
evaluateIntegrationRun,
|
|
295
|
+
runIntegrationSuite,
|
|
296
|
+
readIntegrationRunArtefact,
|
|
297
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Library compatibility gate — requirement R6.
|
|
5
|
+
*
|
|
6
|
+
* A biz service may only ship if every @onlineapps/* package it pins EXACTLY
|
|
7
|
+
* matches the version the platform declares. Mismatch means the service was
|
|
8
|
+
* built against a different platform contract than the one it will run on.
|
|
9
|
+
*
|
|
10
|
+
* Only packages at least one infra service actually installs are compared: the
|
|
11
|
+
* SSOT lists biz-only packages too, and for those there is no infra version to
|
|
12
|
+
* diverge from, so asking "does this match what infra ships" has no answer.
|
|
13
|
+
* The SSOT carries that set as `infraConsumed`. A bare version map has no such
|
|
14
|
+
* list — a platform-release entry records what was deployed, so everything in
|
|
15
|
+
* it is by definition shipped and must match.
|
|
16
|
+
*
|
|
17
|
+
* Pure module: the rules and the fetch live here, presentation and exit codes
|
|
18
|
+
* live in the CLI.
|
|
19
|
+
*
|
|
20
|
+
* Configuration (GitLab CI variables, NOT service env files — the biz env
|
|
21
|
+
* convention in api/docs/biz/60-templates/env-conventions.md governs
|
|
22
|
+
* config/env-active/*.env and compose, and deliberately does not reach CI job
|
|
23
|
+
* variables, so these carry no DEVEL_/TESTING_ prefix):
|
|
24
|
+
* LIBRARIES_SSOT_URL where the platform SSOT is published
|
|
25
|
+
* LIBRARIES_SSOT_TOKEN optional read token for that endpoint
|
|
26
|
+
*
|
|
27
|
+
* Both are PLATFORM-GLOBAL: one value, set once at GitLab group level, shared
|
|
28
|
+
* by all biz repos and every developer. They have no tenant or workspace
|
|
29
|
+
* dimension, and must not grow one. `workspace_id` scopes tenant DATA at
|
|
30
|
+
* runtime; this pair answers "which platform contract was this code built
|
|
31
|
+
* against", which is a property of the build, identical for every tenant. A
|
|
32
|
+
* per-workspace SSOT would mean two workspaces could run the same service
|
|
33
|
+
* built against different library sets — the exact divergence R6 exists to
|
|
34
|
+
* prevent. Compare TESTING_TENANT_ID / TESTING_WORKSPACE_ID, which are
|
|
35
|
+
* per-service test fixtures and therefore legitimately differ.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
|
|
40
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
|
|
41
|
+
const SCOPE = '@onlineapps/';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the SSOT into a version map plus its optional infraConsumed list.
|
|
45
|
+
* Accepts the committed wrapped shape and a bare map alike.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeLibrarySet(raw) {
|
|
48
|
+
if (!raw || typeof raw !== 'object') {
|
|
49
|
+
throw new Error('[LibCompat] Invalid library set - Expected a JSON object. '
|
|
50
|
+
+ 'Fix: point LIBRARIES_SSOT_URL at config/libraries.json or a platform-release libraries entry.');
|
|
51
|
+
}
|
|
52
|
+
if (raw.libraries && typeof raw.libraries === 'object') {
|
|
53
|
+
return {
|
|
54
|
+
versions: raw.libraries,
|
|
55
|
+
infraConsumed: Array.isArray(raw.infraConsumed) ? new Set(raw.infraConsumed) : null
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { versions: raw, infraConsumed: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compare a package.json against the platform library set.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} pkg parsed package.json of the biz service
|
|
65
|
+
* @param {object} librarySet committed SSOT shape or bare version map
|
|
66
|
+
* @returns {{ok: boolean, gated: string[], notGated: string[], violations: Array<{package: string, message: string}>}}
|
|
67
|
+
*/
|
|
68
|
+
function checkLibCompat(pkg, librarySet) {
|
|
69
|
+
const { versions, infraConsumed } = normalizeLibrarySet(librarySet);
|
|
70
|
+
|
|
71
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
72
|
+
const ours = Object.entries(deps).filter(([name]) => name.startsWith(SCOPE));
|
|
73
|
+
|
|
74
|
+
const gated = infraConsumed ? ours.filter(([name]) => infraConsumed.has(name)) : ours;
|
|
75
|
+
const notGated = infraConsumed
|
|
76
|
+
? ours.filter(([name]) => !infraConsumed.has(name)).map(([name]) => name)
|
|
77
|
+
: [];
|
|
78
|
+
|
|
79
|
+
const violations = [];
|
|
80
|
+
for (const [name, version] of gated) {
|
|
81
|
+
if (!EXACT_VERSION.test(version)) {
|
|
82
|
+
violations.push({
|
|
83
|
+
package: name,
|
|
84
|
+
message: `${name}: version '${version}' is not exact x.y.z — caret/tilde/range pins are banned, `
|
|
85
|
+
+ 'because what resolves today is not what resolved when the image was built.'
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!(name in versions)) {
|
|
90
|
+
violations.push({
|
|
91
|
+
package: name,
|
|
92
|
+
message: `${name}: not present in the infra library set — the platform does not ship this package version contract.`
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (versions[name] !== version) {
|
|
97
|
+
violations.push({
|
|
98
|
+
package: name,
|
|
99
|
+
message: `${name}: biz pins ${version}, infra ships ${versions[name]} — `
|
|
100
|
+
+ 'rebuild the service against the current platform, or deploy the matching infra release first.'
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: violations.length === 0,
|
|
107
|
+
gated: gated.map(([name]) => name),
|
|
108
|
+
notGated,
|
|
109
|
+
violations
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load the SSOT from a local path or an https endpoint.
|
|
115
|
+
*
|
|
116
|
+
* Fail-fast when unconfigured: a gate that silently passes because it could not
|
|
117
|
+
* find its reference is worse than no gate, since it reports success.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} [source] path or URL; defaults to LIBRARIES_SSOT_URL
|
|
120
|
+
* @param {object} [env] environment to read (injected for testability)
|
|
121
|
+
*/
|
|
122
|
+
async function loadLibrarySet(source, env = process.env) {
|
|
123
|
+
const location = source || env.LIBRARIES_SSOT_URL;
|
|
124
|
+
|
|
125
|
+
if (!location) {
|
|
126
|
+
throw new Error('[LibCompat] Missing environment variable - LIBRARIES_SSOT_URL is required '
|
|
127
|
+
+ '(or pass --libraries <path|url>). It points at the platform library SSOT the biz pins are compared against. '
|
|
128
|
+
+ 'Fix: set LIBRARIES_SSOT_URL as a GitLab CI variable at group level.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (/^https?:\/\//.test(location)) {
|
|
132
|
+
const headers = { Accept: 'application/json' };
|
|
133
|
+
if (env.LIBRARIES_SSOT_TOKEN) headers['PRIVATE-TOKEN'] = env.LIBRARIES_SSOT_TOKEN;
|
|
134
|
+
|
|
135
|
+
const response = await fetch(location, { headers });
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
throw new Error(`[LibCompat] Could not fetch the library SSOT - ${location} returned ${response.status} ${response.statusText}. `
|
|
138
|
+
+ 'Fix: check LIBRARIES_SSOT_URL, and that LIBRARIES_SSOT_TOKEN grants read access to the infra repository.');
|
|
139
|
+
}
|
|
140
|
+
const body = await response.text();
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(body);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new Error(`[LibCompat] Could not parse the library SSOT fetched from ${location} - ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!fs.existsSync(location)) {
|
|
149
|
+
throw new Error(`[LibCompat] Library SSOT not found: ${location}`);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(fs.readFileSync(location, 'utf8'));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
throw new Error(`[LibCompat] Could not parse ${location} - ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { checkLibCompat, loadLibrarySet, normalizeLibrarySet };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pre-validation: run a service's cookbooks offline against mocked
|
|
5
|
+
* infrastructure and write the validation proof the Registry consumes.
|
|
6
|
+
*
|
|
7
|
+
* One implementation for every service. It replaces five copies of
|
|
8
|
+
* scripts/run-pre-validation.js — four identical, plus property's 16-line
|
|
9
|
+
* stand-in that counted .json files and printed OK without running a cookbook
|
|
10
|
+
* or writing a proof, while being wired to the same `npm run test:cookbooks`.
|
|
11
|
+
*
|
|
12
|
+
* Two things the copies carried are deliberately absent:
|
|
13
|
+
* - the legacy HTTP branch (spawn scripts/test-server.js, dispatch over
|
|
14
|
+
* axios). Every service declares schema_version "3.0" with handler
|
|
15
|
+
* operations, so the branch was unreachable; a non-v3 shape now fails fast
|
|
16
|
+
* rather than silently taking the handler path.
|
|
17
|
+
* - a ConfigLoader import. That lives in @onlineapps/service-wrapper (L4) and
|
|
18
|
+
* this package is L3, so the resolved service URL is INJECTED by the
|
|
19
|
+
* caller — architecture-principles.md §7, no reverse dependencies.
|
|
20
|
+
*
|
|
21
|
+
* Pure module: it returns the outcome and never exits. The CLI owns exit codes
|
|
22
|
+
* and presentation, this owns the rules.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A step error is whatever the handler threw or the transport returned — often
|
|
30
|
+
* an object, not a string. The scripts this replaces interpolated it straight
|
|
31
|
+
* into a template literal, so a real failure was reported as "[object Object]"
|
|
32
|
+
* and the operator learned nothing. Render it so the message survives.
|
|
33
|
+
*/
|
|
34
|
+
function describeStepError(error) {
|
|
35
|
+
if (error === undefined || error === null) return 'Validation failed';
|
|
36
|
+
if (typeof error === 'string') return error;
|
|
37
|
+
if (typeof error.message === 'string' && error.message.length > 0) return error.message;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(error);
|
|
40
|
+
} catch {
|
|
41
|
+
return String(error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const COOKBOOKS_RELATIVE_DIR = path.join('tests', 'cookbooks');
|
|
46
|
+
const PROOF_RELATIVE_PATH = path.join('conn-runtime', 'validation-proof.json');
|
|
47
|
+
const OPERATIONS_RELATIVE_PATH = path.join('config', 'service', 'operations.json');
|
|
48
|
+
const VALIDATOR_NAME = '@onlineapps/conn-orch-validator';
|
|
49
|
+
const COOKBOOK_TIMEOUT_MS = 30000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* v3 means MQ-only handler dispatch. Anything else is a service that has not
|
|
53
|
+
* been migrated, and it must say so rather than be run through a path its
|
|
54
|
+
* operations were never written for.
|
|
55
|
+
*/
|
|
56
|
+
function assertV3Operations(serviceRoot) {
|
|
57
|
+
const operationsPath = path.join(serviceRoot, OPERATIONS_RELATIVE_PATH);
|
|
58
|
+
|
|
59
|
+
if (!fs.existsSync(operationsPath)) {
|
|
60
|
+
throw new Error(`[PreValidation] Missing operations.json - ${operationsPath} is required. `
|
|
61
|
+
+ 'Fix: declare the service operations at config/service/operations.json.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf8'));
|
|
65
|
+
const declaresV3 = operations.schema_version === '3.0' || operations.schema_version === '3';
|
|
66
|
+
const hasHandlers = operations.operations
|
|
67
|
+
&& Object.values(operations.operations).some((operation) => operation.handler);
|
|
68
|
+
|
|
69
|
+
if (!declaresV3 && !hasHandlers) {
|
|
70
|
+
throw new Error('[PreValidation] Unsupported operations.json shape - schema_version "3.0" with '
|
|
71
|
+
+ 'handler-based operations is required. Fix: migrate the service to v3 handlers; the legacy '
|
|
72
|
+
+ 'HTTP dispatch path no longer exists.');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {object} params
|
|
78
|
+
* @param {string} params.serviceRoot service repository root
|
|
79
|
+
* @param {string} params.serviceUrl resolved by the caller from the service's own config
|
|
80
|
+
* @param {Function} params.RunnerClass CookbookTestRunner (injected so the module stays testable)
|
|
81
|
+
* @param {Function} params.ProofGeneratorClass ValidationProofGenerator
|
|
82
|
+
* @param {{log: Function, error: Function}} params.logger
|
|
83
|
+
* @returns {Promise<{ok: boolean, results: object, failedSteps: Array, proof: object|null, proofPath: string|null}>}
|
|
84
|
+
*/
|
|
85
|
+
async function runPreValidation({ serviceRoot, serviceUrl, RunnerClass, ProofGeneratorClass, logger }) {
|
|
86
|
+
const cookbooksDir = path.join(serviceRoot, COOKBOOKS_RELATIVE_DIR);
|
|
87
|
+
if (!fs.existsSync(cookbooksDir)) {
|
|
88
|
+
throw new Error(`[PreValidation] Missing cookbooks directory - ${cookbooksDir} is required. `
|
|
89
|
+
+ 'Fix: add cookbook .json files under tests/cookbooks.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
assertV3Operations(serviceRoot);
|
|
93
|
+
|
|
94
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(serviceRoot, 'package.json'), 'utf8'));
|
|
95
|
+
|
|
96
|
+
const runner = new RunnerClass({
|
|
97
|
+
serviceName: packageJson.name,
|
|
98
|
+
serviceUrl,
|
|
99
|
+
servicePath: serviceRoot,
|
|
100
|
+
mockInfrastructure: true,
|
|
101
|
+
timeout: COOKBOOK_TIMEOUT_MS,
|
|
102
|
+
logger
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const results = await runner.runCookbooks(cookbooksDir);
|
|
106
|
+
|
|
107
|
+
if (results.failed > 0) {
|
|
108
|
+
// No proof on a failed run. A proof is a claim that the cookbooks passed,
|
|
109
|
+
// so writing one here would make the Registry trust a run that did not.
|
|
110
|
+
const failedSteps = results.steps
|
|
111
|
+
.filter((step) => !step.passed)
|
|
112
|
+
.map((step) => ({
|
|
113
|
+
id: step.id || step.operation,
|
|
114
|
+
error: describeStepError(step.error),
|
|
115
|
+
validationErrors: step.validationErrors || []
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
return { ok: false, results, failedSteps, proof: null, proofPath: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const proofGenerator = new ProofGeneratorClass({
|
|
122
|
+
serviceName: packageJson.name,
|
|
123
|
+
serviceVersion: packageJson.version,
|
|
124
|
+
validatorName: VALIDATOR_NAME,
|
|
125
|
+
validatorVersion: require('../../package.json').version
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const proof = proofGenerator.generateProof(results, packageJson.dependencies || {});
|
|
129
|
+
|
|
130
|
+
const proofPath = path.join(serviceRoot, PROOF_RELATIVE_PATH);
|
|
131
|
+
fs.mkdirSync(path.dirname(proofPath), { recursive: true });
|
|
132
|
+
fs.writeFileSync(proofPath, JSON.stringify(proof, null, 2));
|
|
133
|
+
|
|
134
|
+
return { ok: true, results, failedSteps: [], proof, proofPath };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { runPreValidation, PROOF_RELATIVE_PATH, COOKBOOKS_RELATIVE_DIR };
|